实现每周一刷新逻辑与幂等周结算
- weeklySettlement 改为结算上一周数据(周一当前周 XP 为 0) - 快照写入使用 onDuplicateKeyUpdate 保证幂等 - userWeeklyXp.settled 标记防止重复结算 - 新增 dryRun 模式返回结算预览不写库 - 时区策略注释:所有周榜计算统一 UTC,客户端本地转换
This commit is contained in:
parent
d7d5f8109c
commit
08461485d5
@ -108,7 +108,7 @@
|
|||||||
| # | 任务 | 状态 | 验收标准 |
|
| # | 任务 | 状态 | 验收标准 |
|
||||||
|---|------|------|----------|
|
|---|------|------|----------|
|
||||||
| G5-1 | 改造排行榜数据源为本周 XP | [x] | 排行榜不再按累计 XP 排名,展示本周 XP |
|
| G5-1 | 改造排行榜数据源为本周 XP | [x] | 排行榜不再按累计 XP 排名,展示本周 XP |
|
||||||
| G5-2 | 实现每周一刷新逻辑 | [ ] | 自然周边界清晰,UTC/本地时区策略写入代码注释和文档 |
|
| G5-2 | 实现每周一刷新逻辑 | [x] | 自然周边界清晰,UTC/本地时区策略写入代码注释和文档 |
|
||||||
| G5-3 | 实现 20-30 人分组 | [ ] | 每个用户进入稳定榜组,分页和我的排名基于组内排名 |
|
| G5-3 | 实现 20-30 人分组 | [ ] | 每个用户进入稳定榜组,分页和我的排名基于组内排名 |
|
||||||
| G5-4 | 实现前三奖励结算 | [ ] | 周结算给前 3 名发金币、徽章碎片或头像框奖励,幂等执行 |
|
| G5-4 | 实现前三奖励结算 | [ ] | 周结算给前 3 名发金币、徽章碎片或头像框奖励,幂等执行 |
|
||||||
| G5-5 | 暴露周榜元信息 | [ ] | API 返回 weekStart、weekEnd、nextRefreshAt、groupId、rank、rewardPreview |
|
| G5-5 | 暴露周榜元信息 | [ ] | API 返回 weekStart、weekEnd、nextRefreshAt、groupId、rank、rewardPreview |
|
||||||
|
|||||||
@ -18,6 +18,14 @@ export interface LeaderboardEntry {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 计算当前自然周的起止日期(UTC)。
|
* 计算当前自然周的起止日期(UTC)。
|
||||||
|
*
|
||||||
|
* 时区策略:所有周榜计算统一使用 UTC。
|
||||||
|
* - weekStart 为 UTC 周一 00:00:00(由 LEADERBOARD_RULES.weekStartsOnIsoDay=1 配置)
|
||||||
|
* - weekEnd 为 UTC 周日 23:59:59
|
||||||
|
* - 客户端展示时可按用户本地时区转换,但排序和结算以 UTC 为准
|
||||||
|
*
|
||||||
|
* 注意:此函数返回的是「当前所在的自然周」。
|
||||||
|
* 周结算(weeklySettlement)应结算上一周的数据,使用 getPreviousWeekRange()。
|
||||||
*/
|
*/
|
||||||
function getCurrentWeekRange(): { weekStart: Date; weekEnd: Date } {
|
function getCurrentWeekRange(): { weekStart: Date; weekEnd: Date } {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@ -32,6 +40,16 @@ function getCurrentWeekRange(): { weekStart: Date; weekEnd: Date } {
|
|||||||
return { weekStart: start, weekEnd: end };
|
return { weekStart: start, weekEnd: end };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取上一自然周的起止日期,用于周结算。 */
|
||||||
|
function getPreviousWeekRange(): { weekStart: Date; weekEnd: Date } {
|
||||||
|
const { weekStart } = getCurrentWeekRange();
|
||||||
|
const prevStart = new Date(weekStart);
|
||||||
|
prevStart.setUTCDate(weekStart.getUTCDate() - 7);
|
||||||
|
const prevEnd = new Date(prevStart);
|
||||||
|
prevEnd.setUTCDate(prevStart.getUTCDate() + 6);
|
||||||
|
return { weekStart: prevStart, weekEnd: prevEnd };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取当前周排行榜,按本周 XP 排名。
|
* 获取当前周排行榜,按本周 XP 排名。
|
||||||
* 数据源从 userWeeklyXp 表读取,不再按 users.xpTotal 排序。
|
* 数据源从 userWeeklyXp 表读取,不再按 users.xpTotal 排序。
|
||||||
@ -110,15 +128,28 @@ export async function getUserRank(userId: string): Promise<{ rank: number; tier:
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 运行周结算:记录本周排行榜快照。
|
* 运行周结算:结算上一自然周的排行榜快照并标记已结算。
|
||||||
* 应通过定时任务在每周一调用。
|
*
|
||||||
|
* 调用时机:每周一 UTC 00:00 后通过定时任务调用。
|
||||||
|
* 幂等性:基于 leaderboardSnapshots 的 uk_leaderboard_snapshot_user_week
|
||||||
|
* 唯一索引(userId + weekStart),重复调用只会更新快照而不重复插入。
|
||||||
|
* userWeeklyXp.settled 标记防止重复处理已结算的周。
|
||||||
|
*
|
||||||
|
* @param dryRun 为 true 时只返回结算预览,不写入数据库。
|
||||||
*/
|
*/
|
||||||
export async function weeklySettlement(): Promise<void> {
|
export async function weeklySettlement(dryRun = false): Promise<{
|
||||||
const { weekStart, weekEnd } = getCurrentWeekRange();
|
settled: boolean;
|
||||||
|
weekStart: string;
|
||||||
// 从 userWeeklyXp 取本周排名。
|
weekEnd: string;
|
||||||
|
userCount: number;
|
||||||
|
top3: Array<{ userId: string; weeklyXp: number; rank: number }>;
|
||||||
|
}> {
|
||||||
|
// 结算上一周的数据(周一时当前周刚开始,上一周才是完整的)。
|
||||||
|
const { weekStart, weekEnd } = getPreviousWeekRange();
|
||||||
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);
|
||||||
|
|
||||||
|
// 从 userWeeklyXp 取上一周排名。
|
||||||
const allEntries = await db
|
const allEntries = await db
|
||||||
.select({
|
.select({
|
||||||
userId: userWeeklyXp.userId,
|
userId: userWeeklyXp.userId,
|
||||||
@ -128,26 +159,55 @@ export async function weeklySettlement(): Promise<void> {
|
|||||||
.where(sql`CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr}`)
|
.where(sql`CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr}`)
|
||||||
.orderBy(desc(userWeeklyXp.xpEarned));
|
.orderBy(desc(userWeeklyXp.xpEarned));
|
||||||
|
|
||||||
|
const top3 = allEntries.slice(0, 3).map((entry, i) => ({
|
||||||
|
userId: entry.userId,
|
||||||
|
weeklyXp: entry.weeklyXp ?? 0,
|
||||||
|
rank: i + 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
return { settled: false, weekStart: weekStartStr, weekEnd: weekEndStr, userCount: allEntries.length, top3 };
|
||||||
|
}
|
||||||
|
|
||||||
const perTier = Math.max(1, Math.ceil(allEntries.length / TIERS.length));
|
const perTier = Math.max(1, Math.ceil(allEntries.length / TIERS.length));
|
||||||
|
|
||||||
// 为每个用户创建排行榜快照。
|
// 为每个用户创建排行榜快照(幂等:已存在则更新)。
|
||||||
for (let i = 0; i < allEntries.length; i++) {
|
for (let i = 0; i < allEntries.length; i++) {
|
||||||
const entry = allEntries[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
|
||||||
id: uuid(),
|
.insert(leaderboardSnapshots)
|
||||||
userId: entry.userId,
|
.values({
|
||||||
tier,
|
id: uuid(),
|
||||||
weeklyXp: entry.weeklyXp ?? 0,
|
userId: entry.userId,
|
||||||
rank,
|
tier,
|
||||||
league: `${tier}-${Math.ceil(rank / perTier)}`,
|
weeklyXp: entry.weeklyXp ?? 0,
|
||||||
weekStart: sql`CAST(${weekStartStr} AS DATE)`,
|
rank,
|
||||||
weekEnd: sql`CAST(${weekEndStr} AS DATE)`,
|
league: `${tier}-${Math.ceil(rank / perTier)}`,
|
||||||
});
|
weekStart: sql`CAST(${weekStartStr} AS DATE)`,
|
||||||
|
weekEnd: sql`CAST(${weekEndStr} AS DATE)`,
|
||||||
|
settledAt: sql`NOW()`,
|
||||||
|
})
|
||||||
|
// 唯一索引 uk_leaderboard_snapshot_user_week 保证幂等。
|
||||||
|
.onDuplicateKeyUpdate({
|
||||||
|
set: {
|
||||||
|
weeklyXp: entry.weeklyXp ?? 0,
|
||||||
|
rank,
|
||||||
|
settledAt: sql`NOW()`,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 标记上一周所有用户的周 XP 统计为已结算。
|
||||||
|
await db
|
||||||
|
.update(userWeeklyXp)
|
||||||
|
.set({ settled: 1, settledAt: sql`NOW()`, nextRefreshAt: sql`CAST(${getCurrentWeekRange().weekStart.toISOString().slice(0, 10)} AS DATE)` })
|
||||||
|
.where(sql`CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr} AND COALESCE(${userWeeklyXp.settled}, 0) = 0`);
|
||||||
|
|
||||||
|
return { settled: true, weekStart: weekStartStr, weekEnd: weekEndStr, userCount: allEntries.length, top3 };
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTierForRank(rank: number): string {
|
function getTierForRank(rank: number): string {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user