# API 명세

Base URL: `/api`

## 인증

### POST /auth/login
로그인 (JWT 토큰 발급)

**Rate Limit:** 1분당 5회 (IP 기준)

### GET /auth/verify
토큰 검증 및 사용자 정보 (인증 필요)

---

## 멤버

### GET /members
멤버 목록 조회

### GET /members/:name
멤버 상세 조회

**Parameters:**
- `name` - 멤버 이름 (한글 또는 영문, 대소문자 무관)

**예시:**
- `/members/박지원` - 한글명으로 조회
- `/members/jiwon` - 영문명으로 조회

---

## 앨범

### GET /albums
앨범 목록 조회

### GET /albums/:id
앨범 상세 조회

---

## 일정

### GET /schedules
일정 조회

**Query Parameters:**
- `year`, `month` - 월별 조회
- `startDate` - 시작 날짜 (YYYY-MM-DD), 다가오는 일정 조회
- `search` - 검색어 (Meilisearch 사용)
- `offset`, `limit` - 페이징

※ `search`, `startDate`, `year/month` 중 하나는 필수

**월별 조회 응답:**
```json
{
  "schedules": [
    {
      "id": 123,
      "title": "...",
      "date": "2026-01-18",
      "time": "19:00:00",
      "category": { "id": 2, "name": "유튜브", "color": "#ff0033" },
      "source": {
        "name": "fromis_9",
        "url": "https://www.youtube.com/watch?v=VIDEO_ID"
      },
      "members": ["송하영"]
    }
  ]
}
```

**특수 일정 ID 형식:**
- 생일: `birthday-{year}-{nameEn}` (예: `birthday-2026-jiwon`)
- 데뷔: `debut-{year}` (예: `debut-2018`)
- 주년: `anniversary-{year}` (예: `anniversary-2026`)
※ `time`: 시간이 없는 일정은 `null`, 00:00 시간은 `"00:00:00"`으로 반환
```

**source 객체 (카테고리별):**
- YouTube (category_id=2): `{ name: "채널명", url: "https://www.youtube.com/..." }`
- X (category_id=3): `{ name: "", url: "https://x.com/realfromis_9/status/..." }` (name 빈 문자열)
- 기타 카테고리: source 없음

**다가오는 일정 응답 (startDate):**
```json
{
  "schedules": [
    {
      "id": 123,
      "title": "...",
      "date": "2026-01-18",
      "time": "19:00:00",
      "category": { "id": 2, "name": "유튜브", "color": "#ff0033" },
      "source": { "name": "fromis_9", "url": "https://..." },
      "members": ["송하영"]
    }
  ]
}
```
※ 현재 활동 멤버 전원인 경우 `["프로미스나인"]` 반환 (탈퇴 멤버 제외)
※ `time`: 시간이 없는 일정은 `null`, 00:00 시간은 `"00:00:00"`으로 반환

**검색 응답:**
```json
{
  "schedules": [
    {
      "id": 123,
      "title": "...",
      "date": "2026-01-18",
      "time": "19:00:00",
      "category": { "id": 2, "name": "유튜브", "color": "#ff0033" },
      "source": { "name": "fromis_9", "url": "https://..." },
      "members": ["송하영"],
      "_rankingScore": 0.95
    }
  ],
  "total": 100,
  "offset": 0,
  "limit": 20,
  "hasMore": true
}
```
※ `time`: 시간이 없는 일정은 `null`, 00:00 시간은 `"00:00:00"`으로 반환
```

### GET /schedules/categories
카테고리 목록 조회

**응답:**
```json
[
  { "id": 1, "name": "기타", "color": "#gray", "sort_order": 0 },
  { "id": 2, "name": "유튜브", "color": "#ff0033", "sort_order": 1 }
]
```

### GET /schedules/:id
일정 상세 조회

### DELETE /schedules/:id
일정 삭제 (인증 필요)

### POST /schedules/sync-search
Meilisearch 전체 동기화 (인증 필요)

---

## 추천 검색어

### GET /schedules/suggestions
추천 검색어 조회

**Query Parameters:**
- `q` - 검색어 (2자 이상)
- `limit` - 결과 개수 (기본 10)

**응답:**
```json
{
  "suggestions": ["송하영", "송하영 직캠", "하영"]
}
```

### GET /schedules/suggestions/popular
인기 검색어 조회

**Query Parameters:**
- `limit` - 결과 개수 (기본 10)

**응답:**
```json
{
  "queries": ["프로미스나인", "송하영", "이서연"]
}
```

### POST /schedules/suggestions/save
검색어 저장 (검색 실행 시 호출)

**Request Body:**
```json
{
  "query": "검색어"
}
```

### GET /schedules/suggestions/dict
사용자 사전 조회 (인증 필요)

**응답:**
```json
{
  "content": "프로미스나인\t프로미스나인\tNNP\n..."
}
```

### PUT /schedules/suggestions/dict
사용자 사전 저장 (인증 필요)

**Request Body:**
```json
{
  "content": "프로미스나인\t프로미스나인\tNNP\n..."
}
```

---

## 관리자 - 봇 관리 (인증 필요)

### GET /admin/bots
봇 목록 조회

**응답:**
```json
[
  {
    "id": "youtube-fromis9",
    "name": "fromis_9",
    "type": "youtube",
    "status": "running",
    "last_check_at": "2026-01-18T19:30:00+09:00",
    "last_added_count": 2,
    "last_sync_duration": 1234,
    "schedules_added": 150,
    "check_interval": 2,
    "error_message": null,
    "enabled": true
  },
  {
    "id": "meilisearch-sync",
    "name": "Meilisearch 동기화",
    "type": "meilisearch",
    "status": "running",
    "last_check_at": "2026-01-18T04:00:00+09:00",
    "last_added_count": 500,
    "last_sync_duration": 2500,
    "schedules_added": 500,
    "check_interval": 0,
    "error_message": null,
    "enabled": true,
    "version": "1.6.0"
  }
]
```

**필드 설명:**
- `type`: `youtube` | `x` | `festival` | `meilisearch`
- `last_check_at`: 마지막 동기화 시간 (KST, +09:00)
- `last_sync_duration`: 마지막 동기화 소요 시간 (ms)
- `version`: Meilisearch 버전 (meilisearch 타입만)

### POST /admin/bots/:id/start
봇 시작

### POST /admin/bots/:id/stop
봇 정지

### POST /admin/bots/:id/sync-all
전체 동기화 (모든 영상/트윗 수집)

**응답:**
```json
{
  "success": true,
  "addedCount": 25,
  "total": 100
}
```

### GET /admin/bots/quota-warning
YouTube API 할당량 경고 조회

**응답:**
```json
{
  "active": true,
  "message": "YouTube API 할당량 초과",
  "timestamp": "2026-01-18T19:00:00+09:00"
}
```

### DELETE /admin/bots/quota-warning
할당량 경고 해제

---

## 관리자 - YouTube 봇 (인증 필요)

### POST /admin/youtube-bots/lookup
채널 핸들로 채널 정보 조회

**Request Body:**
```json
{
  "handle": "@studiofromis_9"
}
```

**응답:**
```json
{
  "channelId": "UCxxx",
  "title": "채널명",
  "thumbnailUrl": "https://...",
  "bannerUrl": "https://..."
}
```

### GET /admin/youtube-bots
YouTube 봇 목록 조회

### GET /admin/youtube-bots/:id
YouTube 봇 상세 조회

### POST /admin/youtube-bots
YouTube 봇 추가

**Request Body:**
```json
{
  "channel_id": "UCxxx",
  "channel_handle": "@studiofromis_9",
  "channel_name": "채널명",
  "cron_interval": 2,
  "title_filters": ["fromis_9", "프로미스나인"],
  "default_member_ids": [1, 2],
  "extract_members_from_desc": true,
  "extract_members_from_title": false,
  "auto_schedule_config": {
    "dayOfWeek": 4,
    "time": "18:00:00",
    "titleTemplate": "{channelName} {episode}화",
    "deadlineDayOfWeek": 5
  },
  "weekly_schedule_config": {
    "dayOfWeek": 3,
    "startTime": "19:00",
    "intervalSeconds": 30,
    "durationMinutes": 30
  }
}
```

**폴링 방식:**
- `cron_interval` (분): 상시 폴링. `weekly_schedule_config`가 null이면 이 값 사용
- `weekly_schedule_config`: 지정 요일/시각에만 집중 폴링. 값이 있으면 `cron_interval`은 무시(서버에서 null로 저장). 새 영상 1개 발견 시 즉시 종료(stopOnFound 기본), `durationMinutes` 초과 시에도 종료

### PUT /admin/youtube-bots/:id
YouTube 봇 수정

### DELETE /admin/youtube-bots/:id
YouTube 봇 삭제

---

## 관리자 - X 봇 (인증 필요)

### POST /admin/x-bots/lookup
X username으로 프로필 정보 조회 (Nitter 사용)

**Request Body:**
```json
{
  "username": "realfromis_9"
}
```

**응답:**
```json
{
  "username": "realfromis_9",
  "displayName": "프로미스나인 (fromis_9)",
  "avatarUrl": "https://..."
}
```

### GET /admin/x-bots
X 봇 목록 조회

**응답:** `XBot[]`

### GET /admin/x-bots/:id
X 봇 상세 조회

**응답:**
```json
{
  "id": 1,
  "username": "realfromis_9",
  "display_name": "프로미스나인 (fromis_9)",
  "avatar_url": "https://...",
  "text_filters": ["fromis", "프로미스"],
  "include_retweets": false,
  "extract_youtube": true,
  "cron_interval": 1,
  "enabled": true
}
```

### POST /admin/x-bots
X 봇 추가

**Request Body:**
```json
{
  "username": "realfromis_9",
  "display_name": "프로미스나인 (fromis_9)",
  "avatar_url": "https://...",
  "text_filters": ["fromis"],
  "include_retweets": false,
  "extract_youtube": false,
  "cron_interval": 1
}
```

| 필드 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `username` | string | (필수) | X username (@ 없이) |
| `display_name` | string\|null | null | 표시 이름 |
| `avatar_url` | string\|null | null | 프로필 이미지 URL |
| `text_filters` | string[]\|null | null | 텍스트 필터 (하나라도 포함 시 추가, 비어있으면 모든 트윗) |
| `include_retweets` | boolean | false | 리트윗 포함 여부 |
| `extract_youtube` | boolean | false | 트윗 내 YouTube 링크 자동 추출하여 유튜브 일정 추가 |
| `exclude_managed_channels` | boolean | true | `extract_youtube`가 true일 때, 등록된 YouTube 봇 채널의 영상은 중복 추가에서 제외 |
| `cron_interval` | integer | 1 | 동기화 간격 (분) |

### PUT /admin/x-bots/:id
X 봇 수정 (부분 업데이트 가능)

### DELETE /admin/x-bots/:id
X 봇 삭제

---

## 관리자 - 축제 봇 (인증 필요)

대학 축제 크롤러 봇. 검색 페이지(memogipost 등)를 크롤링하여 프로미스나인 출연 대학 축제를
Gemini `url_context`로 추출, 행사(EVENT) 일정을 자동 생성한다.

