Meilisearch 검색 기능 개선
- 검색 결과 유사도순 정렬 (동일 유사도 시 최신 날짜 우선) - 프론트엔드 검색 재정렬 제거 (Meilisearch 순서 유지) - 관리자 일정 페이지 Meilisearch 검색 적용 - 일정 수정 시 Meilisearch 동기화 추가 - 서버 시작 시 자동 동기화 - 멤버 이름 쉼표 구분으로 통일
This commit is contained in:
parent
346d6529f2
commit
068c5ffbbb
7 changed files with 112 additions and 37 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 봇 스케줄러 초기화
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue