feat: note module done

This commit is contained in:
Innei
2021-09-06 12:45:33 +08:00
parent fd1921a2b0
commit 8ce7532261
10 changed files with 351 additions and 85 deletions

View File

@@ -6,34 +6,20 @@ import {
CallHandler,
ExecutionContext,
Injectable,
Logger,
NestInterceptor,
} from '@nestjs/common'
import { Reflector } from '@nestjs/core'
import { InjectModel } from 'nestjs-typegoose'
import { map } from 'rxjs'
import { ArticleType } from '~/constants/article.constant'
import { RedisKeys } from '~/constants/cache.constant'
import { HTTP_RES_UPDATE_DOC_COUNT_TYPE } from '~/constants/meta.constant'
import { NoteModel } from '~/modules/note/note.model'
import { PostModel } from '~/modules/post/post.model'
import { CacheService } from '~/processors/cache/cache.service'
import { CountingService } from '~/processors/helper/helper.counting.service'
import { getIp } from '~/utils/ip.util'
import { getRedisKey } from '~/utils/redis.util'
// ResponseInterceptor -> JSONSerializeInterceptor -> CountingInterceptor -> HttpCacheInterceptor
@Injectable()
export class CountingInterceptor<T> implements NestInterceptor<T, any> {
private logger: Logger
constructor(
private readonly countingService: CountingService,
private readonly reflector: Reflector,
@InjectModel(PostModel)
private readonly postModel: MongooseModel<PostModel>,
@InjectModel(NoteModel)
private readonly noteModel: MongooseModel<NoteModel>,
private readonly redis: CacheService,
) {
this.logger = new Logger(CountingInterceptor.name)
}
) {}
intercept(context: ExecutionContext, next: CallHandler) {
const handler = context.getHandler()
@@ -45,7 +31,7 @@ export class CountingInterceptor<T> implements NestInterceptor<T, any> {
handler,
)
if (documentType) {
this.updateReadCount(
this.countingService.updateReadCount(
documentType as any,
data.id || data?.data?.id,
getIp(context.switchToHttp().getRequest()),
@@ -56,42 +42,4 @@ export class CountingInterceptor<T> implements NestInterceptor<T, any> {
}),
)
}
private async updateReadCount(
type: keyof typeof ArticleType,
id: string,
ip: string,
) {
if (!ip) {
this.logger.debug('无法更新阅读计数, IP 无效')
return
}
if (!id) {
this.logger.debug('无法更新阅读计数, ID 不存在')
return
}
const modelMap = {
Post: this.postModel,
Note: this.noteModel,
} as const
const model = modelMap[type]
const redis = this.redis.getClient()
const isReadBefore = await redis.sismember(
getRedisKey(RedisKeys.Read, id),
ip,
)
if (isReadBefore) {
this.logger.debug('已经增加过计数了, ' + id)
return
}
const doc = await model.findOne({ _id: id })
await Promise.all([
redis.sadd(getRedisKey(RedisKeys.Read, doc._id), ip),
doc.updateOne({ $inc: { 'count.read': 1 } }),
])
this.logger.debug('增加计数, ' + doc.title)
}
}

View File