- 이미 처리한 게시글 URL은 `festival_crawl_log`에 기록되어 재요청하지 않음
- 새 게시글이 없으면 Gemini를 호출하지 않음 (무료 티어 RPD 20 제한 대응)
- 장소는 카카오맵 검색으로 자동 매칭, 멤버는 활동 멤버 전체로 등록
- 같은 학교(캠퍼스명 부분일치) + 날짜 일정이 있으면 중복으로 건너뜀
- `active_months`로 실행 월을 제한 가능 (대학 축제는 학기 중 한정 시즌). 비활성 월에는
  크롤링/Gemini 호출을 아예 건너뜀. `null` 또는 12개월 전체 = 항상 실행

### GET /admin/festival-bots
축제 봇 목록 조회

**응답:** `FestivalBot[]`

### GET /admin/festival-bots/:id
축제 봇 상세 조회

**응답:**
```json
{
  "id": 1,
  "name": "대학 축제 봇",
  "search_url": "https://memogipost.tistory.com/search/프로미스나인",
  "cron_interval": 360,
  "active_months": [4, 5, 8, 9],
  "enabled": true
}
```

`active_months`는 정수 배열(`null`이면 전체 월). 위 예시는 4·5·8·9월에만 실행.

### POST /admin/festival-bots
축제 봇 추가

**Request Body:**
```json
{
  "name": "대학 축제 봇",
  "search_url": "https://memogipost.tistory.com/search/프로미스나인",
  "cron_interval": 360,
  "active_months": [4, 5, 8, 9]
}
```

| 필드 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `name` | string | (필수) | 봇 이름 |
| `search_url` | string | (필수) | 크롤링할 검색 페이지 URL |
| `cron_interval` | integer | 360 | 동기화 간격 (분). 60 이상은 시간 단위 cron으로 변환 |
| `active_months` | integer[] \| null | null | 실행할 월(1~12). `null`/빈 배열/12개 전체는 항상 실행 |

### PUT /admin/festival-bots/:id
축제 봇 수정 (부분 업데이트 가능)

### DELETE /admin/festival-bots/:id
축제 봇 삭제

---

## 관리자 - YouTube (인증 필요)

### GET /admin/youtube/video-info
YouTube 영상 정보 조회

**Query Parameters:**
- `url` - YouTube URL (watch, shorts, youtu.be 모두 지원)

**응답:**
```json
{
  "videoId": "abc123",
  "title": "영상 제목",
  "channelId": "UCxxx",
  "channelName": "채널명",
  "date": "2026-01-19",
  "time": "15:00:00",
  "videoType": "video",
  "videoUrl": "https://www.youtube.com/watch?v=abc123"
}
```

### POST /admin/youtube/schedule
YouTube 일정 저장

**Request Body:**
```json
{
  "videoId": "abc123",
  "title": "영상 제목",
  "channelId": "UCxxx",
  "channelName": "채널명",
  "date": "2026-01-19",
  "time": "15:00:00",
  "videoType": "video"
}
```

### PUT /admin/youtube/schedule/:id
YouTube 일정 수정 (멤버, 영상 유형)

**Request Body:**
```json
{
  "memberIds": [1, 2, 3],
  "videoType": "video"
}
```
※ `videoType`: "video" 또는 "shorts"

---

## 관리자 - X (인증 필요)

### GET /admin/x/post-info
X 게시글 정보 조회 (Nitter 스크래핑)

**Query Parameters:**
- `postId` - 게시글 ID (필수)
- `username` - 사용자명 (기본: realfromis_9)

**응답:**
```json
{
  "postId": "1234567890",
  "username": "realfromis_9",
  "text": "게시글 전체 내용",
  "title": "첫 문단 (자동 추출)",
  "imageUrls": ["https://pbs.twimg.com/media/..."],
  "date": "2026-01-19",
  "time": "15:00:00",
  "postUrl": "https://x.com/realfromis_9/status/1234567890",
  "profile": {
    "displayName": "프로미스나인 (fromis_9)",
    "avatarUrl": "https://..."
  }
}
```

### POST /admin/x/schedule
X 일정 저장

**Request Body:**
```json
{
  "postId": "1234567890",
  "title": "게시글 제목",
  "content": "게시글 내용",
  "imageUrls": ["https://..."],
  "date": "2026-01-19",
  "time": "15:00:00"
}
```

---

## 관리자 - 행사 (인증 필요)

### GET /admin/events/:id
행사 상세 조회 (수정 폼용)

**응답:**
```json
{
  "id": 2565,
  "title": "2026 UNION : PAINT THE UNION🎨",
  "date": "2026-05-07",
  "time": "21:30",
  "subtype": "university",
  "schoolName": "인천대학교",
  "memberIds": [1, 2, 3, 4, 5],
  "venue": {
    "id": 1,
    "name": "인천대학교",
    "address": "...",
    "roadAddress": "...",
    "lat": 37.xxx,
    "lng": 126.xxx,
    "kakao_id": null
  },
  "postUrls": ["https://www.instagram.com/p/..."],
  "posters": [
    { "id": 10001, "originalUrl": "...", "mediumUrl": "...", "thumbUrl": "..." }
  ]
}
```

### POST /admin/events
행사 생성 (`multipart/form-data`)

**multipart 파트:**
- `payload` (JSON string): `{ subtype, title, schoolName, date, time, memberIds, venue, postUrls }`
  - `subtype`: 현재 `'university'`만 지원
  - `venue`: `{ name, address, roadAddress?, lat, lng, kakao_id? }` — kakao_id 기준으로 event_venues 테이블에 upsert
  - `title`, `schoolName`, `date`, `venue` 필수
- `posters` (파일, 0개 이상): 포스터 이미지. 여러 장 가능

**응답:** `{ "id": 2565 }`

### PUT /admin/events/:id
행사 수정 (`multipart/form-data`)

**multipart 파트:**
- `payload` (JSON string): 위 POST 필드 + `keepPosterIds: number[]` (유지할 기존 포스터 ID 순서대로)
- `posters` (파일, 0개 이상): 새로 추가할 포스터

서버는 `keepPosterIds` 다음에 새 파일 id들을 이어붙여 `poster_image_ids` 업데이트.

### DELETE /admin/events/:id
행사 삭제 (schedules CASCADE로 schedule_event도 정리)

---

## 관리자 - 활동 로그 (인증 필요)

### GET /admin/logs
활동 로그 목록 조회

**Query Parameters:**
- `page` - 페이지 번호 (기본 1)
- `limit` - 페이지당 개수 (기본 50, 최대 100)
- `category` - 카테고리 필터 (콤마 구분: album, schedule, member, bot, category, dict, concert, sync)
- `actor` - 행위자 필터 (admin 또는 bot)
- `search` - summary 텍스트 검색
- `from` - 시작 날짜 (YYYY-MM-DD)
- `to` - 종료 날짜 (YYYY-MM-DD)

**응답:**
```json
{
  "logs": [
    {
      "id": 1,
      "actor": "admin",
      "action": "create",
      "category": "album",
      "target_type": "album",
      "target_id": 12,
      "summary": "앨범 생성: Unlock My World",
      "details": null,
      "created_at": "2026-03-02 14:30:00"
    }
  ],
  "total": 150,
  "page": 1,
  "limit": 50,
  "totalPages": 3
}
```

**actor 값:**
- `"admin"` - 관리자 수동 작업
- `"youtube-{id}"` - YouTube 봇 (예: youtube-3)
- `"x-{id}"` - X 봇 (예: x-1)

**action 값:**
- `create`, `update`, `delete`, `upload` - CRUD 작업
- `start`, `stop` - 봇 시작/정지
- `sync_complete` - 봇 동기화 완료
- `error` - 봇 동기화 에러

---

## 헬스 체크

### GET /health
서버 상태 확인

---

## API 문서

### GET /docs
Scalar API Reference UI

### GET /docs/json
OpenAPI JSON 스펙
