Meilisearch 검색 엔진 도입
- Docker Compose에 Meilisearch 서비스 추가 - meilisearch.js 서비스 생성 (초기화, CRUD, 검색) - 공개 일정 API에 Meilisearch 검색 통합 - 일정 생성/삭제 시 Meilisearch 자동 동기화 - YouTube 봇 일정 추가 시 Meilisearch 동기화 - sync-search API 추가 (기존 데이터 일괄 동기화) - 다중 키워드, 오타 허용, 유사어 검색 지원
This commit is contained in:
parent
9ab79ef507
commit
346d6529f2
10 changed files with 305 additions and 1 deletions
1
.env
1
.env
|
|
@ -22,3 +22,4 @@ KAKAO_REST_KEY=cf21156ae6cc8f6e95b3a3150926cdf8
|
|||
|
||||
# YouTube API
|
||||
YOUTUBE_API_KEY=AIzaSyC6l3nFlcHgLc0d1Q9WPyYQjVKTv21ZqFs
|
||||
MEILI_MASTER_KEY=xMLNzlGX4xYji494JOb5IMlLHULcYw91
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -18,3 +18,4 @@ Thumbs.db
|
|||
# Build
|
||||
dist/
|
||||
build/
|
||||
meilisearch_data/
|
||||
|
|
|
|||
7
backend/package-lock.json
generated
7
backend/package-lock.json
generated
|
|
@ -12,6 +12,7 @@
|
|||
"bcrypt": "^6.0.0",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"meilisearch": "^0.55.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"mysql2": "^3.11.0",
|
||||
"node-cron": "^4.2.1",
|
||||
|
|
@ -2781,6 +2782,12 @@
|
|||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/meilisearch": {
|
||||
"version": "0.55.0",
|
||||
"resolved": "https://registry.npmjs.org/meilisearch/-/meilisearch-0.55.0.tgz",
|
||||
"integrity": "sha512-qSMeiezfDgIqciIeYzh5E4pXDZZD7CtHeWDCs43kN3trLgl5FtfmBAIkljL3huFaOx08feYtC8FfIFUpVwq6rg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/merge-descriptors": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
"bcrypt": "^6.0.0",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"meilisearch": "^0.55.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"mysql2": "^3.11.0",
|
||||
"node-cron": "^4.2.1",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ import {
|
|||
import pool from "../lib/db.js";
|
||||
import { syncNewVideos, syncAllVideos } from "../services/youtube-bot.js";
|
||||
import { startBot, stopBot } from "../services/youtube-scheduler.js";
|
||||
import {
|
||||
addOrUpdateSchedule,
|
||||
deleteSchedule as deleteScheduleFromSearch,
|
||||
} from "../services/meilisearch.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
|
|
@ -1321,6 +1325,33 @@ router.post(
|
|||
|
||||
await connection.commit();
|
||||
|
||||
// Meilisearch에 동기화
|
||||
try {
|
||||
const [categoryInfo] = await pool.query(
|
||||
"SELECT name, color FROM schedule_categories WHERE id = ?",
|
||||
[category || null]
|
||||
);
|
||||
const [memberInfo] = await pool.query(
|
||||
"SELECT id, name FROM members WHERE id IN (?)",
|
||||
[members?.length ? members : [0]]
|
||||
);
|
||||
await addOrUpdateSchedule({
|
||||
id: scheduleId,
|
||||
title,
|
||||
description,
|
||||
date,
|
||||
time,
|
||||
category_id: category,
|
||||
category_name: categoryInfo[0]?.name || "",
|
||||
category_color: categoryInfo[0]?.color || "",
|
||||
source_name: sourceName,
|
||||
source_url: url,
|
||||
members: memberInfo,
|
||||
});
|
||||
} catch (searchError) {
|
||||
console.error("Meilisearch 동기화 오류:", searchError.message);
|
||||
}
|
||||
|
||||
res.json({ message: "일정이 생성되었습니다.", scheduleId });
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
|
|
@ -1650,6 +1681,14 @@ router.delete("/schedules/:id", authenticateToken, async (req, res) => {
|
|||
await connection.query("DELETE FROM schedules WHERE id = ?", [id]);
|
||||
|
||||
await connection.commit();
|
||||
|
||||
// Meilisearch에서도 삭제
|
||||
try {
|
||||
await deleteScheduleFromSearch(id);
|
||||
} catch (searchError) {
|
||||
console.error("Meilisearch 삭제 오류:", searchError.message);
|
||||
}
|
||||
|
||||
res.json({ message: "일정이 삭제되었습니다." });
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
|
|
|
|||
|
|
@ -1,11 +1,21 @@
|
|||
import express from "express";
|
||||
import pool from "../lib/db.js";
|
||||
import { searchSchedules } from "../services/meilisearch.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 공개 일정 목록 조회
|
||||
// 공개 일정 목록 조회 (검색 포함)
|
||||
router.get("/", async (req, res) => {
|
||||
try {
|
||||
const { search } = req.query;
|
||||
|
||||
// 검색어가 있으면 Meilisearch 사용
|
||||
if (search && search.trim()) {
|
||||
const results = await searchSchedules(search.trim());
|
||||
return res.json(results);
|
||||
}
|
||||
|
||||
// 검색어 없으면 DB에서 전체 조회
|
||||
const [schedules] = await pool.query(`
|
||||
SELECT
|
||||
s.id,
|
||||
|
|
@ -80,4 +90,38 @@ router.get("/:id", async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// Meilisearch 동기화 API
|
||||
router.post("/sync-search", async (req, res) => {
|
||||
try {
|
||||
const { syncAllSchedules } = await import("../services/meilisearch.js");
|
||||
|
||||
// DB에서 모든 일정 조회
|
||||
const [schedules] = await pool.query(`
|
||||
SELECT
|
||||
s.id,
|
||||
s.title,
|
||||
s.description,
|
||||
s.date,
|
||||
s.time,
|
||||
s.category_id,
|
||||
s.source_url,
|
||||
s.source_name,
|
||||
c.name as category_name,
|
||||
c.color as category_color,
|
||||
GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ' ') as member_names
|
||||
FROM schedules s
|
||||
LEFT JOIN schedule_categories c ON s.category_id = c.id
|
||||
LEFT JOIN schedule_members sm ON s.id = sm.schedule_id
|
||||
LEFT JOIN members m ON sm.member_id = m.id
|
||||
GROUP BY s.id
|
||||
`);
|
||||
|
||||
const count = await syncAllSchedules(schedules);
|
||||
res.json({ success: true, synced: count });
|
||||
} catch (error) {
|
||||
console.error("Meilisearch 동기화 오류:", error);
|
||||
res.status(500).json({ error: "동기화 중 오류가 발생했습니다." });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import statsRouter from "./routes/stats.js";
|
|||
import adminRouter from "./routes/admin.js";
|
||||
import schedulesRouter from "./routes/schedules.js";
|
||||
import { initScheduler } from "./services/youtube-scheduler.js";
|
||||
import { initMeilisearch } from "./services/meilisearch.js";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
|
@ -44,6 +45,14 @@ app.get("*", (req, res) => {
|
|||
app.listen(PORT, async () => {
|
||||
console.log(`🌸 fromis_9 서버가 포트 ${PORT}에서 실행 중입니다`);
|
||||
|
||||
// Meilisearch 초기화
|
||||
try {
|
||||
await initMeilisearch();
|
||||
console.log("🔍 Meilisearch 초기화 완료");
|
||||
} catch (error) {
|
||||
console.error("Meilisearch 초기화 오류:", error);
|
||||
}
|
||||
|
||||
// YouTube 봇 스케줄러 초기화
|
||||
try {
|
||||
await initScheduler();
|
||||
|
|
|
|||
163
backend/services/meilisearch.js
Normal file
163
backend/services/meilisearch.js
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { MeiliSearch } from "meilisearch";
|
||||
|
||||
// Meilisearch 클라이언트 초기화
|
||||
const client = new MeiliSearch({
|
||||
host: "http://fromis9-meilisearch:7700",
|
||||
apiKey: process.env.MEILI_MASTER_KEY,
|
||||
});
|
||||
|
||||
const SCHEDULE_INDEX = "schedules";
|
||||
|
||||
/**
|
||||
* 인덱스 초기화 및 설정
|
||||
*/
|
||||
export async function initMeilisearch() {
|
||||
try {
|
||||
// 인덱스 생성 (이미 존재하면 무시)
|
||||
await client.createIndex(SCHEDULE_INDEX, { primaryKey: "id" });
|
||||
|
||||
// 인덱스 설정
|
||||
const index = client.index(SCHEDULE_INDEX);
|
||||
|
||||
// 검색 가능한 필드 설정
|
||||
await index.updateSearchableAttributes([
|
||||
"title",
|
||||
"description",
|
||||
"member_names",
|
||||
"category_name",
|
||||
"source_name",
|
||||
]);
|
||||
|
||||
// 필터링 가능한 필드 설정
|
||||
await index.updateFilterableAttributes(["category_id", "date"]);
|
||||
|
||||
// 정렬 가능한 필드 설정
|
||||
await index.updateSortableAttributes(["date", "time"]);
|
||||
|
||||
// 오타 허용 설정 (typo tolerance)
|
||||
await index.updateTypoTolerance({
|
||||
enabled: true,
|
||||
minWordSizeForTypos: {
|
||||
oneTypo: 2,
|
||||
twoTypos: 4,
|
||||
},
|
||||
});
|
||||
|
||||
console.log("[Meilisearch] 인덱스 초기화 완료");
|
||||
} catch (error) {
|
||||
console.error("[Meilisearch] 초기화 오류:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 일정 문서 추가/업데이트
|
||||
*/
|
||||
export async function addOrUpdateSchedule(schedule) {
|
||||
try {
|
||||
const index = client.index(SCHEDULE_INDEX);
|
||||
|
||||
// 멤버 이름을 문자열로 변환
|
||||
const memberNames = schedule.members
|
||||
? schedule.members.map((m) => m.name).join(" ")
|
||||
: "";
|
||||
|
||||
const document = {
|
||||
id: schedule.id,
|
||||
title: schedule.title,
|
||||
description: schedule.description || "",
|
||||
date: schedule.date,
|
||||
time: schedule.time || "",
|
||||
category_id: schedule.category_id,
|
||||
category_name: schedule.category_name || "",
|
||||
category_color: schedule.category_color || "",
|
||||
source_name: schedule.source_name || "",
|
||||
source_url: schedule.source_url || "",
|
||||
member_names: memberNames,
|
||||
};
|
||||
|
||||
await index.addDocuments([document]);
|
||||
console.log(`[Meilisearch] 일정 추가/업데이트: ${schedule.id}`);
|
||||
} catch (error) {
|
||||
console.error("[Meilisearch] 문서 추가 오류:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 일정 문서 삭제
|
||||
*/
|
||||
export async function deleteSchedule(scheduleId) {
|
||||
try {
|
||||
const index = client.index(SCHEDULE_INDEX);
|
||||
await index.deleteDocument(scheduleId);
|
||||
console.log(`[Meilisearch] 일정 삭제: ${scheduleId}`);
|
||||
} catch (error) {
|
||||
console.error("[Meilisearch] 문서 삭제 오류:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 일정 검색
|
||||
*/
|
||||
export async function searchSchedules(query, options = {}) {
|
||||
try {
|
||||
const index = client.index(SCHEDULE_INDEX);
|
||||
|
||||
const searchOptions = {
|
||||
limit: options.limit || 50,
|
||||
attributesToRetrieve: ["*"],
|
||||
};
|
||||
|
||||
// 카테고리 필터
|
||||
if (options.categoryId) {
|
||||
searchOptions.filter = `category_id = ${options.categoryId}`;
|
||||
}
|
||||
|
||||
// 정렬 (기본: 날짜 내림차순)
|
||||
searchOptions.sort = options.sort || ["date:desc", "time:desc"];
|
||||
|
||||
const results = await index.search(query, searchOptions);
|
||||
|
||||
return results.hits;
|
||||
} catch (error) {
|
||||
console.error("[Meilisearch] 검색 오류:", error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 일정 동기화 (초기 데이터 로드용)
|
||||
*/
|
||||
export async function syncAllSchedules(schedules) {
|
||||
try {
|
||||
const index = client.index(SCHEDULE_INDEX);
|
||||
|
||||
// 기존 문서 모두 삭제
|
||||
await index.deleteAllDocuments();
|
||||
|
||||
// 문서 변환
|
||||
const documents = schedules.map((schedule) => ({
|
||||
id: schedule.id,
|
||||
title: schedule.title,
|
||||
description: schedule.description || "",
|
||||
date: schedule.date,
|
||||
time: schedule.time || "",
|
||||
category_id: schedule.category_id,
|
||||
category_name: schedule.category_name || "",
|
||||
category_color: schedule.category_color || "",
|
||||
source_name: schedule.source_name || "",
|
||||
source_url: schedule.source_url || "",
|
||||
member_names: schedule.member_names || "",
|
||||
}));
|
||||
|
||||
// 일괄 추가
|
||||
await index.addDocuments(documents);
|
||||
console.log(`[Meilisearch] ${documents.length}개 일정 동기화 완료`);
|
||||
|
||||
return documents.length;
|
||||
} catch (error) {
|
||||
console.error("[Meilisearch] 동기화 오류:", error.message);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export { client };
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import Parser from "rss-parser";
|
||||
import pool from "../lib/db.js";
|
||||
import { addOrUpdateSchedule } from "./meilisearch.js";
|
||||
|
||||
// YouTube API 키
|
||||
const YOUTUBE_API_KEY =
|
||||
|
|
@ -293,6 +294,33 @@ export async function createScheduleFromVideo(
|
|||
);
|
||||
}
|
||||
|
||||
// Meilisearch에 동기화
|
||||
try {
|
||||
const [categoryInfo] = await pool.query(
|
||||
"SELECT name, color FROM schedule_categories WHERE id = ?",
|
||||
[categoryId]
|
||||
);
|
||||
const [memberInfo] = await pool.query(
|
||||
"SELECT id, name FROM members WHERE id IN (?)",
|
||||
[memberIds.length > 0 ? [...new Set(memberIds)] : [0]]
|
||||
);
|
||||
await addOrUpdateSchedule({
|
||||
id: scheduleId,
|
||||
title: video.title,
|
||||
description: "",
|
||||
date: video.date,
|
||||
time: video.time,
|
||||
category_id: categoryId,
|
||||
category_name: categoryInfo[0]?.name || "",
|
||||
category_color: categoryInfo[0]?.color || "",
|
||||
source_name: sourceName,
|
||||
source_url: video.videoUrl,
|
||||
members: memberInfo,
|
||||
});
|
||||
} catch (searchError) {
|
||||
console.error("Meilisearch 동기화 오류:", searchError.message);
|
||||
}
|
||||
|
||||
return scheduleId;
|
||||
} catch (error) {
|
||||
console.error("일정 생성 오류:", error);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,17 @@ services:
|
|||
- db
|
||||
restart: unless-stopped
|
||||
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.6
|
||||
container_name: fromis9-meilisearch
|
||||
environment:
|
||||
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY}
|
||||
volumes:
|
||||
- ./meilisearch_data:/meili_data
|
||||
networks:
|
||||
- app
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
app:
|
||||
external: true
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue