Compare commits

...

48 Commits

Author SHA1 Message Date
2a3413c4d5 完成游戏化服务端全部 Phase G0-G6
Some checks failed
CI/CD Pipeline / Code Quality (push) Successful in 43s
CI/CD Pipeline / Unit Tests (push) Failing after 20s
CI/CD Pipeline / Build & Deploy Test (push) Has been skipped
CI/CD Pipeline / Build & Deploy Production (push) Has been skipped
最终验证通过 typecheck(零错误)、eslint(零错误)、git diff --check。
覆盖规则常量、挑战组、XP/等级、金币/商店、广告恢复、周榜排行榜、
Admin 查看、集成测试、定时任务调度和 API 文档更新。
2026-05-13 22:46:51 +08:00
c24be16b6a 增加定时任务调度入口和 Admin 触发路由
- 新增 scheduler/index.ts 统一调度周榜结算和订阅过期检查
- 支持 CLI 入口:bun run src/services/scheduler/index.ts weekly-settlement --dry-run
- 支持 Admin 手动触发:GET/POST /v1/admin/jobs
- 所有任务支持 dry-run 模式预览不写库
2026-05-13 22:39:35 +08:00
1d9c67b30c 增加游戏化核心流程集成测试
新增 gamification-flow.test.ts,覆盖挑战 XP 累加→金币发放→周榜
分组、广告恢复爱心→rewardLedger、组内排名统计 3 个跨服务集成场景。
验证 addXp 写入 userWeeklyXp 含 groupId、grantCoins 幂等、
completeAdRecoverySession 写入统一奖励流水。
2026-05-13 22:33:47 +08:00
f64c8e2fe4 增加 Admin 游戏化数据只读查看
新增 5 个 Admin 端点查看用户金币钱包、道具库存、奖励流水、
广告恢复记录和资源变更流水,全部只读 GET,支持分页。
路由注册在 /v1/admin/gamification/ 下。
2026-05-13 22:24:23 +08:00
cff1c148de 更新实施计划索引添加 Game Economy 阶段
- implementation-plan.md 新增 Phase 1d Game Economy 索引
- 更新总体进度表、依赖图、成功标准
- 链接到 gamification-server-plan.md 和 GAMIFICATION_DESIGN.md
- Success Criteria 新增游戏化相关验收条目
2026-05-13 22:20:07 +08:00
0ee3522280 更新 API 参考文档对齐游戏化变更
- 排行榜响应新增 meta 字段含周信息/分组/奖励预览
- xp 改为本周 XP,说明排名基于 20-30 人分组
- 广告恢复 session 新增 Plus 用户 subscriptionBenefits 响应
- 标注旧恢复接口已废弃,指向 session+complete 两步流程
2026-05-13 22:11:52 +08:00
407a2b9b32 添加排行榜回归测试覆盖 G5-6
新增 leaderboard-service.test.ts,覆盖周 XP 累加与首次分组/加入
未满组/不重分配、组内排名、无记录、dryRun 预览/正式结算/多组
独立奖励、周榜元信息与奖励预览 10 个场景。Phase G5 全部完成。
2026-05-13 22:03:38 +08:00
6c63b6e24a 暴露周榜元信息到排行榜 API
- 新增 LeaderboardMetaDto 含 weekStart/weekEnd/nextRefreshAt/groupId/rank/rewardPreview
- leaderboard-service 新增 getLeaderboardMeta() 获取当前周元信息
- /leaderboards 和 /leaderboards/me 响应中附带 meta 字段
- 奖励预览返回前 3 名的 300/150/50 金币配置
2026-05-13 21:48:54 +08:00
a3da577bf3 实现排行榜前三奖励结算
- weeklySettlement 按组结算,每组前 3 名发 300/150/50 金币
- 奖励通过 grantCoins 幂等发放,idempotencyKey 包含组+排名+用户
- 结算返回 SettlementResult 含 rewards 列表和 groupCount
- coin-service 新增 leaderboard_settlement 奖励来源
- schema inventoryTransactions.sourceType 新增 leaderboard_settlement 枚举值
2026-05-13 21:43:14 +08:00
66112c30f8 实现排行榜 20-30 人分组
- 用户本周首次获得 XP 时自动分配到 20-30 人榜组
- 分组策略:查找未满组加入,否则创建新组
- 组 ID 格式 week-{date}-group-{序号},方便调试
- 排行榜查询和我的排名改为组内排名
- getLeaderboard 新增 userId 参数获取用户所在组
2026-05-13 21:30:08 +08:00
08461485d5 实现每周一刷新逻辑与幂等周结算
- weeklySettlement 改为结算上一周数据(周一当前周 XP 为 0)
- 快照写入使用 onDuplicateKeyUpdate 保证幂等
- userWeeklyXp.settled 标记防止重复结算
- 新增 dryRun 模式返回结算预览不写库
- 时区策略注释:所有周榜计算统一 UTC,客户端本地转换
2026-05-13 21:06:15 +08:00
d7d5f8109c 改造排行榜数据源为本周 XP
- addXp() 每次获得 XP 时同步累加 userWeeklyXp 表的本周统计
- 使用 INSERT ON DUPLICATE KEY UPDATE 实现幂等周 XP 累加
- leaderboard-service 从 userWeeklyXp 查询本周 XP 排名替代累计 XP
- leaderboard-api-service DTO 中 xp 字段改为展示本周 XP
- weeklySettlement() 基于 userWeeklyXp 生成周快照
2026-05-13 21:00:48 +08:00
eee2116633 添加广告恢复回归测试覆盖 G4-7
新增 ad-recovery-service.test.ts,覆盖幂等 session 创建、Plus 拦截
与权益摘要、每日上限、会话过期、provider token 缺失、信任测试
provider、已完成会话幂等返回、rewardLedger 幂等命中 8 个场景。
Phase G4 全部完成。
2026-05-13 20:34:55 +08:00
de0055e794 标记旧恢复接口废弃并明确 Plus 用户分支
- 在 3 个旧恢复路由上标记 [废弃] 注释,指向新的 ad-recovery 两步流程
- Plus 用户调用广告恢复接口时返回 subscriptionBenefits 权益摘要
- 包含 tier、unlimitedHearts、dailyHighRewardSessions 供客户端展示
2026-05-13 20:24:32 +08:00
8401d8c714 对齐广告恢复奖励到统一奖励结算层
将 ad-recovery-service 的 applyReward() 从直接操作 users 表改为通过
rewardLedger 统一结算层发放,使用 ad_recovery:{sessionId} 幂等 key
防止重复结算,记录 stateBefore/After 资源快照便于审计追溯。
2026-05-13 20:04:32 +08:00
7aa53657fc 补齐金币商店测试覆盖 2026-05-13 17:45:58 +08:00
6bf9db9820 扩展游戏化启动与商店 DTO 2026-05-13 17:38:54 +08:00
b74201d6e0 实现游戏化道具使用接口 2026-05-13 17:31:54 +08:00
ff75c34873 实现游戏化商店购买接口 2026-05-13 17:16:30 +08:00
5a29c59cf0 实现游戏化道具库存服务 2026-05-13 16:57:34 +08:00
3bcaf0fbf3 实现游戏化宝箱奖励服务 2026-05-13 16:41:57 +08:00
18865e17ca 实现游戏化金币发放服务 2026-05-13 13:01:00 +08:00
1ad26d0fe8 补齐 XP 与连续学习测试覆盖 2026-05-13 10:55:17 +08:00
c08d3f75b9 实现每日首次进入红心补给 2026-05-13 10:53:27 +08:00
d71c45b2f1 实现连续学习里程碑奖励 2026-05-13 10:51:01 +08:00
447cef3dea 按挑战组完成更新连续学习 2026-05-13 10:47:46 +08:00
b5b3aaf3a7 实现游戏化 XP 来源与连对奖励 2026-05-13 10:26:21 +08:00
b590e60bce feat: implement non-linear 50-level XP curve (G2-1)
Replace flat 400 XP/level formula with the segmented curve from
LEVEL_RULES: Lv.1-5 steep ramp, Lv.6-10 moderate, Lv.11-20 linear +80,
Lv.21-35 +120, Lv.36-50 +180. Level 50 is hard-capped with xpToNextLevel=0.

Uses binary search over pre-computed cumulative thresholds for O(log n)
level lookup.
2026-05-12 11:04:23 +08:00
665efa4370 test: add comprehensive challenge group tests (G1-7)
Add 11 new test cases covering challenge session creation, correct/wrong
answers, idempotent duplicate submission, completion settlement, resource
exhaustion, Plus user bypass, and invalid input validation.

Refactor test helpers to use queue-based mockImplementation pattern for
more reliable db.select mocking across complex async flows.
2026-05-12 10:38:17 +08:00
8801ca1db2 docs: mark G1-6 challenge API DTO update as completed 2026-05-12 00:12:50 +08:00
c8a5d0bf25 feat: add high-reward quota fields to challenge answer DTO
Include highRewardSessionsLeft/Max in AnswerResultDto.progress
so clients can update UI after each answer without extra API calls.
2026-05-12 00:12:24 +08:00
e2fdce2268 docs: mark G1-5 daily high-reward session limits as completed 2026-05-12 00:01:57 +08:00
05b9faa0ea feat: enforce daily high-reward session limits with tier-based quotas
Free users get 3 high-reward sessions/day, Plus users get 8. Sessions
after quota are still playable but with degraded XP rewards.
2026-05-12 00:01:31 +08:00
708165e121 docs: mark G1-4 heart deduction boundaries as completed 2026-05-11 23:45:07 +08:00
6ea5ed9de0 feat: add heart deduction boundaries with new user protection
Add 3-day new user heart protection (minimum 1 heart) and block
answering when hearts are exhausted for free-tier users.
2026-05-11 23:44:45 +08:00
9e0f97d162 Settle completed challenge sessions 2026-05-11 21:40:41 +08:00
5bb6ba29a2 Record idempotent challenge answers 2026-05-11 21:34:27 +08:00
1d84de8d15 Create challenge sessions with five questions 2026-05-11 18:32:40 +08:00
fd4c2b6361 Generate game economy migrations 2026-05-11 18:23:29 +08:00
6a655d0ce2 Add weekly XP schema 2026-05-11 18:18:33 +08:00
7a617ce1f9 Add daily progress schema 2026-05-11 18:06:19 +08:00
51395bf5ec Add reward ledger schema 2026-05-11 17:59:03 +08:00
a23f1abc12 Add wallet and inventory schema 2026-05-11 17:41:26 +08:00
5570973f74 Add challenge session schema 2026-05-11 17:39:06 +08:00
8382183ee5 Add gamification rule constants 2026-05-11 17:33:53 +08:00
0dd6633fd4 Add gamification design and server plan 2026-05-11 17:06:42 +08:00
94b807ad16 docs: annotate database schema fields 2026-05-11 12:45:00 +08:00
2649b24277 Add ad recovery API contract 2026-05-05 16:12:04 +08:00
51 changed files with 12530 additions and 1638 deletions

View File

@ -0,0 +1,27 @@
CREATE TABLE `ad_recovery_sessions` (
`id` char(36) NOT NULL,
`user_id` char(36) NOT NULL,
`type` enum('hearts','bonusAttempts','streakProtection') NOT NULL,
`status` enum('pending','settling','completed','failed','expired') DEFAULT 'pending',
`client_request_id` varchar(80) NOT NULL,
`complete_request_id` varchar(80),
`platform` enum('ios','android','harmony','web') NOT NULL,
`ad_provider` varchar(50) NOT NULL,
`ad_placement_id` varchar(120) NOT NULL,
`provider_reward_token` varchar(500),
`reward_snapshot` json,
`progress_before` json,
`progress_after` json,
`failure_reason` varchar(80),
`provider_error` varchar(500),
`duplicate_count` int DEFAULT 0,
`expires_at` datetime NOT NULL,
`completed_at` datetime,
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `ad_recovery_sessions_id` PRIMARY KEY(`id`),
CONSTRAINT `uk_ad_recovery_user_client_request` UNIQUE(`user_id`,`client_request_id`)
);
--> statement-breakpoint
ALTER TABLE `ad_recovery_sessions` ADD CONSTRAINT `ad_recovery_sessions_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `idx_ad_recovery_user_type_status_created` ON `ad_recovery_sessions` (`user_id`,`type`,`status`,`created_at`);

View File

@ -0,0 +1,201 @@
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

View File

@ -15,6 +15,20 @@
"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
}
]
}

201
docs/GAMIFICATION_DESIGN.md Normal file
View File

@ -0,0 +1,201 @@
# 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

View File

@ -0,0 +1,155 @@
# 游戏化服务端实施计划
> 来源设计文档:[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 G2XP、等级、连续学习和知识卡奖励
| # | 任务 | 状态 | 验收标准 |
|---|------|------|----------|
| G2-1 | 实现 50 级等级曲线 | [x] | Lv.1-50 按设计表计算,`xpToNextLevel` 准确,超过 50 级有明确封顶或溢出策略 |
| G2-2 | 扩展 XP 奖励来源 | [x] | 支持普通题 10、困难题 15、看解析 3、完成挑战 20、全对 30、首次知识卡 15、每日任务、主题节点奖励 |
| G2-3 | 修正连对奖励 | [x] | 3 连对 +55 连对 +1010 连对 +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-13G2-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-13G2-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-13G2-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-13G2-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-13G2-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-13G2-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-13G3-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-13G3-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-13G3-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-13G3-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-13G3-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-13G3-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-13G3-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-13G4-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-13G4-7 已通过 `./node_modules/.bin/tsc --noEmit``./node_modules/.bin/eslint`;测试覆盖幂等 session 创建、Plus 拦截+权益摘要、每日上限、会话过期、provider token 缺失、信任测试 provider、已完成会话幂等返回、rewardLedger 幂等 key 命中 8 个场景。
验证记录2026-05-13G5 全部通过 `./node_modules/.bin/tsc --noEmit``./node_modules/.bin/eslint`G5-6 测试覆盖 addToWeeklyXp 首次分组/加入未满组/不重新分配、getUserRank 组内排名/无记录、weeklySettlement dryRun/正式结算/多组独立奖励、getLeaderboardMeta 周信息与奖励预览。
验证记录2026-05-13G6-6 最终验证通过 `./node_modules/.bin/tsc --noEmit`(零错误)、`./node_modules/.bin/eslint .`(零错误)、`git diff --check`无空白问题。vitest 因 `@rolldown/binding-darwin-x64` 原生 binding 签名问题仍无法在本地启动,需修复依赖安装或签名后复跑。全部 6 个 PhaseG0-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 G6API 文档、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、错误码保持一致。

View File

@ -1,14 +1,14 @@
# duoqi-api Implementation Plan
> Phase 1b (Core Features) + Phase 1c (Commercialization)
> Phase 1b (Core Features) + Phase 1c (Commercialization) + Phase 1d (Game Economy)
> Created: 2026-04-08
> Last Updated: 2026-04-09
> Last Updated: 2026-05-13
## Overview
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.
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.
### Overall Progress: 42/44 Steps Complete (95%)
### Overall Progress
| Phase | Steps | Status |
|-------|-------|--------|
@ -24,6 +24,21 @@ 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
@ -158,6 +173,10 @@ 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
@ -183,5 +202,11 @@ 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

View File

@ -0,0 +1,179 @@
/**
*
*
* 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. 完成挑战获得 XPaddXp 内部累加 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);
});
});

View File

@ -0,0 +1,79 @@
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,
});
});
});

View File

@ -0,0 +1,42 @@
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);
});
});

View File

@ -0,0 +1,157 @@
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();
});
});

View File

@ -0,0 +1,211 @@
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('金币余额不足');
});
});

View File

@ -0,0 +1,272 @@
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('提示羽毛库存不足');
});
});

View File

@ -0,0 +1,181 @@
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),
}));
});
});

View File

@ -0,0 +1,246 @@
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 },
]);
});
});
});

View File

@ -0,0 +1,543 @@
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');
});
});
});

View File

@ -1,10 +1,27 @@
import { describe, expect, it } from 'vitest';
import { getDailyAttemptsMax, getLevelInfo, getNextHeartRestoreAt } from '../../../services/learning/progress-summary-service.js';
import {
getDailyAttemptsMax,
getLevelInfo,
getNextHeartRestoreAt,
shouldGrantDailyFirstVisitHeart,
} from '../../../services/learning/progress-summary-service.js';
describe('progress-summary-service', () => {
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('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('uses tier-specific daily attempt limits', () => {
@ -17,4 +34,58 @@ 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);
});
});

View File

@ -1,6 +1,26 @@
import { describe, it, expect } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { db } from '../../../db/client.js';
describe('Hearts service — constants', () => {
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', () => {
it('MAX_FREE_HEARTS is 5', async () => {
const { MAX_FREE_HEARTS } = await import('../../../services/progress/hearts-service.js');
expect(MAX_FREE_HEARTS).toBe(5);
@ -10,4 +30,112 @@ describe('Hearts service — constants', () => {
const { PRO_HEARTS } = await import('../../../services/progress/hearts-service.js');
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);
});
});
});

View File

@ -1,9 +1,20 @@
import { describe, it, expect } from 'vitest';
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';
// 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}$/);
@ -20,3 +31,135 @@ 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),
}));
});
});

View File

@ -1,5 +1,13 @@
import { describe, it, expect } from 'vitest';
import { calculateXp } from '../../../services/progress/xp-service.js';
import {
calculateXp,
createCorrectAnswerXpReward,
createCorrectAnswerXpRewards,
createXpReward,
getComboBonusXp,
getQuestionXpSource,
getXpRewardAmount,
} from '../../../services/progress/xp-service.js';
describe('XP service', () => {
describe('calculateXp', () => {
@ -10,18 +18,21 @@ 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 +20 bonus at 10-combo', () => {
expect(calculateXp(10, 10)).toBe(30);
expect(calculateXp(10, 20)).toBe(30);
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('works with different base XP values', () => {
@ -29,4 +40,62 @@ 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',
});
});
});
});

View File

@ -0,0 +1,301 @@
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 });
// 无重复请求 + 免费用户 selecttier 查询)
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();
});
});
});

View File

@ -1,7 +1,39 @@
import { describe, expect, it } from 'vitest';
import { getShopBenefits } from '../../../services/shop/shop-service.js';
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;
}
describe('shop-service', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns the client shop benefit catalog', async () => {
const benefits = await getShopBenefits();
@ -13,4 +45,98 @@ 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('金币余额不足');
});
});

View File

@ -19,82 +19,86 @@ 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(),
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(), // 面向 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`), // 更新时间。
}, (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] }),
@ -102,13 +106,14 @@ 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] }),
@ -117,135 +122,389 @@ 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'),
league: varchar('league', { length: 50 }),
weekStart: date('week_start'),
weekEnd: date('week_end'),
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'), // 当前排名。
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`), // 创建时间。
}, (table) => [
index('idx_user_week').on(table.userId, table.weekStart),
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] }),
]);
// ── 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 }),
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 }), // 操作来源 IP。
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),
]);

View File

@ -18,6 +18,7 @@ import { progressRoutes } from './routes/progress.js';
import { gamificationRoutes } from './routes/gamification.js';
import { paymentRoutes } from './routes/payment.js';
import { appApiRoutes } from './routes/app-api.js';
import { rewardsRoutes } from './routes/rewards.js';
import { adminRoutes } from './routes/admin/index.js';
async function main(): Promise<void> {
@ -67,6 +68,7 @@ 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' });

View File

@ -0,0 +1,47 @@
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 };
});
}

View File

@ -8,6 +8,8 @@ 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);
@ -19,4 +21,6 @@ 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' });
}

18
src/routes/admin/jobs.ts Normal file
View File

@ -0,0 +1,18 @@
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 };
});
}

View File

@ -12,8 +12,9 @@ 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 { getShopBenefits } from '../services/shop/shop-service.js';
import { getShopCatalog, purchaseShopProduct } 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']),
@ -25,6 +26,7 @@ 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({
@ -45,6 +47,17 @@ 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;
}
@ -82,10 +95,12 @@ 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 };
});
@ -107,6 +122,8 @@ 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);
@ -114,6 +131,8 @@ 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);
@ -121,6 +140,8 @@ 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);
@ -138,18 +159,37 @@ export async function appApiRoutes(app: FastifyInstance): Promise<void> {
parsed.data.page,
parsed.data.limit,
);
return { success: true, data: data.items, pagination: data.pagination, error: null };
return { success: true, data: data.items, meta: data.meta, 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, error: null };
return { success: true, data: data?.entry ?? null, meta: data?.meta ?? null, error: null };
});
app.get('/shop', async () => {
const data = await getShopBenefits();
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,
});
return { success: true, data, error: null };
});

View File

@ -4,8 +4,9 @@ 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(tier, Number(page), Number(limit));
const data = await getLeaderboard(userId, tier, Number(page), Number(limit));
return { success: true, data: data.items, pagination: data.pagination, error: null };
});

View File

@ -38,6 +38,7 @@ 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) {

48
src/routes/rewards.ts Normal file
View File

@ -0,0 +1,48 @@
import { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
completeAdRecoverySession,
createAdRecoverySession,
} from '../services/rewards/ad-recovery-service.js';
const adRecoveryTypeSchema = z.enum(['hearts', 'bonusAttempts', 'streakProtection']);
const platformSchema = z.enum(['ios', 'android', 'harmony', 'web']);
const createAdRecoverySessionSchema = z.object({
type: adRecoveryTypeSchema,
clientRequestId: z.string().min(1).max(80),
platform: platformSchema,
adProvider: z.string().min(1).max(50),
});
const completeAdRecoverySessionSchema = z.object({
sessionId: z.string().uuid(),
clientRequestId: z.string().min(1).max(80),
adProvider: z.string().min(1).max(50),
providerRewardToken: z.string().max(500).optional(),
completedAt: z.string().datetime(),
});
function getUserId(request: { user: unknown }): string {
return (request.user as { userId: string }).userId;
}
function validationError(message: string | undefined) {
return { success: false, data: null, error: { code: 'VALIDATION_ERROR', message } };
}
export async function rewardsRoutes(app: FastifyInstance): Promise<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 };
});
}

View File

@ -0,0 +1,111 @@
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 },
};
}

View File

@ -3,8 +3,10 @@ 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 { getShopBenefits } from '../shop/shop-service.js';
import { getShopCatalog } from '../shop/shop-service.js';
import { getClientSubscription } from '../subscription/subscription-api-service.js';
import { getCoinBalance } from '../gamification/coin-service.js';
import { getClientInventory } from '../gamification/inventory-service.js';
import type { BootstrapDto, SubscriptionTier } from '../../types/app-api.js';
export async function getBootstrap(userId: string): Promise<BootstrapDto> {
@ -20,11 +22,13 @@ export async function getBootstrap(userId: string): Promise<BootstrapDto> {
.where(eq(users.id, userId))
.limit(1);
const [progress, tracks, shopBenefits, subscription] = await Promise.all([
const [progress, tracks, shop, subscription, coinsBalance, inventory] = await Promise.all([
getProgressSummary(userId),
getThemeTracks(userId),
getShopBenefits(),
getShopCatalog(),
getClientSubscription(userId),
getCoinBalance(userId),
getClientInventory(userId),
]);
const xp = user?.xpTotal ?? progress.xp;
@ -40,7 +44,12 @@ export async function getBootstrap(userId: string): Promise<BootstrapDto> {
},
progress,
tracks,
shopBenefits,
shopBenefits: shop.benefits,
shop,
wallet: {
coinsBalance,
},
inventory,
subscription,
};
}

View File

@ -0,0 +1,162 @@
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;
}

View File

@ -0,0 +1,338 @@
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);
}

View File

@ -0,0 +1,356 @@
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();
}

View File

@ -0,0 +1,191 @@
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);
}

View File

@ -1,7 +1,9 @@
import { db } from '../../db/client.js';
import { users, leaderboardSnapshots } from '../../db/schema.js';
import { eq, desc, sql } from 'drizzle-orm';
import { leaderboardSnapshots, userWeeklyXp, users } from '../../db/schema.js';
import { desc, eq, 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];
@ -10,51 +12,96 @@ export interface LeaderboardEntry {
userId: string;
nickname: string | null;
avatarUrl: string | null;
xpTotal: number;
weeklyXp: number;
rank: number;
tier: string;
}
/**
* Get the current leaderboard, optionally filtered by tier.
* Uses live xp_total ranking (not weekly snapshot).
* UTC
*
* 使 UTC
* - weekStart UTC 00:00:00 LEADERBOARD_RULES.weekStartsOnIsoDay=1
* - weekEnd UTC 23:59:59
* - UTC
*
*
* weeklySettlement使 getPreviousWeekRange()
*/
export async function getLeaderboard(tier?: string, page = 1, limit = 20): Promise<{
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<{
items: LeaderboardEntry[];
pagination: { total: number; page: number; limit: number };
}> {
const { weekStart } = getCurrentWeekRange();
const weekStartStr = weekStart.toISOString().slice(0, 10);
const offset = (page - 1) * limit;
// Simpler approach: rank all users by xp_total
const allUsers = await db
// 获取用户所在分组。
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
.select({
id: users.id,
userId: userWeeklyXp.userId,
weeklyXp: userWeeklyXp.xpEarned,
nickname: users.nickname,
avatarUrl: users.avatarUrl,
xpTotal: users.xpTotal,
})
.from(users)
.orderBy(desc(users.xpTotal))
.from(userWeeklyXp)
.innerJoin(users, eq(userWeeklyXp.userId, users.id))
.where(groupFilter)
.orderBy(desc(userWeeklyXp.xpEarned))
.limit(1000);
// 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,
const total = allEntries.length;
const items: LeaderboardEntry[] = allEntries.slice(offset, offset + limit).map((entry, i) => ({
userId: entry.userId,
nickname: entry.nickname ?? null,
avatarUrl: entry.avatarUrl ?? null,
weeklyXp: entry.weeklyXp ?? 0,
rank: offset + i + 1,
tier: getTierForRank(offset + i + 1),
}));
@ -63,74 +110,190 @@ export async function getLeaderboard(tier?: string, page = 1, limit = 20): Promi
}
/**
* Get a specific user's rank and tier.
*
* XP
*/
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))
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}`)
.limit(1);
if (!user) return null;
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}`;
const [higher] = await db
.select({ count: sql<number>`COUNT(*)` })
.from(users)
.where(sql`COALESCE(xp_total, 0) > ${user.xpTotal ?? 0}`);
.from(userWeeklyXp)
.where(sql`${groupFilter} AND COALESCE(${userWeeklyXp.xpEarned}, 0) > ${userXp}`);
const rank = Number(higher?.count ?? 0) + 1;
return { rank, tier: getTierForRank(rank) };
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 }>;
}
/**
* Run weekly settlement: promote/demote users based on weekly XP.
* Should be called via a scheduled job (cron).
* 3
*
* UTC 00:00
*
* - uk_leaderboard_snapshot_user_week userId + weekStart
* - grantCoins idempotencyKeyleaderboard_settlement:{groupId}:{rank}:{userId}
* - userWeeklyXp.settled
*
* @param dryRun true
*/
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);
export async function weeklySettlement(dryRun = false): Promise<SettlementResult> {
// 结算上一周的数据(周一时当前周刚开始,上一周才是完整的)。
const { weekStart, weekEnd } = getPreviousWeekRange();
const weekStartStr = weekStart.toISOString().slice(0, 10);
const weekEndStr = weekEnd.toISOString().slice(0, 10);
// Get all users ordered by XP
const allUsers = await db
// 从 userWeeklyXp 取上一周所有记录,按组内 XP 排名。
const allEntries = await db
.select({
id: users.id,
xpTotal: users.xpTotal,
userId: userWeeklyXp.userId,
weeklyXp: userWeeklyXp.xpEarned,
groupId: userWeeklyXp.groupId,
})
.from(users)
.orderBy(desc(users.xpTotal));
.from(userWeeklyXp)
.where(sql`CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr}`)
.orderBy(userWeeklyXp.groupId, desc(userWeeklyXp.xpEarned));
const perTier = Math.max(1, Math.ceil(allUsers.length / TIERS.length));
// 按组分组,计算组内排名。
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,
});
}
// Create leaderboard snapshots
for (let i = 0; i < allUsers.length; i++) {
const user = allUsers[i]!;
// 收集全局前 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]!;
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: user.id,
userId: entry.userId,
tier,
weeklyXp: user.xpTotal ?? 0,
weeklyXp: entry.weeklyXp ?? 0,
rank,
league: `${tier}-${Math.ceil(rank / perTier)}`,
league: entry.groupId ?? `${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';
@ -144,3 +307,38 @@ 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,
};
}

