暴露周榜元信息到排行榜 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-2 | 实现每周一刷新逻辑 | [x] | 自然周边界清晰,UTC/本地时区策略写入代码注释和文档 |
|
||||||
| G5-3 | 实现 20-30 人分组 | [x] | 每个用户进入稳定榜组,分页和我的排名基于组内排名 |
|
| G5-3 | 实现 20-30 人分组 | [x] | 每个用户进入稳定榜组,分页和我的排名基于组内排名 |
|
||||||
| G5-4 | 实现前三奖励结算 | [x] | 周结算给前 3 名发金币、徽章碎片或头像框奖励,幂等执行 |
|
| 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 累加、分组、我的排名、周结算、重复结算 |
|
| G5-6 | 添加排行榜测试 | [ ] | 覆盖周 XP 累加、分组、我的排名、周结算、重复结算 |
|
||||||
|
|
||||||
## Phase G6:API 文档、Admin 和运维
|
## Phase G6:API 文档、Admin 和运维
|
||||||
|
|||||||
@ -159,14 +159,14 @@ export async function appApiRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
parsed.data.page,
|
parsed.data.page,
|
||||||
parsed.data.limit,
|
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) => {
|
app.get('/leaderboards/me', async (request) => {
|
||||||
const parsed = leaderboardQuerySchema.safeParse(request.query);
|
const parsed = leaderboardQuerySchema.safeParse(request.query);
|
||||||
if (!parsed.success) return validationError(parsed.error.issues[0]?.message);
|
if (!parsed.success) return validationError(parsed.error.issues[0]?.message);
|
||||||
const data = await getClientLeaderboardMe(getUserId(request), parsed.data.scope, parsed.data.trackId);
|
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 () => {
|
app.get('/shop', async () => {
|
||||||
|
|||||||
@ -307,3 +307,38 @@ function getTierForRank(rank: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export { TIERS };
|
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 { getLeaderboard, getLeaderboardMeta, getUserRank } from '../gamification/leaderboard-service.js';
|
||||||
import type { LeaderboardEntryDto, LeaderboardScope } from '../../types/app-api.js';
|
import type { LeaderboardEntryDto, LeaderboardMetaDto, LeaderboardScope } from '../../types/app-api.js';
|
||||||
import { db } from '../../db/client.js';
|
import { db } from '../../db/client.js';
|
||||||
import { users } from '../../db/schema.js';
|
import { users } from '../../db/schema.js';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
@ -17,8 +17,16 @@ export async function getClientLeaderboard(
|
|||||||
_trackId: string | undefined,
|
_trackId: string | undefined,
|
||||||
page: number,
|
page: number,
|
||||||
limit: number,
|
limit: number,
|
||||||
): Promise<{ items: LeaderboardEntryDto[]; pagination: { total: number; page: number; limit: number } }> {
|
): Promise<{
|
||||||
const data = await getLeaderboard(userId, undefined, page, limit);
|
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 {
|
return {
|
||||||
items: data.items.map((entry) => ({
|
items: data.items.map((entry) => ({
|
||||||
rank: entry.rank,
|
rank: entry.rank,
|
||||||
@ -29,6 +37,7 @@ export async function getClientLeaderboard(
|
|||||||
badge: getBadge(entry.rank),
|
badge: getBadge(entry.rank),
|
||||||
isMe: entry.userId === userId,
|
isMe: entry.userId === userId,
|
||||||
})),
|
})),
|
||||||
|
meta,
|
||||||
pagination: data.pagination,
|
pagination: data.pagination,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -37,8 +46,11 @@ export async function getClientLeaderboardMe(
|
|||||||
userId: string,
|
userId: string,
|
||||||
_scope: LeaderboardScope,
|
_scope: LeaderboardScope,
|
||||||
_trackId: string | undefined,
|
_trackId: string | undefined,
|
||||||
): Promise<LeaderboardEntryDto | null> {
|
): Promise<{ entry: LeaderboardEntryDto; meta: LeaderboardMetaDto } | null> {
|
||||||
const rank = await getUserRank(userId);
|
const [rank, meta] = await Promise.all([
|
||||||
|
getUserRank(userId),
|
||||||
|
getLeaderboardMeta(userId),
|
||||||
|
]);
|
||||||
if (!rank) return null;
|
if (!rank) return null;
|
||||||
|
|
||||||
const [user] = await db
|
const [user] = await db
|
||||||
@ -51,6 +63,7 @@ export async function getClientLeaderboardMe(
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
entry: {
|
||||||
rank: rank.rank,
|
rank: rank.rank,
|
||||||
userId,
|
userId,
|
||||||
displayName: user?.nickname ?? '你',
|
displayName: user?.nickname ?? '你',
|
||||||
@ -58,5 +71,7 @@ export async function getClientLeaderboardMe(
|
|||||||
xp: rank.weeklyXp,
|
xp: rank.weeklyXp,
|
||||||
badge: getBadge(rank.rank),
|
badge: getBadge(rank.rank),
|
||||||
isMe: true,
|
isMe: true,
|
||||||
|
},
|
||||||
|
meta: { ...meta, rank: rank.rank },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -213,3 +213,15 @@ export interface LeaderboardEntryDto {
|
|||||||
badge: string;
|
badge: string;
|
||||||
isMe: boolean;
|
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