diff --git a/docs/gamification-server-plan.md b/docs/gamification-server-plan.md index 74101c2..d89f3a2 100644 --- a/docs/gamification-server-plan.md +++ b/docs/gamification-server-plan.md @@ -109,7 +109,7 @@ |---|------|------|----------| | G5-1 | 改造排行榜数据源为本周 XP | [x] | 排行榜不再按累计 XP 排名,展示本周 XP | | G5-2 | 实现每周一刷新逻辑 | [x] | 自然周边界清晰,UTC/本地时区策略写入代码注释和文档 | -| G5-3 | 实现 20-30 人分组 | [ ] | 每个用户进入稳定榜组,分页和我的排名基于组内排名 | +| G5-3 | 实现 20-30 人分组 | [x] | 每个用户进入稳定榜组,分页和我的排名基于组内排名 | | G5-4 | 实现前三奖励结算 | [ ] | 周结算给前 3 名发金币、徽章碎片或头像框奖励,幂等执行 | | G5-5 | 暴露周榜元信息 | [ ] | API 返回 weekStart、weekEnd、nextRefreshAt、groupId、rank、rewardPreview | | G5-6 | 添加排行榜测试 | [ ] | 覆盖周 XP 累加、分组、我的排名、周结算、重复结算 | diff --git a/src/routes/gamification.ts b/src/routes/gamification.ts index e42c8a3..4afb803 100644 --- a/src/routes/gamification.ts +++ b/src/routes/gamification.ts @@ -4,8 +4,9 @@ import { getAchievements, checkAchievements } from '../services/gamification/ach export async function gamificationRoutes(app: FastifyInstance): Promise { app.get('/leaderboard', async (request) => { + const userId = (request.user as { userId: string }).userId; const { tier, page = '1', limit = '20' } = request.query as Record; - const data = await getLeaderboard(tier, Number(page), Number(limit)); + const data = await getLeaderboard(userId, tier, Number(page), Number(limit)); return { success: true, data: data.items, pagination: data.pagination, error: null }; }); diff --git a/src/services/gamification/leaderboard-service.ts b/src/services/gamification/leaderboard-service.ts index b3cba27..5a06ec2 100644 --- a/src/services/gamification/leaderboard-service.ts +++ b/src/services/gamification/leaderboard-service.ts @@ -51,18 +51,37 @@ function getPreviousWeekRange(): { weekStart: Date; weekEnd: Date } { } /** - * 获取当前周排行榜,按本周 XP 排名。 - * 数据源从 userWeeklyXp 表读取,不再按 users.xpTotal 排序。 + * 获取用户当前周所在的分组 ID。 */ -export async function getLeaderboard(tier?: string, page = 1, limit = 20): Promise<{ +async function getUserGroupId(userId: string, weekStartStr: string): Promise { + const [row] = await db + .select({ groupId: userWeeklyXp.groupId }) + .from(userWeeklyXp) + .where(sql`${userWeeklyXp.userId} = ${userId} AND CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr}`) + .limit(1); + return row?.groupId ?? null; +} + +/** + * 获取当前周排行榜,按用户所在组内本周 XP 排名。 + * 每个用户只能看到自己组内的 20-30 人排名。 + */ +export async function getLeaderboard(userId: string, _tier?: string, page = 1, limit = 20): Promise<{ items: LeaderboardEntry[]; pagination: { total: number; page: number; limit: number }; }> { const { weekStart } = getCurrentWeekRange(); + const weekStartStr = weekStart.toISOString().slice(0, 10); const offset = (page - 1) * limit; - // 从 userWeeklyXp 查询本周 XP,JOIN users 获取昵称和头像。 - const weekStartStr = weekStart.toISOString().slice(0, 10); + // 获取用户所在分组。 + const groupId = await getUserGroupId(userId, weekStartStr); + + // 构建查询条件:基于组内排名。 + const groupFilter = groupId + ? sql`CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr} AND ${userWeeklyXp.groupId} = ${groupId}` + : sql`CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr}`; + const allEntries = await db .select({ userId: userWeeklyXp.userId, @@ -72,23 +91,12 @@ export async function getLeaderboard(tier?: string, page = 1, limit = 20): Promi }) .from(userWeeklyXp) .innerJoin(users, eq(userWeeklyXp.userId, users.id)) - .where(sql`CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr}`) + .where(groupFilter) .orderBy(desc(userWeeklyXp.xpEarned)) .limit(1000); - // 按段位过滤(段位由排名决定)。 - let filtered = allEntries; - if (tier) { - const tierIndex = TIERS.indexOf(tier as Tier); - if (tierIndex >= 0) { - const perTier = Math.ceil(allEntries.length / TIERS.length); - const start = tierIndex * perTier; - filtered = allEntries.slice(start, start + perTier); - } - } - - const total = filtered.length; - const items: LeaderboardEntry[] = filtered.slice(offset, offset + limit).map((entry, i) => ({ + const total = allEntries.length; + const items: LeaderboardEntry[] = allEntries.slice(offset, offset + limit).map((entry, i) => ({ userId: entry.userId, nickname: entry.nickname ?? null, avatarUrl: entry.avatarUrl ?? null, @@ -101,27 +109,32 @@ export async function getLeaderboard(tier?: string, page = 1, limit = 20): Promi } /** - * 获取用户在本周排行榜中的排名。 - * 从 userWeeklyXp 查询本周 XP,统计高于自己的用户数量。 + * 获取用户在本周排行榜中的组内排名。 + * 统计同组内本周 XP 比自己高的用户数量,得出组内排名。 */ 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); + + // 获取用户本周 XP 和所在分组。 const [userRow] = await db - .select({ xpEarned: userWeeklyXp.xpEarned }) + .select({ xpEarned: userWeeklyXp.xpEarned, groupId: userWeeklyXp.groupId }) .from(userWeeklyXp) .where(sql`${userWeeklyXp.userId} = ${userId} AND CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr}`) .limit(1); - const userXp = userRow?.xpEarned ?? 0; + if (!userRow) return null; + const userXp = userRow.xpEarned ?? 0; + + // 统计同组内本周 XP 比自己高的用户数。 + const groupFilter = userRow.groupId + ? sql`CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr} AND ${userWeeklyXp.groupId} = ${userRow.groupId}` + : sql`CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr}`; - // 统计本周 XP 比自己高的用户数。 const [higher] = await db .select({ count: sql`COUNT(*)` }) .from(userWeeklyXp) - .where(sql`CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr} AND COALESCE(${userWeeklyXp.xpEarned}, 0) > ${userXp}`); + .where(sql`${groupFilter} AND COALESCE(${userWeeklyXp.xpEarned}, 0) > ${userXp}`); const rank = Number(higher?.count ?? 0) + 1; return { rank, tier: getTierForRank(rank), weeklyXp: userXp }; diff --git a/src/services/learning/leaderboard-api-service.ts b/src/services/learning/leaderboard-api-service.ts index 0280b68..7113c68 100644 --- a/src/services/learning/leaderboard-api-service.ts +++ b/src/services/learning/leaderboard-api-service.ts @@ -18,7 +18,7 @@ export async function getClientLeaderboard( page: number, limit: number, ): Promise<{ items: LeaderboardEntryDto[]; pagination: { total: number; page: number; limit: number } }> { - const data = await getLeaderboard(undefined, page, limit); + const data = await getLeaderboard(userId, undefined, page, limit); return { items: data.items.map((entry) => ({ rank: entry.rank, diff --git a/src/services/progress/xp-service.ts b/src/services/progress/xp-service.ts index a4b7bb7..4de910e 100644 --- a/src/services/progress/xp-service.ts +++ b/src/services/progress/xp-service.ts @@ -162,13 +162,55 @@ function getCurrentWeekRange(): { weekStart: Date; weekEnd: Date } { return { weekStart: start, weekEnd: end }; } +/** + * 为用户分配一个周榜分组 ID。 + * 策略:查找本周未满的组(人数 < groupSizeMax),有则加入,否则创建新组。 + * 组 ID 格式:week-{weekStart}-group-{序号},方便调试和排序。 + */ +async function assignGroupId(weekStartStr: string): Promise { + // 查找本周各组的当前人数。 + const groupCounts = await db + .select({ + groupId: userWeeklyXp.groupId, + count: sql`COUNT(*)`, + }) + .from(userWeeklyXp) + .where(sql`CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr} AND ${userWeeklyXp.groupId} IS NOT NULL`) + .groupBy(userWeeklyXp.groupId) + .orderBy(userWeeklyXp.groupId); + + // 找一个未满的组。 + for (const row of groupCounts) { + if (row.groupId && Number(row.count) < LEADERBOARD_RULES.groupSizeMax) { + return row.groupId; + } + } + + // 没有未满的组,创建新组。序号 = 当前组数 + 1。 + const groupIndex = groupCounts.length + 1; + return `week-${weekStartStr}-group-${groupIndex}`; +} + /** * 累加用户本周 XP 统计。 * 使用 INSERT ... ON DUPLICATE KEY UPDATE 实现幂等累加, * 确保每周每个用户只有一行记录。 + * 首次获得本周 XP 时自动分配到 20-30 人的排行榜分组。 */ async function addToWeeklyXp(userId: string, amount: number): Promise { const { weekStart, weekEnd } = getCurrentWeekRange(); + const weekStartStr = weekStart.toISOString().slice(0, 10); + + // 检查用户是否已有本周记录(决定是否需要分配组)。 + const [existing] = await db + .select({ groupId: userWeeklyXp.groupId }) + .from(userWeeklyXp) + .where(sql`${userWeeklyXp.userId} = ${userId} AND CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr}`) + .limit(1); + + // 首次获得本周 XP 时分配组;已有记录则保持原组。 + const groupId = existing?.groupId ?? await assignGroupId(weekStartStr); + await db .insert(userWeeklyXp) .values({ @@ -177,6 +219,7 @@ async function addToWeeklyXp(userId: string, amount: number): Promise { weekStart, weekEnd, xpEarned: amount, + groupId, lastXpAt: sql`NOW()`, }) // uk_weekly_xp_user_week 唯一索引保证幂等:同用户同周只更新不重复插入。