暴露周榜元信息到排行榜 API

- 新增 LeaderboardMetaDto 含 weekStart/weekEnd/nextRefreshAt/groupId/rank/rewardPreview
- leaderboard-service 新增 getLeaderboardMeta() 获取当前周元信息
- /leaderboards 和 /leaderboards/me 响应中附带 meta 字段
- 奖励预览返回前 3 名的 300/150/50 金币配置
This commit is contained in:
Wang Zhuoxuan 2026-05-13 21:48:54 +08:00
parent a3da577bf3
commit 6c63b6e24a
5 changed files with 78 additions and 16 deletions

View File

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

View File

@ -159,14 +159,14 @@ export async function appApiRoutes(app: FastifyInstance): Promise<void> {
parsed.data.page,
parsed.data.limit,
);
return { success: true, data: data.items, pagination: data.pagination, error: null };
return { success: true, data: data.items, meta: data.meta, pagination: data.pagination, error: null };
});
app.get('/leaderboards/me', async (request) => {
const parsed = leaderboardQuerySchema.safeParse(request.query);
if (!parsed.success) return validationError(parsed.error.issues[0]?.message);
const data = await getClientLeaderboardMe(getUserId(request), parsed.data.scope, parsed.data.trackId);
return { success: true, data, error: null };
return { success: true, data: data?.entry ?? null, meta: data?.meta ?? null, error: null };
});
app.get('/shop', async () => {

View File

@ -307,3 +307,38 @@ function getTierForRank(rank: number): string {
}
export { TIERS };
/** 奖励预览:前 3 名的金币配置。 */
const REWARD_PREVIEW = [
{ rank: 1, coins: 300 },
{ rank: 2, coins: 150 },
{ rank: 3, coins: 50 },
];
/**
*
*
*/
export async function getLeaderboardMeta(userId: string): Promise<{
weekStart: string;
weekEnd: string;
nextRefreshAt: string;
groupId: string | null;
rewardPreview: Array<{ rank: number; coins: number }>;
}> {
const { weekStart, weekEnd } = getCurrentWeekRange();
// 下次刷新时间 = 下一周的 weekStart。
const nextRefresh = new Date(weekStart);
nextRefresh.setUTCDate(weekStart.getUTCDate() + 7);
const weekStartStr = weekStart.toISOString().slice(0, 10);
const groupId = await getUserGroupId(userId, weekStartStr);
return {
weekStart: weekStartStr,
weekEnd: weekEnd.toISOString().slice(0, 10),
nextRefreshAt: nextRefresh.toISOString().slice(0, 10),
groupId,
rewardPreview: REWARD_PREVIEW,
};
}

View File

@ -1,5 +1,5 @@
import { getLeaderboard, getUserRank } from '../gamification/leaderboard-service.js';
import type { LeaderboardEntryDto, LeaderboardScope } from '../../types/app-api.js';
import { getLeaderboard, getLeaderboardMeta, getUserRank } from '../gamification/leaderboard-service.js';
import type { LeaderboardEntryDto, LeaderboardMetaDto, LeaderboardScope } from '../../types/app-api.js';
import { db } from '../../db/client.js';
import { users } from '../../db/schema.js';
import { eq } from 'drizzle-orm';
@ -17,8 +17,16 @@ export async function getClientLeaderboard(
_trackId: string | undefined,
page: number,
limit: number,
): Promise<{ items: LeaderboardEntryDto[]; pagination: { total: number; page: number; limit: number } }> {
const data = await getLeaderboard(userId, undefined, page, limit);
): Promise<{
items: LeaderboardEntryDto[];
meta: LeaderboardMetaDto;
pagination: { total: number; page: number; limit: number };
}> {
const [data, meta] = await Promise.all([
getLeaderboard(userId, undefined, page, limit),
getLeaderboardMeta(userId),
]);
return {
items: data.items.map((entry) => ({
rank: entry.rank,
@ -29,6 +37,7 @@ export async function getClientLeaderboard(
badge: getBadge(entry.rank),
isMe: entry.userId === userId,
})),
meta,
pagination: data.pagination,
};
}
@ -37,8 +46,11 @@ export async function getClientLeaderboardMe(
userId: string,
_scope: LeaderboardScope,
_trackId: string | undefined,
): Promise<LeaderboardEntryDto | null> {
const rank = await getUserRank(userId);
): Promise<{ entry: LeaderboardEntryDto; meta: LeaderboardMetaDto } | null> {
const [rank, meta] = await Promise.all([
getUserRank(userId),
getLeaderboardMeta(userId),
]);
if (!rank) return null;
const [user] = await db
@ -51,6 +63,7 @@ export async function getClientLeaderboardMe(
.limit(1);
return {
entry: {
rank: rank.rank,
userId,
displayName: user?.nickname ?? '你',
@ -58,5 +71,7 @@ export async function getClientLeaderboardMe(
xp: rank.weeklyXp,
badge: getBadge(rank.rank),
isMe: true,
},
meta: { ...meta, rank: rank.rank },
};
}

View File

@ -213,3 +213,15 @@ export interface LeaderboardEntryDto {
badge: string;
isMe: boolean;
}
/** 周榜元信息,附带在排行榜响应中。 */
export interface LeaderboardMetaDto {
weekStart: string;
weekEnd: string;
nextRefreshAt: string;
groupId: string | null;
/** 当前用户组内排名(仅 /leaderboards/me 返回)。 */
rank?: number;
/** 当前周奖励预览:各组前 3 名的金币奖励。 */
rewardPreview: Array<{ rank: number; coins: number }>;
}