From 2099fed3184d8c070aec179367f917cca27230dc Mon Sep 17 00:00:00 2001 From: R2m1liA <15258427350@163.com> Date: Thu, 23 Oct 2025 09:11:44 +0000 Subject: [PATCH] =?UTF-8?q?feat(hook):=20=E4=B8=BAmeilisearch=E7=B4=A2?= =?UTF-8?q?=E5=BC=95=E5=88=B7=E6=96=B0=E6=B7=BB=E5=8A=A0hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 词条更新时,刷新当前集合的索引 - 每天凌晨刷新所有启用的索引 --- src/meilisearch_hook/index.ts | 345 +++++++++++++++++++++++++++++++++- 1 file changed, 339 insertions(+), 6 deletions(-) diff --git a/src/meilisearch_hook/index.ts b/src/meilisearch_hook/index.ts index 0bcb0b3..a5e14f8 100644 --- a/src/meilisearch_hook/index.ts +++ b/src/meilisearch_hook/index.ts @@ -1,9 +1,13 @@ import { defineHook } from '@directus/extensions-sdk'; import { createLogger } from '../logger'; +import { MeiliDocs, MeiliIndexConfig, MeiliSearchConfig } from '../types/meilisearch'; +import { MeiliSearch } from 'meilisearch'; +import { buildQueryFields, filterTranslations } from '../helper/collection'; +import { getNestedProperty } from '../helper/nest'; const logger = createLogger('meilisearch_hook'); -export default defineHook(async ({ init, filter, action }, { services, getSchema }) => { +export default defineHook(async ({ init, filter, action, schedule }, { services, getSchema }) => { init('app.after', async () => { logger.info('Directus App Started - MeiliSearch Hook Initialized'); const { CollectionsService } = services; @@ -15,7 +19,7 @@ export default defineHook(async ({ init, filter, action }, { services, getSchema // 检查meili_search_config集合是否存在 const meili_search_config_exists = await collectionsSvc.readOne('meili_search_config').then(() => true).catch(() => false); if (!meili_search_config_exists) { - logger.warn('collection meili_search_config does not exist, creating...'); + logger.warn('Collection meili_search_config does not exist, creating...'); await collectionsSvc.createOne({ collection: 'meili_search_config', meta: { @@ -49,7 +53,7 @@ export default defineHook(async ({ init, filter, action }, { services, getSchema meta: { note: '配置哪些集合需要被索引到 MeiliSearch', icon: 'search', - hidden: true, + // hidden: true, system: true, }, schema: { @@ -60,7 +64,6 @@ export default defineHook(async ({ init, filter, action }, { services, getSchema { field: 'index_name', type: 'string', meta: { note: 'MeiliSearch 索引名称', interface: 'input' } }, { field: 'fields', type: 'json', meta: { note: '要索引的字段数组', interface: 'code-editor' } }, { field: 'enabled', type: 'boolean', meta: { note: '是否启用', interface: 'boolean' } }, - { field: 'settings', type: 'json', meta: { note: 'MeiliSearch 索引设置', interface: 'code-editor' } }, ], }).then(() => { logger.info('meili_index_configs collection created successfully.'); @@ -70,13 +73,343 @@ export default defineHook(async ({ init, filter, action }, { services, getSchema } else { logger.info('meili_index_configs collection already exists.'); } + + // 读取所有可索引集合并存入meili_index_configs + const allCollections = await collectionsSvc.readByQuery(); + const availableCollections = allCollections.filter((col) => { + const isVisible = !col.meta?.hidden; + const isUserDefined = !col.meta?.system; + const hasSchema = !!col.schema; + const isNotMeiliConfig = col.collection !== 'meili_index_configs' && col.collection !== 'meili_search_config'; + + return isVisible && isUserDefined && hasSchema && isNotMeiliConfig; + }); + + const { ItemsService } = services; + const meiliIndexConfigsSvc = new ItemsService('meili_index_configs', { + schema, + }); + + // 读取已有配置 + const existingConfigs = await meiliIndexConfigsSvc.readByQuery({ limit: -1 }); + const existingMap = new Map(existingConfigs.map((config) => [config.collection_name, config])); + + // 为每一个可用集合创建默认配置(如果不存在的话) + for (const col of availableCollections) { + if (!existingMap.has(col.collection)) { + await meiliIndexConfigsSvc.createOne({ + collection_name: col.collection, + index_name: col.collection, + fields: [], + enabled: false, + }); + logger.info(`Created default MeiliSearch index config for collection: ${col.collection}`); + } + } }); filter('items.create', () => { logger.info('Creating Item!'); }); - action('items.create', () => { - logger.info('Item created!'); + async function getConfig(collection: string) { + const schema = await getSchema(); + const { ItemsService } = services; + const cfgSvc = new ItemsService('meili_index_configs', { schema }); + const configs = await cfgSvc.readByQuery({ filter: { collection_name: { _eq: collection }, enabled: { _eq: true } }, limit: 1 }); + return configs.length ? configs[0] : null; + } + + // 监听 items.create 事件以触发 MeiliSearch 索引创建 + action('items.create', async ({ meta }) => { + const cfg = await getConfig(meta?.collection); + if (!cfg) return; + const configId = cfg.id; + logger.info('Item created, MeiliSearch indexing enabled for collection:', meta?.collection, 'Config ID:', configId); + + const { ItemsService } = services; + const schema = await getSchema(); + + // 获取可用语言选项 + let availableLanguages: string[] = []; + try { + const langService = new ItemsService('languages', { schema }); + const languages = await langService.readByQuery({ limit: -1 }); + availableLanguages = languages.map(lang => lang.code); + } catch (error) { + logger.error('Error fetching languages:', error); + } + + // 读取 MeiliSearch 全局配置 + const meiliService = new ItemsService('meili_search_config', { schema }); + const meiliConfigs = await meiliService.readByQuery({ limit: 1 }); + if (meiliConfigs.length === 0) { + return; + } + const { host } = meiliConfigs[0] || { host: '', apiKey: '' }; + + const client = new MeiliSearch({ + host, + }) + const result: any[] = []; + + const fields = cfg.fields; + const queryFields = buildQueryFields(fields); + + const itemService = new ItemsService(cfg.collection_name, { schema }); + const items = await itemService.readByQuery({ fields: queryFields, limit: -1 }); + + // 为每种语言重建索引 + for (const lang of availableLanguages) { + const filteredItems = filterTranslations(items, lang); + const docs = filteredItems.map(item => { + const doc: MeiliDocs = { id: item.id }; + for (const [key, value] of Object.entries(cfg.fields)) { + const fieldValue = getNestedProperty(item, value); + logger.info(`Mapping field ${key} to value: ${fieldValue}`); + doc[key] = fieldValue; + } + return doc; + }); + + const index = client.index(`${cfg.index_name}_${lang}`); + // 删除所有索引 + await index.deleteAllDocuments(); + try { + const enqueueRes = await index.addDocuments(docs); + result.push({ config: cfg.collection_name, enqueued: enqueueRes }); + } catch (error) { + console.error(`Error reindexing collection ${cfg.collection_name}:`, error); + result.push({ config: cfg.collection_name, error: (error as Error).message }); + } + + // 处理结果 + if (cfg.enabled) { + console.log(`Reindexing triggered for collection: ${cfg.collection_name}`); + } else { + console.log(`Skipping disabled config for collection: ${cfg.collection_name}`); + } + } + }); + + // 监听 items.update 事件以触发 MeiliSearch 索引更新 + action('items.update', async (meta) => { + const cfg = await getConfig(meta?.collection); + if (!cfg) return; + const configId = cfg.id; + logger.info('Item updated, MeiliSearch indexing enabled for collection:', meta?.collection, 'Config ID:', configId); + + const { ItemsService } = services; + const schema = await getSchema(); + + // 获取可用语言选项 + let availableLanguages: string[] = []; + try { + const langService = new ItemsService('languages', { schema }); + const languages = await langService.readByQuery({ limit: -1 }); + availableLanguages = languages.map(lang => lang.code); + } catch (error) { + logger.error('Error fetching languages:', error); + } + + // 读取 MeiliSearch 全局配置 + const meiliService = new ItemsService('meili_search_config', { schema }); + const meiliConfigs = await meiliService.readByQuery({ limit: 1 }); + if (meiliConfigs.length === 0) { + return; + } + const { host } = meiliConfigs[0] || { host: '', apiKey: '' }; + + const client = new MeiliSearch({ + host, + }) + const result: any[] = []; + + const fields = cfg.fields; + const queryFields = buildQueryFields(fields); + + const itemService = new ItemsService(cfg.collection_name, { schema }); + const items = await itemService.readByQuery({ fields: queryFields, limit: -1 }); + + // 为每种语言重建索引 + for (const lang of availableLanguages) { + const filteredItems = filterTranslations(items, lang); + const docs = filteredItems.map(item => { + const doc: MeiliDocs = { id: item.id }; + for (const [key, value] of Object.entries(cfg.fields)) { + const fieldValue = getNestedProperty(item, value); + logger.info(`Mapping field ${key} to value: ${fieldValue}`); + doc[key] = fieldValue; + } + return doc; + }); + + const index = client.index(`${cfg.index_name}_${lang}`); + // 删除所有索引 + await index.deleteAllDocuments(); + try { + const enqueueRes = await index.addDocuments(docs); + result.push({ config: cfg.collection_name, enqueued: enqueueRes }); + } catch (error) { + console.error(`Error reindexing collection ${cfg.collection_name}:`, error); + result.push({ config: cfg.collection_name, error: (error as Error).message }); + } + + // 处理结果 + if (cfg.enabled) { + console.log(`Reindexing triggered for collection: ${cfg.collection_name}`); + } else { + console.log(`Skipping disabled config for collection: ${cfg.collection_name}`); + } + } + }); + + // 监听 items.delete 事件以触发 MeiliSearch 索引刷新 + action('items.delete', async (meta) => { + const cfg = await getConfig(meta?.collection); + if (!cfg) return; + const configId = cfg.id; + logger.info('Item deleted, MeiliSearch indexing enabled for collection:', meta?.collection, 'Config ID:', configId); + + const { ItemsService } = services; + const schema = await getSchema(); + + // 获取可用语言选项 + let availableLanguages: string[] = []; + try { + const langService = new ItemsService('languages', { schema }); + const languages = await langService.readByQuery({ limit: -1 }); + availableLanguages = languages.map(lang => lang.code); + } catch (error) { + logger.error('Error fetching languages:', error); + } + + // 读取 MeiliSearch 全局配置 + const meiliService = new ItemsService('meili_search_config', { schema }); + const meiliConfigs = await meiliService.readByQuery({ limit: 1 }); + if (meiliConfigs.length === 0) { + return; + } + const { host } = meiliConfigs[0] || { host: '', apiKey: '' }; + + const client = new MeiliSearch({ + host, + }) + const result: any[] = []; + + const fields = cfg.fields; + const queryFields = buildQueryFields(fields); + + const itemService = new ItemsService(cfg.collection_name, { schema }); + const items = await itemService.readByQuery({ fields: queryFields, limit: -1 }); + + // 为每种语言重建索引 + for (const lang of availableLanguages) { + const filteredItems = filterTranslations(items, lang); + const docs = filteredItems.map(item => { + const doc: MeiliDocs = { id: item.id }; + for (const [key, value] of Object.entries(cfg.fields)) { + const fieldValue = getNestedProperty(item, value); + logger.info(`Mapping field ${key} to value: ${fieldValue}`); + doc[key] = fieldValue; + } + return doc; + }); + + const index = client.index(`${cfg.index_name}_${lang}`); + // 删除所有索引 + await index.deleteAllDocuments(); + try { + const enqueueRes = await index.addDocuments(docs); + result.push({ config: cfg.collection_name, enqueued: enqueueRes }); + } catch (error) { + console.error(`Error reindexing collection ${cfg.collection_name}:`, error); + result.push({ config: cfg.collection_name, error: (error as Error).message }); + } + + // 处理结果 + if (cfg.enabled) { + console.log(`Reindexing triggered for collection: ${cfg.collection_name}`); + } else { + console.log(`Skipping disabled config for collection: ${cfg.collection_name}`); + } + } + }); + + // 定时任务:每天凌晨重建所有启用的 MeiliSearch 索引 + schedule('0 0 0 * * *', async () => { + const { ItemsService } = services; + const schema = await getSchema(); + + // 读取Meilisearch索引配置 + let configs = []; + const configService = new ItemsService('meili_index_configs', { schema }); + + // 读取所有启用的配置 + const resp = await configService.readByQuery({ filter: { enabled: { _eq: true } }, limit: -1 }); + configs = resp; + + // 获取可用语言选项 + let availableLanguages: string[] = []; + try { + const langService = new ItemsService('languages', { schema }); + const languages = await langService.readByQuery({ limit: -1 }); + availableLanguages = languages.map(lang => lang.code); + } catch (error) { + logger.error('Error fetching languages:', error); + } + + // 读取 MeiliSearch 全局配置 + const meiliService = new ItemsService('meili_search_config', { schema }); + const meiliConfigs = await meiliService.readByQuery({ limit: 1 }); + if (meiliConfigs.length === 0) { + return; + } + const { host } = meiliConfigs[0] || { host: '', apiKey: '' }; + + const client = new MeiliSearch({ + host, + }) + const result: any[] = []; + + for (const cfg of configs) { + const fields = cfg.fields; + const queryFields = buildQueryFields(fields); + + const itemService = new ItemsService(cfg.collection_name, { schema }); + const items = await itemService.readByQuery({ fields: queryFields, limit: -1 }); + + // 为每种语言重建索引 + for (const lang of availableLanguages) { + const filteredItems = filterTranslations(items, lang); + const docs = filteredItems.map(item => { + const doc: MeiliDocs = { id: item.id }; + for (const [key, value] of Object.entries(cfg.fields)) { + const fieldValue = getNestedProperty(item, value); + logger.info(`Mapping field ${key} to value: ${fieldValue}`); + doc[key] = fieldValue; + } + return doc; + }); + + const index = client.index(`${cfg.index_name}_${lang}`); + // 删除所有索引 + await index.deleteAllDocuments(); + try { + const enqueueRes = await index.addDocuments(docs); + result.push({ config: cfg.collection_name, enqueued: enqueueRes }); + } catch (error) { + console.error(`Error reindexing collection ${cfg.collection_name}:`, error); + result.push({ config: cfg.collection_name, error: (error as Error).message }); + } + + // 处理结果 + if (cfg.enabled) { + console.log(`Reindexing triggered for collection: ${cfg.collection_name}`); + } else { + console.log(`Skipping disabled config for collection: ${cfg.collection_name}`); + } + } + } }); });