改造排行榜数据源为本周 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:
parent
eee2116633
commit
d7d5f8109c
@ -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 名发金币、徽章碎片或头像框奖励,幂等执行 |
|
||||
|
||||
@ -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<number>`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<void> {
|
||||
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<void> {
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
@ -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<LeaderboardEntryDto | null> {
|
||||
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,
|
||||
};
|
||||
|
||||
@ -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<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.
|
||||
* Uses atomic SQL update to prevent race conditions.
|
||||
* 同时累加本周 XP 统计到 userWeeklyXp 表。
|
||||
*/
|
||||
export async function addXp(userId: string, amount: number): Promise<void> {
|
||||
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<void> {
|
||||
dailyXpDate: sql`CAST(${today} AS DATE)`,
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
// 同步累加本周 XP 统计,供排行榜查询。
|
||||
await addToWeeklyXp(userId, amount);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Loading…
Reference in New Issue
Block a user