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
|
||||||
YOUTUBE_API_KEY=AIzaSyC6l3nFlcHgLc0d1Q9WPyYQjVKTv21ZqFs
|
YOUTUBE_API_KEY=AIzaSyC6l3nFlcHgLc0d1Q9WPyYQjVKTv21ZqFs
|
||||||
|
MEILI_MASTER_KEY=xMLNzlGX4xYji494JOb5IMlLHULcYw91
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -18,3 +18,4 @@ Thumbs.db
|
||||||
# Build
|
# Build
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
|
meilisearch_data/
|
||||||
|
|
|
||||||
7
backend/package-lock.json
generated
7
backend/package-lock.json
generated
|
|
@ -12,6 +12,7 @@
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"meilisearch": "^0.55.0",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"mysql2": "^3.11.0",
|
"mysql2": "^3.11.0",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
|
|
@ -2781,6 +2782,12 @@
|
||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/merge-descriptors": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"meilisearch": "^0.55.0",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"mysql2": "^3.11.0",
|
"mysql2": "^3.11.0",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@ import {
|
||||||
import pool from "../lib/db.js";
|
import pool from "../lib/db.js";
|
||||||
import { syncNewVideos, syncAllVideos } from "../services/youtube-bot.js";
|
import { syncNewVideos, syncAllVideos } from "../services/youtube-bot.js";
|
||||||
import { startBot, stopBot } from "../services/youtube-scheduler.js";
|
import { startBot, stopBot } from "../services/youtube-scheduler.js";
|
||||||
|
import {
|
||||||
|
addOrUpdateSchedule,
|
||||||
|
deleteSchedule as deleteScheduleFromSearch,
|
||||||
|
} from "../services/meilisearch.js";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
|
@ -1321,6 +1325,33 @@ router.post(
|
||||||
|
|
||||||
await connection.commit();
|
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 });
|
res.json({ message: "일정이 생성되었습니다.", scheduleId });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await connection.rollback();
|
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.query("DELETE FROM schedules WHERE id = ?", [id]);
|
||||||
|
|
||||||
await connection.commit();
|
await connection.commit();
|
||||||
|
|
||||||
|
// Meilisearch에서도 삭제
|
||||||
|
try {
|
||||||
|
await deleteScheduleFromSearch(id);
|
||||||
|
} catch (searchError) {
|
||||||
|
console.error("Meilisearch 삭제 오류:", searchError.message);
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ message: "일정이 삭제되었습니다." });
|
res.json({ message: "일정이 삭제되었습니다." });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await connection.rollback();
|
await connection.rollback();
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,21 @@
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import pool from "../lib/db.js";
|
import pool from "../lib/db.js";
|
||||||
|
import { searchSchedules } from "../services/meilisearch.js";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// 공개 일정 목록 조회
|
// 공개 일정 목록 조회 (검색 포함)
|
||||||
router.get("/", async (req, res) => {
|
router.get("/", async (req, res) => {
|
||||||
try {
|
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(`
|
const [schedules] = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
s.id,
|
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;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import statsRouter from "./routes/stats.js";
|
||||||
import adminRouter from "./routes/admin.js";
|
import adminRouter from "./routes/admin.js";
|
||||||
import schedulesRouter from "./routes/schedules.js";
|
import schedulesRouter from "./routes/schedules.js";
|
||||||
import { initScheduler } from "./services/youtube-scheduler.js";
|
import { initScheduler } from "./services/youtube-scheduler.js";
|
||||||
|
import { initMeilisearch } from "./services/meilisearch.js";
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
@ -44,6 +45,14 @@ app.get("*", (req, res) => {
|
||||||
app.listen(PORT, async () => {
|
app.listen(PORT, async () => {
|
||||||
console.log(`🌸 fromis_9 서버가 포트 ${PORT}에서 실행 중입니다`);
|
console.log(`🌸 fromis_9 서버가 포트 ${PORT}에서 실행 중입니다`);
|
||||||
|
|
||||||
|
// Meilisearch 초기화
|
||||||
|
try {
|
||||||
|
await initMeilisearch();
|
||||||
|
console.log("🔍 Meilisearch 초기화 완료");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Meilisearch 초기화 오류:", error);
|
||||||
|
}
|
||||||
|
|
||||||
// YouTube 봇 스케줄러 초기화
|
// YouTube 봇 스케줄러 초기화
|
||||||
try {
|
try {
|
||||||
await initScheduler();
|
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 Parser from "rss-parser";
|
||||||
import pool from "../lib/db.js";
|
import pool from "../lib/db.js";
|
||||||
|
import { addOrUpdateSchedule } from "./meilisearch.js";
|
||||||
|
|
||||||
// YouTube API 키
|
// YouTube API 키
|
||||||
const YOUTUBE_API_KEY =
|
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;
|
return scheduleId;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("일정 생성 오류:", error);
|
console.error("일정 생성 오류:", error);
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,17 @@ services:
|
||||||
- db
|
- db
|
||||||
restart: unless-stopped
|
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:
|
networks:
|
||||||
app:
|
app:
|
||||||
external: true
|
external: true
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue