refactor: cache clean and ttl
Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -17,6 +17,7 @@ import { Inject, Injectable, Logger, RequestMethod } from '@nestjs/common'
|
||||
import { HttpAdapterHost, Reflector } from '@nestjs/core'
|
||||
|
||||
import { REDIS } from '~/app.config'
|
||||
import { API_CACHE_PREFIX } from '~/constants/cache.constant'
|
||||
import * as META from '~/constants/meta.constant'
|
||||
import * as SYSTEM from '~/constants/system.constant'
|
||||
import { CacheService } from '~/processors/redis/cache.service'
|
||||
@@ -63,7 +64,7 @@ export class HttpCacheInterceptor implements NestInterceptor {
|
||||
if (isDisableCache) {
|
||||
return call$
|
||||
}
|
||||
const key = this.trackBy(context) || `mx-api-cache:${request.url}`
|
||||
const key = this.trackBy(context) || `${API_CACHE_PREFIX}${request.url}`
|
||||
|
||||
const metaTTL = this.reflector.get(META.HTTP_CACHE_TTL_METADATA, handler)
|
||||
const ttl = metaTTL || REDIS.httpCacheTTL
|
||||
@@ -89,10 +90,6 @@ export class HttpCacheInterceptor implements NestInterceptor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @function trackBy
|
||||
* @description 目前的命中规则是:必须手动设置了 CacheKey 才会启用缓存机制,默认 ttl 为 APP_CONFIG.REDIS.defaultCacheTTL
|
||||
*/
|
||||
trackBy(context: ExecutionContext): string | undefined {
|
||||
const request = this.getRequest(context)
|
||||
const httpServer = this.httpAdapterHost.httpAdapter
|
||||
|
||||
@@ -25,11 +25,10 @@ export enum RedisKeys {
|
||||
/** 函数编译缓存 */
|
||||
FunctionComplieCache = 'function_complie_cache',
|
||||
}
|
||||
|
||||
export const API_CACHE_PREFIX = 'mx-api-cache:'
|
||||
export enum CacheKeys {
|
||||
AggregateCatch = 'mx-api-cache:aggregate_catch',
|
||||
SiteMapCatch = 'mx-api-cache:aggregate_sitemap_catch',
|
||||
SiteMapXmlCatch = 'mx-api-cache:aggregate_sitemap_xml_catch',
|
||||
RSS = 'mx-api-cache:rss',
|
||||
RSSXmlCatch = 'mx-api-cache:rss_xml_catch',
|
||||
SiteMapCatch = `${API_CACHE_PREFIX}aggregate_sitemap_catch`,
|
||||
SiteMapXmlCatch = `${API_CACHE_PREFIX}aggregate_sitemap_xml_catch`,
|
||||
RSS = `${API_CACHE_PREFIX}rss`,
|
||||
RSSXmlCatch = `${API_CACHE_PREFIX}rss_xml_catch`,
|
||||
}
|
||||
|
||||
@@ -10,7 +10,13 @@ import { CacheKeys } from '~/constants/cache.constant'
|
||||
|
||||
import { AnalyzeService } from '../analyze/analyze.service'
|
||||
import { ConfigsService } from '../configs/configs.service'
|
||||
import { TimelineQueryDto, TopQueryDto } from './aggregate.dto'
|
||||
import { NoteService } from '../note/note.service'
|
||||
import { SnippetService } from '../snippet/snippet.service'
|
||||
import {
|
||||
AggregateQueryDto,
|
||||
TimelineQueryDto,
|
||||
TopQueryDto,
|
||||
} from './aggregate.dto'
|
||||
import { AggregateService } from './aggregate.service'
|
||||
|
||||
@ApiController('aggregate')
|
||||
@@ -19,32 +25,49 @@ export class AggregateController {
|
||||
private readonly aggregateService: AggregateService,
|
||||
private readonly configsService: ConfigsService,
|
||||
private readonly analyzeService: AnalyzeService,
|
||||
private readonly noteService: NoteService,
|
||||
private readonly snippetService: SnippetService,
|
||||
) {}
|
||||
|
||||
@Get('/')
|
||||
@CacheKey(CacheKeys.AggregateCatch)
|
||||
@CacheTTL(300)
|
||||
async aggregate() {
|
||||
@CacheTTL(10 * 60)
|
||||
async aggregate(@Query() query: AggregateQueryDto) {
|
||||
const { theme } = query
|
||||
|
||||
const tasks = await Promise.allSettled([
|
||||
this.configsService.getMaster(),
|
||||
this.aggregateService.getAllCategory(),
|
||||
this.aggregateService.getAllPages(),
|
||||
this.configsService.get('url'),
|
||||
this.configsService.get('seo'),
|
||||
this.noteService.getLatestNoteId(),
|
||||
!theme
|
||||
? Promise.resolve()
|
||||
: this.snippetService
|
||||
.getCachedSnippet('theme', theme, 'public')
|
||||
.then((cached) => {
|
||||
if (cached) {
|
||||
return JSON.safeParse(cached) || cached
|
||||
}
|
||||
return this.snippetService.getPublicSnippetByName(theme, 'theme')
|
||||
}),
|
||||
])
|
||||
const [user, categories, pageMeta, url, seo] = tasks.map((t) => {
|
||||
if (t.status === 'fulfilled') {
|
||||
return t.value
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
})
|
||||
const [user, categories, pageMeta, url, seo, latestNodeId, themeConfig] =
|
||||
tasks.map((t) => {
|
||||
if (t.status === 'fulfilled') {
|
||||
return t.value
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
})
|
||||
return {
|
||||
user,
|
||||
seo,
|
||||
url: omit(url, ['adminUrl']),
|
||||
categories,
|
||||
pageMeta,
|
||||
latestNodeId,
|
||||
theme: themeConfig,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Transform } from 'class-transformer'
|
||||
import { IsEnum, IsInt, IsOptional, Max, Min } from 'class-validator'
|
||||
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'
|
||||
|
||||
export class TopQueryDto {
|
||||
@Transform(({ value: val }) => parseInt(val))
|
||||
@@ -29,3 +29,9 @@ export class TimelineQueryDto {
|
||||
@Transform(({ value: v }) => v | 0)
|
||||
type?: TimelineType
|
||||
}
|
||||
|
||||
export class AggregateQueryDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
theme?: string
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { PageModule } from '../page/page.module'
|
||||
import { PostModule } from '../post/post.module'
|
||||
import { RecentlyModule } from '../recently/recently.module'
|
||||
import { SayModule } from '../say/say.module'
|
||||
import { SnippetModule } from '../snippet/snippet.module'
|
||||
import { AggregateController } from './aggregate.controller'
|
||||
import { AggregateService } from './aggregate.service'
|
||||
|
||||
@@ -24,6 +25,7 @@ import { AggregateService } from './aggregate.service'
|
||||
forwardRef(() => CommentModule),
|
||||
forwardRef(() => LinkModule),
|
||||
forwardRef(() => RecentlyModule),
|
||||
forwardRef(() => SnippetModule),
|
||||
|
||||
AnalyzeModule,
|
||||
GatewayModule,
|
||||
|
||||
@@ -10,7 +10,11 @@ import type { RSSProps } from './aggregate.interface'
|
||||
import { forwardRef, Inject, Injectable } from '@nestjs/common'
|
||||
import { OnEvent } from '@nestjs/event-emitter'
|
||||
|
||||
import { CacheKeys, RedisKeys } from '~/constants/cache.constant'
|
||||
import {
|
||||
API_CACHE_PREFIX,
|
||||
CacheKeys,
|
||||
RedisKeys,
|
||||
} from '~/constants/cache.constant'
|
||||
import { EventBusEvents } from '~/constants/event-bus.constant'
|
||||
import { WebEventsGateway } from '~/processors/gateway/web/events.gateway'
|
||||
import { UrlBuilderService } from '~/processors/helper/helper.url-builder.service'
|
||||
@@ -412,7 +416,7 @@ export class AggregateService {
|
||||
return Promise.all([
|
||||
this.cacheService.getClient().del(CacheKeys.RSS),
|
||||
this.cacheService.getClient().del(CacheKeys.RSSXmlCatch),
|
||||
this.cacheService.getClient().del(CacheKeys.AggregateCatch),
|
||||
this.cacheService.getClient().del(`${API_CACHE_PREFIX}/aggregate*`),
|
||||
this.cacheService.getClient().del(CacheKeys.SiteMapCatch),
|
||||
this.cacheService.getClient().del(CacheKeys.SiteMapXmlCatch),
|
||||
])
|
||||
|
||||
@@ -19,7 +19,6 @@ import { ApiController } from '~/common/decorators/api-controller.decorator'
|
||||
import { Auth } from '~/common/decorators/auth.decorator'
|
||||
import { HTTPDecorators } from '~/common/decorators/http.decorator'
|
||||
import { CannotFindException } from '~/common/exceptions/cant-find.exception'
|
||||
import { NoContentCanBeModifiedException } from '~/common/exceptions/no-content-canbe-modified.exception'
|
||||
import { MongoIdDto } from '~/shared/dto/id.dto'
|
||||
|
||||
import { PostService } from '../post/post.service'
|
||||
@@ -129,7 +128,7 @@ export class CategoryController {
|
||||
@HTTPDecorators.Idempotence()
|
||||
async create(@Body() body: CategoryModel) {
|
||||
const { name, slug } = body
|
||||
return this.categoryService.model.create({ name, slug: slug ?? name })
|
||||
return this.categoryService.create(name, slug)
|
||||
}
|
||||
|
||||
@Put('/:id')
|
||||
@@ -137,14 +136,11 @@ export class CategoryController {
|
||||
async modify(@Param() params: MongoIdDto, @Body() body: CategoryModel) {
|
||||
const { type, slug, name } = body
|
||||
const { id } = params
|
||||
await this.categoryService.model.updateOne(
|
||||
{ _id: id },
|
||||
{
|
||||
slug,
|
||||
type,
|
||||
name,
|
||||
},
|
||||
)
|
||||
await this.categoryService.update(id, {
|
||||
slug,
|
||||
type,
|
||||
name,
|
||||
})
|
||||
return await this.categoryService.model.findById(id)
|
||||
}
|
||||
|
||||
@@ -153,7 +149,7 @@ export class CategoryController {
|
||||
@Auth()
|
||||
async patch(@Param() params: MongoIdDto, @Body() body: PartialCategoryModel) {
|
||||
const { id } = params
|
||||
await this.categoryService.model.updateOne({ _id: id }, body)
|
||||
await this.categoryService.update(id, body)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -161,22 +157,7 @@ export class CategoryController {
|
||||
@Auth()
|
||||
async deleteCategory(@Param() params: MongoIdDto) {
|
||||
const { id } = params
|
||||
const category = await this.categoryService.model.findById(id)
|
||||
if (!category) {
|
||||
throw new NoContentCanBeModifiedException()
|
||||
}
|
||||
const postsInCategory = await this.categoryService.findPostsInCategory(
|
||||
category.id,
|
||||
)
|
||||
if (postsInCategory.length > 0) {
|
||||
throw new BadRequestException('该分类中有其他文章,无法被删除')
|
||||
}
|
||||
const res = await this.categoryService.model.deleteOne({
|
||||
_id: category._id,
|
||||
})
|
||||
if ((await this.categoryService.model.countDocuments({})) === 0) {
|
||||
await this.categoryService.createDefaultCategory()
|
||||
}
|
||||
return res
|
||||
|
||||
return await this.categoryService.deleteById(id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,21 @@ import type { DocumentType } from '@typegoose/typegoose'
|
||||
import type { FilterQuery } from 'mongoose'
|
||||
import type { PostModel } from '../post/post.model'
|
||||
|
||||
import { forwardRef, Inject, Injectable } from '@nestjs/common'
|
||||
import {
|
||||
BadRequestException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
} from '@nestjs/common'
|
||||
import { ReturnModelType } from '@typegoose/typegoose'
|
||||
|
||||
import { CannotFindException } from '~/common/exceptions/cant-find.exception'
|
||||
import { NoContentCanBeModifiedException } from '~/common/exceptions/no-content-canbe-modified.exception'
|
||||
import { EventScope } from '~/constants/business-event.constant'
|
||||
import { EventBusEvents } from '~/constants/event-bus.constant'
|
||||
import { EventManagerService } from '~/processors/helper/helper.event.service'
|
||||
import { InjectModel } from '~/transformers/model.transformer'
|
||||
import { scheduleManager } from '~/utils'
|
||||
|
||||
import { PostService } from '../post/post.service'
|
||||
import { CategoryModel, CategoryType } from './category.model'
|
||||
@@ -19,6 +29,7 @@ export class CategoryService {
|
||||
private readonly categoryModel: ReturnModelType<typeof CategoryModel>,
|
||||
@Inject(forwardRef(() => PostService))
|
||||
private readonly postService: PostService,
|
||||
private readonly eventManager: EventManagerService,
|
||||
) {
|
||||
this.createDefaultCategory()
|
||||
}
|
||||
@@ -114,6 +125,54 @@ export class CategoryService {
|
||||
})
|
||||
}
|
||||
|
||||
async create(name: string, slug?: string) {
|
||||
const doc = await this.model.create({ name, slug: slug ?? name })
|
||||
this.clearCache()
|
||||
return doc
|
||||
}
|
||||
|
||||
async update(id: string, partialDoc: Partial<CategoryModel>) {
|
||||
const newDoc = await this.model.updateOne(
|
||||
{ _id: id },
|
||||
{
|
||||
...partialDoc,
|
||||
},
|
||||
{
|
||||
new: true,
|
||||
},
|
||||
)
|
||||
this.clearCache()
|
||||
return newDoc
|
||||
}
|
||||
async deleteById(id: string) {
|
||||
const category = await this.model.findById(id)
|
||||
if (!category) {
|
||||
throw new NoContentCanBeModifiedException()
|
||||
}
|
||||
const postsInCategory = await this.findPostsInCategory(category.id)
|
||||
if (postsInCategory.length > 0) {
|
||||
throw new BadRequestException('该分类中有其他文章,无法被删除')
|
||||
}
|
||||
const res = await this.model.deleteOne({
|
||||
_id: category._id,
|
||||
})
|
||||
if ((await this.model.countDocuments({})) === 0) {
|
||||
await this.createDefaultCategory()
|
||||
}
|
||||
this.clearCache()
|
||||
return res
|
||||
}
|
||||
|
||||
private clearCache() {
|
||||
scheduleManager.schedule(() =>
|
||||
Promise.all([
|
||||
this.eventManager.emit(EventBusEvents.CleanAggregateCache, null, {
|
||||
scope: EventScope.TO_SYSTEM,
|
||||
}),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
async createDefaultCategory() {
|
||||
if ((await this.model.countDocuments()) === 0) {
|
||||
return await this.model.create({
|
||||
|
||||
@@ -53,6 +53,21 @@ export class NoteService {
|
||||
return isSecret
|
||||
}
|
||||
|
||||
async getLatestNoteId() {
|
||||
const note = await this.noteModel
|
||||
.findOne()
|
||||
.sort({
|
||||
created: -1,
|
||||
})
|
||||
.lean()
|
||||
if (!note) {
|
||||
throw new CannotFindException()
|
||||
}
|
||||
return {
|
||||
nid: note.nid,
|
||||
id: note.id,
|
||||
}
|
||||
}
|
||||
async getLatestOne(
|
||||
condition: FilterQuery<DocumentType<NoteModel>> = {},
|
||||
projection: any = undefined,
|
||||
@@ -256,6 +271,9 @@ export class NoteService {
|
||||
])
|
||||
scheduleManager.schedule(async () => {
|
||||
await Promise.all([
|
||||
this.eventManager.emit(EventBusEvents.CleanAggregateCache, null, {
|
||||
scope: EventScope.TO_SYSTEM,
|
||||
}),
|
||||
this.eventManager.broadcast(BusinessEvents.NOTE_DELETE, id, {
|
||||
scope: EventScope.TO_SYSTEM_VISITOR,
|
||||
}),
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
@@ -21,7 +20,7 @@ import { PagerDto } from '~/shared/dto/pager.dto'
|
||||
import { transformDataToPaginate } from '~/transformers/paginate.transformer'
|
||||
|
||||
import { SnippetMoreDto } from './snippet.dto'
|
||||
import { SnippetModel, SnippetType } from './snippet.model'
|
||||
import { SnippetModel } from './snippet.model'
|
||||
import { SnippetService } from './snippet.service'
|
||||
|
||||
@ApiController('snippets')
|
||||
@@ -160,23 +159,10 @@ export class SnippetController {
|
||||
if (cached) {
|
||||
const json = JSON.safeParse(cached)
|
||||
|
||||
return json ? json : cached
|
||||
return json || cached
|
||||
}
|
||||
|
||||
const snippet = await this.snippetService.getSnippetByName(name, reference)
|
||||
|
||||
if (snippet.type === SnippetType.Function) {
|
||||
throw new NotFoundException()
|
||||
}
|
||||
|
||||
if (snippet.private && !isMaster) {
|
||||
throw new ForbiddenException('snippet is private')
|
||||
}
|
||||
|
||||
return this.snippetService.attachSnippet(snippet).then((res) => {
|
||||
this.snippetService.cacheSnippet(res, res.data)
|
||||
return res.data
|
||||
})
|
||||
return await this.snippetService.getPublicSnippetByName(name, reference)
|
||||
}
|
||||
|
||||
@Put('/:id')
|
||||
|
||||
@@ -5,13 +5,17 @@ import type { AggregatePaginateModel, Document } from 'mongoose'
|
||||
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common'
|
||||
|
||||
import { EventScope } from '~/constants/business-event.constant'
|
||||
import { RedisKeys } from '~/constants/cache.constant'
|
||||
import { EventBusEvents } from '~/constants/event-bus.constant'
|
||||
import { EventManagerService } from '~/processors/helper/helper.event.service'
|
||||
import { CacheService } from '~/processors/redis/cache.service'
|
||||
import { InjectModel } from '~/transformers/model.transformer'
|
||||
import { getRedisKey } from '~/utils'
|
||||
@@ -28,6 +32,7 @@ export class SnippetService {
|
||||
@Inject(forwardRef(() => ServerlessService))
|
||||
private readonly serverlessService: ServerlessService,
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly eventManager: EventManagerService,
|
||||
) {}
|
||||
|
||||
get model() {
|
||||
@@ -103,6 +108,11 @@ export class SnippetService {
|
||||
}
|
||||
|
||||
await this.deleteCachedSnippet(old.reference, old.name)
|
||||
|
||||
await this.eventManager.emit(EventBusEvents.CleanAggregateCache, null, {
|
||||
scope: EventScope.TO_SYSTEM,
|
||||
})
|
||||
|
||||
const newerDoc = await this.model.findByIdAndUpdate(
|
||||
id,
|
||||
{ ...newModel, modified: new Date() },
|
||||
@@ -234,6 +244,22 @@ export class SnippetService {
|
||||
return doc
|
||||
}
|
||||
|
||||
async getPublicSnippetByName(name: string, reference: string) {
|
||||
const snippet = await this.getSnippetByName(name, reference)
|
||||
if (snippet.type === SnippetType.Function) {
|
||||
throw new NotFoundException()
|
||||
}
|
||||
|
||||
if (snippet.private) {
|
||||
throw new ForbiddenException('snippet is private')
|
||||
}
|
||||
|
||||
return this.attachSnippet(snippet).then((res) => {
|
||||
this.cacheSnippet(res, res.data)
|
||||
return res.data
|
||||
})
|
||||
}
|
||||
|
||||
async attachSnippet(model: SnippetModel) {
|
||||
if (!model) {
|
||||
throw new NotFoundException()
|
||||
@@ -282,6 +308,7 @@ export class SnippetService {
|
||||
const value = await client.hget(getRedisKey(RedisKeys.SnippetCache), key)
|
||||
return value
|
||||
}
|
||||
|
||||
async deleteCachedSnippet(reference: string, name: string) {
|
||||
const keyBase = `${reference}:${name}`
|
||||
const key1 = `${keyBase}:`
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Inject, Injectable, Logger } from '@nestjs/common'
|
||||
import { Emitter } from '@socket.io/redis-emitter'
|
||||
|
||||
import { RedisIoAdapterKey } from '~/common/adapters/socket.adapter'
|
||||
import { API_CACHE_PREFIX } from '~/constants/cache.constant'
|
||||
import { getRedisKey } from '~/utils/redis.util'
|
||||
|
||||
// Cache 客户端管理器
|
||||
@@ -68,7 +69,7 @@ export class CacheService {
|
||||
|
||||
public async cleanCatch() {
|
||||
const redis = this.getClient()
|
||||
const keys: string[] = await redis.keys('mx-api-cache:*')
|
||||
const keys: string[] = await redis.keys(`${API_CACHE_PREFIX}*`)
|
||||
await Promise.all(keys.map((key) => redis.del(key)))
|
||||
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user