import { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { getBootstrap } from '../services/app/bootstrap-service.js'; import { getRegionsConfig, updateUserRegion } from '../services/app/regions-service.js'; import { getThemeTrackById, getThemeTracks } from '../services/learning/tracks-service.js'; import { getNextChallenge, submitChallengeAnswer } from '../services/learning/challenge-service.js'; import { acknowledgeKnowledgeCard } from '../services/learning/knowledge-card-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 { getShopCatalog } from '../services/shop/shop-service.js'; import { getClientSubscription } 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(), submitRequestId: z.string().min(1).max(80).optional(), }); const preferencesSchema = z.object({ activeTrackId: z.string().min(1).max(50), }); const userRegionSchema = z.object({ regionCode: z.string().regex(/^\d{6}$/), }); const leaderboardQuerySchema = z.object({ scope: z.enum(['region', 'topic']).default('region'), trackId: z.string().optional(), regionCode: z.string().regex(/^\d{6}$/).optional(), page: z.coerce.number().int().min(1).default(1), limit: z.coerce.number().int().min(1).max(100).default(20), }); 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 { app.get('/app/bootstrap', async (request) => { const data = await getBootstrap(getUserId(request)); return { success: true, data, error: null }; }); app.get('/app/regions', async () => { const data = getRegionsConfig(); 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.challengeId, parsed.data.questionId, parsed.data.selectedOptionId, parsed.data.timeMs, parsed.data.comboCount, parsed.data.submitRequestId, ); return { success: true, data: { ...data, challengeId: parsed.data.challengeId }, error: null }; }); // MVP:用户答错后打开或收下知识卡时调用,发放 review_explanation (3 XP) 和首次 first_knowledge_card (15 XP)。 const knowledgeCardIdSchema = z.string().min(1).max(80).regex(/^(fallback-)?[a-zA-Z0-9_-]+$/, 'Invalid cardId'); const knowledgeCardViewSchema = z.object({ challengeId: z.string().min(1).max(80).optional(), }); app.post('/challenges/knowledge-cards/:cardId/view', async (request) => { const rawCardId = (request.params as { cardId?: string }).cardId ?? ''; const cardId = knowledgeCardIdSchema.parse(rawCardId); const parsed = knowledgeCardViewSchema.safeParse(request.body ?? {}); if (!parsed.success) return validationError(parsed.error.issues[0]?.message); const data = await acknowledgeKnowledgeCard(getUserId(request), cardId, parsed.data.challengeId); return { success: true, data, 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.patch('/users/me/region', async (request) => { const parsed = userRegionSchema.safeParse(request.body); if (!parsed.success) return validationError(parsed.error.issues[0]?.message); const region = await updateUserRegion(getUserId(request), parsed.data.regionCode); return { success: true, data: { region }, error: null }; }); app.post('/progress/check-in', async (request) => { const data = await checkIn(getUserId(request)); return { success: true, data, error: null }; }); // [废弃] 请使用 POST /rewards/ad-recovery/session + /rewards/ad-recovery/complete 两步流程。 // 该接口不做幂等、每日上限和 Plus 分支检查,仅供内部测试或过渡期使用。 app.post('/rewards/hearts/restore', async (request) => { const parsed = rewardSourceSchema.safeParse(request.body); if (!parsed.success) return validationError(parsed.error.issues[0]?.message); const data = await restoreHearts(getUserId(request), 'ad'); return { success: true, data, error: null }; }); // [废弃] 请使用 POST /rewards/ad-recovery/session + /rewards/ad-recovery/complete 两步流程。 // 该接口不做幂等、每日上限和 Plus 分支检查,仅供内部测试或过渡期使用。 app.post('/rewards/attempts/restore', async (request) => { const parsed = rewardSourceSchema.safeParse(request.body); if (!parsed.success) return validationError(parsed.error.issues[0]?.message); const data = await restoreDailyAttempts(getUserId(request), parsed.data.source); return { success: true, data, error: null }; }); // [废弃] 请使用 POST /rewards/ad-recovery/session + /rewards/ad-recovery/complete 两步流程。 // 该接口不做幂等、冷却期和 Plus 分支检查,仅供内部测试或过渡期使用。 app.post('/rewards/streak/protect', async (request) => { const parsed = rewardSourceSchema.safeParse(request.body); if (!parsed.success) return validationError(parsed.error.issues[0]?.message); 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.regionCode, parsed.data.page, parsed.data.limit, ); return { success: true, data: data.items, meta: data.meta, pagination: data.pagination, error: null }; }); app.get('/leaderboards/me', async (request) => { const parsed = leaderboardQuerySchema.safeParse(request.query); if (!parsed.success) return validationError(parsed.error.issues[0]?.message); const data = await getClientLeaderboardMe(getUserId(request), parsed.data.scope, parsed.data.trackId, parsed.data.regionCode); return { success: true, data: data?.entry ?? null, meta: data?.meta ?? null, error: null }; }); app.get('/shop', async () => { const data = await getShopCatalog(); return { success: true, data, error: null }; }); // MVP:商店购买、道具使用、Plus 订阅验证均不开放(docs/GAMIFICATION_DESIGN.md「MVP 不实现道具 / Plus / 金币消费」)。 // 路由保留以便客户端兼容,但统一返回 NOT_AVAILABLE_IN_MVP,不调底层 service。 app.post('/shop/purchase', async () => { return { success: false, data: null, error: { code: 'NOT_AVAILABLE_IN_MVP', message: '商店购买暂未开放' } }; }); app.post('/inventory/items/use', async () => { return { success: false, data: null, error: { code: 'NOT_AVAILABLE_IN_MVP', message: '道具使用暂未开放' } }; }); app.get('/subscription', async (request) => { const data = await getClientSubscription(getUserId(request)); return { success: true, data, error: null }; }); app.post('/subscription/verify', async () => { return { success: false, data: null, error: { code: 'NOT_AVAILABLE_IN_MVP', message: 'Plus 订阅暂未开放' } }; }); }