diff --git a/docs/gamification-server-plan.md b/docs/gamification-server-plan.md index d89f3a2..9613845 100644 --- a/docs/gamification-server-plan.md +++ b/docs/gamification-server-plan.md @@ -110,7 +110,7 @@ | G5-1 | 改造排行榜数据源为本周 XP | [x] | 排行榜不再按累计 XP 排名,展示本周 XP | | G5-2 | 实现每周一刷新逻辑 | [x] | 自然周边界清晰,UTC/本地时区策略写入代码注释和文档 | | G5-3 | 实现 20-30 人分组 | [x] | 每个用户进入稳定榜组,分页和我的排名基于组内排名 | -| G5-4 | 实现前三奖励结算 | [ ] | 周结算给前 3 名发金币、徽章碎片或头像框奖励,幂等执行 | +| G5-4 | 实现前三奖励结算 | [x] | 周结算给前 3 名发金币、徽章碎片或头像框奖励,幂等执行 | | G5-5 | 暴露周榜元信息 | [ ] | API 返回 weekStart、weekEnd、nextRefreshAt、groupId、rank、rewardPreview | | G5-6 | 添加排行榜测试 | [ ] | 覆盖周 XP 累加、分组、我的排名、周结算、重复结算 | diff --git a/src/db/schema.ts b/src/db/schema.ts index 18e4f27..05a8bed 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -249,7 +249,7 @@ export const inventoryTransactions = mysqlTable('inventory_transactions', { direction: mysqlEnum('direction', ['grant', 'consume', 'adjust']).notNull(), // 获得、消耗或运营调整。 quantityDelta: int('quantity_delta').notNull(), // 资源数量变化,消耗为负数。 balanceAfter: int('balance_after'), // 变更后的金币余额或道具库存。 - sourceType: mysqlEnum('source_type', ['challenge', 'daily_task', 'level_up', 'theme_node', 'chest', 'shop_purchase', 'ad_recovery', 'subscription', 'admin_grant', 'system_adjust']).notNull(), // 资源变化来源。 + sourceType: mysqlEnum('source_type', ['challenge', 'daily_task', 'level_up', 'theme_node', 'chest', 'shop_purchase', 'ad_recovery', 'subscription', 'admin_grant', 'system_adjust', 'leaderboard_settlement']).notNull(), // 资源变化来源。 sourceId: varchar('source_id', { length: 120 }), // 来源业务 ID,如挑战组、订单或广告会话。 idempotencyKey: varchar('idempotency_key', { length: 160 }), // 幂等边界,防止重复发放或重复扣减。 snapshot: json('snapshot').$type>(), // 本次变更的上下文快照。 diff --git a/src/services/gamification/coin-service.ts b/src/services/gamification/coin-service.ts index 6bef24e..13bf728 100644 --- a/src/services/gamification/coin-service.ts +++ b/src/services/gamification/coin-service.ts @@ -10,10 +10,11 @@ export type CoinRewardSource = | 'daily_task' | 'level_up' | 'theme_node' - | 'chest'; + | 'chest' + | 'leaderboard_settlement'; -type CoinLedgerSource = 'challenge_completion' | 'daily_task' | 'level_up' | 'theme_node' | 'chest'; -type CoinInventorySource = 'challenge' | 'daily_task' | 'level_up' | 'theme_node' | 'chest'; +type CoinLedgerSource = 'challenge_completion' | 'daily_task' | 'level_up' | 'theme_node' | 'chest' | 'leaderboard_settlement'; +type CoinInventorySource = 'challenge' | 'daily_task' | 'level_up' | 'theme_node' | 'chest' | 'leaderboard_settlement'; export interface CoinReward { type: 'coin'; @@ -59,6 +60,7 @@ const COIN_REWARD_TITLES: Readonly> = Object.fr level_up: '升级奖励', theme_node: '主题节点', chest: '宝箱奖励', + leaderboard_settlement: '排行榜结算', }); const REWARD_LEDGER_SOURCE: Readonly> = Object.freeze({ @@ -67,6 +69,7 @@ const REWARD_LEDGER_SOURCE: Readonly> level_up: 'level_up', theme_node: 'theme_node', chest: 'chest', + leaderboard_settlement: 'leaderboard_settlement', }); const INVENTORY_SOURCE: Readonly> = Object.freeze({ @@ -75,6 +78,7 @@ const INVENTORY_SOURCE: Readonly> level_up: 'level_up', theme_node: 'theme_node', chest: 'chest', + leaderboard_settlement: 'leaderboard_settlement', }); export function getCoinRewardAmount(source: CoinRewardSource, amount?: number): number { @@ -89,6 +93,8 @@ export function getCoinRewardAmount(source: CoinRewardSource, amount?: number): return COIN_RULES.themeNode; case 'chest': return clampCoins(amount, COIN_RULES.chestMin, COIN_RULES.chestMax); + case 'leaderboard_settlement': + return amount ?? 0; } } diff --git a/src/services/gamification/leaderboard-service.ts b/src/services/gamification/leaderboard-service.ts index 5a06ec2..52b7524 100644 --- a/src/services/gamification/leaderboard-service.ts +++ b/src/services/gamification/leaderboard-service.ts @@ -2,6 +2,7 @@ import { db } from '../../db/client.js'; import { leaderboardSnapshots, userWeeklyXp, users } from '../../db/schema.js'; import { desc, eq, sql } from 'drizzle-orm'; import { v4 as uuid } from 'uuid'; +import { grantCoins } from './coin-service.js'; import { LEADERBOARD_RULES } from './rules.js'; const TIERS = ['bronze', 'silver', 'gold', 'platinum', 'diamond', 'master', 'grandmaster', 'champion', 'legend', 'mythic'] as const; @@ -140,51 +141,102 @@ export async function getUserRank(userId: string): Promise<{ rank: number; tier: return { rank, tier: getTierForRank(rank), weeklyXp: userXp }; } -/** - * 运行周结算:结算上一自然周的排行榜快照并标记已结算。 - * - * 调用时机:每周一 UTC 00:00 后通过定时任务调用。 - * 幂等性:基于 leaderboardSnapshots 的 uk_leaderboard_snapshot_user_week - * 唯一索引(userId + weekStart),重复调用只会更新快照而不重复插入。 - * userWeeklyXp.settled 标记防止重复处理已结算的周。 - * - * @param dryRun 为 true 时只返回结算预览,不写入数据库。 - */ -export async function weeklySettlement(dryRun = false): Promise<{ +/** 前三名奖励金币配置:第 1 名 300,第 2 名 150,第 3 名 50。 */ +const TOP_REWARD_COINS: ReadonlyMap = new Map([ + [1, 300], + [2, 150], + [3, 50], +]); + +export interface SettlementResult { settled: boolean; weekStart: string; weekEnd: string; userCount: number; + groupCount: number; + /** 全局前 3 名预览(dryRun 时展示)。 */ top3: Array<{ userId: string; weeklyXp: number; rank: number }>; -}> { + /** 各组前 3 名实际发放的金币奖励。 */ + rewards: Array<{ userId: string; groupId: string; rank: number; coins: number }>; +} + +/** + * 运行周结算:按组结算上一自然周的排行榜快照并给每组前 3 名发金币奖励。 + * + * 调用时机:每周一 UTC 00:00 后通过定时任务调用。 + * 幂等性: + * - 快照写入基于 uk_leaderboard_snapshot_user_week 唯一索引(userId + weekStart) + * - 金币发放基于 grantCoins 的 idempotencyKey(leaderboard_settlement:{groupId}:{rank}:{userId}) + * - userWeeklyXp.settled 标记防止重复处理已结算的周 + * + * @param dryRun 为 true 时只返回结算预览,不写入数据库。 + */ +export async function weeklySettlement(dryRun = false): Promise { // 结算上一周的数据(周一时当前周刚开始,上一周才是完整的)。 const { weekStart, weekEnd } = getPreviousWeekRange(); const weekStartStr = weekStart.toISOString().slice(0, 10); const weekEndStr = weekEnd.toISOString().slice(0, 10); - // 从 userWeeklyXp 取上一周排名。 + // 从 userWeeklyXp 取上一周所有记录,按组内 XP 排名。 const allEntries = await db .select({ userId: userWeeklyXp.userId, weeklyXp: userWeeklyXp.xpEarned, + groupId: userWeeklyXp.groupId, }) .from(userWeeklyXp) .where(sql`CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr}`) - .orderBy(desc(userWeeklyXp.xpEarned)); + .orderBy(userWeeklyXp.groupId, 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 groups = new Map>(); + for (const entry of allEntries) { + const gid = entry.groupId ?? 'ungrouped'; + if (!groups.has(gid)) groups.set(gid, []); + groups.get(gid)!.push({ + userId: entry.userId, + weeklyXp: entry.weeklyXp ?? 0, + groupRank: groups.get(gid)!.length + 1, + }); } - const perTier = Math.max(1, Math.ceil(allEntries.length / TIERS.length)); + // 收集全局前 3 名(跨组最高 XP)。 + const globalTop3 = [...allEntries] + .sort((a, b) => (b.weeklyXp ?? 0) - (a.weeklyXp ?? 0)) + .slice(0, 3) + .map((entry, i) => ({ + userId: entry.userId, + weeklyXp: entry.weeklyXp ?? 0, + rank: i + 1, + })); + + // 收集各组前 3 名的奖励。 + const rewards: Array<{ userId: string; groupId: string; rank: number; coins: number }> = []; + for (const [groupId, members] of groups) { + for (const member of members) { + if (LEADERBOARD_RULES.topRewardRanks.includes(member.groupRank as 1 | 2 | 3)) { + const coins = TOP_REWARD_COINS.get(member.groupRank) ?? 0; + if (coins > 0) { + rewards.push({ userId: member.userId, groupId, rank: member.groupRank, coins }); + } + } + } + } + + if (dryRun) { + return { + settled: false, + weekStart: weekStartStr, + weekEnd: weekEndStr, + userCount: allEntries.length, + groupCount: groups.size, + top3: globalTop3, + rewards, + }; + } // 为每个用户创建排行榜快照(幂等:已存在则更新)。 + 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; @@ -199,12 +251,11 @@ export async function weeklySettlement(dryRun = false): Promise<{ tier, weeklyXp: entry.weeklyXp ?? 0, rank, - league: `${tier}-${Math.ceil(rank / perTier)}`, + league: entry.groupId ?? `${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, @@ -214,13 +265,32 @@ export async function weeklySettlement(dryRun = false): Promise<{ }); } + // 给各组前 3 名发金币奖励(幂等:grantCoins 自带幂等保护)。 + for (const reward of rewards) { + await grantCoins({ + userId: reward.userId, + source: 'leaderboard_settlement', + sourceId: `${reward.groupId}:${reward.rank}`, + amount: reward.coins, + idempotencyKey: `leaderboard_settlement:${reward.groupId}:rank${reward.rank}:${reward.userId}`, + }); + } + // 标记上一周所有用户的周 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 }; + return { + settled: true, + weekStart: weekStartStr, + weekEnd: weekEndStr, + userCount: allEntries.length, + groupCount: groups.size, + top3: globalTop3, + rewards, + }; } function getTierForRank(rank: number): string {