实现排行榜 20-30 人分组
- 用户本周首次获得 XP 时自动分配到 20-30 人榜组
- 分组策略:查找未满组加入,否则创建新组
- 组 ID 格式 week-{date}-group-{序号},方便调试
- 排行榜查询和我的排名改为组内排名
- getLeaderboard 新增 userId 参数获取用户所在组
This commit is contained in:
parent
08461485d5
commit
66112c30f8
@ -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 累加、分组、我的排名、周结算、重复结算 |
|
||||||
|
|||||||
@ -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 };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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 查询本周 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
|
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 };
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 唯一索引保证幂等:同用户同周只更新不重复插入。
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user