改造排行榜数据源为本周 XP

- addXp() 每次获得 XP 时同步累加 userWeeklyXp 表的本周统计
- 使用 INSERT ON DUPLICATE KEY UPDATE 实现幂等周 XP 累加
- leaderboard-service 从 userWeeklyXp 查询本周 XP 排名替代累计 XP
- leaderboard-api-service DTO 中 xp 字段改为展示本周 XP
- weeklySettlement() 基于 userWeeklyXp 生成周快照
This commit is contained in:
Wang Zhuoxuan 2026-05-13 21:00:48 +08:00
parent eee2116633
commit d7d5f8109c
4 changed files with 140 additions and 73 deletions

View File

@ -107,7 +107,7 @@
| # | 任务 | 状态 | 验收标准 | | # | 任务 | 状态 | 验收标准 |
|---|------|------|----------| |---|------|------|----------|
| G5-1 | 改造排行榜数据源为本周 XP | [ ] | 排行榜不再按累计 XP 排名,展示本周 XP | | G5-1 | 改造排行榜数据源为本周 XP | [x] | 排行榜不再按累计 XP 排名,展示本周 XP |
| G5-2 | 实现每周一刷新逻辑 | [ ] | 自然周边界清晰UTC/本地时区策略写入代码注释和文档 | | G5-2 | 实现每周一刷新逻辑 | [ ] | 自然周边界清晰UTC/本地时区策略写入代码注释和文档 |
| G5-3 | 实现 20-30 人分组 | [ ] | 每个用户进入稳定榜组,分页和我的排名基于组内排名 | | G5-3 | 实现 20-30 人分组 | [ ] | 每个用户进入稳定榜组,分页和我的排名基于组内排名 |
| G5-4 | 实现前三奖励结算 | [ ] | 周结算给前 3 名发金币、徽章碎片或头像框奖励,幂等执行 | | G5-4 | 实现前三奖励结算 | [ ] | 周结算给前 3 名发金币、徽章碎片或头像框奖励,幂等执行 |

View File

