refactor: 일정 API source 객체 구조 변경

- source_name, source_url → source: { name, url } 형태로 변경
- YouTube: schedule_youtube에서 video_id로 URL 생성
- X: schedule_x에서 post_id로 URL 생성
- 프론트엔드 전체 파일 source 객체 형태로 수정
- 문서 업데이트 (api.md, architecture.md, migration.md 등)
- tracks → album_tracks 테이블명 변경 반영

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-18 21:50:04 +09:00
parent f762302689
commit d4697ad996
19 changed files with 274 additions and 348 deletions

View file

@ -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) - 마이그레이션 현황 및 남은 작업

View file

@ -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으로 기존 방식 유지 |

View file

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

View file

@ -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);
// 카테고리 카운트

View file

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

View file

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

View file

@ -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` - 일정 검색용 인덱스

View file

@ -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 (내부 네트워크)
```
---
## 유용한 명령어

View file

@ -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` (이름 변경)

View file

@ -1001,10 +1001,10 @@ function ScheduleCard({ schedule, categoryColor, categories, delay = 0, onClick
</h3>
{/* 출처 */}
{schedule.source_name && (
{schedule.source?.name && (
<div className="flex items-center gap-1 mt-1.5 text-xs text-gray-400">
<Link2 size={11} />
<span>{schedule.source_name}</span>
<span>{schedule.source?.name}</span>
</div>
)}
@ -1080,10 +1080,10 @@ function TimelineScheduleCard({ schedule, categoryColor, categories, delay = 0,
</h3>
{/* 출처 */}
{schedule.source_name && (
{schedule.source?.name && (
<div className="flex items-center gap-1 mt-1.5 text-xs text-gray-400">
<Link2 size={11} />
<span>{schedule.source_name}</span>
<span>{schedule.source?.name}</span>
</div>
)}

View file

@ -197,8 +197,8 @@ const formatXDateTime = (dateStr, timeStr) => {
//
function YoutubeSection({ schedule }) {
const videoId = extractYoutubeVideoId(schedule.source_url);
const isShorts = schedule.source_url?.includes('/shorts/');
const videoId = extractYoutubeVideoId(schedule.source?.url);
const isShorts = schedule.source?.url?.includes('/shorts/');
// ( )
useFullscreenOrientation(isShorts);
@ -254,10 +254,10 @@ function YoutubeSection({ schedule }) {
<span>{formatTime(schedule.time)}</span>
</div>
)}
{schedule.source_name && (
{schedule.source?.name && (
<div className="flex items-center gap-1">
<Link2 size={12} />
<span>{schedule.source_name}</span>
<span>{schedule.source?.name}</span>
</div>
)}
</div>
@ -284,7 +284,7 @@ function YoutubeSection({ schedule }) {
{/* 유튜브에서 보기 버튼 */}
<a
href={schedule.source_url}
href={schedule.source?.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 w-full py-3 bg-red-500 active:bg-red-600 text-white rounded-xl font-medium transition-colors"
@ -301,7 +301,7 @@ function YoutubeSection({ schedule }) {
// X()
function XSection({ schedule }) {
const username = extractXUsername(schedule.source_url);
const username = extractXUsername(schedule.source?.url);
//
const { data: profile } = useQuery({
@ -311,7 +311,7 @@ function XSection({ schedule }) {
staleTime: 1000 * 60 * 60, // 1
});
const displayName = profile?.displayName || schedule.source_name || username || 'Unknown';
const displayName = profile?.displayName || schedule.source?.name || username || 'Unknown';
const avatarUrl = profile?.avatarUrl;
return (
@ -382,7 +382,7 @@ function XSection({ schedule }) {
{/* X에서 보기 버튼 */}
<div className="px-4 py-3 border-t border-gray-100 bg-gray-50/50">
<a
href={schedule.source_url}
href={schedule.source?.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 w-full py-2.5 bg-gray-900 active:bg-black text-white rounded-full font-medium transition-colors"
@ -418,7 +418,7 @@ function ConcertSection({ schedule }) {
locationLat: schedule.location_lat,
locationLng: schedule.location_lng,
description: schedule.description,
sourceUrl: schedule.source_url,
sourceUrl: schedule.source?.url,
});
//
@ -447,7 +447,7 @@ function ConcertSection({ schedule }) {
if (prev.locationLat !== newData.location_lat) updates.locationLat = newData.location_lat;
if (prev.locationLng !== newData.location_lng) updates.locationLng = newData.location_lng;
if (prev.description !== newData.description) updates.description = newData.description;
if (prev.sourceUrl !== newData.source_url) updates.sourceUrl = newData.source_url;
if (prev.sourceUrl !== newData.source?.url) updates.sourceUrl = newData.source?.url;
//
if (Object.keys(updates).length > 0) {

View file

@ -80,10 +80,10 @@ const ScheduleItem = memo(function ScheduleItem({
<Tag size={14} />
{categoryName}
</span>
{schedule.source_name && (
{schedule.source?.name && (
<span className="flex items-center gap-1">
<Link2 size={14} />
{schedule.source_name}
{schedule.source?.name}
</span>
)}
</div>
@ -107,9 +107,9 @@ const ScheduleItem = memo(function ScheduleItem({
{/* 생일 일정은 수정/삭제 불가 */}
{!isBirthday && (
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
{schedule.source_url && (
{schedule.source?.url && (
<a
href={schedule.source_url}
href={schedule.source?.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
@ -1268,10 +1268,10 @@ function AdminSchedule() {
<Tag size={14} />
{categories.find(c => c.id === schedule.category_id)?.name || '미분류'}
</span>
{schedule.source_name && (
{schedule.source?.name && (
<span className="flex items-center gap-1">
<Link2 size={14} />
{schedule.source_name}
{schedule.source?.name}
</span>
)}
</div>
@ -1293,9 +1293,9 @@ function AdminSchedule() {
</div>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
{schedule.source_url && (
{schedule.source?.url && (
<a
href={schedule.source_url}
href={schedule.source?.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}

View file

@ -174,8 +174,8 @@ function AdminScheduleForm() {
isRange: !!data.end_date,
category: data.category_id || "",
description: data.description || "",
url: data.source_url || "",
sourceName: data.source_name || "",
url: data.source?.url || "",
sourceName: data.source?.name || "",
members: data.members?.map((m) => m.id) || [],
images: [],
locationName: data.location_name || "",
@ -198,8 +198,8 @@ function AdminScheduleForm() {
endTime: data.end_time?.slice(0, 5) || "",
category: data.category_id || 1,
description: data.description || "",
url: data.source_url || "",
sourceName: data.source_name || "",
url: data.source?.url || "",
sourceName: data.source?.name || "",
members: data.members?.map((m) => m.id) || [],
images: data.images.map(img => ({ id: img.id, url: img.image_url })),
locationName: data.location_name || "",

View file

@ -316,13 +316,13 @@ function Home() {
<span>{schedule.category_name}</span>
</div>
)}
{schedule.source_name && (
{schedule.source?.name && (
<div className="flex items-center gap-1">
<Link2
size={14}
className="text-primary opacity-60"
/>
<span>{schedule.source_name}</span>
<span>{schedule.source?.name}</span>
</div>
)}
</div>

View file

@ -553,8 +553,8 @@ function Schedule() {
}
// URL
if (!schedule.description && schedule.source_url) {
window.open(schedule.source_url, '_blank');
if (!schedule.description && schedule.source?.url) {
window.open(schedule.source?.url, '_blank');
} else {
//
navigate(`/schedule/${schedule.id}`);
@ -1212,10 +1212,10 @@ function Schedule() {
<Tag size={16} className="opacity-60" />
{categoryName}
</span>
{schedule.source_name && (
{schedule.source?.name && (
<span className="flex items-center gap-1">
<Link2 size={16} className="opacity-60" />
{schedule.source_name}
{schedule.source?.name}
</span>
)}
</div>
@ -1307,10 +1307,10 @@ function Schedule() {
<Tag size={16} className="opacity-60" />
{categoryName}
</span>
{schedule.source_name && (
{schedule.source?.name && (
<span className="flex items-center gap-1">
<Link2 size={16} className="opacity-60" />
{schedule.source_name}
{schedule.source?.name}
</span>
)}
</div>

View file

@ -25,7 +25,7 @@ function ConcertSection({ schedule }) {
locationLat: schedule.location_lat,
locationLng: schedule.location_lng,
description: schedule.description,
sourceUrl: schedule.source_url,
sourceUrl: schedule.source?.url,
});
//
@ -54,7 +54,7 @@ function ConcertSection({ schedule }) {
if (prev.locationLat !== newData.location_lat) updates.locationLat = newData.location_lat;
if (prev.locationLng !== newData.location_lng) updates.locationLng = newData.location_lng;
if (prev.description !== newData.description) updates.description = newData.description;
if (prev.sourceUrl !== newData.source_url) updates.sourceUrl = newData.source_url;
if (prev.sourceUrl !== newData.source?.url) updates.sourceUrl = newData.source?.url;
if (Object.keys(updates).length > 0) {
return { ...prev, ...updates };

View file

@ -34,9 +34,9 @@ function DefaultSection({ schedule }) {
)}
{/* 원본 링크 */}
{schedule.source_url && (
{schedule.source?.url && (
<a
href={schedule.source_url}
href={schedule.source?.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-5 py-2.5 bg-gray-900 hover:bg-gray-800 text-white rounded-xl font-medium transition-colors"

View file

@ -12,7 +12,7 @@ const extractXUsername = (url) => {
// X()
function XSection({ schedule }) {
const username = extractXUsername(schedule.source_url);
const username = extractXUsername(schedule.source?.url);
//
const { data: profile } = useQuery({
@ -22,7 +22,7 @@ function XSection({ schedule }) {
staleTime: 1000 * 60 * 60, // 1
});
const displayName = profile?.displayName || schedule.source_name || username || 'Unknown';
const displayName = profile?.displayName || schedule.source?.name || username || 'Unknown';
const avatarUrl = profile?.avatarUrl;
return (
@ -95,7 +95,7 @@ function XSection({ schedule }) {
{/* X에서 보기 버튼 */}
<div className="px-5 py-4 border-t border-gray-100 bg-gray-50/50">
<a
href={schedule.source_url}
href={schedule.source?.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-5 py-2.5 bg-gray-900 hover:bg-black text-white rounded-full font-medium transition-colors"

View file

@ -34,12 +34,12 @@ function VideoInfo({ schedule, isShorts }) {
)}
{/* 채널명 */}
{schedule.source_name && (
{schedule.source?.name && (
<>
<div className="w-px h-4 bg-gray-300" />
<div className="flex items-center gap-2 text-gray-500">
<Link2 size={14} className="opacity-60" />
<span className="font-medium">{schedule.source_name}</span>
<span className="font-medium">{schedule.source.name}</span>
</div>
</>
)}
@ -68,7 +68,7 @@ function VideoInfo({ schedule, isShorts }) {
{/* 유튜브에서 보기 버튼 */}
<div className="mt-6 pt-5 border-t border-gray-200">
<a
href={schedule.source_url}
href={schedule.source?.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-5 py-2.5 bg-red-500 hover:bg-red-600 text-white rounded-xl font-medium transition-colors shadow-lg shadow-red-500/20"
@ -97,8 +97,8 @@ const extractYoutubeVideoId = (url) => {
//
function YoutubeSection({ schedule }) {
const videoId = extractYoutubeVideoId(schedule.source_url);
const isShorts = schedule.source_url?.includes('/shorts/');
const videoId = extractYoutubeVideoId(schedule.source?.url);
const isShorts = schedule.source?.url?.includes('/shorts/');
if (!videoId) return null;