Add Flutter app-facing API routes
This commit is contained in:
parent
c70748dde2
commit
3ea44189e8
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,7 +2,6 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
.env
|
.env
|
||||||
*.log
|
*.log
|
||||||
db/migrations/
|
|
||||||
|
|
||||||
# Claude Code
|
# Claude Code
|
||||||
.claude/
|
.claude/
|
||||||
|
|||||||
201
db/migrations/0000_melodic_blacklash.sql
Normal file
201
db/migrations/0000_melodic_blacklash.sql
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
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`);
|
||||||
6
db/migrations/0001_sturdy_invaders.sql
Normal file
6
db/migrations/0001_sturdy_invaders.sql
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
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;
|
||||||
1368
db/migrations/meta/0000_snapshot.json
Normal file
1368
db/migrations/meta/0000_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1412
db/migrations/meta/0001_snapshot.json
Normal file
1412
db/migrations/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
20
db/migrations/meta/_journal.json
Normal file
20
db/migrations/meta/_journal.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"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 { v4 as uuid } from 'uuid';
|
||||||
import { db } from '../../src/db/client.js';
|
import { db } from '../../src/db/client.js';
|
||||||
import { categories, questions, knowledgeCards, skillTree, achievements, adminUsers } from '../../src/db/schema.js';
|
import { categories, questions, knowledgeCards, skillTree, achievements } from '../../src/db/schema.js';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import * as adminAuthService from '../../src/services/admin/admin-auth.js';
|
import * as adminAuthService from '../../src/services/admin/admin-auth.js';
|
||||||
|
|
||||||
|
|||||||
26
eslint.config.js
Normal file
26
eslint.config.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
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: '^_',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
16
src/__tests__/services/shop/shop-service.test.ts
Normal file
16
src/__tests__/services/shop/shop-service.test.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
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,6 +35,12 @@ export const users = mysqlTable('users', {
|
|||||||
dailyXpEarned: smallint('daily_xp_earned').default(0),
|
dailyXpEarned: smallint('daily_xp_earned').default(0),
|
||||||
dailyXpDate: date('daily_xp_date'),
|
dailyXpDate: date('daily_xp_date'),
|
||||||
currentTheme: varchar('current_theme', { length: 20 }).default('inkTeal'),
|
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`),
|
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`),
|
||||||
updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`),
|
updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`),
|
||||||
}, (table) => [
|
}, (table) => [
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import { quizRoutes } from './routes/quiz.js';
|
|||||||
import { progressRoutes } from './routes/progress.js';
|
import { progressRoutes } from './routes/progress.js';
|
||||||
import { gamificationRoutes } from './routes/gamification.js';
|
import { gamificationRoutes } from './routes/gamification.js';
|
||||||
import { paymentRoutes } from './routes/payment.js';
|
import { paymentRoutes } from './routes/payment.js';
|
||||||
|
import { appApiRoutes } from './routes/app-api.js';
|
||||||
import { adminRoutes } from './routes/admin/index.js';
|
import { adminRoutes } from './routes/admin/index.js';
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
@ -65,6 +66,7 @@ async function main(): Promise<void> {
|
|||||||
app.register(progressRoutes, { prefix: '/v1' });
|
app.register(progressRoutes, { prefix: '/v1' });
|
||||||
app.register(gamificationRoutes, { prefix: '/v1' });
|
app.register(gamificationRoutes, { prefix: '/v1' });
|
||||||
app.register(paymentRoutes, { prefix: '/v1' });
|
app.register(paymentRoutes, { prefix: '/v1' });
|
||||||
|
app.register(appApiRoutes, { prefix: '/v1' });
|
||||||
|
|
||||||
// Admin routes: higher rate limit (100/min)
|
// Admin routes: higher rate limit (100/min)
|
||||||
app.register(adminRoutes, { prefix: '/v1/admin' });
|
app.register(adminRoutes, { prefix: '/v1/admin' });
|
||||||
|
|||||||
173
src/routes/app-api.ts
Normal file
173
src/routes/app-api.ts
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
46
src/services/app/bootstrap-service.ts
Normal file
46
src/services/app/bootstrap-service.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
228
src/services/learning/challenge-service.ts
Normal file
228
src/services/learning/challenge-service.ts
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
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` }] : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
64
src/services/learning/leaderboard-api-service.ts
Normal file
64
src/services/learning/leaderboard-api-service.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
182
src/services/learning/progress-summary-service.ts
Normal file
182
src/services/learning/progress-summary-service.ts
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
114
src/services/learning/tracks-service.ts
Normal file
114
src/services/learning/tracks-service.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
40
src/services/shop/shop-service.ts
Normal file
40
src/services/shop/shop-service.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
42
src/services/subscription/subscription-api-service.ts
Normal file
42
src/services/subscription/subscription-api-service.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
118
src/types/app-api.ts
Normal file
118
src/types/app-api.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
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