暴露周榜元信息到排行榜 API
- 新增 LeaderboardMetaDto 含 weekStart/weekEnd/nextRefreshAt/groupId/rank/rewardPreview - leaderboard-service 新增 getLeaderboardMeta() 获取当前周元信息 - /leaderboards 和 /leaderboards/me 响应中附带 meta 字段 - 奖励预览返回前 3 名的 300/150/50 金币配置
This commit is contained in:
parent
a3da577bf3
commit
6c63b6e24a
@ -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 和运维
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
@ -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 }>;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user