实现排行榜 20-30 人分组

- 用户本周首次获得 XP 时自动分配到 20-30 人榜组
- 分组策略:查找未满组加入,否则创建新组
- 组 ID 格式 week-{date}-group-{序号},方便调试
- 排行榜查询和我的排名改为组内排名
- getLeaderboard 新增 userId 参数获取用户所在组
This commit is contained in:
Wang Zhuoxuan 2026-05-13 21:30:08 +08:00
parent 08461485d5
commit 66112c30f8
5 changed files with 87 additions and 30 deletions

View File

@ -109,7 +109,7 @@
|---|------|------|----------| |---|------|------|----------|
| G5-1 | 改造排行榜数据源为本周 XP | [x] | 排行榜不再按累计 XP 排名,展示本周 XP | | G5-1 | 改造排行榜数据源为本周 XP | [x] | 排行榜不再按累计 XP 排名,展示本周 XP |
| G5-2 | 实现每周一刷新逻辑 | [x] | 自然周边界清晰UTC/本地时区策略写入代码注释和文档 | | G5-2 | 实现每周一刷新逻辑 | [x] | 自然周边界清晰UTC/本地时区策略写入代码注释和文档 |
| G5-3 | 实现 20-30 人分组 | [ ] | 每个用户进入稳定榜组,分页和我的排名基于组内排名 | | G5-3 | 实现 20-30 人分组 | [x] | 每个用户进入稳定榜组,分页和我的排名基于组内排名 |
| G5-4 | 实现前三奖励结算 | [ ] | 周结算给前 3 名发金币、徽章碎片或头像框奖励,幂等执行 | | G5-4 | 实现前三奖励结算 | [ ] | 周结算给前 3 名发金币、徽章碎片或头像框奖励,幂等执行 |
| G5-5 | 暴露周榜元信息 | [ ] | API 返回 weekStart、weekEnd、nextRefreshAt、groupId、rank、rewardPreview | | G5-5 | 暴露周榜元信息 | [ ] | API 返回 weekStart、weekEnd、nextRefreshAt、groupId、rank、rewardPreview |
| G5-6 | 添加排行榜测试 | [ ] | 覆盖周 XP 累加、分组、我的排名、周结算、重复结算 | | G5-6 | 添加排行榜测试 | [ ] | 覆盖周 XP 累加、分组、我的排名、周结算、重复结算 |

View File

