- Admin: EventEditForm 추가 (기존 포스터 유지 + 신규 추가 조합), ScheduleItem 편집 경로에 '행사' 분기 - PC 공개 상세: EventSection 추가 - 포스터 Swiper 슬라이드 + 호버 화살표, 클릭 시 Lightbox, 카카오맵 + 마커 + 장소명 오버레이, 관련 링크는 중간점+primary 색상, max-w-5xl 및 text-2xl로 크기 확대 - Mobile 공개 상세: MobileEventSection 추가 (포스터/장소/지도/링크) - KakaoMap 공용 컴포넌트 신규 (SDK 1회 로드 공유), VITE_KAKAO_JS_KEY 사용 - .gitignore: frontend/.env 제외 - routes/admin/events.js: PUT 핸들러의 addOrUpdateSchedule → syncScheduleById 정정 - 관련 문서(api/architecture/development) 업데이트 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
395 lines
12 KiB
Markdown
395 lines
12 KiB
Markdown
# 개발/배포 가이드
|
|
|
|
## 개발 모드
|
|
|
|
### 실행
|
|
```bash
|
|
cd /docker/fromis_9
|
|
docker compose up -d --build
|
|
```
|
|
|
|
### 컨테이너 구성
|
|
| 컨테이너 | 포트 | 설명 |
|
|
|---------|------|------|
|
|
| `fromis9-frontend` | 80 | Vite 개발 서버, HMR 지원 |
|
|
| `fromis9-backend` | 80 | Fastify API, --watch 모드 |
|
|
| `fromis9-meilisearch` | 7700 | 검색 엔진 |
|
|
| `fromis9-redis` | 6379 | 캐시 |
|
|
|
|
- Vite가 `/api`, `/docs` 요청을 백엔드로 프록시
|
|
|
|
### 로그 확인
|
|
```bash
|
|
# 전체 로그
|
|
docker compose logs -f
|
|
|
|
# 백엔드만
|
|
docker compose logs -f fromis9-backend
|
|
|
|
# 프론트엔드만
|
|
docker compose logs -f fromis9-frontend
|
|
```
|
|
|
|
### 코드 수정
|
|
- `frontend/`, `backend/` 폴더가 컨테이너에 마운트됨
|
|
- `node_modules`도 호스트 폴더에 직접 설치됨
|
|
- 코드 수정 시 자동 반영 (HMR, watch)
|
|
|
|
### 재시작
|
|
```bash
|
|
# 백엔드만 재시작
|
|
docker compose restart fromis9-backend
|
|
|
|
# 프론트엔드만 재시작
|
|
docker compose restart fromis9-frontend
|
|
|
|
# 전체 재시작
|
|
docker compose restart
|
|
```
|
|
|
|
---
|
|
|
|
## 배포 모드 전환
|
|
|
|
### 1. Dockerfile 수정
|
|
|
|
**backend/Dockerfile:**
|
|
```dockerfile
|
|
# 개발 모드 주석처리
|
|
# FROM node:20-alpine
|
|
# ...
|
|
|
|
# 배포 모드 주석해제
|
|
FROM node:20-alpine
|
|
WORKDIR /app
|
|
RUN apk add --no-cache ffmpeg
|
|
COPY package*.json ./
|
|
RUN npm install --production
|
|
COPY . .
|
|
EXPOSE 3000
|
|
CMD ["npm", "start"]
|
|
```
|
|
|
|
**frontend/Dockerfile:**
|
|
```dockerfile
|
|
# 개발 모드 주석처리
|
|
# FROM node:20-alpine
|
|
# ...
|
|
|
|
# 배포 모드 주석해제
|
|
FROM node:20-alpine AS builder
|
|
WORKDIR /app
|
|
COPY package*.json ./
|
|
RUN npm install
|
|
COPY . .
|
|
RUN npm run build
|
|
|
|
FROM nginx:alpine
|
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
|
EXPOSE 80
|
|
CMD ["nginx", "-g", "daemon off;"]
|
|
```
|
|
|
|
### 2. 빌드 및 실행
|
|
```bash
|
|
docker compose up -d --build
|
|
```
|
|
|
|
---
|
|
|
|
## 환경 변수 (.env)
|
|
|
|
```env
|
|
# 서버
|
|
PORT=80
|
|
|
|
# 데이터베이스
|
|
DB_HOST=mariadb
|
|
DB_PORT=3306
|
|
DB_USER=...
|
|
DB_PASSWORD=...
|
|
DB_NAME=fromis9
|
|
|
|
# Redis
|
|
REDIS_HOST=fromis9-redis
|
|
REDIS_PORT=6379
|
|
|
|
# Meilisearch
|
|
MEILI_HOST=http://fromis9-meilisearch:7700
|
|
MEILI_MASTER_KEY=...
|
|
|
|
# JWT
|
|
JWT_SECRET=...
|
|
|
|
# AWS S3
|
|
AWS_ACCESS_KEY_ID=...
|
|
AWS_SECRET_ACCESS_KEY=...
|
|
AWS_REGION=...
|
|
S3_BUCKET=...
|
|
|
|
# YouTube API
|
|
YOUTUBE_API_KEY=...
|
|
```
|
|
|
|
---
|
|
|
|
## Caddy 설정
|
|
|
|
위치: `/docker/caddy/Caddyfile`
|
|
|
|
### fromis_9 사이트 설정
|
|
```caddyfile
|
|
fromis9.caadiq.co.kr {
|
|
import custom_errors
|
|
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) → fromis9-backend (:80)
|
|
↓
|
|
MariaDB, Redis, Meilisearch (내부 네트워크)
|
|
```
|
|
|
|
---
|
|
|
|
## 프론트엔드 개발 가이드
|
|
|
|
### API 클라이언트 구조
|
|
|
|
```
|
|
src/api/
|
|
├── index.js # 전체 export
|
|
├── client.js # api, authApi 헬퍼 (에러 처리, 토큰 주입)
|
|
├── public/ # 공개 API (인증 불필요)
|
|
│ ├── albums.js # getAlbums, getAlbumByName, getTrack
|
|
│ ├── members.js # getMembers
|
|
│ └── schedules.js # getSchedules, getSchedule, getCategories
|
|
└── admin/ # 관리자 API (인증 필요)
|
|
├── auth.js # login, verifyToken
|
|
├── albums.js # createAlbum, updateAlbum, deleteAlbum, ...
|
|
├── bots.js # getBots, startBot, stopBot, syncBot, getXBot, createXBot, updateXBot, deleteXBot, lookupXProfile
|
|
├── categories.js # getCategories, createCategory, updateCategory, ...
|
|
├── members.js # updateMember
|
|
├── schedules.js # getYoutubeInfo, saveYoutube, getXInfo, saveX, ...
|
|
├── stats.js # getStats
|
|
└── suggestions.js # getDict, saveDict
|
|
```
|
|
|
|
**client.js 헬퍼:**
|
|
```jsx
|
|
// 공개 API 헬퍼 (인증 불필요)
|
|
import { api } from '@/api/client';
|
|
|
|
api.get('/albums');
|
|
api.post('/schedules/suggestions/save', { query: '검색어' });
|
|
|
|
// 인증 API 헬퍼 (토큰 자동 주입)
|
|
import { authApi } from '@/api/client';
|
|
|
|
authApi.get('/admin/stats');
|
|
authApi.post('/admin/schedules', data);
|
|
authApi.put('/admin/albums/1', data);
|
|
authApi.del('/admin/schedules/1');
|
|
```
|
|
|
|
**사용 예시:**
|
|
```jsx
|
|
// 공개 API
|
|
import { getSchedules, getSchedule } from '@/api/public/schedules';
|
|
|
|
// 관리자 API
|
|
import * as botsApi from '@/api/admin/bots';
|
|
```
|
|
|
|
### React Query 사용 (데이터 페칭)
|
|
|
|
데이터 페칭 시 `useEffect` 대신 `useQuery`를 사용합니다.
|
|
|
|
**이유:**
|
|
- `useEffect`는 React StrictMode에서 2번 실행됨 (개발 모드)
|
|
- `useQuery`는 자동 캐싱, 중복 요청 방지, 에러/로딩 상태 관리 제공
|
|
|
|
**예시:**
|
|
```jsx
|
|
// ❌ Bad - useEffect 사용
|
|
const [data, setData] = useState(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
fetch('/api/data')
|
|
.then(res => res.json())
|
|
.then(data => setData(data))
|
|
.finally(() => setLoading(false));
|
|
}, []);
|
|
|
|
// ✅ Good - useQuery 사용
|
|
import { useQuery } from '@tanstack/react-query';
|
|
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ['data'],
|
|
queryFn: () => fetch('/api/data').then(res => res.json()),
|
|
});
|
|
```
|
|
|
|
**캐시 무효화:**
|
|
```jsx
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
// 특정 쿼리 무효화
|
|
queryClient.invalidateQueries({ queryKey: ['schedules'] });
|
|
|
|
// 모든 쿼리 무효화
|
|
queryClient.invalidateQueries();
|
|
```
|
|
|
|
---
|
|
|
|
## YouTube 봇 동기화
|
|
|
|
### 동기화 흐름 (syncNewVideos)
|
|
1. `fetchRecentVideoIds()` — Activities API로 최근 영상 ID 목록만 조회 (1 unit)
|
|
2. DB에서 이미 존재하는 video_id 필터링
|
|
3. 새 영상만 `fetchVideoInfo()` — Videos API로 상세 정보 조회 (새 영상당 1 unit)
|
|
4. `saveVideo()` — DB 저장 + Meilisearch 동기화
|
|
|
|
### API 할당량
|
|
- 일일 할당량: 10,000 units
|
|
- 새 영상 없을 때: activities.list 1 unit만 소비
|
|
- 새 영상 있을 때: 1 + 새 영상 수 units
|
|
- 1분 간격, 3채널 기준: ~4,320 units/일 (43%)
|
|
|
|
### 폴링 모드 (bot_youtube)
|
|
|
|
두 가지 모드 중 하나를 선택 — 봇 레코드에 `cron_interval`(분) 또는 `weekly_schedule_config`(JSON) 중 하나가 채워짐.
|
|
|
|
**상시 폴링 (기본)**
|
|
- `cron_interval`이 분 단위로 지정됨. cron: `*/N * * * *`
|
|
- 매주 여러 날 업로드하는 채널에 적합 (예: `studio_fromis_9`)
|
|
|
|
**주간 지정 시간 (weekly)**
|
|
- `weekly_schedule_config: { dayOfWeek, startTime, intervalSeconds, durationMinutes }`
|
|
- 주 1회만 특정 요일·시각에 업로드되는 채널용 (예: 워크맨 매주 수 19:00)
|
|
- cron: `mm hh * * dayOfWeek` — 시작 시각 1회만 트리거
|
|
- 트리거 시 `startWeeklyBurst()`가 `setInterval`로 `intervalSeconds`마다 폴링
|
|
- **종료 조건** (둘 중 먼저):
|
|
1. 새 영상 1개 발견 (stopOnFound, 기본 동작)
|
|
2. `durationMinutes` 경과
|
|
- 평상시에는 API 호출 없음 → 할당량 최소화
|
|
- `burstTimers` Map에서 봇 ID별 내부 타이머 추적, `stopBot()`에서 같이 정리
|
|
|
|
두 모드 모두 `MAX_CONSECUTIVE_ERRORS` (기본 10회) 자동 정지 로직이 공통 적용됨.
|
|
|
|
### 주요 API 함수 (services/youtube/api.js)
|
|
| 함수 | YouTube API | 용도 |
|
|
|------|-----------|------|
|
|
| `fetchRecentVideoIds()` | activities.list (1 unit) | 최근 영상 ID 목록 조회 |
|
|
| `fetchVideoInfo()` | videos.list (1 unit) | 단일 영상 상세 정보 |
|
|
| `fetchAllVideos()` | playlistItems.list + videos.list | 전체 영상 초기 동기화 |
|
|
| `getChannelByHandle()` | channels.list (1 unit) | 핸들로 채널 조회 |
|
|
| `getChannelInfo()` | channels.list (1 unit) | 채널 정보 (배너 등) |
|
|
|
|
---
|
|
|
|
## 행사 (Event)
|
|
|
|
`schedule_categories`의 "행사" 카테고리(id=11)로 일반 일정과 분리된 상세 테이블(`schedule_event`)을 가짐. 세부 타입(`subtype`)으로 폼/UI를 분기.
|
|
|
|
### 세부 타입
|
|
| slug | label | 현재 사용 필드 |
|
|
|------|-------|---------------|
|
|
| `university` | 학교 축제 | `school_name`, venue(카카오맵), 멤버, 포스터 다중, URL 다중 |
|
|
|
|
추가 세부 타입을 도입할 때는 1) `frontend/src/pages/pc/admin/schedules/form/event/index.jsx` 의 `SUBTYPES` 상수에 추가, 2) 필요 시 `schedule_event` 컬럼 확장 (또는 `details JSON`), 3) `routes/admin/events.js`의 `VALID_SUBTYPES`, 4) 상세 페이지 섹션(`EventSection`, `MobileEventSection`)에 분기 추가.
|
|
|
|
### 장소 관리
|
|
- `event_venues` 테이블에 `name`/`address`/`road_address`/`lat`/`lng`/`kakao_id` 저장
|
|
- 카카오맵 검색은 기존 `/api/admin/kakao/places` 엔드포인트 재사용 (콘서트와 동일)
|
|
- `kakao_id` 기준 upsert — 같은 장소가 여러 행사에서 쓰여도 row는 1개
|
|
|
|
### 포스터 업로드 경로
|
|
S3: `event/{scheduleId}/poster/{original|medium_800|thumb_400}/{파일명}`
|
|
`services/image.js` 의 `uploadEventPoster(scheduleId, filename, buffer)` 사용.
|
|
|
|
### Meilisearch 검색 지원
|
|
- `source_name`에 `school_name`이 들어가 Meilisearch 검색 가능
|
|
- 부분 입력 대응: `resolveSchoolNames(db, query)` 가 `schedule_event` 테이블에서 LIKE로 부분 일치 학교명을 찾아 검색 쿼리를 확장 (예: "인천대" → "인천대학교" 쿼리 추가). 멤버 별명 확장과 동일한 패턴.
|
|
|
|
---
|
|
|
|
## X 봇 / Nitter
|
|
|
|
X 봇은 `/docker/nitter/`의 Nitter 인스턴스(zedeus/nitter)를 스크래핑하여 트윗을 수집합니다. 백엔드는 `NITTER_URL`(기본값 `http://nitter:8080`)로 접속합니다.
|
|
|
|
### 세션 관리 (`sessions.jsonl`)
|
|
X는 비로그인 API 접근을 막고 있어, Nitter는 `/docker/nitter/sessions.jsonl`에 저장된 실제 X 계정 쿠키(`auth_token`, `ct0`)로 요청을 보냅니다.
|
|
|
|
- 세션이 만료/차단되면 Nitter 측에서 `no sessions available for API` 로그가 찍히고 SIGSEGV로 크래시 → 백엔드에서 `[x-N] 동기화 오류: 요청 타임아웃` 반복 (단, 연속 10회 실패 시 자동 정지 — `logs.md` 참조)
|
|
- `renew_sessions.py`가 매시 세션을 점검하지만, 판별 기준(`check_nitter()`)이 약하면 만료 상태에서도 "정상"으로 오판할 수 있음 → 기준은 트윗 본문(`tweet-content` 블록) 렌더 여부로 유지할 것
|
|
- 수동 갱신: `python3 /docker/nitter/create_session_curl.py <username> <password>` 로 새 쿠키 발급 후 `sessions.jsonl` 두 줄을 덮어쓰고 `docker compose restart nitter` 실행
|
|
|
|
### 포크 관련 메모
|
|
`unixfox/nitter` 같은 구버전 기반 포크는 sessions.jsonl을 아예 인식하지 못해 트윗 수집이 불가능합니다. 교체 시에는 바이너리에 sessions 처리 심볼이 있는지 확인할 것(예: `strings nitter | grep sessions.jsonl`).
|
|
|
|
---
|
|
|
|
## 활동 로그 시스템
|
|
|
|
관리자/봇의 모든 활동을 `logs` 테이블에 기록하고 관리자 페이지에서 조회.
|
|
|
|
### 로그 기록 방법
|
|
|
|
```js
|
|
import { logActivity } from '../utils/log.js';
|
|
|
|
// fire-and-forget: 로그 실패가 비즈니스 로직에 영향 주지 않음
|
|
logActivity(db, {
|
|
actor: 'admin', // "admin" 또는 봇 ID ("youtube-3", "x-1")
|
|
action: 'create', // create, update, delete, upload, start, stop, sync_complete, error
|
|
category: 'album', // album, schedule, member, bot, category, dict, concert, sync
|
|
targetType: 'album', // 대상 타입 (optional)
|
|
targetId: 12, // 대상 DB ID (optional)
|
|
summary: '앨범 생성: 제목', // 한 줄 요약
|
|
details: { key: 'value' }, // 추가 정보 JSON (optional)
|
|
});
|
|
```
|
|
|
|
### 새 기능 추가 시
|
|
로그는 자동 수집이 아니므로, 새로운 라우트나 기능을 추가할 때 `logActivity` 호출을 직접 넣어야 합니다.
|
|
|
|
### 로그 대상
|
|
- **관리자 라우트**: 앨범/일정/멤버/봇/카테고리/사전/콘서트 CRUD
|
|
- **봇 스케줄러**: 동기화 완료(addedCount > 0), 동기화 에러
|
|
- **봇 서비스**: YouTube 영상 추가, X 트윗 추가
|
|
|
|
---
|
|
|
|
## 유용한 명령어
|
|
|
|
```bash
|
|
# 컨테이너 상태 확인
|
|
docker compose ps
|
|
|
|
# 완전 재시작
|
|
docker compose down && docker compose up -d --build
|
|
|
|
# Meilisearch 동기화
|
|
curl -X POST https://fromis9.caadiq.co.kr/api/schedules/sync-search \
|
|
-H "Authorization: Bearer <token>"
|
|
|
|
# Redis 확인 (SCAN 사용 권장)
|
|
docker exec fromis9-redis redis-cli SCAN 0 MATCH "*" COUNT 100
|
|
```
|