Add performance optimizations and component refactors
- Cache read-heavy public API routes via redisCache (leaderboard, values, shop, articles, photos, guilds, teams, staff, users, home, radio, badges) - Add single-flight and bounded-memory cache layer with unit tests - Parallelize independent DB queries on search, rares, shop, staff, polls and profile pages - Push radio points leaderboard aggregation to SQL with a LIMIT - Split studio-client and import-furni-client into focused modules - Clean up next.config.ts
This commit is contained in:
1 parent
54f2bc5e0c
commit
f285a7cd98
42 files changed
+1616
-1205
No files matched your search
@@ -5,6 +5,7 @@ import { bearerUserId } from "@/lib/api-auth";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db, User, UsersSettings } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
|
||||
|
||||
type BadgeRarityKey =
|
||||
| "common"
|
||||
@@ -28,6 +29,12 @@ interface BadgeLeaderboardBoard {
|
||||
viewerEntry?: Partial<BadgeLeaderboardEntry>;
|
||||
}
|
||||
|
||||
/** The shared, viewer-independent part of a leaderboard (cacheable). */
|
||||
type BoardCore = {
|
||||
entries: BadgeLeaderboardEntry[];
|
||||
totalPlayers: number;
|
||||
};
|
||||
|
||||
interface BadgeLeaderboardStat {
|
||||
badgeCode: string;
|
||||
ownerCount: number;
|
||||
@@ -65,9 +72,7 @@ async function rawRows<T>(query: ReturnType<typeof sql>): Promise<T[]> {
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function loadTotalBadgesBoard(
|
||||
userId: number | null,
|
||||
): Promise<BadgeLeaderboardBoard> {
|
||||
async function loadTotalBadgesBoard(): Promise<BoardCore> {
|
||||
const rows = await rawRows<{
|
||||
userId: bigint;
|
||||
username: string;
|
||||
@@ -103,48 +108,10 @@ async function loadTotalBadgesBoard(
|
||||
)
|
||||
: 0;
|
||||
|
||||
let viewerEntry: Partial<BadgeLeaderboardEntry> | undefined;
|
||||
if (userId) {
|
||||
const viewerRow = await rawRows<{ cnt: bigint }>(
|
||||
sql`SELECT COUNT(*) AS cnt FROM users_badges WHERE user_id = ${userId}`,
|
||||
);
|
||||
const viewerScore = Number(viewerRow[0]?.cnt ?? 0);
|
||||
if (viewerScore > 0) {
|
||||
const viewerRank =
|
||||
entries.length > 0
|
||||
? Number(
|
||||
(
|
||||
await rawRows<{ cnt: bigint }>(sql`
|
||||
SELECT COUNT(*) + 1 AS cnt
|
||||
FROM (SELECT user_id, COUNT(*) AS total FROM users_badges GROUP BY user_id) t
|
||||
WHERE t.total > ${viewerScore}
|
||||
`)
|
||||
)[0]?.cnt ?? entries.length + 1,
|
||||
)
|
||||
: 1;
|
||||
const [viewerUser] = await db
|
||||
.select({ username: User.username, look: User.look })
|
||||
.from(User)
|
||||
.where(eq(User.id, userId))
|
||||
.limit(1);
|
||||
if (viewerUser) {
|
||||
viewerEntry = {
|
||||
userId,
|
||||
username: viewerUser.username,
|
||||
figure: viewerUser.look,
|
||||
score: viewerScore,
|
||||
rank: viewerRank,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { entries, totalPlayers, viewerEntry };
|
||||
return { entries, totalPlayers };
|
||||
}
|
||||
|
||||
async function loadAchievementBoard(
|
||||
userId: number | null,
|
||||
): Promise<BadgeLeaderboardBoard> {
|
||||
async function loadAchievementBoard(): Promise<BoardCore> {
|
||||
const rows = await rawRows<{
|
||||
userId: bigint;
|
||||
username: string;
|
||||
@@ -179,46 +146,10 @@ async function loadAchievementBoard(
|
||||
)
|
||||
: 0;
|
||||
|
||||
let viewerEntry: Partial<BadgeLeaderboardEntry> | undefined;
|
||||
if (userId) {
|
||||
const [viewerUser] = await db
|
||||
.select({ username: User.username, look: User.look })
|
||||
.from(User)
|
||||
.where(eq(User.id, userId))
|
||||
.limit(1);
|
||||
if (viewerUser) {
|
||||
const [viewerSettings] = await db
|
||||
.select({ achievementScore: UsersSettings.achievementScore })
|
||||
.from(UsersSettings)
|
||||
.where(eq(UsersSettings.userId, userId))
|
||||
.limit(1);
|
||||
if (viewerSettings && viewerSettings.achievementScore > 0) {
|
||||
const viewerRank = Number(
|
||||
(
|
||||
await rawRows<{ cnt: bigint }>(sql`
|
||||
SELECT COUNT(*) + 1 AS cnt FROM users_settings WHERE achievement_score > ${viewerSettings.achievementScore}
|
||||
`)
|
||||
)[0]?.cnt ?? entries.length + 1,
|
||||
);
|
||||
viewerEntry = {
|
||||
userId,
|
||||
username: viewerUser.username,
|
||||
figure: viewerUser.look,
|
||||
score: viewerSettings.achievementScore,
|
||||
rank: viewerRank,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { entries, totalPlayers, viewerEntry };
|
||||
return { entries, totalPlayers };
|
||||
}
|
||||
|
||||
async function loadRarityBoard(
|
||||
_rarity: BadgeRarityKey,
|
||||
badgeCodes: string[],
|
||||
userId: number | null,
|
||||
): Promise<BadgeLeaderboardBoard> {
|
||||
async function loadRarityBoard(badgeCodes: string[]): Promise<BoardCore> {
|
||||
if (badgeCodes.length === 0) return { entries: [], totalPlayers: 0 };
|
||||
|
||||
const codes = sql.join(
|
||||
@@ -262,41 +193,111 @@ async function loadRarityBoard(
|
||||
)
|
||||
: 0;
|
||||
|
||||
let viewerEntry: Partial<BadgeLeaderboardEntry> | undefined;
|
||||
if (userId) {
|
||||
const viewerRow = await rawRows<{ cnt: bigint }>(sql`
|
||||
SELECT COUNT(*) AS cnt FROM users_badges
|
||||
WHERE user_id = ${userId} AND badge_code IN (${codes})
|
||||
`);
|
||||
const viewerScore = Number(viewerRow[0]?.cnt ?? 0);
|
||||
if (viewerScore > 0) {
|
||||
const [viewerUser] = await db
|
||||
.select({ username: User.username, look: User.look })
|
||||
.from(User)
|
||||
.where(eq(User.id, userId))
|
||||
.limit(1);
|
||||
if (viewerUser) {
|
||||
const viewerRank = Number(
|
||||
(
|
||||
await rawRows<{ cnt: bigint }>(sql`
|
||||
SELECT COUNT(*) + 1 AS cnt
|
||||
FROM (SELECT user_id, COUNT(*) AS total FROM users_badges WHERE badge_code IN (${codes}) GROUP BY user_id) t
|
||||
WHERE t.total > ${viewerScore}
|
||||
`)
|
||||
)[0]?.cnt ?? entries.length + 1,
|
||||
);
|
||||
viewerEntry = {
|
||||
userId,
|
||||
username: viewerUser.username,
|
||||
figure: viewerUser.look,
|
||||
score: viewerScore,
|
||||
rank: viewerRank,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return { entries, totalPlayers };
|
||||
}
|
||||
|
||||
return { entries, totalPlayers, viewerEntry };
|
||||
async function loadTotalBadgesViewer(
|
||||
userId: number,
|
||||
): Promise<Partial<BadgeLeaderboardEntry> | undefined> {
|
||||
const viewerRow = await rawRows<{ cnt: bigint }>(
|
||||
sql`SELECT COUNT(*) AS cnt FROM users_badges WHERE user_id = ${userId}`,
|
||||
);
|
||||
const viewerScore = Number(viewerRow[0]?.cnt ?? 0);
|
||||
if (viewerScore <= 0) return undefined;
|
||||
|
||||
const rankRow = await rawRows<{ cnt: bigint }>(sql`
|
||||
SELECT COUNT(*) + 1 AS cnt
|
||||
FROM (SELECT user_id, COUNT(*) AS total FROM users_badges GROUP BY user_id) t
|
||||
WHERE t.total > ${viewerScore}
|
||||
`);
|
||||
const rank = Number(rankRow[0]?.cnt ?? 1);
|
||||
|
||||
const [viewerUser] = await db
|
||||
.select({ username: User.username, look: User.look })
|
||||
.from(User)
|
||||
.where(eq(User.id, userId))
|
||||
.limit(1);
|
||||
if (!viewerUser) return undefined;
|
||||
|
||||
return {
|
||||
userId,
|
||||
username: viewerUser.username,
|
||||
figure: viewerUser.look,
|
||||
score: viewerScore,
|
||||
rank,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadAchievementViewer(
|
||||
userId: number,
|
||||
): Promise<Partial<BadgeLeaderboardEntry> | undefined> {
|
||||
const [viewerUser] = await db
|
||||
.select({ username: User.username, look: User.look })
|
||||
.from(User)
|
||||
.where(eq(User.id, userId))
|
||||
.limit(1);
|
||||
if (!viewerUser) return undefined;
|
||||
|
||||
const [viewerSettings] = await db
|
||||
.select({ achievementScore: UsersSettings.achievementScore })
|
||||
.from(UsersSettings)
|
||||
.where(eq(UsersSettings.userId, userId))
|
||||
.limit(1);
|
||||
if (!viewerSettings || viewerSettings.achievementScore <= 0) return undefined;
|
||||
|
||||
const rankRow = await rawRows<{ cnt: bigint }>(sql`
|
||||
SELECT COUNT(*) + 1 AS cnt FROM users_settings WHERE achievement_score > ${viewerSettings.achievementScore}
|
||||
`);
|
||||
const rank = Number(rankRow[0]?.cnt ?? 1);
|
||||
|
||||
return {
|
||||
userId,
|
||||
username: viewerUser.username,
|
||||
figure: viewerUser.look,
|
||||
score: viewerSettings.achievementScore,
|
||||
rank,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadRarityViewer(
|
||||
userId: number,
|
||||
badgeCodes: string[],
|
||||
): Promise<Partial<BadgeLeaderboardEntry> | undefined> {
|
||||
if (badgeCodes.length === 0) return undefined;
|
||||
|
||||
const codes = sql.join(
|
||||
badgeCodes.map((c) => sql`${c}`),
|
||||
sql`, `,
|
||||
);
|
||||
|
||||
const viewerRow = await rawRows<{ cnt: bigint }>(sql`
|
||||
SELECT COUNT(*) AS cnt FROM users_badges
|
||||
WHERE user_id = ${userId} AND badge_code IN (${codes})
|
||||
`);
|
||||
const viewerScore = Number(viewerRow[0]?.cnt ?? 0);
|
||||
if (viewerScore <= 0) return undefined;
|
||||
|
||||
const [viewerUser] = await db
|
||||
.select({ username: User.username, look: User.look })
|
||||
.from(User)
|
||||
.where(eq(User.id, userId))
|
||||
.limit(1);
|
||||
if (!viewerUser) return undefined;
|
||||
|
||||
const rankRow = await rawRows<{ cnt: bigint }>(sql`
|
||||
SELECT COUNT(*) + 1 AS cnt
|
||||
FROM (SELECT user_id, COUNT(*) AS total FROM users_badges WHERE badge_code IN (${codes}) GROUP BY user_id) t
|
||||
WHERE t.total > ${viewerScore}
|
||||
`);
|
||||
const rank = Number(rankRow[0]?.cnt ?? 1);
|
||||
|
||||
return {
|
||||
userId,
|
||||
username: viewerUser.username,
|
||||
figure: viewerUser.look,
|
||||
score: viewerScore,
|
||||
rank,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
@@ -308,28 +309,6 @@ export async function GET(req: Request) {
|
||||
userId = session?.user?.id ? Number(session.user.id) : null;
|
||||
}
|
||||
|
||||
const badgeStatsRaw = await rawRows<{
|
||||
badgeCode: string;
|
||||
ownerCount: bigint;
|
||||
}>(sql`
|
||||
SELECT badge_code AS badgeCode, COUNT(DISTINCT user_id) AS ownerCount
|
||||
FROM users_badges
|
||||
GROUP BY badge_code
|
||||
`);
|
||||
|
||||
const badgeStats: BadgeLeaderboardStat[] = badgeStatsRaw.map((r) => ({
|
||||
badgeCode: r.badgeCode,
|
||||
ownerCount: Number(r.ownerCount),
|
||||
rarity: assignRarity(Number(r.ownerCount)),
|
||||
}));
|
||||
|
||||
const badgesByRarity = new Map<BadgeRarityKey, string[]>();
|
||||
for (const stat of badgeStats) {
|
||||
const list = badgesByRarity.get(stat.rarity) ?? [];
|
||||
list.push(stat.badgeCode);
|
||||
badgesByRarity.set(stat.rarity, list);
|
||||
}
|
||||
|
||||
const rarityKeys: BadgeRarityKey[] = [
|
||||
"common",
|
||||
"rare",
|
||||
@@ -339,24 +318,109 @@ export async function GET(req: Request) {
|
||||
"unique",
|
||||
];
|
||||
|
||||
const [totalBadges, achievementLevel, ...rarityBoards] = await Promise.all([
|
||||
loadTotalBadgesBoard(userId),
|
||||
loadAchievementBoard(userId),
|
||||
...rarityKeys.map((rk) =>
|
||||
loadRarityBoard(rk, badgesByRarity.get(rk) ?? [], userId),
|
||||
),
|
||||
]);
|
||||
// The heavy global aggregation (badge rarity stats + the 8 leaderboards)
|
||||
// is user-independent, so it is cached and recomputed once per TTL instead
|
||||
// of on every request. The viewer's own rank is derived per request.
|
||||
const global = await redisCache(
|
||||
apiCacheKey("badges:leaderboard:global"),
|
||||
120,
|
||||
async () => {
|
||||
const badgeStatsRaw = await rawRows<{
|
||||
badgeCode: string;
|
||||
ownerCount: bigint;
|
||||
}>(sql`
|
||||
SELECT badge_code AS badgeCode, COUNT(DISTINCT user_id) AS ownerCount
|
||||
FROM users_badges
|
||||
GROUP BY badge_code
|
||||
`);
|
||||
|
||||
const rarity = Object.fromEntries(
|
||||
// eslint-disable-next-line security/detect-object-injection -- rk from rarityKeys const, i is array index
|
||||
rarityKeys.map((rk, i) => [rk, rarityBoards[i]]),
|
||||
) as Record<BadgeRarityKey, BadgeLeaderboardBoard>;
|
||||
const badgeStats: BadgeLeaderboardStat[] = badgeStatsRaw.map((r) => ({
|
||||
badgeCode: r.badgeCode,
|
||||
ownerCount: Number(r.ownerCount),
|
||||
rarity: assignRarity(Number(r.ownerCount)),
|
||||
}));
|
||||
|
||||
const badgesByRarity = new Map<BadgeRarityKey, string[]>();
|
||||
for (const stat of badgeStats) {
|
||||
const list = badgesByRarity.get(stat.rarity) ?? [];
|
||||
list.push(stat.badgeCode);
|
||||
badgesByRarity.set(stat.rarity, list);
|
||||
}
|
||||
|
||||
const [totalBadges, achievementLevel, ...rarityBoards] =
|
||||
await Promise.all([
|
||||
loadTotalBadgesBoard(),
|
||||
loadAchievementBoard(),
|
||||
...rarityKeys.map((rk) =>
|
||||
loadRarityBoard(badgesByRarity.get(rk) ?? []),
|
||||
),
|
||||
]);
|
||||
|
||||
const rarity = Object.fromEntries(
|
||||
// eslint-disable-next-line security/detect-object-injection -- rk from rarityKeys const, i is array index
|
||||
rarityKeys.map((rk, i) => [rk, rarityBoards[i]]),
|
||||
) as Record<BadgeRarityKey, BoardCore>;
|
||||
|
||||
return cacheSafe({ badgeStats, totalBadges, achievementLevel, rarity });
|
||||
},
|
||||
);
|
||||
|
||||
// Per-viewer rank entries (only when a user is signed in).
|
||||
let totalBadgesViewer: Partial<BadgeLeaderboardEntry> | undefined;
|
||||
let achievementViewer: Partial<BadgeLeaderboardEntry> | undefined;
|
||||
let rarityViewer: Record<
|
||||
BadgeRarityKey,
|
||||
Partial<BadgeLeaderboardEntry> | undefined
|
||||
> = {
|
||||
common: undefined,
|
||||
rare: undefined,
|
||||
epic: undefined,
|
||||
legendary: undefined,
|
||||
mythical: undefined,
|
||||
unique: undefined,
|
||||
};
|
||||
|
||||
if (userId) {
|
||||
const badgesByRarity = new Map<BadgeRarityKey, string[]>();
|
||||
for (const stat of global.badgeStats) {
|
||||
const list = badgesByRarity.get(stat.rarity) ?? [];
|
||||
list.push(stat.badgeCode);
|
||||
badgesByRarity.set(stat.rarity, list);
|
||||
}
|
||||
|
||||
const [tb, ach, ...rvs] = await Promise.all([
|
||||
loadTotalBadgesViewer(userId),
|
||||
loadAchievementViewer(userId),
|
||||
...rarityKeys.map((rk) =>
|
||||
loadRarityViewer(userId, badgesByRarity.get(rk) ?? []),
|
||||
),
|
||||
]);
|
||||
totalBadgesViewer = tb;
|
||||
achievementViewer = ach;
|
||||
rarityViewer = Object.fromEntries(
|
||||
// eslint-disable-next-line security/detect-object-injection -- rk from rarityKeys const, i is array index
|
||||
rarityKeys.map((rk, i) => [rk, rvs[i]]),
|
||||
) as Record<BadgeRarityKey, Partial<BadgeLeaderboardEntry> | undefined>;
|
||||
}
|
||||
|
||||
return apiJson({
|
||||
viewerUserId: userId ?? 0,
|
||||
badgeStats,
|
||||
badgeStats: global.badgeStats,
|
||||
thresholds: THRESHOLDS,
|
||||
leaderboards: { totalBadges, achievementLevel, rarity },
|
||||
leaderboards: {
|
||||
totalBadges: { ...global.totalBadges, viewerEntry: totalBadgesViewer },
|
||||
achievementLevel: {
|
||||
...global.achievementLevel,
|
||||
viewerEntry: achievementViewer,
|
||||
},
|
||||
rarity: Object.fromEntries(
|
||||
// eslint-disable-next-line security/detect-object-injection -- rk from rarityKeys const
|
||||
rarityKeys.map((rk) => [
|
||||
rk,
|
||||
{ ...global.rarity[rk], viewerEntry: rarityViewer[rk] },
|
||||
]),
|
||||
) as Record<BadgeRarityKey, BadgeLeaderboardBoard>,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error("Badge leaderboard error", {
|
||||
|
||||
Reference in new issue
Block a user