实现每周一刷新逻辑与幂等周结算

- weeklySettlement 改为结算上一周数据(周一当前周 XP 为 0)
- 快照写入使用 onDuplicateKeyUpdate 保证幂等
- userWeeklyXp.settled 标记防止重复结算
- 新增 dryRun 模式返回结算预览不写库
- 时区策略注释:所有周榜计算统一 UTC,客户端本地转换
This commit is contained in:
Wang Zhuoxuan 2026-05-13 21:06:15 +08:00
parent d7d5f8109c
commit 08461485d5
2 changed files with 78 additions and 18 deletions

View File

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

View File

@ -18,6 +18,14 @@ export interface LeaderboardEntry {
/**
* 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 } {
const now = new Date();
@ -32,6 +40,16 @@ function getCurrentWeekRange(): { weekStart: Date; weekEnd: Date } {
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
* 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> {
const { weekStart, weekEnd } = getCurrentWeekRange();
// 从 userWeeklyXp 取本周排名。
export async function weeklySettlement(dryRun = false): Promise<{
settled: boolean;
weekStart: string;
weekEnd: string;
userCount: number;
top3: Array<{ userId: string; weeklyXp: number; rank: number }>;
}> {
// 结算上一周的数据(周一时当前周刚开始,上一周才是完整的)。
const { weekStart, weekEnd } = getPreviousWeekRange();
const weekStartStr = weekStart.toISOString().slice(0, 10);
const weekEndStr = weekEnd.toISOString().slice(0, 10);
// 从 userWeeklyXp 取上一周排名。
const allEntries = await db
.select({
userId: userWeeklyXp.userId,
@ -128,26 +159,55 @@ export async function weeklySettlement(): Promise<void> {
.where(sql`CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr}`)
.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));
// 为每个用户创建排行榜快照。
// 为每个用户创建排行榜快照(幂等:已存在则更新)
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: entry.userId,
tier,
weeklyXp: entry.weeklyXp ?? 0,
rank,
league: `${tier}-${Math.ceil(rank / perTier)}`,
weekStart: sql`CAST(${weekStartStr} AS DATE)`,
weekEnd: sql`CAST(${weekEndStr} AS DATE)`,
});
await db
.insert(leaderboardSnapshots)
.values({
id: uuid(),
userId: entry.userId,
tier,
weeklyXp: entry.weeklyXp ?? 0,
rank,
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 {