实现排行榜前三奖励结算
- weeklySettlement 按组结算,每组前 3 名发 300/150/50 金币 - 奖励通过 grantCoins 幂等发放,idempotencyKey 包含组+排名+用户 - 结算返回 SettlementResult 含 rewards 列表和 groupCount - coin-service 新增 leaderboard_settlement 奖励来源 - schema inventoryTransactions.sourceType 新增 leaderboard_settlement 枚举值
This commit is contained in:
parent
66112c30f8
commit
a3da577bf3
@ -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 累加、分组、我的排名、周结算、重复结算 |
|
||||
|
||||
|
||||
@ -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<Record<string, unknown>>(), // 本次变更的上下文快照。
|
||||
|
||||
@ -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<Record<CoinRewardSource, string>> = Object.fr
|
||||
level_up: '升级奖励',
|
||||
theme_node: '主题节点',
|
||||
chest: '宝箱奖励',
|
||||
leaderboard_settlement: '排行榜结算',
|
||||
});
|
||||
|
||||
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',
|
||||
theme_node: 'theme_node',
|
||||
chest: 'chest',
|
||||
leaderboard_settlement: 'leaderboard_settlement',
|
||||
});
|
||||
|
||||
const INVENTORY_SOURCE: Readonly<Record<CoinRewardSource, CoinInventorySource>> = Object.freeze({
|
||||
@ -75,6 +78,7 @@ const INVENTORY_SOURCE: Readonly<Record<CoinRewardSource, CoinInventorySource>>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<number, number> = 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<SettlementResult> {
|
||||
// 结算上一周的数据(周一时当前周刚开始,上一周才是完整的)。
|
||||
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<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,
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user