From 068c5ffbbb170fdc8742831e08eabfa8d266b743 Mon Sep 17 00:00:00 2001 From: caadiq Date: Tue, 6 Jan 2026 08:46:10 +0900 Subject: [PATCH] =?UTF-8?q?Meilisearch=20=EA=B2=80=EC=83=89=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 검색 결과 유사도순 정렬 (동일 유사도 시 최신 날짜 우선) - 프론트엔드 검색 재정렬 제거 (Meilisearch 순서 유지) - 관리자 일정 페이지 Meilisearch 검색 적용 - 일정 수정 시 Meilisearch 동기화 추가 - 서버 시작 시 자동 동기화 - 멤버 이름 쉼표 구분으로 통일 --- backend/routes/admin.js | 28 ++++++++++++ backend/routes/schedules.js | 2 +- backend/server.js | 22 +++++++++- backend/services/meilisearch.js | 26 ++++++++--- docker-compose.dev.yml | 12 ++++++ frontend/src/pages/pc/Schedule.jsx | 16 +++---- frontend/src/pages/pc/admin/AdminSchedule.jsx | 43 ++++++++++++------- 7 files changed, 112 insertions(+), 37 deletions(-) diff --git a/backend/routes/admin.js b/backend/routes/admin.js index 8249392..c4cb8ff 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -1632,6 +1632,34 @@ router.put( } 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: parseInt(id), + 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: "일정이 수정되었습니다." }); } catch (error) { await connection.rollback(); diff --git a/backend/routes/schedules.js b/backend/routes/schedules.js index df9a473..cb737f2 100644 --- a/backend/routes/schedules.js +++ b/backend/routes/schedules.js @@ -108,7 +108,7 @@ router.post("/sync-search", async (req, res) => { 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 + 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 diff --git a/backend/server.js b/backend/server.js index 6742ce1..b994ba7 100644 --- a/backend/server.js +++ b/backend/server.js @@ -45,12 +45,30 @@ app.get("*", (req, res) => { app.listen(PORT, async () => { console.log(`🌸 fromis_9 서버가 포트 ${PORT}에서 실행 중입니다`); - // Meilisearch 초기화 + // Meilisearch 초기화 및 동기화 try { await initMeilisearch(); console.log("🔍 Meilisearch 초기화 완료"); + + // 서버 시작 시 일정 데이터 자동 동기화 + const { syncAllSchedules } = await import("./services/meilisearch.js"); + const [schedules] = await ( + await import("./lib/db.js") + ).default.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 syncedCount = await syncAllSchedules(schedules); + console.log(`🔍 Meilisearch ${syncedCount}개 일정 동기화 완료`); } catch (error) { - console.error("Meilisearch 초기화 오류:", error); + console.error("Meilisearch 초기화/동기화 오류:", error); } // YouTube 봇 스케줄러 초기화 diff --git a/backend/services/meilisearch.js b/backend/services/meilisearch.js index 3b6e275..c41997c 100644 --- a/backend/services/meilisearch.js +++ b/backend/services/meilisearch.js @@ -19,13 +19,13 @@ export async function initMeilisearch() { // 인덱스 설정 const index = client.index(SCHEDULE_INDEX); - // 검색 가능한 필드 설정 + // 검색 가능한 필드 설정 (순서가 우선순위 결정) await index.updateSearchableAttributes([ "title", - "description", "member_names", - "category_name", + "description", "source_name", + "category_name", ]); // 필터링 가능한 필드 설정 @@ -34,6 +34,16 @@ export async function initMeilisearch() { // 정렬 가능한 필드 설정 await index.updateSortableAttributes(["date", "time"]); + // 랭킹 규칙 설정 (동일 유사도일 때 최신 날짜 우선) + await index.updateRankingRules([ + "words", // 검색어 포함 개수 + "typo", // 오타 수 + "proximity", // 검색어 간 거리 + "attribute", // 필드 우선순위 + "exactness", // 정확도 + "date:desc", // 동일 유사도 시 최신 날짜 우선 + ]); + // 오타 허용 설정 (typo tolerance) await index.updateTypoTolerance({ enabled: true, @@ -56,9 +66,9 @@ export async function addOrUpdateSchedule(schedule) { try { const index = client.index(SCHEDULE_INDEX); - // 멤버 이름을 문자열로 변환 + // 멤버 이름을 쉼표로 구분하여 저장 const memberNames = schedule.members - ? schedule.members.map((m) => m.name).join(" ") + ? schedule.members.map((m) => m.name).join(",") : ""; const document = { @@ -112,8 +122,10 @@ export async function searchSchedules(query, options = {}) { searchOptions.filter = `category_id = ${options.categoryId}`; } - // 정렬 (기본: 날짜 내림차순) - searchOptions.sort = options.sort || ["date:desc", "time:desc"]; + // 정렬 지정 시에만 적용 (기본은 유사도순) + if (options.sort) { + searchOptions.sort = options.sort; + } const results = await index.search(query, searchOptions); diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index d1d42a0..1e18a82 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -31,6 +31,18 @@ services: - db restart: unless-stopped + # Meilisearch - 검색 엔진 + 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 diff --git a/frontend/src/pages/pc/Schedule.jsx b/frontend/src/pages/pc/Schedule.jsx index 1dbc4f1..e61b85a 100644 --- a/frontend/src/pages/pc/Schedule.jsx +++ b/frontend/src/pages/pc/Schedule.jsx @@ -71,7 +71,7 @@ function Schedule() { } }; - // 검색 함수 (API 호출) + // 검색 함수 (Meilisearch API 호출) const searchSchedules = async (term) => { if (!term.trim()) { setSearchResults([]); @@ -79,7 +79,7 @@ function Schedule() { } setSearchLoading(true); try { - const response = await fetch(`/api/admin/schedules?search=${encodeURIComponent(term)}`); + const response = await fetch(`/api/schedules?search=${encodeURIComponent(term)}`); if (response.ok) { const data = await response.json(); setSearchResults(data); @@ -178,17 +178,11 @@ function Schedule() { const filteredSchedules = useMemo(() => { // 검색 모드일 때 if (isSearchMode) { - // 검색 전엔 빈 목록, 검색 후엔 API 결과 + // 검색 전엔 빈 목록, 검색 후엔 API 결과 (Meilisearch 유사도순 유지) if (!searchTerm) return []; - return searchResults.sort((a, b) => { - const dateA = a.date ? a.date.split('T')[0] : ''; - const dateB = b.date ? b.date.split('T')[0] : ''; - if (dateA !== dateB) return dateA.localeCompare(dateB); - const timeA = a.time || '00:00:00'; - const timeB = b.time || '00:00:00'; - return timeA.localeCompare(timeB); - }); + return searchResults; } + // 일반 모드: 기존 필터링 return schedules diff --git a/frontend/src/pages/pc/admin/AdminSchedule.jsx b/frontend/src/pages/pc/admin/AdminSchedule.jsx index 5759bac..d58471e 100644 --- a/frontend/src/pages/pc/admin/AdminSchedule.jsx +++ b/frontend/src/pages/pc/admin/AdminSchedule.jsx @@ -185,7 +185,7 @@ function AdminSchedule() { } }; - // 검색 함수 (API 호출) + // 검색 함수 (Meilisearch API 호출) const searchSchedules = async (term) => { if (!term.trim()) { setSearchResults([]); @@ -193,7 +193,7 @@ function AdminSchedule() { } setSearchLoading(true); try { - const res = await fetch(`/api/admin/schedules?search=${encodeURIComponent(term)}`); + const res = await fetch(`/api/schedules?search=${encodeURIComponent(term)}`); const data = await res.json(); setSearchResults(data); } catch (error) { @@ -203,6 +203,7 @@ function AdminSchedule() { } }; + // 외부 클릭 시 피커 닫기 useEffect(() => { const handleClickOutside = (event) => { @@ -974,21 +975,31 @@ function AdminSchedule() {

{schedule.description}

)} {/* 멤버 태그 */} - {schedule.members && schedule.members.length > 0 && ( -
- {schedule.members.length >= 5 ? ( - - 프로미스나인 - - ) : ( - schedule.members.map((member) => ( - - {member.name} + {(() => { + // members 배열 또는 member_names 문자열 처리 + const memberList = schedule.members?.length > 0 + ? schedule.members + : schedule.member_names + ? schedule.member_names.split(',').filter(n => n.trim()).map((name, idx) => ({ id: idx, name: name.trim() })) + : []; + if (memberList.length === 0) return null; + return ( +
+ {memberList.length >= 5 ? ( + + 프로미스나인 - )) - )} -
- )} + ) : ( + memberList.map((member) => ( + + {member.name} + + )) + )} +
+ ); + })()} + {/* 액션 버튼 */}