feat: aggregate module
This commit is contained in:
@@ -9,3 +9,9 @@ export enum RedisKeys {
|
||||
export enum RedisItems {
|
||||
Ips = 'ips',
|
||||
}
|
||||
|
||||
export enum CacheKeys {
|
||||
AggregateCatch = 'aggregate_catch',
|
||||
SiteMapCatch = 'aggregate_sitemap_catch',
|
||||
RSS = 'rss',
|
||||
}
|
||||
|
||||
@@ -1,6 +1,84 @@
|
||||
import { Controller } from '@nestjs/common'
|
||||
import { CacheKey, CacheTTL, Controller, Get, Query } from '@nestjs/common'
|
||||
import { ApiProperty } from '@nestjs/swagger'
|
||||
import { Auth } from '~/common/decorator/auth.decorator'
|
||||
import { ApiName } from '~/common/decorator/openapi.decorator'
|
||||
import { IsMaster } from '~/common/decorator/role.decorator'
|
||||
import { CacheKeys } from '~/constants/cache.constant'
|
||||
import { addConditionToSeeHideContent } from '~/utils/query.util'
|
||||
import { AnalyzeService } from '../analyze/analyze.service'
|
||||
import { ConfigsService } from '../configs/configs.service'
|
||||
import { TimelineQueryDto, TopQueryDto } from './aggregate.dto'
|
||||
import { AggregateService } from './aggregate.service'
|
||||
|
||||
@Controller('aggregate')
|
||||
@ApiName
|
||||
export class AggregateController {}
|
||||
export class AggregateController {
|
||||
constructor(
|
||||
private readonly aggregateService: AggregateService,
|
||||
private readonly configsService: ConfigsService,
|
||||
private readonly analyzeService: AnalyzeService,
|
||||
) {}
|
||||
|
||||
@Get('/')
|
||||
@CacheKey(CacheKeys.AggregateCatch)
|
||||
@CacheTTL(300)
|
||||
async aggregate(@IsMaster() isMaster: boolean) {
|
||||
const [user, categories, pageMeta, lastestNoteNid] = await Promise.all([
|
||||
this.configsService.getMaster(),
|
||||
this.aggregateService.getAllCategory(),
|
||||
this.aggregateService.getAllPages(),
|
||||
this.aggregateService
|
||||
.getLatestNote(addConditionToSeeHideContent(isMaster))
|
||||
.then((r) => r.nid),
|
||||
])
|
||||
return {
|
||||
user,
|
||||
seo: this.configsService.get('seo'),
|
||||
categories,
|
||||
pageMeta,
|
||||
lastestNoteNid,
|
||||
}
|
||||
}
|
||||
|
||||
@Get('/top')
|
||||
@ApiProperty({ description: '获取最新发布的内容' })
|
||||
async top(@Query() query: TopQueryDto, @IsMaster() isMaster: boolean) {
|
||||
const { size } = query
|
||||
return await this.aggregateService.topActivity(size, isMaster)
|
||||
}
|
||||
|
||||
@Get('/timeline')
|
||||
async getTimeline(@Query() query: TimelineQueryDto) {
|
||||
const { sort = 1, type, year } = query
|
||||
return { data: await this.aggregateService.getTimeline(year, type, sort) }
|
||||
}
|
||||
|
||||
@Get('/sitemap')
|
||||
@CacheKey(CacheKeys.SiteMapCatch)
|
||||
@CacheTTL(3600)
|
||||
async getSiteMapContent() {
|
||||
return { data: await this.aggregateService.getSiteMapContent() }
|
||||
}
|
||||
|
||||
@Get('/feed')
|
||||
@CacheKey(CacheKeys.RSS)
|
||||
@CacheTTL(3600)
|
||||
async getRSSFeed() {
|
||||
return await this.aggregateService.buildRssStructure()
|
||||
}
|
||||
|
||||
@Get('/stat')
|
||||
@Auth()
|
||||
async stat() {
|
||||
const [count, callTime, todayIpAccess] = await Promise.all([
|
||||
this.aggregateService.getCounts(),
|
||||
this.analyzeService.getCallTime(),
|
||||
this.analyzeService.getTodayAccessIp(),
|
||||
])
|
||||
return {
|
||||
...count,
|
||||
...callTime,
|
||||
todayIpAccessCount: todayIpAccess.length,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
34
src/modules/aggregate/aggregate.dto.ts
Normal file
34
src/modules/aggregate/aggregate.dto.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { ApiProperty } from '@nestjs/swagger'
|
||||
import { Transform } from 'class-transformer'
|
||||
import { IsEnum, IsInt, IsOptional, Max, Min } from 'class-validator'
|
||||
|
||||
export class TopQueryDto {
|
||||
@Transform(({ value: val }) => parseInt(val))
|
||||
@Min(1)
|
||||
@Max(10)
|
||||
@IsOptional()
|
||||
size?: number
|
||||
}
|
||||
export enum TimelineType {
|
||||
Post,
|
||||
Note,
|
||||
}
|
||||
|
||||
export class TimelineQueryDto {
|
||||
@Transform(({ value: val }) => Number(val))
|
||||
@IsEnum([1, -1])
|
||||
@IsOptional()
|
||||
@ApiProperty({ enum: [-1, 1] })
|
||||
sort?: -1 | 1
|
||||
|
||||
@Transform(({ value: val }) => Number(val))
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
year?: number
|
||||
|
||||
@IsEnum(TimelineType)
|
||||
@IsOptional()
|
||||
@ApiProperty({ enum: [0, 1] })
|
||||
@Transform(({ value: v }) => v | 0)
|
||||
type?: TimelineType
|
||||
}
|
||||
12
src/modules/aggregate/aggregate.interface.ts
Normal file
12
src/modules/aggregate/aggregate.interface.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export interface RSSProps {
|
||||
title: string
|
||||
url: string
|
||||
author: string
|
||||
data: {
|
||||
created: Date
|
||||
modified: Date
|
||||
link: string
|
||||
title: string
|
||||
text: string
|
||||
}[]
|
||||
}
|
||||
@@ -1,4 +1,31 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { forwardRef, Module } from '@nestjs/common'
|
||||
import { GatewayModule } from '~/processors/gateway/gateway.module'
|
||||
import { AnalyzeModule } from '../analyze/analyze.module'
|
||||
import { CategoryModule } from '../category/category.module'
|
||||
import { CommentModule } from '../comment/comment.module'
|
||||
import { LinkModule } from '../link/link.module'
|
||||
import { NoteModule } from '../note/note.module'
|
||||
import { PageModule } from '../page/page.module'
|
||||
import { PostModule } from '../post/post.module'
|
||||
import { SayModule } from '../say/say.module'
|
||||
import { AggregateController } from './aggregate.controller'
|
||||
import { AggregateService } from './aggregate.service'
|
||||
|
||||
@Module({})
|
||||
@Module({
|
||||
imports: [
|
||||
forwardRef(() => CategoryModule),
|
||||
forwardRef(() => PostModule),
|
||||
forwardRef(() => NoteModule),
|
||||
forwardRef(() => PageModule),
|
||||
forwardRef(() => SayModule),
|
||||
forwardRef(() => CommentModule),
|
||||
forwardRef(() => LinkModule),
|
||||
|
||||
AnalyzeModule,
|
||||
GatewayModule,
|
||||
],
|
||||
providers: [AggregateService],
|
||||
exports: [AggregateService],
|
||||
controllers: [AggregateController],
|
||||
})
|
||||
export class AggregateModule {}
|
||||
|
||||
@@ -1,4 +1,356 @@
|
||||
import { Injectable } from '@nestjs/common'
|
||||
|
||||
import { forwardRef, Inject, Injectable } from '@nestjs/common'
|
||||
import { DocumentType, ReturnModelType } from '@typegoose/typegoose'
|
||||
import { AnyParamConstructor } from '@typegoose/typegoose/lib/types'
|
||||
import dayjs from 'dayjs'
|
||||
import { pick } from 'lodash'
|
||||
import { FilterQuery } from 'mongoose'
|
||||
import { RedisKeys } from '~/constants/cache.constant'
|
||||
import { CacheService } from '~/processors/cache/cache.service'
|
||||
import { WebEventsGateway } from '~/processors/gateway/web/events.gateway'
|
||||
import { addYearCondition } from '~/utils/query.util'
|
||||
import { getRedisKey } from '~/utils/redis.util'
|
||||
import { CategoryModel } from '../category/category.model'
|
||||
import { CategoryService } from '../category/category.service'
|
||||
import { CommentState } from '../comment/comment.model'
|
||||
import { CommentService } from '../comment/comment.service'
|
||||
import { ConfigsService } from '../configs/configs.service'
|
||||
import { LinkState } from '../link/link.model'
|
||||
import { LinkService } from '../link/link.service'
|
||||
import { NoteModel } from '../note/note.model'
|
||||
import { NoteService } from '../note/note.service'
|
||||
import { PageService } from '../page/page.service'
|
||||
import { PostService } from '../post/post.service'
|
||||
import { SayService } from '../say/say.service'
|
||||
import { TimelineType } from './aggregate.dto'
|
||||
import { RSSProps } from './aggregate.interface'
|
||||
@Injectable()
|
||||
export class AggregateService {}
|
||||
export class AggregateService {
|
||||
constructor(
|
||||
@Inject(forwardRef(() => PostService))
|
||||
private readonly postService: PostService,
|
||||
@Inject(forwardRef(() => NoteService))
|
||||
private readonly noteService: NoteService,
|
||||
|
||||
@Inject(forwardRef(() => CategoryService))
|
||||
private readonly categoryService: CategoryService,
|
||||
|
||||
@Inject(forwardRef(() => PageService))
|
||||
private readonly pageService: PageService,
|
||||
|
||||
@Inject(forwardRef(() => SayService))
|
||||
private readonly sayService: SayService,
|
||||
|
||||
@Inject(forwardRef(() => CommentService))
|
||||
private readonly commentService: CommentService,
|
||||
@Inject(forwardRef(() => LinkService))
|
||||
private readonly linkService: LinkService,
|
||||
|
||||
private readonly configs: ConfigsService,
|
||||
private readonly gateway: WebEventsGateway,
|
||||
private readonly cacheService: CacheService,
|
||||
) {}
|
||||
|
||||
getAllCategory() {
|
||||
return this.categoryService.findAllCategory()
|
||||
}
|
||||
|
||||
getAllPages() {
|
||||
return this.pageService.model.find({}, 'title _id slug order').lean()
|
||||
}
|
||||
|
||||
async getLatestNote(cond: FilterQuery<DocumentType<NoteModel>> = {}) {
|
||||
return (await this.noteService.getLatestOne(cond)).latest
|
||||
}
|
||||
|
||||
private findTop<
|
||||
U extends AnyParamConstructor<any>,
|
||||
T extends ReturnModelType<U>,
|
||||
>(model: T, condition = {}, size = 6) {
|
||||
return model
|
||||
.find(condition)
|
||||
.sort({ created: -1 })
|
||||
.limit(size)
|
||||
.select('_id title name slug avatar nid')
|
||||
}
|
||||
|
||||
async topActivity(size = 6, isMaster = false) {
|
||||
const [notes, posts, says] = await Promise.all([
|
||||
this.findTop(
|
||||
this.noteService.model,
|
||||
!isMaster
|
||||
? {
|
||||
hide: false,
|
||||
password: undefined,
|
||||
}
|
||||
: {},
|
||||
size,
|
||||
).lean(),
|
||||
|
||||
this.findTop(
|
||||
this.postService.model,
|
||||
!isMaster ? { hide: false } : {},
|
||||
size,
|
||||
)
|
||||
.populate('categoryId')
|
||||
.lean()
|
||||
.then((res) => {
|
||||
return res.map((post) => {
|
||||
post.category = pick(post.categoryId, ['name', 'slug'])
|
||||
delete post.categoryId
|
||||
return post
|
||||
})
|
||||
}),
|
||||
|
||||
this.sayService.model.find({}).sort({ create: -1 }).limit(size),
|
||||
])
|
||||
|
||||
return { notes, posts, says }
|
||||
}
|
||||
|
||||
async getTimeline(year: number, type: TimelineType, sortBy: 1 | -1 = 1) {
|
||||
const data: any = {}
|
||||
const getPosts = () =>
|
||||
this.postService.model
|
||||
.find({ hide: false, ...addYearCondition(year) })
|
||||
.sort({ created: sortBy })
|
||||
.populate('category')
|
||||
.lean()
|
||||
.then((list) =>
|
||||
list.map((item) => ({
|
||||
...pick(item, ['_id', 'title', 'slug', 'created', 'modified']),
|
||||
category: item.category,
|
||||
// summary:
|
||||
// item.summary ??
|
||||
// (item.text.length > 150
|
||||
// ? item.text.slice(0, 150) + '...'
|
||||
// : item.text),
|
||||
url: encodeURI(
|
||||
'/posts/' +
|
||||
(item.category as CategoryModel).slug +
|
||||
'/' +
|
||||
item.slug,
|
||||
),
|
||||
})),
|
||||
)
|
||||
|
||||
const getNotes = () =>
|
||||
this.noteService.model
|
||||
.find(
|
||||
{
|
||||
hide: false,
|
||||
password: undefined,
|
||||
...addYearCondition(year),
|
||||
},
|
||||
'_id nid title weather mood created modified hasMemory',
|
||||
)
|
||||
.sort({ created: sortBy })
|
||||
.lean()
|
||||
|
||||
switch (type) {
|
||||
case TimelineType.Post: {
|
||||
data.posts = await getPosts()
|
||||
break
|
||||
}
|
||||
case TimelineType.Note: {
|
||||
data.notes = await getNotes()
|
||||
break
|
||||
}
|
||||
default: {
|
||||
const tasks = await Promise.all([getPosts(), getNotes()])
|
||||
data.posts = [tasks[0]]
|
||||
data.notes = [tasks[1]]
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
async getSiteMapContent() {
|
||||
const baseURL = this.configs.get('url').webUrl
|
||||
|
||||
const combineTasks = await Promise.all([
|
||||
this.pageService.model
|
||||
.find()
|
||||
.lean()
|
||||
.then((list) =>
|
||||
list.map((doc) => ({
|
||||
url: new URL(`/${doc.slug}`, baseURL),
|
||||
published_at: doc.modified,
|
||||
})),
|
||||
),
|
||||
|
||||
this.noteService.model
|
||||
.find({
|
||||
hide: false,
|
||||
$or: [
|
||||
{ password: undefined },
|
||||
{ password: { $exists: false } },
|
||||
{ password: null },
|
||||
],
|
||||
secret: {
|
||||
$lte: new Date(),
|
||||
},
|
||||
})
|
||||
.lean()
|
||||
.then((list) =>
|
||||
list.map((doc) => {
|
||||
return {
|
||||
url: new URL(`/notes/${doc.nid}`, baseURL),
|
||||
published_at: doc.modified,
|
||||
}
|
||||
}),
|
||||
),
|
||||
|
||||
this.postService.model
|
||||
.find({
|
||||
hide: false,
|
||||
})
|
||||
.populate('category')
|
||||
.then((list) =>
|
||||
list.map((doc) => {
|
||||
return {
|
||||
url: new URL(
|
||||
`/posts/${(doc.category as CategoryModel).slug}/${doc.slug}`,
|
||||
baseURL,
|
||||
),
|
||||
published_at: doc.modified,
|
||||
}
|
||||
}),
|
||||
),
|
||||
])
|
||||
|
||||
return combineTasks
|
||||
.flat(1)
|
||||
.sort((a, b) => -(a.published_at.getTime() - b.published_at.getTime()))
|
||||
}
|
||||
|
||||
async buildRssStructure(): Promise<RSSProps> {
|
||||
const data = await this.getRSSFeedContent()
|
||||
const title = this.configs.get('seo').title
|
||||
const author = (await this.configs.getMaster()).name
|
||||
const url = this.configs.get('url').webUrl
|
||||
return {
|
||||
title,
|
||||
author,
|
||||
url,
|
||||
data,
|
||||
}
|
||||
}
|
||||
async getRSSFeedContent() {
|
||||
const baseURL = this.configs.get('url').webUrl
|
||||
|
||||
const [posts, notes] = await Promise.all([
|
||||
this.postService.model
|
||||
.find({ hide: false })
|
||||
.limit(10)
|
||||
.sort({ created: -1 })
|
||||
.populate('category'),
|
||||
|
||||
this.noteService.model
|
||||
.find({
|
||||
hide: false,
|
||||
$or: [
|
||||
{ password: undefined },
|
||||
{ password: { $exists: false } },
|
||||
{ password: null },
|
||||
],
|
||||
})
|
||||
.limit(10)
|
||||
.sort({ created: -1 }),
|
||||
])
|
||||
|
||||
const postsRss: RSSProps['data'] = posts.map((post) => {
|
||||
return {
|
||||
title: post.title,
|
||||
text: post.text,
|
||||
created: post.created,
|
||||
modified: post.modified,
|
||||
link: new URL(
|
||||
'/posts' + `/${(post.category as CategoryModel).slug}/${post.slug}`,
|
||||
baseURL,
|
||||
).toString(),
|
||||
}
|
||||
})
|
||||
const notesRss: RSSProps['data'] = notes.map((note) => {
|
||||
const isSecret = note.secret
|
||||
? dayjs(note.secret).isAfter(new Date())
|
||||
: false
|
||||
return {
|
||||
title: note.title,
|
||||
text: isSecret ? '这篇文章暂时没有公开呢' : note.text,
|
||||
created: note.created,
|
||||
modified: note.modified,
|
||||
link: new URL('/notes/' + note.nid, baseURL).toString(),
|
||||
}
|
||||
})
|
||||
return postsRss
|
||||
.concat(notesRss)
|
||||
.sort((a, b) => b.created.getTime() - a.created.getTime())
|
||||
.slice(0, 10)
|
||||
}
|
||||
|
||||
async getCounts() {
|
||||
const online = this.gateway.wsClients.length
|
||||
|
||||
const redisClient = this.cacheService.getClient()
|
||||
const dateFormat = dayjs().format('YYYY-MM-DD')
|
||||
|
||||
const [
|
||||
posts,
|
||||
notes,
|
||||
pages,
|
||||
says,
|
||||
comments,
|
||||
allComments,
|
||||
unreadComments,
|
||||
links,
|
||||
linkApply,
|
||||
categories,
|
||||
] = await Promise.all([
|
||||
this.postService.model.countDocuments(),
|
||||
this.noteService.model.countDocuments(),
|
||||
this.categoryService.model.countDocuments(),
|
||||
this.sayService.model.countDocuments(),
|
||||
this.commentService.model.countDocuments({
|
||||
parent: null,
|
||||
$or: [{ state: CommentState.Read }, { state: CommentState.Unread }],
|
||||
}),
|
||||
this.commentService.model.countDocuments({
|
||||
$or: [{ state: CommentState.Read }, { state: CommentState.Unread }],
|
||||
}),
|
||||
this.commentService.model.countDocuments({
|
||||
state: CommentState.Unread,
|
||||
}),
|
||||
this.linkService.model.countDocuments({
|
||||
state: LinkState.Pass,
|
||||
}),
|
||||
this.linkService.model.countDocuments({
|
||||
state: LinkState.Audit,
|
||||
}),
|
||||
this.categoryService.model.countDocuments({}),
|
||||
])
|
||||
|
||||
const [todayMaxOnline, todayOnlineTotal] = await Promise.all([
|
||||
redisClient.get(getRedisKey(RedisKeys.MaxOnlineCount, dateFormat)),
|
||||
redisClient.get(
|
||||
getRedisKey(RedisKeys.MaxOnlineCount, dateFormat, 'total'),
|
||||
),
|
||||
])
|
||||
|
||||
return {
|
||||
allComments,
|
||||
categories,
|
||||
comments,
|
||||
linkApply,
|
||||
links,
|
||||
notes,
|
||||
pages,
|
||||
posts,
|
||||
says,
|
||||
unreadComments,
|
||||
online,
|
||||
todayMaxOnline: todayMaxOnline || 0,
|
||||
todayOnlineTotal: todayOnlineTotal || 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { InjectModel } from 'nestjs-typegoose'
|
||||
import { CannotFindException } from '~/common/exceptions/cant-find.exception'
|
||||
import { PostModel } from '../post/post.model'
|
||||
import { PostService } from '../post/post.service'
|
||||
import { CategoryModel } from './category.model'
|
||||
import { CategoryModel, CategoryType } from './category.model'
|
||||
|
||||
@Injectable()
|
||||
export class CategoryService {
|
||||
@@ -31,7 +31,7 @@ export class CategoryService {
|
||||
}
|
||||
|
||||
async findAllCategory() {
|
||||
const data = await this.model.find().lean()
|
||||
const data = await this.model.find({ type: CategoryType.Category }).lean()
|
||||
const counts = await Promise.all(
|
||||
data.map((item) => {
|
||||
const id = item._id
|
||||
|
||||
@@ -101,6 +101,7 @@ export class ConfigsService {
|
||||
}
|
||||
|
||||
get getMaster() {
|
||||
return this.userService.getMaster
|
||||
// HINT: 需要注入 this 的指向
|
||||
return this.userService.getMaster.bind(this.userService)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { SayController } from './say.controller'
|
||||
import { SayService } from './say.service'
|
||||
|
||||
@Module({ controllers: [SayController] })
|
||||
@Module({
|
||||
controllers: [SayController],
|
||||
providers: [SayService],
|
||||
exports: [SayService],
|
||||
})
|
||||
export class SayModule {}
|
||||
|
||||
14
src/modules/say/say.service.ts
Normal file
14
src/modules/say/say.service.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Injectable } from '@nestjs/common'
|
||||
import { InjectModel } from 'nestjs-typegoose'
|
||||
import { SayModel } from './say.model'
|
||||
|
||||
@Injectable()
|
||||
export class SayService {
|
||||
constructor(
|
||||
@InjectModel(SayModel) private readonly sayModel: MongooseModel<SayModel>,
|
||||
) {}
|
||||
|
||||
public get model() {
|
||||
return this.sayModel
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,10 @@ import {
|
||||
prop,
|
||||
Severity,
|
||||
} from '@typegoose/typegoose'
|
||||
import { Schema } from 'mongoose'
|
||||
|
||||
import { hashSync } from 'bcrypt'
|
||||
import { Schema } from 'mongoose'
|
||||
import { BaseModel } from '~/shared/model/base.model'
|
||||
|
||||
export type UserDocument = DocumentType<UserModel>
|
||||
|
||||
export class OAuthModel {
|
||||
@@ -72,7 +72,7 @@ export class UserModel extends BaseModel {
|
||||
@prop({ type: Schema.Types.Mixed })
|
||||
socialIds?: any
|
||||
|
||||
@prop({ select: true, required: true })
|
||||
@prop({ select: false, required: true })
|
||||
authCode!: string
|
||||
|
||||
@prop({ type: TokenModel, select: false })
|
||||
|
||||
Reference in New Issue
Block a user