@ -1,7 +1,8 @@
import { db } from '../../db/client.js'; import { db } from '../../db/client.js';
import { users, leaderboardSnapshots } from '../../db/schema.js'; import { leaderboardSnapshots, userWeeklyXp, users } from '../../db/schema.js';
import { eq, desc, sql } from 'drizzle-orm'; import { desc, eq, sql } from 'drizzle-orm';
import { v4 as uuid } from 'uuid'; 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; const TIERS = ['bronze', 'silver', 'gold', 'platinum', 'diamond', 'master', 'grandmaster', 'champion', 'legend', 'mythic'] as const;
export type Tier = typeof TIERS[number]; export type Tier = typeof TIERS[number];
@ -10,51 +11,70 @@ export interface LeaderboardEntry {
userId: string; userId: string;
nickname: string | null; nickname: string | null;
avatarUrl: string | null; avatarUrl: string | null;
xpTotal: number; weeklyXp: number;
rank: number; rank: number;
tier: string; tier: string;
} }
/** /**
* Get the current leaderboard, optionally filtered by tier. * UTC
* Uses live xp_total ranking (not weekly snapshot). */
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<{ export async function getLeaderboard(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 offset = (page - 1) * limit; const offset = (page - 1) * limit;
// Simpler approach: rank all users by xp_total // 从 userWeeklyXp 查询本周 XPJOIN users 获取昵称和头像。
const allUsers = await db const weekStartStr = weekStart.toISOString().slice(0, 10);
const allEntries = await db
.select({ .select({
id: users.id, userId: userWeeklyXp.userId,
weeklyXp: userWeeklyXp.xpEarned,
nickname: users.nickname, nickname: users.nickname,
avatarUrl: users.avatarUrl, avatarUrl: users.avatarUrl,
xpTotal: users.xpTotal,
}) })
.from(users) .from(userWeeklyXp)
.orderBy(desc(users.xpTotal)) .innerJoin(users, eq(userWeeklyXp.userId, users.id))
.where(sql`CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr}`)
.orderBy(desc(userWeeklyXp.xpEarned))
.limit(1000); .limit(1000);
// Filter by tier if specified (tier is determined by rank ranges) // 按段位过滤(段位由排名决定)。
let filtered = allUsers; let filtered = allEntries;
if (tier) { if (tier) {
// Each tier covers ~10% of players, roughly 100 per tier for top 1000
const tierIndex = TIERS.indexOf(tier as Tier); const tierIndex = TIERS.indexOf(tier as Tier);
if (tierIndex >= 0) { if (tierIndex >= 0) {
const perTier = Math.ceil(allUsers.length / TIERS.length); const perTier = Math.ceil(allEntries.length / TIERS.length);
const start = tierIndex * perTier; const start = tierIndex * perTier;
filtered = allUsers.slice(start, start + perTier); filtered = allEntries.slice(start, start + perTier);
} }
} }
const total = filtered.length; const total = filtered.length;
const items: LeaderboardEntry[] = filtered.slice(offset, offset + limit).map((u, i) => ({ const items: LeaderboardEntry[] = filtered.slice(offset, offset + limit).map((entry, i) => ({
userId: u.id, userId: entry.userId,
nickname: u.nickname ?? null, nickname: entry.nickname ?? null,
avatarUrl: u.avatarUrl ?? null, avatarUrl: entry.avatarUrl ?? null,
xpTotal: u.xpTotal ?? 0, weeklyXp: entry.weeklyXp ?? 0,
rank: offset + i + 1, rank: offset + i + 1,
tier: getTierForRank(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> { export async function getUserRank(userId: string): Promise<{ rank: number; tier: string; weeklyXp: number } | null> {
// Count users with higher XP const { weekStart } = getCurrentWeekRange();
const [user] = await db
.select({ xpTotal: users.xpTotal }) // 获取用户本周 XP。
.from(users) const weekStartStr = weekStart.toISOString().slice(0, 10);
.where(eq(users.id, userId)) const [userRow] = await db
.select({ xpEarned: userWeeklyXp.xpEarned })
.from(userWeeklyXp)
.where(sql`${userWeeklyXp.userId} = ${userId} AND CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr}`)
.limit(1); .limit(1);
if (!user) return null; const userXp = userRow?.xpEarned ?? 0;
// 统计本周 XP 比自己高的用户数。
const [higher] = await db const [higher] = await db
.select({ count: sql<number>`COUNT(*)` }) .select({ count: sql<number>`COUNT(*)` })
.from(users) .from(userWeeklyXp)
.where(sql`COALESCE(xp_total, 0) > ${user.xpTotal ?? 0}`); .where(sql`CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr} 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) }; 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<void> { export async function weeklySettlement(): Promise<void> {
const today = new Date(); const { weekStart, weekEnd } = getCurrentWeekRange();
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);
// 从 userWeeklyXp 取本周排名。
const weekStartStr = weekStart.toISOString().slice(0, 10); const weekStartStr = weekStart.toISOString().slice(0, 10);
const weekEndStr = weekEnd.toISOString().slice(0, 10); const weekEndStr = weekEnd.toISOString().slice(0, 10);
const allEntries = await db
// Get all users ordered by XP
const allUsers = await db
.select({ .select({
id: users.id, userId: userWeeklyXp.userId,
xpTotal: users.xpTotal, weeklyXp: userWeeklyXp.xpEarned,
}) })
.from(users) .from(userWeeklyXp)
.orderBy(desc(users.xpTotal)); .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++) { for (let i = 0; i < allEntries.length; i++) {
const user = allUsers[i]!; const entry = allEntries[i]!;
const rank = i + 1; const rank = i + 1;
const tierIndex = Math.min(Math.floor(rank / perTier), TIERS.length - 1); const tierIndex = Math.min(Math.floor(rank / perTier), TIERS.length - 1);
const tier = TIERS[tierIndex]!; const tier = TIERS[tierIndex]!;
await db.insert(leaderboardSnapshots).values({ await db.insert(leaderboardSnapshots).values({
id: uuid(), id: uuid(),
userId: user.id, userId: entry.userId,
tier, tier,
weeklyXp: user.xpTotal ?? 0, weeklyXp: entry.weeklyXp ?? 0,
rank, rank,
league: `${tier}-${Math.ceil(rank / perTier)}`, league: `${tier}-${Math.ceil(rank / perTier)}`,
weekStart: sql`CAST(${weekStartStr} AS DATE)`, weekStart: sql`CAST(${weekStartStr} AS DATE)`,
@ -130,7 +151,6 @@ export async function weeklySettlement(): Promise<void> {
} }
function getTierForRank(rank: number): string { function getTierForRank(rank: number): string {
// Equal distribution across 10 tiers
if (rank <= 10) return 'mythic'; if (rank <= 10) return 'mythic';
if (rank <= 30) return 'legend'; if (rank <= 30) return 'legend';
if (rank <= 60) return 'champion'; if (rank <= 60) return 'champion';

View File

@ -25,7 +25,7 @@ export async function getClientLeaderboard(
userId: entry.userId, userId: entry.userId,
displayName: entry.userId === userId ? '你' : (entry.nickname ?? '知识探险家'), displayName: entry.userId === userId ? '你' : (entry.nickname ?? '知识探险家'),
avatarUrl: entry.avatarUrl, avatarUrl: entry.avatarUrl,
xp: entry.xpTotal, xp: entry.weeklyXp,
badge: getBadge(entry.rank), badge: getBadge(entry.rank),
isMe: entry.userId === userId, isMe: entry.userId === userId,
})), })),
@ -38,26 +38,24 @@ export async function getClientLeaderboardMe(
_scope: LeaderboardScope, _scope: LeaderboardScope,
_trackId: string | undefined, _trackId: string | undefined,
): Promise<LeaderboardEntryDto | null> { ): Promise<LeaderboardEntryDto | null> {
const [rank, user] = await Promise.all([ const rank = await getUserRank(userId);
getUserRank(userId),
db
.select({
nickname: users.nickname,
avatarUrl: users.avatarUrl,
xpTotal: users.xpTotal,
})
.from(users)
.where(eq(users.id, userId))
.limit(1),
]);
if (!rank) return null; 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 { return {
rank: rank.rank, rank: rank.rank,
userId, userId,
displayName: user[0]?.nickname ?? '你', displayName: user?.nickname ?? '你',
avatarUrl: user[0]?.avatarUrl ?? null, avatarUrl: user?.avatarUrl ?? null,
xp: user[0]?.xpTotal ?? 0, xp: rank.weeklyXp,
badge: getBadge(rank.rank), badge: getBadge(rank.rank),
isMe: true, isMe: true,
}; };

View File

@ -1,7 +1,8 @@
import { db } from '../../db/client.js'; 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 { 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 BASE_XP = XP_RULES.correctNormal;
const DEFAULT_DAILY_GOAL = 50; const DEFAULT_DAILY_GOAL = 50;
@ -143,14 +144,59 @@ export function createCorrectAnswerXpRewards(
return rewards; 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<void> {
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. * Add XP to a user. Handles daily XP reset if the date has changed.
* Uses atomic SQL update to prevent race conditions. * Uses atomic SQL update to prevent race conditions.
* XP userWeeklyXp
*/ */
export async function addXp(userId: string, amount: number): Promise<void> { export async function addXp(userId: string, amount: number): Promise<void> {
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
// Atomically update total XP and handle daily reset // 原子更新累计 XP 和每日 XP
await db await db
.update(users) .update(users)
.set({ .set({
@ -163,6 +209,9 @@ export async function addXp(userId: string, amount: number): Promise<void> {
dailyXpDate: sql`CAST(${today} AS DATE)`, dailyXpDate: sql`CAST(${today} AS DATE)`,
}) })
.where(eq(users.id, userId)); .where(eq(users.id, userId));
// 同步累加本周 XP 统计,供排行榜查询。
await addToWeeklyXp(userId, amount);
} }
/** /**