Meilisearch 검색 기능 개선

- 검색 결과 유사도순 정렬 (동일 유사도 시 최신 날짜 우선)
- 프론트엔드 검색 재정렬 제거 (Meilisearch 순서 유지)
- 관리자 일정 페이지 Meilisearch 검색 적용
- 일정 수정 시 Meilisearch 동기화 추가
- 서버 시작 시 자동 동기화
- 멤버 이름 쉼표 구분으로 통일
This commit is contained in:
caadiq 2026-01-06 08:46:10 +09:00
parent 346d6529f2
commit 068c5ffbbb
7 changed files with 112 additions and 37 deletions

View file

@ -1632,6 +1632,34 @@ router.put(
} }
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: 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: "일정이 수정되었습니다." }); res.json({ message: "일정이 수정되었습니다." });
} catch (error) { } catch (error) {
await connection.rollback(); await connection.rollback();

View file

@ -108,7 +108,7 @@ router.post("/sync-search", async (req, res) => {
s.source_name, s.source_name,
c.name as category_name, c.name as category_name,
c.color as category_color, 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 FROM schedules s
LEFT JOIN schedule_categories c ON s.category_id = c.id LEFT JOIN schedule_categories c ON s.category_id = c.id
LEFT JOIN schedule_members sm ON s.id = sm.schedule_id LEFT JOIN schedule_members sm ON s.id = sm.schedule_id

View file

@ -45,12 +45,30 @@ app.get("*", (req, res) => {
app.listen(PORT, async () => { app.listen(PORT, async () => {
console.log(`🌸 fromis_9 서버가 포트 ${PORT}에서 실행 중입니다`); console.log(`🌸 fromis_9 서버가 포트 ${PORT}에서 실행 중입니다`);
// Meilisearch 초기화 // Meilisearch 초기화 및 동기화
try { try {
await initMeilisearch(); await initMeilisearch();
console.log("🔍 Meilisearch 초기화 완료"); 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) { } catch (error) {
console.error("Meilisearch 초기화 오류:", error); console.error("Meilisearch 초기화/동기화 오류:", error);
} }
// YouTube 봇 스케줄러 초기화 // YouTube 봇 스케줄러 초기화

View file

@ -19,13 +19,13 @@ export async function initMeilisearch() {
// 인덱스 설정 // 인덱스 설정
const index = client.index(SCHEDULE_INDEX); const index = client.index(SCHEDULE_INDEX);
// 검색 가능한 필드 설정 // 검색 가능한 필드 설정 (순서가 우선순위 결정)
await index.updateSearchableAttributes([ await index.updateSearchableAttributes([
"title", "title",
"description",
"member_names", "member_names",
"category_name", "description",
"source_name", "source_name",
"category_name",
]); ]);
// 필터링 가능한 필드 설정 // 필터링 가능한 필드 설정
@ -34,6 +34,16 @@ export async function initMeilisearch() {
// 정렬 가능한 필드 설정 // 정렬 가능한 필드 설정
await index.updateSortableAttributes(["date", "time"]); await index.updateSortableAttributes(["date", "time"]);
// 랭킹 규칙 설정 (동일 유사도일 때 최신 날짜 우선)
await index.updateRankingRules([
"words", // 검색어 포함 개수
"typo", // 오타 수
"proximity", // 검색어 간 거리
"attribute", // 필드 우선순위
"exactness", // 정확도
"date:desc", // 동일 유사도 시 최신 날짜 우선
]);
// 오타 허용 설정 (typo tolerance) // 오타 허용 설정 (typo tolerance)
await index.updateTypoTolerance({ await index.updateTypoTolerance({
enabled: true, enabled: true,
@ -56,9 +66,9 @@ export async function addOrUpdateSchedule(schedule) {
try { try {
const index = client.index(SCHEDULE_INDEX); const index = client.index(SCHEDULE_INDEX);
// 멤버 이름을 문자열로 변환 // 멤버 이름을 쉼표로 구분하여 저장
const memberNames = schedule.members const memberNames = schedule.members
? schedule.members.map((m) => m.name).join(" ") ? schedule.members.map((m) => m.name).join(",")
: ""; : "";
const document = { const document = {
@ -112,8 +122,10 @@ export async function searchSchedules(query, options = {}) {
searchOptions.filter = `category_id = ${options.categoryId}`; 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); const results = await index.search(query, searchOptions);

View file

@ -31,6 +31,18 @@ services:
- db - db
restart: unless-stopped 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: networks:
app: app:
external: true external: true

View file

@ -71,7 +71,7 @@ function Schedule() {
} }
}; };
// (API ) // (Meilisearch API )
const searchSchedules = async (term) => { const searchSchedules = async (term) => {
if (!term.trim()) { if (!term.trim()) {
setSearchResults([]); setSearchResults([]);
@ -79,7 +79,7 @@ function Schedule() {
} }
setSearchLoading(true); setSearchLoading(true);
try { try {
const response = await fetch(`/api/admin/schedules?search=${encodeURIComponent(term)}`); const response = await fetch(`/api/schedules?search=${encodeURIComponent(term)}`);
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setSearchResults(data); setSearchResults(data);
@ -178,17 +178,11 @@ function Schedule() {
const filteredSchedules = useMemo(() => { const filteredSchedules = useMemo(() => {
// //
if (isSearchMode) { if (isSearchMode) {
// , API // , API (Meilisearch )
if (!searchTerm) return []; if (!searchTerm) return [];
return searchResults.sort((a, b) => { return searchResults;
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 schedules return schedules

View file

@ -185,7 +185,7 @@ function AdminSchedule() {
} }
}; };
// (API ) // (Meilisearch API )
const searchSchedules = async (term) => { const searchSchedules = async (term) => {
if (!term.trim()) { if (!term.trim()) {
setSearchResults([]); setSearchResults([]);
@ -193,7 +193,7 @@ function AdminSchedule() {
} }
setSearchLoading(true); setSearchLoading(true);
try { 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(); const data = await res.json();
setSearchResults(data); setSearchResults(data);
} catch (error) { } catch (error) {
@ -203,6 +203,7 @@ function AdminSchedule() {
} }
}; };
// //
useEffect(() => { useEffect(() => {
const handleClickOutside = (event) => { const handleClickOutside = (event) => {
@ -974,21 +975,31 @@ function AdminSchedule() {
<p className="text-sm text-gray-500 mb-1">{schedule.description}</p> <p className="text-sm text-gray-500 mb-1">{schedule.description}</p>
)} )}
{/* 멤버 태그 */} {/* 멤버 태그 */}
{schedule.members && schedule.members.length > 0 && ( {(() => {
<div className="flex flex-wrap gap-1.5"> // members member_names
{schedule.members.length >= 5 ? ( const memberList = schedule.members?.length > 0
<span className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full"> ? schedule.members
프로미스나인 : schedule.member_names
</span> ? schedule.member_names.split(',').filter(n => n.trim()).map((name, idx) => ({ id: idx, name: name.trim() }))
) : ( : [];
schedule.members.map((member) => ( if (memberList.length === 0) return null;
<span key={member.id} className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full"> return (
{member.name} <div className="flex flex-wrap gap-1.5">
{memberList.length >= 5 ? (
<span className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full">
프로미스나인
</span> </span>
)) ) : (
)} memberList.map((member) => (
</div> <span key={member.id} className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full">
)} {member.name}
</span>
))
)}
</div>
);
})()}
</div> </div>
{/* 액션 버튼 */} {/* 액션 버튼 */}