View File

@ -0,0 +1,145 @@
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'];

View File

@ -1,17 +1,20 @@
import { db } from '../../db/client.js';
import { knowledgeCards, questions, skillTree, userChapterProgress, userProgress } from '../../db/schema.js';
import { and, asc, eq, notInArray, sql } from 'drizzle-orm';
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 { v4 as uuid } from 'uuid';
import { NotFoundError, ValidationError } from '../../utils/errors.js';
import { addXp, BASE_XP, calculateXp } from '../progress/xp-service.js';
import { addXp, createCorrectAnswerXpRewards, createXpReward } from '../progress/xp-service.js';
import { deductHeart } from '../progress/hearts-service.js';
import { updateStreak } from '../progress/streak-service.js';
import { updateStreakForCompletedChallenge } from '../progress/streak-service.js';
import { deductDailyAttempt, getProgressSummary } from './progress-summary-service.js';
import { getTrackCategory } from './tracks-service.js';
import type { AnswerResultDto, ChallengeQuestionDto } from '../../types/app-api.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';
type QuestionRow = typeof questions.$inferSelect;
type ChapterRow = typeof skillTree.$inferSelect;
type ChallengeSessionRow = typeof challengeSessions.$inferSelect;
interface OptionDto {
id: string;
@ -34,6 +37,14 @@ 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 = [
@ -49,9 +60,9 @@ function buildOptions(question: QuestionRow): readonly OptionDto[] {
}));
}
function toChallengeDto(trackId: string, chapter: ChapterRow, question: QuestionRow): ChallengeQuestionDto {
function toChallengeDto(challengeId: string, trackId: string, chapter: ChapterRow, question: QuestionRow): ChallengeQuestionDto {
return {
challengeId: question.id,
challengeId,
trackId,
nodeId: chapter.id,
question: {
@ -82,7 +93,7 @@ async function getCurrentChapter(userId: string, categoryId: string): Promise<Ch
?? chapters[0]!;
}
async function getQuestionForChapter(userId: string, chapter: ChapterRow): Promise<QuestionRow | null> {
async function getQuestionsForChapter(userId: string, chapter: ChapterRow): Promise<QuestionRow[]> {
const answered = await db
.select({ questionId: userProgress.questionId })
.from(userProgress)
@ -98,20 +109,45 @@ async function getQuestionForChapter(userId: string, chapter: ChapterRow): Promi
? 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[0] ?? null;
return available.slice(0, CHALLENGE_RULES.questionsPerSession);
}
async function getCorrectAnswersToday(userId: string): Promise<number> {
const today = new Date().toISOString().slice(0, 10);
async function hasPreviousCorrectAnswer(userId: string, questionId: string): Promise<boolean> {
const rows = await db
.select({ id: userProgress.id })
.from(userProgress)
.where(and(
eq(userProgress.userId, userId),
eq(userProgress.questionId, questionId),
eq(userProgress.correct, 1),
sql`DATE(${userProgress.answeredAt}) = ${today}`,
));
return rows.length;
))
.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) };
}
async function getKnowledgeCard(question: QuestionRow): Promise<AnswerResultDto['knowledgeCard']> {
@ -138,7 +174,140 @@ async function getKnowledgeCard(question: QuestionRow): Promise<AnswerResultDto[
};
}
export async function getNextChallenge(userId: string, trackId: string): Promise<ChallengeQuestionDto | null> {
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> {
const category = await getTrackCategory(trackId);
if (!category || category.status !== 'active') {
throw new NotFoundError('Track');
@ -147,19 +316,82 @@ export async function getNextChallenge(userId: string, trackId: string): Promise
const chapter = await getCurrentChapter(userId, category.id);
if (!chapter) return null;
const question = await getQuestionForChapter(userId, chapter);
if (!question) return null;
const sessionQuestions = await getQuestionsForChapter(userId, chapter);
if (sessionQuestions.length < CHALLENGE_RULES.questionsPerSession) return null;
return toChallengeDto(category.slug || category.id, chapter, question);
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)),
};
}
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');
@ -169,6 +401,7 @@ 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(),
@ -198,31 +431,93 @@ 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) {
xpDelta = calculateXp(BASE_XP, comboCount);
const answerRewards = createCorrectAnswerXpRewards(question.difficulty, comboCount);
xpDelta = answerRewards.reduce((total, reward) => total + reward.amount, 0);
await addXp(userId, xpDelta);
await updateStreak(userId, await getCorrectAnswersToday(userId));
if (xpDelta > 0) {
rewards.push(...answerRewards);
}
} else {
await deductHeart(userId);
const heartResult = await deductHeart(userId);
if (!heartResult.success && heartResult.remaining === 0) {
throw new ValidationError('红心已用完,请等待恢复或观看广告');
}
await deductDailyAttempt(userId);
}
const [progress, knowledgeCard] = await Promise.all([
getProgressSummary(userId),
getKnowledgeCard(question),
]);
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);
}
return {
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 = {
answerState: correct ? 'correct' : 'wrong',
correctOptionId,
xpDelta,
xpDelta: xpDelta + completionXpDelta,
progress: {
hearts: progress.hearts,
dailyAttemptsLeft: progress.dailyAttemptsLeft,
highRewardSessionsLeft: progress.highRewardSessionsLeft,
highRewardSessionsMax: progress.highRewardSessionsMax,
xp: progress.xp,
streakDays: progress.streakDays,
},
knowledgeCard,
rewards: correct && xpDelta > 0 ? [{ type: 'xp', amount: xpDelta, title: `+${xpDelta} XP` }] : [],
rewards,
};
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;
}

