diff --git a/backend/models/kiwi/user.dict b/backend/models/kiwi/user.dict index d1c6887..7487cd7 100644 --- a/backend/models/kiwi/user.dict +++ b/backend/models/kiwi/user.dict @@ -1,15 +1,8 @@ -# 사용자 정의 사전 -# 형식: 단어\t품사\t점수(선택) -# 품사: NNP(고유명사), NNG(일반명사), SL(외국어) - -# 그룹명 프로미스나인 NNP 프미나 NNP 프나 NNP fromis_9 SL fromis SL - -# 멤버 이름 이새롬 NNP 새롬 NNP 송하영 NNP @@ -28,70 +21,20 @@ fromis SL 나경 NNP 백지헌 NNP 지헌 NNP - -# 팬덤 플로버 NNP flover SL - -# 음악 프로그램 뮤직뱅크 NNP 인기가요 NNP 음악중심 NNP 엠카운트다운 NNP 쇼챔피언 NNP 더쇼 NNP - -# 앨범/곡명 -하얀그리움 NNP -LIKE_YOU_BETTER SL - -# 콘텐츠/채널명 스프 NNP 성수기 NNP 이단장 NNP 슈퍼E나경 NNP FM_1.24 SL -벌거벗은한국사 NNP -꿈친구 NNP -미미미누 NNP -비밀전학생 NNP -하일병 NNP -동네스타K쇼 NNP -밥사효 NNP -워크맨 NNP -영업중 NNP -개그콘서트 NNP - -# 방송/행사 -워터밤 NNP -서든어택 NNP -NPOP SL -하이록스 NNP - -# 관련 용어 팬미팅 NNG 직캠 NNG -시구 NNG -시타 NNG 컴백 NNG -호캉스 NNG -팬캠 NNG -응원법 NNG -브이라이브 NNG -브이앱 NNG - -# 다른 아이돌/연예인 -류진 NNP -RYUJIN SL -ITZY SL -이즈나 NNP -izna SL -아일릿 NNP -ILLIT SL -키스오브라이프 NNP -하이키 NNP -H1KEY SL -아이들 NNP -미연 NNP -효연 NNP -T1 SL +응원법 NNG \ No newline at end of file diff --git a/backend/src/routes/schedules/suggestions.js b/backend/src/routes/schedules/suggestions.js index 3fa5859..7305bde 100644 --- a/backend/src/routes/schedules/suggestions.js +++ b/backend/src/routes/schedules/suggestions.js @@ -1,7 +1,9 @@ /** * 추천 검색어 API 라우트 */ +import { readFileSync, writeFileSync } from 'fs'; import { SuggestionService } from '../../services/suggestions/index.js'; +import { reloadMorpheme, getUserDictPath } from '../../services/suggestions/morpheme.js'; let suggestionService = null; @@ -113,4 +115,83 @@ export default async function suggestionsRoutes(fastify) { await suggestionService.saveSearchQuery(query); return { success: true }; }); + + /** + * GET /api/schedules/suggestions/dict + * 사용자 사전 조회 (관리자 전용) + */ + fastify.get('/dict', { + schema: { + tags: ['suggestions'], + summary: '사용자 사전 조회', + security: [{ bearerAuth: [] }], + response: { + 200: { + type: 'object', + properties: { + content: { type: 'string', description: '사전 내용' }, + }, + }, + }, + }, + preHandler: [fastify.authenticate], + }, async (request, reply) => { + try { + const dictPath = getUserDictPath(); + const content = readFileSync(dictPath, 'utf-8'); + return { content }; + } catch (error) { + if (error.code === 'ENOENT') { + return { content: '' }; + } + throw error; + } + }); + + /** + * PUT /api/schedules/suggestions/dict + * 사용자 사전 저장 및 리로드 (관리자 전용) + */ + fastify.put('/dict', { + schema: { + tags: ['suggestions'], + summary: '사용자 사전 저장', + security: [{ bearerAuth: [] }], + body: { + type: 'object', + required: ['content'], + properties: { + content: { type: 'string', description: '사전 내용' }, + }, + }, + response: { + 200: { + type: 'object', + properties: { + success: { type: 'boolean' }, + message: { type: 'string' }, + }, + }, + }, + }, + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const { content } = request.body; + + try { + const dictPath = getUserDictPath(); + writeFileSync(dictPath, content, 'utf-8'); + + // 형태소 분석기 리로드 + await reloadMorpheme(); + + return { success: true, message: '사전이 저장되었습니다.' }; + } catch (error) { + console.error('[Suggestions] 사전 저장 오류:', error.message); + return reply.code(500).send({ + success: false, + message: '사전 저장 중 오류가 발생했습니다.', + }); + } + }); } diff --git a/backend/src/services/suggestions/morpheme.js b/backend/src/services/suggestions/morpheme.js index d3a1b18..05d092c 100644 --- a/backend/src/services/suggestions/morpheme.js +++ b/backend/src/services/suggestions/morpheme.js @@ -162,3 +162,22 @@ function fallbackExtract(text) { export function isReady() { return isInitialized && kiwi !== null; } + +/** + * 형태소 분석기 리로드 (사전 변경 시 호출) + */ +export async function reloadMorpheme() { + console.log('[Morpheme] 리로드 시작...'); + isInitialized = false; + kiwi = null; + initPromise = null; + await initMorpheme(); + console.log('[Morpheme] 리로드 완료'); +} + +/** + * 사용자 사전 파일 경로 반환 + */ +export function getUserDictPath() { + return join(__dirname, '../../../models/kiwi', USER_DICT); +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e9a177a..75bfd13 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -40,6 +40,7 @@ import AdminSchedule from './pages/pc/admin/AdminSchedule'; import AdminScheduleForm from './pages/pc/admin/AdminScheduleForm'; import AdminScheduleCategory from './pages/pc/admin/AdminScheduleCategory'; import AdminScheduleBots from './pages/pc/admin/AdminScheduleBots'; +import AdminScheduleDict from './pages/pc/admin/AdminScheduleDict'; // 레이아웃 import PCLayout from './components/pc/Layout'; @@ -75,6 +76,7 @@ function App() { } /> } /> } /> + } /> {/* 일반 페이지 (레이아웃 포함) */} fromis_9의 일정을 관리합니다

+ + + {showPosDropdown && ( + + {POS_TAGS.map((tag) => ( + + ))} + + )} + +
+ + + + + + ); +} + +function AdminScheduleDict() { + const { user, isAuthenticated } = useAdminAuth(); + const { toast, setToast } = useToast(); + const [entries, setEntries] = useState([]); // [{word, pos, isComment, id}] + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [filterPos, setFilterPos] = useState('all'); + const [showFilterDropdown, setShowFilterDropdown] = useState(false); + + // 새 단어 입력 + const [newWord, setNewWord] = useState(''); + const [newPos, setNewPos] = useState('NNP'); + const [showNewPosDropdown, setShowNewPosDropdown] = useState(false); + + // 드롭다운 refs + const newPosDropdownRef = useRef(null); + const filterDropdownRef = useRef(null); + + // 다이얼로그 상태 + const [addDialogOpen, setAddDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [wordToDelete, setWordToDelete] = useState(null); // { index, word, id } + + // 외부 클릭 시 드롭다운 닫기 + useEffect(() => { + const handleClickOutside = (event) => { + if (newPosDropdownRef.current && !newPosDropdownRef.current.contains(event.target)) { + setShowNewPosDropdown(false); + } + if (filterDropdownRef.current && !filterDropdownRef.current.contains(event.target)) { + setShowFilterDropdown(false); + } + }; + + if (showNewPosDropdown || showFilterDropdown) { + document.addEventListener('mousedown', handleClickOutside); + } + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [showNewPosDropdown, showFilterDropdown]); + + // 필터링된 항목 + const filteredEntries = useMemo(() => { + return entries.filter((entry, index) => { + if (entry.isComment) return true; // 주석은 항상 포함 (but 표시 안함) + + const matchesSearch = !searchQuery || + entry.word.toLowerCase().includes(searchQuery.toLowerCase()); + const matchesPos = filterPos === 'all' || entry.pos === filterPos; + + return matchesSearch && matchesPos; + }); + }, [entries, searchQuery, filterPos]); + + // 실제 단어 항목만 (주석 제외) + const wordEntries = useMemo(() => { + return filteredEntries.filter(e => !e.isComment); + }, [filteredEntries]); + + // 품사별 통계 + const posStats = useMemo(() => { + const stats = { total: 0 }; + entries.forEach(e => { + if (!e.isComment) { + stats.total++; + stats[e.pos] = (stats[e.pos] || 0) + 1; + } + }); + return stats; + }, [entries]); + + useEffect(() => { + if (isAuthenticated) { + fetchDict(); + } + }, [isAuthenticated]); + + // 고유 ID 생성 + const generateId = () => `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + // 사전 파일 파싱 + const parseDict = (content) => { + const lines = content.split('\n'); + return lines.map(line => { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + return { isComment: true, raw: line, id: generateId() }; + } + const parts = trimmed.split('\t'); + return { + word: parts[0] || '', + pos: parts[1] || 'NNP', + isComment: false, + id: generateId(), + }; + }).filter(e => e.isComment || e.word); // 빈 줄 제거하되 주석은 유지 + }; + + // 사전 파일 생성 + const serializeDict = (entries) => { + return entries.map(e => { + if (e.isComment) return e.raw; + return `${e.word}\t${e.pos}`; + }).join('\n'); + }; + + // 사전 내용 조회 + const fetchDict = async () => { + setLoading(true); + try { + const data = await suggestionsApi.getDict(); + const parsed = parseDict(data.content || ''); + setEntries(parsed); + } catch (error) { + console.error('사전 조회 오류:', error); + setToast({ type: 'error', message: '사전을 불러올 수 없습니다.' }); + } finally { + setLoading(false); + } + }; + + // 사전 저장 (entries 배열을 받아서 저장) + const saveDict = async (newEntries) => { + try { + const content = serializeDict(newEntries); + await suggestionsApi.saveDict(content); + return true; + } catch (error) { + console.error('사전 저장 오류:', error); + setToast({ type: 'error', message: error.message || '저장 중 오류가 발생했습니다.' }); + return false; + } + }; + + // 단어 추가 다이얼로그 열기 + const openAddDialog = () => { + if (!newWord.trim()) return; + + // 중복 확인 + const isDuplicate = entries.some(e => !e.isComment && e.word.toLowerCase() === newWord.trim().toLowerCase()); + if (isDuplicate) { + setToast({ type: 'error', message: '이미 존재하는 단어입니다.' }); + return; + } + + setAddDialogOpen(true); + }; + + // 단어 추가 확인 + const handleAddWord = async () => { + setSaving(true); + const wordToAdd = newWord.trim(); + const newEntry = { word: wordToAdd, pos: newPos, isComment: false, id: generateId() }; + const newEntries = [...entries, newEntry]; + + const success = await saveDict(newEntries); + if (success) { + setEntries(newEntries); + setNewWord(''); + setToast({ type: 'success', message: `"${wordToAdd}" 단어가 추가되었습니다.` }); + } + setAddDialogOpen(false); + setSaving(false); + }; + + // 단어 수정 (id 기반) + const handleUpdateWord = async (id, word, pos) => { + const entryIndex = entries.findIndex(e => e.id === id); + if (entryIndex === -1) return; + + const newEntries = [...entries]; + newEntries[entryIndex] = { ...newEntries[entryIndex], word, pos }; + + const success = await saveDict(newEntries); + if (success) { + setEntries(newEntries); + } + }; + + // 단어 삭제 다이얼로그 열기 + const openDeleteDialog = (id, word) => { + setWordToDelete({ id, word }); + setDeleteDialogOpen(true); + }; + + // 단어 삭제 확인 + const handleDeleteWord = async () => { + if (!wordToDelete) return; + + setSaving(true); + const deletedWord = wordToDelete.word; + const newEntries = entries.filter(e => e.id !== wordToDelete.id); + + const success = await saveDict(newEntries); + if (success) { + setEntries(newEntries); + setToast({ type: 'success', message: `"${deletedWord}" 단어가 삭제되었습니다.` }); + } + setDeleteDialogOpen(false); + setWordToDelete(null); + setSaving(false); + }; + + // 엔터키로 추가 다이얼로그 열기 + const handleKeyDown = (e) => { + if (e.key === 'Enter') { + openAddDialog(); + } + }; + + return ( + + setToast(null)} /> + + {/* 단어 추가 확인 다이얼로그 */} + !saving && setAddDialogOpen(false)} + onConfirm={handleAddWord} + title="단어 추가" + message={ + <> +

다음 단어를 추가하시겠습니까?

+
+

{newWord}

+

+ {POS_TAGS.find(t => t.value === newPos)?.label} +

+
+ + } + confirmText="추가" + loadingText="추가 중..." + loading={saving} + variant="primary" + icon={Plus} + /> + + {/* 단어 삭제 확인 다이얼로그 */} + { + if (!saving) { + setDeleteDialogOpen(false); + setWordToDelete(null); + } + }} + onConfirm={handleDeleteWord} + title="단어 삭제" + message={ + <> +

다음 단어를 삭제하시겠습니까?

+

+ {wordToDelete?.word} +

+ + } + confirmText="삭제" + loadingText="삭제 중..." + loading={saving} + variant="danger" + /> + + {/* 메인 콘텐츠 */} +
+ {/* 브레드크럼 */} +
+ + + + + + 일정 관리 + + + 사전 관리 +
+ + {/* 타이틀 */} +
+

사전 관리

+

형태소 분석기 사용자 사전을 관리합니다

+
+ + {/* 통계 카드 */} +
+
+
{posStats.total || 0}
+
전체 단어
+
+
+
{posStats.NNP || 0}
+
고유명사
+
+
+
{posStats.NNG || 0}
+
일반명사
+
+
+
{posStats.SL || 0}
+
외국어
+
+
+ + {/* 단어 추가 영역 */} +
+

단어 추가

+
+
+ setNewWord(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="추가할 단어 입력..." + className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors" + /> +
+
+ + + {showNewPosDropdown && ( + + {POS_TAGS.map((tag) => ( + + ))} + + )} + +
+ +
+
+ + {/* 단어 목록 */} +
+ {/* 검색 및 필터 */} +
+
+ + setSearchQuery(e.target.value)} + placeholder="단어 검색..." + className="w-full pl-11 pr-4 py-2.5 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors" + /> +
+
+ + + {showFilterDropdown && ( + + + {POS_TAGS.map((tag) => ( + + ))} + + )} + +
+
+ + {/* 테이블 */} + {loading ? ( +
+
+
+ ) : wordEntries.length === 0 ? ( +
+ +

{searchQuery || filterPos !== 'all' ? '검색 결과가 없습니다' : '등록된 단어가 없습니다'}

+

위의 입력창에서 단어를 추가하세요

+
+ ) : ( +
+ + + + + + + + + + + + {wordEntries.map((entry, index) => ( + openDeleteDialog(entry.id, entry.word)} + /> + ))} + + +
#단어품사
+
+ )} + + {/* 푸터 */} + {wordEntries.length > 0 && ( +
+ {searchQuery || filterPos !== 'all' ? ( + {wordEntries.length}개 검색됨 (전체 {posStats.total}개) + ) : ( + 총 {posStats.total}개 단어 + )} +
+ )} +
+
+
+ ); +} + +export default AdminScheduleDict;