Compare commits

..

No commits in common. "b46b6c8ae0ace2d1f4c91396f108332269518df7" and "c70748dde2e2afe794a6a08b9a97a9f4406d8c44" have entirely different histories.

23 changed files with 2 additions and 4232 deletions

3
.gitignore vendored
View File

@ -2,8 +2,7 @@ node_modules/
dist/
.env
*.log
db/migrations/
# Claude Code
.claude/
coverage/

132
AGENTS.md
View File

@ -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

View File

@ -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`);

View File

@ -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

View File

@ -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
}
]
}

View File

@ -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';

View File

@ -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: '^_',
}],
},
},
);

View File

@ -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();
});
});

View File

@ -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);
});
});

View File

@ -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,
});
});
});

View File

@ -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) => [

View File

@ -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' });

View File

@ -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 };
});
}

View File

@ -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,
};
}

View File

@ -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` }] : [],
};
}

View File

@ -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,
};
}

View File

@ -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,
};
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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;
}