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();
// 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();

View file

@ -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

View file

@ -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 봇 스케줄러 초기화

View file

@ -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);

View file

@ -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

View file

@ -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,18 +178,12 @@ 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
.filter(s => {

View file

@ -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() {
<p className="text-sm text-gray-500 mb-1">{schedule.description}</p>
)}
{/* 멤버 태그 */}
{schedule.members && schedule.members.length > 0 && (
{(() => {
// 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 (
<div className="flex flex-wrap gap-1.5">
{schedule.members.length >= 5 ? (
{memberList.length >= 5 ? (
<span className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full">
프로미스나인
</span>
) : (
schedule.members.map((member) => (
memberList.map((member) => (
<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>
{/* 액션 버튼 */}