From 6c63b6e24aa412f25a9af341be0bfc139ed334e4 Mon Sep 17 00:00:00 2001 From: Wang Zhuoxuan Date: Wed, 13 May 2026 21:48:54 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9A=B4=E9=9C=B2=E5=91=A8=E6=A6=9C=E5=85=83?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E5=88=B0=E6=8E=92=E8=A1=8C=E6=A6=9C=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 LeaderboardMetaDto 含 weekStart/weekEnd/nextRefreshAt/groupId/rank/rewardPreview - leaderboard-service 新增 getLeaderboardMeta() 获取当前周元信息 - /leaderboards 和 /leaderboards/me 响应中附带 meta 字段 - 奖励预览返回前 3 名的 300/150/50 金币配置 --- docs/gamification-server-plan.md | 2 +- src/routes/app-api.ts | 4 +- .../gamification/leaderboard-service.ts | 35 ++++++++++++++++ .../learning/leaderboard-api-service.ts | 41 +++++++++++++------ src/types/app-api.ts | 12 ++++++ 5 files changed, 78 insertions(+), 16 deletions(-) diff --git a/docs/gamification-server-plan.md b/docs/gamification-server-plan.md index 9613845..e55c028 100644 --- a/docs/gamification-server-plan.md +++ b/docs/gamification-server-plan.md @@ -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 G6:API 文档、Admin 和运维 diff --git a/src/routes/app-api.ts b/src/routes/app-api.ts index 3bba309..e314ab5 100644 --- a/src/routes/app-api.ts +++ b/src/routes/app-api.ts @@ -159,14 +159,14 @@ export async function appApiRoutes(app: FastifyInstance): Promise { 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 () => { diff --git a/src/services/gamification/leaderboard-service.ts b/src/services/gamification/leaderboard-service.ts index 52b7524..88d7d28 100644 --- a/src/services/gamification/leaderboard-service.ts +++ b/src/services/gamification/leaderboard-service.ts @@ -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, + }; +} diff --git a/src/services/learning/leaderboard-api-service.ts b/src/services/learning/leaderboard-api-service.ts index 7113c68..80cc5a7 100644 --- a/src/services/learning/leaderboard-api-service.ts +++ b/src/services/learning/leaderboard-api-service.ts @@ -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 { - 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,12 +63,15 @@ export async function getClientLeaderboardMe( .limit(1); return { - rank: rank.rank, - userId, - displayName: user?.nickname ?? '你', - avatarUrl: user?.avatarUrl ?? null, - xp: rank.weeklyXp, - badge: getBadge(rank.rank), - isMe: true, + entry: { + rank: rank.rank, + userId, + displayName: user?.nickname ?? '你', + avatarUrl: user?.avatarUrl ?? null, + xp: rank.weeklyXp, + badge: getBadge(rank.rank), + isMe: true, + }, + meta: { ...meta, rank: rank.rank }, }; } diff --git a/src/types/app-api.ts b/src/types/app-api.ts index ae59aa1..9e6e787 100644 --- a/src/types/app-api.ts +++ b/src/types/app-api.ts @@ -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 }>; +}