实现排行榜前三奖励结算

- weeklySettlement 按组结算,每组前 3 名发 300/150/50 金币
- 奖励通过 grantCoins 幂等发放,idempotencyKey 包含组+排名+用户
- 结算返回 SettlementResult 含 rewards 列表和 groupCount
- coin-service 新增 leaderboard_settlement 奖励来源
- schema inventoryTransactions.sourceType 新增 leaderboard_settlement 枚举值
This commit is contained in:
Wang Zhuoxuan 2026-05-13 21:43:14 +08:00
parent 66112c30f8
commit a3da577bf3
4 changed files with 107 additions and 31 deletions

View File

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

View File

@ -249,7 +249,7 @@ export const inventoryTransactions = mysqlTable('inventory_transactions', {
direction: mysqlEnum('direction', ['grant', 'consume', 'adjust']).notNull(), // 获得、消耗或运营调整。 direction: mysqlEnum('direction', ['grant', 'consume', 'adjust']).notNull(), // 获得、消耗或运营调整。
quantityDelta: int('quantity_delta').notNull(), // 资源数量变化,消耗为负数。 quantityDelta: int('quantity_delta').notNull(), // 资源数量变化,消耗为负数。
balanceAfter: int('balance_after'), // 变更后的金币余额或道具库存。 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如挑战组、订单或广告会话。 sourceId: varchar('source_id', { length: 120 }), // 来源业务 ID如挑战组、订单或广告会话。
idempotencyKey: varchar('idempotency_key', { length: 160 }), // 幂等边界,防止重复发放或重复扣减。 idempotencyKey: varchar('idempotency_key', { length: 160 }), // 幂等边界,防止重复发放或重复扣减。
snapshot: json('snapshot').$type<Record<string, unknown>>(), // 本次变更的上下文快照。 snapshot: json('snapshot').$type<Record<string, unknown>>(), // 本次变更的上下文快照。

View File

@ -10,10 +10,11 @@ export type CoinRewardSource =
| 'daily_task' | 'daily_task'
| 'level_up' | 'level_up'
| 'theme_node' | 'theme_node'
| 'chest'; | 'chest'
| 'leaderboard_settlement';
type CoinLedgerSource = 'challenge_completion' | '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'; type CoinInventorySource = 'challenge' | 'daily_task' | 'level_up' | 'theme_node' | 'chest' | 'leaderboard_settlement';
export interface CoinReward { export interface CoinReward {
type: 'coin'; type: 'coin';
@ -59,6 +60,7 @@ const COIN_REWARD_TITLES: Readonly<Record<CoinRewardSource, string>> = Object.fr
level_up: '升级奖励', level_up: '升级奖励',
theme_node: '主题节点', theme_node: '主题节点',
chest: '宝箱奖励', chest: '宝箱奖励',
leaderboard_settlement: '排行榜结算',
}); });
const REWARD_LEDGER_SOURCE: Readonly<Record<CoinRewardSource, CoinLedgerSource>> = Object.freeze({ const REWARD_LEDGER_SOURCE: Readonly<Record<CoinRewardSource, CoinLedgerSource>> = Object.freeze({
@ -67,6 +69,7 @@ const REWARD_LEDGER_SOURCE: Readonly<Record<CoinRewardSource, CoinLedgerSource>>
level_up: 'level_up', level_up: 'level_up',
theme_node: 'theme_node', theme_node: 'theme_node',
chest: 'chest', chest: 'chest',
leaderboard_settlement: 'leaderboard_settlement',
}); });
const INVENTORY_SOURCE: Readonly<Record<CoinRewardSource, CoinInventorySource>> = Object.freeze({ const INVENTORY_SOURCE: Readonly<Record<CoinRewardSource, CoinInventorySource>> = Object.freeze({
@ -75,6 +78,7 @@ const INVENTORY_SOURCE: Readonly<Record<CoinRewardSource, CoinInventorySource>>
level_up: 'level_up', level_up: 'level_up',
theme_node: 'theme_node', theme_node: 'theme_node',
chest: 'chest', chest: 'chest',
leaderboard_settlement: 'leaderboard_settlement',
}); });
export function getCoinRewardAmount(source: CoinRewardSource, amount?: number): number { export function getCoinRewardAmount(source: CoinRewardSource, amount?: number): number {
@ -89,6 +93,8 @@ export function getCoinRewardAmount(source: CoinRewardSource, amount?: number):
return COIN_RULES.themeNode; return COIN_RULES.themeNode;
case 'chest': case 'chest':
return clampCoins(amount, COIN_RULES.chestMin, COIN_RULES.chestMax); return clampCoins(amount, COIN_RULES.chestMin, COIN_RULES.chestMax);
case 'leaderboard_settlement':
return amount ?? 0;
} }
} }

View File

