feat: aggregate module

This commit is contained in:
Innei
2021-09-07 17:02:20 +08:00
parent 5c19ab741e
commit b22878d2e8
12 changed files with 543 additions and 14 deletions

BIN
paw.paw

Binary file not shown.

View File

@@ -9,3 +9,9 @@ export enum RedisKeys {
export enum RedisItems {
Ips = 'ips',
}
export enum CacheKeys {
AggregateCatch = 'aggregate_catch',
SiteMapCatch = 'aggregate_sitemap_catch',
RSS = 'rss',
}

View File

@@ -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,
}
}
}

View 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
}

View 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
}[]
}

View File

@@ -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 {}

View File

@@ -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,
}
}
}

View File

@@ -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

View File

@@ -101,6 +101,7 @@ export class ConfigsService {
}
get getMaster() {
return this.userService.getMaster
// HINT: 需要注入 this 的指向
return this.userService.getMaster.bind(this.userService)
}
}

View File

@@ -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 {}

View 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
}
}

View File

@@ -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 })