diff --git a/CLAUDE.md b/CLAUDE.md index e7c1c20..9232fd6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,14 @@ docker compose up -d --build docker compose logs -f fromis9-frontend ``` +## 환경 변수 + +DB 및 외부 서비스 접근 정보는 `.env` 파일 참조: +- DB_HOST, DB_USER, DB_PASSWORD, DB_NAME (MariaDB) +- RUSTFS_* (S3 호환 스토리지) +- YOUTUBE_API_KEY +- MEILI_MASTER_KEY (Meilisearch) + ## 문서 - [docs/migration.md](docs/migration.md) - 마이그레이션 현황 및 남은 작업 diff --git a/backend/docs/suggestion-improvement-plan.md b/backend/docs/suggestion-improvement-plan.md deleted file mode 100644 index e16572c..0000000 --- a/backend/docs/suggestion-improvement-plan.md +++ /dev/null @@ -1,253 +0,0 @@ -# 추천 검색어 시스템 개선 계획서 - -## 1. 현재 시스템 분석 - -### 1.1 기존 동작 방식 - -``` -검색어 입력 → 띄어쓰기 분리 → Unigram/Bi-gram 저장 → prefix 매칭으로 추천 -``` - -### 1.2 기존 코드의 문제점 - -| 문제 | 설명 | 예시 | -|------|------|------| -| **띄어쓰기 기준 분리** | 조사/어미가 포함된 형태로 저장됨 | "콘서트가", "열립니다" | -| **무의미한 검색어 저장** | 검색 결과 없어도 저장됨 | 오타, 의미없는 문자열 | -| **Redis 캐시 미활용** | prefix 검색은 매번 DB 조회 | 성능 저하 | -| **서브쿼리 반복** | `MAX(count)` 매 쿼리마다 실행 | 성능 저하 | -| **초성 검색 미지원** | "ㅍㅁㅅ"로 검색 불가 | 사용성 저하 | - -### 1.3 기존 테이블 구조 - -```sql --- Unigram (전체 검색어) -CREATE TABLE search_queries ( - query VARCHAR(255) PRIMARY KEY, - count INT DEFAULT 1, - last_searched_at TIMESTAMP -); - --- Bi-gram (단어 쌍) -CREATE TABLE word_pairs ( - word1 VARCHAR(100), - word2 VARCHAR(100), - count INT DEFAULT 1, - PRIMARY KEY (word1, word2) -); -``` - ---- - -## 2. 개선 목표 - -1. **형태소 분석기 도입**: 의미있는 단어(명사, 고유명사)만 추출 -2. **검색 결과 기반 필터링**: 실제 검색 결과가 있는 검색어만 저장 -3. **캐시 성능 개선**: Redis 활용 강화 -4. **초성 검색 지원**: "ㅍㅁㅅ" → "프로미스나인" -5. **코드 구조 개선**: Fastify 플러그인 구조에 맞게 재작성 - ---- - -## 3. 개선 설계 - -### 3.1 형태소 분석기 도입 (koalanlp) - -**설치 요구사항:** -- Java 8 이상 (Docker에 추가 필요) -- `npm install koalanlp` - -**사용할 분석기:** -- **KMR (코모란)**: 속도 빠름, 정확도 양호 - -**추출 대상 품사:** -| 태그 | 설명 | 예시 | -|------|------|------| -| NNP | 고유명사 | 프로미스나인, 지원 | -| NNG | 일반명사 | 콘서트, 생일 | -| NNB | 의존명사 | (필요시) | -| SL | 외국어 | fromis_9 | - -**예시:** -``` -입력: "프로미스나인 콘서트가 열립니다" -기존: ["프로미스나인", "콘서트가", "열립니다"] -개선: ["프로미스나인", "콘서트"] -``` - -### 3.2 검색어 저장 로직 개선 - -``` -검색 실행 - ↓ -Meilisearch 검색 결과 확인 - ↓ (결과 있음) -형태소 분석으로 명사 추출 - ↓ -Unigram 저장 (전체 검색어) - ↓ -Bi-gram 저장 (명사 쌍) - ↓ -Redis 캐시 업데이트 -``` - -### 3.3 테이블 구조 개선 - -```sql --- 검색어 테이블 (변경 없음) -CREATE TABLE search_queries ( - id INT AUTO_INCREMENT PRIMARY KEY, - query VARCHAR(255) UNIQUE NOT NULL, - count INT DEFAULT 1, - has_results BOOLEAN DEFAULT TRUE, -- 추가: 검색 결과 유무 - last_searched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - INDEX idx_prefix (query(50)), - INDEX idx_count (count DESC) -); - --- 단어 쌍 테이블 (변경 없음) -CREATE TABLE word_pairs ( - word1 VARCHAR(100) NOT NULL, - word2 VARCHAR(100) NOT NULL, - count INT DEFAULT 1, - PRIMARY KEY (word1, word2), - INDEX idx_word1 (word1, count DESC) -); - --- 추가: 초성 인덱스 테이블 -CREATE TABLE chosung_index ( - chosung VARCHAR(50) NOT NULL, -- 초성 (예: "ㅍㅁㅅㄴㅇ") - word VARCHAR(100) NOT NULL, -- 원본 단어 (예: "프로미스나인") - count INT DEFAULT 1, - PRIMARY KEY (chosung, word), - INDEX idx_chosung (chosung) -); -``` - -### 3.4 Redis 캐시 전략 - -| 키 패턴 | 용도 | TTL | -|---------|------|-----| -| `suggest:prefix:{query}` | prefix 검색 결과 캐시 | 1시간 | -| `suggest:bigram:{word}` | Bi-gram 다음 단어 | 24시간 | -| `suggest:popular` | 인기 검색어 Top 100 | 10분 | -| `suggest:max_count` | 최대 검색 횟수 (임계값용) | 1시간 | - -### 3.5 초성 검색 구현 - -```javascript -// 한글 → 초성 변환 -function getChosung(text) { - const CHOSUNG = ['ㄱ','ㄲ','ㄴ','ㄷ','ㄸ','ㄹ','ㅁ','ㅂ','ㅃ','ㅅ','ㅆ','ㅇ','ㅈ','ㅉ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ']; - let result = ''; - for (const char of text) { - const code = char.charCodeAt(0) - 0xAC00; - if (code >= 0 && code <= 11171) { - result += CHOSUNG[Math.floor(code / 588)]; - } - } - return result; -} - -// "프로미스나인" → "ㅍㄹㅁㅅㄴㅇ" -``` - -### 3.6 API 엔드포인트 - -``` -GET /api/schedules/suggestions - ?q=검색어 - &limit=10 - -Response: -{ - "suggestions": ["프로미스나인", "프로미스나인 콘서트", ...] -} -``` - ---- - -## 4. 파일 구조 - -``` -backend/src/ -├── services/ -│ └── suggestions/ -│ ├── index.js # 메인 서비스 (저장/조회) -│ ├── morpheme.js # 형태소 분석 (koalanlp) -│ ├── chosung.js # 초성 변환/검색 -│ └── cache.js # Redis 캐시 관리 -├── routes/ -│ └── schedules/ -│ └── suggestions.js # API 라우트 -└── plugins/ - └── koalanlp.js # koalanlp 초기화 플러그인 -``` - ---- - -## 5. 구현 순서 - -### Phase 1: 기본 마이그레이션 -1. [ ] Docker에 Java 설치 추가 -2. [ ] koalanlp 패키지 설치 및 초기화 -3. [ ] 기존 suggestions.js를 Fastify 구조로 마이그레이션 -4. [ ] 기본 API 동작 확인 - -### Phase 2: 형태소 분석 적용 -5. [ ] morpheme.js 구현 (명사 추출) -6. [ ] saveSearchQuery에 형태소 분석 적용 -7. [ ] 검색 결과 있을 때만 저장하도록 수정 - -### Phase 3: 캐시 및 성능 개선 -8. [ ] Redis 캐시 로직 강화 -9. [ ] MAX(count) 캐싱 -10. [ ] 인기 검색어 캐시 - -### Phase 4: 초성 검색 -11. [ ] chosung.js 구현 -12. [ ] chosung_index 테이블 생성 -13. [ ] 초성 검색 API 통합 - -### Phase 5: 테스트 및 정리 -14. [ ] 기존 데이터 마이그레이션 (형태소 재분석) -15. [ ] 성능 테스트 -16. [ ] 문서화 - ---- - -## 6. Docker 변경사항 - -```dockerfile -# Dockerfile에 Java 추가 -FROM node:20-alpine - -# Java 설치 (koalanlp 필요) -RUN apk add --no-cache openjdk11-jre - -# JAVA_HOME 설정 -ENV JAVA_HOME=/usr/lib/jvm/java-11-openjdk -ENV PATH="$JAVA_HOME/bin:$PATH" -``` - ---- - -## 7. 예상 효과 - -| 항목 | 기존 | 개선 후 | -|------|------|---------| -| 단어 추출 정확도 | 낮음 (띄어쓰기 기준) | 높음 (형태소 기준) | -| 불필요한 데이터 | 많음 | 적음 (결과 있는 것만) | -| 검색 응답 속도 | 보통 | 빠름 (캐시 활용) | -| 초성 검색 | 불가 | 가능 | -| 사용자 경험 | 보통 | 향상 | - ---- - -## 8. 리스크 및 대응 - -| 리스크 | 영향 | 대응 방안 | -|--------|------|-----------| -| Java 설치로 이미지 크기 증가 | ~100MB | Alpine + JRE만 설치 | -| koalanlp 초기 로딩 시간 | 첫 요청 지연 | 서버 시작 시 미리 로드 | -| 형태소 분석 오류 | 단어 추출 실패 | fallback으로 기존 방식 유지 | diff --git a/backend/src/routes/albums/index.js b/backend/src/routes/albums/index.js index 12203bd..b20222a 100644 --- a/backend/src/routes/albums/index.js +++ b/backend/src/routes/albums/index.js @@ -21,7 +21,7 @@ export default async function albumsRoutes(fastify) { */ async function getAlbumDetails(album) { const [tracks] = await db.query( - 'SELECT * FROM tracks WHERE album_id = ? ORDER BY track_number', + 'SELECT * FROM album_tracks WHERE album_id = ? ORDER BY track_number', [album.id] ); album.tracks = tracks; @@ -91,7 +91,7 @@ export default async function albumsRoutes(fastify) { for (const album of albums) { const [tracks] = await db.query( `SELECT id, track_number, title, is_title_track, duration, lyricist, composer, arranger - FROM tracks WHERE album_id = ? ORDER BY track_number`, + FROM album_tracks WHERE album_id = ? ORDER BY track_number`, [album.id] ); album.tracks = tracks; @@ -124,7 +124,7 @@ export default async function albumsRoutes(fastify) { const album = albums[0]; const [tracks] = await db.query( - 'SELECT * FROM tracks WHERE album_id = ? AND title = ?', + 'SELECT * FROM album_tracks WHERE album_id = ? AND title = ?', [album.id, trackTitle] ); @@ -135,7 +135,7 @@ export default async function albumsRoutes(fastify) { const track = tracks[0]; const [otherTracks] = await db.query( - 'SELECT id, track_number, title, is_title_track, duration FROM tracks WHERE album_id = ? ORDER BY track_number', + 'SELECT id, track_number, title, is_title_track, duration FROM album_tracks WHERE album_id = ? ORDER BY track_number', [album.id] ); @@ -261,7 +261,7 @@ export default async function albumsRoutes(fastify) { if (tracks && tracks.length > 0) { for (const track of tracks) { await connection.query( - `INSERT INTO tracks (album_id, track_number, title, duration, is_title_track, + `INSERT INTO album_tracks (album_id, track_number, title, duration, is_title_track, lyricist, composer, arranger, lyrics, music_video_url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [albumId, track.track_number, track.title, track.duration || null, @@ -342,12 +342,12 @@ export default async function albumsRoutes(fastify) { coverOriginalUrl, coverMediumUrl, coverThumbUrl, description || null, id] ); - await connection.query('DELETE FROM tracks WHERE album_id = ?', [id]); + await connection.query('DELETE FROM album_tracks WHERE album_id = ?', [id]); if (tracks && tracks.length > 0) { for (const track of tracks) { await connection.query( - `INSERT INTO tracks (album_id, track_number, title, duration, is_title_track, + `INSERT INTO album_tracks (album_id, track_number, title, duration, is_title_track, lyricist, composer, arranger, lyrics, music_video_url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [id, track.track_number, track.title, track.duration || null, @@ -395,7 +395,7 @@ export default async function albumsRoutes(fastify) { await deleteAlbumCover(album.folder_name); } - await connection.query('DELETE FROM tracks WHERE album_id = ?', [id]); + await connection.query('DELETE FROM album_tracks WHERE album_id = ?', [id]); await connection.query('DELETE FROM albums WHERE id = ?', [id]); await connection.commit(); diff --git a/backend/src/routes/schedules/index.js b/backend/src/routes/schedules/index.js index 25007ed..30276da 100644 --- a/backend/src/routes/schedules/index.js +++ b/backend/src/routes/schedules/index.js @@ -80,10 +80,14 @@ export default async function schedulesRoutes(fastify) { s.*, c.name as category_name, c.color as category_color, - sy.channel_name as source_name + sy.channel_name as youtube_channel, + sy.video_id as youtube_video_id, + sy.video_type as youtube_video_type, + sx.post_id as x_post_id 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_x sx ON s.id = sx.schedule_id WHERE s.id = ? `, [id]); @@ -105,9 +109,23 @@ export default async function schedulesRoutes(fastify) { created_at: s.created_at, updated_at: s.updated_at, }; - if (s.source_name) { - result.source_name = s.source_name; + + // source 정보 추가 (YouTube: 2, X: 3) + if (s.category_id === 2 && s.youtube_video_id) { + const videoUrl = s.youtube_video_type === 'shorts' + ? `https://www.youtube.com/shorts/${s.youtube_video_id}` + : `https://www.youtube.com/watch?v=${s.youtube_video_id}`; + result.source = { + name: s.youtube_channel || 'YouTube', + url: videoUrl, + }; + } else if (s.category_id === 3 && s.x_post_id) { + result.source = { + name: 'X', + url: `https://x.com/realfromis_9/status/${s.x_post_id}`, + }; } + return result; }); } @@ -160,7 +178,7 @@ async function handleMonthlySchedules(db, year, month) { const startDate = `${year}-${String(month).padStart(2, '0')}-01`; const endDate = new Date(year, month, 0).toISOString().split('T')[0]; - // 일정 조회 + // 일정 조회 (YouTube, X 소스 정보 포함) const [schedules] = await db.query(` SELECT s.id, @@ -170,10 +188,14 @@ async function handleMonthlySchedules(db, year, month) { s.category_id, c.name as category_name, c.color as category_color, - sy.channel_name as source_name + sy.channel_name as youtube_channel, + sy.video_id as youtube_video_id, + sy.video_type as youtube_video_type, + sx.post_id as x_post_id 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_x sx ON s.id = sx.schedule_id WHERE s.date BETWEEN ? AND ? ORDER BY s.date ASC, s.time ASC `, [startDate, endDate]); @@ -213,9 +235,23 @@ async function handleMonthlySchedules(db, year, month) { color: s.category_color, }, }; - if (s.source_name) { - schedule.source_name = s.source_name; + + // source 정보 추가 (YouTube: 2, X: 3) + if (s.category_id === 2 && s.youtube_video_id) { + const videoUrl = s.youtube_video_type === 'shorts' + ? `https://www.youtube.com/shorts/${s.youtube_video_id}` + : `https://www.youtube.com/watch?v=${s.youtube_video_id}`; + schedule.source = { + name: s.youtube_channel || 'YouTube', + url: videoUrl, + }; + } else if (s.category_id === 3 && s.x_post_id) { + schedule.source = { + name: 'X', + url: `https://x.com/realfromis_9/status/${s.x_post_id}`, + }; } + grouped[dateKey].schedules.push(schedule); // 카테고리 카운트 diff --git a/backend/src/routes/stats/index.js b/backend/src/routes/stats/index.js index 599cdb2..72d526f 100644 --- a/backend/src/routes/stats/index.js +++ b/backend/src/routes/stats/index.js @@ -58,7 +58,7 @@ export default async function statsRoutes(fastify, opts) { // 트랙 수 const [[{ trackCount }]] = await db.query( - 'SELECT COUNT(*) as trackCount FROM tracks' + 'SELECT COUNT(*) as trackCount FROM album_tracks' ); return { diff --git a/docs/api.md b/docs/api.md index 18a793a..2c431eb 100644 --- a/docs/api.md +++ b/docs/api.md @@ -55,13 +55,21 @@ Base URL: `/api` "title": "...", "time": "19:00:00", "category": { "id": 2, "name": "유튜브", "color": "#ff0033" }, - "source_name": "fromis_9" + "source": { + "name": "fromis_9", + "url": "https://www.youtube.com/watch?v=VIDEO_ID" + } } ] } } ``` +**source 객체 (카테고리별):** +- YouTube (category_id=2): `{ name: "채널명", url: "https://www.youtube.com/..." }` +- X (category_id=3): `{ name: "X", url: "https://x.com/realfromis_9/status/..." }` +- 기타 카테고리: source 없음 + **검색 응답:** ```json { @@ -71,7 +79,7 @@ Base URL: `/api` "title": "...", "datetime": "2026-01-18T19:00:00", "category": { "id": 2, "name": "유튜브", "color": "#ff0033" }, - "source_name": "fromis_9", + "source": { "name": "fromis_9", "url": "https://..." }, "members": ["송하영"], "_rankingScore": 0.95 } diff --git a/docs/architecture.md b/docs/architecture.md index 94a83b6..0c05868 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -82,16 +82,48 @@ fromis_9/ ## 데이터베이스 -### 주요 테이블 -- `members` - 멤버 정보 +### 테이블 목록 (25개) + +#### 사용자/인증 +- `admin_users` - 관리자 계정 + +#### 멤버 +- `members` - 멤버 정보 (이름, 생년월일, 인스타그램 등) - `member_nicknames` - 멤버 별명 (검색용) -- `albums` - 앨범 정보 -- `schedules` - 일정 -- `schedule_categories` - 일정 카테고리 -- `schedule_youtube` - YouTube 영상 정보 -- `schedule_x` - X(Twitter) 게시물 정보 + +#### 앨범 +- `albums` - 앨범 정보 (제목, 발매일, 커버 이미지 등) +- `album_tracks` - 앨범 트랙 (곡명, 작사/작곡, 가사 등) +- `album_photos` - 앨범 컨셉 포토 +- `album_photo_members` - 컨셉 포토-멤버 연결 +- `album_teasers` - 앨범 티저 이미지/영상 + +#### 일정 +- `schedules` - 일정 (제목, 날짜, 시간 등) +- `schedule_categories` - 일정 카테고리 (유튜브, X, 콘서트 등) - `schedule_members` - 일정-멤버 연결 -- `images` - 이미지 메타데이터 +- `schedule_images` - 일정 첨부 이미지 +- `schedule_youtube` - YouTube 영상 연결 정보 +- `schedule_x` - X(Twitter) 게시물 연결 정보 +- `schedule_concert` - 콘서트 일정 추가 정보 + +#### 콘서트 +- `concert_venues` - 콘서트 장소 정보 +- `concert_series` - 콘서트 시리즈 (투어 등) +- `concert_series_md` - 콘서트 MD 상품 +- `concert_setlists` - 콘서트 셋리스트 +- `concert_setlist_members` - 셋리스트-멤버 연결 + +#### X(Twitter) 프로필 +- `x_profiles` - X 프로필 캐시 (프로필 이미지, 이름 등) + +#### 이미지 +- `images` - 이미지 메타데이터 (3개 해상도 URL) + +#### 추천 검색어 +- `suggestion_queries` - 검색 쿼리 로그 +- `suggestion_word_pairs` - 단어 bi-gram 빈도 +- `suggestion_chosung` - 초성 검색 매핑 ### 검색 인덱스 (Meilisearch) - `schedules` - 일정 검색용 인덱스 diff --git a/docs/development.md b/docs/development.md index 48ececf..ea97df0 100644 --- a/docs/development.md +++ b/docs/development.md @@ -93,17 +93,33 @@ YOUTUBE_API_KEY=... ## Caddy 설정 -`/docker/caddy/Caddyfile`: +위치: `/docker/caddy/Caddyfile` + +### fromis_9 사이트 설정 ```caddyfile fromis9.caadiq.co.kr { import custom_errors - request_body { - max_size 500MB - } reverse_proxy fromis9-frontend:80 } ``` +### 설정 설명 +- `import custom_errors`: 공통 에러 페이지 (403, 404, 500, 502, 503) +- `reverse_proxy fromis9-frontend:80`: Docker 네트워크로 프론트엔드 컨테이너에 연결 +- 업로드 크기 제한 없음 (Caddy 기본값) + +### Caddy 재시작 +```bash +docker exec caddy caddy reload --config /etc/caddy/Caddyfile +``` + +### 네트워크 구조 +``` +인터넷 → Caddy (:443) → fromis9-frontend (:80) → Fastify (:3000) + ↓ + MariaDB, Redis, Meilisearch (내부 네트워크) +``` + --- ## 유용한 명령어 diff --git a/docs/migration.md b/docs/migration.md index 03b68c3..91ded01 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -17,38 +17,117 @@ ### API 라우트 (`src/routes/`) - [x] 인증 (`/api/auth`) + - POST /login - 로그인 + - GET /me - 현재 사용자 정보 - [x] 멤버 (`/api/members`) + - GET / - 목록 조회 + - GET /:name - 상세 조회 + - PUT /:name - 수정 (이미지 업로드 포함) - [x] 앨범 (`/api/albums`) -- [x] 일정 (`/api/schedules`) - - 월별 조회 (생일 일정 포함) - - Meilisearch 검색 - - 별명 → 멤버이름 변환 - - 영문자판 → 한글 변환 + - GET / - 목록 조회 + - GET /:id - ID로 조회 + - GET /by-name/:name - 이름으로 조회 + - GET /by-name/:albumName/track/:trackTitle - 트랙 조회 + - POST / - 생성 + - PUT /:id - 수정 + - DELETE /:id - 삭제 + - 사진 관리 (`/api/albums/:id/photos`) + - GET / - 목록 + - POST / - 업로드 + - PUT /:photoId - 수정 + - DELETE /:photoId - 삭제 + - 티저 관리 (`/api/albums/:id/teasers`) + - GET / - 목록 + - POST / - 업로드 + - DELETE /:teaserId - 삭제 +- [x] 일정 (`/api/schedules`) - 조회만 + - GET / - 월별 조회 (생일 포함) + - GET /?search= - Meilisearch 검색 + - GET /:id - 상세 조회 + - POST /sync-search - Meilisearch 동기화 - [x] 추천 검색어 (`/api/schedules/suggestions`) + - GET / - 추천 검색어 조회 - kiwi-nlp 형태소 분석 - bi-gram 자동완성 + - 초성 검색 +- [x] 통계 (`/api/stats`) + - GET / - 대시보드 통계 ### 서비스 (`src/services/`) -- [x] YouTube 봇 - 영상 자동 수집 -- [x] X(Twitter) 봇 - Nitter 스크래핑 -- [x] Meilisearch 검색 -- [x] 추천 검색어 +- [x] YouTube 봇 (`services/youtube/`) + - 영상 자동 수집 + - 채널별 필터링 (제목 필터, 멤버 추출) +- [x] X(Twitter) 봇 (`services/x/`) + - Nitter 스크래핑 + - 이미지 URL 추출 +- [x] Meilisearch 검색 (`services/meilisearch/`) + - 일정 검색 + - 전체 동기화 +- [x] 추천 검색어 (`services/suggestions/`) + - 형태소 분석 + - bi-gram 빈도 +- [x] 이미지 업로드 (`services/image.js`) + - 앨범 커버 + - 멤버 이미지 + - 앨범 사진/티저 ## 남은 작업 -### 어드민 API -- [ ] 일정 CRUD (`POST/PUT/DELETE /api/schedules`) -- [ ] 이미지 업로드 (`/api/images`) -- [ ] 멤버 관리 (`POST/PUT/DELETE /api/members`) -- [ ] 앨범 관리 (`POST/PUT/DELETE /api/albums`) -- [ ] 카테고리 관리 (`/api/categories`) +### 관리자 API (admin.js에서 마이그레이션 필요) +- [ ] 일정 CRUD + - POST /api/schedules - 생성 + - PUT /api/schedules/:id - 수정 + - DELETE /api/schedules/:id - 삭제 +- [ ] 일정 카테고리 CRUD + - GET /api/schedule-categories - 목록 + - POST /api/schedule-categories - 생성 + - PUT /api/schedule-categories/:id - 수정 + - DELETE /api/schedule-categories/:id - 삭제 + - PUT /api/schedule-categories-order - 순서 변경 +- [ ] 봇 관리 API + - GET /api/bots - 봇 목록 + - POST /api/bots/:id/start - 봇 시작 + - POST /api/bots/:id/stop - 봇 정지 + - POST /api/bots/:id/sync-all - 전체 동기화 +- [ ] 카카오 장소 검색 프록시 + - GET /api/kakao/places - 장소 검색 +- [ ] YouTube 할당량 관리 + - POST /api/quota-alert - Webhook 수신 + - GET /api/quota-warning - 경고 상태 조회 + - DELETE /api/quota-warning - 경고 해제 -### 기타 -- [ ] 통계 API (`/api/stats`) -- [ ] 어드민 사전 관리 (형태소 분석용) +### 기타 기능 +- [ ] X 프로필 조회 (`/api/schedules/x-profile/:username`) +- [ ] 어드민 사전 관리 (형태소 분석용 사전) + +## 파일 비교표 + +| Express (backend-backup) | Fastify (backend) | 상태 | +|--------------------------|-------------------|------| +| routes/admin.js (로그인) | routes/auth.js | 완료 | +| routes/admin.js (앨범 CRUD) | routes/albums/index.js | 완료 | +| routes/admin.js (사진/티저) | routes/albums/photos.js, teasers.js | 완료 | +| routes/admin.js (멤버 수정) | routes/members/index.js | 완료 | +| routes/admin.js (일정 CRUD) | - | 미완료 | +| routes/admin.js (카테고리) | - | 미완료 | +| routes/admin.js (봇 관리) | - | 미완료 | +| routes/admin.js (카카오) | - | 미완료 | +| routes/admin.js (할당량) | - | 미완료 | +| routes/albums.js | routes/albums/index.js | 완료 | +| routes/members.js | routes/members/index.js | 완료 | +| routes/schedules.js | routes/schedules/index.js | 부분 완료 | +| routes/stats.js | routes/stats/index.js | 완료 | +| services/youtube-bot.js | services/youtube/ | 완료 | +| services/youtube-scheduler.js | plugins/scheduler.js | 완료 | +| services/x-bot.js | services/x/ | 완료 | +| services/meilisearch.js | services/meilisearch/ | 완료 | +| services/meilisearch-bot.js | services/meilisearch/ | 완료 | +| services/suggestions.js | services/suggestions/ | 완료 | ## 참고 사항 - 기존 Express 코드는 `backend-backup/` 폴더에 보존 - 마이그레이션 시 기존 코드 참조하여 동일 기능 구현 -- DB 스키마는 변경 없음 (기존 테이블 그대로 사용) +- DB 스키마 변경 사항: + - `tracks` → `album_tracks` (이름 변경) + - `venues` → `concert_venues` (이름 변경) diff --git a/frontend/src/pages/mobile/public/Schedule.jsx b/frontend/src/pages/mobile/public/Schedule.jsx index 6de6543..bf402f1 100644 --- a/frontend/src/pages/mobile/public/Schedule.jsx +++ b/frontend/src/pages/mobile/public/Schedule.jsx @@ -1001,10 +1001,10 @@ function ScheduleCard({ schedule, categoryColor, categories, delay = 0, onClick {/* 출처 */} - {schedule.source_name && ( + {schedule.source?.name && (