add meili search

This commit is contained in:
liuyi 2025-06-01 20:58:45 +08:00
parent 996f887d73
commit 7af6efc642
4 changed files with 170 additions and 1 deletions

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

@ -0,0 +1,94 @@
import { ForbiddenException, 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';
export class SearchService implements OnModuleInit {
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(['deleteAt', '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.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 (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,5 +1,14 @@
import { SelectTrashMode } from '@/modules/database/constants';
export type SearchType = 'mysql';
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], []);