Compare commits
No commits in common. "2a3413c4d55f6639bcf2f81d1559bf0b5348b3e6" and "b46b6c8ae0ace2d1f4c91396f108332269518df7" have entirely different histories.
2a3413c4d5
...
b46b6c8ae0
@ -1,27 +0,0 @@
|
||||
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`);
|
||||
@ -1,201 +0,0 @@
|
||||
CREATE TABLE `challenge_session_answers` (
|
||||
`id` char(36) NOT NULL,
|
||||
`session_id` char(36) NOT NULL,
|
||||
`user_id` char(36) NOT NULL,
|
||||
`question_id` char(36) NOT NULL,
|
||||
`submit_request_id` varchar(80) NOT NULL,
|
||||
`answer_order` tinyint NOT NULL,
|
||||
`answer` varchar(500),
|
||||
`correct` tinyint NOT NULL,
|
||||
`time_ms` int,
|
||||
`combo_count` tinyint DEFAULT 0,
|
||||
`result_snapshot` json,
|
||||
`submitted_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `challenge_session_answers_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `uk_challenge_answer_session_question` UNIQUE(`session_id`,`question_id`),
|
||||
CONSTRAINT `uk_challenge_answer_session_request` UNIQUE(`session_id`,`submit_request_id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `challenge_sessions` (
|
||||
`id` char(36) NOT NULL,
|
||||
`user_id` char(36) NOT NULL,
|
||||
`track_id` varchar(50) NOT NULL,
|
||||
`category_id` varchar(50) NOT NULL,
|
||||
`chapter_id` char(36),
|
||||
`status` enum('pending','in_progress','completed','abandoned','expired') DEFAULT 'pending',
|
||||
`client_request_id` varchar(80) NOT NULL,
|
||||
`complete_request_id` varchar(80),
|
||||
`question_ids` json NOT NULL,
|
||||
`total_questions` tinyint DEFAULT 5,
|
||||
`answered_count` tinyint DEFAULT 0,
|
||||
`correct_count` tinyint DEFAULT 0,
|
||||
`high_reward_eligible` tinyint DEFAULT 1,
|
||||
`reward_snapshot` json,
|
||||
`progress_before` json,
|
||||
`progress_after` json,
|
||||
`expires_at` datetime,
|
||||
`completed_at` datetime,
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `challenge_sessions_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `uk_challenge_session_user_client_request` UNIQUE(`user_id`,`client_request_id`),
|
||||
CONSTRAINT `uk_challenge_session_user_complete_request` UNIQUE(`user_id`,`complete_request_id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `inventory_transactions` (
|
||||
`id` char(36) NOT NULL,
|
||||
`user_id` char(36) NOT NULL,
|
||||
`inventory_item_id` char(36),
|
||||
`item_id` enum('coins','streak_shield','double_xp_potion','heart_supply','hint_feather','mascot_outfit') NOT NULL,
|
||||
`direction` enum('grant','consume','adjust') NOT NULL,
|
||||
`quantity_delta` int NOT NULL,
|
||||
`balance_after` int,
|
||||
`source_type` enum('challenge','daily_task','level_up','theme_node','chest','shop_purchase','ad_recovery','subscription','admin_grant','system_adjust') NOT NULL,
|
||||
`source_id` varchar(120),
|
||||
`idempotency_key` varchar(160),
|
||||
`snapshot` json,
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `inventory_transactions_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `uk_inventory_transaction_idempotency` UNIQUE(`user_id`,`idempotency_key`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `reward_ledger` (
|
||||
`id` char(36) NOT NULL,
|
||||
`user_id` char(36) NOT NULL,
|
||||
`source_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') NOT NULL,
|
||||
`source_id` varchar(120),
|
||||
`idempotency_key` varchar(160) NOT NULL,
|
||||
`status` enum('pending','settling','completed','failed','reversed') DEFAULT 'pending',
|
||||
`reward_snapshot` json NOT NULL,
|
||||
`resource_deltas` json,
|
||||
`state_before` json,
|
||||
`state_after` json,
|
||||
`failure_reason` varchar(120),
|
||||
`settled_at` datetime,
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `reward_ledger_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `uk_reward_ledger_user_idempotency` UNIQUE(`user_id`,`idempotency_key`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `user_daily_progress` (
|
||||
`id` char(36) NOT NULL,
|
||||
`user_id` char(36) NOT NULL,
|
||||
`progress_date` date NOT NULL,
|
||||
`timezone` varchar(50) DEFAULT 'UTC',
|
||||
`first_challenge_session_id` char(36),
|
||||
`first_challenge_completed_at` datetime,
|
||||
`challenge_sessions_completed` smallint DEFAULT 0,
|
||||
`high_reward_sessions_max` smallint DEFAULT 3,
|
||||
`high_reward_sessions_used` smallint DEFAULT 0,
|
||||
`high_reward_sessions_restored` smallint DEFAULT 0,
|
||||
`daily_tasks_completed` smallint DEFAULT 0,
|
||||
`daily_tasks_reward_claimed` smallint DEFAULT 0,
|
||||
`xp_earned` int DEFAULT 0,
|
||||
`coins_earned` int DEFAULT 0,
|
||||
`streak_counted` tinyint DEFAULT 0,
|
||||
`metadata` json,
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `user_daily_progress_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `uk_daily_progress_user_date` UNIQUE(`user_id`,`progress_date`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `user_daily_tasks` (
|
||||
`id` char(36) NOT NULL,
|
||||
`daily_progress_id` char(36) NOT NULL,
|
||||
`user_id` char(36) NOT NULL,
|
||||
`task_date` date NOT NULL,
|
||||
`task_id` varchar(80) NOT NULL,
|
||||
`task_type` enum('complete_challenge','earn_xp','answer_correct','review_explanation','use_item','watch_ad') NOT NULL,
|
||||
`target_count` smallint DEFAULT 1,
|
||||
`current_count` smallint DEFAULT 0,
|
||||
`status` enum('active','completed','reward_claimed','expired') DEFAULT 'active',
|
||||
`reward_snapshot` json,
|
||||
`completed_at` datetime,
|
||||
`reward_claimed_at` datetime,
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `user_daily_tasks_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `uk_daily_task_user_date_task` UNIQUE(`user_id`,`task_date`,`task_id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `user_inventory_items` (
|
||||
`id` char(36) NOT NULL,
|
||||
`user_id` char(36) NOT NULL,
|
||||
`item_id` enum('streak_shield','double_xp_potion','heart_supply','hint_feather','mascot_outfit') NOT NULL,
|
||||
`quantity` int DEFAULT 0,
|
||||
`active_until` datetime,
|
||||
`metadata` json,
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `user_inventory_items_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `uk_inventory_user_item` UNIQUE(`user_id`,`item_id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `user_wallets` (
|
||||
`user_id` char(36) NOT NULL,
|
||||
`coins_balance` int DEFAULT 0,
|
||||
`lifetime_coins_earned` int DEFAULT 0,
|
||||
`lifetime_coins_spent` int DEFAULT 0,
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `user_wallets_user_id` PRIMARY KEY(`user_id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `user_weekly_xp` (
|
||||
`id` char(36) NOT NULL,
|
||||
`user_id` char(36) NOT NULL,
|
||||
`week_start` date NOT NULL,
|
||||
`week_end` date NOT NULL,
|
||||
`timezone` varchar(50) DEFAULT 'UTC',
|
||||
`xp_earned` int DEFAULT 0,
|
||||
`challenge_sessions_completed` int DEFAULT 0,
|
||||
`group_id` varchar(80),
|
||||
`rank` int,
|
||||
`settled` tinyint DEFAULT 0,
|
||||
`settled_at` datetime,
|
||||
`last_xp_at` datetime,
|
||||
`next_refresh_at` datetime,
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `user_weekly_xp_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `uk_weekly_xp_user_week` UNIQUE(`user_id`,`week_start`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DROP INDEX `idx_user_week` ON `leaderboard_snapshots`;--> statement-breakpoint
|
||||
ALTER TABLE `leaderboard_snapshots` MODIFY COLUMN `week_start` date NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `leaderboard_snapshots` MODIFY COLUMN `week_end` date NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `leaderboard_snapshots` ADD `group_id` varchar(80);--> statement-breakpoint
|
||||
ALTER TABLE `leaderboard_snapshots` ADD `reward_snapshot` json;--> statement-breakpoint
|
||||
ALTER TABLE `leaderboard_snapshots` ADD `settled_at` datetime;--> statement-breakpoint
|
||||
ALTER TABLE `leaderboard_snapshots` ADD CONSTRAINT `uk_leaderboard_snapshot_user_week` UNIQUE(`user_id`,`week_start`);--> statement-breakpoint
|
||||
ALTER TABLE `challenge_session_answers` ADD CONSTRAINT `challenge_session_answers_session_id_challenge_sessions_id_fk` FOREIGN KEY (`session_id`) REFERENCES `challenge_sessions`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `challenge_session_answers` ADD CONSTRAINT `challenge_session_answers_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `challenge_session_answers` ADD CONSTRAINT `challenge_session_answers_question_id_questions_id_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `challenge_sessions` ADD CONSTRAINT `challenge_sessions_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `challenge_sessions` ADD CONSTRAINT `challenge_sessions_category_id_categories_id_fk` FOREIGN KEY (`category_id`) REFERENCES `categories`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `challenge_sessions` ADD CONSTRAINT `challenge_sessions_chapter_id_skill_tree_id_fk` FOREIGN KEY (`chapter_id`) REFERENCES `skill_tree`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `inventory_transactions` ADD CONSTRAINT `inventory_transactions_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `inventory_transactions` ADD CONSTRAINT `inventory_transactions_inventory_item_id_user_inventory_items_id_fk` FOREIGN KEY (`inventory_item_id`) REFERENCES `user_inventory_items`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `reward_ledger` ADD CONSTRAINT `reward_ledger_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `user_daily_progress` ADD CONSTRAINT `user_daily_progress_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `user_daily_progress` ADD CONSTRAINT `user_daily_progress_first_challenge_session_id_challenge_sessions_id_fk` FOREIGN KEY (`first_challenge_session_id`) REFERENCES `challenge_sessions`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `user_daily_tasks` ADD CONSTRAINT `user_daily_tasks_daily_progress_id_user_daily_progress_id_fk` FOREIGN KEY (`daily_progress_id`) REFERENCES `user_daily_progress`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `user_daily_tasks` ADD CONSTRAINT `user_daily_tasks_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `user_inventory_items` ADD CONSTRAINT `user_inventory_items_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `user_wallets` ADD CONSTRAINT `user_wallets_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `user_weekly_xp` ADD CONSTRAINT `user_weekly_xp_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_challenge_answer_user_submitted` ON `challenge_session_answers` (`user_id`,`submitted_at`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_challenge_session_user_status_created` ON `challenge_sessions` (`user_id`,`status`,`created_at`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_challenge_session_chapter_status` ON `challenge_sessions` (`chapter_id`,`status`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_inventory_transaction_user_created` ON `inventory_transactions` (`user_id`,`created_at`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_inventory_transaction_source` ON `inventory_transactions` (`source_type`,`source_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_reward_ledger_user_status_created` ON `reward_ledger` (`user_id`,`status`,`created_at`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_reward_ledger_source` ON `reward_ledger` (`source_type`,`source_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_daily_progress_date` ON `user_daily_progress` (`progress_date`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_daily_task_progress_status` ON `user_daily_tasks` (`daily_progress_id`,`status`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_inventory_user_active` ON `user_inventory_items` (`user_id`,`active_until`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_weekly_xp_group_rank` ON `user_weekly_xp` (`group_id`,`week_start`,`xp_earned`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_weekly_xp_week_settled` ON `user_weekly_xp` (`week_start`,`settled`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_leaderboard_snapshot_group_rank` ON `leaderboard_snapshots` (`group_id`,`week_start`,`rank`);
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -15,20 +15,6 @@
|
||||
"when": 1777827874032,
|
||||
"tag": "0001_sturdy_invaders",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "5",
|
||||
"when": 1777965665440,
|
||||
"tag": "0002_foamy_rachel_grey",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "5",
|
||||
"when": 1778494900458,
|
||||
"tag": "0003_lyrical_carnage",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,201 +0,0 @@
|
||||
# Duoqi 游戏化机制设计
|
||||
|
||||
本文记录 Duoqi 第一版游戏化机制。目标是在不增加过重系统复杂度的前提下,让挑战、主题路线、排行榜、商店和个人资料形成稳定的学习循环。
|
||||
|
||||
## 设计目标
|
||||
|
||||
- 降低开始学习的心理成本:一次挑战应能在 2-4 分钟内完成。
|
||||
- 让错误有轻微成本,但不要让新用户过早被卡死。
|
||||
- 用连续学习、等级和主题路线提供长期目标。
|
||||
- 第一版加入看广告恢复,让免费用户能继续挑战,也为后续订阅权益留下清晰对比。
|
||||
- 道具先保持少量、高价值、易理解,避免背包系统过早复杂化。
|
||||
|
||||
## 核心循环
|
||||
|
||||
第一版推荐循环:
|
||||
|
||||
1. 用户选择一个主题路线。
|
||||
2. 完成 1 组挑战,每组 5 题。
|
||||
3. 答题后获得即时反馈、知识卡和 XP。
|
||||
4. 挑战完成后推进主题进度、每日任务和连续学习。
|
||||
5. 资源不足时,用户可看广告恢复爱心或挑战次数。
|
||||
6. 用户通过 XP 升级、金币购买道具、参与本周排行榜。
|
||||
|
||||
原因:学习类产品需要把“我今天要做什么”压缩到一个清楚的小目标。5 题一组既有完整感,又不会让用户觉得负担重。
|
||||
|
||||
## 爱心机制
|
||||
|
||||
| 项目 | 第一版规则 |
|
||||
|---|---:|
|
||||
| 免费用户最大爱心 | 5 颗 |
|
||||
| 答错扣除 | 每错 1 题扣 1 颗 |
|
||||
| 答对 | 不扣爱心 |
|
||||
| 爱心自然恢复 | 每 30 分钟恢复 1 颗 |
|
||||
| 每日首次进入 | 送 1 颗,最多不超过上限 |
|
||||
| 新用户保护 | 前 3 天最低保留 1 颗,不因答错完全卡死 |
|
||||
| Plus 用户 | 无限爱心,或不被爱心阻断 |
|
||||
|
||||
原因:5 颗爱心足以制造认真答题的感觉,但不会因为一两次错误造成强挫败。30 分钟恢复能形成自然回访点。新用户保护用于降低早期流失。
|
||||
|
||||
## 每日挑战次数
|
||||
|
||||
第一版保留每日挑战次数,但建议把它定义为“每日高奖励挑战次数”,而不是完全禁止继续学习。
|
||||
|
||||
| 项目 | 第一版规则 |
|
||||
|---|---:|
|
||||
| 每日高奖励挑战 | 3 组 |
|
||||
| 第 4 组之后 | XP 正常,金币和宝箱概率降低 |
|
||||
| 看广告恢复 | 每次恢复 1 组高奖励挑战 |
|
||||
| Plus 用户 | 高奖励次数提升到 8 组或无限 |
|
||||
|
||||
原因:爱心控制错误成本,每日高奖励次数控制经济系统产出。这样高手用户仍可继续学习,但不能无限刷金币和宝箱。
|
||||
|
||||
## 看广告恢复
|
||||
|
||||
看广告恢复是第一版机制,不作为后续可选项。它用于让免费用户在资源耗尽时继续挑战,同时建立 Plus 订阅的价值锚点。
|
||||
|
||||
### 可恢复资源
|
||||
|
||||
| 恢复项 | 广告完成后的效果 | 建议限制 |
|
||||
|---|---|---|
|
||||
| 恢复爱心 | 爱心恢复至上限 5 颗 | 每日最多 3 次 |
|
||||
| 恢复高奖励挑战 | 恢复 1 组每日高奖励挑战 | 每日最多 3 次 |
|
||||
| 连续学习保护 | 当天补一次连续学习保护 | 每 7 天最多 1 次广告恢复 |
|
||||
|
||||
### 触发入口
|
||||
|
||||
- 首页阻断弹窗:爱心为 0 或高奖励次数为 0 时,引导去商店恢复。
|
||||
- 商店页:展示“看视频拿回冒险资源”。
|
||||
- 个人资料页:连续学习即将中断时提示保护。
|
||||
|
||||
### 奖励发放原则
|
||||
|
||||
- 必须等广告 SDK 返回完成状态后再发奖励。
|
||||
- 奖励应由服务端或 repository 的奖励服务统一结算,不由 UI 直接改进度。
|
||||
- 如果广告加载失败,应展示“稍后再试”,不能扣资源或吞掉恢复机会。
|
||||
- Plus 用户不需要看广告恢复;同入口应展示订阅权益或直接恢复。
|
||||
|
||||
原因:看广告恢复既能缓解免费用户被卡住的挫败,也能产生商业化入口。但如果不限制次数,会破坏爱心和每日次数的意义;如果失败处理不好,会让用户对系统信任下降。
|
||||
|
||||
## XP 与等级
|
||||
|
||||
第一版建议 50 个用户等级。
|
||||
|
||||
| 等级段 | 每级所需 XP | 目的 |
|
||||
|---|---:|---|
|
||||
| Lv.1-5 | 100, 120, 150, 180, 220 | 新手快速升级 |
|
||||
| Lv.6-10 | 260, 300, 350, 400, 460 | 建立成长感 |
|
||||
| Lv.11-20 | 520 起,每级 +80 | 稳定成长 |
|
||||
| Lv.21-35 | 1400 起,每级 +120 | 中期目标 |
|
||||
| Lv.36-50 | 3300 起,每级 +180 | 长期目标 |
|
||||
|
||||
XP 奖励:
|
||||
|
||||
| 行为 | XP |
|
||||
|---|---:|
|
||||
| 答对普通题 | 10 |
|
||||
| 答对困难题 | 15 |
|
||||
| 答错后看解析 | 3 |
|
||||
| 完成 1 组挑战 | 20 |
|
||||
| 全对完成挑战 | 额外 30 |
|
||||
| 首次获得知识卡 | 15 |
|
||||
| 完成每日任务 | 30-60 |
|
||||
| 完成主题节点 | 80-120 |
|
||||
|
||||
原因:XP 不能只奖励答对,否则弱用户会逐渐退出。少量奖励“看解析”和“完成挑战”,能把学习过程本身也变成正反馈。
|
||||
|
||||
## 连续学习与连对
|
||||
|
||||
第一版区分两个概念:
|
||||
|
||||
| 概念 | 定义 | 用途 |
|
||||
|---|---|---|
|
||||
| 连续学习 | 每天至少完成 1 组挑战 | 长期留存 |
|
||||
| 连对 | 单次挑战中连续答对题数 | 即时爽感 |
|
||||
|
||||
连续学习奖励:
|
||||
|
||||
| 连续天数 | 奖励 |
|
||||
|---:|---|
|
||||
| 3 天 | 小宝箱 |
|
||||
| 7 天 | 连胜护盾 x1 |
|
||||
| 14 天 | 双倍 XP 药水 x1 |
|
||||
| 30 天 | 限定徽章或吉祥物装扮 |
|
||||
| 100 天 | 稀有头像框或称号 |
|
||||
|
||||
连对奖励:
|
||||
|
||||
| 连对数 | 奖励 |
|
||||
|---:|---|
|
||||
| 3 连对 | +5 XP |
|
||||
| 5 连对 | +10 XP,吉祥物特殊反馈 |
|
||||
| 10 连对 | +25 XP,宝箱概率提升 |
|
||||
|
||||
原因:连续学习负责习惯,连对负责局内情绪峰值。两者不要混在一个文案里,否则用户难以理解。
|
||||
|
||||
## 道具
|
||||
|
||||
第一版道具保持 4 个:
|
||||
|
||||
| 道具 | 效果 | 获取方式 |
|
||||
|---|---|---|
|
||||
| 连胜护盾 | 断签时保护 1 天 | 7 日奖励、广告、商店 |
|
||||
| 双倍 XP 药水 | 15 分钟内 XP x2 | 宝箱、任务、商店 |
|
||||
| 爱心补给 | 恢复满爱心 | 广告、升级奖励、商店 |
|
||||
| 提示羽毛 | 答题时排除 1 个错误选项 | 每日任务、金币购买 |
|
||||
|
||||
原因:这些道具都直接服务学习体验。第一版不加入攻击他人、偷取资源、随机惩罚类道具,避免破坏温和公平的产品气质。
|
||||
|
||||
## 金币与商店
|
||||
|
||||
金币来源:
|
||||
|
||||
| 行为 | 金币 |
|
||||
|---|---:|
|
||||
| 每日首组挑战 | 20 |
|
||||
| 完成每日任务 | 30-80 |
|
||||
| 升级 | 100 |
|
||||
| 完成主题节点 | 50 |
|
||||
| 宝箱 | 20-200 |
|
||||
|
||||
商店价格:
|
||||
|
||||
| 商品 | 价格 |
|
||||
|---|---:|
|
||||
| 提示羽毛 x1 | 80 |
|
||||
| 爱心补给 x1 | 150 |
|
||||
| 双倍 XP 药水 x1 | 250 |
|
||||
| 连胜护盾 x1 | 400 |
|
||||
| 吉祥物装扮 | 800-3000 |
|
||||
|
||||
原因:金币要让用户 2-5 天能买到一个有价值道具。价格太低会让资源失去意义,价格太高会让商店像摆设。
|
||||
|
||||
## 排行榜
|
||||
|
||||
第一版排行榜建议使用本周 XP,不使用总 XP。
|
||||
|
||||
- 每周一刷新。
|
||||
- 按本周 XP 排名。
|
||||
- 每组 20-30 人。
|
||||
- 前 3 名获得金币、徽章碎片或头像框。
|
||||
- 第一版不做降级惩罚。
|
||||
|
||||
原因:总 XP 会让新用户永远追不上老用户。本周 XP 更公平,也更能制造周期性回流。
|
||||
|
||||
## 第一版 MVP 参数
|
||||
|
||||
```text
|
||||
爱心:5 颗
|
||||
自然恢复:30 分钟 1 颗
|
||||
挑战:每组 5 题
|
||||
每日高奖励挑战:3 组
|
||||
看广告恢复爱心:每日最多 3 次,恢复满爱心
|
||||
看广告恢复高奖励挑战:每日最多 3 次,每次恢复 1 组
|
||||
看广告保护连续学习:每 7 天最多 1 次
|
||||
普通答对:10 XP
|
||||
完成挑战:20 XP
|
||||
全对奖励:30 XP
|
||||
等级:50 级
|
||||
第一版道具:连胜护盾、双倍 XP、爱心补给、提示羽毛
|
||||
排行榜:本周 XP
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,155 +0,0 @@
|
||||
# 游戏化服务端实施计划
|
||||
|
||||
> 来源设计文档:[docs/GAMIFICATION_DESIGN.md](./GAMIFICATION_DESIGN.md)
|
||||
> 创建时间:2026-05-11
|
||||
> 状态标记:`[ ]` 未开始,`[~]` 进行中,`[x]` 已完成,`[!]` 阻塞或需产品确认
|
||||
|
||||
## 目标
|
||||
|
||||
把第一版游戏化规则落到服务端可裁决、可持久化、可审计的实现中。客户端负责展示和交互,服务端负责挑战组、资源消耗、奖励结算、背包库存、周榜统计和广告恢复幂等。
|
||||
|
||||
## 当前差异
|
||||
|
||||
- 当前挑战接口以单题为单位,设计要求一组挑战 5 题。
|
||||
- 当前每日挑战次数更接近“可挑战次数”,设计要求改为“每日高奖励挑战次数”,次数耗尽后仍可继续学习。
|
||||
- 当前等级为固定 400 XP 一级,设计要求 50 级分段曲线。
|
||||
- 当前已有红心、订阅、广告恢复 session、基础 XP、连对 XP 和排行榜底座,但还缺金币、道具、背包、任务、挑战组完成奖励和本周 XP 榜。
|
||||
- 当前排行榜仍按累计 XP 排名,设计要求按本周 XP 排名,每周一刷新。
|
||||
|
||||
## 执行原则
|
||||
|
||||
- 先更新 `src/db/schema.ts`,再通过 `bun run db:generate` 生成迁移文件。
|
||||
- 服务端奖励统一通过一个奖励结算层发放,避免 UI 或单个 route 直接修改资源。
|
||||
- 所有资源变更必须有幂等边界或流水记录,尤其是广告恢复、购买、挑战完成和排行榜结算。
|
||||
- 保留现有 `/v1/rewards/ad-recovery/session` 和 `/v1/rewards/ad-recovery/complete` 作为广告恢复主入口。
|
||||
- 调整公开接口后同步更新 `docs/api-reference.md`。
|
||||
- 每完成一个阶段至少运行 `bun run typecheck`、`bun run test`;涉及 lint 规则时运行 `bun run lint`。
|
||||
|
||||
## Phase G0:规则常量与数据模型
|
||||
|
||||
| # | 任务 | 状态 | 验收标准 |
|
||||
|---|------|------|----------|
|
||||
| G0-1 | 梳理游戏化规则常量模块 | [x] | 新增集中规则定义,覆盖红心、挑战组、XP、等级、金币、道具、广告恢复、周榜周期 |
|
||||
| G0-2 | 新增挑战组数据模型 | [x] | 支持 challenge session、session answers、组状态、正确数、完成时间、幂等提交 |
|
||||
| G0-3 | 新增钱包和道具库存模型 | [x] | 支持金币余额、道具库存、道具获得/消耗流水 |
|
||||
| G0-4 | 新增奖励流水模型 | [x] | 记录奖励来源、幂等 key、奖励快照、发放前后状态 |
|
||||
| G0-5 | 新增每日任务或每日进度模型 | [x] | 可统计每日首组挑战、每日任务完成、每日高奖励次数 |
|
||||
| G0-6 | 新增周 XP 统计模型或扩展周榜快照 | [x] | 可按自然周统计 XP,支持每周一刷新和历史快照 |
|
||||
| G0-7 | 生成并提交数据库迁移 | [x] | `db/migrations/` 包含 schema 变更,迁移文件不被 `.gitignore` 遗漏 |
|
||||
|
||||
## Phase G1:挑战组与答题结算
|
||||
|
||||
| # | 任务 | 状态 | 验收标准 |
|
||||
|---|------|------|----------|
|
||||
| G1-1 | 实现创建挑战组服务 | [x] | 一次返回 5 题,题目不泄露正确答案,绑定 track/node/chapter |
|
||||
| G1-2 | 实现挑战组答题提交 | [x] | 单题提交可幂等记录,重复提交不会重复扣资源或发奖励 |
|
||||
| G1-3 | 实现挑战组完成结算 | [x] | 组内 5 题完成后统一计算完成奖励、全对奖励、主题节点进度和每日高奖励状态 |
|
||||
| G1-4 | 调整红心扣除边界 | [x] | 答错扣 1 颗;Plus 用户不被红心阻断;新用户前 3 天最低保留 1 颗 |
|
||||
| G1-5 | 调整每日高奖励挑战次数 | [x] | 免费用户每日 3 组,Plus 8 组或按产品确认无限;次数为 0 后仍可继续学习但高价值奖励降级 |
|
||||
| G1-6 | 更新挑战 API DTO | [x] | `/challenges/next` 或新增 session API 能表达组、题、组进度、资源状态 |
|
||||
| G1-7 | 添加挑战组测试 | [x] | 覆盖创建、答对、答错、重复提交、完成结算、资源不足和 Plus 分支 |
|
||||
|
||||
## Phase G2:XP、等级、连续学习和知识卡奖励
|
||||
|
||||
| # | 任务 | 状态 | 验收标准 |
|
||||
|---|------|------|----------|
|
||||
| G2-1 | 实现 50 级等级曲线 | [x] | Lv.1-50 按设计表计算,`xpToNextLevel` 准确,超过 50 级有明确封顶或溢出策略 |
|
||||
| G2-2 | 扩展 XP 奖励来源 | [x] | 支持普通题 10、困难题 15、看解析 3、完成挑战 20、全对 30、首次知识卡 15、每日任务、主题节点奖励 |
|
||||
| G2-3 | 修正连对奖励 | [x] | 3 连对 +5,5 连对 +10,10 连对 +25,并返回客户端可展示奖励 |
|
||||
| G2-4 | 将连续学习改为按挑战组完成计算 | [x] | 每天至少完成 1 组挑战才更新 streak,不再依赖当天正确题数阈值 |
|
||||
| G2-5 | 实现连续学习里程碑奖励 | [x] | 3/7/14/30/100 天奖励可发放且不可重复领取 |
|
||||
| G2-6 | 实现每日首次进入送红心 | [x] | 每日首次 bootstrap 或专用 check-in 最多补 1 颗,不超过上限 |
|
||||
| G2-7 | 添加 XP/streak 测试 | [x] | 覆盖等级边界、首次知识卡、完成组奖励、全对奖励、看解析奖励、连续学习保护 |
|
||||
|
||||
验证记录(2026-05-13):G2-2 已通过 `./node_modules/.bin/tsc --noEmit` 和 `./node_modules/.bin/eslint .`;`bun` 当前 shell 不在 PATH,`./node_modules/.bin/vitest run` 启动阶段被 macOS 拒绝加载未签名的 `@rolldown/binding-darwin-x64` 原生 binding,需修复本地依赖安装或签名后复跑。
|
||||
验证记录(2026-05-13):G2-3 已通过 `./node_modules/.bin/tsc --noEmit` 和 `./node_modules/.bin/eslint .`;定向运行 `./node_modules/.bin/vitest run src/__tests__/services/progress/xp-service.test.ts src/__tests__/services/learning/challenge-service.test.ts` 仍在启动阶段被同一个 `@rolldown/binding-darwin-x64` 原生 binding 签名问题阻塞。
|
||||
验证记录(2026-05-13):G2-4 已通过 `./node_modules/.bin/tsc --noEmit` 和 `./node_modules/.bin/eslint .`;定向运行 `./node_modules/.bin/vitest run src/__tests__/services/progress/streak-service.test.ts src/__tests__/services/learning/challenge-service.test.ts` 仍在启动阶段被同一个 `@rolldown/binding-darwin-x64` 原生 binding 签名问题阻塞。
|
||||
验证记录(2026-05-13):G2-5 已通过 `./node_modules/.bin/tsc --noEmit` 和 `./node_modules/.bin/eslint .`;定向运行 `./node_modules/.bin/vitest run src/__tests__/services/progress/streak-service.test.ts src/__tests__/services/learning/challenge-service.test.ts` 仍在启动阶段被同一个 `@rolldown/binding-darwin-x64` 原生 binding 签名问题阻塞。
|
||||
验证记录(2026-05-13):G2-6 已通过 `./node_modules/.bin/tsc --noEmit` 和 `./node_modules/.bin/eslint .`;定向运行 `./node_modules/.bin/vitest run src/__tests__/services/learning/progress-summary-service.test.ts` 仍在启动阶段被同一个 `@rolldown/binding-darwin-x64` 原生 binding 签名问题阻塞。
|
||||
验证记录(2026-05-13):G2-7 已通过 `./node_modules/.bin/tsc --noEmit` 和 `./node_modules/.bin/eslint .`;定向运行 `./node_modules/.bin/vitest run src/__tests__/services/progress/xp-service.test.ts src/__tests__/services/progress/streak-service.test.ts src/__tests__/services/learning/challenge-service.test.ts src/__tests__/services/learning/progress-summary-service.test.ts` 仍在启动阶段被同一个 `@rolldown/binding-darwin-x64` 原生 binding 签名问题阻塞。
|
||||
|
||||
## Phase G3:金币、商店和道具
|
||||
|
||||
| # | 任务 | 状态 | 验收标准 |
|
||||
|---|------|------|----------|
|
||||
| G3-1 | 实现金币发放服务 | [x] | 支持每日首组挑战 20、每日任务 30-80、升级 100、主题节点 50、宝箱 20-200 |
|
||||
| G3-2 | 实现宝箱奖励服务 | [x] | 支持基础概率、10 连对概率提升、高奖励次数耗尽后的概率降级 |
|
||||
| G3-3 | 实现道具库存服务 | [x] | 支持连胜护盾、双倍 XP 药水、爱心补给、提示羽毛的获得和消耗 |
|
||||
| G3-4 | 实现商店商品和购买接口 | [x] | 商品价格符合设计:提示羽毛 80、爱心补给 150、双倍 XP 250、连胜护盾 400、装扮 800-3000 |
|
||||
| G3-5 | 实现道具使用接口 | [x] | 爱心补给恢复满心,双倍 XP 药水 15 分钟生效,提示羽毛返回可排除选项,连胜护盾可保护断签 |
|
||||
| G3-6 | 更新 bootstrap/shop DTO | [x] | 客户端能拿到金币、库存、可购买商品、广告商品、订阅权益 |
|
||||
| G3-7 | 添加金币/商店测试 | [x] | 覆盖余额不足、重复购买、使用道具、药水时效、库存扣减和流水记录 |
|
||||
|
||||
验证记录(2026-05-13):G3-1 已通过 `./node_modules/.bin/tsc --noEmit`、`./node_modules/.bin/eslint .` 和 `git diff --check`;定向运行 `./node_modules/.bin/vitest run src/__tests__/services/gamification/coin-service.test.ts src/__tests__/services/learning/challenge-service.test.ts` 仍在启动阶段被 `@rolldown/binding-darwin-x64` 原生 binding 未签名问题阻塞。
|
||||
验证记录(2026-05-13):G3-2 已通过 `./node_modules/.bin/tsc --noEmit`、`./node_modules/.bin/eslint .` 和 `git diff --check`;定向运行 `./node_modules/.bin/vitest run src/__tests__/services/gamification/chest-service.test.ts src/__tests__/services/gamification/coin-service.test.ts src/__tests__/services/gamification-rules.test.ts` 仍在启动阶段被同一个 `@rolldown/binding-darwin-x64` 原生 binding 未签名问题阻塞。
|
||||
验证记录(2026-05-13):G3-3 已通过 `./node_modules/.bin/tsc --noEmit`、`./node_modules/.bin/eslint .` 和 `git diff --check`;定向运行 `./node_modules/.bin/vitest run src/__tests__/services/gamification/inventory-service.test.ts` 仍在启动阶段被同一个 `@rolldown/binding-darwin-x64` 原生 binding 未签名问题阻塞。
|
||||
验证记录(2026-05-13):G3-4 已通过 `./node_modules/.bin/tsc --noEmit`、`./node_modules/.bin/eslint .` 和 `git diff --check`;定向运行 `./node_modules/.bin/vitest run src/__tests__/services/shop/shop-service.test.ts src/__tests__/services/gamification/coin-service.test.ts src/__tests__/services/gamification/inventory-service.test.ts` 仍在启动阶段被同一个 `@rolldown/binding-darwin-x64` 原生 binding 未签名问题阻塞。
|
||||
验证记录(2026-05-13):G3-5 已通过 `./node_modules/.bin/tsc --noEmit`、`./node_modules/.bin/eslint .` 和 `git diff --check`;定向运行 `./node_modules/.bin/vitest run src/__tests__/services/gamification/item-use-service.test.ts src/__tests__/services/gamification/inventory-service.test.ts` 仍在启动阶段被同一个 `@rolldown/binding-darwin-x64` 原生 binding 未签名问题阻塞。
|
||||
验证记录(2026-05-13):G3-6 已通过 `./node_modules/.bin/tsc --noEmit`、`./node_modules/.bin/eslint .` 和 `git diff --check`;定向运行 `./node_modules/.bin/vitest run src/__tests__/services/app/bootstrap-service.test.ts src/__tests__/services/gamification/inventory-service.test.ts src/__tests__/services/shop/shop-service.test.ts` 仍在启动阶段被同一个 `@rolldown/binding-darwin-x64` 原生 binding 未签名问题阻塞。
|
||||
验证记录(2026-05-13):G3-7 已通过 `./node_modules/.bin/tsc --noEmit`、`./node_modules/.bin/eslint .` 和 `git diff --check`;定向运行 `./node_modules/.bin/vitest run src/__tests__/services/gamification/coin-service.test.ts src/__tests__/services/shop/shop-service.test.ts src/__tests__/services/gamification/inventory-service.test.ts src/__tests__/services/gamification/item-use-service.test.ts` 仍在启动阶段被同一个 `@rolldown/binding-darwin-x64` 原生 binding 未签名问题阻塞。
|
||||
|
||||
## Phase G4:广告恢复与订阅权益对齐
|
||||
|
||||
验证记录(2026-05-13):G4-1 已通过 `./node_modules/.bin/tsc --noEmit` 和 `./node_modules/.bin/eslint src/services/rewards/ad-recovery-service.ts`;广告恢复奖励现已通过 `rewardLedger` 统一结算层发放,使用 `ad_recovery:{sessionId}` 幂等 key,记录 stateBefore/After 快照。
|
||||
验证记录(2026-05-13):G4-7 已通过 `./node_modules/.bin/tsc --noEmit` 和 `./node_modules/.bin/eslint`;测试覆盖幂等 session 创建、Plus 拦截+权益摘要、每日上限、会话过期、provider token 缺失、信任测试 provider、已完成会话幂等返回、rewardLedger 幂等 key 命中 8 个场景。
|
||||
验证记录(2026-05-13):G5 全部通过 `./node_modules/.bin/tsc --noEmit` 和 `./node_modules/.bin/eslint`;G5-6 测试覆盖 addToWeeklyXp 首次分组/加入未满组/不重新分配、getUserRank 组内排名/无记录、weeklySettlement dryRun/正式结算/多组独立奖励、getLeaderboardMeta 周信息与奖励预览。
|
||||
|
||||
验证记录(2026-05-13):G6-6 最终验证通过 `./node_modules/.bin/tsc --noEmit`(零错误)、`./node_modules/.bin/eslint .`(零错误)、`git diff --check`(无空白问题)。vitest 因 `@rolldown/binding-darwin-x64` 原生 binding 签名问题仍无法在本地启动,需修复依赖安装或签名后复跑。全部 6 个 Phase(G0-G6)完成。
|
||||
|
||||
| # | 任务 | 状态 | 验收标准 |
|
||||
|---|------|------|----------|
|
||||
| G4-1 | 对齐广告恢复奖励到统一奖励服务 | [x] | session complete 后通过统一奖励结算层发放,记录奖励流水 |
|
||||
| G4-2 | 确认恢复爱心规则 | [x] | 免费用户广告恢复直接恢复至 5 颗,每日最多 3 次 |
|
||||
| G4-3 | 确认恢复高奖励挑战规则 | [x] | 每次只恢复 1 组高奖励挑战,每日最多 3 次 |
|
||||
| G4-4 | 确认连续学习保护规则 | [x] | 每 7 天最多广告恢复 1 次,当天补一次保护 |
|
||||
| G4-5 | 收敛旧恢复接口用途 | [x] | 旧的直接恢复接口仅保留内部、测试或明确废弃,并在 API 文档中说明 |
|
||||
| G4-6 | 明确 Plus 分支 | [x] | Plus 用户无需看广告;同入口返回订阅权益或已订阅原因,不消耗广告次数 |
|
||||
| G4-7 | 添加广告恢复回归测试 | [x] | 覆盖 idempotency、过期、provider token 缺失、每日上限、Plus、重复 complete |
|
||||
|
||||
## Phase G5:本周排行榜与周期结算
|
||||
|
||||
| # | 任务 | 状态 | 验收标准 |
|
||||
|---|------|------|----------|
|
||||
| G5-1 | 改造排行榜数据源为本周 XP | [x] | 排行榜不再按累计 XP 排名,展示本周 XP |
|
||||
| G5-2 | 实现每周一刷新逻辑 | [x] | 自然周边界清晰,UTC/本地时区策略写入代码注释和文档 |
|
||||
| G5-3 | 实现 20-30 人分组 | [x] | 每个用户进入稳定榜组,分页和我的排名基于组内排名 |
|
||||
| G5-4 | 实现前三奖励结算 | [x] | 周结算给前 3 名发金币、徽章碎片或头像框奖励,幂等执行 |
|
||||
| G5-5 | 暴露周榜元信息 | [x] | API 返回 weekStart、weekEnd、nextRefreshAt、groupId、rank、rewardPreview |
|
||||
| G5-6 | 添加排行榜测试 | [x] | 覆盖周 XP 累加、分组、我的排名、周结算、重复结算 |
|
||||
|
||||
## Phase G6:API 文档、Admin 和运维
|
||||
|
||||
| # | 任务 | 状态 | 验收标准 |
|
||||
|---|------|------|----------|
|
||||
| G6-1 | 更新 `docs/api-reference.md` | [x] | 文档只保留最终客户端契约,包含挑战组、奖励、商店、背包、周榜、错误码 |
|
||||
| G6-2 | 更新 `docs/implementation-plan.md` | [x] | 将本计划作为 Phase 1d 或 Game Economy 阶段索引进去 |
|
||||
| G6-3 | 增加 Admin 配置或只读查看能力 | [x] | 管理端至少能查看用户金币、道具、奖励流水、广告恢复记录 |
|
||||
| G6-4 | 增加 E2E 或集成测试 | [x] | 覆盖游客登录、完成挑战组、广告恢复、购买道具、周榜查询 |
|
||||
| G6-5 | 增加定时任务入口 | [x] | 周榜结算和订阅/资源周期任务有可部署入口,支持手动 dry-run |
|
||||
| G6-6 | 完成最终验证 | [x] | `bun run typecheck`、`bun run test`、`bun run lint` 通过或记录明确环境阻塞 |
|
||||
|
||||
## 推荐执行顺序
|
||||
|
||||
1. G0 数据模型与规则常量。
|
||||
2. G1 挑战组会话与完成结算。
|
||||
3. G2 XP、等级、连续学习规则。
|
||||
4. G3 金币、道具、商店。
|
||||
5. G4 广告恢复接入统一奖励服务。
|
||||
6. G5 本周排行榜与周期结算。
|
||||
7. G6 文档、Admin、E2E 和运维入口。
|
||||
|
||||
## 需要产品确认的决策点
|
||||
|
||||
- Plus 用户的每日高奖励挑战次数采用 8 组还是无限。
|
||||
- 等级达到 Lv.50 后是否继续累计 XP,以及 `xpToNextLevel` 如何展示。
|
||||
- 宝箱概率和高奖励次数耗尽后的降级比例。
|
||||
- 排行榜前三奖励的具体数值:金币、徽章碎片、头像框是否第一版都上线。
|
||||
- 吉祥物装扮、头像框、称号是否第一版需要完整库存模型,还是先作为奖励流水中的展示型权益。
|
||||
|
||||
## 完成定义
|
||||
|
||||
- 服务端可以独立裁决所有第一版游戏化资源和奖励。
|
||||
- 客户端不能通过直接改本地状态绕过红心、挑战次数、金币、道具和广告恢复限制。
|
||||
- 所有可重复触发的奖励都有幂等保护。
|
||||
- 所有资源变更可追踪来源和结果。
|
||||
- API 文档与真实路由、DTO、错误码保持一致。
|
||||
@ -1,14 +1,14 @@
|
||||
# duoqi-api Implementation Plan
|
||||
|
||||
> Phase 1b (Core Features) + Phase 1c (Commercialization) + Phase 1d (Game Economy)
|
||||
> Phase 1b (Core Features) + Phase 1c (Commercialization)
|
||||
> Created: 2026-04-08
|
||||
> Last Updated: 2026-05-13
|
||||
> Last Updated: 2026-04-09
|
||||
|
||||
## Overview
|
||||
|
||||
duoqi-api is a gamified knowledge quiz learning platform backend. Phase 1a (Skeleton) was complete. Phase 1b and Phase 1c are **fully implemented** (42/44 steps). Phase 1d (Game Economy) is **fully implemented** (7 phases × ~7 tasks = 48 tasks). Remaining: E2E tests + production deployment config.
|
||||
duoqi-api is a gamified knowledge quiz learning platform backend. Phase 1a (Skeleton) was complete. Phase 1b and Phase 1c are now **fully implemented** (42/44 steps). Remaining: E2E tests + production deployment config.
|
||||
|
||||
### Overall Progress
|
||||
### Overall Progress: 42/44 Steps Complete (95%)
|
||||
|
||||
| Phase | Steps | Status |
|
||||
|-------|-------|--------|
|
||||
@ -24,21 +24,6 @@ duoqi-api is a gamified knowledge quiz learning platform backend. Phase 1a (Skel
|
||||
| 1c-3 IAP + Subscription | 4 | ✅ Done |
|
||||
| 1c-4 Security Hardening | 4 | ✅ Done |
|
||||
| 1c-5 Integration & Deploy | 2 | ⬜ Remaining |
|
||||
| **1d Game Economy** | **48** | **✅ Done** |
|
||||
|
||||
### Phase 1d: Game Economy (2026-05-11 ~ 2026-05-13)
|
||||
|
||||
详细计划见 [`docs/gamification-server-plan.md`](./gamification-server-plan.md),设计文档见 [`docs/GAMIFICATION_DESIGN.md`](./GAMIFICATION_DESIGN.md)。
|
||||
|
||||
| Phase | Tasks | Description |
|
||||
|-------|-------|-------------|
|
||||
| G0 规则常量与数据模型 | 7/7 ✅ | 集中规则定义、挑战组/钱包/奖励流水/每日进度/周 XP 模型、迁移 |
|
||||
| G1 挑战组与答题结算 | 7/7 ✅ | 创建挑战组、幂等答题、完成结算、红心扣除、高奖励次数、DTO、测试 |
|
||||
| G2 XP/等级/连续学习 | 7/7 ✅ | 50 级曲线、XP 来源扩展、连对奖励、streak 按组、里程碑、首日红心、测试 |
|
||||
| G3 金币/商店/道具 | 7/7 ✅ | 金币发放、宝箱、道具库存、商店购买、道具使用、DTO、测试 |
|
||||
| G4 广告恢复与订阅 | 7/7 ✅ | 统一奖励结算、恢复规则确认、Plus 分支、旧接口废弃、回归测试 |
|
||||
| G5 本周排行榜 | 6/6 ✅ | 本周 XP、周一刷新、20-30 人分组、前三奖励、元信息、测试 |
|
||||
| G6 文档/Admin/运维 | 1/6 | API 文档已更新;剩余:实施计划索引、Admin 查看、E2E、定时任务、最终验证 |
|
||||
|
||||
### Current Status
|
||||
|
||||
@ -173,10 +158,6 @@ duoqi-api is a gamified knowledge quiz learning platform backend. Phase 1a (Skel
|
||||
|
|
||||
v
|
||||
1c-4 [Security] -> 1c-5 [Integration/Deploy]
|
||||
|
|
||||
v
|
||||
1d-G0 [规则/模型] -> G1 [挑战组] -> G2 [XP/等级] -> G3 [金币/商店]
|
||||
-> G4 [广告恢复] -> G5 [周榜] -> G6 [文档/运维]
|
||||
```
|
||||
|
||||
## Key Risks
|
||||
@ -202,11 +183,5 @@ duoqi-api is a gamified knowledge quiz learning platform backend. Phase 1a (Skel
|
||||
- [x] Achievements unlock based on user behavior
|
||||
- [x] Huawei IAP verification works with subscription management
|
||||
- [x] All endpoints have rate limiting and input validation
|
||||
- [x] Challenge groups (5 questions per session) with idempotent submission
|
||||
- [x] 50-level XP curve with combo bonus and streak milestone rewards
|
||||
- [x] Coin economy: wallet, shop, item inventory, chest rewards
|
||||
- [x] Ad recovery via unified reward ledger with idempotency
|
||||
- [x] Weekly leaderboard with 20-30 user groups, top-3 coin rewards
|
||||
- [x] Plus subscription: unlimited hearts, ad-free, subscription benefits in API response
|
||||
- [ ] Test coverage >= 80% across all services (current: unit tests for core logic)
|
||||
- [x] TypeScript strict mode compiles with zero errors
|
||||
|
||||
@ -1,179 +0,0 @@
|
||||
/**
|
||||
* 游戏化核心流程集成测试
|
||||
*
|
||||
* 覆盖:游客登录 → 完成挑战组 → XP 累加 → 金币发放 → 商店购买 → 周榜分组
|
||||
* 使用 DB mock 模拟完整业务链路,验证跨服务协作正确性。
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { db } from '../../db/client.js';
|
||||
import { addXp } from '../../services/progress/xp-service.js';
|
||||
import { grantFirstDailyChallengeCoins } from '../../services/gamification/coin-service.js';
|
||||
import { getLeaderboard, getUserRank } from '../../services/gamification/leaderboard-service.js';
|
||||
import { completeAdRecoverySession } from '../../services/rewards/ad-recovery-service.js';
|
||||
|
||||
// ── Mock 外部服务 ──────────────────────────────────────────────────
|
||||
|
||||
vi.mock('../../services/learning/progress-summary-service.js', () => ({
|
||||
getProgressSummary: vi.fn().mockResolvedValue({
|
||||
hearts: 2, maxHearts: 5, nextHeartRestoreAt: null,
|
||||
dailyAttemptsLeft: 2, dailyAttemptsMax: 3, nextAttemptResetAt: null,
|
||||
highRewardSessionsLeft: 2, highRewardSessionsMax: 3,
|
||||
xp: 0, level: 1, xpToNextLevel: 100,
|
||||
streakDays: 0, checkInDays: 0, streakProtectedUntil: null,
|
||||
activeTrackId: null, isSubscribed: false,
|
||||
}),
|
||||
getDailyAttempts: vi.fn().mockResolvedValue({ left: 2, max: 3 }),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/payment/subscription-service.js', () => ({
|
||||
getSubscriptionStatus: vi.fn().mockResolvedValue({ status: 'inactive', tier: 'free', expiresAt: null, autoRenew: false }),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/progress/streak-service.js', () => ({
|
||||
freezeStreak: vi.fn().mockResolvedValue({ streakDays: 5, checkInDays: 5, streakProtectedUntil: null }),
|
||||
}));
|
||||
|
||||
// ── DB Mock 辅助 ───────────────────────────────────────────────────
|
||||
|
||||
/** 按 db.select() 调用顺序分配结果。 */
|
||||
function setupSelectQueue(queue: unknown[][]) {
|
||||
let index = 0;
|
||||
vi.mocked(db.select).mockImplementation((() => {
|
||||
const rows = index < queue.length ? queue[index]! : [];
|
||||
index += 1;
|
||||
const limit = vi.fn().mockResolvedValue(rows);
|
||||
const orderBy = vi.fn().mockReturnValue({ limit });
|
||||
const gte = vi.fn().mockReturnValue({ lt: vi.fn().mockReturnValue({ orderBy }) });
|
||||
const where = vi.fn().mockReturnValue({ limit, orderBy, gte });
|
||||
const from = vi.fn().mockReturnValue({ where });
|
||||
return { from };
|
||||
}) as never);
|
||||
}
|
||||
|
||||
function setupInsert() {
|
||||
const valuesSpy = vi.fn().mockReturnValue(undefined);
|
||||
vi.mocked(db.insert).mockReturnValue({ values: valuesSpy } as never);
|
||||
return valuesSpy;
|
||||
}
|
||||
|
||||
function setupUpdate() {
|
||||
const setSpy = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue({ affectedRows: 1 }) });
|
||||
vi.mocked(db.update).mockReturnValue({ set: setSpy } as never);
|
||||
return setSpy;
|
||||
}
|
||||
|
||||
// ── 集成测试 ───────────────────────────────────────────────────────
|
||||
|
||||
describe('gamification integration flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('完成挑战 XP → 金币发放 → 周榜分组', async () => {
|
||||
// 1. 完成挑战获得 XP(addXp 内部累加 userWeeklyXp)
|
||||
// addXp: update users + insert userWeeklyXp(查已有记录 + 查组人数)
|
||||
setupUpdate(); // update users
|
||||
setupSelectQueue([[]]); // 无已有 userWeeklyXp 记录
|
||||
setupSelectQueue([[]]); // 无已有组 → 创建新组
|
||||
const insertSpy = setupInsert(); // insert userWeeklyXp
|
||||
|
||||
await addXp('user-1', 25);
|
||||
|
||||
// 验证 XP 累加更新了 users 表
|
||||
expect(db.update).toHaveBeenCalled();
|
||||
// 验证周 XP 写入了 userWeeklyXp,包含分组 ID
|
||||
expect(insertSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 'user-1',
|
||||
xpEarned: 25,
|
||||
groupId: expect.stringContaining('group-1'),
|
||||
}),
|
||||
);
|
||||
|
||||
// 2. 每日首组挑战金币发放
|
||||
setupSelectQueue([[]] as unknown[][]); // 无已有金币交易
|
||||
setupSelectQueue([[{ balance: 0 }]] as unknown[][]); // getCoinBalance 返回 0
|
||||
setupSelectQueue([[{ id: 'inv-1' }]] as unknown[][]); // 钱包 upsert 查询
|
||||
setupInsert(); // inventoryTransaction + rewardLedger
|
||||
setupUpdate(); // incrementDailyCoins
|
||||
|
||||
const coinResult = await grantFirstDailyChallengeCoins('user-1', 'session-1');
|
||||
expect(coinResult).not.toBeNull();
|
||||
expect(coinResult!.amount).toBe(20);
|
||||
|
||||
// 4. 周榜查询(组内排名)
|
||||
setupSelectQueue([[{ groupId: 'week-2026-05-11-group-1' }]]); // getUserGroupId
|
||||
setupSelectQueue([[{ userId: 'user-1', weeklyXp: 25, nickname: '测试用户', avatarUrl: null }]]); // 组内成员
|
||||
|
||||
const leaderboard = await getLeaderboard('user-1');
|
||||
expect(leaderboard.items).toHaveLength(1);
|
||||
expect(leaderboard.items[0]!.weeklyXp).toBe(25);
|
||||
expect(leaderboard.items[0]!.rank).toBe(1);
|
||||
});
|
||||
|
||||
it('广告恢复爱心 → 奖励写入 rewardLedger', async () => {
|
||||
const now = new Date();
|
||||
const validSession = {
|
||||
id: 'session-ad-1',
|
||||
userId: 'user-1',
|
||||
type: 'hearts',
|
||||
status: 'pending',
|
||||
clientRequestId: 'req-ad-1',
|
||||
adProvider: 'mock',
|
||||
expiresAt: new Date(now.getTime() + 30 * 60 * 1000),
|
||||
};
|
||||
|
||||
// completeAdRecoverySession 调用顺序:
|
||||
// getSession → rewardLedger 幂等检查 → getUserTier → completedCountToday
|
||||
setupSelectQueue([
|
||||
[validSession],
|
||||
[], // 无已有 rewardLedger
|
||||
[], // getUserTier → free
|
||||
[], // completedCountToday → 0
|
||||
]);
|
||||
setupUpdate(); // update users + update session
|
||||
setupInsert(); // insert rewardLedger
|
||||
|
||||
const result = await completeAdRecoverySession('user-1', {
|
||||
sessionId: 'session-ad-1',
|
||||
clientRequestId: 'req-ad-1',
|
||||
adProvider: 'mock',
|
||||
providerRewardToken: 'token-abc',
|
||||
completedAt: now.toISOString(),
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(result.type).toBe('hearts');
|
||||
expect(result.reward!.heartsDelta).toBeGreaterThan(0);
|
||||
|
||||
// 验证 rewardLedger 写入了正确的 sourceType
|
||||
expect(db.insert).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('用户组内排名统计', async () => {
|
||||
// getUserRank: 第一次查用户信息,第二次统计高排名人数
|
||||
let callIndex = 0;
|
||||
vi.mocked(db.select).mockImplementation((() => {
|
||||
callIndex += 1;
|
||||
if (callIndex === 1) {
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ xpEarned: 100, groupId: 'group-A' }]),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([{ count: 5 }]),
|
||||
}),
|
||||
};
|
||||
}) as never);
|
||||
|
||||
const rank = await getUserRank('user-1');
|
||||
expect(rank).not.toBeNull();
|
||||
expect(rank!.rank).toBe(6); // 5 人比自己高 → 排名 6
|
||||
expect(rank!.weeklyXp).toBe(100);
|
||||
});
|
||||
});
|
||||
@ -1,79 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { db } from '../../../db/client.js';
|
||||
import { getBootstrap } from '../../../services/app/bootstrap-service.js';
|
||||
|
||||
function selectRows(rows: unknown[]) {
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
orderBy: vi.fn().mockResolvedValue(rows),
|
||||
limit: vi.fn().mockResolvedValue(rows),
|
||||
then: (resolve: (value: unknown) => unknown) => Promise.resolve(rows).then(resolve),
|
||||
}),
|
||||
orderBy: vi.fn().mockResolvedValue(rows),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function mockSelectQueue(queue: unknown[][]) {
|
||||
let index = 0;
|
||||
vi.mocked(db.select).mockImplementation((() => {
|
||||
const rows = index < queue.length ? queue[index]! : [];
|
||||
index += 1;
|
||||
return selectRows(rows);
|
||||
}) as never);
|
||||
}
|
||||
|
||||
function mockUpdate() {
|
||||
return { set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) } as never;
|
||||
}
|
||||
|
||||
describe('bootstrap-service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('includes wallet, inventory, shop catalog, ad benefits, and subscription in bootstrap', async () => {
|
||||
mockSelectQueue([
|
||||
[{ id: 'user-1', nickname: '多奇', avatarUrl: null, tier: 'free', xpTotal: 100 }],
|
||||
// getProgressSummary
|
||||
[{ id: 'user-1', tier: 'free', xpTotal: 100, activeTrackId: null, dailyAttemptsLeft: 5, dailyAttemptsDate: new Date(), checkInDays: 1, lastCheckInDate: new Date(), streakProtectedUntil: null, heartsRemaining: 5 }],
|
||||
[{ tier: 'free', heartsRemaining: 5, heartsLastRestore: null }],
|
||||
[{ streakDays: 1, streakLastDate: new Date() }],
|
||||
[],
|
||||
[{ id: 'user-1', tier: 'free', xpTotal: 100, activeTrackId: null, dailyAttemptsLeft: 5, dailyAttemptsDate: new Date(), checkInDays: 1, lastCheckInDate: new Date(), streakProtectedUntil: null, heartsRemaining: 5 }],
|
||||
[{ used: 1, restored: 0 }],
|
||||
// getThemeTracks
|
||||
[],
|
||||
// getClientSubscription
|
||||
[],
|
||||
// getCoinBalance
|
||||
[{ coinsBalance: 260 }],
|
||||
// getClientInventory
|
||||
[{ itemId: 'hint_feather', quantity: 2, activeUntil: null, metadata: null }],
|
||||
]);
|
||||
vi.mocked(db.update).mockReturnValue(mockUpdate());
|
||||
|
||||
const result = await getBootstrap('user-1');
|
||||
|
||||
expect(result.user).toEqual({
|
||||
id: 'user-1',
|
||||
nickname: '多奇',
|
||||
avatarUrl: null,
|
||||
tier: 'free',
|
||||
level: 2,
|
||||
});
|
||||
expect(result.wallet).toEqual({ coinsBalance: 260 });
|
||||
expect(result.inventory.items).toEqual([
|
||||
{ itemId: 'hint_feather', quantity: 2, activeUntil: null, metadata: null },
|
||||
]);
|
||||
expect(result.shop.products.map((product) => product.id)).toContain('hint-feather');
|
||||
expect(result.shopBenefits).toBe(result.shop.benefits);
|
||||
expect(result.subscription).toEqual({
|
||||
status: 'none',
|
||||
tier: 'free',
|
||||
expiresAt: null,
|
||||
autoRenew: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,42 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
AD_RECOVERY_RULES,
|
||||
CHALLENGE_RULES,
|
||||
CHEST_RULES,
|
||||
COIN_RULES,
|
||||
HEART_RULES,
|
||||
ITEM_RULES,
|
||||
LEADERBOARD_RULES,
|
||||
LEVEL_RULES,
|
||||
XP_RULES,
|
||||
} from '../../services/gamification/rules.js';
|
||||
|
||||
describe('gamification rules', () => {
|
||||
it('centralizes the first-version challenge and recovery limits', () => {
|
||||
expect(CHALLENGE_RULES.questionsPerSession).toBe(5);
|
||||
expect(CHALLENGE_RULES.freeDailyHighRewardSessions).toBe(3);
|
||||
expect(CHALLENGE_RULES.plusDailyHighRewardSessions).toBe(8);
|
||||
expect(HEART_RULES.freeMax).toBe(5);
|
||||
expect(HEART_RULES.restoreIntervalMs).toBe(30 * 60 * 1000);
|
||||
expect(AD_RECOVERY_RULES.heartsDailyLimit).toBe(3);
|
||||
expect(AD_RECOVERY_RULES.bonusAttemptsPerRecovery).toBe(1);
|
||||
expect(AD_RECOVERY_RULES.streakProtectionCooldownMs).toBe(7 * 24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it('covers XP, level, coin, item, and leaderboard rules', () => {
|
||||
expect(XP_RULES.correctNormal).toBe(10);
|
||||
expect(XP_RULES.correctHard).toBe(15);
|
||||
expect(XP_RULES.comboBonuses[0]).toEqual({ minCombo: 10, bonus: 25 });
|
||||
expect(LEVEL_RULES.maxLevel).toBe(50);
|
||||
expect(LEVEL_RULES.xpRequirements).toHaveLength(50);
|
||||
expect(LEVEL_RULES.xpRequirements.slice(0, 5)).toEqual([100, 120, 150, 180, 220]);
|
||||
expect(COIN_RULES.firstDailyChallenge).toBe(20);
|
||||
expect(CHEST_RULES.baseDropRate).toBe(0.18);
|
||||
expect(CHEST_RULES.comboBoostAt).toBe(10);
|
||||
expect(CHEST_RULES.highRewardExhaustedDropRateMultiplier).toBe(0.35);
|
||||
expect(ITEM_RULES.hintFeather.shopPriceCoins).toBe(80);
|
||||
expect(ITEM_RULES.doubleXpPotion.durationMs).toBe(15 * 60 * 1000);
|
||||
expect(LEADERBOARD_RULES.xpSource).toBe('weekly_xp');
|
||||
expect(LEADERBOARD_RULES.weekStartsOnIsoDay).toBe(1);
|
||||
});
|
||||
});
|
||||
@ -1,157 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { db } from '../../../db/client.js';
|
||||
import { calculateChestCoinAmount, calculateChestDropRate, openChestReward } from '../../../services/gamification/chest-service.js';
|
||||
|
||||
function selectRows(rows: unknown[]) {
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue(rows),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function mockSelectQueue(queue: unknown[][]) {
|
||||
let index = 0;
|
||||
vi.mocked(db.select).mockImplementation((() => {
|
||||
const rows = index < queue.length ? queue[index]! : [];
|
||||
index += 1;
|
||||
return selectRows(rows);
|
||||
}) as never);
|
||||
}
|
||||
|
||||
function mockInsert(valuesSpy: ReturnType<typeof vi.fn>) {
|
||||
return { values: valuesSpy.mockResolvedValue(undefined) } as never;
|
||||
}
|
||||
|
||||
function mockUpdate(setSpy: ReturnType<typeof vi.fn>) {
|
||||
return { set: setSpy.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) } as never;
|
||||
}
|
||||
|
||||
describe('chest-service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('calculates base, combo-boosted, and degraded drop rates', () => {
|
||||
expect(calculateChestDropRate()).toBe(0.18);
|
||||
expect(calculateChestDropRate({ comboCount: 10 })).toBe(0.30);
|
||||
expect(calculateChestDropRate({ highRewardEligible: false })).toBeCloseTo(0.063);
|
||||
expect(calculateChestDropRate({ comboCount: 10, highRewardEligible: false })).toBeCloseTo(0.105);
|
||||
});
|
||||
|
||||
it('maps amount rolls into the configured chest coin range', () => {
|
||||
expect(calculateChestCoinAmount(0)).toBe(20);
|
||||
expect(calculateChestCoinAmount(0.5)).toBe(110);
|
||||
expect(calculateChestCoinAmount(1)).toBe(200);
|
||||
});
|
||||
|
||||
it('records a miss without granting coins', async () => {
|
||||
const insertValues = vi.fn();
|
||||
mockSelectQueue([
|
||||
[], // no existing chest attempt
|
||||
]);
|
||||
vi.mocked(db.insert).mockReturnValue(mockInsert(insertValues));
|
||||
|
||||
const result = await openChestReward({
|
||||
userId: 'user-1',
|
||||
sourceId: 'challenge-1',
|
||||
random: () => 0.95,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'chest',
|
||||
source: 'chest',
|
||||
opened: false,
|
||||
roll: 0.95,
|
||||
dropRate: 0.18,
|
||||
title: '宝箱未掉落',
|
||||
});
|
||||
expect(db.update).not.toHaveBeenCalled();
|
||||
expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({
|
||||
userId: 'user-1',
|
||||
sourceType: 'chest',
|
||||
sourceId: 'challenge-1',
|
||||
idempotencyKey: 'chest:challenge-1',
|
||||
resourceDeltas: { coins: 0 },
|
||||
}));
|
||||
});
|
||||
|
||||
it('opens a chest and grants coins through the coin service', async () => {
|
||||
const insertValues = vi.fn();
|
||||
const updateSet = vi.fn();
|
||||
const randomValues = [0.1, 0.5];
|
||||
mockSelectQueue([
|
||||
[], // no existing chest attempt
|
||||
[], // no existing coin transaction
|
||||
[{ coinsBalance: 40 }], // wallet before
|
||||
[{ id: 'daily-1' }], // daily progress row
|
||||
]);
|
||||
vi.mocked(db.insert).mockReturnValue(mockInsert(insertValues));
|
||||
vi.mocked(db.update).mockReturnValue(mockUpdate(updateSet));
|
||||
|
||||
const result = await openChestReward({
|
||||
userId: 'user-1',
|
||||
sourceId: 'challenge-1',
|
||||
comboCount: 10,
|
||||
highRewardEligible: true,
|
||||
random: () => randomValues.shift() ?? 0,
|
||||
});
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
type: 'chest',
|
||||
source: 'chest',
|
||||
opened: true,
|
||||
roll: 0.1,
|
||||
dropRate: 0.30,
|
||||
coinAmount: 110,
|
||||
title: '宝箱开出 110 金币',
|
||||
reward: expect.objectContaining({
|
||||
type: 'coin',
|
||||
source: 'chest',
|
||||
amount: 110,
|
||||
}),
|
||||
}));
|
||||
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({
|
||||
coinsBalance: expect.any(Object),
|
||||
lifetimeCoinsEarned: expect.any(Object),
|
||||
}));
|
||||
expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({
|
||||
itemId: 'coins',
|
||||
quantityDelta: 110,
|
||||
balanceAfter: 150,
|
||||
sourceType: 'chest',
|
||||
idempotencyKey: 'chest:challenge-1:coins',
|
||||
}));
|
||||
expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({
|
||||
sourceType: 'chest',
|
||||
idempotencyKey: 'chest:challenge-1',
|
||||
resourceDeltas: { coins: 110 },
|
||||
}));
|
||||
});
|
||||
|
||||
it('returns the stored chest result for duplicate attempts', async () => {
|
||||
const storedResult = {
|
||||
type: 'chest',
|
||||
source: 'chest',
|
||||
opened: false,
|
||||
roll: 0.9,
|
||||
dropRate: 0.18,
|
||||
title: '宝箱未掉落',
|
||||
};
|
||||
mockSelectQueue([
|
||||
[{ rewardSnapshot: { result: storedResult } }],
|
||||
]);
|
||||
|
||||
const result = await openChestReward({
|
||||
userId: 'user-1',
|
||||
sourceId: 'challenge-1',
|
||||
random: () => 0,
|
||||
});
|
||||
|
||||
expect(result).toEqual(storedResult);
|
||||
expect(db.insert).not.toHaveBeenCalled();
|
||||
expect(db.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -1,211 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { db } from '../../../db/client.js';
|
||||
import { createCoinReward, getCoinRewardAmount, grantCoins, spendCoins } from '../../../services/gamification/coin-service.js';
|
||||
|
||||
function selectRows(rows: unknown[]) {
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue(rows),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function mockSelectQueue(queue: unknown[][]) {
|
||||
let index = 0;
|
||||
vi.mocked(db.select).mockImplementation((() => {
|
||||
const rows = index < queue.length ? queue[index]! : [];
|
||||
index += 1;
|
||||
return selectRows(rows);
|
||||
}) as never);
|
||||
}
|
||||
|
||||
function mockInsert(valuesSpy: ReturnType<typeof vi.fn>) {
|
||||
return { values: valuesSpy.mockResolvedValue(undefined) } as never;
|
||||
}
|
||||
|
||||
function mockUpdate(setSpy: ReturnType<typeof vi.fn>) {
|
||||
return { set: setSpy.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) } as never;
|
||||
}
|
||||
|
||||
describe('coin-service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('creates configured coin rewards and clamps bounded sources', () => {
|
||||
expect(createCoinReward('first_daily_challenge')).toEqual({
|
||||
type: 'coin',
|
||||
source: 'first_daily_challenge',
|
||||
amount: 20,
|
||||
title: '每日首组挑战 +20 金币',
|
||||
});
|
||||
expect(getCoinRewardAmount('daily_task', 10)).toBe(30);
|
||||
expect(getCoinRewardAmount('daily_task', 90)).toBe(80);
|
||||
expect(getCoinRewardAmount('level_up')).toBe(100);
|
||||
expect(getCoinRewardAmount('theme_node')).toBe(50);
|
||||
expect(getCoinRewardAmount('chest', 5)).toBe(20);
|
||||
expect(getCoinRewardAmount('chest', 260)).toBe(200);
|
||||
});
|
||||
|
||||
it('grants coins with wallet, inventory transaction, reward ledger, and daily progress updates', async () => {
|
||||
const insertValues = vi.fn();
|
||||
const updateSet = vi.fn();
|
||||
mockSelectQueue([
|
||||
[], // no existing idempotency transaction
|
||||
[{ coinsBalance: 40 }], // wallet before
|
||||
[{ id: 'daily-1' }], // daily progress row to aggregate earned coins
|
||||
]);
|
||||
vi.mocked(db.insert).mockReturnValue(mockInsert(insertValues));
|
||||
vi.mocked(db.update).mockReturnValue(mockUpdate(updateSet));
|
||||
|
||||
const result = await grantCoins({
|
||||
userId: 'user-1',
|
||||
source: 'daily_task',
|
||||
sourceId: 'task-1',
|
||||
amount: 45,
|
||||
idempotencyKey: 'daily_task:2026-05-13:task-1',
|
||||
snapshot: { taskId: 'task-1' },
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
reward: {
|
||||
type: 'coin',
|
||||
source: 'daily_task',
|
||||
amount: 45,
|
||||
title: '每日任务 +45 金币',
|
||||
},
|
||||
granted: true,
|
||||
balanceBefore: 40,
|
||||
balanceAfter: 85,
|
||||
});
|
||||
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({
|
||||
coinsBalance: expect.any(Object),
|
||||
lifetimeCoinsEarned: expect.any(Object),
|
||||
}));
|
||||
expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({
|
||||
userId: 'user-1',
|
||||
itemId: 'coins',
|
||||
direction: 'grant',
|
||||
quantityDelta: 45,
|
||||
balanceAfter: 85,
|
||||
sourceType: 'daily_task',
|
||||
sourceId: 'task-1',
|
||||
idempotencyKey: 'daily_task:2026-05-13:task-1',
|
||||
}));
|
||||
expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({
|
||||
userId: 'user-1',
|
||||
sourceType: 'daily_task',
|
||||
sourceId: 'task-1',
|
||||
idempotencyKey: 'daily_task:2026-05-13:task-1',
|
||||
status: 'completed',
|
||||
resourceDeltas: { coins: 45 },
|
||||
}));
|
||||
});
|
||||
|
||||
it('returns the reward without side effects when the idempotency key already exists', async () => {
|
||||
mockSelectQueue([
|
||||
[{ id: 'tx-1' }],
|
||||
[{ coinsBalance: 120 }],
|
||||
]);
|
||||
|
||||
const result = await grantCoins({
|
||||
userId: 'user-1',
|
||||
source: 'level_up',
|
||||
sourceId: 'level-2',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
reward: {
|
||||
type: 'coin',
|
||||
source: 'level_up',
|
||||
amount: 100,
|
||||
title: '升级奖励 +100 金币',
|
||||
},
|
||||
granted: false,
|
||||
balanceBefore: 120,
|
||||
balanceAfter: 120,
|
||||
});
|
||||
expect(db.insert).not.toHaveBeenCalled();
|
||||
expect(db.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('spends coins with a consume transaction for shop purchases', async () => {
|
||||
const insertValues = vi.fn();
|
||||
const updateSet = vi.fn();
|
||||
mockSelectQueue([
|
||||
[], // no existing spend transaction
|
||||
[{ coinsBalance: 300 }],
|
||||
]);
|
||||
vi.mocked(db.insert).mockReturnValue(mockInsert(insertValues));
|
||||
vi.mocked(db.update).mockReturnValue(mockUpdate(updateSet));
|
||||
|
||||
const result = await spendCoins({
|
||||
userId: 'user-1',
|
||||
amount: 80,
|
||||
sourceId: 'shop:request-1',
|
||||
idempotencyKey: 'shop:request-1:coins',
|
||||
snapshot: { productId: 'hint-feather' },
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
spent: 80,
|
||||
applied: true,
|
||||
balanceBefore: 300,
|
||||
balanceAfter: 220,
|
||||
});
|
||||
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({
|
||||
coinsBalance: expect.any(Object),
|
||||
lifetimeCoinsSpent: expect.any(Object),
|
||||
}));
|
||||
expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({
|
||||
userId: 'user-1',
|
||||
itemId: 'coins',
|
||||
direction: 'consume',
|
||||
quantityDelta: -80,
|
||||
balanceAfter: 220,
|
||||
sourceType: 'shop_purchase',
|
||||
sourceId: 'shop:request-1',
|
||||
idempotencyKey: 'shop:request-1:coins',
|
||||
}));
|
||||
});
|
||||
|
||||
it('does not spend coins twice for a duplicate idempotency key', async () => {
|
||||
mockSelectQueue([
|
||||
[{ id: 'tx-1' }],
|
||||
[{ coinsBalance: 220 }],
|
||||
]);
|
||||
|
||||
const result = await spendCoins({
|
||||
userId: 'user-1',
|
||||
amount: 80,
|
||||
sourceId: 'shop:request-1',
|
||||
idempotencyKey: 'shop:request-1:coins',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
spent: 80,
|
||||
applied: false,
|
||||
balanceBefore: 220,
|
||||
balanceAfter: 220,
|
||||
});
|
||||
expect(db.insert).not.toHaveBeenCalled();
|
||||
expect(db.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when spending more coins than the current balance', async () => {
|
||||
mockSelectQueue([
|
||||
[],
|
||||
[{ coinsBalance: 20 }],
|
||||
]);
|
||||
|
||||
await expect(
|
||||
spendCoins({
|
||||
userId: 'user-1',
|
||||
amount: 80,
|
||||
sourceId: 'shop:request-1',
|
||||
}),
|
||||
).rejects.toThrow('金币余额不足');
|
||||
});
|
||||
});
|
||||
@ -1,272 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { db } from '../../../db/client.js';
|
||||
import {
|
||||
consumeInventoryItem,
|
||||
createInventoryReward,
|
||||
getClientInventory,
|
||||
getInventoryItem,
|
||||
grantInventoryItem,
|
||||
} from '../../../services/gamification/inventory-service.js';
|
||||
|
||||
function selectRows(rows: unknown[]) {
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue(rows),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function mockSelectQueue(queue: unknown[][]) {
|
||||
let index = 0;
|
||||
vi.mocked(db.select).mockImplementation((() => {
|
||||
const rows = index < queue.length ? queue[index]! : [];
|
||||
index += 1;
|
||||
return selectRows(rows);
|
||||
}) as never);
|
||||
}
|
||||
|
||||
function mockInsert(valuesSpy: ReturnType<typeof vi.fn>) {
|
||||
return { values: valuesSpy.mockResolvedValue(undefined) } as never;
|
||||
}
|
||||
|
||||
function mockUpdate(setSpy: ReturnType<typeof vi.fn>) {
|
||||
return { set: setSpy.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) } as never;
|
||||
}
|
||||
|
||||
describe('inventory-service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('creates display rewards for the first-version items', () => {
|
||||
expect(createInventoryReward('streak_shield')).toEqual({
|
||||
type: 'item',
|
||||
source: 'inventory',
|
||||
itemId: 'streak_shield',
|
||||
quantity: 1,
|
||||
title: '连胜护盾 x1',
|
||||
});
|
||||
expect(createInventoryReward('double_xp_potion', 2).title).toBe('双倍 XP 药水 x2');
|
||||
expect(createInventoryReward('heart_supply').title).toBe('爱心补给 x1');
|
||||
expect(createInventoryReward('hint_feather', 3).title).toBe('提示羽毛 x3');
|
||||
});
|
||||
|
||||
it('returns an empty inventory item when no row exists', async () => {
|
||||
mockSelectQueue([[]]);
|
||||
|
||||
const item = await getInventoryItem('user-1', 'hint_feather');
|
||||
|
||||
expect(item).toEqual({
|
||||
itemId: 'hint_feather',
|
||||
quantity: 0,
|
||||
activeUntil: null,
|
||||
metadata: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns client inventory with ISO active time', async () => {
|
||||
mockSelectQueue([
|
||||
[{ itemId: 'double_xp_potion', quantity: 1, activeUntil: new Date('2026-05-13T12:00:00.000Z'), metadata: { activeEffect: 'double_xp' } }],
|
||||
]);
|
||||
|
||||
const result = await getClientInventory('user-1');
|
||||
|
||||
expect(result).toEqual({
|
||||
items: [{
|
||||
itemId: 'double_xp_potion',
|
||||
quantity: 1,
|
||||
activeUntil: '2026-05-13T12:00:00.000Z',
|
||||
metadata: { activeEffect: 'double_xp' },
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
it('grants a new item with inventory transaction and reward ledger records', async () => {
|
||||
const insertValues = vi.fn();
|
||||
mockSelectQueue([
|
||||
[], // no existing transaction
|
||||
[], // no current item
|
||||
[], // no inventory row before insert
|
||||
[{ id: 'inventory-1' }], // inventory row id for transaction
|
||||
]);
|
||||
vi.mocked(db.insert).mockReturnValue(mockInsert(insertValues));
|
||||
|
||||
const result = await grantInventoryItem({
|
||||
userId: 'user-1',
|
||||
itemId: 'hint_feather',
|
||||
quantity: 2,
|
||||
sourceType: 'daily_task',
|
||||
sourceId: 'task-1',
|
||||
idempotencyKey: 'daily_task:task-1:hint',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
item: {
|
||||
itemId: 'hint_feather',
|
||||
quantity: 2,
|
||||
activeUntil: null,
|
||||
metadata: null,
|
||||
},
|
||||
quantityDelta: 2,
|
||||
applied: true,
|
||||
});
|
||||
expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({
|
||||
userId: 'user-1',
|
||||
itemId: 'hint_feather',
|
||||
quantity: 2,
|
||||
}));
|
||||
expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({
|
||||
userId: 'user-1',
|
||||
inventoryItemId: 'inventory-1',
|
||||
itemId: 'hint_feather',
|
||||
direction: 'grant',
|
||||
quantityDelta: 2,
|
||||
balanceAfter: 2,
|
||||
sourceType: 'daily_task',
|
||||
sourceId: 'task-1',
|
||||
idempotencyKey: 'daily_task:task-1:hint',
|
||||
}));
|
||||
expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({
|
||||
userId: 'user-1',
|
||||
sourceType: 'daily_task',
|
||||
sourceId: 'task-1',
|
||||
idempotencyKey: 'daily_task:task-1:hint',
|
||||
status: 'completed',
|
||||
resourceDeltas: {
|
||||
items: [{ itemId: 'hint_feather', quantity: 2 }],
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it('increments an existing item and preserves active metadata', async () => {
|
||||
const updateSet = vi.fn();
|
||||
const insertValues = vi.fn();
|
||||
const activeUntil = new Date('2026-05-13T12:00:00.000Z');
|
||||
mockSelectQueue([
|
||||
[], // no existing transaction
|
||||
[{ itemId: 'double_xp_potion', quantity: 1, activeUntil: null, metadata: { source: 'old' } }],
|
||||
[{ id: 'inventory-2' }],
|
||||
]);
|
||||
vi.mocked(db.update).mockReturnValue(mockUpdate(updateSet));
|
||||
vi.mocked(db.insert).mockReturnValue(mockInsert(insertValues));
|
||||
|
||||
const result = await grantInventoryItem({
|
||||
userId: 'user-1',
|
||||
itemId: 'double_xp_potion',
|
||||
quantity: 1,
|
||||
sourceType: 'chest',
|
||||
sourceId: 'chest-1',
|
||||
activeUntil,
|
||||
metadata: { source: 'chest' },
|
||||
});
|
||||
|
||||
expect(result.item).toEqual({
|
||||
itemId: 'double_xp_potion',
|
||||
quantity: 2,
|
||||
activeUntil,
|
||||
metadata: { source: 'chest' },
|
||||
});
|
||||
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({
|
||||
quantity: expect.any(Object),
|
||||
activeUntil,
|
||||
metadata: { source: 'chest' },
|
||||
}));
|
||||
expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({
|
||||
itemId: 'double_xp_potion',
|
||||
direction: 'grant',
|
||||
quantityDelta: 1,
|
||||
balanceAfter: 2,
|
||||
sourceType: 'chest',
|
||||
}));
|
||||
});
|
||||
|
||||
it('does not apply duplicate grants with the same idempotency key', async () => {
|
||||
mockSelectQueue([
|
||||
[{ id: 'tx-1' }],
|
||||
[{ itemId: 'heart_supply', quantity: 1, activeUntil: null, metadata: null }],
|
||||
]);
|
||||
|
||||
const result = await grantInventoryItem({
|
||||
userId: 'user-1',
|
||||
itemId: 'heart_supply',
|
||||
sourceType: 'ad_recovery',
|
||||
sourceId: 'ad-1',
|
||||
idempotencyKey: 'ad-1:heart_supply',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
item: {
|
||||
itemId: 'heart_supply',
|
||||
quantity: 1,
|
||||
activeUntil: null,
|
||||
metadata: null,
|
||||
},
|
||||
quantityDelta: 0,
|
||||
applied: false,
|
||||
});
|
||||
expect(db.insert).not.toHaveBeenCalled();
|
||||
expect(db.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('consumes existing inventory and records a negative transaction', async () => {
|
||||
const updateSet = vi.fn();
|
||||
const insertValues = vi.fn();
|
||||
mockSelectQueue([
|
||||
[], // no existing transaction
|
||||
[{ itemId: 'streak_shield', quantity: 2, activeUntil: null, metadata: null }],
|
||||
[{ id: 'inventory-3' }],
|
||||
]);
|
||||
vi.mocked(db.update).mockReturnValue(mockUpdate(updateSet));
|
||||
vi.mocked(db.insert).mockReturnValue(mockInsert(insertValues));
|
||||
|
||||
const result = await consumeInventoryItem({
|
||||
userId: 'user-1',
|
||||
itemId: 'streak_shield',
|
||||
quantity: 1,
|
||||
sourceType: 'system_adjust',
|
||||
sourceId: 'protect-1',
|
||||
idempotencyKey: 'protect-1:streak_shield',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
item: {
|
||||
itemId: 'streak_shield',
|
||||
quantity: 1,
|
||||
activeUntil: null,
|
||||
metadata: null,
|
||||
},
|
||||
quantityDelta: -1,
|
||||
applied: true,
|
||||
});
|
||||
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({
|
||||
quantity: expect.any(Object),
|
||||
}));
|
||||
expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({
|
||||
itemId: 'streak_shield',
|
||||
direction: 'consume',
|
||||
quantityDelta: -1,
|
||||
balanceAfter: 1,
|
||||
sourceType: 'system_adjust',
|
||||
idempotencyKey: 'protect-1:streak_shield',
|
||||
}));
|
||||
});
|
||||
|
||||
it('throws when consuming more items than available', async () => {
|
||||
mockSelectQueue([
|
||||
[], // no existing transaction
|
||||
[{ itemId: 'hint_feather', quantity: 0, activeUntil: null, metadata: null }],
|
||||
]);
|
||||
|
||||
await expect(
|
||||
consumeInventoryItem({
|
||||
userId: 'user-1',
|
||||
itemId: 'hint_feather',
|
||||
quantity: 1,
|
||||
sourceType: 'challenge',
|
||||
sourceId: 'challenge-1',
|
||||
}),
|
||||
).rejects.toThrow('提示羽毛库存不足');
|
||||
});
|
||||
});
|
||||
@ -1,181 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { db } from '../../../db/client.js';
|
||||
import { useInventoryItem } from '../../../services/gamification/item-use-service.js';
|
||||
|
||||
function selectRows(rows: unknown[]) {
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue(rows),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function mockSelectQueue(queue: unknown[][]) {
|
||||
let index = 0;
|
||||
vi.mocked(db.select).mockImplementation((() => {
|
||||
const rows = index < queue.length ? queue[index]! : [];
|
||||
index += 1;
|
||||
return selectRows(rows);
|
||||
}) as never);
|
||||
}
|
||||
|
||||
function mockInsert(valuesSpy: ReturnType<typeof vi.fn>) {
|
||||
return { values: valuesSpy.mockResolvedValue(undefined) } as never;
|
||||
}
|
||||
|
||||
function mockUpdate(setSpy: ReturnType<typeof vi.fn>) {
|
||||
return { set: setSpy.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) } as never;
|
||||
}
|
||||
|
||||
describe('item-use-service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('uses heart supply to restore hearts to the user max', async () => {
|
||||
const insertValues = vi.fn();
|
||||
const updateSet = vi.fn();
|
||||
mockSelectQueue([
|
||||
[], // consume: no existing use transaction
|
||||
[{ itemId: 'heart_supply', quantity: 2, activeUntil: null, metadata: null }],
|
||||
[{ id: 'inventory-heart' }],
|
||||
[{ tier: 'free' }],
|
||||
]);
|
||||
vi.mocked(db.insert).mockReturnValue(mockInsert(insertValues));
|
||||
vi.mocked(db.update).mockReturnValue(mockUpdate(updateSet));
|
||||
|
||||
const result = await useInventoryItem({
|
||||
userId: 'user-1',
|
||||
itemId: 'heart_supply',
|
||||
clientRequestId: 'use-heart-1',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
itemId: 'heart_supply',
|
||||
quantityRemaining: 1,
|
||||
effect: {
|
||||
type: 'restore_hearts',
|
||||
hearts: 5,
|
||||
},
|
||||
});
|
||||
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({
|
||||
heartsRemaining: 5,
|
||||
heartsLastRestore: expect.any(Object),
|
||||
}));
|
||||
expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({
|
||||
itemId: 'heart_supply',
|
||||
direction: 'consume',
|
||||
quantityDelta: -1,
|
||||
idempotencyKey: 'use-item:use-heart-1:heart_supply',
|
||||
}));
|
||||
});
|
||||
|
||||
it('uses double XP potion and marks the effect active for 15 minutes', async () => {
|
||||
const insertValues = vi.fn();
|
||||
const updateSet = vi.fn();
|
||||
mockSelectQueue([
|
||||
[],
|
||||
[{ itemId: 'double_xp_potion', quantity: 1, activeUntil: null, metadata: null }],
|
||||
[{ id: 'inventory-xp' }],
|
||||
]);
|
||||
vi.mocked(db.insert).mockReturnValue(mockInsert(insertValues));
|
||||
vi.mocked(db.update).mockReturnValue(mockUpdate(updateSet));
|
||||
|
||||
const result = await useInventoryItem({
|
||||
userId: 'user-1',
|
||||
itemId: 'double_xp_potion',
|
||||
clientRequestId: 'use-xp-1',
|
||||
});
|
||||
|
||||
const activeUntilMs = Date.parse(result.effect.activeUntil ?? '');
|
||||
const expectedMs = Date.now() + 15 * 60 * 1000;
|
||||
expect(result.itemId).toBe('double_xp_potion');
|
||||
expect(result.quantityRemaining).toBe(0);
|
||||
expect(result.effect.type).toBe('double_xp');
|
||||
expect(result.effect.activeUntil).toEqual(expect.any(String));
|
||||
expect(Math.abs(activeUntilMs - expectedMs)).toBeLessThan(2_000);
|
||||
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({
|
||||
activeUntil: expect.any(Date),
|
||||
metadata: {
|
||||
activeEffect: 'double_xp',
|
||||
multiplier: 2,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it('uses hint feather and returns one distractor to exclude', async () => {
|
||||
const insertValues = vi.fn();
|
||||
const updateSet = vi.fn();
|
||||
mockSelectQueue([
|
||||
[{ distractors: ['错误项 A', '错误项 B'] }],
|
||||
[],
|
||||
[{ itemId: 'hint_feather', quantity: 3, activeUntil: null, metadata: null }],
|
||||
[{ id: 'inventory-hint' }],
|
||||
]);
|
||||
vi.mocked(db.insert).mockReturnValue(mockInsert(insertValues));
|
||||
vi.mocked(db.update).mockReturnValue(mockUpdate(updateSet));
|
||||
|
||||
const result = await useInventoryItem({
|
||||
userId: 'user-1',
|
||||
itemId: 'hint_feather',
|
||||
clientRequestId: 'use-hint-1',
|
||||
questionId: 'question-1',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
itemId: 'hint_feather',
|
||||
quantityRemaining: 2,
|
||||
effect: {
|
||||
type: 'hint',
|
||||
excludedOptions: ['错误项 A'],
|
||||
},
|
||||
});
|
||||
expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({
|
||||
itemId: 'hint_feather',
|
||||
direction: 'consume',
|
||||
snapshot: expect.objectContaining({
|
||||
questionId: 'question-1',
|
||||
excludedOptions: ['错误项 A'],
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('requires questionId when using hint feather', async () => {
|
||||
await expect(
|
||||
useInventoryItem({
|
||||
userId: 'user-1',
|
||||
itemId: 'hint_feather',
|
||||
clientRequestId: 'use-hint-1',
|
||||
}),
|
||||
).rejects.toThrow('questionId is required');
|
||||
});
|
||||
|
||||
it('uses streak shield and protects the current streak', async () => {
|
||||
const insertValues = vi.fn();
|
||||
const updateSet = vi.fn();
|
||||
mockSelectQueue([
|
||||
[],
|
||||
[{ itemId: 'streak_shield', quantity: 1, activeUntil: null, metadata: null }],
|
||||
[{ id: 'inventory-shield' }],
|
||||
[{ streakDays: 7 }],
|
||||
]);
|
||||
vi.mocked(db.insert).mockReturnValue(mockInsert(insertValues));
|
||||
vi.mocked(db.update).mockReturnValue(mockUpdate(updateSet));
|
||||
|
||||
const result = await useInventoryItem({
|
||||
userId: 'user-1',
|
||||
itemId: 'streak_shield',
|
||||
clientRequestId: 'use-shield-1',
|
||||
});
|
||||
|
||||
expect(result.itemId).toBe('streak_shield');
|
||||
expect(result.quantityRemaining).toBe(0);
|
||||
expect(result.effect.type).toBe('streak_protection');
|
||||
expect(result.effect.streakProtectedUntil).toEqual(expect.any(String));
|
||||
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({
|
||||
streakProtectedUntil: expect.any(Date),
|
||||
}));
|
||||
});
|
||||
});
|
||||
@ -1,246 +0,0 @@
|
||||
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 { addToWeeklyXp } from '../../../services/progress/xp-service.js';
|
||||
|
||||
// ── Mock 外部服务 ──────────────────────────────────────────────────
|
||||
|
||||
vi.mock('../../../services/gamification/coin-service.js', () => ({
|
||||
grantCoins: vi.fn().mockResolvedValue({ granted: true, balanceBefore: 0, balanceAfter: 300 }),
|
||||
}));
|
||||
|
||||
// ── DB Mock 辅助函数 ───────────────────────────────────────────────
|
||||
|
||||
/** 模拟 db.select() 链式调用,按调用顺序返回不同结果。 */
|
||||
function setupSelectQueue(queue: unknown[][]) {
|
||||
let index = 0;
|
||||
vi.mocked(db.select).mockImplementation((() => {
|
||||
const rows = index < queue.length ? queue[index]! : [];
|
||||
index += 1;
|
||||
const limit = vi.fn().mockResolvedValue(rows);
|
||||
const orderBy = vi.fn().mockReturnValue({ limit });
|
||||
const where = vi.fn().mockReturnValue({ limit, orderBy });
|
||||
const from = vi.fn().mockReturnValue({ where });
|
||||
return { from };
|
||||
}) as never);
|
||||
}
|
||||
|
||||
/** 模拟 db.insert().values() / .onDuplicateKeyUpdate() */
|
||||
function setupInsert() {
|
||||
const valuesSpy = vi.fn();
|
||||
const onDuplicateSpy = vi.fn().mockReturnValue(undefined);
|
||||
// 链式:insert().values().onDuplicateKeyUpdate()
|
||||
vi.mocked(db.insert).mockReturnValue({
|
||||
values: valuesSpy.mockReturnValue({ onDuplicateKeyUpdate: onDuplicateSpy }),
|
||||
} as never);
|
||||
return { valuesSpy, onDuplicateSpy };
|
||||
}
|
||||
|
||||
/** 模拟 db.update().set().where() */
|
||||
function setupUpdate() {
|
||||
const setSpy = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue({ affectedRows: 1 }) });
|
||||
vi.mocked(db.update).mockReturnValue({ set: setSpy } as never);
|
||||
return setSpy;
|
||||
}
|
||||
|
||||
// ── 测试 ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('leaderboard-service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── 周 XP 累加与分组 ───────────────────────────────────────────
|
||||
|
||||
describe('addToWeeklyXp', () => {
|
||||
it('首次获得本周 XP 时分配新分组', async () => {
|
||||
// 无已有记录 → 需要分配组
|
||||
setupSelectQueue([[]]); // 查已有记录为空
|
||||
// 查组人数为空 → 创建新组
|
||||
setupSelectQueue([[]]);
|
||||
const { valuesSpy } = setupInsert();
|
||||
|
||||
await addToWeeklyXp('user-1', 10);
|
||||
|
||||
// 验证插入了 userWeeklyXp 记录,包含 groupId
|
||||
expect(valuesSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 'user-1',
|
||||
xpEarned: 10,
|
||||
groupId: expect.stringMatching(/^week-\d{4}-\d{2}-\d{2}-group-1$/),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('加入已有未满组', async () => {
|
||||
// 已有记录为空 → 需分配组
|
||||
setupSelectQueue([[]]);
|
||||
// 组人数查询:group-1 有 25 人(< 30)
|
||||
setupSelectQueue([[{ groupId: 'week-2026-05-11-group-1', count: 25 }]]);
|
||||
const { valuesSpy } = setupInsert();
|
||||
|
||||
await addToWeeklyXp('user-2', 15);
|
||||
|
||||
expect(valuesSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 'user-2',
|
||||
groupId: 'week-2026-05-11-group-1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('已有本周记录时不重新分配组', async () => {
|
||||
// 已有记录,groupId 已存在
|
||||
setupSelectQueue([[{ groupId: 'week-2026-05-11-group-3' }]]);
|
||||
const { onDuplicateSpy } = setupInsert();
|
||||
|
||||
await addToWeeklyXp('user-1', 20);
|
||||
|
||||
// 应该走 onDuplicateKeyUpdate 而不是新建
|
||||
expect(onDuplicateSpy).toHaveBeenCalledWith({
|
||||
set: {
|
||||
xpEarned: expect.any(Object), // sql`COALESCE(xp_earned, 0) + 20`
|
||||
lastXpAt: expect.any(Object),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── 组内排名 ────────────────────────────────────────────────────
|
||||
|
||||
describe('getUserRank', () => {
|
||||
it('返回组内排名和本周 XP', async () => {
|
||||
// getUserRank: 第一次 select 获取用户信息,第二次 select 统计比自己高的人数
|
||||
let callIndex = 0;
|
||||
vi.mocked(db.select).mockImplementation((() => {
|
||||
callIndex += 1;
|
||||
if (callIndex === 1) {
|
||||
// 用户本周 XP 和 groupId
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ xpEarned: 150, groupId: 'group-A' }]),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
// 同组内比自己高的人数 = 2
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([{ count: 2 }]),
|
||||
}),
|
||||
};
|
||||
}) as never);
|
||||
|
||||
const result = await getUserRank('user-1');
|
||||
|
||||
expect(result).toEqual({
|
||||
rank: 3,
|
||||
tier: expect.any(String),
|
||||
weeklyXp: 150,
|
||||
});
|
||||
});
|
||||
|
||||
it('用户无本周记录时返回 null', async () => {
|
||||
setupSelectQueue([[]]);
|
||||
|
||||
const result = await getUserRank('user-unknown');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── 周结算 ──────────────────────────────────────────────────────
|
||||
|
||||
describe('weeklySettlement', () => {
|
||||
it('dryRun 模式只返回预览不写库', async () => {
|
||||
// 查询上一周所有记录
|
||||
setupSelectQueue([[
|
||||
{ userId: 'u1', weeklyXp: 300, groupId: 'g1' },
|
||||
{ userId: 'u2', weeklyXp: 200, groupId: 'g1' },
|
||||
{ userId: 'u3', weeklyXp: 100, groupId: 'g1' },
|
||||
]]);
|
||||
|
||||
const result = await weeklySettlement(true);
|
||||
|
||||
expect(result.settled).toBe(false);
|
||||
expect(result.userCount).toBe(3);
|
||||
expect(result.groupCount).toBe(1);
|
||||
expect(result.top3).toHaveLength(3);
|
||||
expect(result.top3[0]).toEqual({ userId: 'u1', weeklyXp: 300, rank: 1 });
|
||||
// 奖励预览:每组前 3 名
|
||||
expect(result.rewards).toHaveLength(3);
|
||||
expect(result.rewards[0]).toEqual({ userId: 'u1', groupId: 'g1', rank: 1, coins: 300 });
|
||||
expect(result.rewards[1]).toEqual({ userId: 'u2', groupId: 'g1', rank: 2, coins: 150 });
|
||||
expect(result.rewards[2]).toEqual({ userId: 'u3', groupId: 'g1', rank: 3, coins: 50 });
|
||||
// dryRun 不应写库
|
||||
expect(db.insert).not.toHaveBeenCalled();
|
||||
expect(db.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('正式结算写入快照并发放奖励', async () => {
|
||||
setupSelectQueue([[
|
||||
{ userId: 'u1', weeklyXp: 300, groupId: 'g1' },
|
||||
{ userId: 'u2', weeklyXp: 200, groupId: 'g1' },
|
||||
{ userId: 'u3', weeklyXp: 100, groupId: 'g1' },
|
||||
{ userId: 'u4', weeklyXp: 50, groupId: 'g1' },
|
||||
]]);
|
||||
setupInsert();
|
||||
setupUpdate();
|
||||
|
||||
const result = await weeklySettlement(false);
|
||||
|
||||
expect(result.settled).toBe(true);
|
||||
expect(result.groupCount).toBe(1);
|
||||
// 只有前 3 名有奖励,第 4 名没有
|
||||
expect(result.rewards).toHaveLength(3);
|
||||
// 验证快照插入被调用
|
||||
expect(db.insert).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('多组结算时各组独立奖励', async () => {
|
||||
setupSelectQueue([[
|
||||
// group-1(按 groupId 排序)
|
||||
{ userId: 'u1', weeklyXp: 300, groupId: 'group-1' },
|
||||
{ userId: 'u2', weeklyXp: 200, groupId: 'group-1' },
|
||||
// group-2
|
||||
{ userId: 'u3', weeklyXp: 250, groupId: 'group-2' },
|
||||
{ userId: 'u4', weeklyXp: 150, groupId: 'group-2' },
|
||||
]]);
|
||||
|
||||
const result = await weeklySettlement(true);
|
||||
|
||||
expect(result.groupCount).toBe(2);
|
||||
// 每组前 3 名,但每组只有 2 人,所以只有 rank 1 和 2 有奖励
|
||||
expect(result.rewards).toHaveLength(4);
|
||||
// group-1 的奖励
|
||||
const g1Rewards = result.rewards.filter(r => r.groupId === 'group-1');
|
||||
expect(g1Rewards[0]).toEqual({ userId: 'u1', groupId: 'group-1', rank: 1, coins: 300 });
|
||||
expect(g1Rewards[1]).toEqual({ userId: 'u2', groupId: 'group-1', rank: 2, coins: 150 });
|
||||
// group-2 的奖励
|
||||
const g2Rewards = result.rewards.filter(r => r.groupId === 'group-2');
|
||||
expect(g2Rewards[0]).toEqual({ userId: 'u3', groupId: 'group-2', rank: 1, coins: 300 });
|
||||
expect(g2Rewards[1]).toEqual({ userId: 'u4', groupId: 'group-2', rank: 2, coins: 150 });
|
||||
});
|
||||
});
|
||||
|
||||
// ── 周榜元信息 ──────────────────────────────────────────────────
|
||||
|
||||
describe('getLeaderboardMeta', () => {
|
||||
it('返回周信息和奖励预览', async () => {
|
||||
setupSelectQueue([[{ groupId: 'week-2026-05-11-group-1' }]]);
|
||||
|
||||
const meta = await getLeaderboardMeta('user-1');
|
||||
|
||||
expect(meta.weekStart).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(meta.weekEnd).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(meta.nextRefreshAt).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(meta.groupId).toBe('week-2026-05-11-group-1');
|
||||
expect(meta.rewardPreview).toEqual([
|
||||
{ rank: 1, coins: 300 },
|
||||
{ rank: 2, coins: 150 },
|
||||
{ rank: 3, coins: 50 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,543 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { db } from '../../../db/client.js';
|
||||
import { getChallengeCompletionRewards, getHighRewardQuota, getNextChallenge, submitChallengeAnswer } from '../../../services/learning/challenge-service.js';
|
||||
|
||||
const category = {
|
||||
id: 'history',
|
||||
name: '历史',
|
||||
slug: 'history',
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
questionCount: 5,
|
||||
status: 'active',
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
};
|
||||
|
||||
const chapter = {
|
||||
id: 'chapter-1',
|
||||
categoryId: 'history',
|
||||
title: '第一关',
|
||||
parentId: null,
|
||||
sortOrder: 1,
|
||||
questionsRequired: 5,
|
||||
passThreshold: 3,
|
||||
createdAt: null,
|
||||
};
|
||||
|
||||
const questions = Array.from({ length: 5 }, (_, index) => ({
|
||||
id: `question-${index + 1}`,
|
||||
stem: { text: `题目 ${index + 1}` },
|
||||
contentType: 'text',
|
||||
correctAnswer: `正确答案 ${index + 1}`,
|
||||
distractors: [`干扰项 ${index + 1}-1`, `干扰项 ${index + 1}-2`],
|
||||
categoryId: 'history',
|
||||
difficulty: 1,
|
||||
dynamicDifficulty: null,
|
||||
source: 'system',
|
||||
creatorId: null,
|
||||
status: 'published',
|
||||
stats: { timesAnswered: 0, correctRate: 0, avgTimeMs: 0 },
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Creates a mock chain for `select().from().where()` that resolves with `result`.
|
||||
* Supports `.orderBy()` and `.limit()` after `.where()`.
|
||||
*/
|
||||
function selectChain(result: unknown) {
|
||||
const whereChain = {
|
||||
orderBy: vi.fn().mockResolvedValue(result),
|
||||
limit: vi.fn().mockResolvedValue(result),
|
||||
then: (resolve: (value: unknown) => unknown) => Promise.resolve(result).then(resolve),
|
||||
};
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue(whereChain),
|
||||
orderBy: vi.fn().mockResolvedValue(result),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a select mock object that resolves through `.from().where().limit()`.
|
||||
*/
|
||||
function selectRows(rows: unknown[]) {
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue(rows),
|
||||
then: (resolve: (value: unknown) => unknown) => Promise.resolve(rows).then(resolve),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function mockInsert() {
|
||||
return { values: vi.fn().mockResolvedValue(undefined) } as never;
|
||||
}
|
||||
|
||||
function mockUpdate() {
|
||||
return { set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) } as never;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets db.select to consume rows from a queue in order.
|
||||
* Each call to db.select() returns a mock that resolves to the next queued rows.
|
||||
*/
|
||||
function mockSelectQueue(queue: unknown[][]) {
|
||||
let index = 0;
|
||||
vi.mocked(db.select).mockImplementation((() => {
|
||||
const rows = index < queue.length ? queue[index]! : [];
|
||||
index += 1;
|
||||
return selectRows(rows);
|
||||
}) as never);
|
||||
}
|
||||
|
||||
describe('challenge-service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getChallengeCompletionRewards', () => {
|
||||
it('adds the perfect bonus only when all questions are correct', () => {
|
||||
expect(getChallengeCompletionRewards(5, 5)).toEqual([
|
||||
{ type: 'xp', amount: 20, title: '完成挑战 +20 XP' },
|
||||
{ type: 'xp', amount: 30, title: '全对奖励 +30 XP' },
|
||||
]);
|
||||
expect(getChallengeCompletionRewards(4, 5)).toEqual([
|
||||
{ type: 'xp', amount: 20, title: '完成挑战 +20 XP' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('applies XP multiplier for degraded rewards', () => {
|
||||
expect(getChallengeCompletionRewards(5, 5, 0.5)).toEqual([
|
||||
{ type: 'xp', amount: 10, title: '完成挑战 +10 XP' },
|
||||
{ type: 'xp', amount: 15, title: '全对奖励 +15 XP' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHighRewardQuota', () => {
|
||||
it('returns full quota when no daily progress exists for free user', async () => {
|
||||
mockSelectQueue([[]]);
|
||||
const quota = await getHighRewardQuota('user-1', 'free');
|
||||
expect(quota).toEqual({ max: 3, used: 0, remaining: 3 });
|
||||
});
|
||||
|
||||
it('returns full quota when no daily progress exists for pro user', async () => {
|
||||
mockSelectQueue([[]]);
|
||||
const quota = await getHighRewardQuota('user-1', 'pro');
|
||||
expect(quota).toEqual({ max: 8, used: 0, remaining: 8 });
|
||||
});
|
||||
|
||||
it('returns correct remaining for free user with some used', async () => {
|
||||
mockSelectQueue([[{ used: 2, restored: 0 }]]);
|
||||
const quota = await getHighRewardQuota('user-1', 'free');
|
||||
expect(quota).toEqual({ max: 3, used: 2, remaining: 1 });
|
||||
});
|
||||
|
||||
it('returns zero remaining when quota exhausted', async () => {
|
||||
mockSelectQueue([[{ used: 3, restored: 0 }]]);
|
||||
const quota = await getHighRewardQuota('user-1', 'free');
|
||||
expect(quota).toEqual({ max: 3, used: 3, remaining: 0 });
|
||||
});
|
||||
|
||||
it('accounts for restored sessions', async () => {
|
||||
mockSelectQueue([[{ used: 3, restored: 1 }]]);
|
||||
const quota = await getHighRewardQuota('user-1', 'free');
|
||||
expect(quota).toEqual({ max: 3, used: 2, remaining: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNextChallenge', () => {
|
||||
it('creates a challenge session with five questions and hides correct answers', async () => {
|
||||
const insertedValues = vi.fn().mockResolvedValue([]);
|
||||
// getNextChallenge uses selectChain for some queries (with orderBy)
|
||||
vi.mocked(db.select)
|
||||
.mockReturnValueOnce(selectChain([category]) as never)
|
||||
.mockReturnValueOnce(selectChain([chapter]) as never)
|
||||
.mockReturnValueOnce(selectChain([]) as never)
|
||||
.mockReturnValueOnce(selectChain([]) as never)
|
||||
.mockReturnValueOnce(selectChain(questions) as never)
|
||||
.mockReturnValueOnce(selectRows([{ tier: 'free' }]) as never)
|
||||
.mockReturnValueOnce(selectRows([]) as never);
|
||||
vi.mocked(db.insert).mockReturnValue({ values: insertedValues } as never);
|
||||
|
||||
const result = await getNextChallenge('user-1', 'history');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.trackId).toBe('history');
|
||||
expect(result?.nodeId).toBe('chapter-1');
|
||||
expect(result?.questions).toHaveLength(5);
|
||||
expect(result?.highRewardEligible).toBe(true);
|
||||
expect(result?.questions.every((item) => item.challengeId === result.challengeId)).toBe(true);
|
||||
expect(result?.questions[0]?.question.options[0]).toEqual(expect.not.objectContaining({ isCorrect: expect.any(Boolean) }));
|
||||
expect(insertedValues).toHaveBeenCalledWith(expect.objectContaining({
|
||||
userId: 'user-1',
|
||||
trackId: 'history',
|
||||
categoryId: 'history',
|
||||
chapterId: 'chapter-1',
|
||||
status: 'pending',
|
||||
questionIds: questions.map((question) => question.id),
|
||||
totalQuestions: 5,
|
||||
highRewardEligible: 1,
|
||||
}));
|
||||
});
|
||||
|
||||
it('sets highRewardEligible to false when quota exhausted', async () => {
|
||||
const insertedValues = vi.fn().mockResolvedValue([]);
|
||||
vi.mocked(db.select)
|
||||
.mockReturnValueOnce(selectChain([category]) as never)
|
||||
.mockReturnValueOnce(selectChain([chapter]) as never)
|
||||
.mockReturnValueOnce(selectChain([]) as never)
|
||||
.mockReturnValueOnce(selectChain([]) as never)
|
||||
.mockReturnValueOnce(selectChain(questions) as never)
|
||||
.mockReturnValueOnce(selectRows([{ tier: 'free' }]) as never)
|
||||
.mockReturnValueOnce(selectRows([{ used: 3, restored: 0 }]) as never);
|
||||
vi.mocked(db.insert).mockReturnValue({ values: insertedValues } as never);
|
||||
|
||||
const result = await getNextChallenge('user-1', 'history');
|
||||
|
||||
expect(result?.highRewardEligible).toBe(false);
|
||||
expect(insertedValues).toHaveBeenCalledWith(expect.objectContaining({
|
||||
highRewardEligible: 0,
|
||||
}));
|
||||
});
|
||||
|
||||
it('uses plus quota (8) for pro users', async () => {
|
||||
const insertedValues = vi.fn().mockResolvedValue([]);
|
||||
vi.mocked(db.select)
|
||||
.mockReturnValueOnce(selectChain([category]) as never)
|
||||
.mockReturnValueOnce(selectChain([chapter]) as never)
|
||||
.mockReturnValueOnce(selectChain([]) as never)
|
||||
.mockReturnValueOnce(selectChain([]) as never)
|
||||
.mockReturnValueOnce(selectChain(questions) as never)
|
||||
.mockReturnValueOnce(selectRows([{ tier: 'pro' }]) as never)
|
||||
.mockReturnValueOnce(selectRows([{ used: 5, restored: 0 }]) as never);
|
||||
vi.mocked(db.insert).mockReturnValue({ values: insertedValues } as never);
|
||||
|
||||
const result = await getNextChallenge('user-1', 'history');
|
||||
|
||||
expect(result?.highRewardEligible).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('submitChallengeAnswer', () => {
|
||||
function makeSession(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'challenge-1',
|
||||
userId: 'user-1',
|
||||
status: 'in_progress',
|
||||
questionIds: ['q-1', 'q-2', 'q-3', 'q-4', 'q-5'],
|
||||
totalQuestions: 5,
|
||||
answeredCount: 0,
|
||||
correctCount: 0,
|
||||
highRewardEligible: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const testQuestion = {
|
||||
id: 'q-1',
|
||||
stem: { text: '测试题' },
|
||||
contentType: 'text',
|
||||
correctAnswer: '正确答案',
|
||||
distractors: ['干扰项1', '干扰项2'],
|
||||
categoryId: 'history',
|
||||
difficulty: 1,
|
||||
dynamicDifficulty: null,
|
||||
source: 'system',
|
||||
creatorId: null,
|
||||
status: 'published',
|
||||
stats: { timesAnswered: 0, correctRate: 0, avgTimeMs: 0 },
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
};
|
||||
|
||||
const knowledgeCardRow = {
|
||||
id: 'card-1',
|
||||
questionId: 'q-1',
|
||||
summary: '知识点摘要',
|
||||
deepDive: '深入解析',
|
||||
};
|
||||
|
||||
const freeUserRow = {
|
||||
id: 'user-1', tier: 'free', xpTotal: 100, activeTrackId: null,
|
||||
dailyAttemptsLeft: 5, dailyAttemptsDate: new Date().toISOString(),
|
||||
checkInDays: 1, lastCheckInDate: null, streakProtectedUntil: null,
|
||||
};
|
||||
|
||||
it('returns the stored result for duplicate question submissions without side effects', async () => {
|
||||
const resultSnapshot = {
|
||||
answerState: 'correct',
|
||||
correctOptionId: 'a',
|
||||
xpDelta: 10,
|
||||
progress: { hearts: 5, dailyAttemptsLeft: 3, xp: 120, streakDays: 2 },
|
||||
knowledgeCard: { id: 'card-1', title: '知识点', summary: '知识点', fact: '解析' },
|
||||
rewards: [{ type: 'xp', amount: 10, title: '+10 XP' }],
|
||||
};
|
||||
mockSelectQueue([
|
||||
[{ id: 'challenge-1', userId: 'user-1', status: 'pending', questionIds: ['question-1'] }],
|
||||
[{ id: 'answer-1', sessionId: 'challenge-1', questionId: 'question-1', submitRequestId: 'submit-1', resultSnapshot }],
|
||||
]);
|
||||
|
||||
const result = await submitChallengeAnswer('user-1', 'challenge-1', 'question-1', 'a', 1200, 0, 'submit-1');
|
||||
|
||||
expect(result).toEqual(resultSnapshot);
|
||||
expect(db.insert).not.toHaveBeenCalled();
|
||||
expect(db.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFoundError when session does not exist', async () => {
|
||||
mockSelectQueue([[]]);
|
||||
|
||||
await expect(
|
||||
submitChallengeAnswer('user-1', 'nonexistent', 'q-1', 'a', 1000),
|
||||
).rejects.toThrow('Challenge');
|
||||
});
|
||||
|
||||
it('throws ValidationError when session is already completed', async () => {
|
||||
mockSelectQueue([[makeSession({ status: 'completed' })]]);
|
||||
|
||||
await expect(
|
||||
submitChallengeAnswer('user-1', 'challenge-1', 'q-1', 'a', 1000),
|
||||
).rejects.toThrow('not accepting answers');
|
||||
});
|
||||
|
||||
it('throws ValidationError when question is not in session', async () => {
|
||||
mockSelectQueue([[makeSession()], []]);
|
||||
|
||||
await expect(
|
||||
submitChallengeAnswer('user-1', 'challenge-1', 'unknown-question', 'a', 1000),
|
||||
).rejects.toThrow('does not belong');
|
||||
});
|
||||
|
||||
it('awards XP for a correct answer', async () => {
|
||||
mockSelectQueue([
|
||||
[makeSession()], // session
|
||||
[], // no existing answer
|
||||
[testQuestion], // question
|
||||
[], // no previous correct answer for first knowledge card
|
||||
[knowledgeCardRow], // getKnowledgeCard
|
||||
[freeUserRow], // getResourceUser (getProgressSummary)
|
||||
[{ tier: 'free', heartsRemaining: 5, heartsLastRestore: null }], // getHearts
|
||||
[{ checkInDays: 2, lastCheckInDate: new Date().toISOString() }], // calculateStreak
|
||||
[], // getSubscriptionStatus
|
||||
[freeUserRow], // getDailyAttempts
|
||||
[{ used: 0, restored: 0 }], // getHighRewardQuota
|
||||
]);
|
||||
vi.mocked(db.insert).mockReturnValue(mockInsert());
|
||||
vi.mocked(db.update).mockReturnValue(mockUpdate());
|
||||
|
||||
const result = await submitChallengeAnswer('user-1', 'challenge-1', 'q-1', 'c', 1200, 0);
|
||||
|
||||
expect(result.answerState).toBe('correct');
|
||||
expect(result.correctOptionId).toBe('c');
|
||||
expect(result.xpDelta).toBe(25);
|
||||
expect(result.rewards).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: 'xp', source: 'correct_normal', amount: 10, title: '答对题目 +10 XP' }),
|
||||
expect.objectContaining({ type: 'xp', source: 'first_knowledge_card', amount: 15 }),
|
||||
]),
|
||||
);
|
||||
expect(result.knowledgeCard.id).toBe('card-1');
|
||||
});
|
||||
|
||||
it('deducts a heart for a wrong answer', async () => {
|
||||
const userAfter = { ...freeUserRow, dailyAttemptsLeft: 4 };
|
||||
mockSelectQueue([
|
||||
[makeSession()], // session
|
||||
[], // no existing answer
|
||||
[testQuestion], // question
|
||||
[{ tier: 'free', heartsRemaining: 3 }], // deductHeart: user
|
||||
[{ createdAt: new Date(Date.now() - 10 * 86_400_000).toISOString() }], // isNewUserProtected
|
||||
[freeUserRow], // deductDailyAttempt → getResourceUser
|
||||
[knowledgeCardRow], // getKnowledgeCard
|
||||
[userAfter], // getResourceUser (getProgressSummary)
|
||||
[{ tier: 'free', heartsRemaining: 2, heartsLastRestore: null }], // getHearts
|
||||
[{ checkInDays: 0, lastCheckInDate: null }], // calculateStreak
|
||||
[], // getSubscriptionStatus
|
||||
[userAfter], // getDailyAttempts
|
||||
[{ used: 0, restored: 0 }], // getHighRewardQuota
|
||||
]);
|
||||
vi.mocked(db.insert).mockReturnValue(mockInsert());
|
||||
vi.mocked(db.update).mockReturnValue(mockUpdate());
|
||||
|
||||
const result = await submitChallengeAnswer('user-1', 'challenge-1', 'q-1', 'b', 2000, 0);
|
||||
|
||||
expect(result.answerState).toBe('wrong');
|
||||
expect(result.xpDelta).toBe(0);
|
||||
expect(db.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws ValidationError when hearts are exhausted on wrong answer', async () => {
|
||||
mockSelectQueue([
|
||||
[makeSession()], // session
|
||||
[], // no existing answer
|
||||
[testQuestion], // question
|
||||
[{ tier: 'free', heartsRemaining: 0 }], // deductHeart: user
|
||||
[{ createdAt: new Date(Date.now() - 10 * 86_400_000).toISOString() }], // isNewUserProtected
|
||||
]);
|
||||
vi.mocked(db.insert).mockReturnValue(mockInsert());
|
||||
vi.mocked(db.update).mockReturnValue(mockUpdate());
|
||||
|
||||
await expect(
|
||||
submitChallengeAnswer('user-1', 'challenge-1', 'q-1', 'b', 2000, 0),
|
||||
).rejects.toThrow('红心已用完');
|
||||
});
|
||||
|
||||
it('does not block Plus users when hearts are depleted', async () => {
|
||||
const proUserRow = { ...freeUserRow, tier: 'pro', xpTotal: 200, dailyAttemptsLeft: 10 };
|
||||
const proUserAfter = { ...proUserRow, dailyAttemptsLeft: 9 };
|
||||
mockSelectQueue([
|
||||
[makeSession()], // session
|
||||
[], // no existing answer
|
||||
[testQuestion], // question
|
||||
[{ tier: 'pro', heartsRemaining: 99 }], // deductHeart: pro user
|
||||
[proUserRow], // deductDailyAttempt → getResourceUser
|
||||
[knowledgeCardRow], // getKnowledgeCard
|
||||
[proUserAfter], // getResourceUser (getProgressSummary)
|
||||
[{ tier: 'pro', heartsRemaining: 99, heartsLastRestore: null }], // getHearts
|
||||
[{ checkInDays: 0, lastCheckInDate: null }], // calculateStreak
|
||||
[], // getSubscriptionStatus
|
||||
[proUserAfter], // getDailyAttempts
|
||||
[{ used: 0, restored: 0 }], // getHighRewardQuota
|
||||
]);
|
||||
vi.mocked(db.insert).mockReturnValue(mockInsert());
|
||||
vi.mocked(db.update).mockReturnValue(mockUpdate());
|
||||
|
||||
const result = await submitChallengeAnswer('user-1', 'challenge-1', 'q-1', 'b', 2000, 0);
|
||||
|
||||
expect(result.answerState).toBe('wrong');
|
||||
expect(result.progress.hearts).toBe(99);
|
||||
});
|
||||
|
||||
it('triggers completion settlement on the last question', async () => {
|
||||
const userAfterXp = { ...freeUserRow, 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
|
||||
// settleCompletedChallenge → getProgressSummary (before)
|
||||
[freeUserRow], // getResourceUser
|
||||
[{ tier: 'free', heartsRemaining: 5, heartsLastRestore: null }], // getHearts
|
||||
[{ checkInDays: 2, lastCheckInDate: new Date().toISOString() }], // calculateStreak
|
||||
[], // getSubscriptionStatus
|
||||
[freeUserRow], // getDailyAttempts
|
||||
[{ used: 0, restored: 0 }], // getHighRewardQuota
|
||||
[], // no existing daily progress
|
||||
// updateChapterProgress
|
||||
[{ id: 'chapter-1', passThreshold: 3 }],
|
||||
[{ streakDays: 2, streakLastDate: new Date(Date.now() - 86_400_000).toISOString() }], // updateStreakForCompletedChallenge
|
||||
[], // no existing chapter progress
|
||||
[], // no existing streak milestone reward
|
||||
[], // no existing first daily challenge coin transaction
|
||||
[{ coinsBalance: 40 }], // current wallet balance
|
||||
[{ id: 'daily-1' }], // daily progress row for coin aggregation
|
||||
[knowledgeCardRow],
|
||||
// getProgressSummary (final)
|
||||
[userAfterXp],
|
||||
[{ tier: 'free', heartsRemaining: 5, heartsLastRestore: null }],
|
||||
[{ checkInDays: 2, lastCheckInDate: new Date().toISOString() }],
|
||||
[],
|
||||
[userAfterXp],
|
||||
[{ used: 1, restored: 0 }],
|
||||
]);
|
||||
vi.mocked(db.insert).mockReturnValue(mockInsert());
|
||||
vi.mocked(db.update).mockReturnValue(mockUpdate());
|
||||
|
||||
const result = await submitChallengeAnswer('user-1', 'challenge-1', 'q-5', 'c', 1500, 0);
|
||||
|
||||
expect(result.answerState).toBe('correct');
|
||||
// 10 XP (correct answer) + 15 (first knowledge card) + 20 (complete) + 30 (perfect) = 75
|
||||
expect(result.xpDelta).toBe(75);
|
||||
expect(result.rewards).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: 'xp', source: 'correct_normal', amount: 10, title: '答对题目 +10 XP' }),
|
||||
expect.objectContaining({ type: 'xp', source: 'first_knowledge_card', amount: 15 }),
|
||||
expect.objectContaining({ type: 'xp', amount: 20, title: '完成挑战 +20 XP' }),
|
||||
expect.objectContaining({ type: 'xp', amount: 30, title: '全对奖励 +30 XP' }),
|
||||
expect.objectContaining({ type: 'coin', source: 'first_daily_challenge', amount: 20, title: '每日首组挑战 +20 金币' }),
|
||||
expect.objectContaining({ type: 'chest', source: 'streak_milestone', milestoneDays: 3 }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('gives completion XP but no perfect bonus when not all correct', async () => {
|
||||
const userBefore = { ...freeUserRow, dailyAttemptsLeft: 4 };
|
||||
const userFinal = { ...freeUserRow, xpTotal: 120, dailyAttemptsLeft: 4 };
|
||||
mockSelectQueue([
|
||||
[makeSession({ answeredCount: 4, correctCount: 3 })], // session
|
||||
[], // no existing answer
|
||||
[testQuestion], // question
|
||||
// wrong answer path
|
||||
[{ tier: 'free', heartsRemaining: 3 }], // deductHeart
|
||||
[{ createdAt: new Date(Date.now() - 10 * 86_400_000).toISOString() }], // isNewUserProtected
|
||||
[freeUserRow], // deductDailyAttempt → getResourceUser
|
||||
// settleCompletedChallenge → getProgressSummary (before)
|
||||
[userBefore],
|
||||
[{ tier: 'free', heartsRemaining: 2, heartsLastRestore: null }],
|
||||
[{ checkInDays: 0, lastCheckInDate: null }],
|
||||
[],
|
||||
[userBefore],
|
||||
[{ used: 0, restored: 0 }],
|
||||
[], // updateDailyProgress
|
||||
// updateChapterProgress
|
||||
[{ id: 'chapter-1', passThreshold: 3 }],
|
||||
[{ streakDays: 0, streakLastDate: null }], // updateStreakForCompletedChallenge
|
||||
[], // no existing chapter progress
|
||||
[], // no existing first daily challenge coin transaction
|
||||
[{ coinsBalance: 20 }], // current wallet balance
|
||||
[{ id: 'daily-1' }], // daily progress row for coin aggregation
|
||||
[knowledgeCardRow],
|
||||
// getProgressSummary (final)
|
||||
[userFinal],
|
||||
[{ tier: 'free', heartsRemaining: 2, heartsLastRestore: null }],
|
||||
[{ checkInDays: 0, lastCheckInDate: null }],
|
||||
[],
|
||||
[userFinal],
|
||||
[{ used: 1, restored: 0 }],
|
||||
]);
|
||||
vi.mocked(db.insert).mockReturnValue(mockInsert());
|
||||
vi.mocked(db.update).mockReturnValue(mockUpdate());
|
||||
|
||||
const result = await submitChallengeAnswer('user-1', 'challenge-1', 'q-5', 'b', 2000, 0);
|
||||
|
||||
expect(result.answerState).toBe('wrong');
|
||||
expect(result.xpDelta).toBe(20);
|
||||
const rewardTitles = result.rewards.map((r) => r.title);
|
||||
expect(rewardTitles).toContain('完成挑战 +20 XP');
|
||||
expect(rewardTitles).toContain('每日首组挑战 +20 金币');
|
||||
expect(rewardTitles).not.toContain(expect.stringContaining('全对'));
|
||||
});
|
||||
|
||||
it('throws ValidationError for invalid selectedOptionId', async () => {
|
||||
mockSelectQueue([
|
||||
[makeSession()], // session
|
||||
[], // no existing answer
|
||||
[testQuestion], // question
|
||||
]);
|
||||
vi.mocked(db.insert).mockReturnValue(mockInsert());
|
||||
vi.mocked(db.update).mockReturnValue(mockUpdate());
|
||||
|
||||
await expect(
|
||||
submitChallengeAnswer('user-1', 'challenge-1', 'q-1', 'z', 1000, 0),
|
||||
).rejects.toThrow('Invalid selectedOptionId');
|
||||
});
|
||||
|
||||
it('throws NotFoundError when question does not exist in DB', async () => {
|
||||
mockSelectQueue([
|
||||
[makeSession()], // session
|
||||
[], // no existing answer
|
||||
[], // question not found
|
||||
]);
|
||||
|
||||
await expect(
|
||||
submitChallengeAnswer('user-1', 'challenge-1', 'q-1', 'a', 1000, 0),
|
||||
).rejects.toThrow('Question');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,27 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getDailyAttemptsMax,
|
||||
getLevelInfo,
|
||||
getNextHeartRestoreAt,
|
||||
shouldGrantDailyFirstVisitHeart,
|
||||
} from '../../../services/learning/progress-summary-service.js';
|
||||
import { getDailyAttemptsMax, getLevelInfo, getNextHeartRestoreAt } from '../../../services/learning/progress-summary-service.js';
|
||||
|
||||
describe('progress-summary-service', () => {
|
||||
it('calculates level and remaining XP using the non-linear 50-level curve', () => {
|
||||
// Level 1: 0 XP, need 100 more to reach level 2
|
||||
expect(getLevelInfo(0)).toEqual({ level: 1, xpToNextLevel: 100 });
|
||||
// Level 1: 50 XP, need 50 more
|
||||
expect(getLevelInfo(50)).toEqual({ level: 1, xpToNextLevel: 50 });
|
||||
// Level 2: exactly 100 XP (threshold for level 2)
|
||||
expect(getLevelInfo(100)).toEqual({ level: 2, xpToNextLevel: 120 });
|
||||
// Level 4: 500 XP (cumulative: 0,100,220,370,550 → level 4, 50 XP to next)
|
||||
expect(getLevelInfo(500)).toEqual({ level: 4, xpToNextLevel: 50 });
|
||||
// Max level (50): reached at 107_520 XP (cumulative[49])
|
||||
expect(getLevelInfo(107_520)).toEqual({ level: 50, xpToNextLevel: 0 });
|
||||
// Beyond max level: capped at 50
|
||||
expect(getLevelInfo(200_000)).toEqual({ level: 50, xpToNextLevel: 0 });
|
||||
// Negative XP: clamped to 0
|
||||
expect(getLevelInfo(-50)).toEqual({ level: 1, xpToNextLevel: 100 });
|
||||
it('calculates level and remaining XP from total XP', () => {
|
||||
expect(getLevelInfo(0)).toEqual({ level: 1, xpToNextLevel: 400 });
|
||||
expect(getLevelInfo(6680)).toEqual({ level: 17, xpToNextLevel: 120 });
|
||||
});
|
||||
|
||||
it('uses tier-specific daily attempt limits', () => {
|
||||
@ -34,58 +17,4 @@ describe('progress-summary-service', () => {
|
||||
expect(getNextHeartRestoreAt('2026-05-04T00:00:00.000Z', 4, 5)).toBe('2026-05-04T00:30:00.000Z');
|
||||
expect(getNextHeartRestoreAt('2026-05-04T00:00:00.000Z', 5, 5)).toBeNull();
|
||||
});
|
||||
|
||||
it('grants one daily first-visit heart only for free users below max before check-in', () => {
|
||||
expect(shouldGrantDailyFirstVisitHeart({
|
||||
id: 'user-1',
|
||||
tier: 'free',
|
||||
xpTotal: 0,
|
||||
activeTrackId: null,
|
||||
dailyAttemptsLeft: 5,
|
||||
dailyAttemptsDate: null,
|
||||
checkInDays: 0,
|
||||
lastCheckInDate: null,
|
||||
streakProtectedUntil: null,
|
||||
heartsRemaining: 4,
|
||||
})).toBe(true);
|
||||
|
||||
expect(shouldGrantDailyFirstVisitHeart({
|
||||
id: 'user-1',
|
||||
tier: 'free',
|
||||
xpTotal: 0,
|
||||
activeTrackId: null,
|
||||
dailyAttemptsLeft: 5,
|
||||
dailyAttemptsDate: null,
|
||||
checkInDays: 0,
|
||||
lastCheckInDate: null,
|
||||
streakProtectedUntil: null,
|
||||
heartsRemaining: 5,
|
||||
})).toBe(false);
|
||||
|
||||
expect(shouldGrantDailyFirstVisitHeart({
|
||||
id: 'user-1',
|
||||
tier: 'pro',
|
||||
xpTotal: 0,
|
||||
activeTrackId: null,
|
||||
dailyAttemptsLeft: 10,
|
||||
dailyAttemptsDate: null,
|
||||
checkInDays: 0,
|
||||
lastCheckInDate: null,
|
||||
streakProtectedUntil: null,
|
||||
heartsRemaining: 1,
|
||||
})).toBe(false);
|
||||
|
||||
expect(shouldGrantDailyFirstVisitHeart({
|
||||
id: 'user-1',
|
||||
tier: 'free',
|
||||
xpTotal: 0,
|
||||
activeTrackId: null,
|
||||
dailyAttemptsLeft: 5,
|
||||
dailyAttemptsDate: null,
|
||||
checkInDays: 0,
|
||||
lastCheckInDate: new Date().toISOString(),
|
||||
streakProtectedUntil: null,
|
||||
heartsRemaining: 1,
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,26 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { db } from '../../../db/client.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
function selectReturning(rows: unknown[]) {
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue(rows),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function updateReturning() {
|
||||
return { set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) };
|
||||
}
|
||||
|
||||
describe('hearts-service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('constants', () => {
|
||||
describe('Hearts service — constants', () => {
|
||||
it('MAX_FREE_HEARTS is 5', async () => {
|
||||
const { MAX_FREE_HEARTS } = await import('../../../services/progress/hearts-service.js');
|
||||
expect(MAX_FREE_HEARTS).toBe(5);
|
||||
@ -31,111 +11,3 @@ describe('hearts-service', () => {
|
||||
expect(PRO_HEARTS).toBe(99);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deductHeart', () => {
|
||||
it('deducts 1 heart for free-tier users with hearts > 1', async () => {
|
||||
vi.mocked(db.select)
|
||||
// deductHeart: tier + heartsRemaining
|
||||
.mockReturnValueOnce(selectReturning([{ tier: 'free', heartsRemaining: 3 }]) as never)
|
||||
// isNewUserProtected: createdAt (4 days ago → not protected)
|
||||
.mockReturnValueOnce(selectReturning([{ createdAt: new Date(Date.now() - 4 * 86_400_000).toISOString() }]) as never);
|
||||
vi.mocked(db.update).mockReturnValue(updateReturning() as never);
|
||||
|
||||
const { deductHeart } = await import('../../../services/progress/hearts-service.js');
|
||||
const result = await deductHeart('user-1');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.remaining).toBe(2);
|
||||
});
|
||||
|
||||
it('deducts to 0 for old free-tier users', async () => {
|
||||
vi.mocked(db.select)
|
||||
.mockReturnValueOnce(selectReturning([{ tier: 'free', heartsRemaining: 1 }]) as never)
|
||||
.mockReturnValueOnce(selectReturning([{ createdAt: new Date(Date.now() - 10 * 86_400_000).toISOString() }]) as never);
|
||||
vi.mocked(db.update).mockReturnValue(updateReturning() as never);
|
||||
|
||||
const { deductHeart } = await import('../../../services/progress/hearts-service.js');
|
||||
const result = await deductHeart('user-1');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.remaining).toBe(0);
|
||||
});
|
||||
|
||||
it('returns failure when hearts = 0 for old free-tier users', async () => {
|
||||
vi.mocked(db.select)
|
||||
.mockReturnValueOnce(selectReturning([{ tier: 'free', heartsRemaining: 0 }]) as never)
|
||||
.mockReturnValueOnce(selectReturning([{ createdAt: new Date(Date.now() - 10 * 86_400_000).toISOString() }]) as never);
|
||||
|
||||
const { deductHeart } = await import('../../../services/progress/hearts-service.js');
|
||||
const result = await deductHeart('user-1');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.remaining).toBe(0);
|
||||
});
|
||||
|
||||
it('does not deduct for Pro users', async () => {
|
||||
vi.mocked(db.select).mockReturnValueOnce(
|
||||
selectReturning([{ tier: 'pro', heartsRemaining: 99 }]) as never,
|
||||
);
|
||||
|
||||
const { deductHeart } = await import('../../../services/progress/hearts-service.js');
|
||||
const result = await deductHeart('user-1');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.remaining).toBe(99);
|
||||
expect(db.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not deduct for ProPlus users', async () => {
|
||||
vi.mocked(db.select).mockReturnValueOnce(
|
||||
selectReturning([{ tier: 'proplus', heartsRemaining: 99 }]) as never,
|
||||
);
|
||||
|
||||
const { deductHeart } = await import('../../../services/progress/hearts-service.js');
|
||||
const result = await deductHeart('user-1');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.remaining).toBe(99);
|
||||
expect(db.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('protects new users (≤3 days) with minimum 1 heart', async () => {
|
||||
vi.mocked(db.select)
|
||||
// deductHeart: user has 1 heart
|
||||
.mockReturnValueOnce(selectReturning([{ tier: 'free', heartsRemaining: 1 }]) as never)
|
||||
// isNewUserProtected: created 1 day ago
|
||||
.mockReturnValueOnce(selectReturning([{ createdAt: new Date(Date.now() - 1 * 86_400_000).toISOString() }]) as never);
|
||||
|
||||
const { deductHeart } = await import('../../../services/progress/hearts-service.js');
|
||||
const result = await deductHeart('user-1');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.remaining).toBe(1);
|
||||
});
|
||||
|
||||
it('allows deduction from 2→1 for new users (≤3 days)', async () => {
|
||||
vi.mocked(db.select)
|
||||
.mockReturnValueOnce(selectReturning([{ tier: 'free', heartsRemaining: 2 }]) as never)
|
||||
.mockReturnValueOnce(selectReturning([{ createdAt: new Date(Date.now() - 2 * 86_400_000).toISOString() }]) as never);
|
||||
vi.mocked(db.update).mockReturnValue(updateReturning() as never);
|
||||
|
||||
const { deductHeart } = await import('../../../services/progress/hearts-service.js');
|
||||
const result = await deductHeart('user-1');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.remaining).toBe(1);
|
||||
});
|
||||
|
||||
it('returns failure for non-existent user', async () => {
|
||||
vi.mocked(db.select).mockReturnValueOnce(
|
||||
selectReturning([]) as never,
|
||||
);
|
||||
|
||||
const { deductHeart } = await import('../../../services/progress/hearts-service.js');
|
||||
const result = await deductHeart('nonexistent');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.remaining).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,20 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { db } from '../../../db/client.js';
|
||||
import {
|
||||
freezeStreak,
|
||||
getStreakMilestoneReward,
|
||||
grantStreakMilestoneReward,
|
||||
updateStreakForCompletedChallenge,
|
||||
} from '../../../services/progress/streak-service.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
// Test the pure logic of date comparison
|
||||
// The DB-dependent functions are tested via integration tests
|
||||
|
||||
describe('Streak service — date logic', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('todayUtc returns YYYY-MM-DD format', () => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
expect(today).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
@ -31,135 +20,3 @@ describe('Streak service — date logic', () => {
|
||||
expect(todayStr).not.toBe(yesterdayStr);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Streak service — completed challenge updates', () => {
|
||||
function selectQueue(queue: unknown[][]) {
|
||||
let index = 0;
|
||||
vi.mocked(db.select).mockImplementation((() => {
|
||||
const rows = queue[index] ?? [];
|
||||
index += 1;
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue(rows),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}) as never);
|
||||
}
|
||||
|
||||
function selectUser(rows: unknown[]) {
|
||||
selectQueue([rows]);
|
||||
}
|
||||
|
||||
function selectRows(rows: unknown[]) {
|
||||
vi.mocked(db.select).mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue(rows),
|
||||
}),
|
||||
}),
|
||||
} as never);
|
||||
}
|
||||
|
||||
function mockUpdate() {
|
||||
const where = vi.fn().mockResolvedValue(undefined);
|
||||
const set = vi.fn().mockReturnValue({ where });
|
||||
vi.mocked(db.update).mockReturnValue({ set } as never);
|
||||
return { set, where };
|
||||
}
|
||||
|
||||
function mockInsert() {
|
||||
const values = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(db.insert).mockReturnValue({ values } as never);
|
||||
return { values };
|
||||
}
|
||||
|
||||
it('increments streak after completing the first challenge session of a consecutive day', async () => {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
selectQueue([
|
||||
[{ streakDays: 2, streakLastDate: yesterday.toISOString() }],
|
||||
[],
|
||||
]);
|
||||
const update = mockUpdate();
|
||||
const insert = mockInsert();
|
||||
|
||||
const result = await updateStreakForCompletedChallenge('user-1');
|
||||
|
||||
expect(result.days).toBe(3);
|
||||
expect(result.lastDate).toBe(new Date().toISOString().slice(0, 10));
|
||||
expect(result.rewards).toEqual([
|
||||
expect.objectContaining({ type: 'chest', source: 'streak_milestone', milestoneDays: 3 }),
|
||||
]);
|
||||
expect(update.set).toHaveBeenCalled();
|
||||
expect(insert.values).toHaveBeenCalledWith(expect.objectContaining({
|
||||
sourceType: 'streak_milestone',
|
||||
sourceId: '3',
|
||||
idempotencyKey: 'streak_milestone:3',
|
||||
status: 'completed',
|
||||
}));
|
||||
});
|
||||
|
||||
it('does not increment more than once on the same day', async () => {
|
||||
selectUser([{ streakDays: 4, streakLastDate: new Date().toISOString() }]);
|
||||
mockUpdate();
|
||||
|
||||
const result = await updateStreakForCompletedChallenge('user-1');
|
||||
|
||||
expect(result.days).toBe(4);
|
||||
expect(db.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts a new streak when the previous completion was not yesterday', async () => {
|
||||
const oldDate = new Date();
|
||||
oldDate.setDate(oldDate.getDate() - 3);
|
||||
selectUser([{ streakDays: 8, streakLastDate: oldDate.toISOString() }]);
|
||||
const update = mockUpdate();
|
||||
|
||||
const result = await updateStreakForCompletedChallenge('user-1');
|
||||
|
||||
expect(result.days).toBe(1);
|
||||
expect(update.set).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns milestone reward definitions for configured days', () => {
|
||||
expect(getStreakMilestoneReward(7)).toEqual({
|
||||
type: 'item',
|
||||
source: 'streak_milestone',
|
||||
milestoneDays: 7,
|
||||
itemId: 'streak_shield',
|
||||
quantity: 1,
|
||||
title: '连续学习 7 天连胜护盾 x1',
|
||||
});
|
||||
expect(getStreakMilestoneReward(8)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not grant duplicate milestone rewards', async () => {
|
||||
selectRows([{ id: 'ledger-1' }]);
|
||||
mockInsert();
|
||||
|
||||
const rewards = await grantStreakMilestoneReward('user-1', 7);
|
||||
|
||||
expect(rewards).toEqual([]);
|
||||
expect(db.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('freezes streak protection for today without incrementing streak days', async () => {
|
||||
selectQueue([
|
||||
[{ streakDays: 12 }],
|
||||
]);
|
||||
const update = mockUpdate();
|
||||
|
||||
const result = await freezeStreak('user-1');
|
||||
|
||||
expect(result).toEqual({
|
||||
days: 12,
|
||||
lastDate: new Date().toISOString().slice(0, 10),
|
||||
frozen: true,
|
||||
});
|
||||
expect(update.set).toHaveBeenCalledWith(expect.objectContaining({
|
||||
streakLastDate: expect.any(Object),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,13 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
calculateXp,
|
||||
createCorrectAnswerXpReward,
|
||||
createCorrectAnswerXpRewards,
|
||||
createXpReward,
|
||||
getComboBonusXp,
|
||||
getQuestionXpSource,
|
||||
getXpRewardAmount,
|
||||
} from '../../../services/progress/xp-service.js';
|
||||
import { calculateXp } from '../../../services/progress/xp-service.js';
|
||||
|
||||
describe('XP service', () => {
|
||||
describe('calculateXp', () => {
|
||||
@ -18,21 +10,18 @@ describe('XP service', () => {
|
||||
});
|
||||
|
||||
it('adds +5 bonus at 3-combo', () => {
|
||||
expect(getComboBonusXp(3)).toBe(5);
|
||||
expect(calculateXp(10, 3)).toBe(15);
|
||||
expect(calculateXp(10, 4)).toBe(15);
|
||||
});
|
||||
|
||||
it('adds +10 bonus at 5-combo', () => {
|
||||
expect(getComboBonusXp(5)).toBe(10);
|
||||
expect(calculateXp(10, 5)).toBe(20);
|
||||
expect(calculateXp(10, 7)).toBe(20);
|
||||
});
|
||||
|
||||
it('adds +25 bonus at 10-combo', () => {
|
||||
expect(getComboBonusXp(10)).toBe(25);
|
||||
expect(calculateXp(10, 10)).toBe(35);
|
||||
expect(calculateXp(10, 20)).toBe(35);
|
||||
it('adds +20 bonus at 10-combo', () => {
|
||||
expect(calculateXp(10, 10)).toBe(30);
|
||||
expect(calculateXp(10, 20)).toBe(30);
|
||||
});
|
||||
|
||||
it('works with different base XP values', () => {
|
||||
@ -40,62 +29,4 @@ describe('XP service', () => {
|
||||
expect(calculateXp(20, 5)).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('XP reward sources', () => {
|
||||
it('maps all first-version XP sources to rule amounts', () => {
|
||||
expect(getXpRewardAmount('correct_normal')).toBe(10);
|
||||
expect(getXpRewardAmount('correct_hard')).toBe(15);
|
||||
expect(getXpRewardAmount('combo_bonus', 25)).toBe(25);
|
||||
expect(getXpRewardAmount('review_explanation')).toBe(3);
|
||||
expect(getXpRewardAmount('complete_challenge')).toBe(20);
|
||||
expect(getXpRewardAmount('perfect_challenge')).toBe(30);
|
||||
expect(getXpRewardAmount('first_knowledge_card')).toBe(15);
|
||||
expect(getXpRewardAmount('daily_task', 45)).toBe(45);
|
||||
expect(getXpRewardAmount('theme_node', 100)).toBe(100);
|
||||
});
|
||||
|
||||
it('clamps configurable daily task and theme node rewards', () => {
|
||||
expect(getXpRewardAmount('daily_task', 10)).toBe(30);
|
||||
expect(getXpRewardAmount('daily_task', 90)).toBe(60);
|
||||
expect(getXpRewardAmount('theme_node', 20)).toBe(80);
|
||||
expect(getXpRewardAmount('theme_node', 160)).toBe(120);
|
||||
});
|
||||
|
||||
it('builds displayable rewards for answer difficulty and combo', () => {
|
||||
expect(getQuestionXpSource(1)).toBe('correct_normal');
|
||||
expect(getQuestionXpSource(3)).toBe('correct_hard');
|
||||
expect(createCorrectAnswerXpReward(3, 10)).toEqual({
|
||||
type: 'xp',
|
||||
source: 'correct_hard',
|
||||
amount: 40,
|
||||
title: '+40 XP',
|
||||
});
|
||||
expect(createCorrectAnswerXpRewards(3, 10)).toEqual([
|
||||
{
|
||||
type: 'xp',
|
||||
source: 'correct_hard',
|
||||
amount: 15,
|
||||
title: '答对困难题 +15 XP',
|
||||
},
|
||||
{
|
||||
type: 'xp',
|
||||
source: 'combo_bonus',
|
||||
amount: 25,
|
||||
title: '10 连对 +25 XP',
|
||||
},
|
||||
]);
|
||||
expect(createXpReward('first_knowledge_card')).toEqual({
|
||||
type: 'xp',
|
||||
source: 'first_knowledge_card',
|
||||
amount: 15,
|
||||
title: '首次知识卡 +15 XP',
|
||||
});
|
||||
expect(createXpReward('review_explanation')).toEqual({
|
||||
type: 'xp',
|
||||
source: 'review_explanation',
|
||||
amount: 3,
|
||||
title: '查看解析 +3 XP',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,301 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { db } from '../../../db/client.js';
|
||||
import { getProgressSummary } from '../../../services/learning/progress-summary-service.js';
|
||||
import { getSubscriptionStatus } from '../../../services/payment/subscription-service.js';
|
||||
import {
|
||||
completeAdRecoverySession,
|
||||
createAdRecoverySession,
|
||||
} from '../../../services/rewards/ad-recovery-service.js';
|
||||
import type { ProgressSummaryDto } from '../../../types/app-api.js';
|
||||
|
||||
// ── Mock 外部服务 ──────────────────────────────────────────────────
|
||||
|
||||
const mockProgress: ProgressSummaryDto = {
|
||||
hearts: 2,
|
||||
maxHearts: 5,
|
||||
nextHeartRestoreAt: null,
|
||||
dailyAttemptsLeft: 1,
|
||||
dailyAttemptsMax: 3,
|
||||
nextAttemptResetAt: null,
|
||||
highRewardSessionsLeft: 1,
|
||||
highRewardSessionsMax: 3,
|
||||
xp: 100,
|
||||
level: 3,
|
||||
xpToNextLevel: 50,
|
||||
streakDays: 5,
|
||||
checkInDays: 5,
|
||||
streakProtectedUntil: null,
|
||||
activeTrackId: null,
|
||||
isSubscribed: false,
|
||||
};
|
||||
|
||||
const mockFullProgress: ProgressSummaryDto = {
|
||||
...mockProgress,
|
||||
hearts: 5,
|
||||
dailyAttemptsLeft: 3,
|
||||
};
|
||||
|
||||
vi.mock('../../../services/learning/progress-summary-service.js', () => ({
|
||||
getProgressSummary: vi.fn(),
|
||||
getDailyAttempts: vi.fn().mockResolvedValue({ left: 1, max: 3 }),
|
||||
}));
|
||||
|
||||
vi.mock('../../../services/payment/subscription-service.js', () => ({
|
||||
getSubscriptionStatus: vi.fn().mockResolvedValue({ status: 'inactive', tier: 'free', expiresAt: null, autoRenew: false }),
|
||||
}));
|
||||
|
||||
vi.mock('../../../services/progress/streak-service.js', () => ({
|
||||
freezeStreak: vi.fn().mockResolvedValue({ streakDays: 5, checkInDays: 5, streakProtectedUntil: null }),
|
||||
}));
|
||||
|
||||
// ── DB Mock 辅助函数 ───────────────────────────────────────────────
|
||||
|
||||
/** 模拟 db.select().from().where().limit().orderBy() 的链式调用,按调用顺序返回不同结果。 */
|
||||
function setupSelectQueue(queue: unknown[][]) {
|
||||
let index = 0;
|
||||
vi.mocked(db.select).mockImplementation((() => {
|
||||
const rows = index < queue.length ? queue[index]! : [];
|
||||
index += 1;
|
||||
const limit = vi.fn().mockResolvedValue(rows);
|
||||
const orderBy = vi.fn().mockReturnValue({ limit });
|
||||
const gte = vi.fn().mockReturnValue({ lt: vi.fn().mockReturnValue({ orderBy }) });
|
||||
const where = vi.fn().mockReturnValue({ limit, orderBy, gte });
|
||||
const from = vi.fn().mockReturnValue({ where });
|
||||
return { from };
|
||||
}) as never);
|
||||
}
|
||||
|
||||
/** 模拟 db.insert().values(),返回 values spy。 */
|
||||
function setupInsert() {
|
||||
const valuesSpy = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(db.insert).mockReturnValue({ values: valuesSpy } as never);
|
||||
return valuesSpy;
|
||||
}
|
||||
|
||||
/** 模拟 db.update().set().where(),返回 set spy。 */
|
||||
function setupUpdate() {
|
||||
const setSpy = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue({ affectedRows: 1 }) });
|
||||
vi.mocked(db.update).mockReturnValue({ set: setSpy } as never);
|
||||
return setSpy;
|
||||
}
|
||||
|
||||
// ── 测试 ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('ad-recovery-service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// 默认返回免费用户进度
|
||||
vi.mocked(getProgressSummary).mockResolvedValue(mockProgress);
|
||||
});
|
||||
|
||||
// ── 创建 Session ────────────────────────────────────────────────
|
||||
|
||||
describe('createAdRecoverySession', () => {
|
||||
const baseInput = {
|
||||
type: 'hearts' as const,
|
||||
clientRequestId: 'req-1',
|
||||
platform: 'harmony' as const,
|
||||
adProvider: 'mock',
|
||||
};
|
||||
|
||||
it('为免费用户创建爱心恢复 session', async () => {
|
||||
// 无已存在的重复请求、无今日恢复记录、免费用户
|
||||
setupSelectQueue([[], []]);
|
||||
setupInsert();
|
||||
|
||||
const result = await createAdRecoverySession('user-1', baseInput);
|
||||
|
||||
expect(result.eligible).toBe(true);
|
||||
expect(result.sessionId).toBeTruthy();
|
||||
expect(result.type).toBe('hearts');
|
||||
expect(result.remainingToday).toBe(2); // 3 - 1 = 2
|
||||
});
|
||||
|
||||
it('Plus 用户被拦截并返回订阅权益摘要', async () => {
|
||||
vi.mocked(getSubscriptionStatus)
|
||||
.mockResolvedValue({ status: 'active', tier: 'pro', expiresAt: '2026-12-31', autoRenew: true });
|
||||
|
||||
// 无重复请求 + 免费用户 select(tier 查询)
|
||||
setupSelectQueue([[], []]);
|
||||
|
||||
const result = await createAdRecoverySession('user-1', baseInput);
|
||||
|
||||
expect(result.eligible).toBe(false);
|
||||
expect(result.reason).toBe('already_subscribed');
|
||||
expect(result.subscriptionBenefits).toBeDefined();
|
||||
expect(result.subscriptionBenefits!.unlimitedHearts).toBe(true);
|
||||
expect(result.sessionId).toBeNull();
|
||||
});
|
||||
|
||||
it('每日上限耗尽时拒绝创建', async () => {
|
||||
// 无重复请求,但今日已有 3 次恢复
|
||||
setupSelectQueue([[], [{ id: 's1' }, { id: 's2' }, { id: 's3' }]]);
|
||||
|
||||
const result = await createAdRecoverySession('user-1', baseInput);
|
||||
|
||||
expect(result.eligible).toBe(false);
|
||||
expect(result.reason).toBe('daily_limit_reached');
|
||||
});
|
||||
|
||||
it('相同 clientRequestId 幂等返回已有 session', async () => {
|
||||
const existingSession = {
|
||||
id: 'session-existing',
|
||||
type: 'hearts',
|
||||
status: 'pending',
|
||||
adPlacementId: 'duoqi_restore_hearts_harmony',
|
||||
expiresAt: new Date(Date.now() + 30 * 60 * 1000),
|
||||
clientRequestId: 'req-1',
|
||||
};
|
||||
setupSelectQueue([[existingSession]]);
|
||||
setupUpdate(); // 用于 duplicateCount 自增
|
||||
|
||||
const result = await createAdRecoverySession('user-1', baseInput);
|
||||
|
||||
expect(result.eligible).toBe(true);
|
||||
expect(result.sessionId).toBe('session-existing');
|
||||
// 不应创建新的 session
|
||||
expect(db.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── 完成 Session ────────────────────────────────────────────────
|
||||
|
||||
describe('completeAdRecoverySession', () => {
|
||||
const now = new Date();
|
||||
const validSession = {
|
||||
id: 'session-1',
|
||||
userId: 'user-1',
|
||||
type: 'hearts',
|
||||
status: 'pending',
|
||||
clientRequestId: 'req-1',
|
||||
adProvider: 'mock',
|
||||
expiresAt: new Date(now.getTime() + 30 * 60 * 1000),
|
||||
};
|
||||
|
||||
const baseCompleteInput = {
|
||||
sessionId: 'session-1',
|
||||
clientRequestId: 'req-1',
|
||||
adProvider: 'mock',
|
||||
providerRewardToken: 'token-abc',
|
||||
completedAt: now.toISOString(),
|
||||
};
|
||||
|
||||
it('正常完成爱心恢复并写入 rewardLedger', async () => {
|
||||
// getSession → validSession, getProgressSummary → mockProgress,
|
||||
// checkEligibility 内部: getUserTier + getSubscriptionStatus + getProgressSummary + getLimits(completedCountToday)
|
||||
setupSelectQueue([
|
||||
[validSession], // getSession
|
||||
[], // rewardLedger 幂等检查(无已有记录)
|
||||
[], // getUserTier(免费用户)
|
||||
[], // completedCountToday hearts = 0
|
||||
]);
|
||||
setupUpdate(); // update users + update session
|
||||
const insertSpy = setupInsert();
|
||||
|
||||
const result = await completeAdRecoverySession('user-1', baseCompleteInput);
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(result.type).toBe('hearts');
|
||||
expect(result.reward!.heartsDelta).toBeGreaterThan(0);
|
||||
|
||||
// 验证 rewardLedger 写入了正确的 sourceType 和幂等 key
|
||||
expect(insertSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceType: 'ad_recovery',
|
||||
sourceId: 'session-1',
|
||||
idempotencyKey: 'ad_recovery:session-1',
|
||||
status: 'completed',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('会话不存在时返回失败', async () => {
|
||||
setupSelectQueue([[]]); // getSession → 空
|
||||
|
||||
const result = await completeAdRecoverySession('user-1', baseCompleteInput);
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.reason).toBe('invalid_type');
|
||||
});
|
||||
|
||||
it('会话过期时标记为 expired', async () => {
|
||||
const expiredSession = {
|
||||
...validSession,
|
||||
expiresAt: new Date(now.getTime() - 60 * 1000), // 已过期
|
||||
};
|
||||
setupSelectQueue([[expiredSession]]);
|
||||
setupUpdate();
|
||||
|
||||
const result = await completeAdRecoverySession('user-1', baseCompleteInput);
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.reason).toBe('session_expired');
|
||||
});
|
||||
|
||||
it('缺少 providerRewardToken 时(非信任 provider)拒绝完成', async () => {
|
||||
setupSelectQueue([[validSession]]);
|
||||
setupUpdate();
|
||||
|
||||
const result = await completeAdRecoverySession('user-1', {
|
||||
...baseCompleteInput,
|
||||
adProvider: 'unknown_provider',
|
||||
providerRewardToken: undefined,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.reason).toBe('provider_verification_failed');
|
||||
});
|
||||
|
||||
it('信任的测试 provider 无需 token 即可通过', async () => {
|
||||
// mock provider 是 'mock',属于 TRUSTED_TEST_PROVIDERS
|
||||
setupSelectQueue([
|
||||
[validSession],
|
||||
[], // rewardLedger
|
||||
[], // getUserTier
|
||||
[], // completedCountToday
|
||||
]);
|
||||
setupUpdate();
|
||||
setupInsert();
|
||||
|
||||
const result = await completeAdRecoverySession('user-1', {
|
||||
...baseCompleteInput,
|
||||
providerRewardToken: undefined,
|
||||
// adProvider 已经是 'mock'
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('已完成的会话幂等返回之前的结果', async () => {
|
||||
const completedSession = {
|
||||
...validSession,
|
||||
status: 'completed',
|
||||
rewardSnapshot: { heartsDelta: 3, dailyAttemptsDelta: 0, streakProtectionGranted: false },
|
||||
progressAfter: mockFullProgress,
|
||||
};
|
||||
setupSelectQueue([[completedSession]]);
|
||||
setupUpdate(); // duplicateCount 自增
|
||||
|
||||
const result = await completeAdRecoverySession('user-1', baseCompleteInput);
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(result.reward!.heartsDelta).toBe(3);
|
||||
// 不应写入新的 rewardLedger
|
||||
expect(db.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rewardLedger 幂等 key 命中时不重复写入流水', async () => {
|
||||
setupSelectQueue([
|
||||
[validSession],
|
||||
[{ id: 'ledger-existing' }], // rewardLedger 已有记录
|
||||
]);
|
||||
setupUpdate();
|
||||
|
||||
const result = await completeAdRecoverySession('user-1', baseCompleteInput);
|
||||
|
||||
// 幂等返回成功,但不重新写入流水
|
||||
expect(result.status).toBe('completed');
|
||||
expect(db.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,39 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { db } from '../../../db/client.js';
|
||||
import { getShopBenefits, getShopCatalog, purchaseShopProduct } from '../../../services/shop/shop-service.js';
|
||||
|
||||
function selectRows(rows: unknown[]) {
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue(rows),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function mockSelectQueue(queue: unknown[][]) {
|
||||
let index = 0;
|
||||
vi.mocked(db.select).mockImplementation((() => {
|
||||
const rows = index < queue.length ? queue[index]! : [];
|
||||
index += 1;
|
||||
return selectRows(rows);
|
||||
}) as never);
|
||||
}
|
||||
|
||||
function mockInsert(valuesSpy: ReturnType<typeof vi.fn>) {
|
||||
return { values: valuesSpy.mockResolvedValue(undefined) } as never;
|
||||
}
|
||||
|
||||
function mockUpdate(setSpy: ReturnType<typeof vi.fn>) {
|
||||
return { set: setSpy.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) } as never;
|
||||
}
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getShopBenefits } from '../../../services/shop/shop-service.js';
|
||||
|
||||
describe('shop-service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns the client shop benefit catalog', async () => {
|
||||
const benefits = await getShopBenefits();
|
||||
|
||||
@ -45,98 +13,4 @@ describe('shop-service', () => {
|
||||
]);
|
||||
expect(benefits.every((item) => item.enabled)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns shop products with configured prices', async () => {
|
||||
const catalog = await getShopCatalog();
|
||||
|
||||
expect(catalog.benefits).toHaveLength(4);
|
||||
expect(catalog.products.map((item) => [item.id, item.priceCoins])).toEqual([
|
||||
['hint-feather', 80],
|
||||
['heart-supply', 150],
|
||||
['double-xp-potion', 250],
|
||||
['streak-shield', 400],
|
||||
['mascot-outfit-starter', 800],
|
||||
]);
|
||||
});
|
||||
|
||||
it('purchases a product by spending coins and granting inventory', async () => {
|
||||
const insertValues = vi.fn();
|
||||
const updateSet = vi.fn();
|
||||
mockSelectQueue([
|
||||
[], // spendCoins: no existing spend
|
||||
[{ coinsBalance: 300 }],
|
||||
[], // grantInventoryItem: no existing item grant
|
||||
[], // current item
|
||||
[], // no existing inventory row
|
||||
[{ id: 'inventory-1' }], // inventory row id for transaction
|
||||
]);
|
||||
vi.mocked(db.insert).mockReturnValue(mockInsert(insertValues));
|
||||
vi.mocked(db.update).mockReturnValue(mockUpdate(updateSet));
|
||||
|
||||
const result = await purchaseShopProduct('user-1', 'hint-feather', 'request-1');
|
||||
|
||||
expect(result.product.id).toBe('hint-feather');
|
||||
expect(result.coinsSpent).toBe(80);
|
||||
expect(result.coinsBalance).toBe(220);
|
||||
expect(result.item).toEqual({
|
||||
itemId: 'hint_feather',
|
||||
quantity: 1,
|
||||
activeUntil: null,
|
||||
metadata: null,
|
||||
});
|
||||
expect(result.rewards).toEqual([
|
||||
expect.objectContaining({ type: 'item', itemId: 'hint_feather', quantity: 1 }),
|
||||
]);
|
||||
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({
|
||||
coinsBalance: expect.any(Object),
|
||||
lifetimeCoinsSpent: expect.any(Object),
|
||||
}));
|
||||
expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({
|
||||
itemId: 'coins',
|
||||
direction: 'consume',
|
||||
quantityDelta: -80,
|
||||
sourceType: 'shop_purchase',
|
||||
idempotencyKey: 'shop:request-1:coins',
|
||||
}));
|
||||
expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({
|
||||
itemId: 'hint_feather',
|
||||
direction: 'grant',
|
||||
sourceType: 'shop_purchase',
|
||||
idempotencyKey: 'shop:request-1:item',
|
||||
}));
|
||||
});
|
||||
|
||||
it('does not spend coins or grant inventory twice for duplicate purchases', async () => {
|
||||
mockSelectQueue([
|
||||
[{ id: 'coin-tx-1' }], // spendCoins: existing spend transaction
|
||||
[{ coinsBalance: 220 }],
|
||||
[{ id: 'item-tx-1' }], // grantInventoryItem: existing item grant
|
||||
[{ itemId: 'hint_feather', quantity: 1, activeUntil: null, metadata: null }],
|
||||
]);
|
||||
|
||||
const result = await purchaseShopProduct('user-1', 'hint-feather', 'request-1');
|
||||
|
||||
expect(result.product.id).toBe('hint-feather');
|
||||
expect(result.coinsSpent).toBe(0);
|
||||
expect(result.coinsBalance).toBe(220);
|
||||
expect(result.item).toEqual({
|
||||
itemId: 'hint_feather',
|
||||
quantity: 1,
|
||||
activeUntil: null,
|
||||
metadata: null,
|
||||
});
|
||||
expect(db.insert).not.toHaveBeenCalled();
|
||||
expect(db.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when the user does not have enough coins', async () => {
|
||||
mockSelectQueue([
|
||||
[], // no existing spend
|
||||
[{ coinsBalance: 20 }],
|
||||
]);
|
||||
|
||||
await expect(
|
||||
purchaseShopProduct('user-1', 'hint-feather', 'request-1'),
|
||||
).rejects.toThrow('金币余额不足');
|
||||
});
|
||||
});
|
||||
|
||||
457
src/db/schema.ts
457
src/db/schema.ts
@ -19,86 +19,82 @@ import { sql } from 'drizzle-orm';
|
||||
|
||||
// ── Users ──────────────────────────────────────────────────────────
|
||||
|
||||
// 用户账号与学习状态数据。
|
||||
export const users = mysqlTable('users', {
|
||||
id: char('id', { length: 36 }).primaryKey(),
|
||||
authType: mysqlEnum('auth_type', ['huawei', 'guest', 'phone', 'apple', 'google']).notNull(), // 登录方式。
|
||||
authId: varchar('auth_id', { length: 255 }).notNull(), // 第三方平台或游客身份标识。
|
||||
nickname: varchar('nickname', { length: 50 }), // 用户昵称。
|
||||
avatarUrl: varchar('avatar_url', { length: 500 }), // 头像图片地址。
|
||||
tier: mysqlEnum('tier', ['free', 'pro', 'proplus']).default('free'), // 订阅等级。
|
||||
xpTotal: int('xp_total').default(0), // 累计经验值。
|
||||
streakDays: int('streak_days').default(0), // 当前连续学习天数。
|
||||
streakLastDate: date('streak_last_date'), // 最近一次计入连续学习的日期。
|
||||
heartsRemaining: tinyint('hearts_remaining').default(5), // 当前剩余红心数。
|
||||
heartsLastRestore: datetime('hearts_last_restore'), // 最近一次红心自然恢复时间。
|
||||
dailyXpGoal: smallint('daily_xp_goal').default(50), // 每日经验目标。
|
||||
dailyXpEarned: smallint('daily_xp_earned').default(0), // 当日已获得经验值。
|
||||
dailyXpDate: date('daily_xp_date'), // 每日经验统计日期。
|
||||
currentTheme: varchar('current_theme', { length: 20 }).default('inkTeal'), // 当前界面主题。
|
||||
activeTrackId: varchar('active_track_id', { length: 50 }), // 当前学习路径。
|
||||
dailyAttemptsLeft: smallint('daily_attempts_left').default(5), // 当日剩余挑战次数。
|
||||
dailyAttemptsDate: date('daily_attempts_date'), // 每日挑战次数统计日期。
|
||||
checkInDays: int('check_in_days').default(0), // 累计签到天数。
|
||||
lastCheckInDate: date('last_check_in_date'), // 最近一次签到日期。
|
||||
streakProtectedUntil: datetime('streak_protected_until'), // 连续学习保护失效时间。
|
||||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。
|
||||
updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), // 更新时间。
|
||||
authType: mysqlEnum('auth_type', ['huawei', 'guest', 'phone', 'apple', 'google']).notNull(),
|
||||
authId: varchar('auth_id', { length: 255 }).notNull(),
|
||||
nickname: varchar('nickname', { length: 50 }),
|
||||
avatarUrl: varchar('avatar_url', { length: 500 }),
|
||||
tier: mysqlEnum('tier', ['free', 'pro', 'proplus']).default('free'),
|
||||
xpTotal: int('xp_total').default(0),
|
||||
streakDays: int('streak_days').default(0),
|
||||
streakLastDate: date('streak_last_date'),
|
||||
heartsRemaining: tinyint('hearts_remaining').default(5),
|
||||
heartsLastRestore: datetime('hearts_last_restore'),
|
||||
dailyXpGoal: smallint('daily_xp_goal').default(50),
|
||||
dailyXpEarned: smallint('daily_xp_earned').default(0),
|
||||
dailyXpDate: date('daily_xp_date'),
|
||||
currentTheme: varchar('current_theme', { length: 20 }).default('inkTeal'),
|
||||
activeTrackId: varchar('active_track_id', { length: 50 }),
|
||||
dailyAttemptsLeft: smallint('daily_attempts_left').default(5),
|
||||
dailyAttemptsDate: date('daily_attempts_date'),
|
||||
checkInDays: int('check_in_days').default(0),
|
||||
lastCheckInDate: date('last_check_in_date'),
|
||||
streakProtectedUntil: datetime('streak_protected_until'),
|
||||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`),
|
||||
}, (table) => [
|
||||
uniqueIndex('uk_auth').on(table.authType, table.authId),
|
||||
]);
|
||||
|
||||
// ── Categories ─────────────────────────────────────────────────────
|
||||
|
||||
// 题目分类与学习主题数据。
|
||||
export const categories = mysqlTable('categories', {
|
||||
id: varchar('id', { length: 50 }).primaryKey(),
|
||||
name: varchar('name', { length: 100 }).notNull(), // 分类名称。
|
||||
slug: varchar('slug', { length: 100 }).notNull(), // 面向 URL 或导入数据的唯一标识。
|
||||
parentId: varchar('parent_id', { length: 50 }), // 父级分类。
|
||||
sortOrder: int('sort_order').default(0), // 排序权重,数值越小越靠前。
|
||||
questionCount: int('question_count').default(0), // 分类下题目数量缓存。
|
||||
status: mysqlEnum('status', ['active', 'inactive']).default('active'), // 分类启用状态。
|
||||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。
|
||||
updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), // 更新时间。
|
||||
name: varchar('name', { length: 100 }).notNull(),
|
||||
slug: varchar('slug', { length: 100 }).notNull(),
|
||||
parentId: varchar('parent_id', { length: 50 }),
|
||||
sortOrder: int('sort_order').default(0),
|
||||
questionCount: int('question_count').default(0),
|
||||
status: mysqlEnum('status', ['active', 'inactive']).default('active'),
|
||||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`),
|
||||
}, (table) => [
|
||||
uniqueIndex('uk_slug').on(table.slug),
|
||||
]);
|
||||
|
||||
// ── Questions ──────────────────────────────────────────────────────
|
||||
|
||||
// 题库题目与答题统计数据。
|
||||
export const questions = mysqlTable('questions', {
|
||||
id: char('id', { length: 36 }).primaryKey(),
|
||||
stem: json('stem').notNull(), // 题干内容,支持多语言或富媒体结构。
|
||||
contentType: mysqlEnum('content_type', ['text', 'image', 'video', 'audio']).notNull(), // 题目内容类型。
|
||||
correctAnswer: varchar('correct_answer', { length: 500 }).notNull(), // 正确答案。
|
||||
distractors: json('distractors').notNull(), // 干扰选项列表。
|
||||
stem: json('stem').notNull(),
|
||||
contentType: mysqlEnum('content_type', ['text', 'image', 'video', 'audio']).notNull(),
|
||||
correctAnswer: varchar('correct_answer', { length: 500 }).notNull(),
|
||||
distractors: json('distractors').notNull(),
|
||||
categoryId: varchar('category_id', { length: 50 }).notNull(),
|
||||
difficulty: tinyint('difficulty'), // 人工配置的难度等级。
|
||||
dynamicDifficulty: decimal('dynamic_difficulty', { precision: 3, scale: 1 }), // 根据答题表现计算的动态难度。
|
||||
source: mysqlEnum('source', ['system', 'ugc']).default('system'), // 题目来源。
|
||||
difficulty: tinyint('difficulty'),
|
||||
dynamicDifficulty: decimal('dynamic_difficulty', { precision: 3, scale: 1 }),
|
||||
source: mysqlEnum('source', ['system', 'ugc']).default('system'),
|
||||
creatorId: char('creator_id', { length: 36 }),
|
||||
status: mysqlEnum('status', ['draft', 'reviewing', 'published', 'archived']).default('draft'), // 题目发布状态。
|
||||
status: mysqlEnum('status', ['draft', 'reviewing', 'published', 'archived']).default('draft'),
|
||||
stats: json('stats').$type<{ timesAnswered: number; correctRate: number; avgTimeMs: number }>()
|
||||
.default({ timesAnswered: 0, correctRate: 0, avgTimeMs: 0 }), // 答题次数、正确率和平均耗时统计。
|
||||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。
|
||||
updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), // 更新时间。
|
||||
.default({ timesAnswered: 0, correctRate: 0, avgTimeMs: 0 }),
|
||||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`),
|
||||
}, (table) => [
|
||||
foreignKey({ columns: [table.categoryId], foreignColumns: [categories.id] }),
|
||||
]);
|
||||
|
||||
// ── Knowledge Cards ────────────────────────────────────────────────
|
||||
|
||||
// 题目关联的知识讲解卡片数据。
|
||||
export const knowledgeCards = mysqlTable('knowledge_cards', {
|
||||
id: char('id', { length: 36 }).primaryKey(),
|
||||
questionId: char('question_id', { length: 36 }).notNull(),
|
||||
summary: varchar('summary', { length: 300 }).notNull(), // 答题后的知识点摘要。
|
||||
deepDive: text('deep_dive'), // 延伸讲解内容。
|
||||
sourceRef: varchar('source_ref', { length: 500 }), // 参考来源或出处。
|
||||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。
|
||||
updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), // 更新时间。
|
||||
summary: varchar('summary', { length: 300 }).notNull(),
|
||||
deepDive: text('deep_dive'),
|
||||
sourceRef: varchar('source_ref', { length: 500 }),
|
||||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`),
|
||||
}, (table) => [
|
||||
uniqueIndex('uk_question').on(table.questionId),
|
||||
foreignKey({ columns: [table.questionId], foreignColumns: [questions.id] }),
|
||||
@ -106,14 +102,13 @@ export const knowledgeCards = mysqlTable('knowledge_cards', {
|
||||
|
||||
// ── User Progress ──────────────────────────────────────────────────
|
||||
|
||||
// 用户单题答题记录数据。
|
||||
export const userProgress = mysqlTable('user_progress', {
|
||||
id: char('id', { length: 36 }).primaryKey(),
|
||||
userId: char('user_id', { length: 36 }).notNull(),
|
||||
questionId: char('question_id', { length: 36 }).notNull(),
|
||||
correct: tinyint('correct').notNull(), // 本次答题是否正确。
|
||||
timeMs: int('time_ms'), // 答题耗时,单位毫秒。
|
||||
answeredAt: datetime('answered_at').default(sql`CURRENT_TIMESTAMP`), // 答题时间。
|
||||
correct: tinyint('correct').notNull(),
|
||||
timeMs: int('time_ms'),
|
||||
answeredAt: datetime('answered_at').default(sql`CURRENT_TIMESTAMP`),
|
||||
}, (table) => [
|
||||
index('idx_user_answered').on(table.userId, table.answeredAt),
|
||||
foreignKey({ columns: [table.userId], foreignColumns: [users.id] }),
|
||||
@ -122,389 +117,135 @@ export const userProgress = mysqlTable('user_progress', {
|
||||
|
||||
// ── Skill Tree ─────────────────────────────────────────────────────
|
||||
|
||||
// 分类下的学习关卡树数据。
|
||||
export const skillTree = mysqlTable('skill_tree', {
|
||||
id: char('id', { length: 36 }).primaryKey(),
|
||||
categoryId: varchar('category_id', { length: 50 }).notNull(),
|
||||
title: varchar('title', { length: 100 }).notNull(), // 关卡标题。
|
||||
title: varchar('title', { length: 100 }).notNull(),
|
||||
parentId: char('parent_id', { length: 36 }),
|
||||
sortOrder: int('sort_order').default(0), // 同级关卡排序权重。
|
||||
questionsRequired: tinyint('questions_required').default(4), // 完成本关需要回答的题目数。
|
||||
passThreshold: tinyint('pass_threshold').default(2), // 判定通关所需正确题数。
|
||||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。
|
||||
sortOrder: int('sort_order').default(0),
|
||||
questionsRequired: tinyint('questions_required').default(4),
|
||||
passThreshold: tinyint('pass_threshold').default(2),
|
||||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`),
|
||||
}, (table) => [
|
||||
foreignKey({ columns: [table.categoryId], foreignColumns: [categories.id] }),
|
||||
]);
|
||||
|
||||
// ── User Chapter Progress ──────────────────────────────────────────
|
||||
|
||||
// 用户在学习关卡中的进度数据。
|
||||
export const userChapterProgress = mysqlTable('user_chapter_progress', {
|
||||
id: char('id', { length: 36 }).primaryKey(),
|
||||
userId: char('user_id', { length: 36 }).notNull(),
|
||||
chapterId: char('chapter_id', { length: 36 }).notNull(),
|
||||
status: mysqlEnum('status', ['locked', 'unlocked', 'passed', 'perfect']).default('locked'), // 用户在关卡中的进度状态。
|
||||
bestCorrectCount: tinyint('best_correct_count').default(0), // 历史最好正确题数。
|
||||
attempts: int('attempts').default(0), // 累计挑战次数。
|
||||
completedAt: datetime('completed_at'), // 最近一次通关时间。
|
||||
status: mysqlEnum('status', ['locked', 'unlocked', 'passed', 'perfect']).default('locked'),
|
||||
bestCorrectCount: tinyint('best_correct_count').default(0),
|
||||
attempts: int('attempts').default(0),
|
||||
completedAt: datetime('completed_at'),
|
||||
}, (table) => [
|
||||
uniqueIndex('uk_user_chapter').on(table.userId, table.chapterId),
|
||||
foreignKey({ columns: [table.userId], foreignColumns: [users.id] }),
|
||||
foreignKey({ columns: [table.chapterId], foreignColumns: [skillTree.id] }),
|
||||
]);
|
||||
|
||||
// ── Challenge Sessions ─────────────────────────────────────────────
|
||||
|
||||
// 用户挑战组会话数据,服务端以 5 题为一组裁决进度和奖励。
|
||||
export const challengeSessions = mysqlTable('challenge_sessions', {
|
||||
id: char('id', { length: 36 }).primaryKey(),
|
||||
userId: char('user_id', { length: 36 }).notNull(),
|
||||
trackId: varchar('track_id', { length: 50 }).notNull(), // 客户端选择的学习路线。
|
||||
categoryId: varchar('category_id', { length: 50 }).notNull(), // 本组题目所属主题分类。
|
||||
chapterId: char('chapter_id', { length: 36 }), // 绑定的技能树节点或章节。
|
||||
status: mysqlEnum('status', ['pending', 'in_progress', 'completed', 'abandoned', 'expired']).default('pending'), // 挑战组状态。
|
||||
clientRequestId: varchar('client_request_id', { length: 80 }).notNull(), // 创建挑战组的客户端幂等请求号。
|
||||
completeRequestId: varchar('complete_request_id', { length: 80 }), // 完成结算的客户端幂等请求号。
|
||||
questionIds: json('question_ids').$type<readonly string[]>().notNull(), // 本组题目 ID 快照,不包含正确答案。
|
||||
totalQuestions: tinyint('total_questions').default(5), // 本组题目总数。
|
||||
answeredCount: tinyint('answered_count').default(0), // 已提交答案数量。
|
||||
correctCount: tinyint('correct_count').default(0), // 当前正确数量。
|
||||
highRewardEligible: tinyint('high_reward_eligible').default(1), // 是否消耗并享受每日高奖励次数。
|
||||
rewardSnapshot: json('reward_snapshot').$type<Record<string, unknown>>(), // 完成结算后的奖励快照。
|
||||
progressBefore: json('progress_before').$type<Record<string, unknown>>(), // 创建或结算前的资源快照。
|
||||
progressAfter: json('progress_after').$type<Record<string, unknown>>(), // 完成结算后的资源快照。
|
||||
expiresAt: datetime('expires_at'), // 会话过期时间。
|
||||
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_challenge_session_user_client_request').on(table.userId, table.clientRequestId),
|
||||
uniqueIndex('uk_challenge_session_user_complete_request').on(table.userId, table.completeRequestId),
|
||||
index('idx_challenge_session_user_status_created').on(table.userId, table.status, table.createdAt),
|
||||
index('idx_challenge_session_chapter_status').on(table.chapterId, table.status),
|
||||
foreignKey({ columns: [table.userId], foreignColumns: [users.id] }),
|
||||
foreignKey({ columns: [table.categoryId], foreignColumns: [categories.id] }),
|
||||
foreignKey({ columns: [table.chapterId], foreignColumns: [skillTree.id] }),
|
||||
]);
|
||||
|
||||
// 用户挑战组内的单题提交记录,支撑重复提交幂等和组完成结算。
|
||||
export const challengeSessionAnswers = mysqlTable('challenge_session_answers', {
|
||||
id: char('id', { length: 36 }).primaryKey(),
|
||||
sessionId: char('session_id', { length: 36 }).notNull(),
|
||||
userId: char('user_id', { length: 36 }).notNull(),
|
||||
questionId: char('question_id', { length: 36 }).notNull(),
|
||||
submitRequestId: varchar('submit_request_id', { length: 80 }).notNull(), // 单题提交的客户端幂等请求号。
|
||||
answerOrder: tinyint('answer_order').notNull(), // 本题在挑战组中的顺序。
|
||||
answer: varchar('answer', { length: 500 }), // 用户提交答案。
|
||||
correct: tinyint('correct').notNull(), // 本次提交是否正确。
|
||||
timeMs: int('time_ms'), // 答题耗时,单位毫秒。
|
||||
comboCount: tinyint('combo_count').default(0), // 提交后组内连续答对数。
|
||||
resultSnapshot: json('result_snapshot').$type<Record<string, unknown>>(), // 返回客户端的本题裁决快照。
|
||||
submittedAt: datetime('submitted_at').default(sql`CURRENT_TIMESTAMP`), // 提交时间。
|
||||
}, (table) => [
|
||||
uniqueIndex('uk_challenge_answer_session_question').on(table.sessionId, table.questionId),
|
||||
uniqueIndex('uk_challenge_answer_session_request').on(table.sessionId, table.submitRequestId),
|
||||
index('idx_challenge_answer_user_submitted').on(table.userId, table.submittedAt),
|
||||
foreignKey({ columns: [table.sessionId], foreignColumns: [challengeSessions.id] }),
|
||||
foreignKey({ columns: [table.userId], foreignColumns: [users.id] }),
|
||||
foreignKey({ columns: [table.questionId], foreignColumns: [questions.id] }),
|
||||
]);
|
||||
|
||||
// ── Wallets and Inventory ──────────────────────────────────────────
|
||||
|
||||
// 用户金币钱包,所有余额变化必须通过流水记录追踪。
|
||||
export const userWallets = mysqlTable('user_wallets', {
|
||||
userId: char('user_id', { length: 36 }).primaryKey(),
|
||||
coinsBalance: int('coins_balance').default(0), // 当前金币余额。
|
||||
lifetimeCoinsEarned: int('lifetime_coins_earned').default(0), // 历史累计获得金币。
|
||||
lifetimeCoinsSpent: int('lifetime_coins_spent').default(0), // 历史累计消耗金币。
|
||||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。
|
||||
updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), // 更新时间。
|
||||
}, (table) => [
|
||||
foreignKey({ columns: [table.userId], foreignColumns: [users.id] }),
|
||||
]);
|
||||
|
||||
// 用户道具库存,以 item_id 聚合当前可用数量和最近有效期。
|
||||
export const userInventoryItems = mysqlTable('user_inventory_items', {
|
||||
id: char('id', { length: 36 }).primaryKey(),
|
||||
userId: char('user_id', { length: 36 }).notNull(),
|
||||
itemId: mysqlEnum('item_id', ['streak_shield', 'double_xp_potion', 'heart_supply', 'hint_feather', 'mascot_outfit']).notNull(), // 道具标识。
|
||||
quantity: int('quantity').default(0), // 当前库存数量。
|
||||
activeUntil: datetime('active_until'), // 时效型道具的生效截止时间。
|
||||
metadata: json('metadata').$type<Record<string, unknown>>(), // 装扮、头像框等展示权益的扩展信息。
|
||||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。
|
||||
updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), // 更新时间。
|
||||
}, (table) => [
|
||||
uniqueIndex('uk_inventory_user_item').on(table.userId, table.itemId),
|
||||
index('idx_inventory_user_active').on(table.userId, table.activeUntil),
|
||||
foreignKey({ columns: [table.userId], foreignColumns: [users.id] }),
|
||||
]);
|
||||
|
||||
// 钱包和道具库存流水,记录金币与道具的获得、消耗和调整来源。
|
||||
export const inventoryTransactions = mysqlTable('inventory_transactions', {
|
||||
id: char('id', { length: 36 }).primaryKey(),
|
||||
userId: char('user_id', { length: 36 }).notNull(),
|
||||
inventoryItemId: char('inventory_item_id', { length: 36 }),
|
||||
itemId: mysqlEnum('item_id', ['coins', 'streak_shield', 'double_xp_potion', 'heart_supply', 'hint_feather', 'mascot_outfit']).notNull(), // 流水涉及的资源。
|
||||
direction: mysqlEnum('direction', ['grant', 'consume', 'adjust']).notNull(), // 获得、消耗或运营调整。
|
||||
quantityDelta: int('quantity_delta').notNull(), // 资源数量变化,消耗为负数。
|
||||
balanceAfter: int('balance_after'), // 变更后的金币余额或道具库存。
|
||||
sourceType: mysqlEnum('source_type', ['challenge', 'daily_task', 'level_up', 'theme_node', 'chest', 'shop_purchase', 'ad_recovery', 'subscription', 'admin_grant', 'system_adjust', 'leaderboard_settlement']).notNull(), // 资源变化来源。
|
||||
sourceId: varchar('source_id', { length: 120 }), // 来源业务 ID,如挑战组、订单或广告会话。
|
||||
idempotencyKey: varchar('idempotency_key', { length: 160 }), // 幂等边界,防止重复发放或重复扣减。
|
||||
snapshot: json('snapshot').$type<Record<string, unknown>>(), // 本次变更的上下文快照。
|
||||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。
|
||||
}, (table) => [
|
||||
uniqueIndex('uk_inventory_transaction_idempotency').on(table.userId, table.idempotencyKey),
|
||||
index('idx_inventory_transaction_user_created').on(table.userId, table.createdAt),
|
||||
index('idx_inventory_transaction_source').on(table.sourceType, table.sourceId),
|
||||
foreignKey({ columns: [table.userId], foreignColumns: [users.id] }),
|
||||
foreignKey({ columns: [table.inventoryItemId], foreignColumns: [userInventoryItems.id] }),
|
||||
]);
|
||||
|
||||
// ── Reward Ledger ─────────────────────────────────────────────────
|
||||
|
||||
// 统一奖励结算流水,记录奖励来源、幂等边界、快照和发放前后状态。
|
||||
export const rewardLedger = mysqlTable('reward_ledger', {
|
||||
id: char('id', { length: 36 }).primaryKey(),
|
||||
userId: char('user_id', { length: 36 }).notNull(),
|
||||
sourceType: mysqlEnum('source_type', ['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']).notNull(), // 奖励来源。
|
||||
sourceId: varchar('source_id', { length: 120 }), // 来源业务 ID。
|
||||
idempotencyKey: varchar('idempotency_key', { length: 160 }).notNull(), // 奖励结算幂等 key。
|
||||
status: mysqlEnum('status', ['pending', 'settling', 'completed', 'failed', 'reversed']).default('pending'), // 奖励结算状态。
|
||||
rewardSnapshot: json('reward_snapshot').$type<Record<string, unknown>>().notNull(), // 计划发放或已发放的奖励快照。
|
||||
resourceDeltas: json('resource_deltas').$type<Record<string, unknown>>(), // XP、金币、红心、道具等资源变化。
|
||||
stateBefore: json('state_before').$type<Record<string, unknown>>(), // 发放前用户资源状态。
|
||||
stateAfter: json('state_after').$type<Record<string, unknown>>(), // 发放后用户资源状态。
|
||||
failureReason: varchar('failure_reason', { length: 120 }), // 结算失败或回滚原因。
|
||||
settledAt: datetime('settled_at'), // 奖励完成结算时间。
|
||||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。
|
||||
updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), // 更新时间。
|
||||
}, (table) => [
|
||||
uniqueIndex('uk_reward_ledger_user_idempotency').on(table.userId, table.idempotencyKey),
|
||||
index('idx_reward_ledger_user_status_created').on(table.userId, table.status, table.createdAt),
|
||||
index('idx_reward_ledger_source').on(table.sourceType, table.sourceId),
|
||||
foreignKey({ columns: [table.userId], foreignColumns: [users.id] }),
|
||||
]);
|
||||
|
||||
// ── Daily Progress ────────────────────────────────────────────────
|
||||
|
||||
// 用户每日游戏化进度,聚合首组挑战、每日任务和高奖励次数。
|
||||
export const userDailyProgress = mysqlTable('user_daily_progress', {
|
||||
id: char('id', { length: 36 }).primaryKey(),
|
||||
userId: char('user_id', { length: 36 }).notNull(),
|
||||
progressDate: date('progress_date').notNull(), // 统计日期,服务端按 UTC 自然日写入。
|
||||
timezone: varchar('timezone', { length: 50 }).default('UTC'), // 客户端或服务端用于展示的时区。
|
||||
firstChallengeSessionId: char('first_challenge_session_id', { length: 36 }), // 当日首组完成挑战。
|
||||
firstChallengeCompletedAt: datetime('first_challenge_completed_at'), // 当日首组挑战完成时间。
|
||||
challengeSessionsCompleted: smallint('challenge_sessions_completed').default(0), // 当日完成挑战组数。
|
||||
highRewardSessionsMax: smallint('high_reward_sessions_max').default(3), // 当日高奖励挑战次数上限。
|
||||
highRewardSessionsUsed: smallint('high_reward_sessions_used').default(0), // 当日已消耗高奖励次数。
|
||||
highRewardSessionsRestored: smallint('high_reward_sessions_restored').default(0), // 当日通过广告等方式恢复的高奖励次数。
|
||||
dailyTasksCompleted: smallint('daily_tasks_completed').default(0), // 当日已完成任务数。
|
||||
dailyTasksRewardClaimed: smallint('daily_tasks_reward_claimed').default(0), // 当日已领取任务奖励数。
|
||||
xpEarned: int('xp_earned').default(0), // 当日获得 XP。
|
||||
coinsEarned: int('coins_earned').default(0), // 当日获得金币。
|
||||
streakCounted: tinyint('streak_counted').default(0), // 当日是否已计入连续学习。
|
||||
metadata: json('metadata').$type<Record<string, unknown>>(), // 每日宝箱、降级倍率等扩展状态。
|
||||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。
|
||||
updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), // 更新时间。
|
||||
}, (table) => [
|
||||
uniqueIndex('uk_daily_progress_user_date').on(table.userId, table.progressDate),
|
||||
index('idx_daily_progress_date').on(table.progressDate),
|
||||
foreignKey({ columns: [table.userId], foreignColumns: [users.id] }),
|
||||
foreignKey({ columns: [table.firstChallengeSessionId], foreignColumns: [challengeSessions.id] }),
|
||||
]);
|
||||
|
||||
// 用户每日任务进度,用于幂等记录任务完成和奖励领取。
|
||||
export const userDailyTasks = mysqlTable('user_daily_tasks', {
|
||||
id: char('id', { length: 36 }).primaryKey(),
|
||||
dailyProgressId: char('daily_progress_id', { length: 36 }).notNull(),
|
||||
userId: char('user_id', { length: 36 }).notNull(),
|
||||
taskDate: date('task_date').notNull(), // 任务归属日期,服务端按 UTC 自然日写入。
|
||||
taskId: varchar('task_id', { length: 80 }).notNull(), // 每日任务配置标识。
|
||||
taskType: mysqlEnum('task_type', ['complete_challenge', 'earn_xp', 'answer_correct', 'review_explanation', 'use_item', 'watch_ad']).notNull(), // 任务类型。
|
||||
targetCount: smallint('target_count').default(1), // 任务目标次数。
|
||||
currentCount: smallint('current_count').default(0), // 当前完成次数。
|
||||
status: mysqlEnum('status', ['active', 'completed', 'reward_claimed', 'expired']).default('active'), // 任务状态。
|
||||
rewardSnapshot: json('reward_snapshot').$type<Record<string, unknown>>(), // 任务奖励快照。
|
||||
completedAt: datetime('completed_at'), // 任务完成时间。
|
||||
rewardClaimedAt: datetime('reward_claimed_at'), // 奖励领取时间。
|
||||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。
|
||||
updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), // 更新时间。
|
||||
}, (table) => [
|
||||
uniqueIndex('uk_daily_task_user_date_task').on(table.userId, table.taskDate, table.taskId),
|
||||
index('idx_daily_task_progress_status').on(table.dailyProgressId, table.status),
|
||||
foreignKey({ columns: [table.dailyProgressId], foreignColumns: [userDailyProgress.id] }),
|
||||
foreignKey({ columns: [table.userId], foreignColumns: [users.id] }),
|
||||
]);
|
||||
|
||||
// ── Question Ratings ──────────────────────────────────────────────
|
||||
|
||||
// 用户对题目的好坏反馈数据。
|
||||
export const questionRatings = mysqlTable('question_ratings', {
|
||||
id: char('id', { length: 36 }).primaryKey(),
|
||||
userId: char('user_id', { length: 36 }).notNull(),
|
||||
questionId: char('question_id', { length: 36 }).notNull(),
|
||||
rating: mysqlEnum('rating', ['good', 'bad']).notNull(), // 用户对题目的反馈评价。
|
||||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。
|
||||
rating: mysqlEnum('rating', ['good', 'bad']).notNull(),
|
||||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`),
|
||||
}, (table) => [
|
||||
uniqueIndex('uk_user_question_rating').on(table.userId, table.questionId),
|
||||
]);
|
||||
|
||||
// ── User Feedback ──────────────────────────────────────────────────
|
||||
|
||||
// 用户提交的产品反馈数据。
|
||||
export const userFeedback = mysqlTable('user_feedback', {
|
||||
id: char('id', { length: 36 }).primaryKey(),
|
||||
userId: char('user_id', { length: 36 }).notNull(),
|
||||
content: text('content').notNull(), // 用户提交的反馈内容。
|
||||
contact: varchar('contact', { length: 255 }), // 用户留下的联系方式。
|
||||
pageContext: varchar('page_context', { length: 200 }), // 反馈来源页面或场景。
|
||||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。
|
||||
content: text('content').notNull(),
|
||||
contact: varchar('contact', { length: 255 }),
|
||||
pageContext: varchar('page_context', { length: 200 }),
|
||||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
// ── Achievements ──────────────────────────────────────────────────
|
||||
|
||||
// 可解锁的成就配置数据。
|
||||
export const achievements = mysqlTable('achievements', {
|
||||
id: char('id', { length: 36 }).primaryKey(),
|
||||
type: mysqlEnum('type', ['knowledge', 'behavior']).notNull(), // 成就类型。
|
||||
name: varchar('name', { length: 100 }).notNull(), // 成就名称。
|
||||
description: varchar('description', { length: 300 }).notNull(), // 成就说明。
|
||||
iconUrl: varchar('icon_url', { length: 500 }), // 成就图标地址。
|
||||
condition: json('condition').notNull(), // 解锁条件配置。
|
||||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。
|
||||
type: mysqlEnum('type', ['knowledge', 'behavior']).notNull(),
|
||||
name: varchar('name', { length: 100 }).notNull(),
|
||||
description: varchar('description', { length: 300 }).notNull(),
|
||||
iconUrl: varchar('icon_url', { length: 500 }),
|
||||
condition: json('condition').notNull(),
|
||||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
// 用户已解锁成就数据。
|
||||
export const userAchievements = mysqlTable('user_achievements', {
|
||||
id: char('id', { length: 36 }).primaryKey(),
|
||||
userId: char('user_id', { length: 36 }).notNull(),
|
||||
achievementId: char('achievement_id', { length: 36 }).notNull(),
|
||||
unlockedAt: datetime('unlocked_at').default(sql`CURRENT_TIMESTAMP`), // 解锁时间。
|
||||
unlockedAt: datetime('unlocked_at').default(sql`CURRENT_TIMESTAMP`),
|
||||
}, (table) => [
|
||||
uniqueIndex('uk_user_achievement').on(table.userId, table.achievementId),
|
||||
]);
|
||||
|
||||
// ── Leaderboard Snapshots ──────────────────────────────────────────
|
||||
|
||||
// 用户排行榜周期快照数据。
|
||||
export const leaderboardSnapshots = mysqlTable('leaderboard_snapshots', {
|
||||
id: char('id', { length: 36 }).primaryKey(),
|
||||
userId: char('user_id', { length: 36 }).notNull(),
|
||||
tier: mysqlEnum('tier', ['bronze', 'silver', 'gold', 'platinum', 'diamond', 'master', 'grandmaster', 'champion', 'legend', 'mythic']).notNull(), // 排行榜段位。
|
||||
weeklyXp: int('weekly_xp').default(0), // 本周累计经验值。
|
||||
rank: int('rank'), // 当前排名。
|
||||
groupId: varchar('group_id', { length: 80 }), // 周榜分组 ID。
|
||||
league: varchar('league', { length: 50 }), // 所属联赛或榜单分组。
|
||||
rewardSnapshot: json('reward_snapshot').$type<Record<string, unknown>>(), // 周结算奖励预览或实际发放快照。
|
||||
settledAt: datetime('settled_at'), // 周榜结算时间。
|
||||
weekStart: date('week_start').notNull(), // 统计周开始日期,按 UTC 自然周一。
|
||||
weekEnd: date('week_end').notNull(), // 统计周结束日期,按 UTC 自然周日。
|
||||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。
|
||||
tier: mysqlEnum('tier', ['bronze', 'silver', 'gold', 'platinum', 'diamond', 'master', 'grandmaster', 'champion', 'legend', 'mythic']).notNull(),
|
||||
weeklyXp: int('weekly_xp').default(0),
|
||||
rank: int('rank'),
|
||||
league: varchar('league', { length: 50 }),
|
||||
weekStart: date('week_start'),
|
||||
weekEnd: date('week_end'),
|
||||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`),
|
||||
}, (table) => [
|
||||
uniqueIndex('uk_leaderboard_snapshot_user_week').on(table.userId, table.weekStart),
|
||||
index('idx_leaderboard_snapshot_group_rank').on(table.groupId, table.weekStart, table.rank),
|
||||
]);
|
||||
|
||||
// 用户每周 XP 统计,作为当前周排行榜和历史快照的累计数据源。
|
||||
export const userWeeklyXp = mysqlTable('user_weekly_xp', {
|
||||
id: char('id', { length: 36 }).primaryKey(),
|
||||
userId: char('user_id', { length: 36 }).notNull(),
|
||||
weekStart: date('week_start').notNull(), // UTC 自然周一。
|
||||
weekEnd: date('week_end').notNull(), // UTC 自然周日。
|
||||
timezone: varchar('timezone', { length: 50 }).default('UTC'), // 周期展示时区。
|
||||
xpEarned: int('xp_earned').default(0), // 本周累计 XP。
|
||||
challengeSessionsCompleted: int('challenge_sessions_completed').default(0), // 本周完成挑战组数。
|
||||
groupId: varchar('group_id', { length: 80 }), // 分配到的周榜分组 ID。
|
||||
rank: int('rank'), // 当前组内排名缓存。
|
||||
settled: tinyint('settled').default(0), // 本周是否已完成结算。
|
||||
settledAt: datetime('settled_at'), // 结算时间。
|
||||
lastXpAt: datetime('last_xp_at'), // 最近一次 XP 累加时间。
|
||||
nextRefreshAt: datetime('next_refresh_at'), // 下一次周榜刷新时间。
|
||||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。
|
||||
updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), // 更新时间。
|
||||
}, (table) => [
|
||||
uniqueIndex('uk_weekly_xp_user_week').on(table.userId, table.weekStart),
|
||||
index('idx_weekly_xp_group_rank').on(table.groupId, table.weekStart, table.xpEarned),
|
||||
index('idx_weekly_xp_week_settled').on(table.weekStart, table.settled),
|
||||
foreignKey({ columns: [table.userId], foreignColumns: [users.id] }),
|
||||
index('idx_user_week').on(table.userId, table.weekStart),
|
||||
]);
|
||||
|
||||
// ── Subscriptions ──────────────────────────────────────────────────
|
||||
|
||||
// 用户订阅权益与平台购买数据。
|
||||
export const subscriptions = mysqlTable('subscriptions', {
|
||||
id: char('id', { length: 36 }).primaryKey(),
|
||||
userId: char('user_id', { length: 36 }).notNull(),
|
||||
tier: mysqlEnum('tier', ['free', 'pro', 'proplus']).default('free'), // 订阅等级。
|
||||
platform: mysqlEnum('platform', ['huawei', 'apple', 'google']), // 购买平台。
|
||||
purchaseToken: varchar('purchase_token', { length: 500 }), // 平台购买凭证。
|
||||
expiresAt: datetime('expires_at'), // 订阅到期时间。
|
||||
autoRenew: tinyint('auto_renew').default(0), // 是否自动续订。
|
||||
status: mysqlEnum('status', ['active', 'expired', 'cancelled']).default('active'), // 订阅状态。
|
||||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。
|
||||
updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), // 更新时间。
|
||||
tier: mysqlEnum('tier', ['free', 'pro', 'proplus']).default('free'),
|
||||
platform: mysqlEnum('platform', ['huawei', 'apple', 'google']),
|
||||
purchaseToken: varchar('purchase_token', { length: 500 }),
|
||||
expiresAt: datetime('expires_at'),
|
||||
autoRenew: tinyint('auto_renew').default(0),
|
||||
status: mysqlEnum('status', ['active', 'expired', 'cancelled']).default('active'),
|
||||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`),
|
||||
}, (table) => [
|
||||
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<Record<string, unknown>>(), // 本次奖励结算快照。
|
||||
progressBefore: json('progress_before').$type<Record<string, unknown>>(), // 结算前的用户进度快照。
|
||||
progressAfter: json('progress_after').$type<Record<string, unknown>>(), // 结算后的用户进度快照。
|
||||
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', {
|
||||
id: int('id').primaryKey().autoincrement(),
|
||||
adminId: varchar('admin_id', { length: 36 }).notNull(),
|
||||
action: varchar('action', { length: 10 }).notNull(), // 操作类型。
|
||||
resource: varchar('resource', { length: 500 }), // 被操作的资源。
|
||||
details: json('details'), // 操作详情。
|
||||
ipAddress: varchar('ip_address', { length: 45 }), // 操作来源 IP。
|
||||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。
|
||||
action: varchar('action', { length: 10 }).notNull(),
|
||||
resource: varchar('resource', { length: 500 }),
|
||||
details: json('details'),
|
||||
ipAddress: varchar('ip_address', { length: 45 }),
|
||||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
// ── Admin Users ───────────────────────────────────────────────────────
|
||||
|
||||
// 管理端账号数据。
|
||||
export const adminUsers = mysqlTable('admin_users', {
|
||||
id: char('id', { length: 36 }).primaryKey(),
|
||||
username: varchar('username', { length: 50 }).notNull(), // 管理员登录名。
|
||||
passwordHash: varchar('password_hash', { length: 255 }).notNull(), // 密码哈希。
|
||||
role: mysqlEnum('role', ['admin', 'super_admin']).default('admin'), // 管理员角色。
|
||||
isActive: tinyint('is_active').default(1), // 账号是否启用。
|
||||
lastLoginAt: datetime('last_login_at'), // 最近登录时间。
|
||||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。
|
||||
updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), // 更新时间。
|
||||
username: varchar('username', { length: 50 }).notNull(),
|
||||
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
|
||||
role: mysqlEnum('role', ['admin', 'super_admin']).default('admin'),
|
||||
isActive: tinyint('is_active').default(1),
|
||||
lastLoginAt: datetime('last_login_at'),
|
||||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`),
|
||||
}, (table) => [
|
||||
uniqueIndex('uk_admin_username').on(table.username),
|
||||
]);
|
||||
|
||||
@ -18,7 +18,6 @@ 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<void> {
|
||||
@ -68,7 +67,6 @@ async function main(): Promise<void> {
|
||||
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' });
|
||||
|
||||
@ -1,47 +0,0 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import * as gamificationService from '../../services/admin/gamification-service.js';
|
||||
|
||||
export async function adminGamificationRoutes(app: FastifyInstance): Promise<void> {
|
||||
// ── 用户金币钱包 ─────────────────────────────────────────────
|
||||
|
||||
app.get('/users/:id/wallet', async (request) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const data = await gamificationService.getUserWallet(id);
|
||||
return { success: true, data, error: null };
|
||||
});
|
||||
|
||||
// ── 用户道具库存 ─────────────────────────────────────────────
|
||||
|
||||
app.get('/users/:id/inventory', async (request) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const data = await gamificationService.getUserInventory(id);
|
||||
return { success: true, data, error: null };
|
||||
});
|
||||
|
||||
// ── 用户奖励流水 ─────────────────────────────────────────────
|
||||
|
||||
app.get('/users/:id/rewards', async (request) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const { page = '1', limit = '20' } = request.query as Record<string, string>;
|
||||
const result = await gamificationService.getUserRewardLedger(id, Number(page), Number(limit));
|
||||
return { success: true, data: result.items, pagination: result.pagination, error: null };
|
||||
});
|
||||
|
||||
// ── 用户广告恢复记录 ─────────────────────────────────────────
|
||||
|
||||
app.get('/users/:id/ad-recovery', async (request) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const { page = '1', limit = '20' } = request.query as Record<string, string>;
|
||||
const result = await gamificationService.getUserAdRecoverySessions(id, Number(page), Number(limit));
|
||||
return { success: true, data: result.items, pagination: result.pagination, error: null };
|
||||
});
|
||||
|
||||
// ── 用户资源变更流水 ─────────────────────────────────────────
|
||||
|
||||
app.get('/users/:id/transactions', async (request) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const { page = '1', limit = '20' } = request.query as Record<string, string>;
|
||||
const result = await gamificationService.getUserInventoryTransactions(id, Number(page), Number(limit));
|
||||
return { success: true, data: result.items, pagination: result.pagination, error: null };
|
||||
});
|
||||
}
|
||||
@ -8,8 +8,6 @@ import { adminSkillTreeRoutes } from './skill-tree.js';
|
||||
import { adminUsersRoutes } from './users.js';
|
||||
import { adminStatsRoutes } from './stats.js';
|
||||
import { adminFeedbackRoutes } from './feedback.js';
|
||||
import { adminGamificationRoutes } from './gamification.js';
|
||||
import { adminJobsRoutes } from './jobs.js';
|
||||
|
||||
export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.register(adminAuthRoutes);
|
||||
@ -21,6 +19,4 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.register(adminUsersRoutes, { prefix: '/users' });
|
||||
app.register(adminStatsRoutes, { prefix: '/stats' });
|
||||
app.register(adminFeedbackRoutes, { prefix: '/feedback' });
|
||||
app.register(adminGamificationRoutes, { prefix: '/gamification' });
|
||||
app.register(adminJobsRoutes, { prefix: '/jobs' });
|
||||
}
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { listJobs, runJob } from '../../services/scheduler/index.js';
|
||||
import type { JobName } from '../../services/scheduler/index.js';
|
||||
|
||||
export async function adminJobsRoutes(app: FastifyInstance): Promise<void> {
|
||||
// 列出所有可用定时任务
|
||||
app.get('/', async () => {
|
||||
const jobs = listJobs();
|
||||
return { success: true, data: jobs, error: null };
|
||||
});
|
||||
|
||||
// 手动触发定时任务
|
||||
app.post<{ Body: { job: JobName; dryRun?: boolean } }>('/trigger', async (request) => {
|
||||
const { job, dryRun = false } = request.body;
|
||||
const result = await runJob(job, dryRun);
|
||||
return { success: true, data: result, error: null };
|
||||
});
|
||||
}
|
||||
@ -12,9 +12,8 @@ import {
|
||||
} from '../services/learning/progress-summary-service.js';
|
||||
import { restoreHearts } from '../services/progress/progress-service.js';
|
||||
import { getClientLeaderboard, getClientLeaderboardMe } from '../services/learning/leaderboard-api-service.js';
|
||||
import { getShopCatalog, purchaseShopProduct } from '../services/shop/shop-service.js';
|
||||
import { getShopBenefits } from '../services/shop/shop-service.js';
|
||||
import { getClientSubscription, verifyClientSubscription } from '../services/subscription/subscription-api-service.js';
|
||||
import { useInventoryItem } from '../services/gamification/item-use-service.js';
|
||||
|
||||
const rewardSourceSchema = z.object({
|
||||
source: z.enum(['ad', 'check_in', 'subscription', 'admin_grant', 'debug']),
|
||||
@ -26,7 +25,6 @@ const answerSchema = z.object({
|
||||
selectedOptionId: z.string().min(1),
|
||||
timeMs: z.number().min(0),
|
||||
comboCount: z.number().int().min(0).optional(),
|
||||
submitRequestId: z.string().min(1).max(80).optional(),
|
||||
});
|
||||
|
||||
const preferencesSchema = z.object({
|
||||
@ -47,17 +45,6 @@ const subscriptionVerifySchema = z.object({
|
||||
tier: z.enum(['pro', 'proplus']),
|
||||
});
|
||||
|
||||
const shopPurchaseSchema = z.object({
|
||||
productId: z.enum(['hint-feather', 'heart-supply', 'double-xp-potion', 'streak-shield', 'mascot-outfit-starter']),
|
||||
clientRequestId: z.string().min(1).max(80),
|
||||
});
|
||||
|
||||
const useItemSchema = z.object({
|
||||
itemId: z.enum(['streak_shield', 'double_xp_potion', 'heart_supply', 'hint_feather']),
|
||||
clientRequestId: z.string().min(1).max(80),
|
||||
questionId: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
function getUserId(request: { user: unknown }): string {
|
||||
return (request.user as { userId: string }).userId;
|
||||
}
|
||||
@ -95,12 +82,10 @@ export async function appApiRoutes(app: FastifyInstance): Promise<void> {
|
||||
if (!parsed.success) return validationError(parsed.error.issues[0]?.message);
|
||||
const data = await submitChallengeAnswer(
|
||||
getUserId(request),
|
||||
parsed.data.challengeId,
|
||||
parsed.data.questionId,
|
||||
parsed.data.selectedOptionId,
|
||||
parsed.data.timeMs,
|
||||
parsed.data.comboCount,
|
||||
parsed.data.submitRequestId,
|
||||
);
|
||||
return { success: true, data: { ...data, challengeId: parsed.data.challengeId }, error: null };
|
||||
});
|
||||
@ -122,8 +107,6 @@ export async function appApiRoutes(app: FastifyInstance): Promise<void> {
|
||||
return { success: true, data, error: null };
|
||||
});
|
||||
|
||||
// [废弃] 请使用 POST /rewards/ad-recovery/session + /rewards/ad-recovery/complete 两步流程。
|
||||
// 该接口不做幂等、每日上限和 Plus 分支检查,仅供内部测试或过渡期使用。
|
||||
app.post('/rewards/hearts/restore', async (request) => {
|
||||
const parsed = rewardSourceSchema.safeParse(request.body);
|
||||
if (!parsed.success) return validationError(parsed.error.issues[0]?.message);
|
||||
@ -131,8 +114,6 @@ export async function appApiRoutes(app: FastifyInstance): Promise<void> {
|
||||
return { success: true, data, error: null };
|
||||
});
|
||||
|
||||
// [废弃] 请使用 POST /rewards/ad-recovery/session + /rewards/ad-recovery/complete 两步流程。
|
||||
// 该接口不做幂等、每日上限和 Plus 分支检查,仅供内部测试或过渡期使用。
|
||||
app.post('/rewards/attempts/restore', async (request) => {
|
||||
const parsed = rewardSourceSchema.safeParse(request.body);
|
||||
if (!parsed.success) return validationError(parsed.error.issues[0]?.message);
|
||||
@ -140,8 +121,6 @@ export async function appApiRoutes(app: FastifyInstance): Promise<void> {
|
||||
return { success: true, data, error: null };
|
||||
});
|
||||
|
||||
// [废弃] 请使用 POST /rewards/ad-recovery/session + /rewards/ad-recovery/complete 两步流程。
|
||||
// 该接口不做幂等、冷却期和 Plus 分支检查,仅供内部测试或过渡期使用。
|
||||
app.post('/rewards/streak/protect', async (request) => {
|
||||
const parsed = rewardSourceSchema.safeParse(request.body);
|
||||
if (!parsed.success) return validationError(parsed.error.issues[0]?.message);
|
||||
@ -159,37 +138,18 @@ export async function appApiRoutes(app: FastifyInstance): Promise<void> {
|
||||
parsed.data.page,
|
||||
parsed.data.limit,
|
||||
);
|
||||
return { success: true, data: data.items, meta: data.meta, pagination: data.pagination, error: null };
|
||||
return { success: true, data: data.items, pagination: data.pagination, error: null };
|
||||
});
|
||||
|
||||
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);
|
||||
return { success: true, data: data?.entry ?? null, meta: data?.meta ?? null, error: null };
|
||||
return { success: true, data, error: null };
|
||||
});
|
||||
|
||||
app.get('/shop', async () => {
|
||||
const data = await getShopCatalog();
|
||||
return { success: true, data, error: null };
|
||||
});
|
||||
|
||||
app.post('/shop/purchase', async (request) => {
|
||||
const parsed = shopPurchaseSchema.safeParse(request.body);
|
||||
if (!parsed.success) return validationError(parsed.error.issues[0]?.message);
|
||||
const data = await purchaseShopProduct(getUserId(request), parsed.data.productId, parsed.data.clientRequestId);
|
||||
return { success: true, data, error: null };
|
||||
});
|
||||
|
||||
app.post('/inventory/items/use', async (request) => {
|
||||
const parsed = useItemSchema.safeParse(request.body);
|
||||
if (!parsed.success) return validationError(parsed.error.issues[0]?.message);
|
||||
const data = await useInventoryItem({
|
||||
userId: getUserId(request),
|
||||
itemId: parsed.data.itemId,
|
||||
clientRequestId: parsed.data.clientRequestId,
|
||||
questionId: parsed.data.questionId,
|
||||
});
|
||||
const data = await getShopBenefits();
|
||||
return { success: true, data, error: null };
|
||||
});
|
||||
|
||||
|
||||
@ -4,9 +4,8 @@ import { getAchievements, checkAchievements } from '../services/gamification/ach
|
||||
|
||||
export async function gamificationRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get('/leaderboard', async (request) => {
|
||||
const userId = (request.user as { userId: string }).userId;
|
||||
const { tier, page = '1', limit = '20' } = request.query as Record<string, string>;
|
||||
const data = await getLeaderboard(userId, tier, Number(page), Number(limit));
|
||||
const data = await getLeaderboard(tier, Number(page), Number(limit));
|
||||
return { success: true, data: data.items, pagination: data.pagination, error: null };
|
||||
});
|
||||
|
||||
|
||||
@ -38,7 +38,6 @@ export async function progressRoutes(app: FastifyInstance): Promise<void> {
|
||||
return { success: true, data, error: null };
|
||||
});
|
||||
|
||||
// [废弃] 请使用 POST /rewards/ad-recovery/session + /rewards/ad-recovery/complete 两步流程。
|
||||
app.post('/progress/hearts/restore', async (request) => {
|
||||
const parsed = restoreHeartsSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
|
||||
@ -1,48 +0,0 @@
|
||||
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<void> {
|
||||
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 };
|
||||
});
|
||||
}
|
||||
@ -1,111 +0,0 @@
|
||||
import { db } from '../../db/client.js';
|
||||
import { adRecoverySessions, inventoryTransactions, rewardLedger, userInventoryItems, userWallets } from '../../db/schema.js';
|
||||
import { desc, eq, sql } from 'drizzle-orm';
|
||||
|
||||
// ── 用户钱包 ────────────────────────────────────────────────────────
|
||||
|
||||
export async function getUserWallet(userId: string) {
|
||||
const [wallet] = await db
|
||||
.select()
|
||||
.from(userWallets)
|
||||
.where(eq(userWallets.userId, userId))
|
||||
.limit(1);
|
||||
return wallet ?? null;
|
||||
}
|
||||
|
||||
// ── 用户道具库存 ────────────────────────────────────────────────────
|
||||
|
||||
export async function getUserInventory(userId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(userInventoryItems)
|
||||
.where(eq(userInventoryItems.userId, userId))
|
||||
.orderBy(desc(userInventoryItems.quantity));
|
||||
}
|
||||
|
||||
// ── 用户奖励流水 ────────────────────────────────────────────────────
|
||||
|
||||
export async function getUserRewardLedger(
|
||||
userId: string,
|
||||
page = 1,
|
||||
limit = 20,
|
||||
): Promise<{ items: unknown[]; pagination: { total: number; page: number; limit: number } }> {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const [items, countResult] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(rewardLedger)
|
||||
.where(eq(rewardLedger.userId, userId))
|
||||
.orderBy(desc(rewardLedger.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db
|
||||
.select({ count: sql<number>`COUNT(*)` })
|
||||
.from(rewardLedger)
|
||||
.where(eq(rewardLedger.userId, userId)),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
pagination: { total: Number(countResult[0]?.count ?? 0), page, limit },
|
||||
};
|
||||
}
|
||||
|
||||
// ── 用户广告恢复记录 ────────────────────────────────────────────────
|
||||
|
||||
export async function getUserAdRecoverySessions(
|
||||
userId: string,
|
||||
page = 1,
|
||||
limit = 20,
|
||||
): Promise<{ items: unknown[]; pagination: { total: number; page: number; limit: number } }> {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const [items, countResult] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(adRecoverySessions)
|
||||
.where(eq(adRecoverySessions.userId, userId))
|
||||
.orderBy(desc(adRecoverySessions.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db
|
||||
.select({ count: sql<number>`COUNT(*)` })
|
||||
.from(adRecoverySessions)
|
||||
.where(eq(adRecoverySessions.userId, userId)),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
pagination: { total: Number(countResult[0]?.count ?? 0), page, limit },
|
||||
};
|
||||
}
|
||||
|
||||
// ── 用户资源变更流水 ────────────────────────────────────────────────
|
||||
|
||||
export async function getUserInventoryTransactions(
|
||||
userId: string,
|
||||
page = 1,
|
||||
limit = 20,
|
||||
): Promise<{ items: unknown[]; pagination: { total: number; page: number; limit: number } }> {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const [items, countResult] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(inventoryTransactions)
|
||||
.where(eq(inventoryTransactions.userId, userId))
|
||||
.orderBy(desc(inventoryTransactions.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db
|
||||
.select({ count: sql<number>`COUNT(*)` })
|
||||
.from(inventoryTransactions)
|
||||
.where(eq(inventoryTransactions.userId, userId)),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
pagination: { total: Number(countResult[0]?.count ?? 0), page, limit },
|
||||
};
|
||||
}
|
||||
@ -3,10 +3,8 @@ import { users } from '../../db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getProgressSummary, getLevelInfo } from '../learning/progress-summary-service.js';
|
||||
import { getThemeTracks } from '../learning/tracks-service.js';
|
||||
import { getShopCatalog } from '../shop/shop-service.js';
|
||||
import { getShopBenefits } 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 type { BootstrapDto, SubscriptionTier } from '../../types/app-api.js';
|
||||
|
||||
export async function getBootstrap(userId: string): Promise<BootstrapDto> {
|
||||
@ -22,13 +20,11 @@ export async function getBootstrap(userId: string): Promise<BootstrapDto> {
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
const [progress, tracks, shop, subscription, coinsBalance, inventory] = await Promise.all([
|
||||
const [progress, tracks, shopBenefits, subscription] = await Promise.all([
|
||||
getProgressSummary(userId),
|
||||
getThemeTracks(userId),
|
||||
getShopCatalog(),
|
||||
getShopBenefits(),
|
||||
getClientSubscription(userId),
|
||||
getCoinBalance(userId),
|
||||
getClientInventory(userId),
|
||||
]);
|
||||
|
||||
const xp = user?.xpTotal ?? progress.xp;
|
||||
@ -44,12 +40,7 @@ export async function getBootstrap(userId: string): Promise<BootstrapDto> {
|
||||
},
|
||||
progress,
|
||||
tracks,
|
||||
shopBenefits: shop.benefits,
|
||||
shop,
|
||||
wallet: {
|
||||
coinsBalance,
|
||||
},
|
||||
inventory,
|
||||
shopBenefits,
|
||||
subscription,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,162 +0,0 @@
|
||||
import { db } from '../../db/client.js';
|
||||
import { rewardLedger } from '../../db/schema.js';
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { grantCoins, type CoinReward } from './coin-service.js';
|
||||
import { CHEST_RULES, COIN_RULES } from './rules.js';
|
||||
|
||||
type RandomSource = () => number;
|
||||
|
||||
export interface ChestRewardContext {
|
||||
userId: string;
|
||||
sourceId: string;
|
||||
idempotencyKey?: string;
|
||||
comboCount?: number;
|
||||
highRewardEligible?: boolean;
|
||||
random?: RandomSource;
|
||||
snapshot?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ChestRollContext {
|
||||
comboCount?: number;
|
||||
highRewardEligible?: boolean;
|
||||
}
|
||||
|
||||
export interface ChestRewardMiss {
|
||||
type: 'chest';
|
||||
source: 'chest';
|
||||
opened: false;
|
||||
roll: number;
|
||||
dropRate: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface ChestRewardOpened {
|
||||
type: 'chest';
|
||||
source: 'chest';
|
||||
opened: true;
|
||||
roll: number;
|
||||
dropRate: number;
|
||||
coinAmount: number;
|
||||
title: string;
|
||||
reward: CoinReward;
|
||||
}
|
||||
|
||||
export type ChestRewardResult = ChestRewardMiss | ChestRewardOpened;
|
||||
|
||||
export function calculateChestDropRate(context: ChestRollContext = {}): number {
|
||||
const comboBoost = (context.comboCount ?? 0) >= CHEST_RULES.comboBoostAt
|
||||
? CHEST_RULES.comboDropRateBonus
|
||||
: 0;
|
||||
const rawRate = CHEST_RULES.baseDropRate + comboBoost;
|
||||
const degradedRate = context.highRewardEligible === false
|
||||
? rawRate * CHEST_RULES.highRewardExhaustedDropRateMultiplier
|
||||
: rawRate;
|
||||
|
||||
return clampRate(degradedRate);
|
||||
}
|
||||
|
||||
export function calculateChestCoinAmount(roll: number): number {
|
||||
const safeRoll = clampRate(roll);
|
||||
const range = COIN_RULES.chestMax - COIN_RULES.chestMin + 1;
|
||||
return COIN_RULES.chestMin + Math.floor(safeRoll * range);
|
||||
}
|
||||
|
||||
export async function openChestReward(context: ChestRewardContext): Promise<ChestRewardResult> {
|
||||
const idempotencyKey = context.idempotencyKey ?? `chest:${context.sourceId}`;
|
||||
const [existing] = await db
|
||||
.select({ rewardSnapshot: rewardLedger.rewardSnapshot })
|
||||
.from(rewardLedger)
|
||||
.where(and(
|
||||
eq(rewardLedger.userId, context.userId),
|
||||
eq(rewardLedger.idempotencyKey, idempotencyKey),
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
const existingResult = toChestRewardResult(existing?.rewardSnapshot);
|
||||
if (existingResult) return existingResult;
|
||||
|
||||
const random = context.random ?? Math.random;
|
||||
const dropRate = calculateChestDropRate(context);
|
||||
const roll = clampRate(random());
|
||||
|
||||
if (roll >= dropRate) {
|
||||
const result: ChestRewardMiss = {
|
||||
type: 'chest',
|
||||
source: 'chest',
|
||||
opened: false,
|
||||
roll,
|
||||
dropRate,
|
||||
title: '宝箱未掉落',
|
||||
};
|
||||
await recordChestAttempt(context, idempotencyKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
const amountRoll = clampRate(random());
|
||||
const coinAmount = calculateChestCoinAmount(amountRoll);
|
||||
const rewardResult = await grantCoins({
|
||||
userId: context.userId,
|
||||
source: 'chest',
|
||||
sourceId: context.sourceId,
|
||||
idempotencyKey: `${idempotencyKey}:coins`,
|
||||
amount: coinAmount,
|
||||
snapshot: {
|
||||
roll,
|
||||
amountRoll,
|
||||
dropRate,
|
||||
comboCount: context.comboCount ?? 0,
|
||||
highRewardEligible: context.highRewardEligible ?? true,
|
||||
...(context.snapshot ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
const result: ChestRewardOpened = {
|
||||
type: 'chest',
|
||||
source: 'chest',
|
||||
opened: true,
|
||||
roll,
|
||||
dropRate,
|
||||
coinAmount: rewardResult.reward.amount,
|
||||
title: `宝箱开出 ${rewardResult.reward.amount} 金币`,
|
||||
reward: rewardResult.reward,
|
||||
};
|
||||
await recordChestAttempt(context, idempotencyKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function clampRate(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
async function recordChestAttempt(
|
||||
context: ChestRewardContext,
|
||||
idempotencyKey: string,
|
||||
result: ChestRewardResult,
|
||||
): Promise<void> {
|
||||
// 宝箱未掉落也要记录,避免同一个业务事件重试时重新抽奖。
|
||||
await db.insert(rewardLedger).values({
|
||||
id: uuid(),
|
||||
userId: context.userId,
|
||||
sourceType: 'chest',
|
||||
sourceId: context.sourceId,
|
||||
idempotencyKey,
|
||||
status: 'completed',
|
||||
rewardSnapshot: {
|
||||
result,
|
||||
comboCount: context.comboCount ?? 0,
|
||||
highRewardEligible: context.highRewardEligible ?? true,
|
||||
...(context.snapshot ?? {}),
|
||||
},
|
||||
resourceDeltas: result.opened ? { coins: result.coinAmount } : { coins: 0 },
|
||||
settledAt: sql`NOW()`,
|
||||
});
|
||||
}
|
||||
|
||||
function toChestRewardResult(snapshot: unknown): ChestRewardResult | null {
|
||||
if (!snapshot || typeof snapshot !== 'object' || !('result' in snapshot)) return null;
|
||||
const result = (snapshot as { result?: unknown }).result;
|
||||
if (!result || typeof result !== 'object' || !('type' in result) || !('opened' in result)) return null;
|
||||
return result as ChestRewardResult;
|
||||
}
|
||||
@ -1,338 +0,0 @@
|
||||
import { db } from '../../db/client.js';
|
||||
import { inventoryTransactions, rewardLedger, userDailyProgress, userWallets } from '../../db/schema.js';
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { ValidationError } from '../../utils/errors.js';
|
||||
import { COIN_RULES } from './rules.js';
|
||||
|
||||
export type CoinRewardSource =
|
||||
| 'first_daily_challenge'
|
||||
| 'daily_task'
|
||||
| 'level_up'
|
||||
| 'theme_node'
|
||||
| 'chest'
|
||||
| 'leaderboard_settlement';
|
||||
|
||||
type CoinLedgerSource = 'challenge_completion' | 'daily_task' | 'level_up' | 'theme_node' | 'chest' | 'leaderboard_settlement';
|
||||
type CoinInventorySource = 'challenge' | 'daily_task' | 'level_up' | 'theme_node' | 'chest' | 'leaderboard_settlement';
|
||||
|
||||
export interface CoinReward {
|
||||
type: 'coin';
|
||||
source: CoinRewardSource;
|
||||
amount: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface GrantCoinsInput {
|
||||
userId: string;
|
||||
source: CoinRewardSource;
|
||||
sourceId: string;
|
||||
idempotencyKey?: string;
|
||||
amount?: number;
|
||||
snapshot?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GrantCoinsResult {
|
||||
reward: CoinReward;
|
||||
granted: boolean;
|
||||
balanceBefore: number;
|
||||
balanceAfter: number;
|
||||
}
|
||||
|
||||
export interface SpendCoinsInput {
|
||||
userId: string;
|
||||
amount: number;
|
||||
sourceId: string;
|
||||
idempotencyKey?: string;
|
||||
snapshot?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SpendCoinsResult {
|
||||
spent: number;
|
||||
applied: boolean;
|
||||
balanceBefore: number;
|
||||
balanceAfter: number;
|
||||
}
|
||||
|
||||
const COIN_REWARD_TITLES: Readonly<Record<CoinRewardSource, string>> = Object.freeze({
|
||||
first_daily_challenge: '每日首组挑战',
|
||||
daily_task: '每日任务',
|
||||
level_up: '升级奖励',
|
||||
theme_node: '主题节点',
|
||||
chest: '宝箱奖励',
|
||||
leaderboard_settlement: '排行榜结算',
|
||||
});
|
||||
|
||||
const REWARD_LEDGER_SOURCE: Readonly<Record<CoinRewardSource, CoinLedgerSource>> = Object.freeze({
|
||||
first_daily_challenge: 'challenge_completion',
|
||||
daily_task: 'daily_task',
|
||||
level_up: 'level_up',
|
||||
theme_node: 'theme_node',
|
||||
chest: 'chest',
|
||||
leaderboard_settlement: 'leaderboard_settlement',
|
||||
});
|
||||
|
||||
const INVENTORY_SOURCE: Readonly<Record<CoinRewardSource, CoinInventorySource>> = Object.freeze({
|
||||
first_daily_challenge: 'challenge',
|
||||
daily_task: 'daily_task',
|
||||
level_up: 'level_up',
|
||||
theme_node: 'theme_node',
|
||||
chest: 'chest',
|
||||
leaderboard_settlement: 'leaderboard_settlement',
|
||||
});
|
||||
|
||||
export function getCoinRewardAmount(source: CoinRewardSource, amount?: number): number {
|
||||
switch (source) {
|
||||
case 'first_daily_challenge':
|
||||
return COIN_RULES.firstDailyChallenge;
|
||||
case 'daily_task':
|
||||
return clampCoins(amount, COIN_RULES.dailyTaskMin, COIN_RULES.dailyTaskMax);
|
||||
case 'level_up':
|
||||
return COIN_RULES.levelUp;
|
||||
case 'theme_node':
|
||||
return COIN_RULES.themeNode;
|
||||
case 'chest':
|
||||
return clampCoins(amount, COIN_RULES.chestMin, COIN_RULES.chestMax);
|
||||
case 'leaderboard_settlement':
|
||||
return amount ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function createCoinReward(source: CoinRewardSource, amount?: number): CoinReward {
|
||||
const resolvedAmount = getCoinRewardAmount(source, amount);
|
||||
return {
|
||||
type: 'coin',
|
||||
source,
|
||||
amount: resolvedAmount,
|
||||
title: `${COIN_REWARD_TITLES[source]} +${resolvedAmount} 金币`,
|
||||
};
|
||||
}
|
||||
|
||||
export function createFirstDailyChallengeCoinReward(): CoinReward {
|
||||
return createCoinReward('first_daily_challenge');
|
||||
}
|
||||
|
||||
export async function grantCoins(input: GrantCoinsInput): Promise<GrantCoinsResult> {
|
||||
const reward = createCoinReward(input.source, input.amount);
|
||||
const idempotencyKey = input.idempotencyKey ?? `${input.source}:${input.sourceId}`;
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: inventoryTransactions.id })
|
||||
.from(inventoryTransactions)
|
||||
.where(and(
|
||||
eq(inventoryTransactions.userId, input.userId),
|
||||
eq(inventoryTransactions.idempotencyKey, idempotencyKey),
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
const balanceBefore = await getCoinBalance(input.userId);
|
||||
if (existing) {
|
||||
return {
|
||||
reward,
|
||||
granted: false,
|
||||
balanceBefore,
|
||||
balanceAfter: balanceBefore,
|
||||
};
|
||||
}
|
||||
|
||||
const balanceAfter = balanceBefore + reward.amount;
|
||||
await upsertWalletForGrant(input.userId, reward.amount, balanceBefore);
|
||||
|
||||
const stateBefore = { coinsBalance: balanceBefore };
|
||||
const stateAfter = { coinsBalance: balanceAfter };
|
||||
const sourceType = REWARD_LEDGER_SOURCE[input.source];
|
||||
const inventorySource = INVENTORY_SOURCE[input.source];
|
||||
const snapshot = {
|
||||
source: input.source,
|
||||
sourceId: input.sourceId,
|
||||
...(input.snapshot ?? {}),
|
||||
};
|
||||
|
||||
// 钱包流水和奖励流水共用同一个幂等 key,便于审计时从业务来源追到余额变化。
|
||||
await db.insert(inventoryTransactions).values({
|
||||
id: uuid(),
|
||||
userId: input.userId,
|
||||
itemId: 'coins',
|
||||
direction: 'grant',
|
||||
quantityDelta: reward.amount,
|
||||
balanceAfter,
|
||||
sourceType: inventorySource,
|
||||
sourceId: input.sourceId,
|
||||
idempotencyKey,
|
||||
snapshot,
|
||||
});
|
||||
|
||||
await db.insert(rewardLedger).values({
|
||||
id: uuid(),
|
||||
userId: input.userId,
|
||||
sourceType,
|
||||
sourceId: input.sourceId,
|
||||
idempotencyKey,
|
||||
status: 'completed',
|
||||
rewardSnapshot: {
|
||||
rewards: [reward],
|
||||
...snapshot,
|
||||
},
|
||||
resourceDeltas: {
|
||||
coins: reward.amount,
|
||||
},
|
||||
stateBefore,
|
||||
stateAfter,
|
||||
settledAt: sql`NOW()`,
|
||||
});
|
||||
|
||||
await incrementDailyCoins(input.userId, reward.amount);
|
||||
return { reward, granted: true, balanceBefore, balanceAfter };
|
||||
}
|
||||
|
||||
export async function grantFirstDailyChallengeCoins(
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
snapshot: Record<string, unknown> = {},
|
||||
): Promise<CoinReward | null> {
|
||||
const result = await grantCoins({
|
||||
userId,
|
||||
source: 'first_daily_challenge',
|
||||
sourceId: sessionId,
|
||||
idempotencyKey: `first_daily_challenge:${sessionId}`,
|
||||
snapshot,
|
||||
});
|
||||
|
||||
return result.granted ? result.reward : null;
|
||||
}
|
||||
|
||||
export async function getCoinBalance(userId: string): Promise<number> {
|
||||
const [wallet] = await db
|
||||
.select({ coinsBalance: userWallets.coinsBalance })
|
||||
.from(userWallets)
|
||||
.where(eq(userWallets.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
return wallet?.coinsBalance ?? 0;
|
||||
}
|
||||
|
||||
export async function spendCoins(input: SpendCoinsInput): Promise<SpendCoinsResult> {
|
||||
const amount = normalizeSpendAmount(input.amount);
|
||||
const idempotencyKey = input.idempotencyKey ?? `shop_purchase:${input.sourceId}:coins`;
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: inventoryTransactions.id })
|
||||
.from(inventoryTransactions)
|
||||
.where(and(
|
||||
eq(inventoryTransactions.userId, input.userId),
|
||||
eq(inventoryTransactions.idempotencyKey, idempotencyKey),
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
const balanceBefore = await getCoinBalance(input.userId);
|
||||
if (existing) {
|
||||
return {
|
||||
spent: amount,
|
||||
applied: false,
|
||||
balanceBefore,
|
||||
balanceAfter: balanceBefore,
|
||||
};
|
||||
}
|
||||
|
||||
if (balanceBefore < amount) {
|
||||
throw new ValidationError('金币余额不足');
|
||||
}
|
||||
|
||||
const balanceAfter = balanceBefore - amount;
|
||||
await db
|
||||
.update(userWallets)
|
||||
.set({
|
||||
coinsBalance: sql`GREATEST(COALESCE(coins_balance, 0) - ${amount}, 0)`,
|
||||
lifetimeCoinsSpent: sql`COALESCE(lifetime_coins_spent, 0) + ${amount}`,
|
||||
})
|
||||
.where(eq(userWallets.userId, input.userId));
|
||||
|
||||
await db.insert(inventoryTransactions).values({
|
||||
id: uuid(),
|
||||
userId: input.userId,
|
||||
itemId: 'coins',
|
||||
direction: 'consume',
|
||||
quantityDelta: -amount,
|
||||
balanceAfter,
|
||||
sourceType: 'shop_purchase',
|
||||
sourceId: input.sourceId,
|
||||
idempotencyKey,
|
||||
snapshot: {
|
||||
source: 'shop_purchase',
|
||||
sourceId: input.sourceId,
|
||||
coinsBalanceBefore: balanceBefore,
|
||||
coinsBalanceAfter: balanceAfter,
|
||||
...(input.snapshot ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
return { spent: amount, applied: true, balanceBefore, balanceAfter };
|
||||
}
|
||||
|
||||
async function upsertWalletForGrant(userId: string, amount: number, balanceBefore: number): Promise<void> {
|
||||
if (balanceBefore === 0) {
|
||||
const [wallet] = await db
|
||||
.select({ userId: userWallets.userId })
|
||||
.from(userWallets)
|
||||
.where(eq(userWallets.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!wallet) {
|
||||
await db.insert(userWallets).values({
|
||||
userId,
|
||||
coinsBalance: amount,
|
||||
lifetimeCoinsEarned: amount,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(userWallets)
|
||||
.set({
|
||||
coinsBalance: sql`COALESCE(coins_balance, 0) + ${amount}`,
|
||||
lifetimeCoinsEarned: sql`COALESCE(lifetime_coins_earned, 0) + ${amount}`,
|
||||
})
|
||||
.where(eq(userWallets.userId, userId));
|
||||
}
|
||||
|
||||
async function incrementDailyCoins(userId: string, amount: number): Promise<void> {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const [daily] = await db
|
||||
.select({ id: userDailyProgress.id })
|
||||
.from(userDailyProgress)
|
||||
.where(and(
|
||||
eq(userDailyProgress.userId, userId),
|
||||
eq(userDailyProgress.progressDate, sql`CAST(${today} AS DATE)`),
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (!daily) {
|
||||
await db.insert(userDailyProgress).values({
|
||||
id: uuid(),
|
||||
userId,
|
||||
progressDate: sql`CAST(${today} AS DATE)`,
|
||||
coinsEarned: amount,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(userDailyProgress)
|
||||
.set({
|
||||
coinsEarned: sql`COALESCE(coins_earned, 0) + ${amount}`,
|
||||
})
|
||||
.where(eq(userDailyProgress.id, daily.id));
|
||||
}
|
||||
|
||||
function clampCoins(value: number | undefined, min: number, max: number): number {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) return min;
|
||||
return Math.max(min, Math.min(max, Math.floor(value)));
|
||||
}
|
||||
|
||||
function normalizeSpendAmount(value: number): number {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
throw new ValidationError('金币消耗数量必须大于 0');
|
||||
}
|
||||
return Math.floor(value);
|
||||
}
|
||||
@ -1,356 +0,0 @@
|
||||
import { db } from '../../db/client.js';
|
||||
import { inventoryTransactions, rewardLedger, userInventoryItems } from '../../db/schema.js';
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { ValidationError } from '../../utils/errors.js';
|
||||
import { ITEM_RULES, type InventoryItemId } from './rules.js';
|
||||
import type { InventoryDto, InventoryItemDto } from '../../types/app-api.js';
|
||||
|
||||
export type InventorySourceType =
|
||||
| 'challenge'
|
||||
| 'daily_task'
|
||||
| 'level_up'
|
||||
| 'theme_node'
|
||||
| 'chest'
|
||||
| 'shop_purchase'
|
||||
| 'ad_recovery'
|
||||
| 'subscription'
|
||||
| 'admin_grant'
|
||||
| 'system_adjust';
|
||||
|
||||
type RewardLedgerSourceType =
|
||||
| 'challenge_completion'
|
||||
| 'daily_task'
|
||||
| 'level_up'
|
||||
| 'theme_node'
|
||||
| 'chest'
|
||||
| 'shop_purchase'
|
||||
| 'ad_recovery'
|
||||
| 'subscription'
|
||||
| 'admin_grant'
|
||||
| 'system_adjust';
|
||||
|
||||
export interface InventoryItemSnapshot {
|
||||
itemId: InventoryItemId;
|
||||
quantity: number;
|
||||
activeUntil: Date | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface GrantInventoryItemInput {
|
||||
userId: string;
|
||||
itemId: InventoryItemId;
|
||||
quantity?: number;
|
||||
sourceType: InventorySourceType;
|
||||
sourceId: string;
|
||||
idempotencyKey?: string;
|
||||
activeUntil?: Date | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
snapshot?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ConsumeInventoryItemInput {
|
||||
userId: string;
|
||||
itemId: InventoryItemId;
|
||||
quantity?: number;
|
||||
sourceType: InventorySourceType;
|
||||
sourceId: string;
|
||||
idempotencyKey?: string;
|
||||
snapshot?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface InventoryMutationResult {
|
||||
item: InventoryItemSnapshot;
|
||||
quantityDelta: number;
|
||||
applied: boolean;
|
||||
}
|
||||
|
||||
const ITEM_TITLES: Readonly<Record<InventoryItemId, string>> = Object.freeze({
|
||||
streak_shield: '连胜护盾',
|
||||
double_xp_potion: '双倍 XP 药水',
|
||||
heart_supply: '爱心补给',
|
||||
hint_feather: '提示羽毛',
|
||||
mascot_outfit: '吉祥物装扮',
|
||||
});
|
||||
|
||||
const REWARD_LEDGER_SOURCE: Readonly<Record<InventorySourceType, RewardLedgerSourceType>> = Object.freeze({
|
||||
challenge: 'challenge_completion',
|
||||
daily_task: 'daily_task',
|
||||
level_up: 'level_up',
|
||||
theme_node: 'theme_node',
|
||||
chest: 'chest',
|
||||
shop_purchase: 'shop_purchase',
|
||||
ad_recovery: 'ad_recovery',
|
||||
subscription: 'subscription',
|
||||
admin_grant: 'admin_grant',
|
||||
system_adjust: 'system_adjust',
|
||||
});
|
||||
|
||||
export function createInventoryReward(itemId: InventoryItemId, quantity = 1) {
|
||||
const safeQuantity = normalizeQuantity(quantity);
|
||||
return {
|
||||
type: 'item' as const,
|
||||
source: 'inventory' as const,
|
||||
itemId,
|
||||
quantity: safeQuantity,
|
||||
title: `${ITEM_TITLES[itemId]} x${safeQuantity}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getInventoryItem(userId: string, itemId: InventoryItemId): Promise<InventoryItemSnapshot> {
|
||||
const [item] = await db
|
||||
.select({
|
||||
itemId: userInventoryItems.itemId,
|
||||
quantity: userInventoryItems.quantity,
|
||||
activeUntil: userInventoryItems.activeUntil,
|
||||
metadata: userInventoryItems.metadata,
|
||||
})
|
||||
.from(userInventoryItems)
|
||||
.where(and(
|
||||
eq(userInventoryItems.userId, userId),
|
||||
eq(userInventoryItems.itemId, itemId),
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
return {
|
||||
itemId,
|
||||
quantity: item?.quantity ?? 0,
|
||||
activeUntil: item?.activeUntil ?? null,
|
||||
metadata: item?.metadata ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getClientInventory(userId: string): Promise<InventoryDto> {
|
||||
const rows = await db
|
||||
.select({
|
||||
itemId: userInventoryItems.itemId,
|
||||
quantity: userInventoryItems.quantity,
|
||||
activeUntil: userInventoryItems.activeUntil,
|
||||
metadata: userInventoryItems.metadata,
|
||||
})
|
||||
.from(userInventoryItems)
|
||||
.where(eq(userInventoryItems.userId, userId));
|
||||
|
||||
return {
|
||||
items: rows.map(toInventoryItemDto),
|
||||
};
|
||||
}
|
||||
|
||||
export async function grantInventoryItem(input: GrantInventoryItemInput): Promise<InventoryMutationResult> {
|
||||
const quantity = normalizeQuantity(input.quantity);
|
||||
const idempotencyKey = input.idempotencyKey ?? `grant_item:${input.itemId}:${input.sourceType}:${input.sourceId}`;
|
||||
const existing = await getExistingTransaction(input.userId, idempotencyKey);
|
||||
const itemBefore = await getInventoryItem(input.userId, input.itemId);
|
||||
|
||||
if (existing) {
|
||||
return { item: itemBefore, quantityDelta: 0, applied: false };
|
||||
}
|
||||
|
||||
const itemAfter = await upsertInventoryForGrant(input, quantity, itemBefore);
|
||||
await db.insert(inventoryTransactions).values({
|
||||
id: uuid(),
|
||||
userId: input.userId,
|
||||
inventoryItemId: await getInventoryRowId(input.userId, input.itemId),
|
||||
itemId: input.itemId,
|
||||
direction: 'grant',
|
||||
quantityDelta: quantity,
|
||||
balanceAfter: itemAfter.quantity,
|
||||
sourceType: input.sourceType,
|
||||
sourceId: input.sourceId,
|
||||
idempotencyKey,
|
||||
snapshot: buildSnapshot(input, itemBefore, itemAfter),
|
||||
});
|
||||
|
||||
const reward = createInventoryReward(input.itemId, quantity);
|
||||
await db.insert(rewardLedger).values({
|
||||
id: uuid(),
|
||||
userId: input.userId,
|
||||
sourceType: REWARD_LEDGER_SOURCE[input.sourceType],
|
||||
sourceId: input.sourceId,
|
||||
idempotencyKey,
|
||||
status: 'completed',
|
||||
rewardSnapshot: {
|
||||
rewards: [reward],
|
||||
...buildSnapshot(input, itemBefore, itemAfter),
|
||||
},
|
||||
resourceDeltas: {
|
||||
items: [{ itemId: input.itemId, quantity }],
|
||||
},
|
||||
stateBefore: { item: itemBefore },
|
||||
stateAfter: { item: itemAfter },
|
||||
settledAt: sql`NOW()`,
|
||||
});
|
||||
|
||||
return { item: itemAfter, quantityDelta: quantity, applied: true };
|
||||
}
|
||||
|
||||
export async function consumeInventoryItem(input: ConsumeInventoryItemInput): Promise<InventoryMutationResult> {
|
||||
const quantity = normalizeQuantity(input.quantity);
|
||||
const idempotencyKey = input.idempotencyKey ?? `consume_item:${input.itemId}:${input.sourceType}:${input.sourceId}`;
|
||||
const existing = await getExistingTransaction(input.userId, idempotencyKey);
|
||||
const itemBefore = await getInventoryItem(input.userId, input.itemId);
|
||||
|
||||
if (existing) {
|
||||
return { item: itemBefore, quantityDelta: 0, applied: false };
|
||||
}
|
||||
|
||||
if (itemBefore.quantity < quantity) {
|
||||
throw new ValidationError(`${ITEM_TITLES[input.itemId]}库存不足`);
|
||||
}
|
||||
|
||||
const itemAfter: InventoryItemSnapshot = {
|
||||
...itemBefore,
|
||||
quantity: itemBefore.quantity - quantity,
|
||||
};
|
||||
|
||||
await db
|
||||
.update(userInventoryItems)
|
||||
.set({
|
||||
quantity: sql`GREATEST(COALESCE(quantity, 0) - ${quantity}, 0)`,
|
||||
})
|
||||
.where(and(
|
||||
eq(userInventoryItems.userId, input.userId),
|
||||
eq(userInventoryItems.itemId, input.itemId),
|
||||
));
|
||||
|
||||
await db.insert(inventoryTransactions).values({
|
||||
id: uuid(),
|
||||
userId: input.userId,
|
||||
inventoryItemId: await getInventoryRowId(input.userId, input.itemId),
|
||||
itemId: input.itemId,
|
||||
direction: 'consume',
|
||||
quantityDelta: -quantity,
|
||||
balanceAfter: itemAfter.quantity,
|
||||
sourceType: input.sourceType,
|
||||
sourceId: input.sourceId,
|
||||
idempotencyKey,
|
||||
snapshot: buildSnapshot(input, itemBefore, itemAfter),
|
||||
});
|
||||
|
||||
return { item: itemAfter, quantityDelta: -quantity, applied: true };
|
||||
}
|
||||
|
||||
async function upsertInventoryForGrant(
|
||||
input: GrantInventoryItemInput,
|
||||
quantity: number,
|
||||
itemBefore: InventoryItemSnapshot,
|
||||
): Promise<InventoryItemSnapshot> {
|
||||
const activeUntil = resolveActiveUntil(input);
|
||||
const metadata = input.metadata ?? itemBefore.metadata;
|
||||
|
||||
if (itemBefore.quantity === 0) {
|
||||
const [row] = await db
|
||||
.select({ id: userInventoryItems.id })
|
||||
.from(userInventoryItems)
|
||||
.where(and(
|
||||
eq(userInventoryItems.userId, input.userId),
|
||||
eq(userInventoryItems.itemId, input.itemId),
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (!row) {
|
||||
await db.insert(userInventoryItems).values({
|
||||
id: uuid(),
|
||||
userId: input.userId,
|
||||
itemId: input.itemId,
|
||||
quantity,
|
||||
activeUntil: activeUntil ?? undefined,
|
||||
metadata: metadata ?? undefined,
|
||||
});
|
||||
return { itemId: input.itemId, quantity, activeUntil, metadata };
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(userInventoryItems)
|
||||
.set({
|
||||
quantity: sql`COALESCE(quantity, 0) + ${quantity}`,
|
||||
activeUntil: activeUntil ?? undefined,
|
||||
metadata: metadata ?? undefined,
|
||||
})
|
||||
.where(and(
|
||||
eq(userInventoryItems.userId, input.userId),
|
||||
eq(userInventoryItems.itemId, input.itemId),
|
||||
));
|
||||
|
||||
return {
|
||||
itemId: input.itemId,
|
||||
quantity: itemBefore.quantity + quantity,
|
||||
activeUntil,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
async function getExistingTransaction(userId: string, idempotencyKey: string) {
|
||||
const [existing] = await db
|
||||
.select({ id: inventoryTransactions.id })
|
||||
.from(inventoryTransactions)
|
||||
.where(and(
|
||||
eq(inventoryTransactions.userId, userId),
|
||||
eq(inventoryTransactions.idempotencyKey, idempotencyKey),
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
return existing ?? null;
|
||||
}
|
||||
|
||||
async function getInventoryRowId(userId: string, itemId: InventoryItemId): Promise<string | null> {
|
||||
const [row] = await db
|
||||
.select({ id: userInventoryItems.id })
|
||||
.from(userInventoryItems)
|
||||
.where(and(
|
||||
eq(userInventoryItems.userId, userId),
|
||||
eq(userInventoryItems.itemId, itemId),
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
function buildSnapshot(
|
||||
input: GrantInventoryItemInput | ConsumeInventoryItemInput,
|
||||
before: InventoryItemSnapshot,
|
||||
after: InventoryItemSnapshot,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
sourceType: input.sourceType,
|
||||
sourceId: input.sourceId,
|
||||
itemBefore: before,
|
||||
itemAfter: after,
|
||||
...(input.snapshot ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeQuantity(value = 1): number {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
throw new ValidationError('道具数量必须大于 0');
|
||||
}
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
function resolveActiveUntil(input: GrantInventoryItemInput): Date | null {
|
||||
if (input.activeUntil !== undefined) return input.activeUntil;
|
||||
if (input.itemId === ITEM_RULES.doubleXpPotion.id && input.snapshot?.activatedAt === true) {
|
||||
return new Date(Date.now() + ITEM_RULES.doubleXpPotion.durationMs);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toInventoryItemDto(item: {
|
||||
itemId: InventoryItemId;
|
||||
quantity: number | null;
|
||||
activeUntil: Date | string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
}): InventoryItemDto {
|
||||
return {
|
||||
itemId: item.itemId,
|
||||
quantity: item.quantity ?? 0,
|
||||
activeUntil: toIso(item.activeUntil),
|
||||
metadata: item.metadata ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function toIso(value: Date | string | null): string | null {
|
||||
if (!value) return null;
|
||||
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
|
||||
}
|
||||
@ -1,191 +0,0 @@
|
||||
import { db } from '../../db/client.js';
|
||||
import { questions, userInventoryItems, users } from '../../db/schema.js';
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { NotFoundError, ValidationError } from '../../utils/errors.js';
|
||||
import { freezeStreak } from '../progress/streak-service.js';
|
||||
import { HEART_RULES, ITEM_RULES, type InventoryItemId } from './rules.js';
|
||||
import { consumeInventoryItem } from './inventory-service.js';
|
||||
import type { UseInventoryItemResultDto, UsableInventoryItemId } from '../../types/app-api.js';
|
||||
|
||||
export interface UseInventoryItemInput {
|
||||
userId: string;
|
||||
itemId: UsableInventoryItemId;
|
||||
clientRequestId: string;
|
||||
questionId?: string;
|
||||
}
|
||||
|
||||
export async function useInventoryItem(input: UseInventoryItemInput): Promise<UseInventoryItemResultDto> {
|
||||
switch (input.itemId) {
|
||||
case ITEM_RULES.heartSupply.id:
|
||||
return useHeartSupply(input);
|
||||
case ITEM_RULES.doubleXpPotion.id:
|
||||
return useDoubleXpPotion(input);
|
||||
case ITEM_RULES.hintFeather.id:
|
||||
return useHintFeather(input);
|
||||
case ITEM_RULES.streakShield.id:
|
||||
return useStreakShield(input);
|
||||
}
|
||||
}
|
||||
|
||||
async function useHeartSupply(input: UseInventoryItemInput): Promise<UseInventoryItemResultDto> {
|
||||
const consumption = await consumeUseItem(input);
|
||||
const maxHearts = await getMaxHearts(input.userId);
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
heartsRemaining: maxHearts,
|
||||
heartsLastRestore: sql`NOW()`,
|
||||
})
|
||||
.where(eq(users.id, input.userId));
|
||||
|
||||
return {
|
||||
itemId: input.itemId,
|
||||
quantityRemaining: consumption.item.quantity,
|
||||
effect: {
|
||||
type: 'restore_hearts',
|
||||
hearts: maxHearts,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function useDoubleXpPotion(input: UseInventoryItemInput): Promise<UseInventoryItemResultDto> {
|
||||
const consumption = await consumeUseItem(input);
|
||||
if (!consumption.applied) {
|
||||
return {
|
||||
itemId: input.itemId,
|
||||
quantityRemaining: consumption.item.quantity,
|
||||
effect: {
|
||||
type: 'double_xp',
|
||||
activeUntil: consumption.item.activeUntil?.toISOString() ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const activeUntil = new Date(Date.now() + ITEM_RULES.doubleXpPotion.durationMs);
|
||||
await db
|
||||
.update(userInventoryItems)
|
||||
.set({
|
||||
activeUntil,
|
||||
metadata: {
|
||||
activeEffect: 'double_xp',
|
||||
multiplier: ITEM_RULES.doubleXpPotion.multiplier,
|
||||
},
|
||||
})
|
||||
.where(and(
|
||||
eq(userInventoryItems.userId, input.userId),
|
||||
eq(userInventoryItems.itemId, input.itemId),
|
||||
));
|
||||
|
||||
return {
|
||||
itemId: input.itemId,
|
||||
quantityRemaining: consumption.item.quantity,
|
||||
effect: {
|
||||
type: 'double_xp',
|
||||
activeUntil: activeUntil.toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function useHintFeather(input: UseInventoryItemInput): Promise<UseInventoryItemResultDto> {
|
||||
if (!input.questionId) {
|
||||
throw new ValidationError('questionId is required for hint feather');
|
||||
}
|
||||
|
||||
const excludedOptions = await getHintExcludedOptions(input.questionId);
|
||||
const consumption = await consumeUseItem(input, {
|
||||
questionId: input.questionId,
|
||||
excludedOptions,
|
||||
});
|
||||
|
||||
return {
|
||||
itemId: input.itemId,
|
||||
quantityRemaining: consumption.item.quantity,
|
||||
effect: {
|
||||
type: 'hint',
|
||||
excludedOptions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function useStreakShield(input: UseInventoryItemInput): Promise<UseInventoryItemResultDto> {
|
||||
const consumption = await consumeUseItem(input);
|
||||
if (!consumption.applied) {
|
||||
const protectedUntil = await getStreakProtectedUntil(input.userId);
|
||||
return {
|
||||
itemId: input.itemId,
|
||||
quantityRemaining: consumption.item.quantity,
|
||||
effect: {
|
||||
type: 'streak_protection',
|
||||
streakProtectedUntil: protectedUntil,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const protectedUntil = new Date();
|
||||
protectedUntil.setUTCDate(protectedUntil.getUTCDate() + ITEM_RULES.streakShield.protectDays);
|
||||
await db
|
||||
.update(users)
|
||||
.set({ streakProtectedUntil: protectedUntil })
|
||||
.where(eq(users.id, input.userId));
|
||||
await freezeStreak(input.userId);
|
||||
|
||||
return {
|
||||
itemId: input.itemId,
|
||||
quantityRemaining: consumption.item.quantity,
|
||||
effect: {
|
||||
type: 'streak_protection',
|
||||
streakProtectedUntil: protectedUntil.toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function getStreakProtectedUntil(userId: string): Promise<string | null> {
|
||||
const [user] = await db
|
||||
.select({ streakProtectedUntil: users.streakProtectedUntil })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
const value = user?.streakProtectedUntil ?? null;
|
||||
if (!value) return null;
|
||||
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
|
||||
}
|
||||
|
||||
async function consumeUseItem(input: UseInventoryItemInput, snapshot: Record<string, unknown> = {}) {
|
||||
return consumeInventoryItem({
|
||||
userId: input.userId,
|
||||
itemId: input.itemId as InventoryItemId,
|
||||
quantity: 1,
|
||||
sourceType: 'system_adjust',
|
||||
sourceId: `use-item:${input.clientRequestId}`,
|
||||
idempotencyKey: `use-item:${input.clientRequestId}:${input.itemId}`,
|
||||
snapshot,
|
||||
});
|
||||
}
|
||||
|
||||
async function getMaxHearts(userId: string): Promise<number> {
|
||||
const [user] = await db
|
||||
.select({ tier: users.tier })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
return user?.tier === 'pro' || user?.tier === 'proplus'
|
||||
? HEART_RULES.subscribedMax
|
||||
: HEART_RULES.freeMax;
|
||||
}
|
||||
|
||||
async function getHintExcludedOptions(questionId: string): Promise<readonly string[]> {
|
||||
const [question] = await db
|
||||
.select({ distractors: questions.distractors })
|
||||
.from(questions)
|
||||
.where(eq(questions.id, questionId))
|
||||
.limit(1);
|
||||
|
||||
if (!question) throw new NotFoundError('Question');
|
||||
const distractors = Array.isArray(question.distractors)
|
||||
? question.distractors.filter((item): item is string => typeof item === 'string')
|
||||
: [];
|
||||
|
||||
return distractors.slice(0, ITEM_RULES.hintFeather.excludedDistractors);
|
||||
}
|
||||
@ -1,9 +1,7 @@
|
||||
import { db } from '../../db/client.js';
|
||||
import { leaderboardSnapshots, userWeeklyXp, users } from '../../db/schema.js';
|
||||
import { desc, eq, sql } from 'drizzle-orm';
|
||||
import { users, leaderboardSnapshots } from '../../db/schema.js';
|
||||
import { eq, desc, sql } from 'drizzle-orm';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { grantCoins } from './coin-service.js';
|
||||
import { LEADERBOARD_RULES } from './rules.js';
|
||||
|
||||
const TIERS = ['bronze', 'silver', 'gold', 'platinum', 'diamond', 'master', 'grandmaster', 'champion', 'legend', 'mythic'] as const;
|
||||
export type Tier = typeof TIERS[number];
|
||||
@ -12,96 +10,51 @@ export interface LeaderboardEntry {
|
||||
userId: string;
|
||||
nickname: string | null;
|
||||
avatarUrl: string | null;
|
||||
weeklyXp: number;
|
||||
xpTotal: number;
|
||||
rank: number;
|
||||
tier: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算当前自然周的起止日期(UTC)。
|
||||
*
|
||||
* 时区策略:所有周榜计算统一使用 UTC。
|
||||
* - weekStart 为 UTC 周一 00:00:00(由 LEADERBOARD_RULES.weekStartsOnIsoDay=1 配置)
|
||||
* - weekEnd 为 UTC 周日 23:59:59
|
||||
* - 客户端展示时可按用户本地时区转换,但排序和结算以 UTC 为准
|
||||
*
|
||||
* 注意:此函数返回的是「当前所在的自然周」。
|
||||
* 周结算(weeklySettlement)应结算上一周的数据,使用 getPreviousWeekRange()。
|
||||
* Get the current leaderboard, optionally filtered by tier.
|
||||
* Uses live xp_total ranking (not weekly snapshot).
|
||||
*/
|
||||
function getCurrentWeekRange(): { weekStart: Date; weekEnd: Date } {
|
||||
const now = new Date();
|
||||
const targetDay = LEADERBOARD_RULES.weekStartsOnIsoDay;
|
||||
const currentDay = now.getUTCDay() || 7;
|
||||
const diff = (currentDay - targetDay + 7) % 7;
|
||||
const start = new Date(now);
|
||||
start.setUTCDate(now.getUTCDate() - diff);
|
||||
start.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(start);
|
||||
end.setUTCDate(start.getUTCDate() + 6);
|
||||
return { weekStart: start, weekEnd: end };
|
||||
}
|
||||
|
||||
/** 获取上一自然周的起止日期,用于周结算。 */
|
||||
function getPreviousWeekRange(): { weekStart: Date; weekEnd: Date } {
|
||||
const { weekStart } = getCurrentWeekRange();
|
||||
const prevStart = new Date(weekStart);
|
||||
prevStart.setUTCDate(weekStart.getUTCDate() - 7);
|
||||
const prevEnd = new Date(prevStart);
|
||||
prevEnd.setUTCDate(prevStart.getUTCDate() + 6);
|
||||
return { weekStart: prevStart, weekEnd: prevEnd };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户当前周所在的分组 ID。
|
||||
*/
|
||||
async function getUserGroupId(userId: string, weekStartStr: string): Promise<string | null> {
|
||||
const [row] = await db
|
||||
.select({ groupId: userWeeklyXp.groupId })
|
||||
.from(userWeeklyXp)
|
||||
.where(sql`${userWeeklyXp.userId} = ${userId} AND CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr}`)
|
||||
.limit(1);
|
||||
return row?.groupId ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前周排行榜,按用户所在组内本周 XP 排名。
|
||||
* 每个用户只能看到自己组内的 20-30 人排名。
|
||||
*/
|
||||
export async function getLeaderboard(userId: string, _tier?: string, page = 1, limit = 20): Promise<{
|
||||
export async function getLeaderboard(tier?: 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 groupId = await getUserGroupId(userId, weekStartStr);
|
||||
|
||||
// 构建查询条件:基于组内排名。
|
||||
const groupFilter = groupId
|
||||
? sql`CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr} AND ${userWeeklyXp.groupId} = ${groupId}`
|
||||
: sql`CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr}`;
|
||||
|
||||
const allEntries = await db
|
||||
// Simpler approach: rank all users by xp_total
|
||||
const allUsers = await db
|
||||
.select({
|
||||
userId: userWeeklyXp.userId,
|
||||
weeklyXp: userWeeklyXp.xpEarned,
|
||||
id: users.id,
|
||||
nickname: users.nickname,
|
||||
avatarUrl: users.avatarUrl,
|
||||
xpTotal: users.xpTotal,
|
||||
})
|
||||
.from(userWeeklyXp)
|
||||
.innerJoin(users, eq(userWeeklyXp.userId, users.id))
|
||||
.where(groupFilter)
|
||||
.orderBy(desc(userWeeklyXp.xpEarned))
|
||||
.from(users)
|
||||
.orderBy(desc(users.xpTotal))
|
||||
.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,
|
||||
// Filter by tier if specified (tier is determined by rank ranges)
|
||||
let filtered = allUsers;
|
||||
if (tier) {
|
||||
// Each tier covers ~10% of players, roughly 100 per tier for top 1000
|
||||
const tierIndex = TIERS.indexOf(tier as Tier);
|
||||
if (tierIndex >= 0) {
|
||||
const perTier = Math.ceil(allUsers.length / TIERS.length);
|
||||
const start = tierIndex * perTier;
|
||||
filtered = allUsers.slice(start, start + perTier);
|
||||
}
|
||||
}
|
||||
|
||||
const total = filtered.length;
|
||||
const items: LeaderboardEntry[] = filtered.slice(offset, offset + limit).map((u, i) => ({
|
||||
userId: u.id,
|
||||
nickname: u.nickname ?? null,
|
||||
avatarUrl: u.avatarUrl ?? null,
|
||||
xpTotal: u.xpTotal ?? 0,
|
||||
rank: offset + i + 1,
|
||||
tier: getTierForRank(offset + i + 1),
|
||||
}));
|
||||
@ -110,190 +63,74 @@ export async function getLeaderboard(userId: string, _tier?: string, page = 1, l
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户在本周排行榜中的组内排名。
|
||||
* 统计同组内本周 XP 比自己高的用户数量,得出组内排名。
|
||||
* Get a specific user's rank and tier.
|
||||
*/
|
||||
export async function getUserRank(userId: string): Promise<{ rank: number; tier: string; weeklyXp: number } | null> {
|
||||
const { weekStart } = getCurrentWeekRange();
|
||||
const weekStartStr = weekStart.toISOString().slice(0, 10);
|
||||
|
||||
// 获取用户本周 XP 和所在分组。
|
||||
const [userRow] = await db
|
||||
.select({ xpEarned: userWeeklyXp.xpEarned, groupId: userWeeklyXp.groupId })
|
||||
.from(userWeeklyXp)
|
||||
.where(sql`${userWeeklyXp.userId} = ${userId} AND CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr}`)
|
||||
export async function getUserRank(userId: string): Promise<{ rank: number; tier: string } | null> {
|
||||
// Count users with higher XP
|
||||
const [user] = await db
|
||||
.select({ xpTotal: users.xpTotal })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!userRow) return null;
|
||||
const userXp = userRow.xpEarned ?? 0;
|
||||
|
||||
// 统计同组内本周 XP 比自己高的用户数。
|
||||
const groupFilter = userRow.groupId
|
||||
? sql`CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr} AND ${userWeeklyXp.groupId} = ${userRow.groupId}`
|
||||
: sql`CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr}`;
|
||||
if (!user) return null;
|
||||
|
||||
const [higher] = await db
|
||||
.select({ count: sql<number>`COUNT(*)` })
|
||||
.from(userWeeklyXp)
|
||||
.where(sql`${groupFilter} AND COALESCE(${userWeeklyXp.xpEarned}, 0) > ${userXp}`);
|
||||
.from(users)
|
||||
.where(sql`COALESCE(xp_total, 0) > ${user.xpTotal ?? 0}`);
|
||||
|
||||
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<number, number> = new Map([
|
||||
[1, 300],
|
||||
[2, 150],
|
||||
[3, 50],
|
||||
]);
|
||||
|
||||
export interface SettlementResult {
|
||||
settled: boolean;
|
||||
weekStart: string;
|
||||
weekEnd: string;
|
||||
userCount: number;
|
||||
groupCount: number;
|
||||
/** 全局前 3 名预览(dryRun 时展示)。 */
|
||||
top3: Array<{ userId: string; weeklyXp: number; rank: number }>;
|
||||
/** 各组前 3 名实际发放的金币奖励。 */
|
||||
rewards: Array<{ userId: string; groupId: string; rank: number; coins: number }>;
|
||||
return { rank, tier: getTierForRank(rank) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行周结算:按组结算上一自然周的排行榜快照并给每组前 3 名发金币奖励。
|
||||
*
|
||||
* 调用时机:每周一 UTC 00:00 后通过定时任务调用。
|
||||
* 幂等性:
|
||||
* - 快照写入基于 uk_leaderboard_snapshot_user_week 唯一索引(userId + weekStart)
|
||||
* - 金币发放基于 grantCoins 的 idempotencyKey(leaderboard_settlement:{groupId}:{rank}:{userId})
|
||||
* - userWeeklyXp.settled 标记防止重复处理已结算的周
|
||||
*
|
||||
* @param dryRun 为 true 时只返回结算预览,不写入数据库。
|
||||
* Run weekly settlement: promote/demote users based on weekly XP.
|
||||
* Should be called via a scheduled job (cron).
|
||||
*/
|
||||
export async function weeklySettlement(dryRun = false): Promise<SettlementResult> {
|
||||
// 结算上一周的数据(周一时当前周刚开始,上一周才是完整的)。
|
||||
const { weekStart, weekEnd } = getPreviousWeekRange();
|
||||
export async function weeklySettlement(): Promise<void> {
|
||||
const today = new Date();
|
||||
const weekStart = new Date(today);
|
||||
weekStart.setDate(today.getDate() - today.getDay()); // Start of this week (Sunday)
|
||||
const weekEnd = new Date(weekStart);
|
||||
weekEnd.setDate(weekStart.getDate() + 6);
|
||||
|
||||
const weekStartStr = weekStart.toISOString().slice(0, 10);
|
||||
const weekEndStr = weekEnd.toISOString().slice(0, 10);
|
||||
|
||||
// 从 userWeeklyXp 取上一周所有记录,按组内 XP 排名。
|
||||
const allEntries = await db
|
||||
// Get all users ordered by XP
|
||||
const allUsers = await db
|
||||
.select({
|
||||
userId: userWeeklyXp.userId,
|
||||
weeklyXp: userWeeklyXp.xpEarned,
|
||||
groupId: userWeeklyXp.groupId,
|
||||
id: users.id,
|
||||
xpTotal: users.xpTotal,
|
||||
})
|
||||
.from(userWeeklyXp)
|
||||
.where(sql`CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr}`)
|
||||
.orderBy(userWeeklyXp.groupId, desc(userWeeklyXp.xpEarned));
|
||||
.from(users)
|
||||
.orderBy(desc(users.xpTotal));
|
||||
|
||||
// 按组分组,计算组内排名。
|
||||
const groups = new Map<string, Array<{ userId: string; weeklyXp: number; groupRank: number }>>();
|
||||
for (const entry of allEntries) {
|
||||
const gid = entry.groupId ?? 'ungrouped';
|
||||
if (!groups.has(gid)) groups.set(gid, []);
|
||||
groups.get(gid)!.push({
|
||||
userId: entry.userId,
|
||||
weeklyXp: entry.weeklyXp ?? 0,
|
||||
groupRank: groups.get(gid)!.length + 1,
|
||||
});
|
||||
}
|
||||
const perTier = Math.max(1, Math.ceil(allUsers.length / TIERS.length));
|
||||
|
||||
// 收集全局前 3 名(跨组最高 XP)。
|
||||
const globalTop3 = [...allEntries]
|
||||
.sort((a, b) => (b.weeklyXp ?? 0) - (a.weeklyXp ?? 0))
|
||||
.slice(0, 3)
|
||||
.map((entry, i) => ({
|
||||
userId: entry.userId,
|
||||
weeklyXp: entry.weeklyXp ?? 0,
|
||||
rank: i + 1,
|
||||
}));
|
||||
|
||||
// 收集各组前 3 名的奖励。
|
||||
const rewards: Array<{ userId: string; groupId: string; rank: number; coins: number }> = [];
|
||||
for (const [groupId, members] of groups) {
|
||||
for (const member of members) {
|
||||
if (LEADERBOARD_RULES.topRewardRanks.includes(member.groupRank as 1 | 2 | 3)) {
|
||||
const coins = TOP_REWARD_COINS.get(member.groupRank) ?? 0;
|
||||
if (coins > 0) {
|
||||
rewards.push({ userId: member.userId, groupId, rank: member.groupRank, coins });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
return {
|
||||
settled: false,
|
||||
weekStart: weekStartStr,
|
||||
weekEnd: weekEndStr,
|
||||
userCount: allEntries.length,
|
||||
groupCount: groups.size,
|
||||
top3: globalTop3,
|
||||
rewards,
|
||||
};
|
||||
}
|
||||
|
||||
// 为每个用户创建排行榜快照(幂等:已存在则更新)。
|
||||
const perTier = Math.max(1, Math.ceil(allEntries.length / TIERS.length));
|
||||
for (let i = 0; i < allEntries.length; i++) {
|
||||
const entry = allEntries[i]!;
|
||||
// Create leaderboard snapshots
|
||||
for (let i = 0; i < allUsers.length; i++) {
|
||||
const user = allUsers[i]!;
|
||||
const rank = i + 1;
|
||||
const tierIndex = Math.min(Math.floor(rank / perTier), TIERS.length - 1);
|
||||
const tier = TIERS[tierIndex]!;
|
||||
|
||||
await db
|
||||
.insert(leaderboardSnapshots)
|
||||
.values({
|
||||
await db.insert(leaderboardSnapshots).values({
|
||||
id: uuid(),
|
||||
userId: entry.userId,
|
||||
userId: user.id,
|
||||
tier,
|
||||
weeklyXp: entry.weeklyXp ?? 0,
|
||||
weeklyXp: user.xpTotal ?? 0,
|
||||
rank,
|
||||
league: entry.groupId ?? `${tier}-${Math.ceil(rank / perTier)}`,
|
||||
league: `${tier}-${Math.ceil(rank / perTier)}`,
|
||||
weekStart: sql`CAST(${weekStartStr} AS DATE)`,
|
||||
weekEnd: sql`CAST(${weekEndStr} AS DATE)`,
|
||||
settledAt: sql`NOW()`,
|
||||
})
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
weeklyXp: entry.weeklyXp ?? 0,
|
||||
rank,
|
||||
settledAt: sql`NOW()`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 给各组前 3 名发金币奖励(幂等:grantCoins 自带幂等保护)。
|
||||
for (const reward of rewards) {
|
||||
await grantCoins({
|
||||
userId: reward.userId,
|
||||
source: 'leaderboard_settlement',
|
||||
sourceId: `${reward.groupId}:${reward.rank}`,
|
||||
amount: reward.coins,
|
||||
idempotencyKey: `leaderboard_settlement:${reward.groupId}:rank${reward.rank}:${reward.userId}`,
|
||||
});
|
||||
}
|
||||
|
||||
// 标记上一周所有用户的周 XP 统计为已结算。
|
||||
await db
|
||||
.update(userWeeklyXp)
|
||||
.set({ settled: 1, settledAt: sql`NOW()`, nextRefreshAt: sql`CAST(${getCurrentWeekRange().weekStart.toISOString().slice(0, 10)} AS DATE)` })
|
||||
.where(sql`CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr} AND COALESCE(${userWeeklyXp.settled}, 0) = 0`);
|
||||
|
||||
return {
|
||||
settled: true,
|
||||
weekStart: weekStartStr,
|
||||
weekEnd: weekEndStr,
|
||||
userCount: allEntries.length,
|
||||
groupCount: groups.size,
|
||||
top3: globalTop3,
|
||||
rewards,
|
||||
};
|
||||
}
|
||||
|
||||
function getTierForRank(rank: number): string {
|
||||
// Equal distribution across 10 tiers
|
||||
if (rank <= 10) return 'mythic';
|
||||
if (rank <= 30) return 'legend';
|
||||
if (rank <= 60) return 'champion';
|
||||
@ -307,38 +144,3 @@ function getTierForRank(rank: number): string {
|
||||
}
|
||||
|
||||
export { TIERS };
|
||||
|
||||
/** 奖励预览:前 3 名的金币配置。 */
|
||||
const REWARD_PREVIEW = [
|
||||
{ rank: 1, coins: 300 },
|
||||
{ rank: 2, coins: 150 },
|
||||
{ rank: 3, coins: 50 },
|
||||
];
|
||||
|
||||
/**
|
||||
* 获取当前周的排行榜元信息。
|
||||
* 返回周起止日期、下次刷新时间、用户所在分组和奖励预览。
|
||||
*/
|
||||
export async function getLeaderboardMeta(userId: string): Promise<{
|
||||
weekStart: string;
|
||||
weekEnd: string;
|
||||
nextRefreshAt: string;
|
||||
groupId: string | null;
|
||||
rewardPreview: Array<{ rank: number; coins: number }>;
|
||||
}> {
|
||||
const { weekStart, weekEnd } = getCurrentWeekRange();
|
||||
// 下次刷新时间 = 下一周的 weekStart。
|
||||
const nextRefresh = new Date(weekStart);
|
||||
nextRefresh.setUTCDate(weekStart.getUTCDate() + 7);
|
||||
|
||||
const weekStartStr = weekStart.toISOString().slice(0, 10);
|
||||
const groupId = await getUserGroupId(userId, weekStartStr);
|
||||
|
||||
return {
|
||||
weekStart: weekStartStr,
|
||||
weekEnd: weekEnd.toISOString().slice(0, 10),
|
||||
nextRefreshAt: nextRefresh.toISOString().slice(0, 10),
|
||||
groupId,
|
||||
rewardPreview: REWARD_PREVIEW,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,145 +0,0 @@
|
||||
export const MS_PER_MINUTE = 60 * 1000;
|
||||
export const MS_PER_DAY = 24 * 60 * MS_PER_MINUTE;
|
||||
|
||||
export const HEART_RULES = Object.freeze({
|
||||
freeMax: 5,
|
||||
subscribedMax: 99,
|
||||
wrongAnswerCost: 1,
|
||||
restoreIntervalMs: 30 * MS_PER_MINUTE,
|
||||
dailyFirstVisitGrant: 1,
|
||||
newUserProtectionDays: 3,
|
||||
newUserMinimumHearts: 1,
|
||||
});
|
||||
|
||||
export const CHALLENGE_RULES = Object.freeze({
|
||||
questionsPerSession: 5,
|
||||
freeDailyHighRewardSessions: 3,
|
||||
plusDailyHighRewardSessions: 8,
|
||||
highRewardExhaustedXpMultiplier: 1,
|
||||
sessionStatuses: Object.freeze(['pending', 'in_progress', 'completed', 'abandoned', 'expired'] as const),
|
||||
answerStatuses: Object.freeze(['correct', 'incorrect', 'skipped'] as const),
|
||||
});
|
||||
|
||||
export const XP_RULES = Object.freeze({
|
||||
correctNormal: 10,
|
||||
correctHard: 15,
|
||||
reviewExplanation: 3,
|
||||
completeChallenge: 20,
|
||||
perfectChallengeBonus: 30,
|
||||
firstKnowledgeCard: 15,
|
||||
dailyTaskMin: 30,
|
||||
dailyTaskMax: 60,
|
||||
themeNodeMin: 80,
|
||||
themeNodeMax: 120,
|
||||
comboBonuses: Object.freeze([
|
||||
Object.freeze({ minCombo: 10, bonus: 25 }),
|
||||
Object.freeze({ minCombo: 5, bonus: 10 }),
|
||||
Object.freeze({ minCombo: 3, bonus: 5 }),
|
||||
]),
|
||||
});
|
||||
|
||||
function levelXpRequirement(level: number): number {
|
||||
if (level <= 1) return 100;
|
||||
if (level === 2) return 120;
|
||||
if (level === 3) return 150;
|
||||
if (level === 4) return 180;
|
||||
if (level === 5) return 220;
|
||||
if (level <= 10) return [260, 300, 350, 400, 460][level - 6] ?? 460;
|
||||
if (level <= 20) return 520 + (level - 11) * 80;
|
||||
if (level <= 35) return 1400 + (level - 21) * 120;
|
||||
return 3300 + (level - 36) * 180;
|
||||
}
|
||||
|
||||
export const LEVEL_RULES = Object.freeze({
|
||||
maxLevel: 50,
|
||||
xpRequirements: Object.freeze(
|
||||
Array.from({ length: 50 }, (_, index) => levelXpRequirement(index + 1)),
|
||||
),
|
||||
overflowStrategy: 'product_confirmation_required',
|
||||
});
|
||||
|
||||
export const STREAK_RULES = Object.freeze({
|
||||
countedByCompletedChallengeSessions: 1,
|
||||
milestoneDays: Object.freeze([3, 7, 14, 30, 100] as const),
|
||||
milestoneRewards: Object.freeze({
|
||||
3: Object.freeze({ type: 'chest', title: '连续学习 3 天小宝箱' }),
|
||||
7: Object.freeze({ type: 'item', itemId: 'streak_shield', quantity: 1, title: '连续学习 7 天连胜护盾 x1' }),
|
||||
14: Object.freeze({ type: 'item', itemId: 'double_xp_potion', quantity: 1, title: '连续学习 14 天双倍 XP 药水 x1' }),
|
||||
30: Object.freeze({ type: 'cosmetic', itemId: 'streak_badge_30', quantity: 1, title: '连续学习 30 天限定徽章' }),
|
||||
100: Object.freeze({ type: 'cosmetic', itemId: 'streak_title_100', quantity: 1, title: '连续学习 100 天稀有称号' }),
|
||||
} as const),
|
||||
comboChestBoostAt: 10,
|
||||
});
|
||||
|
||||
export const COIN_RULES = Object.freeze({
|
||||
firstDailyChallenge: 20,
|
||||
dailyTaskMin: 30,
|
||||
dailyTaskMax: 80,
|
||||
levelUp: 100,
|
||||
themeNode: 50,
|
||||
chestMin: 20,
|
||||
chestMax: 200,
|
||||
});
|
||||
|
||||
export const CHEST_RULES = Object.freeze({
|
||||
baseDropRate: 0.18,
|
||||
comboBoostAt: STREAK_RULES.comboChestBoostAt,
|
||||
comboDropRateBonus: 0.12,
|
||||
highRewardExhaustedDropRateMultiplier: 0.35,
|
||||
});
|
||||
|
||||
export const ITEM_RULES = Object.freeze({
|
||||
streakShield: Object.freeze({
|
||||
id: 'streak_shield',
|
||||
protectDays: 1,
|
||||
shopPriceCoins: 400,
|
||||
}),
|
||||
doubleXpPotion: Object.freeze({
|
||||
id: 'double_xp_potion',
|
||||
durationMs: 15 * MS_PER_MINUTE,
|
||||
multiplier: 2,
|
||||
shopPriceCoins: 250,
|
||||
}),
|
||||
heartSupply: Object.freeze({
|
||||
id: 'heart_supply',
|
||||
restoresToMax: true,
|
||||
shopPriceCoins: 150,
|
||||
}),
|
||||
hintFeather: Object.freeze({
|
||||
id: 'hint_feather',
|
||||
excludedDistractors: 1,
|
||||
shopPriceCoins: 80,
|
||||
}),
|
||||
mascotOutfit: Object.freeze({
|
||||
id: 'mascot_outfit',
|
||||
shopPriceCoinsMin: 800,
|
||||
shopPriceCoinsMax: 3000,
|
||||
}),
|
||||
});
|
||||
|
||||
export const AD_RECOVERY_RULES = Object.freeze({
|
||||
heartsDailyLimit: 3,
|
||||
bonusAttemptsDailyLimit: 3,
|
||||
streakProtectionCooldownMs: 7 * MS_PER_DAY,
|
||||
sessionTtlMs: 30 * MS_PER_MINUTE,
|
||||
bonusAttemptsPerRecovery: 1,
|
||||
trustedTestProviders: Object.freeze(['mock'] as const),
|
||||
});
|
||||
|
||||
export const LEADERBOARD_RULES = Object.freeze({
|
||||
xpSource: 'weekly_xp',
|
||||
weekStartsOnIsoDay: 1,
|
||||
groupSizeMin: 20,
|
||||
groupSizeMax: 30,
|
||||
topRewardRanks: Object.freeze([1, 2, 3] as const),
|
||||
demotionEnabledInV1: false,
|
||||
});
|
||||
|
||||
export const REWARD_RULES = Object.freeze({
|
||||
idempotencyScope: Object.freeze(['ad_recovery', 'purchase', 'challenge_completion', 'leaderboard_settlement'] as const),
|
||||
snapshotRequired: true,
|
||||
});
|
||||
|
||||
export type ChallengeSessionStatus = typeof CHALLENGE_RULES.sessionStatuses[number];
|
||||
export type ChallengeAnswerStatus = typeof CHALLENGE_RULES.answerStatuses[number];
|
||||
export type InventoryItemId = typeof ITEM_RULES[keyof typeof ITEM_RULES]['id'];
|
||||
@ -1,20 +1,17 @@
|
||||
import { db } from '../../db/client.js';
|
||||
import { challengeSessionAnswers, challengeSessions, knowledgeCards, questions, skillTree, userChapterProgress, userDailyProgress, userProgress, users } from '../../db/schema.js';
|
||||
import { and, asc, eq, notInArray, or, sql } from 'drizzle-orm';
|
||||
import { knowledgeCards, questions, skillTree, userChapterProgress, userProgress } from '../../db/schema.js';
|
||||
import { and, asc, eq, notInArray, sql } from 'drizzle-orm';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { NotFoundError, ValidationError } from '../../utils/errors.js';
|
||||
import { addXp, createCorrectAnswerXpRewards, createXpReward } from '../progress/xp-service.js';
|
||||
import { addXp, BASE_XP, calculateXp } from '../progress/xp-service.js';
|
||||
import { deductHeart } from '../progress/hearts-service.js';
|
||||
import { updateStreakForCompletedChallenge } from '../progress/streak-service.js';
|
||||
import { updateStreak } from '../progress/streak-service.js';
|
||||
import { deductDailyAttempt, getProgressSummary } from './progress-summary-service.js';
|
||||
import { getTrackCategory } from './tracks-service.js';
|
||||
import { CHALLENGE_RULES, XP_RULES } from '../gamification/rules.js';
|
||||
import { grantFirstDailyChallengeCoins } from '../gamification/coin-service.js';
|
||||
import type { AnswerResultDto, ChallengeQuestionDto, ChallengeSessionDto, ProgressSummaryDto } from '../../types/app-api.js';
|
||||
import type { AnswerResultDto, ChallengeQuestionDto } from '../../types/app-api.js';
|
||||
|
||||
type QuestionRow = typeof questions.$inferSelect;
|
||||
type ChapterRow = typeof skillTree.$inferSelect;
|
||||
type ChallengeSessionRow = typeof challengeSessions.$inferSelect;
|
||||
|
||||
interface OptionDto {
|
||||
id: string;
|
||||
@ -37,14 +34,6 @@ function hash(value: string): number {
|
||||
return result;
|
||||
}
|
||||
|
||||
function todayUtc(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function toRecord(value: unknown): Record<string, unknown> {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function buildOptions(question: QuestionRow): readonly OptionDto[] {
|
||||
const distractors = Array.isArray(question.distractors) ? question.distractors.filter((item): item is string => typeof item === 'string') : [];
|
||||
const rawOptions = [
|
||||
@ -60,9 +49,9 @@ function buildOptions(question: QuestionRow): readonly OptionDto[] {
|
||||
}));
|
||||
}
|
||||
|
||||
function toChallengeDto(challengeId: string, trackId: string, chapter: ChapterRow, question: QuestionRow): ChallengeQuestionDto {
|
||||
function toChallengeDto(trackId: string, chapter: ChapterRow, question: QuestionRow): ChallengeQuestionDto {
|
||||
return {
|
||||
challengeId,
|
||||
challengeId: question.id,
|
||||
trackId,
|
||||
nodeId: chapter.id,
|
||||
question: {
|
||||
@ -93,7 +82,7 @@ async function getCurrentChapter(userId: string, categoryId: string): Promise<Ch
|
||||
?? chapters[0]!;
|
||||
}
|
||||
|
||||
async function getQuestionsForChapter(userId: string, chapter: ChapterRow): Promise<QuestionRow[]> {
|
||||
async function getQuestionForChapter(userId: string, chapter: ChapterRow): Promise<QuestionRow | null> {
|
||||
const answered = await db
|
||||
.select({ questionId: userProgress.questionId })
|
||||
.from(userProgress)
|
||||
@ -109,45 +98,20 @@ async function getQuestionsForChapter(userId: string, chapter: ChapterRow): Prom
|
||||
? await db.select().from(questions).where(and(...conditions, notInArray(questions.id, answeredIds))).limit(20)
|
||||
: await db.select().from(questions).where(and(...conditions)).limit(20);
|
||||
|
||||
return available.slice(0, CHALLENGE_RULES.questionsPerSession);
|
||||
return available[0] ?? null;
|
||||
}
|
||||
|
||||
async function hasPreviousCorrectAnswer(userId: string, questionId: string): Promise<boolean> {
|
||||
async function getCorrectAnswersToday(userId: string): Promise<number> {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const rows = await db
|
||||
.select({ id: userProgress.id })
|
||||
.from(userProgress)
|
||||
.where(and(
|
||||
eq(userProgress.userId, userId),
|
||||
eq(userProgress.questionId, questionId),
|
||||
eq(userProgress.correct, 1),
|
||||
))
|
||||
.limit(1);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
export async function getHighRewardQuota(userId: string, tier: string | null): Promise<{
|
||||
max: number;
|
||||
used: number;
|
||||
remaining: number;
|
||||
}> {
|
||||
const max = tier === 'pro' || tier === 'proplus'
|
||||
? CHALLENGE_RULES.plusDailyHighRewardSessions
|
||||
: CHALLENGE_RULES.freeDailyHighRewardSessions;
|
||||
|
||||
const today = todayUtc();
|
||||
const [daily] = await db
|
||||
.select({
|
||||
used: userDailyProgress.highRewardSessionsUsed,
|
||||
restored: userDailyProgress.highRewardSessionsRestored,
|
||||
})
|
||||
.from(userDailyProgress)
|
||||
.where(and(eq(userDailyProgress.userId, userId), eq(userDailyProgress.progressDate, sql`CAST(${today} AS DATE)`)))
|
||||
.limit(1);
|
||||
|
||||
if (!daily) return { max, used: 0, remaining: max };
|
||||
|
||||
const effectiveUsed = Math.max(0, (daily.used ?? 0) - (daily.restored ?? 0));
|
||||
return { max, used: effectiveUsed, remaining: Math.max(0, max - effectiveUsed) };
|
||||
sql`DATE(${userProgress.answeredAt}) = ${today}`,
|
||||
));
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async function getKnowledgeCard(question: QuestionRow): Promise<AnswerResultDto['knowledgeCard']> {
|
||||
@ -174,140 +138,7 @@ async function getKnowledgeCard(question: QuestionRow): Promise<AnswerResultDto[
|
||||
};
|
||||
}
|
||||
|
||||
async function updateChapterProgress(userId: string, session: ChallengeSessionRow, correctCount: number, totalQuestions: number): Promise<void> {
|
||||
if (!session.chapterId) return;
|
||||
|
||||
const [chapter] = await db
|
||||
.select()
|
||||
.from(skillTree)
|
||||
.where(eq(skillTree.id, session.chapterId))
|
||||
.limit(1);
|
||||
const passThreshold = chapter?.passThreshold ?? Math.ceil(totalQuestions / 2);
|
||||
const nextStatus = correctCount >= totalQuestions ? 'perfect' : correctCount >= passThreshold ? 'passed' : 'unlocked';
|
||||
|
||||
const [current] = await db
|
||||
.select()
|
||||
.from(userChapterProgress)
|
||||
.where(and(eq(userChapterProgress.userId, userId), eq(userChapterProgress.chapterId, session.chapterId)))
|
||||
.limit(1);
|
||||
|
||||
if (!current) {
|
||||
await db.insert(userChapterProgress).values({
|
||||
id: uuid(),
|
||||
userId,
|
||||
chapterId: session.chapterId,
|
||||
status: nextStatus,
|
||||
bestCorrectCount: correctCount,
|
||||
attempts: 1,
|
||||
completedAt: nextStatus === 'passed' || nextStatus === 'perfect' ? sql`NOW()` : undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(userChapterProgress)
|
||||
.set({
|
||||
status: sql`CASE
|
||||
WHEN status = 'perfect' THEN 'perfect'
|
||||
WHEN status = 'passed' AND ${nextStatus} = 'unlocked' THEN 'passed'
|
||||
ELSE ${nextStatus}
|
||||
END`,
|
||||
bestCorrectCount: sql`GREATEST(COALESCE(best_correct_count, 0), ${correctCount})`,
|
||||
attempts: sql`COALESCE(attempts, 0) + 1`,
|
||||
completedAt: nextStatus === 'passed' || nextStatus === 'perfect' ? sql`NOW()` : undefined,
|
||||
})
|
||||
.where(and(eq(userChapterProgress.userId, userId), eq(userChapterProgress.chapterId, session.chapterId)));
|
||||
}
|
||||
|
||||
async function updateDailyProgress(userId: string, session: ChallengeSessionRow, xpDelta: number): Promise<boolean> {
|
||||
const progressDate = todayUtc();
|
||||
const [daily] = await db
|
||||
.select()
|
||||
.from(userDailyProgress)
|
||||
.where(and(eq(userDailyProgress.userId, userId), eq(userDailyProgress.progressDate, sql`CAST(${progressDate} AS DATE)`)))
|
||||
.limit(1);
|
||||
|
||||
if (!daily) {
|
||||
await db.insert(userDailyProgress).values({
|
||||
id: uuid(),
|
||||
userId,
|
||||
progressDate: sql`CAST(${progressDate} AS DATE)`,
|
||||
firstChallengeSessionId: session.id,
|
||||
firstChallengeCompletedAt: sql`NOW()`,
|
||||
challengeSessionsCompleted: 1,
|
||||
highRewardSessionsUsed: session.highRewardEligible ? 1 : 0,
|
||||
xpEarned: xpDelta,
|
||||
streakCounted: 1,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const isFirstChallengeToday = !daily.firstChallengeSessionId;
|
||||
|
||||
await db
|
||||
.update(userDailyProgress)
|
||||
.set({
|
||||
firstChallengeSessionId: daily.firstChallengeSessionId ?? session.id,
|
||||
firstChallengeCompletedAt: daily.firstChallengeCompletedAt ?? sql`NOW()`,
|
||||
challengeSessionsCompleted: sql`COALESCE(challenge_sessions_completed, 0) + 1`,
|
||||
highRewardSessionsUsed: sql`COALESCE(high_reward_sessions_used, 0) + ${session.highRewardEligible ? 1 : 0}`,
|
||||
xpEarned: sql`COALESCE(xp_earned, 0) + ${xpDelta}`,
|
||||
streakCounted: 1,
|
||||
})
|
||||
.where(eq(userDailyProgress.id, daily.id));
|
||||
|
||||
return isFirstChallengeToday;
|
||||
}
|
||||
|
||||
export function getChallengeCompletionRewards(correctCount: number, totalQuestions: number, multiplier = 1): AnswerResultDto['rewards'] {
|
||||
const completeXp = Math.floor(XP_RULES.completeChallenge * multiplier);
|
||||
const perfectXp = correctCount >= totalQuestions ? Math.floor(XP_RULES.perfectChallengeBonus * multiplier) : 0;
|
||||
return [
|
||||
{ ...createXpReward('complete_challenge'), amount: completeXp, title: `完成挑战 +${completeXp} XP` },
|
||||
...(perfectXp > 0 ? [{ ...createXpReward('perfect_challenge'), amount: perfectXp, title: `全对奖励 +${perfectXp} XP` }] : []),
|
||||
];
|
||||
}
|
||||
|
||||
async function settleCompletedChallenge(
|
||||
userId: string,
|
||||
session: ChallengeSessionRow,
|
||||
correctCount: number,
|
||||
totalQuestions: number,
|
||||
): Promise<{ rewards: AnswerResultDto['rewards']; xpDelta: number; progressBefore: ProgressSummaryDto }> {
|
||||
const progressBefore = await getProgressSummary(userId);
|
||||
const multiplier = session.highRewardEligible ? 1 : CHALLENGE_RULES.highRewardExhaustedXpMultiplier;
|
||||
const completeXp = Math.floor(XP_RULES.completeChallenge * multiplier);
|
||||
const perfectXp = correctCount >= totalQuestions ? Math.floor(XP_RULES.perfectChallengeBonus * multiplier) : 0;
|
||||
const xpDelta = completeXp + perfectXp;
|
||||
|
||||
if (xpDelta > 0) {
|
||||
await addXp(userId, xpDelta);
|
||||
}
|
||||
|
||||
const [dailyFirstChallenge, , streak] = await Promise.all([
|
||||
updateDailyProgress(userId, session, xpDelta),
|
||||
updateChapterProgress(userId, session, correctCount, totalQuestions),
|
||||
updateStreakForCompletedChallenge(userId),
|
||||
]);
|
||||
|
||||
const coinReward = dailyFirstChallenge
|
||||
? await grantFirstDailyChallengeCoins(userId, session.id, {
|
||||
correctCount,
|
||||
totalQuestions,
|
||||
highRewardEligible: session.highRewardEligible === 1,
|
||||
})
|
||||
: null;
|
||||
|
||||
const rewards = [
|
||||
...getChallengeCompletionRewards(correctCount, totalQuestions, multiplier),
|
||||
...(coinReward ? [coinReward] : []),
|
||||
...(streak.rewards ?? []),
|
||||
];
|
||||
|
||||
return { rewards, xpDelta, progressBefore };
|
||||
}
|
||||
|
||||
export async function getNextChallenge(userId: string, trackId: string): Promise<ChallengeSessionDto | null> {
|
||||
export async function getNextChallenge(userId: string, trackId: string): Promise<ChallengeQuestionDto | null> {
|
||||
const category = await getTrackCategory(trackId);
|
||||
if (!category || category.status !== 'active') {
|
||||
throw new NotFoundError('Track');
|
||||
@ -316,82 +147,19 @@ export async function getNextChallenge(userId: string, trackId: string): Promise
|
||||
const chapter = await getCurrentChapter(userId, category.id);
|
||||
if (!chapter) return null;
|
||||
|
||||
const sessionQuestions = await getQuestionsForChapter(userId, chapter);
|
||||
if (sessionQuestions.length < CHALLENGE_RULES.questionsPerSession) return null;
|
||||
const question = await getQuestionForChapter(userId, chapter);
|
||||
if (!question) return null;
|
||||
|
||||
const [user] = await db
|
||||
.select({ tier: users.tier })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
const quota = await getHighRewardQuota(userId, user?.tier ?? null);
|
||||
const eligible = quota.remaining > 0 ? 1 : 0;
|
||||
|
||||
const sessionId = uuid();
|
||||
const resolvedTrackId = category.slug || category.id;
|
||||
await db.insert(challengeSessions).values({
|
||||
id: sessionId,
|
||||
userId,
|
||||
trackId: resolvedTrackId,
|
||||
categoryId: category.id,
|
||||
chapterId: chapter.id,
|
||||
status: 'pending',
|
||||
clientRequestId: sessionId,
|
||||
questionIds: sessionQuestions.map((question) => question.id),
|
||||
totalQuestions: sessionQuestions.length,
|
||||
highRewardEligible: eligible,
|
||||
});
|
||||
|
||||
return {
|
||||
challengeId: sessionId,
|
||||
trackId: resolvedTrackId,
|
||||
nodeId: chapter.id,
|
||||
totalQuestions: sessionQuestions.length,
|
||||
highRewardEligible: eligible === 1,
|
||||
questions: sessionQuestions.map((question) => toChallengeDto(sessionId, resolvedTrackId, chapter, question)),
|
||||
};
|
||||
return toChallengeDto(category.slug || category.id, chapter, question);
|
||||
}
|
||||
|
||||
export async function submitChallengeAnswer(
|
||||
userId: string,
|
||||
challengeId: string,
|
||||
questionId: string,
|
||||
selectedOptionId: string,
|
||||
timeMs: number,
|
||||
comboCount = 0,
|
||||
submitRequestId = `${challengeId}:${questionId}`,
|
||||
): Promise<AnswerResultDto> {
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(challengeSessions)
|
||||
.where(and(eq(challengeSessions.id, challengeId), eq(challengeSessions.userId, userId)))
|
||||
.limit(1);
|
||||
if (!session) throw new NotFoundError('Challenge');
|
||||
if (session.status === 'completed' || session.status === 'expired' || session.status === 'abandoned') {
|
||||
throw new ValidationError('Challenge session is not accepting answers');
|
||||
}
|
||||
|
||||
const sessionQuestionIds = Array.isArray(session.questionIds) ? session.questionIds : [];
|
||||
const answerOrder = sessionQuestionIds.indexOf(questionId) + 1;
|
||||
if (answerOrder <= 0) throw new ValidationError('Question does not belong to challenge session');
|
||||
|
||||
const [existingAnswer] = await db
|
||||
.select()
|
||||
.from(challengeSessionAnswers)
|
||||
.where(and(
|
||||
eq(challengeSessionAnswers.sessionId, challengeId),
|
||||
or(
|
||||
eq(challengeSessionAnswers.questionId, questionId),
|
||||
eq(challengeSessionAnswers.submitRequestId, submitRequestId),
|
||||
),
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (existingAnswer?.resultSnapshot) {
|
||||
return existingAnswer.resultSnapshot as unknown as AnswerResultDto;
|
||||
}
|
||||
|
||||
const [question] = await db.select().from(questions).where(eq(questions.id, questionId)).limit(1);
|
||||
if (!question) throw new NotFoundError('Question');
|
||||
|
||||
@ -401,7 +169,6 @@ export async function submitChallengeAnswer(
|
||||
|
||||
const correct = selected.isCorrect;
|
||||
const correctOptionId = options.find((option) => option.isCorrect)?.id ?? 'a';
|
||||
const previousCorrectAnswer = correct ? await hasPreviousCorrectAnswer(userId, questionId) : false;
|
||||
|
||||
await db.insert(userProgress).values({
|
||||
id: uuid(),
|
||||
@ -431,93 +198,31 @@ export async function submitChallengeAnswer(
|
||||
.where(eq(questions.id, questionId));
|
||||
|
||||
let xpDelta = 0;
|
||||
const rewards: Array<{ type: string; source?: string; amount?: number; title?: string }> = [];
|
||||
if (correct) {
|
||||
const answerRewards = createCorrectAnswerXpRewards(question.difficulty, comboCount);
|
||||
xpDelta = answerRewards.reduce((total, reward) => total + reward.amount, 0);
|
||||
xpDelta = calculateXp(BASE_XP, comboCount);
|
||||
await addXp(userId, xpDelta);
|
||||
if (xpDelta > 0) {
|
||||
rewards.push(...answerRewards);
|
||||
}
|
||||
await updateStreak(userId, await getCorrectAnswersToday(userId));
|
||||
} else {
|
||||
const heartResult = await deductHeart(userId);
|
||||
if (!heartResult.success && heartResult.remaining === 0) {
|
||||
throw new ValidationError('红心已用完,请等待恢复或观看广告');
|
||||
}
|
||||
await deductHeart(userId);
|
||||
await deductDailyAttempt(userId);
|
||||
}
|
||||
|
||||
const totalQuestions = session.totalQuestions ?? CHALLENGE_RULES.questionsPerSession;
|
||||
const answeredAfter = Math.min((session.answeredCount ?? 0) + 1, totalQuestions);
|
||||
const correctAfter = (session.correctCount ?? 0) + (correct ? 1 : 0);
|
||||
const completed = answeredAfter >= totalQuestions;
|
||||
let completionProgressBefore: ProgressSummaryDto | null = null;
|
||||
let completionXpDelta = 0;
|
||||
if (completed) {
|
||||
const completion = await settleCompletedChallenge(userId, session, correctAfter, totalQuestions);
|
||||
completionProgressBefore = completion.progressBefore;
|
||||
completionXpDelta = completion.xpDelta;
|
||||
rewards.push(...completion.rewards);
|
||||
}
|
||||
const [progress, knowledgeCard] = await Promise.all([
|
||||
getProgressSummary(userId),
|
||||
getKnowledgeCard(question),
|
||||
]);
|
||||
|
||||
const knowledgeCard = await getKnowledgeCard(question);
|
||||
const firstKnowledgeCardReward = correct && !previousCorrectAnswer && !knowledgeCard.id.startsWith('fallback-')
|
||||
? createXpReward('first_knowledge_card')
|
||||
: null;
|
||||
if (firstKnowledgeCardReward) {
|
||||
await addXp(userId, firstKnowledgeCardReward.amount);
|
||||
xpDelta += firstKnowledgeCardReward.amount;
|
||||
rewards.push(firstKnowledgeCardReward);
|
||||
}
|
||||
const progress = await getProgressSummary(userId);
|
||||
|
||||
const result: AnswerResultDto = {
|
||||
return {
|
||||
answerState: correct ? 'correct' : 'wrong',
|
||||
correctOptionId,
|
||||
xpDelta: xpDelta + completionXpDelta,
|
||||
xpDelta,
|
||||
progress: {
|
||||
hearts: progress.hearts,
|
||||
dailyAttemptsLeft: progress.dailyAttemptsLeft,
|
||||
highRewardSessionsLeft: progress.highRewardSessionsLeft,
|
||||
highRewardSessionsMax: progress.highRewardSessionsMax,
|
||||
xp: progress.xp,
|
||||
streakDays: progress.streakDays,
|
||||
},
|
||||
knowledgeCard,
|
||||
rewards,
|
||||
rewards: correct && xpDelta > 0 ? [{ type: 'xp', amount: xpDelta, title: `+${xpDelta} XP` }] : [],
|
||||
};
|
||||
|
||||
await db.insert(challengeSessionAnswers).values({
|
||||
id: uuid(),
|
||||
sessionId: challengeId,
|
||||
userId,
|
||||
questionId,
|
||||
submitRequestId,
|
||||
answerOrder,
|
||||
answer: selectedOptionId,
|
||||
correct: correct ? 1 : 0,
|
||||
timeMs,
|
||||
comboCount,
|
||||
resultSnapshot: result as unknown as Record<string, unknown>,
|
||||
});
|
||||
|
||||
await db
|
||||
.update(challengeSessions)
|
||||
.set({
|
||||
status: completed ? 'completed' : 'in_progress',
|
||||
answeredCount: answeredAfter,
|
||||
correctCount: correctAfter,
|
||||
rewardSnapshot: completed ? toRecord({
|
||||
rewards,
|
||||
xpDelta: completionXpDelta,
|
||||
correctCount: correctAfter,
|
||||
totalQuestions,
|
||||
}) : undefined,
|
||||
progressBefore: completionProgressBefore ? toRecord(completionProgressBefore) : undefined,
|
||||
progressAfter: completed ? toRecord(progress) : undefined,
|
||||
completedAt: completed ? sql`NOW()` : undefined,
|
||||
})
|
||||
.where(eq(challengeSessions.id, challengeId));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { getLeaderboard, getLeaderboardMeta, getUserRank } from '../gamification/leaderboard-service.js';
|
||||
import type { LeaderboardEntryDto, LeaderboardMetaDto, LeaderboardScope } from '../../types/app-api.js';
|
||||
import { getLeaderboard, getUserRank } from '../gamification/leaderboard-service.js';
|
||||
import type { LeaderboardEntryDto, LeaderboardScope } from '../../types/app-api.js';
|
||||
import { db } from '../../db/client.js';
|
||||
import { users } from '../../db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
@ -17,27 +17,18 @@ export async function getClientLeaderboard(
|
||||
_trackId: string | undefined,
|
||||
page: number,
|
||||
limit: number,
|
||||
): Promise<{
|
||||
items: LeaderboardEntryDto[];
|
||||
meta: LeaderboardMetaDto;
|
||||
pagination: { total: number; page: number; limit: number };
|
||||
}> {
|
||||
const [data, meta] = await Promise.all([
|
||||
getLeaderboard(userId, undefined, page, limit),
|
||||
getLeaderboardMeta(userId),
|
||||
]);
|
||||
|
||||
): Promise<{ items: LeaderboardEntryDto[]; pagination: { total: number; page: number; limit: number } }> {
|
||||
const data = await getLeaderboard(undefined, 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,
|
||||
xp: entry.xpTotal,
|
||||
badge: getBadge(entry.rank),
|
||||
isMe: entry.userId === userId,
|
||||
})),
|
||||
meta,
|
||||
pagination: data.pagination,
|
||||
};
|
||||
}
|
||||
@ -46,32 +37,28 @@ export async function getClientLeaderboardMe(
|
||||
userId: string,
|
||||
_scope: LeaderboardScope,
|
||||
_trackId: string | undefined,
|
||||
): Promise<{ entry: LeaderboardEntryDto; meta: LeaderboardMetaDto } | null> {
|
||||
const [rank, meta] = await Promise.all([
|
||||
): Promise<LeaderboardEntryDto | null> {
|
||||
const [rank, user] = await Promise.all([
|
||||
getUserRank(userId),
|
||||
getLeaderboardMeta(userId),
|
||||
]);
|
||||
if (!rank) return null;
|
||||
|
||||
const [user] = await db
|
||||
db
|
||||
.select({
|
||||
nickname: users.nickname,
|
||||
avatarUrl: users.avatarUrl,
|
||||
xpTotal: users.xpTotal,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
.limit(1),
|
||||
]);
|
||||
if (!rank) return null;
|
||||
|
||||
return {
|
||||
entry: {
|
||||
rank: rank.rank,
|
||||
userId,
|
||||
displayName: user?.nickname ?? '你',
|
||||
avatarUrl: user?.avatarUrl ?? null,
|
||||
xp: rank.weeklyXp,
|
||||
displayName: user[0]?.nickname ?? '你',
|
||||
avatarUrl: user[0]?.avatarUrl ?? null,
|
||||
xp: user[0]?.xpTotal ?? 0,
|
||||
badge: getBadge(rank.rank),
|
||||
isMe: true,
|
||||
},
|
||||
meta: { ...meta, rank: rank.rank },
|
||||
};
|
||||
}
|
||||
|
||||
@ -4,23 +4,13 @@ import { eq, sql } from 'drizzle-orm';
|
||||
import { getHearts } from '../progress/hearts-service.js';
|
||||
import { calculateStreak, freezeStreak } from '../progress/streak-service.js';
|
||||
import { getSubscriptionStatus } from '../payment/subscription-service.js';
|
||||
import { getHighRewardQuota } from './challenge-service.js';
|
||||
import { HEART_RULES, LEVEL_RULES } from '../gamification/rules.js';
|
||||
import type { ProgressSummaryDto, RewardSource } from '../../types/app-api.js';
|
||||
|
||||
export const FREE_DAILY_ATTEMPTS = 5;
|
||||
export const PRO_DAILY_ATTEMPTS = 10;
|
||||
export const PROPLUS_DAILY_ATTEMPTS = 20;
|
||||
const HEART_RESTORE_MS = 30 * 60 * 1000;
|
||||
|
||||
/** Pre-computed cumulative XP thresholds. cumulativeXP[L] = total XP needed to reach level L+1. */
|
||||
const cumulativeXP: number[] = (() => {
|
||||
const thresholds: number[] = [0];
|
||||
for (let i = 0; i < LEVEL_RULES.xpRequirements.length; i += 1) {
|
||||
thresholds.push(thresholds[i]! + LEVEL_RULES.xpRequirements[i]!);
|
||||
}
|
||||
return thresholds;
|
||||
})();
|
||||
const XP_PER_LEVEL = 400;
|
||||
|
||||
type UserTier = 'free' | 'pro' | 'proplus';
|
||||
|
||||
@ -34,7 +24,6 @@ interface ResourceUser {
|
||||
checkInDays: number | null;
|
||||
lastCheckInDate: Date | string | null;
|
||||
streakProtectedUntil: Date | string | null;
|
||||
heartsRemaining?: number | null;
|
||||
}
|
||||
|
||||
function today(): string {
|
||||
@ -63,39 +52,10 @@ export function getDailyAttemptsMax(tier: string | null | undefined): number {
|
||||
return FREE_DAILY_ATTEMPTS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the user's level and XP remaining to the next level
|
||||
* using the non-linear 50-level curve from LEVEL_RULES.
|
||||
*
|
||||
* At max level (50), xpToNextLevel is 0.
|
||||
* XP beyond the max level threshold does not increase level further.
|
||||
*/
|
||||
export function getLevelInfo(xp: number): { level: number; xpToNextLevel: number } {
|
||||
const clampedXp = Math.max(0, xp);
|
||||
const maxLevel = LEVEL_RULES.maxLevel;
|
||||
|
||||
// Binary search: find highest index where cumulativeXP[index] <= clampedXp
|
||||
// cumulativeXP[k] = total XP needed to reach level k+1
|
||||
let low = 0;
|
||||
let high = cumulativeXP.length - 1;
|
||||
while (low < high) {
|
||||
const mid = Math.ceil((low + high) / 2);
|
||||
if (cumulativeXP[mid]! <= clampedXp) {
|
||||
low = mid;
|
||||
} else {
|
||||
high = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// level = low + 1 (since cumulativeXP[0]=0 corresponds to level 1)
|
||||
const level = Math.min(low + 1, maxLevel);
|
||||
|
||||
if (level >= maxLevel) {
|
||||
return { level: maxLevel, xpToNextLevel: 0 };
|
||||
}
|
||||
|
||||
const nextThreshold = cumulativeXP[level]!;
|
||||
return { level, xpToNextLevel: Math.max(0, nextThreshold - clampedXp) };
|
||||
const level = Math.floor(Math.max(0, xp) / XP_PER_LEVEL) + 1;
|
||||
const nextLevelXp = level * XP_PER_LEVEL;
|
||||
return { level, xpToNextLevel: Math.max(0, nextLevelXp - xp) };
|
||||
}
|
||||
|
||||
export function getNextHeartRestoreAt(lastRestore: string | null, hearts: number, maxHearts: number): string | null {
|
||||
@ -115,7 +75,6 @@ async function getResourceUser(userId: string): Promise<ResourceUser | null> {
|
||||
checkInDays: users.checkInDays,
|
||||
lastCheckInDate: users.lastCheckInDate,
|
||||
streakProtectedUntil: users.streakProtectedUntil,
|
||||
heartsRemaining: users.heartsRemaining,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
@ -124,28 +83,6 @@ async function getResourceUser(userId: string): Promise<ResourceUser | null> {
|
||||
return user ?? null;
|
||||
}
|
||||
|
||||
export function shouldGrantDailyFirstVisitHeart(user: ResourceUser | null): boolean {
|
||||
if (!user) return false;
|
||||
if (user.tier === 'pro' || user.tier === 'proplus') return false;
|
||||
if (toDateString(user.lastCheckInDate) === today()) return false;
|
||||
return (user.heartsRemaining ?? HEART_RULES.freeMax) < HEART_RULES.freeMax;
|
||||
}
|
||||
|
||||
export async function grantDailyFirstVisitHeart(userId: string): Promise<boolean> {
|
||||
const user = await getResourceUser(userId);
|
||||
if (!shouldGrantDailyFirstVisitHeart(user)) return false;
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
heartsRemaining: sql`LEAST(COALESCE(hearts_remaining, 0) + ${HEART_RULES.dailyFirstVisitGrant}, ${HEART_RULES.freeMax})`,
|
||||
heartsLastRestore: sql`NOW()`,
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function getDailyAttempts(userId: string): Promise<{ left: number; max: number; nextResetAt: string }> {
|
||||
const user = await getResourceUser(userId);
|
||||
const max = getDailyAttemptsMax(user?.tier);
|
||||
@ -193,15 +130,6 @@ export async function updateProgressPreferences(userId: string, activeTrackId: s
|
||||
export async function checkIn(userId: string): Promise<ProgressSummaryDto> {
|
||||
const user = await getResourceUser(userId);
|
||||
const alreadyCheckedIn = toDateString(user?.lastCheckInDate ?? null) === today();
|
||||
if (shouldGrantDailyFirstVisitHeart(user)) {
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
heartsRemaining: sql`LEAST(COALESCE(hearts_remaining, 0) + ${HEART_RULES.dailyFirstVisitGrant}, ${HEART_RULES.freeMax})`,
|
||||
heartsLastRestore: sql`NOW()`,
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
}
|
||||
if (!alreadyCheckedIn) {
|
||||
await db
|
||||
.update(users)
|
||||
@ -223,8 +151,6 @@ export async function protectStreak(userId: string, _source: RewardSource): Prom
|
||||
}
|
||||
|
||||
export async function getProgressSummary(userId: string): Promise<ProgressSummaryDto> {
|
||||
await grantDailyFirstVisitHeart(userId);
|
||||
|
||||
const [user, hearts, streak, subscription, attempts] = await Promise.all([
|
||||
getResourceUser(userId),
|
||||
getHearts(userId),
|
||||
@ -236,7 +162,6 @@ export async function getProgressSummary(userId: string): Promise<ProgressSummar
|
||||
const xp = user?.xpTotal ?? 0;
|
||||
const level = getLevelInfo(xp);
|
||||
const isSubscribed = subscription.status === 'active' && subscription.tier !== 'free';
|
||||
const highRewardQuota = await getHighRewardQuota(userId, user?.tier ?? null);
|
||||
|
||||
return {
|
||||
hearts: hearts.remaining,
|
||||
@ -245,8 +170,6 @@ export async function getProgressSummary(userId: string): Promise<ProgressSummar
|
||||
dailyAttemptsLeft: attempts.left,
|
||||
dailyAttemptsMax: attempts.max,
|
||||
nextAttemptResetAt: attempts.nextResetAt,
|
||||
highRewardSessionsLeft: highRewardQuota.remaining,
|
||||
highRewardSessionsMax: highRewardQuota.max,
|
||||
xp,
|
||||
level: level.level,
|
||||
xpToNextLevel: level.xpToNextLevel,
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
import { db } from '../../db/client.js';
|
||||
import { users } from '../../db/schema.js';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { HEART_RULES, MS_PER_DAY } from '../gamification/rules.js';
|
||||
|
||||
const MAX_FREE_HEARTS = HEART_RULES.freeMax;
|
||||
const PRO_HEARTS = HEART_RULES.subscribedMax;
|
||||
const RESTORE_INTERVAL_MS = HEART_RULES.restoreIntervalMs;
|
||||
const MAX_FREE_HEARTS = 5;
|
||||
const PRO_HEARTS = 99;
|
||||
const RESTORE_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
export type RestoreMethod = 'ad' | 'wait' | 'upgrade';
|
||||
|
||||
@ -76,26 +75,9 @@ export async function getHearts(userId: string): Promise<HeartsInfo> {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a free-tier user is within the new-user protection window.
|
||||
* New users (account age ≤ newUserProtectionDays) have a minimum hearts floor.
|
||||
*/
|
||||
async function isNewUserProtected(userId: string): Promise<boolean> {
|
||||
const [user] = await db
|
||||
.select({ createdAt: users.createdAt })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!user?.createdAt) return false;
|
||||
const accountAgeMs = Date.now() - new Date(user.createdAt).getTime();
|
||||
return accountAgeMs <= HEART_RULES.newUserProtectionDays * MS_PER_DAY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduct a heart from the user. Returns success status and remaining count.
|
||||
* Pro/ProPlus users are not deducted.
|
||||
* New users (≤3 days) have a minimum floor of 1 heart.
|
||||
* Pro users are not deducted.
|
||||
*/
|
||||
export async function deductHeart(userId: string): Promise<{ success: boolean; remaining: number }> {
|
||||
const [user] = await db
|
||||
@ -108,20 +90,14 @@ export async function deductHeart(userId: string): Promise<{ success: boolean; r
|
||||
return { success: false, remaining: 0 };
|
||||
}
|
||||
|
||||
// Pro/ProPlus users: no deduction
|
||||
// Pro users: no deduction
|
||||
if (user.tier === 'pro' || user.tier === 'proplus') {
|
||||
return { success: true, remaining: PRO_HEARTS };
|
||||
}
|
||||
|
||||
const current = user.heartsRemaining ?? MAX_FREE_HEARTS;
|
||||
|
||||
// New-user protection: floor = 1 heart for accounts ≤3 days old
|
||||
const protectedFloor = await isNewUserProtected(userId)
|
||||
? HEART_RULES.newUserMinimumHearts
|
||||
: 0;
|
||||
|
||||
if (current <= protectedFloor) {
|
||||
return { success: false, remaining: current };
|
||||
if (current <= 0) {
|
||||
return { success: false, remaining: 0 };
|
||||
}
|
||||
|
||||
const newCount = current - 1;
|
||||
|
||||
@ -1,27 +1,16 @@
|
||||
import { db } from '../../db/client.js';
|
||||
import { rewardLedger, users } from '../../db/schema.js';
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { STREAK_RULES } from '../gamification/rules.js';
|
||||
|
||||
type StreakMilestoneDay = typeof STREAK_RULES.milestoneDays[number];
|
||||
|
||||
export interface StreakMilestoneReward {
|
||||
type: string;
|
||||
source: 'streak_milestone';
|
||||
milestoneDays: StreakMilestoneDay;
|
||||
itemId?: string;
|
||||
quantity?: number;
|
||||
title: string;
|
||||
}
|
||||
import { users } from '../../db/schema.js';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
|
||||
export interface StreakInfo {
|
||||
days: number;
|
||||
lastDate: string | null;
|
||||
frozen: boolean;
|
||||
rewards?: readonly StreakMilestoneReward[];
|
||||
}
|
||||
|
||||
/** Minimum correct answers per day to count toward streak */
|
||||
const STREAK_THRESHOLD = 3;
|
||||
|
||||
/**
|
||||
* Normalize a date value (Date or string from mysql2) to 'YYYY-MM-DD' string.
|
||||
*/
|
||||
@ -65,7 +54,10 @@ export async function calculateStreak(userId: string): Promise<StreakInfo> {
|
||||
return { days: 0, lastDate, frozen: false };
|
||||
}
|
||||
|
||||
export async function updateStreakForCompletedChallenge(userId: string): Promise<StreakInfo> {
|
||||
/**
|
||||
* Update the user's streak after answering questions.
|
||||
*/
|
||||
export async function updateStreak(userId: string, correctAnswersToday: number): Promise<StreakInfo> {
|
||||
const today = todayUtc();
|
||||
|
||||
const [user] = await db
|
||||
@ -88,6 +80,15 @@ export async function updateStreakForCompletedChallenge(userId: string): Promise
|
||||
return { days: user.streakDays ?? 0, lastDate: today, frozen: false };
|
||||
}
|
||||
|
||||
// Check if threshold is met
|
||||
if (correctAnswersToday < STREAK_THRESHOLD) {
|
||||
return {
|
||||
days: user.streakDays ?? 0,
|
||||
lastDate,
|
||||
frozen: false,
|
||||
};
|
||||
}
|
||||
|
||||
const yesterday = yesterdayUtc();
|
||||
const isConsecutive = lastDate === yesterday;
|
||||
const newDays = isConsecutive ? (user.streakDays ?? 0) + 1 : 1;
|
||||
@ -97,60 +98,7 @@ export async function updateStreakForCompletedChallenge(userId: string): Promise
|
||||
.set({ streakDays: newDays, streakLastDate: sql`CAST(${today} AS DATE)` })
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
const rewards = await grantStreakMilestoneReward(userId, newDays);
|
||||
return { days: newDays, lastDate: today, frozen: false, rewards };
|
||||
}
|
||||
|
||||
export function getStreakMilestoneReward(days: number): StreakMilestoneReward | null {
|
||||
if (!isStreakMilestoneDay(days)) return null;
|
||||
const reward = STREAK_RULES.milestoneRewards[days];
|
||||
return {
|
||||
type: reward.type,
|
||||
source: 'streak_milestone',
|
||||
milestoneDays: days,
|
||||
itemId: 'itemId' in reward ? reward.itemId : undefined,
|
||||
quantity: 'quantity' in reward ? reward.quantity : undefined,
|
||||
title: reward.title,
|
||||
};
|
||||
}
|
||||
|
||||
export async function grantStreakMilestoneReward(
|
||||
userId: string,
|
||||
days: number,
|
||||
): Promise<readonly StreakMilestoneReward[]> {
|
||||
const reward = getStreakMilestoneReward(days);
|
||||
if (!reward) return [];
|
||||
|
||||
const idempotencyKey = `streak_milestone:${days}`;
|
||||
const [existing] = await db
|
||||
.select({ id: rewardLedger.id })
|
||||
.from(rewardLedger)
|
||||
.where(and(
|
||||
eq(rewardLedger.userId, userId),
|
||||
eq(rewardLedger.idempotencyKey, idempotencyKey),
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (existing) return [];
|
||||
|
||||
await db.insert(rewardLedger).values({
|
||||
id: uuid(),
|
||||
userId,
|
||||
sourceType: 'streak_milestone',
|
||||
sourceId: String(days),
|
||||
idempotencyKey,
|
||||
status: 'completed',
|
||||
rewardSnapshot: {
|
||||
rewards: [reward],
|
||||
milestoneDays: days,
|
||||
},
|
||||
resourceDeltas: {
|
||||
rewards: [reward],
|
||||
},
|
||||
settledAt: sql`NOW()`,
|
||||
});
|
||||
|
||||
return [reward];
|
||||
return { days: newDays, lastDate: today, frozen: false };
|
||||
}
|
||||
|
||||
/**
|
||||
@ -183,6 +131,4 @@ function yesterdayUtc(): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function isStreakMilestoneDay(days: number): days is StreakMilestoneDay {
|
||||
return (STREAK_RULES.milestoneDays as readonly number[]).includes(days);
|
||||
}
|
||||
export { STREAK_THRESHOLD };
|
||||
|
||||
@ -1,42 +1,16 @@
|
||||
import { db } from '../../db/client.js';
|
||||
import { users, userWeeklyXp } from '../../db/schema.js';
|
||||
import { users } from '../../db/schema.js';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { LEADERBOARD_RULES, XP_RULES } from '../gamification/rules.js';
|
||||
|
||||
const BASE_XP = XP_RULES.correctNormal;
|
||||
/** Combo bonus tiers: minimum combo count → bonus XP */
|
||||
const COMBO_BONUSES: ReadonlyArray<{ minCombo: number; bonus: number }> = [
|
||||
{ minCombo: 10, bonus: 20 },
|
||||
{ minCombo: 5, bonus: 10 },
|
||||
{ minCombo: 3, bonus: 5 },
|
||||
];
|
||||
|
||||
const BASE_XP = 10;
|
||||
const DEFAULT_DAILY_GOAL = 50;
|
||||
const HARD_QUESTION_DIFFICULTY = 3;
|
||||
|
||||
export type XpRewardSource =
|
||||
| 'correct_normal'
|
||||
| 'correct_hard'
|
||||
| 'combo_bonus'
|
||||
| 'review_explanation'
|
||||
| 'complete_challenge'
|
||||
| 'perfect_challenge'
|
||||
| 'first_knowledge_card'
|
||||
| 'daily_task'
|
||||
| 'theme_node';
|
||||
|
||||
export interface XpReward {
|
||||
type: 'xp';
|
||||
source: XpRewardSource;
|
||||
amount: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const XP_REWARD_TITLES: Readonly<Record<XpRewardSource, string>> = Object.freeze({
|
||||
correct_normal: '答对题目',
|
||||
correct_hard: '答对困难题',
|
||||
combo_bonus: '连对奖励',
|
||||
review_explanation: '查看解析',
|
||||
complete_challenge: '完成挑战',
|
||||
perfect_challenge: '全对奖励',
|
||||
first_knowledge_card: '首次知识卡',
|
||||
daily_task: '每日任务',
|
||||
theme_node: '主题节点',
|
||||
});
|
||||
|
||||
function toDateString(value: Date | string | null): string | null {
|
||||
if (!value) return null;
|
||||
@ -52,194 +26,27 @@ export interface DailyXpStatus {
|
||||
|
||||
/**
|
||||
* Calculate total XP for a correct answer, including combo bonus.
|
||||
* Combo bonus is cumulative: 3-combo = +5, 5-combo = +10, 10-combo = +25.
|
||||
* Combo bonus is cumulative: 3-combo = +5, 5-combo = +10, 10-combo = +20.
|
||||
*/
|
||||
export function calculateXp(baseXp: number, comboCount: number): number {
|
||||
const bonus = getComboBonusXp(comboCount);
|
||||
return baseXp + bonus;
|
||||
}
|
||||
|
||||
export function getComboBonusXp(comboCount: number): number {
|
||||
for (const tier of XP_RULES.comboBonuses) {
|
||||
let bonus = 0;
|
||||
for (const tier of COMBO_BONUSES) {
|
||||
if (comboCount >= tier.minCombo) {
|
||||
return tier.bonus;
|
||||
bonus = tier.bonus;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function getQuestionXpSource(difficulty: number | null | undefined): 'correct_normal' | 'correct_hard' {
|
||||
return (difficulty ?? 1) >= HARD_QUESTION_DIFFICULTY ? 'correct_hard' : 'correct_normal';
|
||||
}
|
||||
|
||||
export function getXpRewardAmount(source: XpRewardSource, amount?: number): number {
|
||||
switch (source) {
|
||||
case 'correct_normal':
|
||||
return XP_RULES.correctNormal;
|
||||
case 'correct_hard':
|
||||
return XP_RULES.correctHard;
|
||||
case 'combo_bonus':
|
||||
return amount ?? 0;
|
||||
case 'review_explanation':
|
||||
return XP_RULES.reviewExplanation;
|
||||
case 'complete_challenge':
|
||||
return XP_RULES.completeChallenge;
|
||||
case 'perfect_challenge':
|
||||
return XP_RULES.perfectChallengeBonus;
|
||||
case 'first_knowledge_card':
|
||||
return XP_RULES.firstKnowledgeCard;
|
||||
case 'daily_task':
|
||||
return clampXp(amount, XP_RULES.dailyTaskMin, XP_RULES.dailyTaskMax);
|
||||
case 'theme_node':
|
||||
return clampXp(amount, XP_RULES.themeNodeMin, XP_RULES.themeNodeMax);
|
||||
}
|
||||
}
|
||||
|
||||
export function createXpReward(source: XpRewardSource, amount?: number): XpReward {
|
||||
const resolvedAmount = getXpRewardAmount(source, amount);
|
||||
return {
|
||||
type: 'xp',
|
||||
source,
|
||||
amount: resolvedAmount,
|
||||
title: `${XP_REWARD_TITLES[source]} +${resolvedAmount} XP`,
|
||||
};
|
||||
}
|
||||
|
||||
export function createCorrectAnswerXpReward(
|
||||
difficulty: number | null | undefined,
|
||||
comboCount: number,
|
||||
): XpReward {
|
||||
const rewards = createCorrectAnswerXpRewards(difficulty, comboCount);
|
||||
return {
|
||||
type: 'xp',
|
||||
source: rewards[0]!.source,
|
||||
amount: rewards.reduce((total, reward) => total + reward.amount, 0),
|
||||
title: `+${rewards.reduce((total, reward) => total + reward.amount, 0)} XP`,
|
||||
};
|
||||
}
|
||||
|
||||
export function createCorrectAnswerXpRewards(
|
||||
difficulty: number | null | undefined,
|
||||
comboCount: number,
|
||||
): readonly XpReward[] {
|
||||
const source = getQuestionXpSource(difficulty);
|
||||
const baseAmount = getXpRewardAmount(source);
|
||||
const comboBonus = getComboBonusXp(comboCount);
|
||||
const rewards: XpReward[] = [{
|
||||
type: 'xp',
|
||||
source,
|
||||
amount: baseAmount,
|
||||
title: `${XP_REWARD_TITLES[source]} +${baseAmount} XP`,
|
||||
}];
|
||||
|
||||
if (comboBonus > 0) {
|
||||
rewards.push({
|
||||
type: 'xp',
|
||||
source: 'combo_bonus',
|
||||
amount: comboBonus,
|
||||
title: `${comboCount} 连对 +${comboBonus} XP`,
|
||||
});
|
||||
}
|
||||
|
||||
return rewards;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算当前自然周的起止日期。
|
||||
* 按 LEADERBOARD_RULES.weekStartsOnIsoDay 配置的周起始日(1=周一)。
|
||||
*/
|
||||
function getCurrentWeekRange(): { weekStart: Date; weekEnd: Date } {
|
||||
const now = new Date();
|
||||
// ISO 周起始日:1=周一,配置在 LEADERBOARD_RULES.weekStartsOnIsoDay
|
||||
const targetDay = LEADERBOARD_RULES.weekStartsOnIsoDay;
|
||||
const currentDay = now.getUTCDay() || 7; // 0(周日) → 7
|
||||
const diff = (currentDay - targetDay + 7) % 7;
|
||||
const start = new Date(now);
|
||||
start.setUTCDate(now.getUTCDate() - diff);
|
||||
start.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(start);
|
||||
end.setUTCDate(start.getUTCDate() + 6);
|
||||
return { weekStart: start, weekEnd: end };
|
||||
}
|
||||
|
||||
/**
|
||||
* 为用户分配一个周榜分组 ID。
|
||||
* 策略:查找本周未满的组(人数 < groupSizeMax),有则加入,否则创建新组。
|
||||
* 组 ID 格式:week-{weekStart}-group-{序号},方便调试和排序。
|
||||
*/
|
||||
async function assignGroupId(weekStartStr: string): Promise<string> {
|
||||
// 查找本周各组的当前人数。
|
||||
const groupCounts = await db
|
||||
.select({
|
||||
groupId: userWeeklyXp.groupId,
|
||||
count: sql<number>`COUNT(*)`,
|
||||
})
|
||||
.from(userWeeklyXp)
|
||||
.where(sql`CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr} AND ${userWeeklyXp.groupId} IS NOT NULL`)
|
||||
.groupBy(userWeeklyXp.groupId)
|
||||
.orderBy(userWeeklyXp.groupId);
|
||||
|
||||
// 找一个未满的组。
|
||||
for (const row of groupCounts) {
|
||||
if (row.groupId && Number(row.count) < LEADERBOARD_RULES.groupSizeMax) {
|
||||
return row.groupId;
|
||||
}
|
||||
}
|
||||
|
||||
// 没有未满的组,创建新组。序号 = 当前组数 + 1。
|
||||
const groupIndex = groupCounts.length + 1;
|
||||
return `week-${weekStartStr}-group-${groupIndex}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 累加用户本周 XP 统计。
|
||||
* 使用 INSERT ... ON DUPLICATE KEY UPDATE 实现幂等累加,
|
||||
* 确保每周每个用户只有一行记录。
|
||||
* 首次获得本周 XP 时自动分配到 20-30 人的排行榜分组。
|
||||
*/
|
||||
export async function addToWeeklyXp(userId: string, amount: number): Promise<void> {
|
||||
const { weekStart, weekEnd } = getCurrentWeekRange();
|
||||
const weekStartStr = weekStart.toISOString().slice(0, 10);
|
||||
|
||||
// 检查用户是否已有本周记录(决定是否需要分配组)。
|
||||
const [existing] = await db
|
||||
.select({ groupId: userWeeklyXp.groupId })
|
||||
.from(userWeeklyXp)
|
||||
.where(sql`${userWeeklyXp.userId} = ${userId} AND CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr}`)
|
||||
.limit(1);
|
||||
|
||||
// 首次获得本周 XP 时分配组;已有记录则保持原组。
|
||||
const groupId = existing?.groupId ?? await assignGroupId(weekStartStr);
|
||||
|
||||
await db
|
||||
.insert(userWeeklyXp)
|
||||
.values({
|
||||
id: uuid(),
|
||||
userId,
|
||||
weekStart,
|
||||
weekEnd,
|
||||
xpEarned: amount,
|
||||
groupId,
|
||||
lastXpAt: sql`NOW()`,
|
||||
})
|
||||
// uk_weekly_xp_user_week 唯一索引保证幂等:同用户同周只更新不重复插入。
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
xpEarned: sql`COALESCE(xp_earned, 0) + ${amount}`,
|
||||
lastXpAt: sql`NOW()`,
|
||||
},
|
||||
});
|
||||
return baseXp + bonus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add XP to a user. Handles daily XP reset if the date has changed.
|
||||
* Uses atomic SQL update to prevent race conditions.
|
||||
* 同时累加本周 XP 统计到 userWeeklyXp 表。
|
||||
*/
|
||||
export async function addXp(userId: string, amount: number): Promise<void> {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// 原子更新累计 XP 和每日 XP
|
||||
// Atomically update total XP and handle daily reset
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
@ -252,9 +59,6 @@ export async function addXp(userId: string, amount: number): Promise<void> {
|
||||
dailyXpDate: sql`CAST(${today} AS DATE)`,
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
// 同步累加本周 XP 统计,供排行榜查询。
|
||||
await addToWeeklyXp(userId, amount);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -284,9 +88,4 @@ export async function getDailyXpStatus(userId: string): Promise<DailyXpStatus> {
|
||||
};
|
||||
}
|
||||
|
||||
function clampXp(value: number | undefined, min: number, max: number): number {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) return min;
|
||||
return Math.max(min, Math.min(max, Math.floor(value)));
|
||||
}
|
||||
|
||||
export { BASE_XP };
|
||||
|
||||
@ -1,567 +0,0 @@
|
||||
import { and, desc, eq, gte, lt, sql } from 'drizzle-orm';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { db } from '../../db/client.js';
|
||||
import { adRecoverySessions, rewardLedger, users } from '../../db/schema.js';
|
||||
import { AD_RECOVERY_RULES, HEART_RULES } from '../gamification/rules.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;
|
||||
/** Plus 用户被拦截时返回订阅权益摘要,客户端可据此展示替代提示。 */
|
||||
subscriptionBenefits?: {
|
||||
tier: string;
|
||||
unlimitedHearts: boolean;
|
||||
dailyHighRewardSessions: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
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 SESSION_TTL_MS = AD_RECOVERY_RULES.sessionTtlMs;
|
||||
const STREAK_PROTECTION_COOLDOWN_MS = AD_RECOVERY_RULES.streakProtectionCooldownMs;
|
||||
const TRUSTED_TEST_PROVIDERS: ReadonlySet<string> = new Set(AD_RECOVERY_RULES.trustedTestProviders);
|
||||
|
||||
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<AdRecoveryType, string> = {
|
||||
hearts: 'restore_hearts',
|
||||
bonusAttempts: 'restore_bonus_attempts',
|
||||
streakProtection: 'streak_protection',
|
||||
};
|
||||
return `duoqi_${suffixByType[type]}_${platform}`;
|
||||
}
|
||||
|
||||
function isSubscribed(tier: UserTier | null | undefined, subscription: Awaited<ReturnType<typeof getSubscriptionStatus>>): boolean {
|
||||
return tier === 'pro' || tier === 'proplus' || (subscription.status === 'active' && subscription.tier !== 'free');
|
||||
}
|
||||
|
||||
/** 获取 Plus 用户的订阅权益摘要,供广告恢复接口在被拦截时返回给客户端。 */
|
||||
async function getSubscriptionBenefits(userId: string): Promise<{ tier: string; unlimitedHearts: boolean; dailyHighRewardSessions: number | null }> {
|
||||
const [tier, subscription] = await Promise.all([
|
||||
getUserTier(userId),
|
||||
getSubscriptionStatus(userId),
|
||||
]);
|
||||
const effectiveTier = (tier ?? 'free') as UserTier;
|
||||
const isPlus = isSubscribed(effectiveTier, subscription);
|
||||
return {
|
||||
tier: isPlus ? (effectiveTier ?? 'pro') : 'free',
|
||||
unlimitedHearts: isPlus,
|
||||
dailyHighRewardSessions: isPlus ? null : 3,
|
||||
};
|
||||
}
|
||||
|
||||
async function getUserTier(userId: string): Promise<UserTier | null> {
|
||||
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<AdRecoveryType, 'hearts' | 'bonusAttempts'>): Promise<number> {
|
||||
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<SessionRecord | null> {
|
||||
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<AdRecoveryLimits> {
|
||||
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, AD_RECOVERY_RULES.heartsDailyLimit - heartCount),
|
||||
remainingAttemptRecoveriesToday: Math.max(0, AD_RECOVERY_RULES.bonusAttemptsDailyLimit - 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<AdRecoveryCompleteResponse> {
|
||||
await db
|
||||
.update(adRecoverySessions)
|
||||
.set({
|
||||
status: reason === 'session_expired' ? 'expired' : 'failed',
|
||||
failureReason: reason,
|
||||
providerError,
|
||||
progressAfter: progress as unknown as Record<string, unknown>,
|
||||
})
|
||||
.where(eq(adRecoverySessions.id, session.id));
|
||||
return completionFailed(reason, progress, reason === 'session_expired' ? '广告会话已过期,请重新加载广告。' : undefined);
|
||||
}
|
||||
|
||||
async function getSession(userId: string, sessionId: string): Promise<SessionRecord | null> {
|
||||
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<AdRecoveryCompleteResponse> {
|
||||
return {
|
||||
status: 'completed',
|
||||
type: session.type,
|
||||
reward: session.rewardSnapshot as NonNullable<AdRecoveryCompleteResponse['reward']>,
|
||||
progress: session.progressAfter as unknown as ProgressSummaryDto,
|
||||
limits: await getLimits(userId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过统一奖励结算层发放广告恢复奖励。
|
||||
*
|
||||
* 每次恢复都会写入 rewardLedger,以 ad_recovery:{sessionId} 为幂等 key,
|
||||
* 确保同一 session 只能结算一次。流水记录包含奖励快照和发放前后状态,
|
||||
* 方便审计追溯。
|
||||
*/
|
||||
async function applyReward(
|
||||
sessionId: string,
|
||||
userId: string,
|
||||
type: AdRecoveryType,
|
||||
before: ProgressSummaryDto,
|
||||
): Promise<{ reward: NonNullable<AdRecoveryCompleteResponse['reward']>; progress: ProgressSummaryDto }> {
|
||||
// 幂等 key 绑定 sessionId,与 adRecoverySessions 的 CAS 状态机配合双保险。
|
||||
const idempotencyKey = `ad_recovery:${sessionId}`;
|
||||
const [existingLedger] = await db
|
||||
.select({ id: rewardLedger.id })
|
||||
.from(rewardLedger)
|
||||
.where(and(
|
||||
eq(rewardLedger.userId, userId),
|
||||
eq(rewardLedger.idempotencyKey, idempotencyKey),
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
// 已有流水记录说明该 session 已结算过,直接返回当前状态,不重复发放。
|
||||
if (existingLedger) {
|
||||
const progress = await getProgressSummary(userId);
|
||||
return {
|
||||
reward: {
|
||||
heartsDelta: Math.max(0, progress.hearts - before.hearts),
|
||||
dailyAttemptsDelta: Math.max(0, progress.dailyAttemptsLeft - before.dailyAttemptsLeft),
|
||||
streakProtectionGranted: false,
|
||||
},
|
||||
progress,
|
||||
};
|
||||
}
|
||||
|
||||
// 记录发放前的资源快照。
|
||||
const stateBefore = {
|
||||
hearts: before.hearts,
|
||||
maxHearts: before.maxHearts,
|
||||
dailyAttemptsLeft: before.dailyAttemptsLeft,
|
||||
dailyAttemptsMax: before.dailyAttemptsMax,
|
||||
streakDays: before.streakDays,
|
||||
streakProtectedUntil: before.streakProtectedUntil,
|
||||
} as Record<string, unknown>;
|
||||
|
||||
let reward: NonNullable<AdRecoveryCompleteResponse['reward']>;
|
||||
let resourceDeltas: Record<string, unknown>;
|
||||
|
||||
if (type === 'hearts') {
|
||||
// 恢复爱心到上限。
|
||||
const heartsBefore = before.hearts;
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
heartsRemaining: HEART_RULES.freeMax,
|
||||
heartsLastRestore: sql`NOW()`,
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
const progress = await getProgressSummary(userId);
|
||||
const heartsDelta = Math.max(0, progress.hearts - heartsBefore);
|
||||
reward = { heartsDelta, dailyAttemptsDelta: 0, streakProtectionGranted: false };
|
||||
resourceDeltas = { hearts: heartsDelta };
|
||||
} else if (type === 'bonusAttempts') {
|
||||
// 恢复 1 组高奖励挑战次数。
|
||||
const attempts = await getDailyAttempts(userId);
|
||||
const next = Math.min(attempts.left + AD_RECOVERY_RULES.bonusAttemptsPerRecovery, 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);
|
||||
const attemptsDelta = Math.max(0, progress.dailyAttemptsLeft - before.dailyAttemptsLeft);
|
||||
reward = { heartsDelta: 0, dailyAttemptsDelta: attemptsDelta, streakProtectionGranted: false };
|
||||
resourceDeltas = { bonusAttempts: attemptsDelta };
|
||||
} else {
|
||||
// 连胜保护:冻结签到并设置保护期。
|
||||
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));
|
||||
reward = { heartsDelta: 0, dailyAttemptsDelta: 0, streakProtectionGranted: true };
|
||||
resourceDeltas = { streakProtection: true };
|
||||
}
|
||||
|
||||
const progress = await getProgressSummary(userId);
|
||||
|
||||
// 记录发放后的资源快照。
|
||||
const stateAfter = {
|
||||
hearts: progress.hearts,
|
||||
maxHearts: progress.maxHearts,
|
||||
dailyAttemptsLeft: progress.dailyAttemptsLeft,
|
||||
dailyAttemptsMax: progress.dailyAttemptsMax,
|
||||
streakDays: progress.streakDays,
|
||||
streakProtectedUntil: progress.streakProtectedUntil,
|
||||
} as Record<string, unknown>;
|
||||
|
||||
// 写入统一奖励流水,sourceType 为 ad_recovery,与 schema 中的枚举一致。
|
||||
await db.insert(rewardLedger).values({
|
||||
id: uuid(),
|
||||
userId,
|
||||
sourceType: 'ad_recovery',
|
||||
sourceId: sessionId,
|
||||
idempotencyKey,
|
||||
status: 'completed',
|
||||
rewardSnapshot: {
|
||||
type,
|
||||
reward,
|
||||
},
|
||||
resourceDeltas,
|
||||
stateBefore,
|
||||
stateAfter,
|
||||
settledAt: sql`NOW()`,
|
||||
});
|
||||
|
||||
return { reward, progress };
|
||||
}
|
||||
|
||||
export async function createAdRecoverySession(userId: string, input: CreateAdRecoverySessionInput): Promise<AdRecoverySessionResponse> {
|
||||
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) {
|
||||
// Plus 用户不需要广告恢复,返回订阅权益摘要供客户端展示。
|
||||
const subscriptionBenefits = eligibility.reason === 'already_subscribed'
|
||||
? await getSubscriptionBenefits(userId)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
sessionId: null,
|
||||
eligible: false,
|
||||
reason: eligibility.reason,
|
||||
nextAvailableAt: eligibility.nextAvailableAt,
|
||||
subscriptionBenefits,
|
||||
};
|
||||
}
|
||||
|
||||
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<AdRecoveryCompleteResponse> {
|
||||
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(session.id, 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<string, unknown>,
|
||||
progressAfter: after as unknown as Record<string, unknown>,
|
||||
completedAt: safeCompletedAt,
|
||||
})
|
||||
.where(eq(adRecoverySessions.id, session.id));
|
||||
|
||||
return {
|
||||
status: 'completed',
|
||||
type: session.type,
|
||||
reward,
|
||||
progress: after,
|
||||
limits: await getLimits(userId),
|
||||
};
|
||||
}
|
||||
@ -1,86 +0,0 @@
|
||||
/**
|
||||
* 定时任务调度入口
|
||||
*
|
||||
* 提供周榜结算、订阅过期检查等定时任务的可部署入口。
|
||||
* 支持 dry-run 模式用于手动验证。
|
||||
*
|
||||
* 部署方式:
|
||||
* - cron 调用:`bun run src/services/scheduler/index.ts weekly-settlement`
|
||||
* - Admin 手动触发:`POST /v1/admin/jobs/trigger`
|
||||
*/
|
||||
import { weeklySettlement } from '../gamification/leaderboard-service.js';
|
||||
import { checkAndExpireSubscriptions } from '../payment/subscription-service.js';
|
||||
|
||||
export type JobName = 'weekly-settlement' | 'expire-subscriptions';
|
||||
|
||||
export interface JobResult {
|
||||
job: JobName;
|
||||
dryRun: boolean;
|
||||
executedAt: string;
|
||||
result: unknown;
|
||||
}
|
||||
|
||||
const JOBS: Record<JobName, (dryRun: boolean) => Promise<unknown>> = {
|
||||
'weekly-settlement': async (dryRun) => weeklySettlement(dryRun),
|
||||
'expire-subscriptions': async (dryRun) => {
|
||||
if (dryRun) {
|
||||
return { message: 'dry-run: will check and expire subscriptions', note: '无实际变更' };
|
||||
}
|
||||
const count = await checkAndExpireSubscriptions();
|
||||
return { expiredCount: count };
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 执行定时任务。
|
||||
* @param jobName 任务名称
|
||||
* @param dryRun 为 true 时只返回预览,不写库
|
||||
*/
|
||||
export async function runJob(jobName: JobName, dryRun = false): Promise<JobResult> {
|
||||
const handler = JOBS[jobName];
|
||||
if (!handler) {
|
||||
throw new Error(`Unknown job: ${jobName}. Available: ${Object.keys(JOBS).join(', ')}`);
|
||||
}
|
||||
|
||||
const result = await handler(dryRun);
|
||||
return {
|
||||
job: jobName,
|
||||
dryRun,
|
||||
executedAt: new Date().toISOString(),
|
||||
result,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出所有可用定时任务及其描述。
|
||||
*/
|
||||
export function listJobs(): Array<{ name: JobName; description: string; schedule: string }> {
|
||||
return [
|
||||
{
|
||||
name: 'weekly-settlement',
|
||||
description: '周榜结算:按组快照上周排名,给每组前 3 名发金币奖励',
|
||||
schedule: '每周一 UTC 00:30',
|
||||
},
|
||||
{
|
||||
name: 'expire-subscriptions',
|
||||
description: '订阅过期检查:检查并过期到期的订阅',
|
||||
schedule: '每日 UTC 01:00',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// CLI 入口:`bun run src/services/scheduler/index.ts weekly-settlement [--dry-run]`
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length > 0 && args[0] !== '--dry-run') {
|
||||
const jobName = args[0] as JobName;
|
||||
const dryRun = args.includes('--dry-run');
|
||||
runJob(jobName, dryRun)
|
||||
.then((result) => {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`Job failed: ${err instanceof Error ? err.message : err}`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@ -1,8 +1,4 @@
|
||||
import { ValidationError } from '../../utils/errors.js';
|
||||
import { spendCoins } from '../gamification/coin-service.js';
|
||||
import { createInventoryReward, grantInventoryItem } from '../gamification/inventory-service.js';
|
||||
import { ITEM_RULES, type InventoryItemId } from '../gamification/rules.js';
|
||||
import type { ShopBenefitDto, ShopCatalogDto, ShopProductDto, ShopProductId, ShopPurchaseResultDto } from '../../types/app-api.js';
|
||||
import type { ShopBenefitDto } from '../../types/app-api.js';
|
||||
|
||||
const SHOP_BENEFITS: readonly ShopBenefitDto[] = Object.freeze([
|
||||
{
|
||||
@ -39,121 +35,6 @@ const SHOP_BENEFITS: readonly ShopBenefitDto[] = Object.freeze([
|
||||
},
|
||||
]);
|
||||
|
||||
const SHOP_PRODUCTS: readonly ShopProductDto[] = Object.freeze([
|
||||
{
|
||||
id: 'hint-feather',
|
||||
type: 'item',
|
||||
itemId: ITEM_RULES.hintFeather.id,
|
||||
title: '提示羽毛',
|
||||
description: '答题时排除 1 个错误选项',
|
||||
priceCoins: ITEM_RULES.hintFeather.shopPriceCoins,
|
||||
quantity: 1,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'heart-supply',
|
||||
type: 'item',
|
||||
itemId: ITEM_RULES.heartSupply.id,
|
||||
title: '爱心补给',
|
||||
description: '使用后恢复满爱心',
|
||||
priceCoins: ITEM_RULES.heartSupply.shopPriceCoins,
|
||||
quantity: 1,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'double-xp-potion',
|
||||
type: 'item',
|
||||
itemId: ITEM_RULES.doubleXpPotion.id,
|
||||
title: '双倍 XP 药水',
|
||||
description: '使用后 15 分钟内 XP 翻倍',
|
||||
priceCoins: ITEM_RULES.doubleXpPotion.shopPriceCoins,
|
||||
quantity: 1,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'streak-shield',
|
||||
type: 'item',
|
||||
itemId: ITEM_RULES.streakShield.id,
|
||||
title: '连胜护盾',
|
||||
description: '忙碌时保护 1 天连续学习',
|
||||
priceCoins: ITEM_RULES.streakShield.shopPriceCoins,
|
||||
quantity: 1,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'mascot-outfit-starter',
|
||||
type: 'cosmetic',
|
||||
itemId: ITEM_RULES.mascotOutfit.id,
|
||||
title: '多奇出行装',
|
||||
description: '第一版吉祥物装扮',
|
||||
priceCoins: ITEM_RULES.mascotOutfit.shopPriceCoinsMin,
|
||||
quantity: 1,
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
|
||||
export async function getShopBenefits(): Promise<readonly ShopBenefitDto[]> {
|
||||
return SHOP_BENEFITS;
|
||||
}
|
||||
|
||||
export async function getShopCatalog(): Promise<ShopCatalogDto> {
|
||||
return {
|
||||
benefits: SHOP_BENEFITS,
|
||||
products: SHOP_PRODUCTS,
|
||||
};
|
||||
}
|
||||
|
||||
export async function purchaseShopProduct(
|
||||
userId: string,
|
||||
productId: ShopProductId,
|
||||
clientRequestId: string,
|
||||
): Promise<ShopPurchaseResultDto> {
|
||||
const product = getShopProduct(productId);
|
||||
if (!product.enabled) {
|
||||
throw new ValidationError('商品暂不可购买');
|
||||
}
|
||||
|
||||
const sourceId = `shop:${clientRequestId}`;
|
||||
const spend = await spendCoins({
|
||||
userId,
|
||||
amount: product.priceCoins,
|
||||
sourceId,
|
||||
idempotencyKey: `${sourceId}:coins`,
|
||||
snapshot: { productId: product.id },
|
||||
});
|
||||
const inventory = await grantInventoryItem({
|
||||
userId,
|
||||
itemId: product.itemId as InventoryItemId,
|
||||
quantity: product.quantity,
|
||||
sourceType: 'shop_purchase',
|
||||
sourceId,
|
||||
idempotencyKey: `${sourceId}:item`,
|
||||
metadata: product.type === 'cosmetic' ? { productId: product.id, title: product.title } : undefined,
|
||||
snapshot: {
|
||||
productId: product.id,
|
||||
priceCoins: product.priceCoins,
|
||||
coinsSpentApplied: spend.applied,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
product,
|
||||
coinsSpent: spend.applied ? product.priceCoins : 0,
|
||||
coinsBalance: spend.balanceAfter,
|
||||
item: {
|
||||
itemId: inventory.item.itemId,
|
||||
quantity: inventory.item.quantity,
|
||||
activeUntil: inventory.item.activeUntil?.toISOString() ?? null,
|
||||
metadata: inventory.item.metadata,
|
||||
},
|
||||
rewards: [createInventoryReward(product.itemId as InventoryItemId, product.quantity)],
|
||||
};
|
||||
}
|
||||
|
||||
function getShopProduct(productId: ShopProductId): ShopProductDto {
|
||||
const product = SHOP_PRODUCTS.find((item) => item.id === productId);
|
||||
if (!product) {
|
||||
throw new ValidationError('商品不存在');
|
||||
}
|
||||
return product;
|
||||
}
|
||||
|
||||
@ -27,8 +27,6 @@ export interface ProgressSummaryDto {
|
||||
dailyAttemptsLeft: number;
|
||||
dailyAttemptsMax: number;
|
||||
nextAttemptResetAt: string | null;
|
||||
highRewardSessionsLeft: number;
|
||||
highRewardSessionsMax: number;
|
||||
xp: number;
|
||||
level: number;
|
||||
xpToNextLevel: number;
|
||||
@ -64,87 +62,11 @@ export interface ShopBenefitDto {
|
||||
requiresAd: boolean;
|
||||
}
|
||||
|
||||
export type ShopProductType = 'item' | 'cosmetic';
|
||||
export type ShopProductId =
|
||||
| 'hint-feather'
|
||||
| 'heart-supply'
|
||||
| 'double-xp-potion'
|
||||
| 'streak-shield'
|
||||
| 'mascot-outfit-starter';
|
||||
|
||||
export interface ShopProductDto {
|
||||
id: ShopProductId;
|
||||
type: ShopProductType;
|
||||
itemId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
priceCoins: number;
|
||||
quantity: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface ShopCatalogDto {
|
||||
benefits: readonly ShopBenefitDto[];
|
||||
products: readonly ShopProductDto[];
|
||||
}
|
||||
|
||||
export interface ShopPurchaseResultDto {
|
||||
product: ShopProductDto;
|
||||
coinsSpent: number;
|
||||
coinsBalance: number;
|
||||
item: {
|
||||
itemId: string;
|
||||
quantity: number;
|
||||
activeUntil: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
};
|
||||
rewards: ReadonlyArray<{
|
||||
type: string;
|
||||
source?: string;
|
||||
amount?: number;
|
||||
itemId?: string;
|
||||
quantity?: number;
|
||||
title?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface WalletDto {
|
||||
coinsBalance: number;
|
||||
}
|
||||
|
||||
export interface InventoryItemDto {
|
||||
itemId: string;
|
||||
quantity: number;
|
||||
activeUntil: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface InventoryDto {
|
||||
items: readonly InventoryItemDto[];
|
||||
}
|
||||
|
||||
export type UsableInventoryItemId = 'streak_shield' | 'double_xp_potion' | 'heart_supply' | 'hint_feather';
|
||||
|
||||
export interface UseInventoryItemResultDto {
|
||||
itemId: UsableInventoryItemId;
|
||||
quantityRemaining: number;
|
||||
effect: {
|
||||
type: 'restore_hearts' | 'double_xp' | 'hint' | 'streak_protection';
|
||||
activeUntil?: string | null;
|
||||
hearts?: number;
|
||||
excludedOptions?: readonly string[];
|
||||
streakProtectedUntil?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BootstrapDto {
|
||||
user: UserBriefDto;
|
||||
progress: ProgressSummaryDto;
|
||||
tracks: ThemeTrackDto[];
|
||||
shopBenefits: readonly ShopBenefitDto[];
|
||||
shop: ShopCatalogDto;
|
||||
wallet: WalletDto;
|
||||
inventory: InventoryDto;
|
||||
subscription: SubscriptionDto;
|
||||
}
|
||||
|
||||
@ -159,21 +81,11 @@ export interface ChallengeQuestionDto {
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChallengeSessionDto {
|
||||
challengeId: string;
|
||||
trackId: string;
|
||||
nodeId: string;
|
||||
totalQuestions: number;
|
||||
highRewardEligible: boolean;
|
||||
questions: readonly ChallengeQuestionDto[];
|
||||
}
|
||||
|
||||
export interface AnswerRequestDto {
|
||||
challengeId: string;
|
||||
questionId: string;
|
||||
selectedOptionId: string;
|
||||
timeMs: number;
|
||||
submitRequestId?: string;
|
||||
}
|
||||
|
||||
export interface AnswerResultDto {
|
||||
@ -183,8 +95,6 @@ export interface AnswerResultDto {
|
||||
progress: {
|
||||
hearts: number;
|
||||
dailyAttemptsLeft: number;
|
||||
highRewardSessionsLeft: number;
|
||||
highRewardSessionsMax: number;
|
||||
xp: number;
|
||||
streakDays: number;
|
||||
};
|
||||
@ -194,14 +104,7 @@ export interface AnswerResultDto {
|
||||
summary: string;
|
||||
fact: string;
|
||||
};
|
||||
rewards: ReadonlyArray<{
|
||||
type: string;
|
||||
source?: string;
|
||||
amount?: number;
|
||||
itemId?: string;
|
||||
quantity?: number;
|
||||
title?: string;
|
||||
}>;
|
||||
rewards: ReadonlyArray<{ type: string; amount?: number; title?: string }>;
|
||||
}
|
||||
|
||||
export interface LeaderboardEntryDto {
|
||||
@ -213,15 +116,3 @@ export interface LeaderboardEntryDto {
|
||||
badge: string;
|
||||
isMe: boolean;
|
||||
}
|
||||
|
||||
/** 周榜元信息,附带在排行榜响应中。 */
|
||||
export interface LeaderboardMetaDto {
|
||||
weekStart: string;
|
||||
weekEnd: string;
|
||||
nextRefreshAt: string;
|
||||
groupId: string | null;
|
||||
/** 当前用户组内排名(仅 /leaderboards/me 返回)。 */
|
||||
rank?: number;
|
||||
/** 当前周奖励预览:各组前 3 名的金币奖励。 */
|
||||
rewardPreview: Array<{ rank: number; coins: number }>;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user