@ -2,6 +2,7 @@ import { db } from '../../db/client.js';
import { leaderboardSnapshots, userWeeklyXp, users } from '../../db/schema.js'; import { leaderboardSnapshots, userWeeklyXp, users } from '../../db/schema.js';
import { desc, eq, sql } from 'drizzle-orm'; import { desc, eq, sql } from 'drizzle-orm';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { grantCoins } from './coin-service.js';
import { LEADERBOARD_RULES } from './rules.js'; 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;
@ -140,51 +141,102 @@ export async function getUserRank(userId: string): Promise<{ rank: number; tier:
return { rank, tier: getTierForRank(rank), weeklyXp: userXp }; return { rank, tier: getTierForRank(rank), weeklyXp: userXp };
} }
/** /** 前三名奖励金币配置:第 1 名 300第 2 名 150第 3 名 50。 */
* const TOP_REWARD_COINS: ReadonlyMap<number, number> = new Map([
* [1, 300],
* UTC 00:00 [2, 150],
* leaderboardSnapshots uk_leaderboard_snapshot_user_week [3, 50],
* userId + weekStart ]);
* userWeeklyXp.settled
* export interface SettlementResult {
* @param dryRun true
*/
export async function weeklySettlement(dryRun = false): Promise<{
settled: boolean; settled: boolean;
weekStart: string; weekStart: string;
weekEnd: string; weekEnd: string;
userCount: number; userCount: number;
groupCount: number;
/** 全局前 3 名预览dryRun 时展示)。 */
top3: Array<{ userId: string; weeklyXp: number; rank: number }>; 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 idempotencyKeyleaderboard_settlement:{groupId}:{rank}:{userId}
* - userWeeklyXp.settled
*
* @param dryRun true
*/
export async function weeklySettlement(dryRun = false): Promise<SettlementResult> {
// 结算上一周的数据(周一时当前周刚开始,上一周才是完整的)。 // 结算上一周的数据(周一时当前周刚开始,上一周才是完整的)。
const { weekStart, weekEnd } = getPreviousWeekRange(); 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 取上一周排名。 // 从 userWeeklyXp 取上一周所有记录,按组内 XP 排名。
const allEntries = await db const allEntries = await db
.select({ .select({
userId: userWeeklyXp.userId, userId: userWeeklyXp.userId,
weeklyXp: userWeeklyXp.xpEarned, weeklyXp: userWeeklyXp.xpEarned,
groupId: userWeeklyXp.groupId,
}) })
.from(userWeeklyXp) .from(userWeeklyXp)
.where(sql`CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr}`) .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) => ({ // 按组分组,计算组内排名。
const groups = new Map<string, Array<{ userId: string; weeklyXp: number; groupRank: number }>>();
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,
});
}
// 收集全局前 3 名(跨组最高 XP
const globalTop3 = [...allEntries]
.sort((a, b) => (b.weeklyXp ?? 0) - (a.weeklyXp ?? 0))
.slice(0, 3)
.map((entry, i) => ({
userId: entry.userId, userId: entry.userId,
weeklyXp: entry.weeklyXp ?? 0, weeklyXp: entry.weeklyXp ?? 0,
rank: i + 1, rank: i + 1,
})); }));
if (dryRun) { // 收集各组前 3 名的奖励。
return { settled: false, weekStart: weekStartStr, weekEnd: weekEndStr, userCount: allEntries.length, top3 }; 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 });
}
}
}
} }
const perTier = Math.max(1, Math.ceil(allEntries.length / TIERS.length)); 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++) { for (let i = 0; i < allEntries.length; i++) {
const entry = allEntries[i]!; const entry = allEntries[i]!;
const rank = i + 1; const rank = i + 1;
@ -199,12 +251,11 @@ export async function weeklySettlement(dryRun = false): Promise<{
tier, tier,
weeklyXp: entry.weeklyXp ?? 0, weeklyXp: entry.weeklyXp ?? 0,
rank, rank,
league: `${tier}-${Math.ceil(rank / perTier)}`, league: entry.groupId ?? `${tier}-${Math.ceil(rank / perTier)}`,
weekStart: sql`CAST(${weekStartStr} AS DATE)`, weekStart: sql`CAST(${weekStartStr} AS DATE)`,
weekEnd: sql`CAST(${weekEndStr} AS DATE)`, weekEnd: sql`CAST(${weekEndStr} AS DATE)`,
settledAt: sql`NOW()`, settledAt: sql`NOW()`,
}) })
// 唯一索引 uk_leaderboard_snapshot_user_week 保证幂等。
.onDuplicateKeyUpdate({ .onDuplicateKeyUpdate({
set: { set: {
weeklyXp: entry.weeklyXp ?? 0, 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 统计为已结算。 // 标记上一周所有用户的周 XP 统计为已结算。
await db await db
.update(userWeeklyXp) .update(userWeeklyXp)
.set({ settled: 1, settledAt: sql`NOW()`, nextRefreshAt: sql`CAST(${getCurrentWeekRange().weekStart.toISOString().slice(0, 10)} AS DATE)` }) .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`); .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 { function getTierForRank(rank: number): string {