mirror of
https://github.com/ershisan99/www.git
synced 2025-12-17 12:34:17 +00:00
store historical leaderboard data
This commit is contained in:
@@ -1,4 +1,8 @@
|
|||||||
import { createTRPCRouter, publicProcedure } from '@/server/api/trpc'
|
import {
|
||||||
|
adminProcedure,
|
||||||
|
createTRPCRouter,
|
||||||
|
publicProcedure,
|
||||||
|
} from '@/server/api/trpc'
|
||||||
import { LeaderboardService } from '@/server/services/leaderboard'
|
import { LeaderboardService } from '@/server/services/leaderboard'
|
||||||
import type { LeaderboardEntry } from '@/server/services/neatqueue.service'
|
import type { LeaderboardEntry } from '@/server/services/neatqueue.service'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
@@ -15,9 +19,22 @@ export const leaderboard_router = createTRPCRouter({
|
|||||||
const result = await service.getLeaderboard(input.channel_id)
|
const result = await service.getLeaderboard(input.channel_id)
|
||||||
return {
|
return {
|
||||||
data: result.data as LeaderboardEntry[],
|
data: result.data as LeaderboardEntry[],
|
||||||
isStale: result.isStale
|
isStale: result.isStale,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
get_leaderboard_snapshots: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
channel_id: z.string(),
|
||||||
|
limit: z.number().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
return await service.getLeaderboardSnapshots(
|
||||||
|
input.channel_id,
|
||||||
|
input.limit
|
||||||
|
)
|
||||||
|
}),
|
||||||
get_user_rank: publicProcedure
|
get_user_rank: publicProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -30,7 +47,7 @@ export const leaderboard_router = createTRPCRouter({
|
|||||||
if (!result) return null
|
if (!result) return null
|
||||||
return {
|
return {
|
||||||
data: result.data,
|
data: result.data,
|
||||||
isStale: result.isStale
|
isStale: result.isStale,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -179,3 +179,13 @@ export const logFilesRelations = relations(logFiles, ({ one }) => ({
|
|||||||
references: [users.id],
|
references: [users.id],
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
export const leaderboardSnapshots = pgTable('leaderboard_snapshots', {
|
||||||
|
id: integer('id').primaryKey().generatedByDefaultAsIdentity(),
|
||||||
|
channelId: text('channel_id').notNull(),
|
||||||
|
timestamp: timestamp('timestamp').notNull().defaultNow(),
|
||||||
|
data: json('data').notNull(),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const leaderboardSnapshotsRelations = relations(leaderboardSnapshots, ({}) => ({}))
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
import { redis } from '../redis'
|
import { redis } from '../redis'
|
||||||
import { type LeaderboardEntry, neatqueue_service } from './neatqueue.service'
|
import { type LeaderboardEntry, neatqueue_service } from './neatqueue.service'
|
||||||
import { db } from '@/server/db'
|
import { db } from '@/server/db'
|
||||||
import { metadata } from '@/server/db/schema'
|
import { leaderboardSnapshots, metadata } from '@/server/db/schema'
|
||||||
import { eq } from 'drizzle-orm'
|
import { eq, desc } from 'drizzle-orm'
|
||||||
|
import { sql } from 'drizzle-orm'
|
||||||
|
|
||||||
export type LeaderboardResponse = {
|
export type LeaderboardResponse = {
|
||||||
data: LeaderboardEntry[]
|
data: LeaderboardEntry[]
|
||||||
isStale: boolean
|
isStale: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type LeaderboardSnapshotResponse = {
|
||||||
|
data: LeaderboardEntry[]
|
||||||
|
timestamp: string
|
||||||
|
channel_id: string
|
||||||
|
}
|
||||||
|
|
||||||
export type UserRankResponse = {
|
export type UserRankResponse = {
|
||||||
data: LeaderboardEntry
|
data: LeaderboardEntry
|
||||||
isStale: boolean
|
isStale: boolean
|
||||||
@@ -31,12 +38,22 @@ export class LeaderboardService {
|
|||||||
return `backup_leaderboard_${channel_id}`
|
return `backup_leaderboard_${channel_id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getSnapshotKey(channel_id: string, timestamp: string): string {
|
||||||
|
return `snapshot_leaderboard_${channel_id}_${timestamp}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSnapshotPrefix(channel_id: string): string {
|
||||||
|
return `snapshot_leaderboard_${channel_id}_`
|
||||||
|
}
|
||||||
|
|
||||||
async refreshLeaderboard(channel_id: string): Promise<LeaderboardResponse> {
|
async refreshLeaderboard(channel_id: string): Promise<LeaderboardResponse> {
|
||||||
try {
|
try {
|
||||||
const fresh = await neatqueue_service.get_leaderboard(channel_id)
|
const fresh = await neatqueue_service.get_leaderboard(channel_id)
|
||||||
const zsetKey = this.getZSetKey(channel_id)
|
const zsetKey = this.getZSetKey(channel_id)
|
||||||
const rawKey = this.getRawKey(channel_id)
|
const rawKey = this.getRawKey(channel_id)
|
||||||
const backupKey = this.getBackupKey(channel_id)
|
const backupKey = this.getBackupKey(channel_id)
|
||||||
|
const timestamp = new Date().toISOString()
|
||||||
|
const snapshotKey = this.getSnapshotKey(channel_id, timestamp.replace(/[:.]/g, '_'))
|
||||||
|
|
||||||
const pipeline = redis.pipeline()
|
const pipeline = redis.pipeline()
|
||||||
pipeline.setex(rawKey, 180, JSON.stringify(fresh))
|
pipeline.setex(rawKey, 180, JSON.stringify(fresh))
|
||||||
@@ -53,14 +70,35 @@ export class LeaderboardService {
|
|||||||
pipeline.expire(zsetKey, 180)
|
pipeline.expire(zsetKey, 180)
|
||||||
await pipeline.exec()
|
await pipeline.exec()
|
||||||
|
|
||||||
// Store the latest successful leaderboard data in the database
|
// Store the snapshot in the dedicated leaderboardSnapshots table
|
||||||
|
await db
|
||||||
|
.insert(leaderboardSnapshots)
|
||||||
|
.values({
|
||||||
|
channelId: channel_id,
|
||||||
|
timestamp: new Date(timestamp),
|
||||||
|
data: fresh,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Also store the snapshot with a unique timestamp-based key in metadata for backward compatibility
|
||||||
|
await db
|
||||||
|
.insert(metadata)
|
||||||
|
.values({
|
||||||
|
key: snapshotKey,
|
||||||
|
value: JSON.stringify({
|
||||||
|
data: fresh,
|
||||||
|
timestamp,
|
||||||
|
channel_id,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Also store/update the latest successful leaderboard data for backward compatibility
|
||||||
await db
|
await db
|
||||||
.insert(metadata)
|
.insert(metadata)
|
||||||
.values({
|
.values({
|
||||||
key: backupKey,
|
key: backupKey,
|
||||||
value: JSON.stringify({
|
value: JSON.stringify({
|
||||||
data: fresh,
|
data: fresh,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
@@ -68,7 +106,7 @@ export class LeaderboardService {
|
|||||||
set: {
|
set: {
|
||||||
value: JSON.stringify({
|
value: JSON.stringify({
|
||||||
data: fresh,
|
data: fresh,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -130,6 +168,64 @@ export class LeaderboardService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get historical leaderboard snapshots for a channel
|
||||||
|
* @param channel_id The channel ID
|
||||||
|
* @param limit Optional limit on the number of snapshots to return (default: 100)
|
||||||
|
* @returns Array of leaderboard snapshots
|
||||||
|
*/
|
||||||
|
async getLeaderboardSnapshots(
|
||||||
|
channel_id: string,
|
||||||
|
limit: number = 100
|
||||||
|
): Promise<LeaderboardSnapshotResponse[]> {
|
||||||
|
try {
|
||||||
|
// Query the dedicated leaderboardSnapshots table
|
||||||
|
const snapshots = await db
|
||||||
|
.select()
|
||||||
|
.from(leaderboardSnapshots)
|
||||||
|
.where(eq(leaderboardSnapshots.channelId, channel_id))
|
||||||
|
.orderBy(desc(leaderboardSnapshots.timestamp)) // Most recent first
|
||||||
|
.limit(limit)
|
||||||
|
|
||||||
|
// Map the snapshots to the expected response format
|
||||||
|
return snapshots.map((snapshot) => {
|
||||||
|
return {
|
||||||
|
data: snapshot.data as LeaderboardEntry[],
|
||||||
|
timestamp: snapshot.timestamp.toISOString(),
|
||||||
|
channel_id: snapshot.channelId,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting leaderboard snapshots from dedicated table:', error)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fallback to the old metadata table approach if the new table query fails
|
||||||
|
const prefix = this.getSnapshotPrefix(channel_id)
|
||||||
|
|
||||||
|
// Query the database for all entries with keys that start with the snapshot prefix
|
||||||
|
const oldSnapshots = await db
|
||||||
|
.select()
|
||||||
|
.from(metadata)
|
||||||
|
.where(sql`${metadata.key} LIKE ${prefix + '%'}`)
|
||||||
|
.orderBy(sql`${metadata.key} DESC`) // Most recent first
|
||||||
|
.limit(limit)
|
||||||
|
|
||||||
|
// Parse the snapshots
|
||||||
|
return oldSnapshots.map((snapshot) => {
|
||||||
|
const parsedValue = JSON.parse(snapshot.value)
|
||||||
|
return {
|
||||||
|
data: parsedValue.data as LeaderboardEntry[],
|
||||||
|
timestamp: parsedValue.timestamp,
|
||||||
|
channel_id: parsedValue.channel_id,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (fallbackError) {
|
||||||
|
console.error('Error getting leaderboard snapshots from metadata fallback:', fallbackError)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getUserRank(channel_id: string, user_id: string): Promise<UserRankResponse> {
|
async getUserRank(channel_id: string, user_id: string): Promise<UserRankResponse> {
|
||||||
try {
|
try {
|
||||||
// Try to get user data from Redis first
|
// Try to get user data from Redis first
|
||||||
|
|||||||
Reference in New Issue
Block a user