feat: note module

This commit is contained in:
Innei
2021-09-05 23:17:53 +08:00
parent 6635feef7e
commit fd1921a2b0
14 changed files with 399 additions and 26 deletions

View File

@@ -5,7 +5,7 @@ import {
RequestMethod,
} from '@nestjs/common'
import { ConfigModule } from '@nestjs/config'
import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core'
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'
import { AppController } from './app.controller'
import { AllExceptionsFilter } from './common/filters/any-exception.filter'
import { HttpCacheInterceptor } from './common/interceptors/cache.interceptor'
@@ -18,6 +18,7 @@ import { AnalyzeMiddleware } from './common/middlewares/analyze.middleware'
import { SkipBrowserDefaultRequestMiddleware } from './common/middlewares/favicon.middleware'
import { SecurityMiddleware } from './common/middlewares/security.middleware'
import { AuthModule } from './modules/auth/auth.module'
import { RolesGuard } from './modules/auth/roles.guard'
import { CategoryModule } from './modules/category/category.module'
import { CommentModule } from './modules/comment/comment.module'
import { ConfigsModule } from './modules/configs/configs.module'
@@ -78,11 +79,14 @@ import { HelperModule } from './processors/helper/helper.module'
provide: APP_INTERCEPTOR,
useClass: ResponseInterceptor,
},
{
provide: APP_FILTER,
useClass: AllExceptionsFilter,
},
{
provide: APP_GUARD,
useClass: RolesGuard,
},
],
})
export class AppModule implements NestModule {

View File

@@ -15,6 +15,7 @@ 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 { getIp } from '~/utils/ip.util'
@@ -27,6 +28,8 @@ export class CountingInterceptor<T> implements NestInterceptor<T, any> {
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)
@@ -69,6 +72,7 @@ export class CountingInterceptor<T> implements NestInterceptor<T, any> {
}
const modelMap = {
Post: this.postModel,
Note: this.noteModel,
} as const
const model = modelMap[type]

View File

@@ -6,7 +6,6 @@ import {
Post,
Query,
Scope,
UseGuards,
} from '@nestjs/common'
import { ApiBearerAuth, ApiOperation } from '@nestjs/swagger'
import { Transform } from 'class-transformer'
@@ -23,7 +22,6 @@ import { IsMaster as Master } from '~/common/decorator/role.decorator'
import { MongoIdDto } from '~/shared/dto/id.dto'
import { AdminEventsGateway } from '../../processors/gateway/admin/events.gateway'
import { AuthService } from './auth.service'
import { RolesGuard } from './roles.guard'
export class TokenDto {
@IsDate()
@@ -50,7 +48,6 @@ export class AuthController {
@Get()
@ApiOperation({ summary: '判断当前 Token 是否有效 ' })
@ApiBearerAuth()
@UseGuards(RolesGuard)
checkLogged(@Master() isMaster: boolean) {
return { ok: ~~isMaster, isGuest: !isMaster }
}

View File

@@ -1,9 +1,29 @@
import { Controller, Get } from '@nestjs/common'
import {
Body,
Controller,
ForbiddenException,
Get,
HttpCode,
Param,
Post,
Put,
Query,
} from '@nestjs/common'
import { ApiOperation } from '@nestjs/swagger'
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 { addConditionToSeeHideContent } from '~/utils/query.util'
import { UpdateDocumentCount } from '~/common/decorator/update-count.decorator'
import { CannotFindException } from '~/common/exceptions/cant-find.exception'
import { MongoIdDto } from '~/shared/dto/id.dto'
import {
addConditionToSeeHideContent,
addYearCondition,
} from '~/utils/query.util'
import { ListQueryDto, NoteQueryDto, PasswordQueryDto } from './note.dto'
import { NoteModel, PartialNoteModel } from './note.model'
import { NoteService } from './note.service'
@ApiName
@@ -13,6 +33,7 @@ export class NoteController {
@Get('latest')
@ApiOperation({ summary: '获取最新发布一篇记录' })
@UpdateDocumentCount('Note')
async getLatestOne(
@IsMaster() isMaster: boolean,
@IpLocation() location: IpRecord,
@@ -27,4 +48,164 @@ export class NoteController {
// this.noteService.shouldAddReadCount(latest, location.ip)
return { data: latest.toObject(), next: next.toObject() }
}
@Get('/')
@Paginator
@ApiOperation({ summary: '获取记录带分页器' })
async getNotes(@IsMaster() isMaster: boolean, @Query() query: NoteQueryDto) {
const { size, select, page, sortBy, sortOrder, year } = query
const condition = {
...addConditionToSeeHideContent(isMaster),
...addYearCondition(year),
}
return await this.noteService.model.paginate(condition, {
limit: size,
page,
select: isMaster
? select
: select?.replace(/[+-]?(coordinates|location|password)/g, ''),
sort: sortBy ? { [sortBy]: sortOrder || -1 } : { created: -1 },
})
}
@Get(':id')
@UpdateDocumentCount('Note')
async getOneNote(
@Param() params: MongoIdDto,
@IsMaster() isMaster: boolean,
@Query() query: PasswordQueryDto,
@Query('single') isSingle?: boolean,
) {
const { id } = params
const { password } = query
const condition = addConditionToSeeHideContent(isMaster)
const current = await this.noteService.model
.findOne({
_id: id,
...condition,
})
.select('+password ' + (isMaster ? '+location +coordinates' : ''))
if (!current) {
throw new CannotFindException()
}
if (
!this.noteService.checkPasswordToAccess(current, password) &&
!isMaster
) {
throw new ForbiddenException('不要偷看人家的小心思啦~')
}
if (isSingle) {
return current
}
const select = '_id title nid id created modified'
const prev = await this.noteService.model
.findOne({
...condition,
created: {
$gt: current.created,
},
})
.sort({ created: 1 })
.select(select)
.lean()
const next = await this.noteService.model
.findOne({
...condition,
created: {
$lt: current.created,
},
})
.sort({ created: -1 })
.select(select)
.lean()
return { data: current, next, prev }
}
@Get('/list/:id')
@ApiOperation({ summary: '以一篇记录为基准的中间 10 篇记录' })
async getNoteList(
@Query() query: ListQueryDto,
@Param() params: MongoIdDto,
@IsMaster() isMaster: boolean,
) {
const { size = 10 } = query
const half = size >> 1
const { id } = params
const select = 'nid _id title created'
const condition = addConditionToSeeHideContent(isMaster)
const currentDocument = await this.noteService.model
.findOne(
{
_id: id,
...condition,
},
select,
)
.lean()
if (!currentDocument) {
return { data: [], size: 0 }
}
const prevList =
half - 1 === 0
? []
: await this.noteService.model
.find(
{
created: {
$gt: currentDocument.created,
},
...condition,
},
select,
)
.limit(half - 1)
.sort({ created: -1 })
.lean()
const nextList = !half
? []
: await this.noteService.model
.find(
{
created: {
$lt: currentDocument.created,
},
...condition,
},
select,
)
.limit(half - 1)
.sort({ created: -1 })
.lean()
const data = [...prevList, ...nextList, currentDocument].sort(
(a: any, b: any) => b.created - a.created,
)
return { data, size: data.length }
}
@Post('/')
@Auth()
async create(@Body() body: NoteModel) {
// TODO clean cache
// refreshKeyedCache(this.cacheManager)
return await this.noteService.create(body)
}
@Put('/:id')
@Auth()
async modify(@Body() body: NoteModel, @Param() params: MongoIdDto) {
await this.noteService.updateById(params.id, body)
return await this.noteService.model.findById(params.id)
}
@Put('/:id')
@HttpCode(204)
@Auth()
async patch(@Body() body: PartialNoteModel, @Param() params: MongoIdDto) {
await this.noteService.updateById(params.id, body)
return
}
}

View File

@@ -0,0 +1,59 @@
import { Transform } from 'class-transformer'
import {
IsDefined,
IsEnum,
IsInt,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
Max,
Min,
ValidateIf,
} from 'class-validator'
import { PagerDto } from '~/shared/dto/pager.dto'
export class NoteQueryDto extends PagerDto {
@IsOptional()
@IsEnum(['title', 'created', 'modified', 'weather', 'mood'])
sortBy?: string
@IsOptional()
@IsEnum([1, -1])
@ValidateIf((o) => o.sortBy)
@Transform(({ value: v }) => v | 0)
sortOrder?: 1 | -1
}
export class PasswordQueryDto {
@IsString()
@IsOptional()
@IsNotEmpty()
password?: string
}
export class NoteMusicDto {
@IsString()
@IsNotEmpty()
type: string
@IsString()
@IsNotEmpty()
id: string
}
export class ListQueryDto {
@IsNumber()
@Max(20)
@Min(1)
@Transform(({ value: v }) => parseInt(v))
@IsOptional()
size: number
}
export class NidType {
@IsInt()
@Min(1)
@IsDefined()
@Transform(({ value: val }) => parseInt(val))
nid: number
}

View File

@@ -6,10 +6,21 @@
* @FilePath: /server/libs/db/src/models/note.model.ts
* Mark: Coding with Love
*/
import { PartialType } from '@nestjs/mapped-types'
import { AutoIncrementID } from '@typegoose/auto-increment'
import { index, modelOptions, plugin, prop } from '@typegoose/typegoose'
import { IsNumber } from 'class-validator'
import { Transform, Type } from 'class-transformer'
import {
IsBoolean,
IsDate,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
ValidateNested,
} from 'class-validator'
import { CountMixed, WriteBaseModel } from '~/shared/model/base.model'
import { NoteMusicDto } from './note.dto'
@modelOptions({ schemaOptions: { id: false, _id: false } })
export class Coordinate {
@@ -47,38 +58,71 @@ export class NoteMusic {
@index({ modified: -1 })
@index({ nid: -1 })
export class NoteModel extends WriteBaseModel {
@prop()
@IsString()
@IsOptional()
@Transform(({ value: title }) => (title.length === 0 ? '无题' : title))
title: string
@prop({ required: false, unique: true })
public nid: number
@prop({ default: false })
@IsBoolean()
@IsOptional()
hide: boolean
@prop({
select: false,
})
@IsString()
@IsOptional()
@IsNotEmpty()
@Transform(({ value: val }) => (String(val).length === 0 ? null : val))
password?: string
@prop()
@IsOptional()
@IsDate()
@Transform(({ value }) => (value ? new Date(value) : null))
secret?: Date
@prop()
@IsString()
@IsOptional()
mood?: string
@prop()
@IsOptional()
@IsString()
weather?: string
@prop()
@IsBoolean()
@IsOptional()
hasMemory?: boolean
@prop({ select: false, type: Coordinate })
@ValidateNested()
@Type(() => Coordinate)
@IsOptional()
coordinates?: Coordinate
@prop({ select: false })
@IsString()
@IsOptional()
location?: string
@prop({ type: CountMixed, default: { read: 0, like: 0 }, _id: false })
count?: CountMixed
@prop({ type: [NoteMusic] })
@ValidateNested({ each: true })
@IsOptional()
@Type(() => NoteMusicDto)
music?: NoteMusic[]
static get protectedKeys() {
return ['nid', 'count'].concat(super.protectedKeys)
}
}
export class PartialNoteModel extends PartialType(NoteModel) {}

View File

@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common'
import { GatewayModule } from '~/processors/gateway/gateway.module'
import { NoteController } from './note.controller'
import { NoteService } from './note.service'
@@ -6,5 +7,6 @@ import { NoteService } from './note.service'
controllers: [NoteController],
providers: [NoteService],
exports: [NoteService],
imports: [GatewayModule],
})
export class NoteModule {}

View File

@@ -1,8 +1,13 @@
import { Injectable } from '@nestjs/common'
import { DocumentType } from '@typegoose/typegoose'
import { compareSync } from 'bcrypt'
import { FilterQuery } from 'mongoose'
import { InjectModel } from 'nestjs-typegoose'
import { CannotFindException } from '~/common/exceptions/cant-find.exception'
import { EventTypes } from '~/processors/gateway/events.types'
import { WebEventsGateway } from '~/processors/gateway/web/events.gateway'
import { ImageService } from '~/processors/helper/helper.image.service'
import { deleteKeys } from '~/utils/index.util'
import { NoteModel } from './note.model'
@Injectable()
@@ -10,6 +15,8 @@ export class NoteService {
constructor(
@InjectModel(NoteModel)
private readonly noteModel: MongooseModel<NoteModel>,
private readonly imageService: ImageService,
private readonly webGateway: WebEventsGateway,
) {
this.needCreateDefult()
}
@@ -54,6 +61,60 @@ export class NoteService {
}
}
checkPasswordToAccess(
doc: DocumentType<NoteModel>,
password: string,
): boolean {
const hasPassword = doc.password
if (!hasPassword) {
return true
}
if (!password) {
return false
}
const isValid = compareSync(password, doc.password)
return isValid
}
public async create(document: NoteModel) {
const doc = await this.noteModel.create(document)
process.nextTick(async () => {
await Promise.all([
this.imageService.recordImageDimensions(this.noteModel, doc._id),
doc.hide || doc.password
? null
: this.webGateway.broadcast(EventTypes.NOTE_CREATE, doc.toJSON()),
])
})
return doc
}
public async updateById(id: string, doc: Partial<NoteModel>) {
console.log(NoteModel.protectedKeys)
deleteKeys(doc, NoteModel.protectedKeys as any)
await this.noteModel.updateOne(
{
_id: id,
},
{ ...doc, modified: new Date() },
)
process.nextTick(async () => {
Promise.all([
this.imageService.recordImageDimensions(this.noteModel, id),
this.model.findById(id).then((doc) => {
if (doc.hide || doc.password) {
return this.webGateway.broadcast(EventTypes.NOTE_UPDATE, doc)
}
}),
])
// TODO clean cache
// refreshKeyedCache(this.cacheManager)
})
}
async needCreateDefult() {
await this.noteModel.countDocuments({}).then((count) => {
if (!count) {

View File

@@ -10,7 +10,6 @@ import {
Post,
Put,
Query,
UseGuards,
} from '@nestjs/common'
import { ApiOperation } from '@nestjs/swagger'
import { Types } from 'mongoose'
@@ -26,14 +25,12 @@ import {
addConditionToSeeHideContent,
addYearCondition,
} from '~/utils/query.util'
import { RolesGuard } from '../auth/roles.guard'
import { CategoryAndSlug, PostQueryDto } from './post.dto'
import { PartialPostModel, PostModel } from './post.model'
import { PostService } from './post.service'
@Controller('posts')
@ApiName
@UseGuards(RolesGuard)
export class PostController {
constructor(private readonly postService: PostService) {}

View File

@@ -1,13 +1,6 @@
import { PartialType } from '@nestjs/mapped-types'
import { ApiHideProperty, ApiProperty } from '@nestjs/swagger'
import {
index,
modelOptions,
plugin,
prop,
Ref,
Severity,
} from '@typegoose/typegoose'
import { index, modelOptions, prop, Ref, Severity } from '@typegoose/typegoose'
import {
ArrayUnique,
IsBoolean,
@@ -16,7 +9,6 @@ import {
IsOptional,
IsString,
} from 'class-validator'
import Paginate from 'mongoose-paginate-v2'
import { CountMixed as Count, WriteBaseModel } from '~/shared/model/base.model'
import { CategoryModel as Category } from '../category/category.model'
@@ -24,7 +16,6 @@ import { CategoryModel as Category } from '../category/category.model'
@index({ modified: -1 })
@index({ text: 'text' })
@modelOptions({ options: { customName: 'Post', allowMixed: Severity.ALLOW } })
@plugin(Paginate)
export class PostModel extends WriteBaseModel {
@prop({ trim: true, unique: true, required: true })
@IsString()
@@ -72,6 +63,10 @@ export class PostModel extends WriteBaseModel {
@prop({ type: Count, default: { read: 0, like: 0 }, _id: false })
@ApiHideProperty()
count?: Count
static get protectedKeys() {
return ['count'].concat(super.protectedKeys)
}
}
export class PartialPostModel extends PartialType(PostModel) {}

View File

@@ -111,7 +111,7 @@ export class PostService {
{
_id: id,
},
omit(data, ['id', '_id', 'created']),
omit(data, PostModel.protectedKeys),
)
process.nextTick(async () => {
// 更新图片信息缓存

View File

@@ -17,7 +17,6 @@ import { ApiName } from '~/common/decorator/openapi.decorator'
import { IsMaster } from '~/common/decorator/role.decorator'
import { getAvatar } from '~/utils/index.util'
import { AuthService } from '../auth/auth.service'
import { RolesGuard } from '../auth/roles.guard'
import { LoginDto, UserDto, UserPatchDto } from './user.dto'
import { UserDocument, UserModel } from './user.model'
import { UserService } from './user.service'
@@ -32,7 +31,6 @@ export class UserController {
@Get()
@ApiOperation({ summary: '获取主人信息' })
@UseGuards(RolesGuard)
async getMasterInfo(@IsMaster() isMaster: boolean) {
return await this.userService.getMasterInfo(isMaster)
}
@@ -73,7 +71,6 @@ export class UserController {
@Get('check_logged')
@ApiOperation({ summary: '判断当前 Token 是否有效 ' })
@ApiBearerAuth()
@UseGuards(RolesGuard)
@HttpCache({ disable: true })
checkLogged(@IsMaster() isMaster: boolean) {
return { ok: +isMaster, isGuest: !isMaster }

View File

@@ -1,10 +1,18 @@
import { ApiHideProperty } from '@nestjs/swagger'
import { modelOptions, prop } from '@typegoose/typegoose'
import { modelOptions, plugin, prop } from '@typegoose/typegoose'
import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator'
import mongooseLeanVirtuals from 'mongoose-lean-virtuals'
import Paginate from 'mongoose-paginate-v2'
@plugin(mongooseLeanVirtuals)
@plugin(Paginate)
export class BaseModel {
@ApiHideProperty()
created?: Date
static get protectedKeys() {
return ['created']
}
}
export interface Paginator {
@@ -57,6 +65,10 @@ export abstract class BaseCommentIndexModel extends BaseModel {
@IsBoolean()
@IsOptional()
allowComment: boolean
static get protectedKeys() {
return ['commentsIndex'].concat(super.protectedKeys)
}
}
@modelOptions({
@@ -84,6 +96,10 @@ export abstract class WriteBaseModel extends BaseCommentIndexModel {
@prop({ default: null })
@ApiHideProperty()
modified: Date | null
static get protectedKeys() {
return super.protectedKeys
}
}
@modelOptions({

View File

@@ -1,3 +1,5 @@
import { isObject } from 'lodash'
export * from './ip.util'
export const isDev = process.env.NODE_ENV == 'development'
@@ -44,3 +46,17 @@ export function arrDifference(a1: string[], a2: string[]) {
return diff
}
export const deleteKeys = <T extends KV>(
target: T,
keys: readonly (keyof T)[],
): Partial<T> => {
if (!isObject(target)) {
throw new TypeError('target must be Object, got ' + target)
}
for (const key of keys) {
Reflect.deleteProperty(target, key)
}
return target
}