Compare commits
9 Commits
50dbb06b29
...
5452f890ec
Author | SHA1 | Date | |
---|---|---|---|
5452f890ec | |||
02ccf58457 | |||
7af6efc642 | |||
996f887d73 | |||
fdd9d80310 | |||
37f11a0097 | |||
87aba1d0eb | |||
b7bb509b70 | |||
427997f1cb |
@ -30,6 +30,7 @@
|
||||
"class-validator": "^0.14.2",
|
||||
"deepmerge": "^4.3.1",
|
||||
"lodash": "^4.17.21",
|
||||
"meilisearch": "^0.50.0",
|
||||
"mysql2": "^3.14.1",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^5.0.1",
|
||||
|
@ -35,6 +35,9 @@ importers:
|
||||
lodash:
|
||||
specifier: ^4.17.21
|
||||
version: 4.17.21
|
||||
meilisearch:
|
||||
specifier: ^0.50.0
|
||||
version: 0.50.0
|
||||
mysql2:
|
||||
specifier: ^3.14.1
|
||||
version: 3.14.1
|
||||
@ -2744,6 +2747,9 @@ packages:
|
||||
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
meilisearch@0.50.0:
|
||||
resolution: {integrity: sha512-9IzIkobvnuS18Eg4dq/eJB9W+eXqeLZjNRgq/kKMswSmVYYSQsXqGgSuCA0JkF+o5RwJlwIsieQee6rh313VhA==}
|
||||
|
||||
memfs@3.5.3:
|
||||
resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==}
|
||||
engines: {node: '>= 4.0.0'}
|
||||
@ -7316,6 +7322,8 @@ snapshots:
|
||||
media-typer@0.3.0:
|
||||
optional: true
|
||||
|
||||
meilisearch@0.50.0: {}
|
||||
|
||||
memfs@3.5.3:
|
||||
dependencies:
|
||||
fs-monkey: 1.0.6
|
||||
|
@ -4,7 +4,10 @@ import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
|
||||
|
||||
import { AppInterceptor } from '@/modules/core/providers/app.interceptor';
|
||||
|
||||
import { database } from './config';
|
||||
import { MEILI_CONFIG } from '@/modules/meilisearch/meili.config';
|
||||
import { MeiliModule } from '@/modules/meilisearch/meili.module';
|
||||
|
||||
import { content, database } from './config';
|
||||
|
||||
import { DEFAULT_VALIDATION_CONFIG } from './modules/content/constants';
|
||||
import { ContentModule } from './modules/content/content.module';
|
||||
@ -14,7 +17,12 @@ import { AppPipe } from './modules/core/providers/app.pipe';
|
||||
import { DatabaseModule } from './modules/database/database.module';
|
||||
|
||||
@Module({
|
||||
imports: [ContentModule, CoreModule.forRoot(), DatabaseModule.forRoot(database)],
|
||||
imports: [
|
||||
ContentModule.forRoot(content),
|
||||
CoreModule.forRoot(),
|
||||
DatabaseModule.forRoot(database),
|
||||
MeiliModule.forRoot(MEILI_CONFIG),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_PIPE,
|
||||
|
5
src/config/content.config.ts
Normal file
5
src/config/content.config.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { ContentConfig } from '@/modules/content/types';
|
||||
|
||||
export const content = (): ContentConfig => ({
|
||||
SearchType: 'meili',
|
||||
});
|
@ -1 +1,2 @@
|
||||
export * from './database.config';
|
||||
export * from './content.config';
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DynamicModule, Module, ModuleMetadata } from '@nestjs/common';
|
||||
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
@ -6,21 +6,68 @@ import * as controllers from '@/modules/content/controllers';
|
||||
import * as entities from '@/modules/content/entities';
|
||||
import * as repositories from '@/modules/content/repositories';
|
||||
import * as services from '@/modules/content/services';
|
||||
import { SearchService } from '@/modules/content/services';
|
||||
import { SanitizeService } from '@/modules/content/services/SanitizeService';
|
||||
|
||||
import { PostService } from '@/modules/content/services/post.service';
|
||||
import { PostSubscriber } from '@/modules/content/subscribers/post.subscriber';
|
||||
import { ContentConfig } from '@/modules/content/types';
|
||||
import { DatabaseModule } from '@/modules/database/database.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature(Object.values(entities)),
|
||||
DatabaseModule.forRepository(Object.values(repositories)),
|
||||
],
|
||||
controllers: Object.values(controllers),
|
||||
providers: [...Object.values(services), PostSubscriber, SanitizeService],
|
||||
exports: [
|
||||
...Object.values(services),
|
||||
DatabaseModule.forRepository(Object.values(repositories)),
|
||||
],
|
||||
})
|
||||
export class ContentModule {}
|
||||
@Module({})
|
||||
export class ContentModule {
|
||||
static forRoot(configRegister?: () => ContentConfig): DynamicModule {
|
||||
const config: Required<ContentConfig> = {
|
||||
SearchType: 'mysql',
|
||||
...(configRegister ? configRegister() : {}),
|
||||
};
|
||||
const providers: ModuleMetadata['providers'] = [
|
||||
...Object.values(services),
|
||||
SanitizeService,
|
||||
PostSubscriber,
|
||||
{
|
||||
provide: PostService,
|
||||
inject: [
|
||||
repositories.PostRepository,
|
||||
repositories.CategoryRepository,
|
||||
repositories.TagRepository,
|
||||
services.CategoryService,
|
||||
{ token: services.SearchService, optional: true },
|
||||
],
|
||||
useFactory(
|
||||
postRepository: repositories.PostRepository,
|
||||
categoryRepository: repositories.CategoryRepository,
|
||||
tagRepository: repositories.TagRepository,
|
||||
categoryService: services.CategoryService,
|
||||
searchService: SearchService,
|
||||
) {
|
||||
return new PostService(
|
||||
postRepository,
|
||||
categoryRepository,
|
||||
categoryService,
|
||||
tagRepository,
|
||||
searchService,
|
||||
config.SearchType,
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
if (config.SearchType === 'meili') {
|
||||
providers.push(services.SearchService);
|
||||
}
|
||||
return {
|
||||
module: ContentModule,
|
||||
imports: [
|
||||
TypeOrmModule.forFeature(Object.values(entities)),
|
||||
DatabaseModule.forRepository(Object.values(repositories)),
|
||||
],
|
||||
controllers: Object.values(controllers),
|
||||
providers,
|
||||
exports: [
|
||||
...Object.values(services),
|
||||
PostService,
|
||||
DatabaseModule.forRepository(Object.values(repositories)),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -1,16 +1,11 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
SerializeOptions,
|
||||
} from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Post, Query, SerializeOptions } from '@nestjs/common';
|
||||
|
||||
import { CreateCommentDto, QueryCommentDto, QueryCommentTreeDto } from '../dtos/comment.dto';
|
||||
import {
|
||||
CreateCommentDto,
|
||||
DeleteCommentDto,
|
||||
QueryCommentDto,
|
||||
QueryCommentTreeDto,
|
||||
} from '../dtos/comment.dto';
|
||||
import { CommentService } from '../services';
|
||||
|
||||
@Controller('comment')
|
||||
@ -38,9 +33,9 @@ export class CommentController {
|
||||
return this.service.create(data);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Delete()
|
||||
@SerializeOptions({ groups: ['comment-detail'] })
|
||||
async delete(@Param('id', new ParseUUIDPipe()) id: string) {
|
||||
return this.service.delete(id);
|
||||
async delete(@Body() data: DeleteCommentDto) {
|
||||
return this.service.delete(data.ids);
|
||||
}
|
||||
}
|
||||
|
@ -14,6 +14,8 @@ import {
|
||||
import { CreatePostDto, QueryPostDto, UpdatePostDto } from '@/modules/content/dtos/post.dto';
|
||||
import { PostService } from '@/modules/content/services/post.service';
|
||||
|
||||
import { DeleteWithTrashDto, RestoreDto } from '../dtos/delete.with.trash.dto';
|
||||
|
||||
@Controller('posts')
|
||||
export class PostController {
|
||||
constructor(private postService: PostService) {}
|
||||
@ -51,9 +53,18 @@ export class PostController {
|
||||
return this.postService.update(data);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Delete()
|
||||
@SerializeOptions({ groups: ['post-detail'] })
|
||||
async delete(@Param('id', new ParseUUIDPipe()) id: string) {
|
||||
return this.postService.delete(id);
|
||||
async delete(@Body() data: DeleteWithTrashDto) {
|
||||
return this.postService.delete(data.ids, data.trash);
|
||||
}
|
||||
|
||||
@Patch('restore')
|
||||
@SerializeOptions({ groups: ['post-detail'] })
|
||||
async restore(
|
||||
@Body()
|
||||
data: RestoreDto,
|
||||
) {
|
||||
return this.postService.restore(data.ids);
|
||||
}
|
||||
}
|
||||
|
@ -11,6 +11,8 @@ import {
|
||||
SerializeOptions,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { DeleteDto } from '@/modules/content/dtos/delete.dto';
|
||||
|
||||
import { CreateTagDto, QueryTagDto, UpdateTagDto } from '../dtos/tag.dto';
|
||||
import { TagService } from '../services';
|
||||
|
||||
@ -51,9 +53,9 @@ export class TagController {
|
||||
return this.service.update(date);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Delete()
|
||||
@SerializeOptions({})
|
||||
async delete(@Param('id', new ParseUUIDPipe()) id: string) {
|
||||
return this.service.delete(id);
|
||||
async delete(@Body() data: DeleteDto) {
|
||||
return this.service.delete(data.ids);
|
||||
}
|
||||
}
|
||||
|
@ -46,13 +46,13 @@ export class QueryCommentTreeDto extends PickType(QueryCommentDto, ['post']) {}
|
||||
|
||||
@DtoValidation()
|
||||
export class CreateCommentDto {
|
||||
@MaxLength(1000, { message: '' })
|
||||
@IsNotEmpty({ message: '' })
|
||||
@MaxLength(1000, { message: 'The length of the comment content cannot exceed $constraint1' })
|
||||
@IsNotEmpty({ message: 'Comment content cannot be empty' })
|
||||
body: string;
|
||||
|
||||
@IsDataExist(PostEntity, { message: 'The post does not exist' })
|
||||
@IsUUID(undefined, { message: 'The ID format is incorrect' })
|
||||
@IsDefined({ message: 'The ID must be specified' })
|
||||
@IsDefined({ message: 'The post ID must be specified' })
|
||||
post: string;
|
||||
|
||||
@IsDataExist(CommentEntity, { message: 'The parent comment does not exist' })
|
||||
@ -62,3 +62,11 @@ export class CreateCommentDto {
|
||||
@Transform(({ value }) => (value === 'null' ? null : value))
|
||||
parent?: string;
|
||||
}
|
||||
|
||||
@DtoValidation()
|
||||
export class DeleteCommentDto {
|
||||
@IsDataExist(CommentEntity)
|
||||
@IsUUID(undefined, { each: true, always: true, message: 'The ID format is incorrect' })
|
||||
@IsDefined({ each: true, message: 'The ID must be specified' })
|
||||
ids: string[];
|
||||
}
|
||||
|
10
src/modules/content/dtos/delete.dto.ts
Normal file
10
src/modules/content/dtos/delete.dto.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { IsUUID, IsDefined } from 'class-validator';
|
||||
|
||||
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
|
||||
|
||||
@DtoValidation()
|
||||
export class DeleteDto {
|
||||
@IsUUID(undefined, { each: true, always: true, message: 'The ID format is incorrect' })
|
||||
@IsDefined({ each: true, message: 'The ID must be specified' })
|
||||
ids: string[];
|
||||
}
|
19
src/modules/content/dtos/delete.with.trash.dto.ts
Normal file
19
src/modules/content/dtos/delete.with.trash.dto.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
|
||||
import { IsBoolean, IsDefined, IsOptional, IsUUID } from 'class-validator';
|
||||
import { toBoolean } from 'validator';
|
||||
|
||||
import { DeleteDto } from './delete.dto';
|
||||
|
||||
export class DeleteWithTrashDto extends DeleteDto {
|
||||
@Transform(({ value }) => toBoolean(value))
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
trash?: boolean;
|
||||
}
|
||||
|
||||
export class RestoreDto {
|
||||
@IsUUID(undefined, { each: true, always: true, message: 'The ID format is incorrect' })
|
||||
@IsDefined({ each: true, message: 'The ID must be specified' })
|
||||
ids: string[];
|
||||
}
|
@ -19,6 +19,7 @@ import { isNil, toNumber } from 'lodash';
|
||||
import { PostOrder } from '@/modules/content/constants';
|
||||
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
|
||||
import { toBoolean } from '@/modules/core/helpers';
|
||||
import { SelectTrashMode } from '@/modules/database/constants';
|
||||
import { IsDataExist } from '@/modules/database/constraints/data.exist.constraint';
|
||||
import { PaginateOptions } from '@/modules/database/types';
|
||||
|
||||
@ -31,11 +32,14 @@ export class QueryPostDto implements PaginateOptions {
|
||||
@IsOptional()
|
||||
isPublished?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
search?: string;
|
||||
|
||||
@IsEnum(PostOrder, {
|
||||
message: `The sorting rule must be one of ${Object.values(PostOrder).join(',')}`,
|
||||
})
|
||||
@IsOptional()
|
||||
orderBy: PostOrder;
|
||||
orderBy?: PostOrder;
|
||||
|
||||
@Transform(({ value }) => toNumber(value))
|
||||
@Min(1, { always: true, message: 'The current page must be greater than 1.' })
|
||||
@ -52,6 +56,10 @@ export class QueryPostDto implements PaginateOptions {
|
||||
@IsOptional()
|
||||
limit = 10;
|
||||
|
||||
@IsEnum(SelectTrashMode)
|
||||
@IsOptional()
|
||||
trashed?: SelectTrashMode;
|
||||
|
||||
@IsDataExist(CategoryEntity, { always: true, message: 'The category does not exist' })
|
||||
@IsUUID(undefined, { message: 'The ID format is incorrect' })
|
||||
@IsOptional()
|
||||
|
@ -3,6 +3,7 @@ import {
|
||||
BaseEntity,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
Entity,
|
||||
JoinTable,
|
||||
ManyToMany,
|
||||
@ -64,6 +65,11 @@ export class PostEntity extends BaseEntity {
|
||||
@UpdateDateColumn({ comment: '更新时间', nullable: true })
|
||||
updatedAt?: Date;
|
||||
|
||||
@Expose()
|
||||
@Type(() => Date)
|
||||
@DeleteDateColumn({ comment: '删除时间' })
|
||||
deleteAt: Date;
|
||||
|
||||
@Expose()
|
||||
commentCount: number;
|
||||
|
||||
@ -76,7 +82,7 @@ export class PostEntity extends BaseEntity {
|
||||
|
||||
@Expose()
|
||||
@Type(() => TagEntity)
|
||||
@ManyToMany(() => TagEntity, (tag) => tag.posts, { cascade: true })
|
||||
@ManyToMany(() => TagEntity, (tag) => tag.posts, { cascade: ['insert', 'update', 'remove'] })
|
||||
@JoinTable()
|
||||
tags: Relation<TagEntity>[];
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { pick, unset } from 'lodash';
|
||||
import { isNil, pick, unset } from 'lodash';
|
||||
import { FindOptionsUtils, FindTreeOptions, TreeRepository, TreeRepositoryUtils } from 'typeorm';
|
||||
|
||||
import { CategoryEntity } from '@/modules/content/entities/category.entity';
|
||||
@ -111,4 +111,17 @@ export class CategoryRepository extends TreeRepository<CategoryEntity> {
|
||||
}
|
||||
return data as CategoryEntity[];
|
||||
}
|
||||
|
||||
async flatAncestorsTree(item: CategoryEntity) {
|
||||
let data: Omit<CategoryEntity, 'children'>[] = [];
|
||||
const category = await this.findAncestorsTree(item);
|
||||
const { parent } = category;
|
||||
unset(category, 'children');
|
||||
unset(category, 'item');
|
||||
data.push(item);
|
||||
if (!isNil(parent)) {
|
||||
data = [...(await this.flatAncestorsTree(parent)), ...data];
|
||||
}
|
||||
return data as CategoryEntity[];
|
||||
}
|
||||
}
|
||||
|
@ -2,7 +2,7 @@ import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||
|
||||
import { isNil } from 'lodash';
|
||||
|
||||
import { EntityNotFoundError, SelectQueryBuilder } from 'typeorm';
|
||||
import { EntityNotFoundError, In, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import {
|
||||
CreateCommentDto,
|
||||
@ -63,9 +63,9 @@ export class CommentService {
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
const comment = await this.repository.findOneOrFail({ where: { id: id ?? null } });
|
||||
return this.repository.remove(comment);
|
||||
async delete(ids: string[]) {
|
||||
const comments = await this.repository.find({ where: { id: In(ids) } });
|
||||
return this.repository.remove(comments);
|
||||
}
|
||||
|
||||
protected async getPost(id: string) {
|
||||
|
@ -1,4 +1,4 @@
|
||||
export * from './category.service';
|
||||
export * from './tag.service';
|
||||
export * from './post.service';
|
||||
export * from './comment.service';
|
||||
export * from './search.service';
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { isNil } from '@nestjs/common/utils/shared.utils';
|
||||
|
||||
import { isArray, isFunction, omit } from 'lodash';
|
||||
import { isArray, isFunction, omit, pick } from 'lodash';
|
||||
import { EntityNotFoundError, In, IsNull, Not, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { PostOrder } from '@/modules/content/constants';
|
||||
@ -9,6 +9,9 @@ import { CreatePostDto, QueryPostDto, UpdatePostDto } from '@/modules/content/dt
|
||||
import { PostEntity } from '@/modules/content/entities/post.entity';
|
||||
import { CategoryRepository } from '@/modules/content/repositories';
|
||||
import { PostRepository } from '@/modules/content/repositories/post.repository';
|
||||
import { SearchService } from '@/modules/content/services/search.service';
|
||||
import { SearchType } from '@/modules/content/types';
|
||||
import { SelectTrashMode } from '@/modules/database/constants';
|
||||
import { QueryHook } from '@/modules/database/types';
|
||||
import { paginate } from '@/modules/database/utils';
|
||||
|
||||
@ -27,9 +30,17 @@ export class PostService {
|
||||
protected categoryRepository: CategoryRepository,
|
||||
protected categoryService: CategoryService,
|
||||
protected tagRepository: TagRepository,
|
||||
protected searchService?: SearchService,
|
||||
protected searchType: SearchType = 'mysql',
|
||||
) {}
|
||||
|
||||
async paginate(options: QueryPostDto, callback?: QueryHook<PostEntity>) {
|
||||
if (!isNil(this.searchService) && !isNil(options.search) && this.searchType === 'meili') {
|
||||
return this.searchService.search(
|
||||
options.search,
|
||||
pick(options, ['trashed', 'page', 'limit']),
|
||||
);
|
||||
}
|
||||
const qb = await this.buildListQuery(this.repository.buildBaseQB(), options, callback);
|
||||
return paginate(qb, options);
|
||||
}
|
||||
@ -59,7 +70,11 @@ export class PostService {
|
||||
publishedAt,
|
||||
};
|
||||
const item = await this.repository.save(createPostDto);
|
||||
return this.detail(item.id);
|
||||
const result = await this.detail(item.id);
|
||||
if (!isNil(this.searchService)) {
|
||||
await this.searchService.create(result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async update(data: UpdatePostDto) {
|
||||
@ -85,12 +100,57 @@ export class PostService {
|
||||
...omit(data, ['id', 'publish', 'tags', 'category']),
|
||||
publishedAt,
|
||||
});
|
||||
return this.detail(data.id);
|
||||
const result = await this.detail(data.id);
|
||||
if (!isNil(this.searchService)) {
|
||||
await this.searchService.update([result]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
const item = await this.repository.findOneByOrFail({ id });
|
||||
return this.repository.remove(item);
|
||||
async delete(ids: string[], trash?: boolean) {
|
||||
const items = await this.repository
|
||||
.buildBaseQB()
|
||||
.where('post.id IN (:...ids)', { ids })
|
||||
.withDeleted()
|
||||
.getMany();
|
||||
let result: PostEntity[];
|
||||
if (trash) {
|
||||
const directs = items.filter((item) => !isNil(item.deleteAt));
|
||||
const softs = items.filter((item) => isNil(item.deleteAt));
|
||||
result = [
|
||||
...(await this.repository.remove(directs)),
|
||||
...(await this.repository.softRemove(softs)),
|
||||
];
|
||||
if (!isNil(this.searchService)) {
|
||||
await this.searchService.delete(directs.map((item) => item.id));
|
||||
await this.searchService.update(softs);
|
||||
}
|
||||
} else {
|
||||
result = await this.repository.remove(items);
|
||||
if (!isNil(this.searchService)) {
|
||||
await this.searchService.delete(ids);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async restore(ids: string[]) {
|
||||
const items = await this.repository
|
||||
.buildBaseQB()
|
||||
.where('post.id IN (:...ids)', { ids })
|
||||
.withDeleted()
|
||||
.getMany();
|
||||
const trashes = items.filter((item) => !isNil(item.deleteAt));
|
||||
await this.searchService.update(trashes);
|
||||
const trashedIds = trashes.map((item) => item.id);
|
||||
if (trashedIds.length < 1) {
|
||||
return [];
|
||||
}
|
||||
await this.repository.restore(trashedIds);
|
||||
const qb = await this.buildListQuery(this.repository.buildBaseQB(), {}, async (qbuilder) =>
|
||||
qbuilder.andWhereInIds(trashedIds),
|
||||
);
|
||||
return qb.getMany();
|
||||
}
|
||||
|
||||
protected async buildListQuery(
|
||||
@ -98,12 +158,21 @@ export class PostService {
|
||||
options: FindParams,
|
||||
callback?: QueryHook<PostEntity>,
|
||||
) {
|
||||
const { orderBy, isPublished, category, tag } = options;
|
||||
const { orderBy, isPublished, category, tag, trashed, search } = options;
|
||||
if (typeof isPublished === 'boolean') {
|
||||
isPublished
|
||||
? qb.where({ publishedAt: Not(IsNull()) })
|
||||
: qb.where({ publishedAt: IsNull() });
|
||||
}
|
||||
if (!isNil(search)) {
|
||||
this.buildSearchQuery(qb, search);
|
||||
}
|
||||
if (trashed === SelectTrashMode.ALL || trashed === SelectTrashMode.ONLY) {
|
||||
qb.withDeleted();
|
||||
if (trashed === SelectTrashMode.ONLY) {
|
||||
qb.where('post.deletedAt is not null');
|
||||
}
|
||||
}
|
||||
this.queryOrderBy(qb, orderBy);
|
||||
if (category) {
|
||||
await this.queryByCategory(category, qb);
|
||||
@ -117,6 +186,15 @@ export class PostService {
|
||||
return qb;
|
||||
}
|
||||
|
||||
protected buildSearchQuery(qb: SelectQueryBuilder<PostEntity>, search: string) {
|
||||
qb.orWhere('title LIKE :search', { search: `%${search}%` })
|
||||
.orWhere('summary LIKE :search', { search: `%${search}%` })
|
||||
.orWhere('body LIKE :search', { search: `%${search}%` })
|
||||
.orWhere('category.name LIKE :search', { search: `%${search}%` })
|
||||
.orWhere('tags.name LIKE :search', { search: `%${search}%` });
|
||||
return qb;
|
||||
}
|
||||
|
||||
protected queryOrderBy(qb: SelectQueryBuilder<PostEntity>, orderBy?: PostOrder) {
|
||||
switch (orderBy) {
|
||||
case PostOrder.CREATED:
|
||||
|
95
src/modules/content/services/search.service.ts
Normal file
95
src/modules/content/services/search.service.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import { ForbiddenException, Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { isNil, omit } from 'lodash';
|
||||
import { MeiliSearch } from 'meilisearch';
|
||||
|
||||
import { PostEntity } from '@/modules/content/entities';
|
||||
import {
|
||||
CategoryRepository,
|
||||
CommentRepository,
|
||||
PostRepository,
|
||||
} from '@/modules/content/repositories';
|
||||
import { SearchOption } from '@/modules/content/types';
|
||||
import { getSearchData, getSearchItem } from '@/modules/content/utils';
|
||||
import { SelectTrashMode } from '@/modules/database/constants';
|
||||
import { MeiliService } from '@/modules/meilisearch/meili.service';
|
||||
|
||||
@Injectable()
|
||||
export class SearchService implements OnModuleInit {
|
||||
private index = 'content';
|
||||
|
||||
protected client: MeiliSearch;
|
||||
|
||||
constructor(
|
||||
protected meiliService: MeiliService,
|
||||
protected categoryRepository: CategoryRepository,
|
||||
protected commentRepository: CommentRepository,
|
||||
protected postRepository: PostRepository,
|
||||
) {
|
||||
this.client = this.meiliService.getClient();
|
||||
}
|
||||
|
||||
async onModuleInit(): Promise<any> {
|
||||
await this.client.deleteIndex(this.index);
|
||||
this.client.index(this.index).updateFilterableAttributes(['deletedAt', 'publishedAt']);
|
||||
this.client.index(this.index).updateSortableAttributes(['updatedAt', 'commentCount']);
|
||||
const posts = await this.postRepository.buildBaseQB().withDeleted().getMany();
|
||||
await this.client
|
||||
.index(this.index)
|
||||
.addDocuments(
|
||||
await getSearchData(posts, this.categoryRepository, this.commentRepository),
|
||||
);
|
||||
}
|
||||
|
||||
getClient() {
|
||||
if (isNil(this.client)) {
|
||||
throw new ForbiddenException('Has no meili search client!');
|
||||
}
|
||||
return this.client;
|
||||
}
|
||||
|
||||
async search(text: string, param: SearchOption = {}) {
|
||||
const option = { page: 1, limit: 10, trashed: SelectTrashMode.ONLY, ...param };
|
||||
const limit = isNil(option.limit) || option.limit < 1 ? 1 : option.limit;
|
||||
const page = isNil(option.page) || option.page < 1 ? 1 : option.page;
|
||||
let filter = ['deletedAt IS NULL'];
|
||||
if (option.trashed === SelectTrashMode.ALL) {
|
||||
filter = [];
|
||||
} else if (option.trashed === SelectTrashMode.ONLY) {
|
||||
filter = ['deletedAt IS NOT NULL'];
|
||||
}
|
||||
if (option.isPublished) {
|
||||
filter.push('publishedAt IS NOT NULL');
|
||||
}
|
||||
const result = await this.client
|
||||
.index(this.index)
|
||||
.search(text, { page, limit, sort: ['updatedAt:desc', 'commentCount:desc'], filter });
|
||||
return {
|
||||
item: result.hits,
|
||||
currentPage: result.page,
|
||||
perPage: result.hitsPerPage,
|
||||
totalItems: result.estimatedTotalHits,
|
||||
itemCount: result.totalHits,
|
||||
...omit(result, ['hits', 'page', 'hitsPerPage', 'estimatedTotalHits', 'totalHits']),
|
||||
};
|
||||
}
|
||||
|
||||
async create(post: PostEntity) {
|
||||
return this.getClient()
|
||||
.index(this.index)
|
||||
.addDocuments(
|
||||
await getSearchItem(this.categoryRepository, this.commentRepository, post),
|
||||
);
|
||||
}
|
||||
|
||||
async update(posts: PostEntity[]) {
|
||||
return this.getClient()
|
||||
.index(this.index)
|
||||
.updateDocuments(
|
||||
await getSearchData(posts, this.categoryRepository, this.commentRepository),
|
||||
);
|
||||
}
|
||||
|
||||
async delete(ids: string[]) {
|
||||
return this.getClient().index(this.index).deleteDocuments(ids);
|
||||
}
|
||||
}
|
@ -1,6 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { omit } from 'lodash';
|
||||
|
||||
import { In } from 'typeorm';
|
||||
|
||||
import { CreateTagDto, QueryTagDto, UpdateTagDto } from '@/modules/content/dtos/tag.dto';
|
||||
import { TagRepository } from '@/modules/content/repositories/tag.repository';
|
||||
import { paginate } from '@/modules/database/utils';
|
||||
@ -30,8 +32,10 @@ export class TagService {
|
||||
return this.detail(data.id);
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
const item = await this.repository.findOneByOrFail({ id });
|
||||
return this.repository.remove(item);
|
||||
async delete(ids: string[]) {
|
||||
const items = await this.repository.find({
|
||||
where: { id: In(ids) },
|
||||
});
|
||||
return this.repository.remove(items);
|
||||
}
|
||||
}
|
||||
|
14
src/modules/content/types.ts
Normal file
14
src/modules/content/types.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { SelectTrashMode } from '@/modules/database/constants';
|
||||
|
||||
export type SearchType = 'mysql' | 'meili';
|
||||
|
||||
export interface ContentConfig {
|
||||
SearchType?: SearchType;
|
||||
}
|
||||
|
||||
export interface SearchOption {
|
||||
trashed?: SelectTrashMode;
|
||||
isPublished?: boolean;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
53
src/modules/content/utils.ts
Normal file
53
src/modules/content/utils.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { instanceToPlain } from 'class-transformer';
|
||||
import { isNil, pick } from 'lodash';
|
||||
|
||||
import { PostEntity } from '@/modules/content/entities';
|
||||
import { CategoryRepository, CommentRepository } from '@/modules/content/repositories';
|
||||
|
||||
export async function getSearchItem(
|
||||
categoryRepository: CategoryRepository,
|
||||
commentRepository: CommentRepository,
|
||||
post: PostEntity,
|
||||
) {
|
||||
const categories = isNil(post.category)
|
||||
? []
|
||||
: (await categoryRepository.flatAncestorsTree(post.category)).map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
}));
|
||||
const comments = (
|
||||
await commentRepository.find({
|
||||
relations: ['post'],
|
||||
where: { post: { id: post.id } },
|
||||
})
|
||||
).map((item) => ({ id: item.id, name: item.body }));
|
||||
return [
|
||||
{
|
||||
...pick(instanceToPlain(post), [
|
||||
'id',
|
||||
'title',
|
||||
'body',
|
||||
'summary',
|
||||
'commentCount',
|
||||
'deleteAt',
|
||||
'publishedAt',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
]),
|
||||
categories,
|
||||
comments,
|
||||
tags: post.tags.map((item) => ({ id: item.id, name: item.name })),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const getSearchData = async (
|
||||
posts: PostEntity[],
|
||||
categoryRepository: CategoryRepository,
|
||||
commentRepository: CommentRepository,
|
||||
) =>
|
||||
(
|
||||
await Promise.all(
|
||||
posts.map((post) => getSearchItem(categoryRepository, commentRepository, post)),
|
||||
)
|
||||
).reduce((o, n) => [...o, ...n], []);
|
@ -1 +1,10 @@
|
||||
export const CUSTOM_REPOSITORY_METADATA = 'CUSTOM_REPOSITORY_METADATA';
|
||||
|
||||
export enum SelectTrashMode {
|
||||
// ALL: 包含已软删除和未软删除的数据(同时查询正常数据和回收站中的数据)
|
||||
ALL = 'all',
|
||||
// ONLY: 只包含软删除的数据 (只查询回收站中的数据)
|
||||
ONLY = 'only',
|
||||
// NONE: 只包含未软删除的数据 (只查询正常数据)
|
||||
NONE = 'none',
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ import {
|
||||
ValidationOptions,
|
||||
registerDecorator,
|
||||
} from 'class-validator';
|
||||
import { isArray, isNil } from 'lodash';
|
||||
import { ObjectType, Repository, DataSource } from 'typeorm';
|
||||
|
||||
type Condition = {
|
||||
@ -18,9 +19,11 @@ type Condition = {
|
||||
export class DataExistConstraint implements ValidatorConstraintInterface {
|
||||
constructor(private dataSource: DataSource) {}
|
||||
|
||||
errorValues: any[] = [];
|
||||
|
||||
async validate(value: any, validationArguments?: ValidationArguments) {
|
||||
let repo: Repository<any>;
|
||||
if (!value) {
|
||||
if (isNil(value)) {
|
||||
return true;
|
||||
}
|
||||
let map = 'id';
|
||||
@ -30,6 +33,22 @@ export class DataExistConstraint implements ValidatorConstraintInterface {
|
||||
} else {
|
||||
repo = this.dataSource.getRepository(validationArguments.constraints[0]);
|
||||
}
|
||||
if (isArray(value)) {
|
||||
const values = value as any[];
|
||||
const validationResults = await Promise.all(
|
||||
values.map(async (val) => {
|
||||
if (isNil(val)) {
|
||||
return false;
|
||||
}
|
||||
const item = await repo.findOne({ where: { [map]: val } });
|
||||
if (isNil(item)) {
|
||||
this.errorValues.push(val);
|
||||
}
|
||||
return !isNil(item);
|
||||
}),
|
||||
);
|
||||
return validationResults.every((isValid) => isValid);
|
||||
}
|
||||
const item = await repo.findOne({ where: { [map]: value } });
|
||||
return !!item;
|
||||
}
|
||||
@ -37,6 +56,11 @@ export class DataExistConstraint implements ValidatorConstraintInterface {
|
||||
if (!validationArguments.constraints[0]) {
|
||||
return 'Model not been specified!';
|
||||
}
|
||||
if (this.errorValues.length > 0) {
|
||||
return `All instance of ${
|
||||
validationArguments.constraints[0].name
|
||||
} must been exists in databse!Errors are ${this.errorValues.join(',')}`;
|
||||
}
|
||||
return `All instance of ${validationArguments.constraints[0].name} must been exists in databse!`;
|
||||
}
|
||||
}
|
||||
|
9
src/modules/meilisearch/meili.config.ts
Normal file
9
src/modules/meilisearch/meili.config.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { MeiliConfig } from '@/modules/meilisearch/types';
|
||||
|
||||
export const MEILI_CONFIG = (): MeiliConfig => [
|
||||
{
|
||||
name: 'default',
|
||||
host: 'http://localhost:7700',
|
||||
apiKey: 'masterKey',
|
||||
},
|
||||
];
|
28
src/modules/meilisearch/meili.module.ts
Normal file
28
src/modules/meilisearch/meili.module.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { DynamicModule, Module } from '@nestjs/common';
|
||||
|
||||
import { MeiliService } from '@/modules/meilisearch/meili.service';
|
||||
import { MeiliConfig } from '@/modules/meilisearch/types';
|
||||
import { createMeiliOptions } from '@/modules/meilisearch/utils';
|
||||
|
||||
@Module({})
|
||||
export class MeiliModule {
|
||||
static forRoot(configRegister: () => MeiliConfig): DynamicModule {
|
||||
return {
|
||||
global: true,
|
||||
module: MeiliModule,
|
||||
providers: [
|
||||
{
|
||||
provide: MeiliService,
|
||||
useFactory: async () => {
|
||||
const service = new MeiliService(
|
||||
await createMeiliOptions(configRegister()),
|
||||
);
|
||||
await service.createClients();
|
||||
return service;
|
||||
},
|
||||
},
|
||||
],
|
||||
exports: [MeiliService],
|
||||
};
|
||||
}
|
||||
}
|
42
src/modules/meilisearch/meili.service.ts
Normal file
42
src/modules/meilisearch/meili.service.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isNil } from 'lodash';
|
||||
import { MeiliSearch } from 'meilisearch';
|
||||
|
||||
import { MeiliConfig } from '@/modules/meilisearch/types';
|
||||
|
||||
@Injectable()
|
||||
export class MeiliService {
|
||||
protected options: MeiliConfig;
|
||||
|
||||
protected clients: Map<string, MeiliSearch> = new Map();
|
||||
|
||||
constructor(options: MeiliConfig) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
getOptions() {
|
||||
return this.options;
|
||||
}
|
||||
|
||||
async createClients() {
|
||||
for (const option of this.options) {
|
||||
this.clients.set(option.name, new MeiliSearch(option));
|
||||
}
|
||||
}
|
||||
|
||||
getClient(name?: string): MeiliSearch {
|
||||
let key = 'default';
|
||||
if (!isNil(name)) {
|
||||
key = name;
|
||||
}
|
||||
if (!this.clients.has(key)) {
|
||||
throw new Error(`No client found for ${name}`);
|
||||
}
|
||||
return this.clients.get(key);
|
||||
}
|
||||
|
||||
getClients(): Map<string, MeiliSearch> {
|
||||
return this.clients;
|
||||
}
|
||||
}
|
5
src/modules/meilisearch/types.ts
Normal file
5
src/modules/meilisearch/types.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { Config } from 'meilisearch';
|
||||
|
||||
export type MeiliConfig = MeiliOption[];
|
||||
|
||||
export type MeiliOption = Config & { name: string };
|
18
src/modules/meilisearch/utils.ts
Normal file
18
src/modules/meilisearch/utils.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { MeiliConfig } from '@/modules/meilisearch/types';
|
||||
|
||||
export const createMeiliOptions = async (config: MeiliConfig): Promise<MeiliConfig | undefined> => {
|
||||
if (config.length < 0) {
|
||||
return config;
|
||||
}
|
||||
let options: MeiliConfig = [...config];
|
||||
const names = options.map(({ name }) => name);
|
||||
if (!names.includes('default')) {
|
||||
options[0].name = 'default';
|
||||
} else if (names.filter((name) => name === 'default').length > 0) {
|
||||
options = options.reduce(
|
||||
(o, n) => (o.map(({ name }) => name).includes('default') ? o : [...o, n]),
|
||||
[],
|
||||
);
|
||||
}
|
||||
return options;
|
||||
};
|
Loading…
Reference in New Issue
Block a user