Compare commits
No commits in common. "b46b6c8ae0ace2d1f4c91396f108332269518df7" and "c70748dde2e2afe794a6a08b9a97a9f4406d8c44" have entirely different histories.
b46b6c8ae0
...
c70748dde2
3
.gitignore
vendored
3
.gitignore
vendored
@ -2,8 +2,7 @@ node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
db/migrations/
|
||||
|
||||
# Claude Code
|
||||
.claude/
|
||||
|
||||
coverage/
|
||||
|
||||
132
AGENTS.md
132
AGENTS.md
@ -1,132 +0,0 @@
|
||||
# AGENTS.md — duoqi-api
|
||||
|
||||
> 多奇服务端 API,基于 Fastify + TypeScript + Drizzle ORM + MySQL 8.0+
|
||||
> 包管理器:**bun**(禁止使用 npm)
|
||||
|
||||
## 项目概述
|
||||
|
||||
多奇(Duoqi)是游戏化知识闯关学习平台。duoqi-api 是三端(HarmonyOS / Flutter / Web)共享的后端服务,从 Phase 1 起即为 HarmonyOS 客户端提供 API 支持。
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层面 | 选型 | 说明 |
|
||||
|------|------|------|
|
||||
| 后端框架 | **Fastify 5** | 高性能,内置 JSON Schema 验证,TypeScript 友好 |
|
||||
| ORM | **Drizzle ORM** | 轻量、类型安全,`src/db/schema.ts` 为唯一真相源 |
|
||||
| 数据库 | MySQL 8.0+ | 阿里云 RDS |
|
||||
| 认证 | @fastify/jwt | 自建 JWT(华为 ID Kit + 游客模式) |
|
||||
| 校验 | Zod | 环境变量 + 请求体校验(所有路由已接入 Zod) |
|
||||
| 测试 | Vitest | 单元测试框架,19 个测试全部通过 |
|
||||
| 运行时 | Node.js (ESM) | `"type": "module"`,import 使用 `.js` 后缀 |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
bun install # 安装依赖
|
||||
cp .env.example .env # 复制环境变量模板,填入实际值
|
||||
bun run dev # 启动开发服务器,默认端口 3000
|
||||
```
|
||||
|
||||
必填环境变量:`DATABASE_URL`, `JWT_SECRET`, `ADMIN_TOKEN`
|
||||
|
||||
## 开发命令
|
||||
|
||||
```bash
|
||||
bun run dev # 启动开发服务器(tsx watch 热重载)
|
||||
bun run typecheck # 类型检查(tsc --noEmit)
|
||||
bun run build # 编译到 dist/
|
||||
bun run test # 运行测试(vitest run)
|
||||
bun run db:push # 推送 schema 到数据库(开发用)
|
||||
bun run db:generate # 生成迁移文件
|
||||
bun run db:migrate # 执行迁移
|
||||
bun run db:seed # 导入种子数据(分类 → 技能树 → 题目 → 成就)
|
||||
bun run db:studio # Drizzle Studio(数据库可视化浏览器)
|
||||
bun run lint # ESLint 检查
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.ts # 入口:Fastify 实例 + 插件注册 + 路由挂载
|
||||
├── db/
|
||||
│ ├── client.ts # 数据库连接(mysql2 pool + drizzle)
|
||||
│ └── schema.ts # 全部 15 张表定义(唯一真相源)
|
||||
├── types/ # TypeScript 类型(auth, quiz, user, api)
|
||||
├── utils/
|
||||
│ ├── config.ts # 环境变量(Zod 校验,启动时 fail-fast)
|
||||
│ └── errors.ts # AppError 层级 + 统一错误处理器
|
||||
├── middleware/
|
||||
│ ├── auth.ts # JWT 认证(排除公开路径和 admin 路径)
|
||||
│ ├── admin-auth.ts # Admin token 认证(/v1/admin/* 路径)
|
||||
│ ├── audit-log.ts # Admin 操作审计日志
|
||||
│ └── request-logger.ts # 请求耗时日志
|
||||
├── services/ # 业务逻辑(按领域分目录)
|
||||
│ ├── auth/ # jwt, guest, huawei-id-kit, phone
|
||||
│ ├── quiz/ # quiz-service(出题引擎 + 答题验证)
|
||||
│ ├── progress/ # progress, streak, xp, hearts
|
||||
│ ├── gamification/ # leaderboard, achievement
|
||||
│ ├── payment/ # huawei-iap, subscription-service
|
||||
│ └── admin/ # 7 个管理端 CRUD 服务
|
||||
│ └── question, category, knowledge-card, skill-tree,
|
||||
│ user, stats, feedback
|
||||
├── routes/ # 路由(薄层,调 service + Zod 校验)
|
||||
│ ├── health.ts, auth.ts, quiz.ts, progress.ts
|
||||
│ ├── gamification.ts, payment.ts
|
||||
│ └── admin/ # 管理端路由(duoqi-admin 调用)
|
||||
│ └── index, auth, questions, categories, knowledge-cards,
|
||||
│ skill-tree, users, stats, feedback
|
||||
└── __tests__/ # 测试(Vitest + DB mock)
|
||||
├── setup.ts, smoke.test.ts
|
||||
├── helpers/db-mock.ts
|
||||
└── services/ # 单元测试(auth, quiz, progress)
|
||||
content/ # 种子数据(JSON)
|
||||
├── categories.json, skill-tree.json, achievements.json
|
||||
├── history.json, drama.json, crosstalk.json
|
||||
db/seeds/index.ts # 幂等种子导入脚本
|
||||
```
|
||||
|
||||
## 编码约定
|
||||
|
||||
- **路由只做参数提取和响应格式化**,业务逻辑在 `services/`
|
||||
- **响应格式统一**:`{ success: boolean, data: T | null, error: { code, message } | null }`
|
||||
- **分页响应**:额外包含 `pagination: { total, page, limit }`
|
||||
- **不可变数据**:`Object.freeze()` 或返回新对象,不修改入参
|
||||
- **错误处理**:抛出 `AppError` 子类(`NotFoundError`, `ValidationError` 等),由 `errorHandler` 统一捕获
|
||||
- **环境变量**:所有配置通过 `src/utils/config.ts` 读取(Zod 校验),禁止直接读 `process.env`
|
||||
- **导入后缀**:ESM 项目,本地导入必须带 `.js` 后缀(`import { x } from './foo.js'`)
|
||||
|
||||
## API 约定
|
||||
|
||||
- Base URL: `/v1`
|
||||
- 认证:`Authorization: Bearer <jwt>`(公开端点:`/v1/auth/*`, `/v1/health`)
|
||||
- Admin 认证:`Authorization: Bearer <admin_token>`(`/v1/admin/*`)
|
||||
- JWT 有效期:access_token 1h, refresh_token 30d
|
||||
|
||||
## 数据库
|
||||
|
||||
- **15 张表**,定义在 `src/db/schema.ts`
|
||||
- 核心(7):`users`, `categories`, `questions`, `knowledge_cards`, `user_progress`, `skill_tree`, `user_chapter_progress`
|
||||
- 反馈(2):`question_ratings`, `user_feedback`
|
||||
- 游戏化(3):`achievements`, `user_achievements`, `leaderboard_snapshots`
|
||||
- 商业(1):`subscriptions`
|
||||
- 管理员(2):`admin_users`, `admin_audit_log`
|
||||
- Schema 定义在 `src/db/schema.ts`,迁移由 `drizzle-kit` 从 schema 自动生成
|
||||
- `datetime` 列使用 `default(sql\`CURRENT_TIMESTAMP\`)`(MySQL datetime 无 `defaultNow()`)
|
||||
|
||||
## 设计文档
|
||||
|
||||
| 文档 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| 实施计划 | [./docs/implementation-plan.md](./docs/implementation-plan.md) | Phase 1b/1c 实施进度(42/44 步) |
|
||||
| 本库开发规格 | [./dev-spec.md](./dev-spec.md) | 工程实施主文档 |
|
||||
| 产品总纲 | [../docs/product-overview.md](../docs/product-overview.md) | 产品定位、功能范围 |
|
||||
| 技术选型 | [../docs/tech-stack.md](../docs/tech-stack.md) | 全栈技术决策 |
|
||||
| 共享设计文档 | [../docs/specs/shared/](../docs/specs/shared/) | 题目格式、游戏化、吉祥物、推送、埋点 |
|
||||
|
||||
## 当前进度
|
||||
|
||||
- **Phase 1a 骨架**:✅ 已完成
|
||||
- **Phase 1b 核心功能**:✅ 已完成(华为登录、出题引擎、XP/连胜/红心、技能树、Admin CRUD、路由验证)
|
||||
- **Phase 1c 商业化**:✅ 已完成(排行榜、成就系统、华为 IAP + 订阅、安全加固)
|
||||
- **Phase 1c-5 集成部署**:⬜ 待完成(E2E 测试、Dockerfile/CI)
|
||||
@ -1,201 +0,0 @@
|
||||
CREATE TABLE `achievements` (
|
||||
`id` char(36) NOT NULL,
|
||||
`type` enum('knowledge','behavior') NOT NULL,
|
||||
`name` varchar(100) NOT NULL,
|
||||
`description` varchar(300) NOT NULL,
|
||||
`icon_url` varchar(500),
|
||||
`condition` json NOT NULL,
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `achievements_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `admin_audit_log` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`admin_id` varchar(36) NOT NULL,
|
||||
`action` varchar(10) NOT NULL,
|
||||
`resource` varchar(500),
|
||||
`details` json,
|
||||
`ip_address` varchar(45),
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `admin_audit_log_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `admin_users` (
|
||||
`id` char(36) NOT NULL,
|
||||
`username` varchar(50) NOT NULL,
|
||||
`password_hash` varchar(255) NOT NULL,
|
||||
`role` enum('admin','super_admin') DEFAULT 'admin',
|
||||
`is_active` tinyint DEFAULT 1,
|
||||
`last_login_at` datetime,
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `admin_users_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `uk_admin_username` UNIQUE(`username`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `categories` (
|
||||
`id` varchar(50) NOT NULL,
|
||||
`name` varchar(100) NOT NULL,
|
||||
`slug` varchar(100) NOT NULL,
|
||||
`parent_id` varchar(50),
|
||||
`sort_order` int DEFAULT 0,
|
||||
`question_count` int DEFAULT 0,
|
||||
`status` enum('active','inactive') DEFAULT 'active',
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `categories_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `uk_slug` UNIQUE(`slug`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `knowledge_cards` (
|
||||
`id` char(36) NOT NULL,
|
||||
`question_id` char(36) NOT NULL,
|
||||
`summary` varchar(300) NOT NULL,
|
||||
`deep_dive` text,
|
||||
`source_ref` varchar(500),
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `knowledge_cards_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `uk_question` UNIQUE(`question_id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `leaderboard_snapshots` (
|
||||
`id` char(36) NOT NULL,
|
||||
`user_id` char(36) NOT NULL,
|
||||
`tier` enum('bronze','silver','gold','platinum','diamond','master','grandmaster','champion','legend','mythic') NOT NULL,
|
||||
`weekly_xp` int DEFAULT 0,
|
||||
`rank` int,
|
||||
`league` varchar(50),
|
||||
`week_start` date,
|
||||
`week_end` date,
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `leaderboard_snapshots_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `question_ratings` (
|
||||
`id` char(36) NOT NULL,
|
||||
`user_id` char(36) NOT NULL,
|
||||
`question_id` char(36) NOT NULL,
|
||||
`rating` enum('good','bad') NOT NULL,
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `question_ratings_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `uk_user_question_rating` UNIQUE(`user_id`,`question_id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `questions` (
|
||||
`id` char(36) NOT NULL,
|
||||
`stem` json NOT NULL,
|
||||
`content_type` enum('text','image','video','audio') NOT NULL,
|
||||
`correct_answer` varchar(500) NOT NULL,
|
||||
`distractors` json NOT NULL,
|
||||
`category_id` varchar(50) NOT NULL,
|
||||
`difficulty` tinyint,
|
||||
`dynamic_difficulty` decimal(3,1),
|
||||
`source` enum('system','ugc') DEFAULT 'system',
|
||||
`creator_id` char(36),
|
||||
`status` enum('draft','reviewing','published','archived') DEFAULT 'draft',
|
||||
`stats` json DEFAULT ('{"timesAnswered":0,"correctRate":0,"avgTimeMs":0}'),
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `questions_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `skill_tree` (
|
||||
`id` char(36) NOT NULL,
|
||||
`category_id` varchar(50) NOT NULL,
|
||||
`title` varchar(100) NOT NULL,
|
||||
`parent_id` char(36),
|
||||
`sort_order` int DEFAULT 0,
|
||||
`questions_required` tinyint DEFAULT 4,
|
||||
`pass_threshold` tinyint DEFAULT 2,
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `skill_tree_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `subscriptions` (
|
||||
`id` char(36) NOT NULL,
|
||||
`user_id` char(36) NOT NULL,
|
||||
`tier` enum('free','pro','proplus') DEFAULT 'free',
|
||||
`platform` enum('huawei','apple','google'),
|
||||
`purchase_token` varchar(500),
|
||||
`expires_at` datetime,
|
||||
`auto_renew` tinyint DEFAULT 0,
|
||||
`status` enum('active','expired','cancelled') DEFAULT 'active',
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `subscriptions_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `uk_subscription_user` UNIQUE(`user_id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `user_achievements` (
|
||||
`id` char(36) NOT NULL,
|
||||
`user_id` char(36) NOT NULL,
|
||||
`achievement_id` char(36) NOT NULL,
|
||||
`unlocked_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `user_achievements_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `uk_user_achievement` UNIQUE(`user_id`,`achievement_id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `user_chapter_progress` (
|
||||
`id` char(36) NOT NULL,
|
||||
`user_id` char(36) NOT NULL,
|
||||
`chapter_id` char(36) NOT NULL,
|
||||
`status` enum('locked','unlocked','passed','perfect') DEFAULT 'locked',
|
||||
`best_correct_count` tinyint DEFAULT 0,
|
||||
`attempts` int DEFAULT 0,
|
||||
`completed_at` datetime,
|
||||
CONSTRAINT `user_chapter_progress_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `uk_user_chapter` UNIQUE(`user_id`,`chapter_id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `user_feedback` (
|
||||
`id` char(36) NOT NULL,
|
||||
`user_id` char(36) NOT NULL,
|
||||
`content` text NOT NULL,
|
||||
`contact` varchar(255),
|
||||
`page_context` varchar(200),
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `user_feedback_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `user_progress` (
|
||||
`id` char(36) NOT NULL,
|
||||
`user_id` char(36) NOT NULL,
|
||||
`question_id` char(36) NOT NULL,
|
||||
`correct` tinyint NOT NULL,
|
||||
`time_ms` int,
|
||||
`answered_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `user_progress_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `users` (
|
||||
`id` char(36) NOT NULL,
|
||||
`auth_type` enum('huawei','guest','phone','apple','google') NOT NULL,
|
||||
`auth_id` varchar(255) NOT NULL,
|
||||
`nickname` varchar(50),
|
||||
`avatar_url` varchar(500),
|
||||
`tier` enum('free','pro','proplus') DEFAULT 'free',
|
||||
`xp_total` int DEFAULT 0,
|
||||
`streak_days` int DEFAULT 0,
|
||||
`streak_last_date` date,
|
||||
`hearts_remaining` tinyint DEFAULT 5,
|
||||
`hearts_last_restore` datetime,
|
||||
`daily_xp_goal` smallint DEFAULT 50,
|
||||
`daily_xp_earned` smallint DEFAULT 0,
|
||||
`daily_xp_date` date,
|
||||
`current_theme` varchar(20) DEFAULT 'inkTeal',
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `users_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `uk_auth` UNIQUE(`auth_type`,`auth_id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `knowledge_cards` ADD CONSTRAINT `knowledge_cards_question_id_questions_id_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `questions` ADD CONSTRAINT `questions_category_id_categories_id_fk` FOREIGN KEY (`category_id`) REFERENCES `categories`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `skill_tree` ADD CONSTRAINT `skill_tree_category_id_categories_id_fk` FOREIGN KEY (`category_id`) REFERENCES `categories`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `user_chapter_progress` ADD CONSTRAINT `user_chapter_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_chapter_progress` ADD CONSTRAINT `user_chapter_progress_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 `user_progress` ADD CONSTRAINT `user_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_progress` ADD CONSTRAINT `user_progress_question_id_questions_id_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX `idx_user_week` ON `leaderboard_snapshots` (`user_id`,`week_start`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_user_answered` ON `user_progress` (`user_id`,`answered_at`);
|
||||
@ -1,6 +0,0 @@
|
||||
ALTER TABLE `users` ADD `active_track_id` varchar(50);--> statement-breakpoint
|
||||
ALTER TABLE `users` ADD `daily_attempts_left` smallint DEFAULT 5;--> statement-breakpoint
|
||||
ALTER TABLE `users` ADD `daily_attempts_date` date;--> statement-breakpoint
|
||||
ALTER TABLE `users` ADD `check_in_days` int DEFAULT 0;--> statement-breakpoint
|
||||
ALTER TABLE `users` ADD `last_check_in_date` date;--> statement-breakpoint
|
||||
ALTER TABLE `users` ADD `streak_protected_until` datetime;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,20 +0,0 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "mysql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "5",
|
||||
"when": 1775891974121,
|
||||
"tag": "0000_melodic_blacklash",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "5",
|
||||
"when": 1777827874032,
|
||||
"tag": "0001_sturdy_invaders",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { db } from '../../src/db/client.js';
|
||||
import { categories, questions, knowledgeCards, skillTree, achievements } from '../../src/db/schema.js';
|
||||
import { categories, questions, knowledgeCards, skillTree, achievements, adminUsers } from '../../src/db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import * as adminAuthService from '../../src/services/admin/admin-auth.js';
|
||||
|
||||
|
||||
@ -1,26 +0,0 @@
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ['dist/**', 'coverage/**', 'node_modules/**'],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
console: 'readonly',
|
||||
process: 'readonly',
|
||||
Buffer: 'readonly',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': ['error', {
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
}],
|
||||
},
|
||||
},
|
||||
);
|
||||
@ -1,20 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getDailyAttemptsMax, getLevelInfo, getNextHeartRestoreAt } 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('uses tier-specific daily attempt limits', () => {
|
||||
expect(getDailyAttemptsMax('free')).toBe(5);
|
||||
expect(getDailyAttemptsMax('pro')).toBe(10);
|
||||
expect(getDailyAttemptsMax('proplus')).toBe(20);
|
||||
});
|
||||
|
||||
it('returns the next heart restore time only when hearts are not full', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@ -1,16 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getShopBenefits } from '../../../services/shop/shop-service.js';
|
||||
|
||||
describe('shop-service', () => {
|
||||
it('returns the client shop benefit catalog', async () => {
|
||||
const benefits = await getShopBenefits();
|
||||
|
||||
expect(benefits.map((item) => item.id)).toEqual([
|
||||
'restore-hearts',
|
||||
'restore-attempts',
|
||||
'protect-streak',
|
||||
'duoqi-plus',
|
||||
]);
|
||||
expect(benefits.every((item) => item.enabled)).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -1,13 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { verifyClientSubscription } from '../../../services/subscription/subscription-api-service.js';
|
||||
|
||||
describe('subscription-api-service', () => {
|
||||
it('returns an explicit error for unsupported platforms', async () => {
|
||||
await expect(
|
||||
verifyClientSubscription('user-1', 'apple', 'token', 'product-1', 'pro'),
|
||||
).rejects.toMatchObject({
|
||||
code: 'UNSUPPORTED_PLATFORM',
|
||||
statusCode: 400,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -35,12 +35,6 @@ export const users = mysqlTable('users', {
|
||||
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) => [
|
||||
|
||||
@ -17,7 +17,6 @@ import { quizRoutes } from './routes/quiz.js';
|
||||
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 { adminRoutes } from './routes/admin/index.js';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
@ -66,7 +65,6 @@ async function main(): Promise<void> {
|
||||
app.register(progressRoutes, { prefix: '/v1' });
|
||||
app.register(gamificationRoutes, { prefix: '/v1' });
|
||||
app.register(paymentRoutes, { prefix: '/v1' });
|
||||
app.register(appApiRoutes, { prefix: '/v1' });
|
||||
|
||||
// Admin routes: higher rate limit (100/min)
|
||||
app.register(adminRoutes, { prefix: '/v1/admin' });
|
||||
|
||||
@ -1,173 +0,0 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { getBootstrap } from '../services/app/bootstrap-service.js';
|
||||
import { getThemeTrackById, getThemeTracks } from '../services/learning/tracks-service.js';
|
||||
import { getNextChallenge, submitChallengeAnswer } from '../services/learning/challenge-service.js';
|
||||
import {
|
||||
checkIn,
|
||||
getProgressSummary,
|
||||
protectStreak,
|
||||
restoreDailyAttempts,
|
||||
updateProgressPreferences,
|
||||
} 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 { getClientSubscription, verifyClientSubscription } from '../services/subscription/subscription-api-service.js';
|
||||
|
||||
const rewardSourceSchema = z.object({
|
||||
source: z.enum(['ad', 'check_in', 'subscription', 'admin_grant', 'debug']),
|
||||
});
|
||||
|
||||
const answerSchema = z.object({
|
||||
challengeId: z.string().min(1),
|
||||
questionId: z.string().min(1),
|
||||
selectedOptionId: z.string().min(1),
|
||||
timeMs: z.number().min(0),
|
||||
comboCount: z.number().int().min(0).optional(),
|
||||
});
|
||||
|
||||
const preferencesSchema = z.object({
|
||||
activeTrackId: z.string().min(1).max(50),
|
||||
});
|
||||
|
||||
const leaderboardQuerySchema = z.object({
|
||||
scope: z.enum(['region', 'topic']).default('region'),
|
||||
trackId: z.string().optional(),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
});
|
||||
|
||||
const subscriptionVerifySchema = z.object({
|
||||
platform: z.enum(['huawei', 'apple', 'google']),
|
||||
purchaseToken: z.string().min(1),
|
||||
productId: z.string().min(1),
|
||||
tier: z.enum(['pro', 'proplus']),
|
||||
});
|
||||
|
||||
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 appApiRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get('/app/bootstrap', async (request) => {
|
||||
const data = await getBootstrap(getUserId(request));
|
||||
return { success: true, data, error: null };
|
||||
});
|
||||
|
||||
app.get('/tracks', async (request) => {
|
||||
const data = await getThemeTracks(getUserId(request));
|
||||
return { success: true, data, error: null };
|
||||
});
|
||||
|
||||
app.get('/tracks/:trackId', async (request) => {
|
||||
const { trackId } = request.params as { trackId: string };
|
||||
const data = await getThemeTrackById(getUserId(request), trackId);
|
||||
return { success: true, data, error: null };
|
||||
});
|
||||
|
||||
app.get('/challenges/next', async (request) => {
|
||||
const parsed = z.object({ trackId: z.string().min(1) }).safeParse(request.query);
|
||||
if (!parsed.success) return validationError(parsed.error.issues[0]?.message);
|
||||
const data = await getNextChallenge(getUserId(request), parsed.data.trackId);
|
||||
return { success: true, data, error: null };
|
||||
});
|
||||
|
||||
app.post('/challenges/answer', async (request) => {
|
||||
const parsed = answerSchema.safeParse(request.body);
|
||||
if (!parsed.success) return validationError(parsed.error.issues[0]?.message);
|
||||
const data = await submitChallengeAnswer(
|
||||
getUserId(request),
|
||||
parsed.data.questionId,
|
||||
parsed.data.selectedOptionId,
|
||||
parsed.data.timeMs,
|
||||
parsed.data.comboCount,
|
||||
);
|
||||
return { success: true, data: { ...data, challengeId: parsed.data.challengeId }, error: null };
|
||||
});
|
||||
|
||||
app.get('/progress/summary', async (request) => {
|
||||
const data = await getProgressSummary(getUserId(request));
|
||||
return { success: true, data, error: null };
|
||||
});
|
||||
|
||||
app.patch('/progress/preferences', async (request) => {
|
||||
const parsed = preferencesSchema.safeParse(request.body);
|
||||
if (!parsed.success) return validationError(parsed.error.issues[0]?.message);
|
||||
const data = await updateProgressPreferences(getUserId(request), parsed.data.activeTrackId);
|
||||
return { success: true, data, error: null };
|
||||
});
|
||||
|
||||
app.post('/progress/check-in', async (request) => {
|
||||
const data = await checkIn(getUserId(request));
|
||||
return { success: true, data, error: null };
|
||||
});
|
||||
|
||||
app.post('/rewards/hearts/restore', async (request) => {
|
||||
const parsed = rewardSourceSchema.safeParse(request.body);
|
||||
if (!parsed.success) return validationError(parsed.error.issues[0]?.message);
|
||||
const data = await restoreHearts(getUserId(request), 'ad');
|
||||
return { success: true, data, error: null };
|
||||
});
|
||||
|
||||
app.post('/rewards/attempts/restore', async (request) => {
|
||||
const parsed = rewardSourceSchema.safeParse(request.body);
|
||||
if (!parsed.success) return validationError(parsed.error.issues[0]?.message);
|
||||
const data = await restoreDailyAttempts(getUserId(request), parsed.data.source);
|
||||
return { success: true, data, error: null };
|
||||
});
|
||||
|
||||
app.post('/rewards/streak/protect', async (request) => {
|
||||
const parsed = rewardSourceSchema.safeParse(request.body);
|
||||
if (!parsed.success) return validationError(parsed.error.issues[0]?.message);
|
||||
const data = await protectStreak(getUserId(request), parsed.data.source);
|
||||
return { success: true, data, error: null };
|
||||
});
|
||||
|
||||
app.get('/leaderboards', async (request) => {
|
||||
const parsed = leaderboardQuerySchema.safeParse(request.query);
|
||||
if (!parsed.success) return validationError(parsed.error.issues[0]?.message);
|
||||
const data = await getClientLeaderboard(
|
||||
getUserId(request),
|
||||
parsed.data.scope,
|
||||
parsed.data.trackId,
|
||||
parsed.data.page,
|
||||
parsed.data.limit,
|
||||
);
|
||||
return { success: true, data: data.items, pagination: data.pagination, error: null };
|
||||
});
|
||||
|
||||
app.get('/leaderboards/me', async (request) => {
|
||||
const parsed = leaderboardQuerySchema.safeParse(request.query);
|
||||
if (!parsed.success) return validationError(parsed.error.issues[0]?.message);
|
||||
const data = await getClientLeaderboardMe(getUserId(request), parsed.data.scope, parsed.data.trackId);
|
||||
return { success: true, data, error: null };
|
||||
});
|
||||
|
||||
app.get('/shop', async () => {
|
||||
const data = await getShopBenefits();
|
||||
return { success: true, data, error: null };
|
||||
});
|
||||
|
||||
app.get('/subscription', async (request) => {
|
||||
const data = await getClientSubscription(getUserId(request));
|
||||
return { success: true, data, error: null };
|
||||
});
|
||||
|
||||
app.post('/subscription/verify', async (request) => {
|
||||
const parsed = subscriptionVerifySchema.safeParse(request.body);
|
||||
if (!parsed.success) return validationError(parsed.error.issues[0]?.message);
|
||||
const data = await verifyClientSubscription(
|
||||
getUserId(request),
|
||||
parsed.data.platform,
|
||||
parsed.data.purchaseToken,
|
||||
parsed.data.productId,
|
||||
parsed.data.tier,
|
||||
);
|
||||
return { success: true, data, error: null };
|
||||
});
|
||||
}
|
||||
@ -1,46 +0,0 @@
|
||||
import { db } from '../../db/client.js';
|
||||
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 { getClientSubscription } from '../subscription/subscription-api-service.js';
|
||||
import type { BootstrapDto, SubscriptionTier } from '../../types/app-api.js';
|
||||
|
||||
export async function getBootstrap(userId: string): Promise<BootstrapDto> {
|
||||
const [user] = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
nickname: users.nickname,
|
||||
avatarUrl: users.avatarUrl,
|
||||
tier: users.tier,
|
||||
xpTotal: users.xpTotal,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
const [progress, tracks, shopBenefits, subscription] = await Promise.all([
|
||||
getProgressSummary(userId),
|
||||
getThemeTracks(userId),
|
||||
getShopBenefits(),
|
||||
getClientSubscription(userId),
|
||||
]);
|
||||
|
||||
const xp = user?.xpTotal ?? progress.xp;
|
||||
const level = getLevelInfo(xp).level;
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user?.id ?? userId,
|
||||
nickname: user?.nickname ?? '知识探险家',
|
||||
avatarUrl: user?.avatarUrl ?? null,
|
||||
tier: (user?.tier ?? 'free') as SubscriptionTier,
|
||||
level,
|
||||
},
|
||||
progress,
|
||||
tracks,
|
||||
shopBenefits,
|
||||
subscription,
|
||||
};
|
||||
}
|
||||
@ -1,228 +0,0 @@
|
||||
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 { v4 as uuid } from 'uuid';
|
||||
import { NotFoundError, ValidationError } from '../../utils/errors.js';
|
||||
import { addXp, BASE_XP, calculateXp } from '../progress/xp-service.js';
|
||||
import { deductHeart } from '../progress/hearts-service.js';
|
||||
import { updateStreak } 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';
|
||||
|
||||
type QuestionRow = typeof questions.$inferSelect;
|
||||
type ChapterRow = typeof skillTree.$inferSelect;
|
||||
|
||||
interface OptionDto {
|
||||
id: string;
|
||||
text: string;
|
||||
isCorrect: boolean;
|
||||
}
|
||||
|
||||
function getPrompt(stem: unknown): string {
|
||||
if (stem && typeof stem === 'object' && 'text' in stem && typeof stem.text === 'string') {
|
||||
return stem.text;
|
||||
}
|
||||
return String(stem ?? '');
|
||||
}
|
||||
|
||||
function hash(value: string): number {
|
||||
let result = 0;
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
result = (result * 31 + value.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildOptions(question: QuestionRow): readonly OptionDto[] {
|
||||
const distractors = Array.isArray(question.distractors) ? question.distractors.filter((item): item is string => typeof item === 'string') : [];
|
||||
const rawOptions = [
|
||||
{ text: question.correctAnswer, isCorrect: true },
|
||||
...distractors.slice(0, 2).map((text) => ({ text, isCorrect: false })),
|
||||
];
|
||||
|
||||
const sorted = [...rawOptions].sort((a, b) => hash(`${question.id}:${a.text}`) - hash(`${question.id}:${b.text}`));
|
||||
return sorted.map((option, index) => ({
|
||||
id: String.fromCharCode(97 + index),
|
||||
text: option.text,
|
||||
isCorrect: option.isCorrect,
|
||||
}));
|
||||
}
|
||||
|
||||
function toChallengeDto(trackId: string, chapter: ChapterRow, question: QuestionRow): ChallengeQuestionDto {
|
||||
return {
|
||||
challengeId: question.id,
|
||||
trackId,
|
||||
nodeId: chapter.id,
|
||||
question: {
|
||||
id: question.id,
|
||||
prompt: getPrompt(question.stem),
|
||||
options: buildOptions(question).map(({ id, text }) => ({ id, text })),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function getCurrentChapter(userId: string, categoryId: string): Promise<ChapterRow | null> {
|
||||
const chapters = await db
|
||||
.select()
|
||||
.from(skillTree)
|
||||
.where(eq(skillTree.categoryId, categoryId))
|
||||
.orderBy(asc(skillTree.sortOrder));
|
||||
|
||||
if (chapters.length === 0) return null;
|
||||
|
||||
const progress = await db
|
||||
.select()
|
||||
.from(userChapterProgress)
|
||||
.where(eq(userChapterProgress.userId, userId));
|
||||
|
||||
const progressMap = new Map(progress.map((item) => [item.chapterId, item.status]));
|
||||
return chapters.find((chapter) => progressMap.get(chapter.id) === 'unlocked')
|
||||
?? chapters.find((chapter) => progressMap.get(chapter.id) !== 'passed' && progressMap.get(chapter.id) !== 'perfect')
|
||||
?? chapters[0]!;
|
||||
}
|
||||
|
||||
async function getQuestionForChapter(userId: string, chapter: ChapterRow): Promise<QuestionRow | null> {
|
||||
const answered = await db
|
||||
.select({ questionId: userProgress.questionId })
|
||||
.from(userProgress)
|
||||
.where(eq(userProgress.userId, userId));
|
||||
const answeredIds = answered.map((item) => item.questionId);
|
||||
|
||||
const conditions = [
|
||||
eq(questions.categoryId, chapter.categoryId),
|
||||
eq(questions.status, 'published'),
|
||||
];
|
||||
|
||||
const available = answeredIds.length > 0
|
||||
? 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;
|
||||
}
|
||||
|
||||
async function getCorrectAnswersToday(userId: string): Promise<number> {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const rows = await db
|
||||
.select({ id: userProgress.id })
|
||||
.from(userProgress)
|
||||
.where(and(
|
||||
eq(userProgress.userId, userId),
|
||||
eq(userProgress.correct, 1),
|
||||
sql`DATE(${userProgress.answeredAt}) = ${today}`,
|
||||
));
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async function getKnowledgeCard(question: QuestionRow): Promise<AnswerResultDto['knowledgeCard']> {
|
||||
const [card] = await db
|
||||
.select()
|
||||
.from(knowledgeCards)
|
||||
.where(eq(knowledgeCards.questionId, question.id))
|
||||
.limit(1);
|
||||
|
||||
if (card) {
|
||||
return {
|
||||
id: card.id,
|
||||
title: card.summary,
|
||||
summary: card.summary,
|
||||
fact: card.deepDive ?? `正确答案是:${question.correctAnswer}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: `fallback-${question.id}`,
|
||||
title: '知识点回顾',
|
||||
summary: `这道题的正确答案是:${question.correctAnswer}`,
|
||||
fact: '继续观察题干中的关键词,可以更快定位答案。',
|
||||
};
|
||||
}
|
||||
|
||||
export async function getNextChallenge(userId: string, trackId: string): Promise<ChallengeQuestionDto | null> {
|
||||
const category = await getTrackCategory(trackId);
|
||||
if (!category || category.status !== 'active') {
|
||||
throw new NotFoundError('Track');
|
||||
}
|
||||
|
||||
const chapter = await getCurrentChapter(userId, category.id);
|
||||
if (!chapter) return null;
|
||||
|
||||
const question = await getQuestionForChapter(userId, chapter);
|
||||
if (!question) return null;
|
||||
|
||||
return toChallengeDto(category.slug || category.id, chapter, question);
|
||||
}
|
||||
|
||||
export async function submitChallengeAnswer(
|
||||
userId: string,
|
||||
questionId: string,
|
||||
selectedOptionId: string,
|
||||
timeMs: number,
|
||||
comboCount = 0,
|
||||
): Promise<AnswerResultDto> {
|
||||
const [question] = await db.select().from(questions).where(eq(questions.id, questionId)).limit(1);
|
||||
if (!question) throw new NotFoundError('Question');
|
||||
|
||||
const options = buildOptions(question);
|
||||
const selected = options.find((option) => option.id === selectedOptionId);
|
||||
if (!selected) throw new ValidationError('Invalid selectedOptionId');
|
||||
|
||||
const correct = selected.isCorrect;
|
||||
const correctOptionId = options.find((option) => option.isCorrect)?.id ?? 'a';
|
||||
|
||||
await db.insert(userProgress).values({
|
||||
id: uuid(),
|
||||
userId,
|
||||
questionId,
|
||||
correct: correct ? 1 : 0,
|
||||
timeMs,
|
||||
});
|
||||
|
||||
await db
|
||||
.update(questions)
|
||||
.set({
|
||||
stats: sql`JSON_SET(
|
||||
COALESCE(stats, '{}'),
|
||||
'$.timesAnswered', COALESCE(JSON_EXTRACT(stats, '$.timesAnswered'), 0) + 1,
|
||||
'$.correctRate', ROUND(
|
||||
(COALESCE(JSON_EXTRACT(stats, '$.timesAnswered'), 0) * COALESCE(JSON_EXTRACT(stats, '$.correctRate'), 0) + ${correct ? 1 : 0})
|
||||
/ (COALESCE(JSON_EXTRACT(stats, '$.timesAnswered'), 0) + 1),
|
||||
4
|
||||
),
|
||||
'$.avgTimeMs', ROUND(
|
||||
(COALESCE(JSON_EXTRACT(stats, '$.timesAnswered'), 0) * COALESCE(JSON_EXTRACT(stats, '$.avgTimeMs'), 0) + ${timeMs})
|
||||
/ (COALESCE(JSON_EXTRACT(stats, '$.timesAnswered'), 0) + 1)
|
||||
)
|
||||
)`,
|
||||
})
|
||||
.where(eq(questions.id, questionId));
|
||||
|
||||
let xpDelta = 0;
|
||||
if (correct) {
|
||||
xpDelta = calculateXp(BASE_XP, comboCount);
|
||||
await addXp(userId, xpDelta);
|
||||
await updateStreak(userId, await getCorrectAnswersToday(userId));
|
||||
} else {
|
||||
await deductHeart(userId);
|
||||
await deductDailyAttempt(userId);
|
||||
}
|
||||
|
||||
const [progress, knowledgeCard] = await Promise.all([
|
||||
getProgressSummary(userId),
|
||||
getKnowledgeCard(question),
|
||||
]);
|
||||
|
||||
return {
|
||||
answerState: correct ? 'correct' : 'wrong',
|
||||
correctOptionId,
|
||||
xpDelta,
|
||||
progress: {
|
||||
hearts: progress.hearts,
|
||||
dailyAttemptsLeft: progress.dailyAttemptsLeft,
|
||||
xp: progress.xp,
|
||||
streakDays: progress.streakDays,
|
||||
},
|
||||
knowledgeCard,
|
||||
rewards: correct && xpDelta > 0 ? [{ type: 'xp', amount: xpDelta, title: `+${xpDelta} XP` }] : [],
|
||||
};
|
||||
}
|
||||
@ -1,64 +0,0 @@
|
||||
import { getLeaderboard, getUserRank } from '../gamification/leaderboard-service.js';
|
||||
import type { LeaderboardEntryDto, LeaderboardScope } from '../../types/app-api.js';
|
||||
import { db } from '../../db/client.js';
|
||||
import { users } from '../../db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
function getBadge(rank: number): string {
|
||||
if (rank === 1) return '榜首';
|
||||
if (rank <= 3) return '前三';
|
||||
if (rank <= 10) return '冲榜中';
|
||||
return '继续加油';
|
||||
}
|
||||
|
||||
export async function getClientLeaderboard(
|
||||
userId: string,
|
||||
_scope: LeaderboardScope,
|
||||
_trackId: string | undefined,
|
||||
page: number,
|
||||
limit: number,
|
||||
): Promise<{ items: LeaderboardEntryDto[]; pagination: { total: number; page: number; limit: number } }> {
|
||||
const data = await getLeaderboard(undefined, page, limit);
|
||||
return {
|
||||
items: data.items.map((entry) => ({
|
||||
rank: entry.rank,
|
||||
userId: entry.userId,
|
||||
displayName: entry.userId === userId ? '你' : (entry.nickname ?? '知识探险家'),
|
||||
avatarUrl: entry.avatarUrl,
|
||||
xp: entry.xpTotal,
|
||||
badge: getBadge(entry.rank),
|
||||
isMe: entry.userId === userId,
|
||||
})),
|
||||
pagination: data.pagination,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getClientLeaderboardMe(
|
||||
userId: string,
|
||||
_scope: LeaderboardScope,
|
||||
_trackId: string | undefined,
|
||||
): Promise<LeaderboardEntryDto | null> {
|
||||
const [rank, user] = 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),
|
||||
]);
|
||||
if (!rank) return null;
|
||||
|
||||
return {
|
||||
rank: rank.rank,
|
||||
userId,
|
||||
displayName: user[0]?.nickname ?? '你',
|
||||
avatarUrl: user[0]?.avatarUrl ?? null,
|
||||
xp: user[0]?.xpTotal ?? 0,
|
||||
badge: getBadge(rank.rank),
|
||||
isMe: true,
|
||||
};
|
||||
}
|
||||
@ -1,182 +0,0 @@
|
||||
import { db } from '../../db/client.js';
|
||||
import { users } from '../../db/schema.js';
|
||||
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 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;
|
||||
|
||||
type UserTier = 'free' | 'pro' | 'proplus';
|
||||
|
||||
interface ResourceUser {
|
||||
id: string;
|
||||
tier: UserTier | null;
|
||||
xpTotal: number | null;
|
||||
activeTrackId: string | null;
|
||||
dailyAttemptsLeft: number | null;
|
||||
dailyAttemptsDate: Date | string | null;
|
||||
checkInDays: number | null;
|
||||
lastCheckInDate: Date | string | null;
|
||||
streakProtectedUntil: Date | string | null;
|
||||
}
|
||||
|
||||
function today(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function tomorrowIso(): string {
|
||||
const date = new Date();
|
||||
date.setUTCHours(24, 0, 0, 0);
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function toDateString(value: Date | string | null): string | null {
|
||||
if (!value) return null;
|
||||
return typeof value === 'string' ? value.slice(0, 10) : value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function toIso(value: Date | string | null): string | null {
|
||||
if (!value) return null;
|
||||
return typeof value === 'string' ? new Date(value).toISOString() : value.toISOString();
|
||||
}
|
||||
|
||||
export function getDailyAttemptsMax(tier: string | null | undefined): number {
|
||||
if (tier === 'proplus') return PROPLUS_DAILY_ATTEMPTS;
|
||||
if (tier === 'pro') return PRO_DAILY_ATTEMPTS;
|
||||
return FREE_DAILY_ATTEMPTS;
|
||||
}
|
||||
|
||||
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) };
|
||||
}
|
||||
|
||||
export function getNextHeartRestoreAt(lastRestore: string | null, hearts: number, maxHearts: number): string | null {
|
||||
if (!lastRestore || hearts >= maxHearts) return null;
|
||||
return new Date(new Date(lastRestore).getTime() + HEART_RESTORE_MS).toISOString();
|
||||
}
|
||||
|
||||
async function getResourceUser(userId: string): Promise<ResourceUser | null> {
|
||||
const [user] = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
tier: users.tier,
|
||||
xpTotal: users.xpTotal,
|
||||
activeTrackId: users.activeTrackId,
|
||||
dailyAttemptsLeft: users.dailyAttemptsLeft,
|
||||
dailyAttemptsDate: users.dailyAttemptsDate,
|
||||
checkInDays: users.checkInDays,
|
||||
lastCheckInDate: users.lastCheckInDate,
|
||||
streakProtectedUntil: users.streakProtectedUntil,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
return user ?? null;
|
||||
}
|
||||
|
||||
export async function getDailyAttempts(userId: string): Promise<{ left: number; max: number; nextResetAt: string }> {
|
||||
const user = await getResourceUser(userId);
|
||||
const max = getDailyAttemptsMax(user?.tier);
|
||||
if (!user) return { left: max, max, nextResetAt: tomorrowIso() };
|
||||
|
||||
if (toDateString(user.dailyAttemptsDate) !== today()) {
|
||||
await db
|
||||
.update(users)
|
||||
.set({ dailyAttemptsLeft: max, dailyAttemptsDate: sql`CAST(${today()} AS DATE)` })
|
||||
.where(eq(users.id, userId));
|
||||
return { left: max, max, nextResetAt: tomorrowIso() };
|
||||
}
|
||||
|
||||
return {
|
||||
left: Math.min(user.dailyAttemptsLeft ?? max, max),
|
||||
max,
|
||||
nextResetAt: tomorrowIso(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function deductDailyAttempt(userId: string): Promise<number> {
|
||||
const attempts = await getDailyAttempts(userId);
|
||||
const next = Math.max(0, attempts.left - 1);
|
||||
await db
|
||||
.update(users)
|
||||
.set({ dailyAttemptsLeft: next, dailyAttemptsDate: sql`CAST(${today()} AS DATE)` })
|
||||
.where(eq(users.id, userId));
|
||||
return next;
|
||||
}
|
||||
|
||||
export async function restoreDailyAttempts(userId: string, _source: RewardSource): Promise<{ dailyAttemptsLeft: number; dailyAttemptsMax: number }> {
|
||||
const attempts = await getDailyAttempts(userId);
|
||||
await db
|
||||
.update(users)
|
||||
.set({ dailyAttemptsLeft: attempts.max, dailyAttemptsDate: sql`CAST(${today()} AS DATE)` })
|
||||
.where(eq(users.id, userId));
|
||||
return { dailyAttemptsLeft: attempts.max, dailyAttemptsMax: attempts.max };
|
||||
}
|
||||
|
||||
export async function updateProgressPreferences(userId: string, activeTrackId: string): Promise<ProgressSummaryDto> {
|
||||
await db.update(users).set({ activeTrackId }).where(eq(users.id, userId));
|
||||
return getProgressSummary(userId);
|
||||
}
|
||||
|
||||
export async function checkIn(userId: string): Promise<ProgressSummaryDto> {
|
||||
const user = await getResourceUser(userId);
|
||||
const alreadyCheckedIn = toDateString(user?.lastCheckInDate ?? null) === today();
|
||||
if (!alreadyCheckedIn) {
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
checkInDays: sql`COALESCE(check_in_days, 0) + 1`,
|
||||
lastCheckInDate: sql`CAST(${today()} AS DATE)`,
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
}
|
||||
return getProgressSummary(userId);
|
||||
}
|
||||
|
||||
export async function protectStreak(userId: string, _source: RewardSource): Promise<ProgressSummaryDto> {
|
||||
const protectedUntil = new Date();
|
||||
protectedUntil.setUTCDate(protectedUntil.getUTCDate() + 1);
|
||||
await db.update(users).set({ streakProtectedUntil: protectedUntil }).where(eq(users.id, userId));
|
||||
await freezeStreak(userId);
|
||||
return getProgressSummary(userId);
|
||||
}
|
||||
|
||||
export async function getProgressSummary(userId: string): Promise<ProgressSummaryDto> {
|
||||
const [user, hearts, streak, subscription, attempts] = await Promise.all([
|
||||
getResourceUser(userId),
|
||||
getHearts(userId),
|
||||
calculateStreak(userId),
|
||||
getSubscriptionStatus(userId),
|
||||
getDailyAttempts(userId),
|
||||
]);
|
||||
|
||||
const xp = user?.xpTotal ?? 0;
|
||||
const level = getLevelInfo(xp);
|
||||
const isSubscribed = subscription.status === 'active' && subscription.tier !== 'free';
|
||||
|
||||
return {
|
||||
hearts: hearts.remaining,
|
||||
maxHearts: hearts.max,
|
||||
nextHeartRestoreAt: getNextHeartRestoreAt(hearts.lastRestore, hearts.remaining, hearts.max),
|
||||
dailyAttemptsLeft: attempts.left,
|
||||
dailyAttemptsMax: attempts.max,
|
||||
nextAttemptResetAt: attempts.nextResetAt,
|
||||
xp,
|
||||
level: level.level,
|
||||
xpToNextLevel: level.xpToNextLevel,
|
||||
streakDays: streak.days,
|
||||
checkInDays: user?.checkInDays ?? 0,
|
||||
streakProtectedUntil: toIso(user?.streakProtectedUntil ?? null),
|
||||
activeTrackId: user?.activeTrackId ?? null,
|
||||
isSubscribed,
|
||||
};
|
||||
}
|
||||
@ -1,114 +0,0 @@
|
||||
import { db } from '../../db/client.js';
|
||||
import { categories, skillTree, userChapterProgress } from '../../db/schema.js';
|
||||
import { asc, eq, sql } from 'drizzle-orm';
|
||||
import type { NodeStatus, ThemeNodeDto, ThemeTrackDto } from '../../types/app-api.js';
|
||||
import { BASE_XP } from '../progress/xp-service.js';
|
||||
|
||||
const DEFAULT_TRACK_ICON = '📚';
|
||||
const TRACK_ICONS: Readonly<Record<string, string>> = Object.freeze({
|
||||
history: '🏛',
|
||||
drama: '🎭',
|
||||
crosstalk: '🎙',
|
||||
geography: '🗺',
|
||||
general: '💡',
|
||||
});
|
||||
|
||||
type CategoryRow = typeof categories.$inferSelect;
|
||||
type ChapterRow = typeof skillTree.$inferSelect;
|
||||
type ChapterProgressRow = typeof userChapterProgress.$inferSelect;
|
||||
|
||||
function getIcon(category: CategoryRow): string {
|
||||
return TRACK_ICONS[category.slug] ?? TRACK_ICONS[category.id] ?? DEFAULT_TRACK_ICON;
|
||||
}
|
||||
|
||||
function mapNodeStatus(status: ChapterProgressRow['status'] | undefined, hasAnyCurrent: boolean): NodeStatus {
|
||||
if (status === 'passed' || status === 'perfect') return 'done';
|
||||
if (status === 'unlocked') return 'current';
|
||||
if (!status && !hasAnyCurrent) return 'current';
|
||||
return 'locked';
|
||||
}
|
||||
|
||||
function toNode(chapter: ChapterRow, progress: ChapterProgressRow | undefined, hasAnyCurrent: boolean): ThemeNodeDto {
|
||||
const questionCount = chapter.questionsRequired ?? 0;
|
||||
return {
|
||||
id: chapter.id,
|
||||
title: chapter.title,
|
||||
status: mapNodeStatus(progress?.status, hasAnyCurrent),
|
||||
reward: `+${BASE_XP * questionCount} XP`,
|
||||
questionCount,
|
||||
};
|
||||
}
|
||||
|
||||
function calculateTrackProgress(nodes: readonly ThemeNodeDto[]): number {
|
||||
if (nodes.length === 0) return 0;
|
||||
const done = nodes.filter((node) => node.status === 'done').length;
|
||||
return Math.round((done / nodes.length) * 100);
|
||||
}
|
||||
|
||||
async function getProgressMap(userId: string): Promise<Map<string, ChapterProgressRow>> {
|
||||
const progress = await db
|
||||
.select()
|
||||
.from(userChapterProgress)
|
||||
.where(eq(userChapterProgress.userId, userId));
|
||||
return new Map(progress.map((item) => [item.chapterId, item]));
|
||||
}
|
||||
|
||||
export async function getThemeTracks(userId: string): Promise<ThemeTrackDto[]> {
|
||||
const [activeCategories, chapters, progressMap] = await Promise.all([
|
||||
db.select().from(categories).where(eq(categories.status, 'active')).orderBy(asc(categories.sortOrder)),
|
||||
db.select().from(skillTree).orderBy(asc(skillTree.sortOrder)),
|
||||
getProgressMap(userId),
|
||||
]);
|
||||
|
||||
return activeCategories.map((category) => {
|
||||
const categoryChapters = chapters.filter((chapter) => chapter.categoryId === category.id);
|
||||
const hasAnyCurrent = categoryChapters.some((chapter) => progressMap.get(chapter.id)?.status === 'unlocked');
|
||||
const nodes = categoryChapters.map((chapter) => toNode(chapter, progressMap.get(chapter.id), hasAnyCurrent));
|
||||
return {
|
||||
id: category.slug || category.id,
|
||||
name: category.name,
|
||||
icon: getIcon(category),
|
||||
progress: calculateTrackProgress(nodes),
|
||||
nodes,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function getThemeTrackById(userId: string, trackId: string): Promise<ThemeTrackDto | null> {
|
||||
const [category] = await db
|
||||
.select()
|
||||
.from(categories)
|
||||
.where(sql`${categories.id} = ${trackId} OR ${categories.slug} = ${trackId}`)
|
||||
.limit(1);
|
||||
|
||||
if (!category || category.status !== 'active') return null;
|
||||
|
||||
const [chapters, progressMap] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(skillTree)
|
||||
.where(eq(skillTree.categoryId, category.id))
|
||||
.orderBy(asc(skillTree.sortOrder)),
|
||||
getProgressMap(userId),
|
||||
]);
|
||||
|
||||
const hasAnyCurrent = chapters.some((chapter) => progressMap.get(chapter.id)?.status === 'unlocked');
|
||||
const nodes = chapters.map((chapter) => toNode(chapter, progressMap.get(chapter.id), hasAnyCurrent));
|
||||
|
||||
return {
|
||||
id: category.slug || category.id,
|
||||
name: category.name,
|
||||
icon: getIcon(category),
|
||||
progress: calculateTrackProgress(nodes),
|
||||
nodes,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getTrackCategory(trackId: string): Promise<CategoryRow | null> {
|
||||
const [category] = await db
|
||||
.select()
|
||||
.from(categories)
|
||||
.where(sql`${categories.id} = ${trackId} OR ${categories.slug} = ${trackId}`)
|
||||
.limit(1);
|
||||
return category ?? null;
|
||||
}
|
||||
@ -1,40 +0,0 @@
|
||||
import type { ShopBenefitDto } from '../../types/app-api.js';
|
||||
|
||||
const SHOP_BENEFITS: readonly ShopBenefitDto[] = Object.freeze([
|
||||
{
|
||||
id: 'restore-hearts',
|
||||
type: 'hearts',
|
||||
title: '恢复满血',
|
||||
description: '血量不足时继续挑战',
|
||||
enabled: true,
|
||||
requiresAd: true,
|
||||
},
|
||||
{
|
||||
id: 'restore-attempts',
|
||||
type: 'attempts',
|
||||
title: '恢复挑战次数',
|
||||
description: '今日次数用完后继续闯关',
|
||||
enabled: true,
|
||||
requiresAd: true,
|
||||
},
|
||||
{
|
||||
id: 'protect-streak',
|
||||
type: 'streak',
|
||||
title: '连续学习保护',
|
||||
description: '忙碌时保护连续学习天数',
|
||||
enabled: true,
|
||||
requiresAd: true,
|
||||
},
|
||||
{
|
||||
id: 'duoqi-plus',
|
||||
type: 'subscription',
|
||||
title: '多奇 Plus',
|
||||
description: '更多挑战次数和学习权益',
|
||||
enabled: true,
|
||||
requiresAd: false,
|
||||
},
|
||||
]);
|
||||
|
||||
export async function getShopBenefits(): Promise<readonly ShopBenefitDto[]> {
|
||||
return SHOP_BENEFITS;
|
||||
}
|
||||
@ -1,42 +0,0 @@
|
||||
import { AppError } from '../../utils/errors.js';
|
||||
import { verifyReceipt } from '../payment/huawei-iap.js';
|
||||
import { activateSubscription, getSubscriptionStatus } from '../payment/subscription-service.js';
|
||||
import type { SubscriptionDto, SubscriptionPlatform, SubscriptionTier } from '../../types/app-api.js';
|
||||
|
||||
function toSubscriptionDto(status: Awaited<ReturnType<typeof getSubscriptionStatus>>): SubscriptionDto {
|
||||
return {
|
||||
status: status.status as SubscriptionDto['status'],
|
||||
tier: status.tier as SubscriptionTier,
|
||||
expiresAt: status.expiresAt,
|
||||
autoRenew: status.autoRenew,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getClientSubscription(userId: string): Promise<SubscriptionDto> {
|
||||
return toSubscriptionDto(await getSubscriptionStatus(userId));
|
||||
}
|
||||
|
||||
export async function verifyClientSubscription(
|
||||
userId: string,
|
||||
platform: SubscriptionPlatform,
|
||||
purchaseToken: string,
|
||||
productId: string,
|
||||
tier: Exclude<SubscriptionTier, 'free'>,
|
||||
): Promise<SubscriptionDto> {
|
||||
if (platform !== 'huawei') {
|
||||
throw new AppError(`${platform} subscription verification is not supported yet`, 400, 'UNSUPPORTED_PLATFORM');
|
||||
}
|
||||
|
||||
const result = await verifyReceipt(purchaseToken);
|
||||
if (!result.valid) {
|
||||
throw new AppError('Purchase verification failed', 400, 'INVALID_RECEIPT');
|
||||
}
|
||||
|
||||
const expiresAt = result.expiryTime
|
||||
? new Date(result.expiryTime)
|
||||
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
void productId;
|
||||
await activateSubscription(userId, platform, purchaseToken, tier, expiresAt);
|
||||
return getClientSubscription(userId);
|
||||
}
|
||||
@ -1,118 +0,0 @@
|
||||
export type SubscriptionTier = 'free' | 'pro' | 'proplus';
|
||||
export type SubscriptionStatus = 'none' | 'active' | 'expired' | 'cancelled';
|
||||
export type NodeStatus = 'done' | 'current' | 'locked' | 'chest';
|
||||
export type LeaderboardScope = 'region' | 'topic';
|
||||
export type RewardSource = 'ad' | 'check_in' | 'subscription' | 'admin_grant' | 'debug';
|
||||
export type SubscriptionPlatform = 'huawei' | 'apple' | 'google';
|
||||
|
||||
export interface UserBriefDto {
|
||||
id: string;
|
||||
nickname: string;
|
||||
avatarUrl: string | null;
|
||||
tier: SubscriptionTier;
|
||||
level: number;
|
||||
}
|
||||
|
||||
export interface SubscriptionDto {
|
||||
status: SubscriptionStatus;
|
||||
tier: SubscriptionTier;
|
||||
expiresAt: string | null;
|
||||
autoRenew?: boolean;
|
||||
}
|
||||
|
||||
export interface ProgressSummaryDto {
|
||||
hearts: number;
|
||||
maxHearts: number;
|
||||
nextHeartRestoreAt: string | null;
|
||||
dailyAttemptsLeft: number;
|
||||
dailyAttemptsMax: number;
|
||||
nextAttemptResetAt: string | null;
|
||||
xp: number;
|
||||
level: number;
|
||||
xpToNextLevel: number;
|
||||
streakDays: number;
|
||||
checkInDays: number;
|
||||
streakProtectedUntil: string | null;
|
||||
activeTrackId: string | null;
|
||||
isSubscribed: boolean;
|
||||
}
|
||||
|
||||
export interface ThemeNodeDto {
|
||||
id: string;
|
||||
title: string;
|
||||
status: NodeStatus;
|
||||
reward: string;
|
||||
questionCount: number;
|
||||
}
|
||||
|
||||
export interface ThemeTrackDto {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
progress: number;
|
||||
nodes: ThemeNodeDto[];
|
||||
}
|
||||
|
||||
export interface ShopBenefitDto {
|
||||
id: string;
|
||||
type: 'hearts' | 'attempts' | 'streak' | 'subscription';
|
||||
title: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
requiresAd: boolean;
|
||||
}
|
||||
|
||||
export interface BootstrapDto {
|
||||
user: UserBriefDto;
|
||||
progress: ProgressSummaryDto;
|
||||
tracks: ThemeTrackDto[];
|
||||
shopBenefits: readonly ShopBenefitDto[];
|
||||
subscription: SubscriptionDto;
|
||||
}
|
||||
|
||||
export interface ChallengeQuestionDto {
|
||||
challengeId: string;
|
||||
trackId: string;
|
||||
nodeId: string;
|
||||
question: {
|
||||
id: string;
|
||||
prompt: string;
|
||||
options: ReadonlyArray<{ id: string; text: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AnswerRequestDto {
|
||||
challengeId: string;
|
||||
questionId: string;
|
||||
selectedOptionId: string;
|
||||
timeMs: number;
|
||||
}
|
||||
|
||||
export interface AnswerResultDto {
|
||||
answerState: 'correct' | 'wrong';
|
||||
correctOptionId: string;
|
||||
xpDelta: number;
|
||||
progress: {
|
||||
hearts: number;
|
||||
dailyAttemptsLeft: number;
|
||||
xp: number;
|
||||
streakDays: number;
|
||||
};
|
||||
knowledgeCard: {
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
fact: string;
|
||||
};
|
||||
rewards: ReadonlyArray<{ type: string; amount?: number; title?: string }>;
|
||||
}
|
||||
|
||||
export interface LeaderboardEntryDto {
|
||||
rank: number;
|
||||
userId: string;
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
xp: number;
|
||||
badge: string;
|
||||
isMe: boolean;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user