行为按 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) 全部通过。
77 lines
2.5 KiB
TypeScript
77 lines
2.5 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']),
|
|
});
|
|
|
|
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 };
|
|
});
|
|
|
|
// [废弃] 请使用 POST /rewards/ad-recovery/session + /rewards/ad-recovery/complete 两步流程。
|
|
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 };
|
|
});
|
|
}
|