feat:增加meilisearch
- todo:软删除问题
This commit is contained in:
parent
431246bc23
commit
b37dfa8103
18
.vscode/launch.json
vendored
Normal file
18
.vscode/launch.json
vendored
Normal file
@ -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": ["<node_internals>/**"],
|
||||
"type": "node"
|
||||
}
|
||||
]
|
||||
}
|
@ -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",
|
||||
|
@ -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
|
||||
|
@ -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: [
|
||||
{
|
||||
|
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: 'meilli',
|
||||
});
|
@ -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',
|
||||
|
@ -1 +1,3 @@
|
||||
export * from './content.config';
|
||||
export * from './database.config';
|
||||
export * from './meilli.config';
|
||||
|
9
src/config/meilli.config.ts
Normal file
9
src/config/meilli.config.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { MelliConfig } from '@/modules/meilisearch/types';
|
||||
|
||||
export const meilli = (): MelliConfig => [
|
||||
{
|
||||
name: 'default',
|
||||
host: 'http://localhost:7700',
|
||||
apiKey: '12345678910',
|
||||
},
|
||||
];
|
@ -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');
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -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<ContentConfig> = {
|
||||
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,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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()
|
||||
|
@ -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<PostEntity[]>;
|
||||
|
||||
/**
|
||||
* 通过QueryBuilder生成的文章数量(虚拟字段)
|
||||
*/
|
||||
@Expose()
|
||||
postCount: number;
|
||||
|
||||
@Expose()
|
||||
@Type(() => Date)
|
||||
@DeleteDateColumn({
|
||||
comment: '删除时间',
|
||||
})
|
||||
deletedAt: Date;
|
||||
|
||||
/**
|
||||
* 通过queryBuilder生成的文章数量(虚拟字段)
|
||||
*/
|
||||
@Expose()
|
||||
postCount: number;
|
||||
|
||||
@ManyToMany(() => PostEntity, (post) => post.tags)
|
||||
posts: Relation<PostEntity[]>;
|
||||
}
|
||||
|
@ -92,7 +92,7 @@ export class CategoryRepository extends TreeRepository<CategoryEntity> {
|
||||
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<CategoryEntity> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计后代元素数量
|
||||
* 统计祖先元素数量
|
||||
* @param entity
|
||||
* @param options
|
||||
*/
|
||||
@ -157,7 +157,7 @@ export class CategoryRepository extends TreeRepository<CategoryEntity> {
|
||||
|
||||
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();
|
||||
|
@ -23,7 +23,6 @@ export class CategoryService {
|
||||
|
||||
/**
|
||||
* 查询分类树
|
||||
* @param options
|
||||
*/
|
||||
async findTrees(options: QueryCategoryTreeDto) {
|
||||
const { trashed = SelectTrashMode.NONE } = options;
|
||||
|
@ -1,4 +1,5 @@
|
||||
export * from './category.service';
|
||||
export * from './comment.service';
|
||||
export * from './post.service';
|
||||
export * from './search.service';
|
||||
export * from './tag.service';
|
||||
|
@ -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<PostEntity>) {
|
||||
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<PostEntity>, 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
|
||||
|
136
src/modules/content/services/search.service.ts
Normal file
136
src/modules/content/services/search.service.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
5
src/modules/content/types.ts
Normal file
5
src/modules/content/types.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export type SearchType = 'like' | 'against' | 'meilli';
|
||||
|
||||
export interface ContentConfig {
|
||||
searchType?: SearchType;
|
||||
}
|
17
src/modules/meilisearch/helpers.ts
Normal file
17
src/modules/meilisearch/helpers.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { MelliConfig } from '@/modules/meilisearch/types';
|
||||
|
||||
export const createMeilliOptions = async (
|
||||
config: MelliConfig,
|
||||
): Promise<MelliConfig | undefined> => {
|
||||
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;
|
||||
};
|
52
src/modules/meilisearch/meilli.service.ts
Normal file
52
src/modules/meilisearch/meilli.service.ts
Normal file
@ -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<string, MeiliSearch> = 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<string, MeiliSearch> {
|
||||
return this.clients;
|
||||
}
|
||||
}
|
28
src/modules/meilisearch/melli.module.ts
Normal file
28
src/modules/meilisearch/melli.module.ts
Normal file
@ -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],
|
||||
};
|
||||
}
|
||||
}
|
7
src/modules/meilisearch/types.ts
Normal file
7
src/modules/meilisearch/types.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { Config } from 'meilisearch';
|
||||
|
||||
// MelliSearch模块的配置
|
||||
export type MelliConfig = MelliOption[];
|
||||
|
||||
// MeilliSearch的连接节点配置
|
||||
export type MelliOption = Config & { name: string };
|
Loading…
Reference in New Issue
Block a user