@@ -1,10 +1,13 @@
import {
BadRequestException,
Body,
Controller,
Delete,
ForbiddenException,
Get,
HttpCode,
Param,
Patch,
Post,
Put,
Query,
@@ -17,19 +20,29 @@ import { ApiName } from '~/common/decorator/openapi.decorator'
import { IsMaster } from '~/common/decorator/role.decorator'
import { UpdateDocumentCount } from '~/common/decorator/update-count.decorator'
import { CannotFindException } from '~/common/exceptions/cant-find.exception'
import { MongoIdDto } from '~/shared/dto/id.dto'
import { CountingService } from '~/processors/helper/helper.counting.service'
import { IntIdOrMongoIdDto, MongoIdDto } from '~/shared/dto/id.dto'
import { SearchDto } from '~/shared/dto/search.dto'
import {
addConditionToSeeHideContent,
addYearCondition,
} from '~/utils/query.util'
import { ListQueryDto, NoteQueryDto, PasswordQueryDto } from './note.dto'
import {
ListQueryDto,
NidType,
NoteQueryDto,
PasswordQueryDto,
} from './note.dto'
import { NoteModel, PartialNoteModel } from './note.model'
import { NoteService } from './note.service'
@ApiName
@Controller({ path: 'notes' })
export class NoteController {
constructor(private readonly noteService: NoteService) {}
constructor(
private readonly noteService: NoteService,
private readonly countingService: CountingService,
) {}
@Get('latest')
@ApiOperation({ summary: '获取最新发布一篇记录' })
@@ -45,7 +58,6 @@ export class NoteController {
isMaster ? '+location +coordinates' : '-location -coordinates',
)
// this.noteService.shouldAddReadCount(latest, location.ip)
return { data: latest.toObject(), next: next.toObject() }
}
@@ -201,11 +213,108 @@ export class NoteController {
return await this.noteService.model.findById(params.id)
}
@Put('/:id')
@Patch('/:id')
@HttpCode(204)
@Auth()
async patch(@Body() body: PartialNoteModel, @Param() params: MongoIdDto) {
await this.noteService.updateById(params.id, body)
return
}
@Get('like/:id')
@HttpCode(204)
async likeNote(
@Param() param: IntIdOrMongoIdDto,
@IpLocation() location: IpRecord,
) {
const id =
typeof param.id === 'number'
? (await this.noteService.model.findOne({ nid: param.id }).lean())._id
: param.id
if (!id) {
throw new CannotFindException()
}
try {
const res = await this.countingService.updateLikeCount(
'Note',
id,
location.ip,
)
if (!res) {
throw new BadRequestException('你已经喜欢过啦!')
}
return
} catch (e: any) {
throw new BadRequestException(e)
}
}
@Delete(':id')
@Auth()
@HttpCode(204)
async deleteNote(@Param() params: MongoIdDto) {
await this.noteService.deleteById(params.id)
}
@ApiOperation({ summary: '根据 nid 查找' })
@Get('/nid/:nid')
@UpdateDocumentCount('Note')
async getNoteByNid(
@Param() params: NidType,
@IsMaster() isMaster: boolean,
@Query() query: PasswordQueryDto,
@Query('single') isSingle?: boolean,
) {
const id = await this.noteService.getIdByNid(params.nid)
if (!id) {
throw new CannotFindException()
}
return await this.getOneNote({ id }, isMaster, query, isSingle)
}
@ApiOperation({ summary: '根据 nid 修改' })
@Put('/nid/:nid')
@Auth()
async modifyNoteByNid(@Param() params: NidType, @Body() body: NoteModel) {
const id = await this.noteService.getIdByNid(params.nid)
if (!id) {
throw new CannotFindException()
}
return await this.modify(body, {
id,
})
}
@ApiOperation({ summary: '搜索' })
@Get('/search')
@Paginator
async searchNote(@Query() query: SearchDto, @IsMaster() isMaster: boolean) {
const { keyword, page, size } = query
const select = '_id title created modified nid'
const keywordArr = keyword
.split(/\s+/)
.map((item) => new RegExp(String(item), 'ig'))
return await this.noteService.model.paginate(
{
$or: [{ title: { $in: keywordArr } }, { text: { $in: keywordArr } }],
$and: [
{ password: { $in: [undefined, null] } },
{ hide: { $in: isMaster ? [false, true] : [false] } },
{
$or: [
{ secret: { $in: [undefined, null] } },
{ secret: { $lte: new Date() } },
],
},
],
},
{
limit: size,
page,
select,
},
)
}
}

View File

