duoqi-api/src/services/progress/xp-service.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

92 lines
2.4 KiB
TypeScript

import { db } from '../../db/client.js';
import { users } from '../../db/schema.js';
import { eq, sql } from 'drizzle-orm';
/** Combo bonus tiers: minimum combo count → bonus XP */
const COMBO_BONUSES: ReadonlyArray<{ minCombo: number; bonus: number }> = [
{ minCombo: 10, bonus: 20 },
{ minCombo: 5, bonus: 10 },
{ minCombo: 3, bonus: 5 },
];
const BASE_XP = 10;
const DEFAULT_DAILY_GOAL = 50;
function toDateString(value: Date | string | null): string | null {
if (!value) return null;
if (typeof value === 'string') return value.slice(0, 10);
return value.toISOString().slice(0, 10);
}
export interface DailyXpStatus {
earned: number;
goal: number;
date: string;
}
/**
* Calculate total XP for a correct answer, including combo bonus.
* Combo bonus is cumulative: 3-combo = +5, 5-combo = +10, 10-combo = +20.
*/
export function calculateXp(baseXp: number, comboCount: number): number {
let bonus = 0;
for (const tier of COMBO_BONUSES) {
if (comboCount >= tier.minCombo) {
bonus = tier.bonus;
break;
}
}
return baseXp + bonus;
}
/**
* Add XP to a user. Handles daily XP reset if the date has changed.
* Uses atomic SQL update to prevent race conditions.
*/
export async function addXp(userId: string, amount: number): Promise<void> {
const today = new Date().toISOString().slice(0, 10);
// Atomically update total XP and handle daily reset
await db
.update(users)
.set({
xpTotal: sql`COALESCE(xp_total, 0) + ${amount}`,
dailyXpEarned: sql`CASE
WHEN COALESCE(daily_xp_date, '') = ${today}
THEN COALESCE(daily_xp_earned, 0) + ${amount}
ELSE ${amount}
END`,
dailyXpDate: sql`CAST(${today} AS DATE)`,
})
.where(eq(users.id, userId));
}
/**
* Get the user's daily XP status.
*/
export async function getDailyXpStatus(userId: string): Promise<DailyXpStatus> {
const today = new Date().toISOString().slice(0, 10);
const [user] = await db
.select({
dailyXpEarned: users.dailyXpEarned,
dailyXpDate: users.dailyXpDate,
dailyXpGoal: users.dailyXpGoal,
})
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (!user) {
return { earned: 0, goal: DEFAULT_DAILY_GOAL, date: today };
}
const isToday = toDateString(user.dailyXpDate) === today;
return {
earned: isToday ? (user.dailyXpEarned ?? 0) : 0,
goal: user.dailyXpGoal ?? DEFAULT_DAILY_GOAL,
date: today,
};
}
export { BASE_XP };