@ -4,8 +4,9 @@ import { getAchievements, checkAchievements } from '../services/gamification/ach
export async function gamificationRoutes(app: FastifyInstance): Promise<void> { export async function gamificationRoutes(app: FastifyInstance): Promise<void> {
app.get('/leaderboard', async (request) => { app.get('/leaderboard', async (request) => {
const userId = (request.user as { userId: string }).userId;
const { tier, page = '1', limit = '20' } = request.query as Record<string, string>; const { tier, page = '1', limit = '20' } = request.query as Record<string, string>;
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 }; return { success: true, data: data.items, pagination: data.pagination, error: null };
}); });

View File

@ -51,18 +51,37 @@ function getPreviousWeekRange(): { weekStart: Date; weekEnd: Date } {
} }
/** /**
* XP * ID
* userWeeklyXp users.xpTotal
*/ */
export async function getLeaderboard(tier?: string, page = 1, limit = 20): Promise<{ async function getUserGroupId(userId: string, weekStartStr: string): Promise<string | null> {
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[]; items: LeaderboardEntry[];
pagination: { total: number; page: number; limit: number }; pagination: { total: number; page: number; limit: number };
}> { }> {
const { weekStart } = getCurrentWeekRange(); const { weekStart } = getCurrentWeekRange();
const weekStartStr = weekStart.toISOString().slice(0, 10);
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
// 从 userWeeklyXp 查询本周 XPJOIN 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 const allEntries = await db
.select({ .select({
userId: userWeeklyXp.userId, userId: userWeeklyXp.userId,
@ -72,23 +91,12 @@ export async function getLeaderboard(tier?: string, page = 1, limit = 20): Promi
}) })
.from(userWeeklyXp) .from(userWeeklyXp)
.innerJoin(users, eq(userWeeklyXp.userId, users.id)) .innerJoin(users, eq(userWeeklyXp.userId, users.id))
.where(sql`CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr}`) .where(groupFilter)
.orderBy(desc(userWeeklyXp.xpEarned)) .orderBy(desc(userWeeklyXp.xpEarned))
.limit(1000); .limit(1000);
// 按段位过滤(段位由排名决定)。 const total = allEntries.length;
let filtered = allEntries; const items: LeaderboardEntry[] = allEntries.slice(offset, offset + limit).map((entry, i) => ({
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) => ({
userId: entry.userId, userId: entry.userId,
nickname: entry.nickname ?? null, nickname: entry.nickname ?? null,
avatarUrl: entry.avatarUrl ?? 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> { export async function getUserRank(userId: string): Promise<{ rank: number; tier: string; weeklyXp: number } | null> {
const { weekStart } = getCurrentWeekRange(); const { weekStart } = getCurrentWeekRange();
// 获取用户本周 XP。
const weekStartStr = weekStart.toISOString().slice(0, 10); const weekStartStr = weekStart.toISOString().slice(0, 10);
// 获取用户本周 XP 和所在分组。
const [userRow] = await db const [userRow] = await db
.select({ xpEarned: userWeeklyXp.xpEarned }) .select({ xpEarned: userWeeklyXp.xpEarned, groupId: userWeeklyXp.groupId })
.from(userWeeklyXp) .from(userWeeklyXp)
.where(sql`${userWeeklyXp.userId} = ${userId} AND CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr}`) .where(sql`${userWeeklyXp.userId} = ${userId} AND CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr}`)
.limit(1); .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 const [higher] = await db
.select({ count: sql<number>`COUNT(*)` }) .select({ count: sql<number>`COUNT(*)` })
.from(userWeeklyXp) .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; const rank = Number(higher?.count ?? 0) + 1;
return { rank, tier: getTierForRank(rank), weeklyXp: userXp }; return { rank, tier: getTierForRank(rank), weeklyXp: userXp };

View File

@ -18,7 +18,7 @@ export async function getClientLeaderboard(
page: number, page: number,
limit: number, limit: number,
): Promise<{ items: LeaderboardEntryDto[]; pagination: { total: number; 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 { return {
items: data.items.map((entry) => ({ items: data.items.map((entry) => ({
rank: entry.rank, rank: entry.rank,

View File

@ -162,13 +162,55 @@ function getCurrentWeekRange(): { weekStart: Date; weekEnd: Date } {
return { weekStart: start, weekEnd: end }; return { weekStart: start, weekEnd: end };
} }
/**
* ID
* < groupSizeMax
* ID week-{weekStart}-group-{}便
*/
async function assignGroupId(weekStartStr: string): Promise<string> {
// 查找本周各组的当前人数。
const groupCounts = await db
.select({
groupId: userWeeklyXp.groupId,
count: sql<number>`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 * XP
* 使 INSERT ... ON DUPLICATE KEY UPDATE * 使 INSERT ... ON DUPLICATE KEY UPDATE
* *
* XP 20-30
*/ */
async function addToWeeklyXp(userId: string, amount: number): Promise<void> { async function addToWeeklyXp(userId: string, amount: number): Promise<void> {
const { weekStart, weekEnd } = getCurrentWeekRange(); 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 await db
.insert(userWeeklyXp) .insert(userWeeklyXp)
.values({ .values({
@ -177,6 +219,7 @@ async function addToWeeklyXp(userId: string, amount: number): Promise<void> {
weekStart, weekStart,
weekEnd, weekEnd,
xpEarned: amount, xpEarned: amount,
groupId,
lastXpAt: sql`NOW()`, lastXpAt: sql`NOW()`,
}) })
// uk_weekly_xp_user_week 唯一索引保证幂等:同用户同周只更新不重复插入。 // uk_weekly_xp_user_week 唯一索引保证幂等:同用户同周只更新不重复插入。