Compare commits

...

9 Commits

Author SHA1 Message Date
5452f890ec add meili search 2025-06-01 22:02:53 +08:00
02ccf58457 add meili search 2025-06-01 21:24:43 +08:00
7af6efc642 add meili search 2025-06-01 20:58:45 +08:00
996f887d73 add meili search 2025-06-01 18:46:13 +08:00
fdd9d80310 modify PostService and ContentModule 2025-06-01 14:57:20 +08:00
37f11a0097 add content db type 2025-06-01 09:39:59 +08:00
87aba1d0eb tag chang to patch delete 2025-05-31 19:02:53 +08:00
b7bb509b70 post and soft delete 2025-05-31 18:46:12 +08:00
427997f1cb modify delete comment 2025-05-31 12:05:57 +08:00
29 changed files with 580 additions and 59 deletions

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
import { ContentConfig } from '@/modules/content/types';
export const content = (): ContentConfig => ({
SearchType: 'meili',
});

View File

@ -1 +1,2 @@
export * from './database.config';
export * from './content.config';

View File

@ -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)),
],
};
}
}

View File

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

View File

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

View File

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

View File

@ -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[];
}

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

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

View File

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

View File

@ -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>[];

View File

@ -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[];
}
}

View File

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

View File

@ -1,4 +1,4 @@
export * from './category.service';
export * from './tag.service';
export * from './post.service';
export * from './comment.service';
export * from './search.service';

View File

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

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

View File

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

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

View 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], []);

View File

@ -1 +1,10 @@
export const CUSTOM_REPOSITORY_METADATA = 'CUSTOM_REPOSITORY_METADATA';
export enum SelectTrashMode {
// ALL: 包含已软删除和未软删除的数据(同时查询正常数据和回收站中的数据)
ALL = 'all',
// ONLY: 只包含软删除的数据 (只查询回收站中的数据)
ONLY = 'only',
// NONE: 只包含未软删除的数据 (只查询正常数据)
NONE = 'none',
}

View File

@ -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!`;
}
}

View File

@ -0,0 +1,9 @@
import { MeiliConfig } from '@/modules/meilisearch/types';
export const MEILI_CONFIG = (): MeiliConfig => [
{
name: 'default',
host: 'http://localhost:7700',
apiKey: 'masterKey',
},
];

View 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],
};
}
}

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

View File

@ -0,0 +1,5 @@
import { Config } from 'meilisearch';
export type MeiliConfig = MeiliOption[];
export type MeiliOption = Config & { name: string };

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