From b37dfa8103ce7e07cfb5d57cfc94b7e5df9ff25b Mon Sep 17 00:00:00 2001 From: xidongdong-153 Date: Sun, 17 Dec 2023 13:28:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E5=A2=9E=E5=8A=A0meilisearch=20-=20todo?= =?UTF-8?q?=EF=BC=9A=E8=BD=AF=E5=88=A0=E9=99=A4=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/launch.json | 18 +++ package.json | 1 + pnpm-lock.yaml | 44 +++--- src/app.module.ts | 11 +- src/config/content.config.ts | 5 + src/config/database.config.ts | 2 +- src/config/index.ts | 2 + src/config/meilli.config.ts | 9 ++ src/main.ts | 4 +- src/modules/content/content.module.ts | 75 ++++++++-- src/modules/content/dtos/post.dto.ts | 7 + .../content/entities/comment.entity.ts | 2 + src/modules/content/entities/tag.entity.ts | 31 ++-- .../repositories/category.repository.ts | 6 +- .../content/services/category.service.ts | 1 - src/modules/content/services/index.ts | 1 + src/modules/content/services/post.service.ts | 69 +++++++-- .../content/services/search.service.ts | 136 ++++++++++++++++++ src/modules/content/types.ts | 5 + src/modules/meilisearch/helpers.ts | 17 +++ src/modules/meilisearch/meilli.service.ts | 52 +++++++ src/modules/meilisearch/melli.module.ts | 28 ++++ src/modules/meilisearch/types.ts | 7 + 23 files changed, 468 insertions(+), 65 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 src/config/content.config.ts create mode 100644 src/config/meilli.config.ts create mode 100644 src/modules/content/services/search.service.ts create mode 100644 src/modules/content/types.ts create mode 100644 src/modules/meilisearch/helpers.ts create mode 100644 src/modules/meilisearch/meilli.service.ts create mode 100644 src/modules/meilisearch/melli.module.ts create mode 100644 src/modules/meilisearch/types.ts diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..02f090a --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,18 @@ +{ + // 使用 IntelliSense 了解相关属性。 + // 悬停以查看现有属性的描述。 + // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "debug 3rapp", + "request": "launch", + "runtimeArgs": ["run-script", "start:debug"], + "autoAttachChildProcesses": true, + "console": "integratedTerminal", + "runtimeExecutable": "pnpm", + "skipFiles": ["/**"], + "type": "node" + } + ] +} \ No newline at end of file diff --git a/package.json b/package.json index cf85832..c62736a 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "deepmerge": "^4.3.1", "fastify": "^4.24.3", "lodash": "^4.17.21", + "meilisearch": "^0.36.0", "mysql2": "^3.6.5", "reflect-metadata": "^0.1.14", "rxjs": "^7.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4acc55d..ff222ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,9 +1,5 @@ lockfileVersion: '6.0' -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - dependencies: '@nestjs/common': specifier: ^10.2.10 @@ -35,6 +31,9 @@ dependencies: lodash: specifier: ^4.17.21 version: 4.17.21 + meilisearch: + specifier: ^0.36.0 + version: 0.36.0 mysql2: specifier: ^3.6.5 version: 3.6.5 @@ -60,7 +59,7 @@ devDependencies: version: 10.2.1(@swc/cli@0.1.63)(@swc/core@1.3.100) '@nestjs/schematics': specifier: ^10.0.3 - version: 10.0.3(typescript@5.3.3) + version: 10.0.3(chokidar@3.5.3)(typescript@5.2.2) '@nestjs/testing': specifier: ^10.2.10 version: 10.2.10(@nestjs/common@10.2.10)(@nestjs/core@10.2.10) @@ -1114,21 +1113,6 @@ packages: - chokidar dev: true - /@nestjs/schematics@10.0.3(typescript@5.3.3): - resolution: {integrity: sha512-2BRujK0GqGQ7j1Zpz+obVfskDnnOeVKt5aXoSaVngKo8Oczy8uYCY+R547TQB+Kf35epdfFER2pVnQrX3/It5A==} - peerDependencies: - typescript: '>=4.8.2' - dependencies: - '@angular-devkit/core': 16.2.8(chokidar@3.5.3) - '@angular-devkit/schematics': 16.2.8(chokidar@3.5.3) - comment-json: 4.2.3 - jsonc-parser: 3.2.0 - pluralize: 8.0.0 - typescript: 5.3.3 - transitivePeerDependencies: - - chokidar - dev: true - /@nestjs/swagger@7.1.16(@nestjs/common@10.2.10)(@nestjs/core@10.2.10)(class-transformer@0.5.1)(class-validator@0.14.0)(reflect-metadata@0.1.14): resolution: {integrity: sha512-f9KBk/BX9MUKPTj7tQNYJ124wV/jP5W2lwWHLGwe/4qQXixuDOo39zP55HIJ44LE7S04B7BOeUOo9GBJD/vRcw==} peerDependencies: @@ -2649,6 +2633,14 @@ packages: /create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + /cross-fetch@3.1.8: + resolution: {integrity: sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==} + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + dev: false + /cross-spawn@5.1.0: resolution: {integrity: sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==} dependencies: @@ -4997,6 +4989,14 @@ packages: tmpl: 1.0.5 dev: true + /meilisearch@0.36.0: + resolution: {integrity: sha512-swcvEYrct0/zsGj3jlbPm1OYxbH14IURnlysKlXywNicIQ5EMkSYLYCLCwOuBKAaGcdISWdgdylH9TXVLegmOQ==} + dependencies: + cross-fetch: 3.1.8 + transitivePeerDependencies: + - encoding + dev: false + /memfs@3.5.3: resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} engines: {node: '>= 4.0.0'} @@ -6988,3 +6988,7 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} dev: true + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false diff --git a/src/app.module.ts b/src/app.module.ts index 1701570..5870b19 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -2,15 +2,22 @@ import { Module } from '@nestjs/common'; import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; -import { database } from '@/config'; +import { content, database, meilli } from '@/config'; import { ContentModule } from '@/modules/content/content.module'; import { CoreModule } from '@/modules/core/core.module'; import { AppFilter, AppIntercepter, AppPipe } from '@/modules/core/providers'; import { DatabaseModule } from '@/modules/database/database.module'; +import { MeilliModule } from '@/modules/meilisearch/melli.module'; import { WelcomeModule } from '@/modules/welcome/welcome.module'; @Module({ - imports: [DatabaseModule.forRoot(database), ContentModule, WelcomeModule, CoreModule.forRoot()], + imports: [ + DatabaseModule.forRoot(database), + ContentModule.forRoot(content), + WelcomeModule, + CoreModule.forRoot(), + MeilliModule.forRoot(meilli), + ], controllers: [], providers: [ { diff --git a/src/config/content.config.ts b/src/config/content.config.ts new file mode 100644 index 0000000..751dd25 --- /dev/null +++ b/src/config/content.config.ts @@ -0,0 +1,5 @@ +import { ContentConfig } from '@/modules/content/types'; + +export const content = (): ContentConfig => ({ + searchType: 'meilli', +}); diff --git a/src/config/database.config.ts b/src/config/database.config.ts index 6444f5e..b357d2d 100644 --- a/src/config/database.config.ts +++ b/src/config/database.config.ts @@ -8,7 +8,7 @@ export const database = (): TypeOrmModuleOptions => ({ charset: 'utf8mb4', logging: ['error'], type: 'mysql', - host: '127.0.0.1', + host: 'localhost', port: 3306, username: 'root', password: '12345678910', diff --git a/src/config/index.ts b/src/config/index.ts index 64341a6..47b7c5a 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1 +1,3 @@ +export * from './content.config'; export * from './database.config'; +export * from './meilli.config'; diff --git a/src/config/meilli.config.ts b/src/config/meilli.config.ts new file mode 100644 index 0000000..f65cdc9 --- /dev/null +++ b/src/config/meilli.config.ts @@ -0,0 +1,9 @@ +import { MelliConfig } from '@/modules/meilisearch/types'; + +export const meilli = (): MelliConfig => [ + { + name: 'default', + host: 'http://localhost:7700', + apiKey: '12345678910', + }, +]; diff --git a/src/main.ts b/src/main.ts index 8cd30af..313deef 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,8 +18,8 @@ const bootstrap = async () => { fallbackOnErrors: true, }); - await app.listen(2333, () => { - console.log('api: http://localhost:2333/api'); + await app.listen(3100, () => { + console.log('api: http://localhost:3100/api'); }); }; diff --git a/src/modules/content/content.module.ts b/src/modules/content/content.module.ts index 970eeed..0b7ee3e 100644 --- a/src/modules/content/content.module.ts +++ b/src/modules/content/content.module.ts @@ -1,26 +1,73 @@ -import { Module } from '@nestjs/common'; +import { DynamicModule, Module, ModuleMetadata } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { SanitizeService } from '@/modules/content/services/sanitize.service'; import { PostSubscriber } from '@/modules/content/subscribers'; +import { ContentConfig } from '@/modules/content/types'; import { DatabaseModule } from '@/modules/database/database.module'; import * as controllers from './controllers'; import * as entities from './entities'; import * as repositories from './repositories'; import * as services from './services'; +import { PostService } from './services'; -@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 = { + searchType: 'against', + ...(configRegister ? configRegister() : {}), + }; + + const providers: ModuleMetadata['providers'] = [ + ...Object.values(services), + PostSubscriber, + SanitizeService, + { + provide: PostService, + inject: [ + repositories.PostRepository, + repositories.CategoryRepository, + services.CategoryService, + repositories.TagRepository, + { token: services.SearchService, optional: true }, + ], + useFactory( + postRepository: repositories.PostRepository, + categoryRepository: repositories.CategoryRepository, + categoryService: services.CategoryService, + tagRepository: repositories.TagRepository, + searchService: services.SearchService, + ) { + return new PostService( + postRepository, + categoryRepository, + categoryService, + tagRepository, + searchService, + config.searchType, + ); + }, + }, + ]; + + if (config.searchType === 'meilli') 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), + DatabaseModule.forRepository(Object.values(repositories)), + PostService, + ], + }; + } +} diff --git a/src/modules/content/dtos/post.dto.ts b/src/modules/content/dtos/post.dto.ts index d31d693..aceceb1 100644 --- a/src/modules/content/dtos/post.dto.ts +++ b/src/modules/content/dtos/post.dto.ts @@ -71,6 +71,13 @@ export class QueryPostDto implements PaginateOptions { @IsEnum(SelectTrashMode) @IsOptional() trashed?: SelectTrashMode; + + @MaxLength(100, { + always: true, + message: '搜索字符串长度不得超过$constraint1', + }) + @IsOptional({ always: true }) + search?: string; } /** diff --git a/src/modules/content/entities/comment.entity.ts b/src/modules/content/entities/comment.entity.ts index 9759d7e..7caffad 100644 --- a/src/modules/content/entities/comment.entity.ts +++ b/src/modules/content/entities/comment.entity.ts @@ -4,6 +4,7 @@ import { Column, CreateDateColumn, Entity, + Index, ManyToOne, PrimaryColumn, Relation, @@ -24,6 +25,7 @@ export class CommentEntity extends BaseEntity { @Expose() @Column({ comment: '评论内容', type: 'text' }) + @Index({ fulltext: true }) body: string; @Expose() diff --git a/src/modules/content/entities/tag.entity.ts b/src/modules/content/entities/tag.entity.ts index b486c24..b9b6bb7 100644 --- a/src/modules/content/entities/tag.entity.ts +++ b/src/modules/content/entities/tag.entity.ts @@ -1,5 +1,13 @@ import { Exclude, Expose, Type } from 'class-transformer'; -import { Column, DeleteDateColumn, Entity, ManyToMany, PrimaryColumn, Relation } from 'typeorm'; +import { + Column, + DeleteDateColumn, + Entity, + Index, + ManyToMany, + PrimaryColumn, + Relation, +} from 'typeorm'; import { PostEntity } from '@/modules/content/entities/post.entity'; @@ -11,26 +19,27 @@ export class TagEntity { id: string; @Expose() - @Column({ comment: '标签名称' }) + @Column({ comment: '分类名称' }) + @Index({ fulltext: true }) name: string; @Expose() @Column({ comment: '标签描述', nullable: true }) description?: string; - @ManyToMany(() => PostEntity, (post) => post.tags) - posts: Relation; - - /** - * 通过QueryBuilder生成的文章数量(虚拟字段) - */ - @Expose() - postCount: number; - @Expose() @Type(() => Date) @DeleteDateColumn({ comment: '删除时间', }) deletedAt: Date; + + /** + * 通过queryBuilder生成的文章数量(虚拟字段) + */ + @Expose() + postCount: number; + + @ManyToMany(() => PostEntity, (post) => post.tags) + posts: Relation; } diff --git a/src/modules/content/repositories/category.repository.ts b/src/modules/content/repositories/category.repository.ts index 0f8b6a9..a7b0437 100644 --- a/src/modules/content/repositories/category.repository.ts +++ b/src/modules/content/repositories/category.repository.ts @@ -92,7 +92,7 @@ export class CategoryRepository extends TreeRepository { withTrashed?: boolean; }, ) { - const qb = this.createAncestorsQueryBuilder('category', 'treeClosure', entity); + const qb = this.createDescendantsQueryBuilder('category', 'treeClosure', entity); FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, options); qb.orderBy('category.customOrder', 'ASC'); @@ -145,7 +145,7 @@ export class CategoryRepository extends TreeRepository { } /** - * 统计后代元素数量 + * 统计祖先元素数量 * @param entity * @param options */ @@ -157,7 +157,7 @@ export class CategoryRepository extends TreeRepository { if (options?.withTrashed) { qb.withDeleted(); - if (options?.onlyTrashed) qb.where(`category.deleteAt IS NOT NULL`); + if (options?.onlyTrashed) qb.where(`category.deletedAt IS NOT NULL`); } return qb.getCount(); diff --git a/src/modules/content/services/category.service.ts b/src/modules/content/services/category.service.ts index 121ef68..3a542f4 100644 --- a/src/modules/content/services/category.service.ts +++ b/src/modules/content/services/category.service.ts @@ -23,7 +23,6 @@ export class CategoryService { /** * 查询分类树 - * @param options */ async findTrees(options: QueryCategoryTreeDto) { const { trashed = SelectTrashMode.NONE } = options; diff --git a/src/modules/content/services/index.ts b/src/modules/content/services/index.ts index 7d64e84..6b8f50f 100644 --- a/src/modules/content/services/index.ts +++ b/src/modules/content/services/index.ts @@ -1,4 +1,5 @@ export * from './category.service'; export * from './comment.service'; export * from './post.service'; +export * from './search.service'; export * from './tag.service'; diff --git a/src/modules/content/services/post.service.ts b/src/modules/content/services/post.service.ts index da80121..1f8728b 100644 --- a/src/modules/content/services/post.service.ts +++ b/src/modules/content/services/post.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { isArray, isFunction, isNil, omit } from 'lodash'; +import { isArray, isFunction, isNil, omit, pick } from 'lodash'; import { EntityNotFoundError, In, IsNull, Not, SelectQueryBuilder } from 'typeorm'; @@ -10,6 +10,8 @@ import { PostEntity } from '@/modules/content/entities'; import { CategoryRepository, PostRepository, TagRepository } from '@/modules/content/repositories'; import { CategoryService } from '@/modules/content/services/category.service'; +import { SearchService } from '@/modules/content/services/search.service'; +import { SearchType } from '@/modules/content/types'; import { SelectTrashMode } from '@/modules/database/constants'; import { paginate } from '@/modules/database/helpers'; import { QueryHook } from '@/modules/database/types'; @@ -25,6 +27,8 @@ export class PostService { protected categoryRepository: CategoryRepository, protected categoryService: CategoryService, protected tagRepository: TagRepository, + protected searchService?: SearchService, + protected search_type: SearchType = 'against', ) {} /** @@ -33,6 +37,12 @@ export class PostService { * @param callback 添加额外的查询 */ async paginate(options: QueryPostDto, callback?: QueryHook) { + if (!isNil(this.searchService) && !isNil(options.search) && this.search_type === 'meilli') { + 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); } @@ -69,9 +79,10 @@ export class PostService { }) : [], }; - const item = await this.repository.save(createPostDto); + if (!isNil(this.searchService)) await this.searchService.create(item); + return this.detail(item.id); } @@ -101,8 +112,10 @@ export class PostService { } await this.repository.update(data.id, omit(data, ['id', 'tags', 'category'])); + const result = await this.detail(data.id); + if (!isNil(this.searchService)) await this.searchService.update([post]); - return this.detail(data.id); + return result; } /** @@ -111,22 +124,30 @@ export class PostService { */ async delete(ids: string[], trash?: boolean) { const items = await this.repository.find({ - where: { id: In(ids) } as any, + where: { id: In(ids) }, withDeleted: true, }); + let result: PostEntity[] = []; if (trash) { // 对已软删除的数据再次删除时直接通过remove方法从数据库中清除 const directs = items.filter((item) => !isNil(item.deletedAt)); const softs = items.filter((item) => isNil(item.deletedAt)); - - return [ + result = [ ...(await this.repository.remove(directs)), ...(await this.repository.softRemove(softs)), ]; + if (!isNil(this.searchService)) { + await this.searchService.delete(directs.map(({ id }) => id)); + await this.searchService.update(softs); + } + } else { + result = await this.repository.remove(items); + if (!isNil(this.searchService)) { + await this.searchService.delete(result.map(({ id }) => id)); + } } - - return this.repository.remove(items); + return result; } /** @@ -135,14 +156,12 @@ export class PostService { */ async restore(ids: string[]) { const items = await this.repository.find({ - where: { id: In(ids) } as any, + where: { id: In(ids) }, withDeleted: true, }); // 过滤掉不在回收站中的数据 const trasheds = items.filter((item) => !isNil(item)).map((item) => item.id); - if (trasheds.length < 1) return []; - await this.repository.restore(trasheds); const qb = await this.buildListQuery(this.repository.buildBaseQB(), {}, async (qbuilder) => qbuilder.andWhereInIds(trasheds), @@ -179,12 +198,40 @@ export class PostService { this.queryOrderBy(qb, orderBy); if (category) await this.queryByCategory(category, qb); + if (!isNil(options.search)) this.buildSearchQuery(qb, options.search); // 查询某个标签关联的文章 if (tag) qb.where('tags.id = :id', { id: tag }); if (callback) return callback(qb); return qb; } + protected async buildSearchQuery(qb: SelectQueryBuilder, search: string) { + if (this.search_type === 'like') { + qb.andWhere('title LIKE :search', { search: `%${search}%` }) + .orWhere('body LIKE :search', { search: `%${search}%` }) + .orWhere('summary LIKE :search', { search: `%${search}%` }) + .orWhere('category.name LIKE :search', { search: `%${search}%` }) + .orWhere('tags.name LIKE :search', { search: `%${search}%` }); + } else if (this.search_type === 'against') { + qb.andWhere('MATCH(title) AGAINST (:search IN BOOLEAN MODE)', { + search: `${search}*`, + }) + .orWhere('MATCH(body) AGAINST (:search IN BOOLEAN MODE)', { + search: `${search}*`, + }) + .orWhere('MATCH(summary) AGAINST (:search IN BOOLEAN MODE)', { + search: `${search}*`, + }) + .orWhere('MATCH(category.name) AGAINST (:search IN BOOLEAN MODE)', { + search: `${search}*`, + }) + .orWhere('MATCH(tags.name) AGAINST (:search IN BOOLEAN MODE)', { + search: `${search}*`, + }); + } + return qb; + } + /** * 对文章进行排序的Query构建 * @param qb diff --git a/src/modules/content/services/search.service.ts b/src/modules/content/services/search.service.ts new file mode 100644 index 0000000..692d2ca --- /dev/null +++ b/src/modules/content/services/search.service.ts @@ -0,0 +1,136 @@ +import { ForbiddenException, Injectable } from '@nestjs/common'; + +import { instanceToPlain } from 'class-transformer'; + +import { isNil, omit, pick } from 'lodash'; + +import MeiliSearch from 'meilisearch'; + +import { PostEntity } from '@/modules/content/entities'; +import { CategoryRepository, CommentRepository } from '@/modules/content/repositories'; +import { SelectTrashMode } from '@/modules/database/constants'; +import { MeilliService } from '@/modules/meilisearch/meilli.service'; + +interface SearchOption { + trashed?: SelectTrashMode; + isPublished?: boolean; + page?: number; + limit?: number; +} + +async function getPostData( + catRepo: CategoryRepository, + cmtRepo: CommentRepository, + post: PostEntity, +) { + const categories = [ + ...(await catRepo.findAncestors(post.category)).map((item) => { + return { + id: item.id, + name: item.name, + }; + }), + { id: post.category.id, name: post.category.name }, + ]; + + const comments = ( + await cmtRepo.find({ + relations: ['post'], + where: { post: { id: post.id } }, + }) + ).map((item) => ({ id: item.id, body: item.body })); + + return [ + { + ...pick(instanceToPlain(post), [ + 'id', + 'title', + 'body', + 'summary', + 'commentCount', + 'deletedAt', + 'publishedAt', + 'createdAt', + 'updatedAt', + ]), + categories, + tags: post.tags.map((item) => ({ id: item.id, name: item.name })), + comments, + }, + ]; +} + +@Injectable() +export class SearchService { + index = 'content'; + + protected _client: MeiliSearch; + + constructor( + protected meilliService: MeilliService, + protected categoryRepository: CategoryRepository, + protected commentRepository: CommentRepository, + ) { + this._client = this.meilliService.getClient(); + } + + get client() { + if (isNil(this._client)) throw new ForbiddenException('Has not any meilli search client!'); + return this._client; + } + + async search(text: string, param: SearchOption = {}) { + await this.client.index(this.index).addDocuments([]); + this.client.index(this.index).updateFilterableAttributes(['deletedAt', 'publishedAt']); + this.client.index(this.index).updateSortableAttributes(['updatedAt', 'commentCount']); + const option = { page: 1, limit: 10, trashed: SelectTrashMode.NONE, ...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 (!isNil(option.isPublished)) { + filter.push(option.isPublished ? 'publishedAt IS NOT NULL' : 'deletedAt IS NULL'); + } + const result = await this.client.index(this.index).search(text, { + page, + limit, + sort: ['updatedAt:desc', 'commentCount:desc'], + filter, + }); + + return { + items: 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.client + .index(this.index) + .addDocuments(await getPostData(this.categoryRepository, this.commentRepository, post)); + } + + async update(posts: PostEntity[]) { + return this.client + .index(this.index) + .updateDocuments( + await Promise.all( + posts.map((post) => + getPostData(this.categoryRepository, this.commentRepository, post), + ), + ), + ); + } + + async delete(ids: string[]) { + return this.client.index(this.index).deleteDocuments(ids); + } +} diff --git a/src/modules/content/types.ts b/src/modules/content/types.ts new file mode 100644 index 0000000..d083875 --- /dev/null +++ b/src/modules/content/types.ts @@ -0,0 +1,5 @@ +export type SearchType = 'like' | 'against' | 'meilli'; + +export interface ContentConfig { + searchType?: SearchType; +} diff --git a/src/modules/meilisearch/helpers.ts b/src/modules/meilisearch/helpers.ts new file mode 100644 index 0000000..330fe51 --- /dev/null +++ b/src/modules/meilisearch/helpers.ts @@ -0,0 +1,17 @@ +import { MelliConfig } from '@/modules/meilisearch/types'; + +export const createMeilliOptions = async ( + config: MelliConfig, +): Promise => { + if (config.length <= 0) return config; + let options: MelliConfig = [...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; +}; diff --git a/src/modules/meilisearch/meilli.service.ts b/src/modules/meilisearch/meilli.service.ts new file mode 100644 index 0000000..60bfb2a --- /dev/null +++ b/src/modules/meilisearch/meilli.service.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@nestjs/common'; +import { isNil } from 'lodash'; +import MeiliSearch from 'meilisearch'; + +import { MelliConfig } from '@/modules/meilisearch/types'; + +@Injectable() +export class MeilliService { + protected options: MelliConfig; + + /** + * 客户端连接 + */ + protected clients: Map = new Map(); + + constructor(options: MelliConfig) { + this.options = options; + } + + getOptions() { + return this.options; + } + + /** + * 通过配置创建所有连接 + */ + async createClients() { + this.options.forEach(async (o) => { + this.clients.set(o.name, new MeiliSearch(o)); + }); + } + + /** + * 获取一个客户端连接 + * @param name 连接名称,默认default + */ + getClient(name?: string): MeiliSearch { + let key = 'default'; + if (!isNil(name)) key = name; + if (!this.clients.has(key)) { + throw new Error(`client ${key} does not exist`); + } + return this.clients.get(key); + } + + /** + * 获取所有客户端连接 + */ + getClients(): Map { + return this.clients; + } +} diff --git a/src/modules/meilisearch/melli.module.ts b/src/modules/meilisearch/melli.module.ts new file mode 100644 index 0000000..eb4cdd7 --- /dev/null +++ b/src/modules/meilisearch/melli.module.ts @@ -0,0 +1,28 @@ +import { DynamicModule, Module } from '@nestjs/common'; + +import { createMeilliOptions } from '@/modules/meilisearch/helpers'; +import { MeilliService } from '@/modules/meilisearch/meilli.service'; +import { MelliConfig } from '@/modules/meilisearch/types'; + +@Module({}) +export class MeilliModule { + static forRoot(configRegister: () => MelliConfig): DynamicModule { + return { + global: true, + module: MeilliModule, + providers: [ + { + provide: MeilliService, + useFactory: async () => { + const service = new MeilliService( + await createMeilliOptions(configRegister()), + ); + service.createClients(); + return service; + }, + }, + ], + exports: [MeilliService], + }; + } +} diff --git a/src/modules/meilisearch/types.ts b/src/modules/meilisearch/types.ts new file mode 100644 index 0000000..ab4a645 --- /dev/null +++ b/src/modules/meilisearch/types.ts @@ -0,0 +1,7 @@ +import { Config } from 'meilisearch'; + +// MelliSearch模块的配置 +export type MelliConfig = MelliOption[]; + +// MeilliSearch的连接节点配置 +export type MelliOption = Config & { name: string };