Skip to content

Commit 50fb01d

Browse files
committed
feat: add lightweight /reading/top endpoint and optimize /reading/rank
- Add GET /activity/reading/top with Redis cache (300s TTL) - Support top/days query params for flexible top readings - Add limit param to /reading/rank, select only payload field - Sort and slice before querying ref documents for better performance
1 parent db4de1f commit 50fb01d

File tree

4 files changed

+104
-63
lines changed

4 files changed

+104
-63
lines changed

apps/core/src/constants/cache.constant.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export enum RedisKeys {
3333
AnalyzeAggregate = 'analyze_aggregate',
3434
AnalyzeTrafficSource = 'analyze_traffic_source',
3535
AnalyzeDeviceDistribution = 'analyze_device_distribution',
36+
37+
ActivityTopReading = 'activity_top_reading',
3638
}
3739
export const API_CACHE_PREFIX = 'mx-api-cache:'
3840
export enum CacheKeys {

apps/core/src/modules/activity/activity.controller.ts

Lines changed: 67 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@ import { Auth } from '~/common/decorators/auth.decorator'
44
import { HTTPDecorators } from '~/common/decorators/http.decorator'
55
import { IpLocation } from '~/common/decorators/ip.decorator'
66
import type { IpRecord } from '~/common/decorators/ip.decorator'
7+
import { RedisKeys } from '~/constants/cache.constant'
78
import { CollectionRefTypes } from '~/constants/db.constant'
9+
import { RedisService } from '~/processors/redis/redis.service'
810
import { PagerDto } from '~/shared/dto/pager.dto'
911
import { snakecaseKeysWithCompat } from '~/utils/case.util'
12+
import { getRedisKey } from '~/utils/redis.util'
1013
import { keyBy, pick } from 'es-toolkit/compat'
1114
import { ReaderService } from '../reader/reader.service'
1215
import { Activity } from './activity.constant'
@@ -15,6 +18,7 @@ import {
1518
ActivityNotificationDto,
1619
ActivityQueryDto,
1720
ActivityRangeDto,
21+
ActivityTopReadingsDto,
1822
ActivityTypeParamsDto,
1923
GetPresenceQueryDto,
2024
LikeBodyDto,
@@ -27,8 +31,24 @@ export class ActivityController {
2731
constructor(
2832
private readonly service: ActivityService,
2933
private readonly readerService: ReaderService,
34+
private readonly redisService: RedisService,
3035
) {}
3136

37+
private async getOrSetCache<T>(
38+
key: string,
39+
ttlSeconds: number,
40+
getValue: () => Promise<T>,
41+
): Promise<T> {
42+
const client = this.redisService.getClient()
43+
const cached = await client.get(key)
44+
if (cached) {
45+
return JSON.parse(cached)
46+
}
47+
const value = await getValue()
48+
await client.set(key, JSON.stringify(value), 'EX', ttlSeconds)
49+
return value
50+
}
51+
3252
@Post('/like')
3353
async thumbsUpArticle(
3454
@Body() body: LikeBodyDto,
@@ -164,38 +184,59 @@ export class ActivityController {
164184
}
165185
}
166186

187+
@Auth()
188+
@Get('/reading/top')
189+
async getTopReadings(@Query() query: ActivityTopReadingsDto) {
190+
const top = query.top ?? 5
191+
const days = query.days ?? 14
192+
const cacheKey = getRedisKey(
193+
RedisKeys.ActivityTopReading,
194+
String(top),
195+
String(days),
196+
)
197+
return this.getOrSetCache(cacheKey, 300, async () => {
198+
const result = await this.service.getTopReadings(top, days)
199+
return result.map((item) => ({
200+
...item,
201+
ref: pick(item.ref, [
202+
'title',
203+
'slug',
204+
'cover',
205+
'created',
206+
'category',
207+
'categoryId',
208+
'id',
209+
'nid',
210+
]),
211+
}))
212+
})
213+
}
214+
167215
@Auth()
168216
@Get('/reading/rank')
169217
async getReadingRangeRank(@Query() query: ActivityRangeDto) {
170218
const startAt = query.start ? new Date(query.start) : undefined
171219
const endAt = query.end ? new Date(query.end) : undefined
220+
const limit = query.limit ?? 50
172221

173-
return this.service
174-
.getDateRangeOfReadings(startAt, endAt)
175-
.then((arr) => {
176-
return arr.sort((a, b) => {
177-
return b.count - a.count
178-
})
179-
})
180-
.then((arr) => {
181-
// omit ref fields
182-
183-
return arr.map((item) => {
184-
return {
185-
...item,
186-
ref: pick(item.ref, [
187-
'title',
188-
'slug',
189-
'cover',
190-
'created',
191-
'category',
192-
'categoryId',
193-
'id',
194-
'nid',
195-
]),
196-
}
197-
})
198-
})
222+
const result = await this.service.getDateRangeOfReadings(
223+
startAt,
224+
endAt,
225+
limit,
226+
)
227+
return result.map((item) => ({
228+
...item,
229+
ref: pick(item.ref, [
230+
'title',
231+
'slug',
232+
'cover',
233+
'created',
234+
'category',
235+
'categoryId',
236+
'id',
237+
'nid',
238+
]),
239+
}))
199240
}
200241

201242
@Get('/recent')

apps/core/src/modules/activity/activity.schema.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,23 @@ export class ActivityQueryDto extends createZodDto(ActivityQuerySchema) {}
4343
export const ActivityRangeSchema = z.object({
4444
start: zCoerceInt.optional(),
4545
end: zCoerceInt.optional(),
46+
limit: zCoerceInt.min(1).max(200).default(50).optional(),
4647
})
4748

4849
export class ActivityRangeDto extends createZodDto(ActivityRangeSchema) {}
4950

51+
/**
52+
* Activity top readings schema
53+
*/
54+
export const ActivityTopReadingsSchema = z.object({
55+
top: zCoerceInt.min(1).max(50).default(5).optional(),
56+
days: zCoerceInt.min(1).max(365).default(14).optional(),
57+
})
58+
59+
export class ActivityTopReadingsDto extends createZodDto(
60+
ActivityTopReadingsSchema,
61+
) {}
62+
5063
/**
5164
* Activity notification schema
5265
*/
@@ -105,6 +118,7 @@ export type ActivityRangeInput = z.infer<typeof ActivityRangeSchema>
105118
export type ActivityNotificationInput = z.infer<
106119
typeof ActivityNotificationSchema
107120
>
121+
export type ActivityTopReadingsInput = z.infer<typeof ActivityTopReadingsSchema>
108122
export type LikeBodyInput = z.infer<typeof LikeBodySchema>
109123
export type UpdatePresenceInput = z.infer<typeof UpdatePresenceSchema>
110124
export type GetPresenceQueryInput = z.infer<typeof GetPresenceQuerySchema>

apps/core/src/modules/activity/activity.service.ts

Lines changed: 21 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,10 @@ import { CommentService } from '../comment/comment.service'
3030
import { ConfigsService } from '../configs/configs.service'
3131
import type { NoteModel } from '../note/note.model'
3232
import { NoteService } from '../note/note.service'
33-
import type { PageModel } from '../page/page.model'
3433
import type { PostModel } from '../post/post.model'
3534
import type { PostService } from '../post/post.service'
3635
import { ReaderModel } from '../reader/reader.model'
3736
import { ReaderService } from '../reader/reader.service'
38-
import type { RecentlyModel } from '../recently/recently.model'
3937
import { Activity } from './activity.constant'
4038
import type {
4139
ActivityLikePayload,
@@ -503,7 +501,7 @@ export class ActivityService implements OnModuleInit, OnModuleDestroy {
503501
return { objects }
504502
}
505503

506-
async getDateRangeOfReadings(startAt?: Date, endAt?: Date) {
504+
async getDateRangeOfReadings(startAt?: Date, endAt?: Date, limit = 50) {
507505
startAt = startAt ?? new Date('2020-01-01')
508506
endAt = endAt ?? new Date()
509507

@@ -515,52 +513,38 @@ export class ActivityService implements OnModuleInit, OnModuleDestroy {
515513
},
516514
type: Activity.ReadDuration,
517515
})
516+
.select('payload')
518517
.lean({
519518
getters: true,
520519
})
521520

522-
const refIds = new Set<string>()
521+
const countMap = new Map<string, number>()
523522
for (const item of activities) {
524-
const parsed = item.payload
525-
const refId = extractArticleIdFromRoomName(parsed.roomName)
523+
const refId = extractArticleIdFromRoomName(item.payload.roomName)
526524
if (!refId) continue
527-
refIds.add(refId)
525+
countMap.set(refId, (countMap.get(refId) || 0) + 1)
528526
}
529527

530-
const activityCountingMap = activities.reduce(
531-
(acc, item) => {
532-
const refId = extractArticleIdFromRoomName(item.payload.roomName)
533-
if (!refId) return acc
534-
if (!acc[refId]) {
535-
acc[refId] = 0
536-
}
537-
acc[refId]++
538-
539-
return acc
540-
},
541-
{} as Record<string, number>,
542-
)
528+
const sorted = [...countMap.entries()]
529+
.sort((a, b) => b[1] - a[1])
530+
.slice(0, limit)
543531

544-
const result = [] as {
545-
refId: string
546-
count: number
547-
ref: PostModel | NoteModel | PageModel | RecentlyModel
548-
}[]
532+
const topRefIds = sorted.map(([id]) => id)
533+
const idsCollections = await this.databaseService.findGlobalByIds(topRefIds)
534+
const mapping = this.databaseService.flatCollectionToMap(idsCollections)
549535

550-
const idsCollections = await this.databaseService.findGlobalByIds(
551-
Array.from(refIds),
552-
)
536+
return sorted.map(([refId, count]) => ({
537+
refId,
538+
count,
539+
ref: mapping[refId],
540+
}))
541+
}
553542

554-
const mapping = this.databaseService.flatCollectionToMap(idsCollections)
555-
for (const refId of refIds) {
556-
result.push({
557-
refId,
558-
count: activityCountingMap[refId] ?? 0,
559-
ref: mapping[refId],
560-
})
561-
}
543+
async getTopReadings(limit = 5, days = 14) {
544+
const endAt = new Date()
545+
const startAt = new Date(Date.now() - days * 24 * 60 * 60 * 1000)
562546

563-
return result
547+
return this.getDateRangeOfReadings(startAt, endAt, limit)
564548
}
565549

566550
async getRecentComment() {

0 commit comments

Comments
 (0)