duoqi-api/src/routes/progress.ts
Wang Zhuoxuan b872b1cad9 feat: implement Phase 1b core features and Phase 1c commercialization
Phase 1b — Core Features:
- Huawei ID Kit login (token exchange + user info) with guest mode
- Quiz engine: randomized questions, distractor shuffling, answer verification
- XP service with combo bonuses (3/5/10-hit streaks), daily reset
- Streak service: >=3 correct/day, freeze, UTC date handling
- Hearts service: 5/day, 30min auto-restore, Pro unlimited
- 50 quiz questions across 3 categories (history/drama/crosstalk)
- 13 skill tree chapters with linear progression
- Idempotent seed import script (categories → skill tree → questions)
- 7 admin CRUD services (questions, categories, knowledge cards,
  skill tree, users, stats, feedback) with Zod validation
- All routes use Zod schema validation, /auth/me endpoint

Phase 1c — Commercialization:
- Leaderboard with live XP ranking, 10 tiers, weekly settlement
- Achievement system with 15 seed achievements and condition checking
- Huawei IAP receipt verification + subscription management
- Differentiated rate limiting (auth 10/min, quiz 60/min)
- Admin audit logging middleware

Infrastructure:
- Vitest test framework with DB mock utilities (19 tests passing)
- 12 DB tables (5 new: question_ratings, user_feedback, achievements,
  user_achievements, leaderboard_snapshots, subscriptions, admin_audit_log)
- TypeScript strict mode: zero errors
2026-04-09 00:12:12 +08:00

76 lines
2.4 KiB
TypeScript

import { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
getDashboard,
getStreak,
getHearts,
restoreHearts,
getChapterProgress,
} from '../services/progress/progress-service.js';
const restoreHeartsSchema = z.object({
method: z.enum(['ad', 'wait', 'upgrade']),
});
const feedbackSchema = z.object({
content: z.string().min(1).max(2000),
contact: z.string().max(255).optional(),
pageContext: z.string().max(200).optional(),
});
function getUserId(request: { user: unknown }): string {
return (request.user as { userId: string }).userId;
}
export async function progressRoutes(app: FastifyInstance): Promise<void> {
app.get('/progress/dashboard', async (request) => {
const data = await getDashboard(getUserId(request));
return { success: true, data, error: null };
});
app.get('/progress/streak', async (request) => {
const data = await getStreak(getUserId(request));
return { success: true, data, error: null };
});
app.get('/progress/hearts', async (request) => {
const data = await getHearts(getUserId(request));
return { success: true, data, error: null };
});
app.post('/progress/hearts/restore', async (request) => {
const parsed = restoreHeartsSchema.safeParse(request.body);
if (!parsed.success) {
return { success: false, data: null, error: { code: 'VALIDATION_ERROR', message: parsed.error.issues[0]?.message } };
}
const data = await restoreHearts(getUserId(request), parsed.data.method);
return { success: true, data, error: null };
});
app.get('/progress/chapters', async (request) => {
const data = await getChapterProgress(getUserId(request));
return { success: true, data, error: null };
});
app.post('/feedback', async (request) => {
const parsed = feedbackSchema.safeParse(request.body);
if (!parsed.success) {
return { success: false, data: null, error: { code: 'VALIDATION_ERROR', message: parsed.error.issues[0]?.message } };
}
const { db } = await import('../db/client.js');
const { userFeedback } = await import('../db/schema.js');
const { v4: uuid } = await import('uuid');
const userId = getUserId(request);
await db.insert(userFeedback).values({
id: uuid(),
userId,
content: parsed.data.content,
contact: parsed.data.contact ?? null,
pageContext: parsed.data.pageContext ?? null,
});
return { success: true, data: null, error: null };
});
}