diff --git a/docs/gamification-server-plan.md b/docs/gamification-server-plan.md index 7a51041..74101c2 100644 --- a/docs/gamification-server-plan.md +++ b/docs/gamification-server-plan.md @@ -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 | diff --git a/src/services/gamification/leaderboard-service.ts b/src/services/gamification/leaderboard-service.ts index 5b9ab38..b3cba27 100644 --- a/src/services/gamification/leaderboard-service.ts +++ b/src/services/gamification/leaderboard-service.ts @@ -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 { - 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 { .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 {