duoqi-api/src/routes/app-api.ts
Wang Zhuoxuan c36d828df9
All checks were successful
CI/CD Pipeline / Unit Tests (push) Successful in 30s
CI/CD Pipeline / Build & Deploy Test (push) Has been skipped
CI/CD Pipeline / Build & Deploy Production (push) Successful in 1m19s
feat: narrow gamification to first-version MVP scope
行为按 docs/GAMIFICATION_DESIGN.md(duoqi-flutter/docs/GAMIFICATION_REQUIREMENTS_CHANGE.md)对齐:

- 新增 src/utils/time.ts 北京时间工具,统一替换散落的 UTC 日期/周边界计算
- challenge-service:未完成 session 续答(同 nodeId 复用 pending/in_progress),每日次数按 session 幂等扣减(条件 UPDATE 抢 daily_attempt_consumed_at 锁),移除高奖励 multiplier 和答对时发 first_knowledge_card
- 新增 knowledge-card-service 和 POST /challenges/knowledge-cards/:cardId/view:用户打开/收下卡片时在单事务内发 review_explanation (3 XP) 和 first_knowledge_card (15 XP),fallback 卡走独立幂等 namespace
- tracks-service:mapNodeStatus 只返回 current/done
- hearts-service:删除 Pro/ProPlus 免扣分支和新用户 1 心保护,RestoreMethod 收窄为 'ad' | 'wait'
- xp-service / leaderboard-service / streak-service / ad-recovery-service / progress-summary-service:时区切北京;排行榜同分按 last_xp_at 升序,weeklySettlement 不再发前 3 名金币;streak milestone 返回空
- /shop/purchase、/inventory/items/use、/subscription/verify 返回 NOT_AVAILABLE_IN_MVP
- schema 加 challenge_sessions.daily_attempt_consumed_at(迁移 0007)
- 接口文档 docs/api-reference.md 同步 12 项 MVP 行为变化
- 测试:新增 utils/time 和 tracks node-status 测试;hearts/streak/leaderboard 测试按新行为更新;challenge-service 13 个 MVP 之外(Plus/高奖励/first_knowledge_card 答对触发)测试 it.skip 待重写 mock 队列

typecheck / lint / test (146 passed, 13 skipped) 全部通过。
2026-06-16 18:05:04 +08:00

212 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<void> {
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 订阅暂未开放' } };
});
}