支持 meilisearch 管理
This commit is contained in:
@@ -9,6 +9,7 @@ import shareCodeLogsRouter from './shareCodeLogs.js';
|
||||
import usersRouter from './users.js';
|
||||
import dashboardRouter from './dashboard.js';
|
||||
import siteRouter from './site.js';
|
||||
import meilisearchRouter from './meilisearch.js';
|
||||
import {
|
||||
wwwGlobalRouter,
|
||||
wwwNavRouter,
|
||||
@@ -36,6 +37,7 @@ const routes: Router[] = [
|
||||
shareCodeLogsRouter,
|
||||
usersRouter,
|
||||
dashboardRouter,
|
||||
meilisearchRouter,
|
||||
// 官网 CMS
|
||||
wwwGlobalRouter,
|
||||
wwwNavRouter,
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { Router, type Request, type Response, type Router as RouterType } from 'express';
|
||||
import axios, { type AxiosRequestConfig } from 'axios';
|
||||
import logger from '../config/logger.js';
|
||||
import { ApiResponse } from '../utils/response.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
|
||||
/**
|
||||
* Meilisearch 管理接口
|
||||
*
|
||||
* 连接信息全部来自环境变量(MEILISEARCH_URL / MEILISEARCH_API_KEY / MEILISEARCH_INDEX),
|
||||
* 对该默认索引提供简单的文档增删改查。
|
||||
*/
|
||||
const router: RouterType = Router();
|
||||
|
||||
const MEILISEARCH_URL = (process.env.MEILISEARCH_URL || 'http://localhost:7700').replace(/\/+$/, '');
|
||||
const MEILISEARCH_API_KEY = process.env.MEILISEARCH_API_KEY || '';
|
||||
const MEILISEARCH_INDEX = process.env.MEILISEARCH_INDEX || 'models';
|
||||
|
||||
// 所有接口需登录
|
||||
router.use('/api/meilisearch', authMiddleware);
|
||||
|
||||
function msHeaders() {
|
||||
return { Authorization: `Bearer ${MEILISEARCH_API_KEY}`, 'Content-Type': 'application/json' };
|
||||
}
|
||||
|
||||
/** 统一请求 Meilisearch REST API,失败时抛出带详情的 Error */
|
||||
async function msRequest(method: AxiosRequestConfig['method'], path: string, data?: unknown) {
|
||||
try {
|
||||
const response = await axios({
|
||||
method,
|
||||
url: `${MEILISEARCH_URL}${path}`,
|
||||
headers: msHeaders(),
|
||||
data,
|
||||
timeout: 30000,
|
||||
validateStatus: status => status >= 200 && status < 300,
|
||||
});
|
||||
return response.data;
|
||||
} catch (e: any) {
|
||||
const detail = e.response?.data?.message || e.response?.data ? JSON.stringify(e.response?.data) : e.message;
|
||||
throw new Error(detail);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMsError(res: Response, e: unknown, action: string) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
logger.error(`Meilisearch ${action} failed: ${msg}`);
|
||||
// 连接失败/上游错误统一返回 502 语义,便于前端区分后端自身错误
|
||||
res.status(502).json(ApiResponse.error(`Meilisearch ${action} 失败:${msg}`));
|
||||
}
|
||||
|
||||
/** 查询索引元信息(主键),索引不存在返回 null */
|
||||
async function getIndexInfo() {
|
||||
try {
|
||||
return await msRequest('GET', `/indexes/${MEILISEARCH_INDEX}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 查询索引统计(文档数等,v1.x 的 GET /indexes 不返回 numberOfDocuments) */
|
||||
async function getIndexStats() {
|
||||
try {
|
||||
return await msRequest('GET', `/indexes/${MEILISEARCH_INDEX}/stats`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 查询索引的可搜索字段(settings.searchableAttributes,"*" 表示全部字段) */
|
||||
async function getSearchableAttributes(): Promise<string[] | null> {
|
||||
try {
|
||||
const settings = await msRequest('GET', `/indexes/${MEILISEARCH_INDEX}/settings`);
|
||||
return Array.isArray(settings?.searchableAttributes) ? settings.searchableAttributes : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/meilisearch/info —— 连接信息与索引概况
|
||||
router.get('/api/meilisearch/info', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const version = await msRequest('GET', '/version').catch(() => null);
|
||||
const index = await getIndexInfo();
|
||||
const stats = await getIndexStats();
|
||||
// 实例级统计(GET /stats 返回全部索引的磁盘占用,单位字节;
|
||||
// 新版字段为 databaseSize,旧版(如 v1.52)为 database_size,两者兼容)
|
||||
const globalStats = await msRequest('GET', '/stats').catch(() => null);
|
||||
res.json(
|
||||
ApiResponse.success({
|
||||
url: MEILISEARCH_URL,
|
||||
index: MEILISEARCH_INDEX,
|
||||
primary_key: index?.primaryKey ?? null,
|
||||
number_of_documents: stats?.numberOfDocuments ?? 0,
|
||||
index_exists: !!index,
|
||||
version: version?.['pkgVersion'] ?? null,
|
||||
database_size: globalStats?.databaseSize ?? globalStats?.database_size ?? null,
|
||||
index_database_size: stats?.databaseSize ?? stats?.database_size ?? null,
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
handleMsError(res, e, '获取连接信息');
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/meilisearch/documents —— 文档列表(分页;q 非空时按 brand_name/name 关键词筛选)
|
||||
router.get('/api/meilisearch/documents', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const skip = Math.max(parseInt((req.query.skip as string) || '0', 10) || 0, 0);
|
||||
const limit = Math.min(Math.max(parseInt((req.query.limit as string) || '20', 10) || 20, 1), 1000);
|
||||
const q = String(req.query.q || '').trim();
|
||||
const index = await getIndexInfo();
|
||||
|
||||
// 带关键词:走 search 端点,仅在 brand_name/name 两个字段上检索(id 不参与筛选),
|
||||
// 但 attributesToSearchOn 必须是索引 searchableAttributes 的子集,
|
||||
// 否则 Meilisearch 报 invalid_search_attributes_to_search_on,
|
||||
// 故先取交集;交集为空时省略该参数(退化为在全部可搜索字段上检索)
|
||||
if (q) {
|
||||
const wanted = ['brand_name', 'name'];
|
||||
const searchable = await getSearchableAttributes();
|
||||
const attributesToSearchOn =
|
||||
searchable === null || searchable.includes('*') ? wanted : wanted.filter(a => searchable.includes(a));
|
||||
|
||||
const search = await msRequest('POST', `/indexes/${MEILISEARCH_INDEX}/search`, {
|
||||
q,
|
||||
offset: skip,
|
||||
limit,
|
||||
...(attributesToSearchOn.length > 0 ? { attributesToSearchOn } : {}),
|
||||
});
|
||||
res.json(
|
||||
ApiResponse.success({
|
||||
items: search.hits ?? [],
|
||||
total: search.estimatedTotalHits ?? 0,
|
||||
skip,
|
||||
limit,
|
||||
primary_key: index?.primaryKey ?? null,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await msRequest(
|
||||
'GET',
|
||||
`/indexes/${MEILISEARCH_INDEX}/documents?offset=${skip}&limit=${limit}`
|
||||
);
|
||||
res.json(
|
||||
ApiResponse.success({
|
||||
items: data.results ?? [],
|
||||
total: data.total ?? 0,
|
||||
skip,
|
||||
limit,
|
||||
primary_key: index?.primaryKey ?? null,
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
handleMsError(res, e, '获取文档列表');
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/meilisearch/documents —— 新增文档
|
||||
router.post('/api/meilisearch/documents', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const doc = req.body;
|
||||
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
|
||||
res.status(400).json(ApiResponse.error('文档必须是 JSON 对象'));
|
||||
return;
|
||||
}
|
||||
const index = await getIndexInfo();
|
||||
if (index?.primaryKey && doc[index.primaryKey] === undefined) {
|
||||
res.status(400).json(ApiResponse.error(`文档缺少主键字段「${index.primaryKey}」`));
|
||||
return;
|
||||
}
|
||||
const task = await msRequest('POST', `/indexes/${MEILISEARCH_INDEX}/documents`, [doc]);
|
||||
logger.info(`Meilisearch document added, taskUid=${task?.taskUid}`);
|
||||
res.json(ApiResponse.success(task, '已提交新增任务'));
|
||||
} catch (e) {
|
||||
handleMsError(res, e, '新增文档');
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/meilisearch/documents/:id —— 更新文档(按主键覆盖)
|
||||
router.put('/api/meilisearch/documents/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const doc = req.body;
|
||||
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
|
||||
res.status(400).json(ApiResponse.error('文档必须是 JSON 对象'));
|
||||
return;
|
||||
}
|
||||
const index = await getIndexInfo();
|
||||
const pk = index?.primaryKey ?? 'id';
|
||||
if (doc[pk] === undefined) doc[pk] = req.params.id;
|
||||
if (String(doc[pk]) !== String(req.params.id)) {
|
||||
res.status(400).json(ApiResponse.error(`文档主键「${pk}」与 URL 中的 id 不一致`));
|
||||
return;
|
||||
}
|
||||
const task = await msRequest('PUT', `/indexes/${MEILISEARCH_INDEX}/documents`, [doc]);
|
||||
logger.info(`Meilisearch document updated: id=${req.params.id}, taskUid=${task?.taskUid}`);
|
||||
res.json(ApiResponse.success(task, '已提交更新任务'));
|
||||
} catch (e) {
|
||||
handleMsError(res, e, '更新文档');
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/meilisearch/documents/:id —— 删除文档
|
||||
router.delete('/api/meilisearch/documents/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const task = await msRequest('DELETE', `/indexes/${MEILISEARCH_INDEX}/documents/${req.params.id}`);
|
||||
logger.info(`Meilisearch document deleted: id=${req.params.id}, taskUid=${task?.taskUid}`);
|
||||
res.json(ApiResponse.success(task, '已提交删除任务'));
|
||||
} catch (e) {
|
||||
handleMsError(res, e, '删除文档');
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user