@@ -1,7 +1,7 @@
import { Transform } from 'class-transformer'
import {
IsDefined,
IsEnum,
IsIn,
IsInt,
IsNotEmpty,
IsNumber,
@@ -15,11 +15,11 @@ import { PagerDto } from '~/shared/dto/pager.dto'
export class NoteQueryDto extends PagerDto {
@IsOptional()
@IsEnum(['title', 'created', 'modified', 'weather', 'mood'])
@IsIn(['title', 'created', 'modified', 'weather', 'mood'])
sortBy?: string
@IsOptional()
@IsEnum([1, -1])
@IsIn([1, -1])
@ValidateIf((o) => o.sortBy)
@Transform(({ value: v }) => v | 0)
sortOrder?: 1 | -1

View File

@@ -1,6 +1,8 @@
import { Injectable } from '@nestjs/common'
import { DocumentType } from '@typegoose/typegoose'
import { compareSync } from 'bcrypt'
import { isDefined } from 'class-validator'
import { pick } from 'lodash'
import { FilterQuery } from 'mongoose'
import { InjectModel } from 'nestjs-typegoose'
import { CannotFindException } from '~/common/exceptions/cant-find.exception'
@@ -91,14 +93,15 @@ export class NoteService {
}
public async updateById(id: string, doc: Partial<NoteModel>) {
console.log(NoteModel.protectedKeys)
deleteKeys(doc, NoteModel.protectedKeys as any)
deleteKeys(doc, ...NoteModel.protectedKeys)
if (['title', 'text'].some((key) => isDefined(doc[key]))) {
doc.modified = new Date()
}
await this.noteModel.updateOne(
{
_id: id,
},
{ ...doc, modified: new Date() },
{ ...doc },
)
process.nextTick(async () => {
Promise.all([
@@ -115,6 +118,42 @@ export class NoteService {
})
}
async deleteById(id: string) {
const doc = await this.noteModel.findById(id)
if (!doc) {
throw new CannotFindException()
}
await this.noteModel.deleteOne({
_id: id,
})
process.nextTick(async () => {
await Promise.all([
this.webGateway.broadcast(
EventTypes.NOTE_DELETE,
pick(doc, ['_id', 'id', 'nid', 'created', 'modified']),
),
])
})
}
/**
* 查找 nid 时候正确,返回 _id
*
* @param {number} nid
* @returns {Types.ObjectId}
*/
async getIdByNid(nid: number) {
const document = await this.model.findOne({
nid,
})
if (!document) {
return null
}
return document._id
}
async needCreateDefult() {
await this.noteModel.countDocuments({}).then((count) => {
if (!count) {

View File

@@ -1,4 +1,5 @@
import {
BadRequestException,
Body,
Controller,
Delete,
@@ -15,10 +16,12 @@ import { ApiOperation } from '@nestjs/swagger'
import { Types } from 'mongoose'
import { Auth } from '~/common/decorator/auth.decorator'
import { Paginator } from '~/common/decorator/http.decorator'
import { IpLocation, IpRecord } from '~/common/decorator/ip.decorator'
import { ApiName } from '~/common/decorator/openapi.decorator'
import { IsMaster } from '~/common/decorator/role.decorator'
import { UpdateDocumentCount } from '~/common/decorator/update-count.decorator'
import { CannotFindException } from '~/common/exceptions/cant-find.exception'
import { CountingService } from '~/processors/helper/helper.counting.service'
import { MongoIdDto } from '~/shared/dto/id.dto'
import { SearchDto } from '~/shared/dto/search.dto'
import {
@@ -32,7 +35,10 @@ import { PostService } from './post.service'
@Controller('posts')
@ApiName
export class PostController {
constructor(private readonly postService: PostService) {}
constructor(
private readonly postService: PostService,
private readonly countingService: CountingService,
) {}
@Get('/')
@Paginator
@@ -140,6 +146,7 @@ export class PostController {
}
@Get('search')
@Paginator
async searchPost(@Query() query: SearchDto, @IsMaster() isMaster: boolean) {
const { keyword, page, size } = query
const select = '_id title created modified categoryId slug'
@@ -149,7 +156,7 @@ export class PostController {
return await this.postService.findWithPaginator(
{
$or: [{ title: { $in: keywordArr } }, { text: { $in: keywordArr } }],
...addConditionToSeeHideContent(isMaster),
$and: [{ ...addConditionToSeeHideContent(isMaster) }],
},
{
limit: size,
@@ -159,4 +166,24 @@ export class PostController {
},
)
}
@Get('_thumbs-up')
@HttpCode(204)
async thumbsUpArticle(
@Query() query: MongoIdDto,
@IpLocation() location: IpRecord,
) {
const { ip } = location
const { id } = query
try {
const res = await this.countingService.updateLikeCount('Post', id, ip)
if (!res) {
throw new BadRequestException('你已经支持过啦!')
}
} catch (e: any) {
throw new BadRequestException(e)
}
return
}
}

View File

@@ -0,0 +1,111 @@
import { Injectable, Logger } from '@nestjs/common'
import { Reflector } from '@nestjs/core'
import { InjectModel } from 'nestjs-typegoose'
import { ArticleType } from '~/constants/article.constant'
import { RedisKeys } from '~/constants/cache.constant'
import { NoteModel } from '~/modules/note/note.model'
import { PostModel } from '~/modules/post/post.model'
import { getRedisKey } from '~/utils/redis.util'
import { CacheService } from '../cache/cache.service'
@Injectable()
export class CountingService {
private logger: Logger
constructor(
private readonly reflector: Reflector,
@InjectModel(PostModel)
private readonly postModel: MongooseModel<PostModel>,
@InjectModel(NoteModel)
private readonly noteModel: MongooseModel<NoteModel>,
private readonly redis: CacheService,
) {
this.logger = new Logger(CountingService.name)
}
get modelMap() {
return {
Post: this.postModel,
Note: this.noteModel,
} as const
}
private checkIdAndIp(id: string, ip: string) {
if (!ip) {
this.logger.debug('无法更新阅读计数, IP 无效')
return false
}
if (!id) {
this.logger.debug('无法更新阅读计数, ID 不存在')
return false
}
return true
}
public async updateReadCount(
type: keyof typeof ArticleType,
id: string,
ip: string,
) {
if (!this.checkIdAndIp(id, ip)) {
return
}
const model = this.modelMap[type]
const doc = await model.findById(id)
if (!doc) {
this.logger.debug('无法更新阅读计数, 文档不存在')
return
}
const redis = this.redis.getClient()
const isReadBefore = await redis.sismember(
getRedisKey(RedisKeys.Read, id),
ip,
)
if (isReadBefore) {
this.logger.debug('已经增加过计数了, ' + id)
return
}
await Promise.all([
redis.sadd(getRedisKey(RedisKeys.Read, doc._id), ip),
doc.updateOne({ $inc: { 'count.read': 1 } }),
])
this.logger.debug('增加阅读计数, (' + doc.title)
}
public async updateLikeCount(
type: keyof typeof ArticleType,
id: string,
ip: string,
): Promise<boolean> {
if (!this.checkIdAndIp(id, ip)) {
throw '无法获取到 IP'
}
const model = this.modelMap[type]
const doc = await model.findById(id)
if (!doc) {
throw '无法更新喜欢计数, 文档不存在'
}
const redis = this.redis.getClient()
const isLikeBefore = await redis.sismember(
getRedisKey(RedisKeys.Like, id),
ip,
)
if (isLikeBefore) {
this.logger.debug('已经增加过计数了, ' + id)
return false
}
await Promise.all([
redis.sadd(getRedisKey(RedisKeys.Like, doc._id), ip),
doc.updateOne({ $inc: { 'count.like': 1 } }),
])
this.logger.debug('增加喜欢计数, (' + doc.title)
return true
}
}

View File

@@ -1,9 +1,15 @@
import { Global, Module, Provider } from '@nestjs/common'
import { CountingService } from './helper.counting.service'
import { EmailService } from './helper.email.service'
import { HttpService } from './helper.http.service'
import { ImageService } from './helper.image.service'
const providers: Provider<any>[] = [EmailService, HttpService, ImageService]
const providers: Provider<any>[] = [
EmailService,
HttpService,
ImageService,
CountingService,
]
@Module({ imports: [], providers: providers, exports: providers })
@Global()

View File

@@ -1,19 +1,26 @@
import { UnprocessableEntityException } from '@nestjs/common'
import { ApiProperty } from '@nestjs/swagger'
import { IsMongoId } from 'class-validator'
import { IsBooleanOrString } from '~/utils/validator/isBooleanOrString'
import { Transform } from 'class-transformer'
import { IsDefined, isMongoId, IsMongoId } from 'class-validator'
export class MongoIdDto {
@IsMongoId()
@ApiProperty({
name: 'id',
// enum: ['5e6f67e75b303781d2807279', '5e6f67e75b303781d280727f'],
example: '5e6f67e75b303781d2807278',
})
@ApiProperty({ example: '5e6f67e75b303781d2807278' })
id: string
}
export class IntIdOrMongoIdDto {
@IsBooleanOrString()
@ApiProperty({ example: ['12', '5e6f67e75b303781d2807278'] })
@IsDefined()
@Transform(({ value }) => {
if (isMongoId(value)) {
return value
}
const nid = +value
if (!isNaN(nid)) {
return nid
}
throw new UnprocessableEntityException('Invalid id')
})
@ApiProperty({ example: [12, '5e6f67e75b303781d2807278'] })
id: string | number
}

View File

@@ -11,7 +11,7 @@ export class BaseModel {
created?: Date
static get protectedKeys() {
return ['created']
return ['created', 'id', '_id']
}
}

View File

@@ -47,16 +47,35 @@ export function arrDifference(a1: string[], a2: string[]) {
return diff
}
export const deleteKeys = <T extends KV>(
export function deleteKeys<T extends KV>(
target: T,
keys: (keyof T)[],
): Partial<T>
export function deleteKeys<T extends KV>(
target: T,
keys: readonly (keyof T)[],
): Partial<T> => {
): Partial<T>
export function deleteKeys<T extends KV>(
target: T,
...keys: string[]
): Partial<T>
export function deleteKeys<T extends KV>(
target: T,
...keys: any[]
): Partial<T> {
if (!isObject(target)) {
throw new TypeError('target must be Object, got ' + target)
}
for (const key of keys) {
Reflect.deleteProperty(target, key)
if (Array.isArray(keys[0])) {
for (const key of keys[0]) {
Reflect.deleteProperty(target, key)
}
} else {
for (const key of keys) {
Reflect.deleteProperty(target, key)
}
}
return target
}