View File

@ -1,5 +1,5 @@
import { getLeaderboard, getUserRank } from '../gamification/leaderboard-service.js';
import type { LeaderboardEntryDto, LeaderboardScope } from '../../types/app-api.js';
import { getLeaderboard, getLeaderboardMeta, getUserRank } from '../gamification/leaderboard-service.js';
import type { LeaderboardEntryDto, LeaderboardMetaDto, LeaderboardScope } from '../../types/app-api.js';
import { db } from '../../db/client.js';
import { users } from '../../db/schema.js';
import { eq } from 'drizzle-orm';
@ -17,18 +17,27 @@ export async function getClientLeaderboard(
_trackId: string | undefined,
page: number,
limit: number,
): Promise<{ items: LeaderboardEntryDto[]; pagination: { total: number; page: number; limit: number } }> {
const data = await getLeaderboard(undefined, page, limit);
): 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),
]);
return {
items: data.items.map((entry) => ({
rank: entry.rank,
userId: entry.userId,
displayName: entry.userId === userId ? '你' : (entry.nickname ?? '知识探险家'),
avatarUrl: entry.avatarUrl,
xp: entry.xpTotal,
xp: entry.weeklyXp,
badge: getBadge(entry.rank),
isMe: entry.userId === userId,
})),
meta,
pagination: data.pagination,
};
}
@ -37,28 +46,32 @@ export async function getClientLeaderboardMe(
userId: string,
_scope: LeaderboardScope,
_trackId: string | undefined,
): Promise<LeaderboardEntryDto | null> {
const [rank, user] = await Promise.all([
): Promise<{ entry: LeaderboardEntryDto; meta: LeaderboardMetaDto } | null> {
const [rank, meta] = await Promise.all([
getUserRank(userId),
db
.select({
nickname: users.nickname,
avatarUrl: users.avatarUrl,
xpTotal: users.xpTotal,
})
.from(users)
.where(eq(users.id, userId))
.limit(1),
getLeaderboardMeta(userId),
]);
if (!rank) return null;
const [user] = await db
.select({
nickname: users.nickname,
avatarUrl: users.avatarUrl,
})
.from(users)
.where(eq(users.id, userId))
.limit(1);
return {
entry: {
rank: rank.rank,
userId,
displayName: user[0]?.nickname ?? '你',
avatarUrl: user[0]?.avatarUrl ?? null,
xp: user[0]?.xpTotal ?? 0,
displayName: user?.nickname ?? '你',
avatarUrl: user?.avatarUrl ?? null,
xp: rank.weeklyXp,
badge: getBadge(rank.rank),
isMe: true,
},
meta: { ...meta, rank: rank.rank },
};
}

View File

@ -4,13 +4,23 @@ 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;
const XP_PER_LEVEL = 400;
/** 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;
})();
type UserTier = 'free' | 'pro' | 'proplus';
@ -24,6 +34,7 @@ interface ResourceUser {
checkInDays: number | null;
lastCheckInDate: Date | string | null;
streakProtectedUntil: Date | string | null;
heartsRemaining?: number | null;
}
function today(): string {
@ -52,10 +63,39 @@ 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 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) };
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) };
}
export function getNextHeartRestoreAt(lastRestore: string | null, hearts: number, maxHearts: number): string | null {
@ -75,6 +115,7 @@ 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))
@ -83,6 +124,28 @@ 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);
@ -130,6 +193,15 @@ 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)
@ -151,6 +223,8 @@ 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),
@ -162,6 +236,7 @@ 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,
@ -170,6 +245,8 @@ 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,

View File

@ -1,10 +1,11 @@
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 = 5;
const PRO_HEARTS = 99;
const RESTORE_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
const MAX_FREE_HEARTS = HEART_RULES.freeMax;
const PRO_HEARTS = HEART_RULES.subscribedMax;
const RESTORE_INTERVAL_MS = HEART_RULES.restoreIntervalMs;
export type RestoreMethod = 'ad' | 'wait' | 'upgrade';
@ -75,9 +76,26 @@ 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 users are not deducted.
* Pro/ProPlus users are not deducted.
* New users (3 days) have a minimum floor of 1 heart.
*/
export async function deductHeart(userId: string): Promise<{ success: boolean; remaining: number }> {
const [user] = await db
@ -90,14 +108,20 @@ export async function deductHeart(userId: string): Promise<{ success: boolean; r
return { success: false, remaining: 0 };
}
// Pro users: no deduction
// Pro/ProPlus users: no deduction
if (user.tier === 'pro' || user.tier === 'proplus') {
return { success: true, remaining: PRO_HEARTS };
}
const current = user.heartsRemaining ?? MAX_FREE_HEARTS;
if (current <= 0) {
return { success: false, remaining: 0 };
// 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 };
}
const newCount = current - 1;

View File

@ -1,16 +1,27 @@
import { db } from '../../db/client.js';
import { users } from '../../db/schema.js';
import { eq, sql } from 'drizzle-orm';
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;
}
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.
*/
@ -54,10 +65,7 @@ export async function calculateStreak(userId: string): Promise<StreakInfo> {
return { days: 0, lastDate, frozen: false };
}
/**
* Update the user's streak after answering questions.
*/
export async function updateStreak(userId: string, correctAnswersToday: number): Promise<StreakInfo> {
export async function updateStreakForCompletedChallenge(userId: string): Promise<StreakInfo> {
const today = todayUtc();
const [user] = await db
@ -80,15 +88,6 @@ export async function updateStreak(userId: string, correctAnswersToday: number):
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;
@ -98,7 +97,60 @@ export async function updateStreak(userId: string, correctAnswersToday: number):
.set({ streakDays: newDays, streakLastDate: sql`CAST(${today} AS DATE)` })
.where(eq(users.id, userId));
return { days: newDays, lastDate: today, frozen: false };
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];
}
/**
@ -131,4 +183,6 @@ function yesterdayUtc(): string {
return d.toISOString().slice(0, 10);
}
export { STREAK_THRESHOLD };
function isStreakMilestoneDay(days: number): days is StreakMilestoneDay {
return (STREAK_RULES.milestoneDays as readonly number[]).includes(days);
}

View File

@ -1,16 +1,42 @@
import { db } from '../../db/client.js';
import { users } from '../../db/schema.js';
import { users, userWeeklyXp } 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';
/** 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 BASE_XP = XP_RULES.correctNormal;
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;
@ -26,27 +52,194 @@ 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 = +20.
* Combo bonus is cumulative: 3-combo = +5, 5-combo = +10, 10-combo = +25.
*/
export function calculateXp(baseXp: number, comboCount: number): number {
let bonus = 0;
for (const tier of COMBO_BONUSES) {
if (comboCount >= tier.minCombo) {
bonus = tier.bonus;
break;
}
}
const bonus = getComboBonusXp(comboCount);
return baseXp + bonus;
}
export function getComboBonusXp(comboCount: number): number {
for (const tier of XP_RULES.comboBonuses) {
if (comboCount >= tier.minCombo) {
return tier.bonus;
}
}
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()`,
},
});
}
/**
* 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);
// Atomically update total XP and handle daily reset
// 原子更新累计 XP 和每日 XP
await db
.update(users)
.set({
@ -59,6 +252,9 @@ 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);
}
/**
@ -88,4 +284,9 @@ 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 };

View File

@ -0,0 +1,567 @@
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),
};
}

View File

@ -0,0 +1,86 @@
/**
*
*
*
* 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);
});
}

View File

@ -1,4 +1,8 @@
import type { ShopBenefitDto } from '../../types/app-api.js';
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';
const SHOP_BENEFITS: readonly ShopBenefitDto[] = Object.freeze([
{
@ -35,6 +39,121 @@ 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;
}

View File

@ -27,6 +27,8 @@ export interface ProgressSummaryDto {
dailyAttemptsLeft: number;
dailyAttemptsMax: number;
nextAttemptResetAt: string | null;
highRewardSessionsLeft: number;
highRewardSessionsMax: number;
xp: number;
level: number;
xpToNextLevel: number;
@ -62,11 +64,87 @@ 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;
}
@ -81,11 +159,21 @@ 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 {
@ -95,6 +183,8 @@ export interface AnswerResultDto {
progress: {
hearts: number;
dailyAttemptsLeft: number;
highRewardSessionsLeft: number;
highRewardSessionsMax: number;
xp: number;
streakDays: number;
};
@ -104,7 +194,14 @@ export interface AnswerResultDto {
summary: string;
fact: string;
};
rewards: ReadonlyArray<{ type: string; amount?: number; title?: string }>;
rewards: ReadonlyArray<{
type: string;
source?: string;
amount?: number;
itemId?: string;
quantity?: number;
title?: string;
}>;
}
export interface LeaderboardEntryDto {
@ -116,3 +213,15 @@ 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 }>;
}