fix: 修复Server搜索问题
All checks were successful
deploy to server / build-and-deploy (push) Successful in 3m7s

- 将meilisearch搜索服务移至Server端
This commit is contained in:
2025-11-14 14:57:55 +08:00
parent b2b631ed46
commit 17bb8adee3
6 changed files with 148 additions and 175 deletions

37
server/api/search.get.ts Normal file
View File

@ -0,0 +1,37 @@
import { toSearchItemView } from '../mappers/searchItemMapper';
import { getMeiliService } from '../services/search';
export default defineEventHandler(async (event) => {
const query = getQuery(event);
const q = String(query.query || '');
const locale = getHeader(event, 'x-locale') || 'zh-CN';
logger.info(`Search query: "${q}" for locale: "${locale}"`);
if (!q) return [];
const meili = getMeiliService();
const sections = await meili.search(q, { limit: 12 }, locale);
// 空Section过滤
const filteredSections = sections.filter(
(section) => section.hits.length > 0
);
const typeMap = {
products: 'products',
solutions: 'solutions',
questions: 'questions',
product_documents: 'product_documents',
} as const;
const hits = filteredSections.flatMap((section) => {
const type = typeMap[section.rawIndex as keyof typeof typeMap];
if (!type) return [];
return section.hits.map((hit) => ({ type, content: hit }));
});
return hits.map((hit) => {
return toSearchItemView(hit.content, hit.type);
});
});

85
server/services/search.ts Normal file
View File

@ -0,0 +1,85 @@
import { MeiliSearch } from 'meilisearch';
import type { SearchParams } from 'meilisearch';
export class MeiliService {
private client: MeiliSearch;
private indexes: string[];
constructor() {
const runtimeConfig = useRuntimeConfig();
const host = runtimeConfig.public?.meili?.host;
const apiKey = runtimeConfig.public?.meili?.searchKey;
if (!host) throw new Error('Meilisearch host not configured');
if (!apiKey) throw new Error('Meilisearch server key missing');
this.client = new MeiliSearch({ host, apiKey });
this.indexes = runtimeConfig.public.meili.indexes || [];
}
/** 构建索引名: products_zh-CN */
private buildIndexNames(locale?: string) {
if (!locale) return this.indexes;
return this.indexes.map((i) => `${i.trim()}_${locale}`);
}
/** Server 封装的搜索方法 */
async search<
K extends keyof MeiliIndexMap = keyof MeiliIndexMap,
T extends MeiliIndexMap[K] = MeiliIndexMap[K],
>(query: string, params: SearchParams = {}, locale?: string) {
const trimmedQuery = query.trim();
if (!trimmedQuery) return [];
const activeIndexes = this.buildIndexNames(locale);
if (!activeIndexes.length) {
logger.warn('No Meilisearch indexes configured.');
return [];
}
const requests = activeIndexes.map(async (indexUID) => {
const response = await this.client
.index(indexUID)
.search<T>(trimmedQuery, {
limit: params.limit ?? 10,
...params,
});
return {
indexUid: indexUID,
rawIndex: indexUID.replace(`_${locale}`, ''),
hits: response.hits,
estimatedTotalHits: response.estimatedTotalHits ?? response.hits.length,
processingTimeMs: response.processingTimeMs ?? 0,
} as SearchSection<T>;
});
// 并行安全执行
const results = await Promise.allSettled(requests);
results
.filter(
(result): result is PromiseRejectedResult =>
result.status === 'rejected'
)
.forEach((result) => {
logger.error('Meilisearch query failed', result.reason);
});
const fulfilled = results.filter(
(r): r is PromiseFulfilledResult<SearchSection<T>> =>
r.status === 'fulfilled'
);
return fulfilled.map((r) => r.value).filter((s) => s.hits.length > 0);
}
}
/** 单例 */
let instance: MeiliService | null = null;
export const getMeiliService = () => {
if (!instance) instance = new MeiliService();
return instance;
};

View File

@ -52,8 +52,7 @@ describe('converters', () => {
expect(result).toEqual({
id: 1,
title: 'How to use product?',
summary:
'This is a detailed explanation of how to use the product effectively....',
summary: '',
type: 'question',
});
});

View File

@ -22,7 +22,7 @@ export const converters: {
id: item.id,
type: 'question',
title: item.title,
summary: item.content.slice(0, 100) + '...',
summary: '',
}),
product_documents: (