feat(hook): 为meilisearch索引刷新添加hook

- 词条更新时,刷新当前集合的索引
- 每天凌晨刷新所有启用的索引
This commit is contained in:
2025-10-23 09:11:44 +00:00
parent f7b42af030
commit 2099fed318

View File

@ -1,9 +1,13 @@
import { defineHook } from '@directus/extensions-sdk'; import { defineHook } from '@directus/extensions-sdk';
import { createLogger } from '../logger'; 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'); 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 () => { init('app.after', async () => {
logger.info('Directus App Started - MeiliSearch Hook Initialized'); logger.info('Directus App Started - MeiliSearch Hook Initialized');
const { CollectionsService } = services; const { CollectionsService } = services;
@ -15,7 +19,7 @@ export default defineHook(async ({ init, filter, action }, { services, getSchema
// 检查meili_search_config集合是否存在 // 检查meili_search_config集合是否存在
const meili_search_config_exists = await collectionsSvc.readOne('meili_search_config').then(() => true).catch(() => false); const meili_search_config_exists = await collectionsSvc.readOne('meili_search_config').then(() => true).catch(() => false);
if (!meili_search_config_exists) { 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({ await collectionsSvc.createOne({
collection: 'meili_search_config', collection: 'meili_search_config',
meta: { meta: {
@ -49,7 +53,7 @@ export default defineHook(async ({ init, filter, action }, { services, getSchema
meta: { meta: {
note: '配置哪些集合需要被索引到 MeiliSearch', note: '配置哪些集合需要被索引到 MeiliSearch',
icon: 'search', icon: 'search',
hidden: true, // hidden: true,
system: true, system: true,
}, },
schema: { 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: 'index_name', type: 'string', meta: { note: 'MeiliSearch 索引名称', interface: 'input' } },
{ field: 'fields', type: 'json', meta: { note: '要索引的字段数组', interface: 'code-editor' } }, { field: 'fields', type: 'json', meta: { note: '要索引的字段数组', interface: 'code-editor' } },
{ field: 'enabled', type: 'boolean', meta: { note: '是否启用', interface: 'boolean' } }, { field: 'enabled', type: 'boolean', meta: { note: '是否启用', interface: 'boolean' } },
{ field: 'settings', type: 'json', meta: { note: 'MeiliSearch 索引设置', interface: 'code-editor' } },
], ],
}).then(() => { }).then(() => {
logger.info('meili_index_configs collection created successfully.'); logger.info('meili_index_configs collection created successfully.');
@ -70,13 +73,343 @@ export default defineHook(async ({ init, filter, action }, { services, getSchema
} else { } else {
logger.info('meili_index_configs collection already exists.'); 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', () => { filter('items.create', () => {
logger.info('Creating Item!'); logger.info('Creating Item!');
}); });
action('items.create', () => { async function getConfig(collection: string) {
logger.info('Item created!'); const schema = await getSchema();
const { ItemsService } = services;
const cfgSvc = new ItemsService<MeiliIndexConfig>('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<MeiliSearchConfig>('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<MeiliSearchConfig>('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<MeiliSearchConfig>('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<MeiliIndexConfig>('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<MeiliSearchConfig>('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}`);
}
}
}
}); });
}); });