duoqi-api/src/services/gamification/leaderboard-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

147 lines
4.3 KiB
TypeScript

import { db } from '../../db/client.js';
import { users, leaderboardSnapshots } from '../../db/schema.js';
import { eq, desc, sql } from 'drizzle-orm';
import { v4 as uuid } from 'uuid';
const TIERS = ['bronze', 'silver', 'gold', 'platinum', 'diamond', 'master', 'grandmaster', 'champion', 'legend', 'mythic'] as const;
export type Tier = typeof TIERS[number];
export interface LeaderboardEntry {
userId: string;
nickname: string | null;
avatarUrl: string | null;
xpTotal: number;
rank: number;
tier: string;
}
/**
* Get the current leaderboard, optionally filtered by tier.
* Uses live xp_total ranking (not weekly snapshot).
*/
export async function getLeaderboard(tier?: string, page = 1, limit = 20): Promise<{
items: LeaderboardEntry[];
pagination: { total: number; page: number; limit: number };
}> {
const offset = (page - 1) * limit;
// Simpler approach: rank all users by xp_total
const allUsers = await db
.select({
id: users.id,
nickname: users.nickname,
avatarUrl: users.avatarUrl,
xpTotal: users.xpTotal,
})
.from(users)
.orderBy(desc(users.xpTotal))
.limit(1000);
// Filter by tier if specified (tier is determined by rank ranges)
let filtered = allUsers;
if (tier) {
// Each tier covers ~10% of players, roughly 100 per tier for top 1000
const tierIndex = TIERS.indexOf(tier as Tier);
if (tierIndex >= 0) {
const perTier = Math.ceil(allUsers.length / TIERS.length);
const start = tierIndex * perTier;
filtered = allUsers.slice(start, start + perTier);
}
}
const total = filtered.length;
const items: LeaderboardEntry[] = filtered.slice(offset, offset + limit).map((u, i) => ({
userId: u.id,
nickname: u.nickname ?? null,
avatarUrl: u.avatarUrl ?? null,
xpTotal: u.xpTotal ?? 0,
rank: offset + i + 1,
tier: getTierForRank(offset + i + 1),
}));
return { items, pagination: { total, page, limit } };
}
/**
* Get a specific user's rank and tier.
*/
export async function getUserRank(userId: string): Promise<{ rank: number; tier: string } | null> {
// Count users with higher XP
const [user] = await db
.select({ xpTotal: users.xpTotal })
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (!user) return null;
const [higher] = await db
.select({ count: sql<number>`COUNT(*)` })
.from(users)
.where(sql`COALESCE(xp_total, 0) > ${user.xpTotal ?? 0}`);
const rank = Number(higher?.count ?? 0) + 1;
return { rank, tier: getTierForRank(rank) };
}
/**
* Run weekly settlement: promote/demote users based on weekly XP.
* Should be called via a scheduled job (cron).
*/
export async function weeklySettlement(): Promise<void> {
const today = new Date();
const weekStart = new Date(today);
weekStart.setDate(today.getDate() - today.getDay()); // Start of this week (Sunday)
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekStart.getDate() + 6);
const weekStartStr = weekStart.toISOString().slice(0, 10);
const weekEndStr = weekEnd.toISOString().slice(0, 10);
// Get all users ordered by XP
const allUsers = await db
.select({
id: users.id,
xpTotal: users.xpTotal,
})
.from(users)
.orderBy(desc(users.xpTotal));
const perTier = Math.max(1, Math.ceil(allUsers.length / TIERS.length));
// Create leaderboard snapshots
for (let i = 0; i < allUsers.length; i++) {
const user = allUsers[i]!;
const rank = i + 1;
const tierIndex = Math.min(Math.floor(rank / perTier), TIERS.length - 1);
const tier = TIERS[tierIndex]!;
await db.insert(leaderboardSnapshots).values({
id: uuid(),
userId: user.id,
tier,
weeklyXp: user.xpTotal ?? 0,
rank,
league: `${tier}-${Math.ceil(rank / perTier)}`,
weekStart: sql`CAST(${weekStartStr} AS DATE)`,
weekEnd: sql`CAST(${weekEndStr} AS DATE)`,
});
}
}
function getTierForRank(rank: number): string {
// Equal distribution across 10 tiers
if (rank <= 10) return 'mythic';
if (rank <= 30) return 'legend';
if (rank <= 60) return 'champion';
if (rank <= 100) return 'grandmaster';
if (rank <= 150) return 'master';
if (rank <= 210) return 'diamond';
if (rank <= 280) return 'platinum';
if (rank <= 370) return 'gold';
if (rank <= 480) return 'silver';
return 'bronze';
}
export { TIERS };