2026-01-18 18:53:57 +09:00
|
|
|
/**
|
|
|
|
|
* Meilisearch 검색 서비스
|
|
|
|
|
* - 일정 검색 (멤버 별명 → 이름 변환)
|
|
|
|
|
* - 영문 자판 → 한글 변환
|
|
|
|
|
* - 유사도 필터링
|
|
|
|
|
* - 일정 동기화
|
|
|
|
|
*/
|
|
|
|
|
import Inko from 'inko';
|
2026-01-21 14:13:18 +09:00
|
|
|
import config, { CATEGORY_IDS } from '../../config/index.js';
|
2026-01-21 14:20:32 +09:00
|
|
|
import { createLogger } from '../../utils/logger.js';
|
2026-01-21 23:18:48 +09:00
|
|
|
import { buildDatetime } from '../schedule.js';
|
2026-01-18 18:53:57 +09:00
|
|
|
|
|
|
|
|
const inko = new Inko();
|
2026-01-21 14:20:32 +09:00
|
|
|
const logger = createLogger('Meilisearch');
|
2026-01-18 18:53:57 +09:00
|
|
|
const INDEX_NAME = 'schedules';
|
2026-01-21 14:11:35 +09:00
|
|
|
const MIN_SCORE = config.meilisearch.minScore;
|
2026-01-18 18:53:57 +09:00
|
|
|
|
2026-01-22 20:56:38 +09:00
|
|
|
// 캐시된 현재 활동 멤버 수 (동기화 시 갱신)
|
|
|
|
|
let cachedActiveMemberCount = null;
|
|
|
|
|
|
2026-01-18 18:53:57 +09:00
|
|
|
/**
|
|
|
|
|
* 영문 자판으로 입력된 검색어인지 확인
|
|
|
|
|
*/
|
|
|
|
|
function isEnglishKeyboard(text) {
|
|
|
|
|
const englishChars = text.match(/[a-zA-Z]/g) || [];
|
|
|
|
|
const koreanChars = text.match(/[가-힣ㄱ-ㅎㅏ-ㅣ]/g) || [];
|
|
|
|
|
return englishChars.length > 0 && koreanChars.length === 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 별명/이름으로 멤버 이름 조회
|
|
|
|
|
*/
|
|
|
|
|
export async function resolveMemberNames(db, query) {
|
|
|
|
|
const searchTerm = `%${query}%`;
|
|
|
|
|
const [members] = await db.query(`
|
|
|
|
|
SELECT DISTINCT m.name
|
|
|
|
|
FROM members m
|
|
|
|
|
LEFT JOIN member_nicknames mn ON m.id = mn.member_id
|
|
|
|
|
WHERE m.name LIKE ? OR mn.nickname LIKE ?
|
|
|
|
|
`, [searchTerm, searchTerm]);
|
|
|
|
|
|
|
|
|
|
return members.map(m => m.name);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-22 20:56:38 +09:00
|
|
|
/**
|
|
|
|
|
* 현재 활동 멤버 수 조회 및 캐시
|
|
|
|
|
*/
|
|
|
|
|
async function getActiveMemberCount(db) {
|
|
|
|
|
if (cachedActiveMemberCount === null) {
|
|
|
|
|
const [[{ count }]] = await db.query(
|
|
|
|
|
'SELECT COUNT(*) as count FROM members WHERE is_former = 0'
|
|
|
|
|
);
|
|
|
|
|
cachedActiveMemberCount = count;
|
|
|
|
|
}
|
|
|
|
|
return cachedActiveMemberCount;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-18 18:53:57 +09:00
|
|
|
/**
|
|
|
|
|
* 일정 검색
|
|
|
|
|
* @param {object} meilisearch - Meilisearch 클라이언트
|
|
|
|
|
* @param {object} db - DB 연결 풀
|
|
|
|
|
* @param {string} query - 검색어
|
2026-01-21 16:04:07 +09:00
|
|
|
* @param {object} options - 검색 옵션 (offset, limit for pagination)
|
2026-01-18 18:53:57 +09:00
|
|
|
*/
|
|
|
|
|
export async function searchSchedules(meilisearch, db, query, options = {}) {
|
2026-01-21 16:04:07 +09:00
|
|
|
const { limit = 100, offset = 0 } = options;
|
|
|
|
|
// 내부 검색 한도 (여러 검색어 병합 및 유사도 필터링 전 충분한 결과 확보)
|
|
|
|
|
const SEARCH_LIMIT = 1000;
|
2026-01-18 18:53:57 +09:00
|
|
|
|
|
|
|
|
try {
|
2026-01-22 20:56:38 +09:00
|
|
|
// 현재 활동 멤버 수 캐시 (formatScheduleResponse에서 사용)
|
|
|
|
|
await getActiveMemberCount(db);
|
2026-01-18 18:53:57 +09:00
|
|
|
const index = meilisearch.index(INDEX_NAME);
|
|
|
|
|
|
|
|
|
|
const searchOptions = {
|
2026-01-21 16:04:07 +09:00
|
|
|
limit: SEARCH_LIMIT,
|
2026-01-18 18:53:57 +09:00
|
|
|
offset: 0, // 내부적으로 전체 검색 후 필터링
|
|
|
|
|
attributesToRetrieve: ['*'],
|
|
|
|
|
showRankingScore: true,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 검색어 목록 구성
|
|
|
|
|
const searchQueries = [query];
|
|
|
|
|
|
|
|
|
|
// 영문 자판 입력 → 한글 변환
|
|
|
|
|
if (isEnglishKeyboard(query)) {
|
|
|
|
|
const koreanQuery = inko.en2ko(query);
|
|
|
|
|
if (koreanQuery !== query) {
|
|
|
|
|
searchQueries.push(koreanQuery);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 별명 → 멤버 이름 변환
|
|
|
|
|
const memberNames = await resolveMemberNames(db, query);
|
|
|
|
|
for (const name of memberNames) {
|
|
|
|
|
if (!searchQueries.includes(name)) {
|
|
|
|
|
searchQueries.push(name);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 각 검색어로 검색 후 병합
|
|
|
|
|
const allHits = new Map(); // id 기준 중복 제거
|
|
|
|
|
|
|
|
|
|
for (const q of searchQueries) {
|
|
|
|
|
const results = await index.search(q, searchOptions);
|
|
|
|
|
for (const hit of results.hits) {
|
|
|
|
|
// 더 높은 점수로 업데이트
|
|
|
|
|
if (!allHits.has(hit.id) || allHits.get(hit.id)._rankingScore < hit._rankingScore) {
|
|
|
|
|
allHits.set(hit.id, hit);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-21 14:11:35 +09:00
|
|
|
// 유사도 필터링
|
2026-01-18 18:53:57 +09:00
|
|
|
let filteredHits = Array.from(allHits.values())
|
2026-01-21 14:11:35 +09:00
|
|
|
.filter(hit => hit._rankingScore >= MIN_SCORE);
|
2026-01-18 18:53:57 +09:00
|
|
|
|
|
|
|
|
// 유사도 순 정렬
|
|
|
|
|
filteredHits.sort((a, b) => (b._rankingScore || 0) - (a._rankingScore || 0));
|
|
|
|
|
|
|
|
|
|
const total = filteredHits.length;
|
|
|
|
|
|
|
|
|
|
// 페이징 적용
|
|
|
|
|
const paginatedHits = filteredHits.slice(offset, offset + limit);
|
|
|
|
|
|
|
|
|
|
// 응답 형식 변환
|
|
|
|
|
const formattedHits = paginatedHits.map(formatScheduleResponse);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
hits: formattedHits,
|
|
|
|
|
total,
|
|
|
|
|
offset,
|
|
|
|
|
limit,
|
|
|
|
|
hasMore: offset + paginatedHits.length < total,
|
|
|
|
|
};
|
|
|
|
|
} catch (err) {
|
2026-01-21 14:20:32 +09:00
|
|
|
logger.error(`검색 오류: ${err.message}`);
|
2026-01-18 18:53:57 +09:00
|
|
|
return { hits: [], total: 0, offset: 0, limit, hasMore: false };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 검색 결과 응답 형식 변환
|
2026-01-21 23:18:48 +09:00
|
|
|
* schedule.js의 공통 포맷과 동일한 구조 반환
|
|
|
|
|
* (Meilisearch 인덱스 필드명이 다르므로 별도 매핑 필요)
|
2026-01-18 18:53:57 +09:00
|
|
|
*/
|
|
|
|
|
function formatScheduleResponse(hit) {
|
|
|
|
|
// member_names를 배열로 변환
|
2026-01-22 20:56:38 +09:00
|
|
|
let members = hit.member_names
|
2026-01-18 18:53:57 +09:00
|
|
|
? hit.member_names.split(',').map(name => name.trim()).filter(Boolean)
|
|
|
|
|
: [];
|
|
|
|
|
|
2026-01-22 20:56:38 +09:00
|
|
|
// 전체 멤버인 경우 "프로미스나인"으로 대체
|
|
|
|
|
if (cachedActiveMemberCount && members.length === cachedActiveMemberCount) {
|
|
|
|
|
members = ['프로미스나인'];
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-21 23:18:48 +09:00
|
|
|
// source 객체 구성 (Meilisearch에는 URL 없음)
|
2026-01-19 10:16:24 +09:00
|
|
|
let source = null;
|
2026-01-21 14:13:18 +09:00
|
|
|
if (hit.category_id === CATEGORY_IDS.YOUTUBE && hit.source_name) {
|
2026-01-19 10:16:24 +09:00
|
|
|
source = { name: hit.source_name, url: null };
|
2026-01-21 14:13:18 +09:00
|
|
|
} else if (hit.category_id === CATEGORY_IDS.X) {
|
2026-01-19 10:16:24 +09:00
|
|
|
source = { name: '', url: null };
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-18 18:53:57 +09:00
|
|
|
return {
|
|
|
|
|
id: hit.id,
|
|
|
|
|
title: hit.title,
|
2026-01-21 23:18:48 +09:00
|
|
|
datetime: buildDatetime(hit.date, hit.time),
|
2026-01-18 18:53:57 +09:00
|
|
|
category: {
|
|
|
|
|
id: hit.category_id,
|
|
|
|
|
name: hit.category_name,
|
|
|
|
|
color: hit.category_color,
|
|
|
|
|
},
|
2026-01-19 10:16:24 +09:00
|
|
|
source,
|
2026-01-18 18:53:57 +09:00
|
|
|
members,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 일정 추가/업데이트
|
|
|
|
|
*/
|
|
|
|
|
export async function addOrUpdateSchedule(meilisearch, schedule) {
|
|
|
|
|
try {
|
|
|
|
|
const index = meilisearch.index(INDEX_NAME);
|
|
|
|
|
|
|
|
|
|
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 || '',
|
|
|
|
|
member_names: schedule.member_names || '',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await index.addDocuments([document]);
|
2026-01-21 14:20:32 +09:00
|
|
|
logger.info(`일정 추가/업데이트: ${schedule.id}`);
|
2026-01-18 18:53:57 +09:00
|
|
|
} catch (err) {
|
2026-01-21 14:20:32 +09:00
|
|
|
logger.error(`문서 추가 오류: ${err.message}`);
|
2026-01-18 18:53:57 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 일정 삭제
|
|
|
|
|
*/
|
|
|
|
|
export async function deleteSchedule(meilisearch, scheduleId) {
|
|
|
|
|
try {
|
|
|
|
|
const index = meilisearch.index(INDEX_NAME);
|
|
|
|
|
await index.deleteDocument(scheduleId);
|
2026-01-21 14:20:32 +09:00
|
|
|
logger.info(`일정 삭제: ${scheduleId}`);
|
2026-01-18 18:53:57 +09:00
|
|
|
} catch (err) {
|
2026-01-21 14:20:32 +09:00
|
|
|
logger.error(`문서 삭제 오류: ${err.message}`);
|
2026-01-18 18:53:57 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 전체 일정 동기화
|
|
|
|
|
*/
|
|
|
|
|
export async function syncAllSchedules(meilisearch, db) {
|
|
|
|
|
try {
|
2026-01-22 20:56:38 +09:00
|
|
|
// 현재 활동 멤버 수 캐시 갱신
|
|
|
|
|
const [[{ count }]] = await db.query(
|
|
|
|
|
'SELECT COUNT(*) as count FROM members WHERE is_former = 0'
|
|
|
|
|
);
|
|
|
|
|
cachedActiveMemberCount = count;
|
|
|
|
|
|
|
|
|
|
// DB에서 모든 일정 조회 (탈퇴 멤버 제외)
|
2026-01-18 18:53:57 +09:00
|
|
|
const [schedules] = await db.query(`
|
|
|
|
|
SELECT
|
|
|
|
|
s.id,
|
|
|
|
|
s.title,
|
|
|
|
|
s.description,
|
|
|
|
|
s.date,
|
|
|
|
|
s.time,
|
|
|
|
|
s.category_id,
|
|
|
|
|
c.name as category_name,
|
|
|
|
|
c.color as category_color,
|
|
|
|
|
sy.channel_name as source_name,
|
|
|
|
|
GROUP_CONCAT(DISTINCT 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_youtube sy ON s.id = sy.schedule_id
|
|
|
|
|
LEFT JOIN schedule_members sm ON s.id = sm.schedule_id
|
2026-01-22 20:56:38 +09:00
|
|
|
LEFT JOIN members m ON sm.member_id = m.id AND m.is_former = 0
|
2026-01-18 18:53:57 +09:00
|
|
|
GROUP BY s.id
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
const index = meilisearch.index(INDEX_NAME);
|
|
|
|
|
|
2026-01-23 21:01:35 +09:00
|
|
|
// 문서 변환 (addDocuments는 같은 ID면 자동 업데이트)
|
2026-01-18 18:53:57 +09:00
|
|
|
const documents = schedules.map(s => ({
|
|
|
|
|
id: s.id,
|
|
|
|
|
title: s.title,
|
|
|
|
|
description: s.description || '',
|
|
|
|
|
date: s.date instanceof Date ? s.date.toISOString().split('T')[0] : s.date,
|
|
|
|
|
time: s.time || '',
|
|
|
|
|
category_id: s.category_id,
|
|
|
|
|
category_name: s.category_name || '',
|
|
|
|
|
category_color: s.category_color || '',
|
|
|
|
|
source_name: s.source_name || '',
|
|
|
|
|
member_names: s.member_names || '',
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// 일괄 추가
|
|
|
|
|
await index.addDocuments(documents);
|
2026-01-21 14:20:32 +09:00
|
|
|
logger.info(`${documents.length}개 일정 동기화 완료`);
|
2026-01-18 18:53:57 +09:00
|
|
|
|
|
|
|
|
return documents.length;
|
|
|
|
|
} catch (err) {
|
2026-01-21 14:20:32 +09:00
|
|
|
logger.error(`동기화 오류: ${err.message}`);
|
2026-01-18 18:53:57 +09:00
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
}
|