diff --git a/docs/gamification-server-plan.md b/docs/gamification-server-plan.md index 6598507..7a51041 100644 --- a/docs/gamification-server-plan.md +++ b/docs/gamification-server-plan.md @@ -107,7 +107,7 @@ | # | 任务 | 状态 | 验收标准 | |---|------|------|----------| -| G5-1 | 改造排行榜数据源为本周 XP | [ ] | 排行榜不再按累计 XP 排名,展示本周 XP | +| G5-1 | 改造排行榜数据源为本周 XP | [x] | 排行榜不再按累计 XP 排名,展示本周 XP | | G5-2 | 实现每周一刷新逻辑 | [ ] | 自然周边界清晰,UTC/本地时区策略写入代码注释和文档 | | G5-3 | 实现 20-30 人分组 | [ ] | 每个用户进入稳定榜组,分页和我的排名基于组内排名 | | G5-4 | 实现前三奖励结算 | [ ] | 周结算给前 3 名发金币、徽章碎片或头像框奖励,幂等执行 | diff --git a/src/services/gamification/leaderboard-service.ts b/src/services/gamification/leaderboard-service.ts index b6359ca..5b9ab38 100644 --- a/src/services/gamification/leaderboard-service.ts +++ b/src/services/gamification/leaderboard-service.ts @@ -1,7 +1,8 @@ import { db } from '../../db/client.js'; -import { users, leaderboardSnapshots } from '../../db/schema.js'; -import { eq, desc, sql } from 'drizzle-orm'; +import { leaderboardSnapshots, userWeeklyXp, users } from '../../db/schema.js'; +import { desc, eq, sql } from 'drizzle-orm'; import { v4 as uuid } from 'uuid'; +import { LEADERBOARD_RULES } from './rules.js'; const TIERS = ['bronze', 'silver', 'gold', 'platinum', 'diamond', 'master', 'grandmaster', 'champion', 'legend', 'mythic'] as const; export type Tier = typeof TIERS[number]; @@ -10,51 +11,70 @@ export interface LeaderboardEntry { userId: string; nickname: string | null; avatarUrl: string | null; - xpTotal: number; + weeklyXp: number; rank: number; tier: string; } /** - * Get the current leaderboard, optionally filtered by tier. - * Uses live xp_total ranking (not weekly snapshot). + * 计算当前自然周的起止日期(UTC)。 + */ +function getCurrentWeekRange(): { weekStart: Date; weekEnd: Date } { + const now = new Date(); + const targetDay = LEADERBOARD_RULES.weekStartsOnIsoDay; + const currentDay = now.getUTCDay() || 7; + const diff = (currentDay - targetDay + 7) % 7; + const start = new Date(now); + start.setUTCDate(now.getUTCDate() - diff); + start.setUTCHours(0, 0, 0, 0); + const end = new Date(start); + end.setUTCDate(start.getUTCDate() + 6); + return { weekStart: start, weekEnd: end }; +} + +/** + * 获取当前周排行榜,按本周 XP 排名。 + * 数据源从 userWeeklyXp 表读取,不再按 users.xpTotal 排序。 */ export async function getLeaderboard(tier?: string, page = 1, limit = 20): Promise<{ items: LeaderboardEntry[]; pagination: { total: number; page: number; limit: number }; }> { + const { weekStart } = getCurrentWeekRange(); const offset = (page - 1) * limit; - // Simpler approach: rank all users by xp_total - const allUsers = await db + // 从 userWeeklyXp 查询本周 XP,JOIN users 获取昵称和头像。 + const weekStartStr = weekStart.toISOString().slice(0, 10); + const allEntries = await db .select({ - id: users.id, + userId: userWeeklyXp.userId, + weeklyXp: userWeeklyXp.xpEarned, nickname: users.nickname, avatarUrl: users.avatarUrl, - xpTotal: users.xpTotal, }) - .from(users) - .orderBy(desc(users.xpTotal)) + .from(userWeeklyXp) + .innerJoin(users, eq(userWeeklyXp.userId, users.id)) + .where(sql`CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr}`) + .orderBy(desc(userWeeklyXp.xpEarned)) .limit(1000); - // Filter by tier if specified (tier is determined by rank ranges) - let filtered = allUsers; + // 按段位过滤(段位由排名决定)。 + let filtered = allEntries; 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 perTier = Math.ceil(allEntries.length / TIERS.length); const start = tierIndex * perTier; - filtered = allUsers.slice(start, start + perTier); + filtered = allEntries.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, + const items: LeaderboardEntry[] = filtered.slice(offset, offset + limit).map((entry, i) => ({ + userId: entry.userId, + nickname: entry.nickname ?? null, + avatarUrl: entry.avatarUrl ?? null, + weeklyXp: entry.weeklyXp ?? 0, rank: offset + i + 1, tier: getTierForRank(offset + i + 1), })); @@ -63,64 +83,65 @@ export async function getLeaderboard(tier?: string, page = 1, limit = 20): Promi } /** - * Get a specific user's rank and tier. + * 获取用户在本周排行榜中的排名。 + * 从 userWeeklyXp 查询本周 XP,统计高于自己的用户数量。 */ -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)) +export async function getUserRank(userId: string): Promise<{ rank: number; tier: string; weeklyXp: number } | null> { + const { weekStart } = getCurrentWeekRange(); + + // 获取用户本周 XP。 + const weekStartStr = weekStart.toISOString().slice(0, 10); + const [userRow] = await db + .select({ xpEarned: userWeeklyXp.xpEarned }) + .from(userWeeklyXp) + .where(sql`${userWeeklyXp.userId} = ${userId} AND CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr}`) .limit(1); - if (!user) return null; + const userXp = userRow?.xpEarned ?? 0; + // 统计本周 XP 比自己高的用户数。 const [higher] = await db .select({ count: sql`COUNT(*)` }) - .from(users) - .where(sql`COALESCE(xp_total, 0) > ${user.xpTotal ?? 0}`); + .from(userWeeklyXp) + .where(sql`CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr} AND COALESCE(${userWeeklyXp.xpEarned}, 0) > ${userXp}`); const rank = Number(higher?.count ?? 0) + 1; - return { rank, tier: getTierForRank(rank) }; + return { rank, tier: getTierForRank(rank), weeklyXp: userXp }; } /** - * Run weekly settlement: promote/demote users based on weekly XP. - * Should be called via a scheduled job (cron). + * 运行周结算:记录本周排行榜快照。 + * 应通过定时任务在每周一调用。 */ export async function weeklySettlement(): Promise { - 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 { weekStart, weekEnd } = getCurrentWeekRange(); + // 从 userWeeklyXp 取本周排名。 const weekStartStr = weekStart.toISOString().slice(0, 10); const weekEndStr = weekEnd.toISOString().slice(0, 10); - - // Get all users ordered by XP - const allUsers = await db + const allEntries = await db .select({ - id: users.id, - xpTotal: users.xpTotal, + userId: userWeeklyXp.userId, + weeklyXp: userWeeklyXp.xpEarned, }) - .from(users) - .orderBy(desc(users.xpTotal)); + .from(userWeeklyXp) + .where(sql`CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr}`) + .orderBy(desc(userWeeklyXp.xpEarned)); - const perTier = Math.max(1, Math.ceil(allUsers.length / TIERS.length)); + const perTier = Math.max(1, Math.ceil(allEntries.length / TIERS.length)); - // Create leaderboard snapshots - for (let i = 0; i < allUsers.length; i++) { - const user = allUsers[i]!; + // 为每个用户创建排行榜快照。 + for (let i = 0; i < allEntries.length; i++) { + const entry = allEntries[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, + userId: entry.userId, tier, - weeklyXp: user.xpTotal ?? 0, + weeklyXp: entry.weeklyXp ?? 0, rank, league: `${tier}-${Math.ceil(rank / perTier)}`, weekStart: sql`CAST(${weekStartStr} AS DATE)`, @@ -130,7 +151,6 @@ export async function weeklySettlement(): Promise { } 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'; diff --git a/src/services/learning/leaderboard-api-service.ts b/src/services/learning/leaderboard-api-service.ts index a88c44f..0280b68 100644 --- a/src/services/learning/leaderboard-api-service.ts +++ b/src/services/learning/leaderboard-api-service.ts @@ -25,7 +25,7 @@ export async function getClientLeaderboard( userId: entry.userId, displayName: entry.userId === userId ? '你' : (entry.nickname ?? '知识探险家'), avatarUrl: entry.avatarUrl, - xp: entry.xpTotal, + xp: entry.weeklyXp, badge: getBadge(entry.rank), isMe: entry.userId === userId, })), @@ -38,26 +38,24 @@ export async function getClientLeaderboardMe( _scope: LeaderboardScope, _trackId: string | undefined, ): Promise { - const [rank, user] = await Promise.all([ - getUserRank(userId), - db - .select({ - nickname: users.nickname, - avatarUrl: users.avatarUrl, - xpTotal: users.xpTotal, - }) - .from(users) - .where(eq(users.id, userId)) - .limit(1), - ]); + const rank = await getUserRank(userId); if (!rank) return null; + const [user] = await db + .select({ + nickname: users.nickname, + avatarUrl: users.avatarUrl, + }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + return { rank: rank.rank, userId, - displayName: user[0]?.nickname ?? '你', - avatarUrl: user[0]?.avatarUrl ?? null, - xp: user[0]?.xpTotal ?? 0, + displayName: user?.nickname ?? '你', + avatarUrl: user?.avatarUrl ?? null, + xp: rank.weeklyXp, badge: getBadge(rank.rank), isMe: true, }; diff --git a/src/services/progress/xp-service.ts b/src/services/progress/xp-service.ts index a480c14..a4b7bb7 100644 --- a/src/services/progress/xp-service.ts +++ b/src/services/progress/xp-service.ts @@ -1,7 +1,8 @@ import { db } from '../../db/client.js'; -import { users } from '../../db/schema.js'; +import { users, userWeeklyXp } from '../../db/schema.js'; import { eq, sql } from 'drizzle-orm'; -import { XP_RULES } from '../gamification/rules.js'; +import { v4 as uuid } from 'uuid'; +import { LEADERBOARD_RULES, XP_RULES } from '../gamification/rules.js'; const BASE_XP = XP_RULES.correctNormal; const DEFAULT_DAILY_GOAL = 50; @@ -143,14 +144,59 @@ export function createCorrectAnswerXpRewards( return rewards; } +/** + * 计算当前自然周的起止日期。 + * 按 LEADERBOARD_RULES.weekStartsOnIsoDay 配置的周起始日(1=周一)。 + */ +function getCurrentWeekRange(): { weekStart: Date; weekEnd: Date } { + const now = new Date(); + // ISO 周起始日:1=周一,配置在 LEADERBOARD_RULES.weekStartsOnIsoDay + const targetDay = LEADERBOARD_RULES.weekStartsOnIsoDay; + const currentDay = now.getUTCDay() || 7; // 0(周日) → 7 + const diff = (currentDay - targetDay + 7) % 7; + const start = new Date(now); + start.setUTCDate(now.getUTCDate() - diff); + start.setUTCHours(0, 0, 0, 0); + const end = new Date(start); + end.setUTCDate(start.getUTCDate() + 6); + return { weekStart: start, weekEnd: end }; +} + +/** + * 累加用户本周 XP 统计。 + * 使用 INSERT ... ON DUPLICATE KEY UPDATE 实现幂等累加, + * 确保每周每个用户只有一行记录。 + */ +async function addToWeeklyXp(userId: string, amount: number): Promise { + const { weekStart, weekEnd } = getCurrentWeekRange(); + await db + .insert(userWeeklyXp) + .values({ + id: uuid(), + userId, + weekStart, + weekEnd, + xpEarned: amount, + lastXpAt: sql`NOW()`, + }) + // uk_weekly_xp_user_week 唯一索引保证幂等:同用户同周只更新不重复插入。 + .onDuplicateKeyUpdate({ + set: { + xpEarned: sql`COALESCE(xp_earned, 0) + ${amount}`, + lastXpAt: sql`NOW()`, + }, + }); +} + /** * Add XP to a user. Handles daily XP reset if the date has changed. * Uses atomic SQL update to prevent race conditions. + * 同时累加本周 XP 统计到 userWeeklyXp 表。 */ export async function addXp(userId: string, amount: number): Promise { const today = new Date().toISOString().slice(0, 10); - // Atomically update total XP and handle daily reset + // 原子更新累计 XP 和每日 XP await db .update(users) .set({ @@ -163,6 +209,9 @@ export async function addXp(userId: string, amount: number): Promise { dailyXpDate: sql`CAST(${today} AS DATE)`, }) .where(eq(users.id, userId)); + + // 同步累加本周 XP 统计,供排行榜查询。 + await addToWeeklyXp(userId, amount); } /**