改造排行榜数据源为本周 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-2 | 实现每周一刷新逻辑 | [ ] | 自然周边界清晰,UTC/本地时区策略写入代码注释和文档 |
|
||||||
| G5-3 | 实现 20-30 人分组 | [ ] | 每个用户进入稳定榜组,分页和我的排名基于组内排名 |
|
| G5-3 | 实现 20-30 人分组 | [ ] | 每个用户进入稳定榜组,分页和我的排名基于组内排名 |
|
||||||
| G5-4 | 实现前三奖励结算 | [ ] | 周结算给前 3 名发金币、徽章碎片或头像框奖励,幂等执行 |
|
| G5-4 | 实现前三奖励结算 | [ ] | 周结算给前 3 名发金币、徽章碎片或头像框奖励,幂等执行 |
|
||||||
|
|||||||
@ -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 查询本周 XP,JOIN 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';
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user