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:
parent
f762302689
commit
d4697ad996
19 changed files with 274 additions and 348 deletions
|
|
@ -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) - 마이그레이션 현황 및 남은 작업
|
||||
|
|
|
|||
|
|
@ -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으로 기존 방식 유지 |
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
// 카테고리 카운트
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
12
docs/api.md
12
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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` - 일정 검색용 인덱스
|
||||
|
|
|
|||
|
|
@ -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 (내부 네트워크)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 유용한 명령어
|
||||
|
|
|
|||
|
|
@ -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` (이름 변경)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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 || "",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue