Compare commits
3 Commits
f1eab292a2
...
14236f355a
| Author | SHA1 | Date | |
|---|---|---|---|
| 14236f355a | |||
| f1e72e2829 | |||
| 868020f7ed |
287
CLAUDE.md
Normal file
287
CLAUDE.md
Normal file
@@ -0,0 +1,287 @@
|
||||
# CLAUDE.md — web-backend 프로젝트 가이드
|
||||
|
||||
> Claude Code가 이 프로젝트를 작업할 때 참조하는 설정 및 구조 문서.
|
||||
|
||||
---
|
||||
|
||||
## 1. 프로젝트 개요
|
||||
|
||||
Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
||||
- **서비스**: lotto-lab, stock-lab, travel-album, music-lab, deployer
|
||||
- **프론트엔드**: 별도 레포 (React + Vite SPA), 빌드 산출물만 NAS에 배포
|
||||
- **인프라**: Docker Compose + Nginx(리버스 프록시) + Gitea Webhook 자동 배포
|
||||
|
||||
---
|
||||
|
||||
## 2. NAS 환경
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|----|
|
||||
| 장비 | Synology NAS |
|
||||
| CPU | Intel Celeron J4025 (2 Core, 2.0 GHz) |
|
||||
| 메모리 | 18 GB |
|
||||
| Docker | Synology Container Manager |
|
||||
| Git 서버 | Gitea (self-hosted, NAS 내부) |
|
||||
| AI 서버 | Windows PC (192.168.45.59:8000) — NVIDIA 3070 Ti + Ollama |
|
||||
|
||||
---
|
||||
|
||||
## 3. NAS 디렉토리 구조
|
||||
|
||||
```
|
||||
/volume1
|
||||
├── docker/webpage/ # 운영 런타임 (Docker Compose 실행 위치)
|
||||
│ ├── backend/ # lotto-backend 소스 (rsync 동기화)
|
||||
│ ├── stock-lab/ # stock-lab 소스 (rsync 동기화)
|
||||
│ ├── travel-proxy/ # travel-proxy 소스 (rsync 동기화)
|
||||
│ ├── deployer/ # deployer 소스 (rsync 동기화)
|
||||
│ ├── nginx/default.conf # Nginx 설정
|
||||
│ ├── scripts/deploy.sh # Webhook 트리거 배포 스크립트
|
||||
│ ├── docker-compose.yml
|
||||
│ ├── .env # 운영 환경변수
|
||||
│ ├── data/lotto.db # SQLite DB
|
||||
│ └── data/music/ # 생성된 오디오 파일 (music-lab)
|
||||
│
|
||||
├── workspace/web-page-backend/ # Git 레포 클론 위치 (REPO_PATH)
|
||||
│
|
||||
└── web/images/webPage/travel/ # 원본 여행 사진 (RO 마운트)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Docker 서비스 & 포트
|
||||
|
||||
| 컨테이너 | 포트 | 역할 |
|
||||
|---------|------|------|
|
||||
| `lotto-backend` | 18000 | 로또 데이터 수집·분석·추천 API |
|
||||
| `stock-lab` | 18500 | 주식 뉴스·AI 분석·KIS API 연동 |
|
||||
| `music-lab` | 18600 | AI 음악 생성·라이브러리 관리 API |
|
||||
| `travel-proxy` | 19000 | 여행 사진 API + 썸네일 생성 |
|
||||
| `lotto-frontend` (nginx) | 8080 | 정적 SPA 서빙 + API 리버스 프록시 |
|
||||
| `webpage-deployer` | 19010 | Gitea Webhook 수신 → 자동 배포 |
|
||||
|
||||
---
|
||||
|
||||
## 5. Nginx 라우팅 규칙
|
||||
|
||||
| 경로 | 프록시 대상 | 비고 |
|
||||
|------|------------|------|
|
||||
| `/api/` | `lotto-backend:8000` | lotto API (기본) |
|
||||
| `/api/travel/` | `travel-proxy:8000` | travel API |
|
||||
| `/api/stock/` | `stock-lab:8000` | stock API |
|
||||
| `/api/trade/` | `stock-lab:8000` | KIS 실계좌 API |
|
||||
| `/api/portfolio` | `stock-lab:8000` | trailing slash 유무 모두 매칭 |
|
||||
| `/api/music/` | `music-lab:8000` | AI 음악 생성·라이브러리 API |
|
||||
| `/webhook`, `/webhook/` | `deployer:9000` | Gitea Webhook |
|
||||
| `/media/music/` | `/data/music/` (파일 직접 서빙) | 생성된 오디오 파일 |
|
||||
| `/media/travel/.thumb/` | `/data/thumbs/` (파일 직접 서빙) | 썸네일 캐시 |
|
||||
| `/media/travel/` | `/data/travel/` (파일 직접 서빙) | 원본 사진 |
|
||||
| `/assets/` | 정적 파일 (장기 캐시) | Vite 해시 파일 |
|
||||
| `/` | SPA fallback (`try_files → index.html`) | |
|
||||
|
||||
---
|
||||
|
||||
## 6. 기술 스택
|
||||
|
||||
| 레이어 | 기술 |
|
||||
|--------|------|
|
||||
| Backend 언어 | Python 3.12 |
|
||||
| API 프레임워크 | FastAPI |
|
||||
| DB | SQLite (`/app/data/*.db`) |
|
||||
| 스케줄러 | APScheduler |
|
||||
| 컨테이너 | Docker (`python:3.12-slim` 기반) |
|
||||
| AI 연동 | Ollama (Llama 3.1) — Windows PC (192.168.45.59) |
|
||||
| 주식 API | KIS (한국투자증권) Open API |
|
||||
|
||||
---
|
||||
|
||||
## 7. 자동 배포 흐름
|
||||
|
||||
```
|
||||
개발자 git push → Gitea → Webhook (HMAC SHA256 검증)
|
||||
→ deployer 컨테이너 → /scripts/deploy.sh
|
||||
→ rsync(REPO→RUNTIME) → docker compose up -d --build
|
||||
```
|
||||
|
||||
- **배포 스크립트 위치**: `scripts/deploy-nas.sh` (레포) / `scripts/deploy.sh` (런타임)
|
||||
- **환경변수 파일**: `.env` (RUNTIME_PATH, REPO_PATH, PHOTO_PATH, PUID, PGID 등)
|
||||
- **백업**: `.releases/` 디렉토리에 자동 백업
|
||||
|
||||
---
|
||||
|
||||
## 8. 로컬 개발 환경
|
||||
|
||||
```bash
|
||||
# .env 기본값으로 즉시 실행 가능 (RUNTIME_PATH=., PHOTO_PATH=./mock_data/photos)
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
| 서비스 | 로컬 URL |
|
||||
|--------|----------|
|
||||
| Frontend + API | http://localhost:8080 |
|
||||
| Lotto Backend | http://localhost:18000 |
|
||||
| Travel API | http://localhost:19000 |
|
||||
| Stock Lab | http://localhost:18500 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 서비스별 핵심 정보
|
||||
|
||||
### lotto-lab (backend/)
|
||||
- DB: `/app/data/lotto.db`
|
||||
- 데이터 소스: `smok95.github.io/lotto/results/`
|
||||
- 파일 구조: `main.py`, `db.py`, `recommender.py`, `collector.py`, `checker.py`, `generator.py`, `analyzer.py`, `utils.py`
|
||||
|
||||
**lotto.db 테이블**
|
||||
|
||||
| 테이블 | 설명 |
|
||||
|--------|------|
|
||||
| `draws` | 로또 당첨번호 |
|
||||
| `recommendations` | 추천 이력 (즐겨찾기·태그·채점 포함) |
|
||||
| `simulation_runs` | 시뮬레이션 실행 기록 |
|
||||
| `simulation_candidates` | 시뮬레이션 후보 (점수 5종) |
|
||||
| `best_picks` | 현재 활성 최적 번호 20개 (`is_active` 플래그로 교체) |
|
||||
| `todos` | 투두리스트 (UUID PK) |
|
||||
| `blog_posts` | 블로그 글 (tags: JSON 배열) |
|
||||
|
||||
**스케줄러 job**
|
||||
- 09:10 / 21:10 매일 — 당첨번호 동기화 + 채점 (`sync_latest` → `check_results_for_draw`)
|
||||
- 00:05, 04:05, 08:05, 12:05, 16:05, 20:05 — 몬테카를로 시뮬레이션 (20,000후보 → 상위100 → best_picks 20개 교체)
|
||||
|
||||
**lotto-lab API 목록**
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/lotto/latest` | 최신 당첨번호 |
|
||||
| GET | `/api/lotto/{drw_no}` | 특정 회차 |
|
||||
| GET | `/api/lotto/stats` | 번호 빈도 통계 |
|
||||
| GET | `/api/lotto/analysis` | 5가지 통계 분석 리포트 |
|
||||
| GET | `/api/lotto/best` | 시뮬레이션 최적 번호 (기본 20쌍) |
|
||||
| GET | `/api/lotto/simulation` | 시뮬레이션 상세 결과 |
|
||||
| GET | `/api/lotto/recommend` | 통계 기반 추천 |
|
||||
| GET | `/api/lotto/recommend/heatmap` | 히트맵 기반 추천 |
|
||||
| GET | `/api/lotto/recommend/batch` | 배치 추천 |
|
||||
| POST | `/api/lotto/recommend/batch` | 배치 추천 저장 |
|
||||
| POST | `/api/admin/simulate` | 시뮬레이션 수동 실행 |
|
||||
| POST | `/api/admin/sync_latest` | 당첨번호 수동 동기화 |
|
||||
| GET | `/api/history` | 추천 이력 (limit, offset, favorite, tag, sort) |
|
||||
| PATCH | `/api/history/{id}` | 즐겨찾기·메모·태그 수정 |
|
||||
| DELETE | `/api/history/{id}` | 삭제 |
|
||||
| GET | `/api/todos` | 투두 전체 목록 |
|
||||
| POST | `/api/todos` | 투두 생성 (status: todo\|in_progress\|done) |
|
||||
| PUT | `/api/todos/{id}` | 투두 수정 |
|
||||
| DELETE | `/api/todos/done` | 완료 항목 일괄 삭제 |
|
||||
| DELETE | `/api/todos/{id}` | 투두 개별 삭제 |
|
||||
| GET | `/api/blog/posts` | 블로그 글 목록 (`{"posts": [...]}`, date DESC) |
|
||||
| POST | `/api/blog/posts` | 블로그 글 생성 (date 미입력 시 오늘) |
|
||||
| PUT | `/api/blog/posts/{id}` | 블로그 글 수정 |
|
||||
| DELETE | `/api/blog/posts/{id}` | 블로그 글 삭제 |
|
||||
|
||||
### stock-lab (stock-lab/)
|
||||
- Windows AI 서버 연동: `WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000`
|
||||
- KIS API 연동으로 실계좌 잔고·거래 조회
|
||||
- 뉴스 스크래핑: 네이버 증권 + 해외 사이트
|
||||
- DB: `/app/data/stock.db` (articles, portfolio, broker_cash, asset_snapshots, sell_history 테이블)
|
||||
- 파일 구조: `main.py`, `db.py`, `scraper.py`, `price_fetcher.py`, `holidays.json`
|
||||
|
||||
**stock-lab API 목록**
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/stock/news` | 뉴스 조회 (`limit`, `category` 파라미터) |
|
||||
| GET | `/api/stock/indices` | 주요 지표 실시간 조회 |
|
||||
| POST | `/api/stock/scrap` | 수동 뉴스 스크랩 트리거 |
|
||||
| GET | `/api/trade/balance` | 실계좌 잔고 조회 (Windows AI 서버 프록시) |
|
||||
| POST | `/api/trade/order` | 주식 주문 (Windows AI 서버 프록시) |
|
||||
| GET | `/api/portfolio` | 포트폴리오 전체 조회 (현재가·손익·예수금 포함) |
|
||||
| POST | `/api/portfolio` | 종목 추가 |
|
||||
| PUT | `/api/portfolio/{id}` | 종목 수정 |
|
||||
| DELETE | `/api/portfolio/{id}` | 종목 삭제 |
|
||||
| GET | `/api/portfolio/cash` | 예수금 전체 조회 |
|
||||
| PUT | `/api/portfolio/cash` | 예수금 등록·수정 (upsert) |
|
||||
| DELETE | `/api/portfolio/cash/{broker}` | 예수금 삭제 |
|
||||
| POST | `/api/portfolio/snapshot` | 총 자산 스냅샷 수동 저장 |
|
||||
| GET | `/api/portfolio/snapshot/history` | 스냅샷 이력 조회 (`days=0`: 전체, `days=N`: 최근 N건) |
|
||||
| GET | `/api/portfolio/sell-history` | 매도 내역 조회 (`broker`, `days` 필터 선택) |
|
||||
| POST | `/api/portfolio/sell-history` | 매도 기록 저장 (id 포함 레코드 반환) |
|
||||
| PUT | `/api/portfolio/sell-history/{id}` | 매도 기록 수정 (수정된 레코드 반환) |
|
||||
| DELETE | `/api/portfolio/sell-history/{id}` | 매도 기록 삭제 |
|
||||
|
||||
**매도 히스토리 (`sell_history`)**
|
||||
- 독립 테이블 — `portfolio` 테이블과 별개로 관리
|
||||
- `sold_at`: UTC ISO8601 형식 (`new Date().toISOString()`)
|
||||
- `realized_profit` / `realized_rate`: 프론트 계산값 저장 (백엔드 재계산 무방)
|
||||
- 응답 정렬: `sold_at DESC` (최신순)
|
||||
|
||||
**총 자산 스냅샷 (`asset_snapshots`)**
|
||||
- 평일 15:40 APScheduler 자동 실행 (`save_daily_snapshot`)
|
||||
- 공휴일 판별: `holidays.json` (매년 수동 갱신, KRX 기준) → `is_market_open()` 함수
|
||||
- 같은 날 중복 저장 시 upsert (date UNIQUE 제약)
|
||||
- 수동 저장: `POST /api/portfolio/snapshot`
|
||||
- 이력 조회: `GET /api/portfolio/snapshot/history?days=30` (ASC 정렬, 차트용)
|
||||
|
||||
**스케줄러 job**
|
||||
- 08:00 매일 — 뉴스 스크랩 (`run_scraping_job`)
|
||||
- 15:40 평일 — 총 자산 스냅샷 저장 (`save_daily_snapshot`)
|
||||
|
||||
### music-lab (music-lab/)
|
||||
- AI 음악 생성 서비스. Windows AI 서버(`MUSIC_AI_SERVER_URL`)에 생성 요청 프록시
|
||||
- 생성된 오디오 파일: `/app/data/music/` (Nginx가 `/media/music/`로 직접 서빙)
|
||||
- DB: `/app/data/music.db` (music_tasks, music_library 테이블)
|
||||
- 파일 구조: `main.py`, `db.py`
|
||||
- 생성 흐름: POST generate → task_id 반환 → BackgroundTask가 AI 서버 호출 → 파일 저장 → 라이브러리 자동 등록
|
||||
|
||||
**music-lab API 목록**
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| POST | `/api/music/generate` | 음악 생성 시작 (task_id 반환, 비동기) |
|
||||
| GET | `/api/music/status/{task_id}` | 생성 상태 폴링 (queued→processing→succeeded/failed) |
|
||||
| GET | `/api/music/library` | 라이브러리 전체 조회 |
|
||||
| POST | `/api/music/library` | 트랙 수동 추가 (201) |
|
||||
| DELETE | `/api/music/library/{id}` | 트랙 삭제 (로컬 파일 포함) |
|
||||
|
||||
**환경변수**
|
||||
- `MUSIC_AI_SERVER_URL`: AI 음악 생성 서버 URL (미설정 시 생성 요청 실패)
|
||||
- `MUSIC_MEDIA_BASE`: 오디오 파일 공개 URL prefix (기본 `/media/music`)
|
||||
- `MUSIC_DATA_PATH`: NAS 오디오 파일 저장 경로 (기본 `./data/music`)
|
||||
|
||||
**AI 서버 응답 형식 (2가지 모두 지원)**
|
||||
- binary audio (Content-Type: audio/*) → 직접 저장
|
||||
- JSON `{"audio_url": "..."}` → 해당 URL에서 다운로드 후 저장
|
||||
|
||||
### travel-proxy (travel-proxy/)
|
||||
- 원본 사진: `/data/travel/` (RO)
|
||||
- 썸네일 캐시: `/data/thumbs/` (RW)
|
||||
- 메타: `/data/travel/_meta/region_map.json`, `regions.geojson`
|
||||
- 썸네일: 480×480 리사이징 (Pillow), 온디맨드 생성 후 영구 캐시
|
||||
- 메모리 캐시: TTL 300초 (앨범 스캔 결과)
|
||||
|
||||
**travel-proxy API 목록**
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/travel/regions` | 지역 GeoJSON |
|
||||
| GET | `/api/travel/photos` | 사진 목록 (region, page=1, size=20) |
|
||||
| POST | `/api/travel/reload` | 메모리 캐시 초기화 |
|
||||
|
||||
### deployer (deployer/)
|
||||
- Webhook 검증: `X-Gitea-Signature` (HMAC SHA256, `compare_digest` 사용)
|
||||
- `WEBHOOK_SECRET` 환경변수로 시크릿 관리
|
||||
- Webhook 수신 즉시 `{"ok": True}` 응답 후 BackgroundTask로 배포 실행
|
||||
- 배포 타임아웃: 10분 (`scripts/deploy.sh`)
|
||||
|
||||
---
|
||||
|
||||
## 10. 주의사항
|
||||
|
||||
- **Nginx trailing slash**: `/api/portfolio`는 trailing slash 없이도 매칭되도록 두 location 블록으로 처리
|
||||
- **라우트 순서**: `DELETE /api/todos/done`은 `DELETE /api/todos/{id}` 보다 **반드시 먼저** 등록 (FastAPI prefix 매칭 순서)
|
||||
- **PUID/PGID**: travel-proxy는 NAS 파일 권한을 위해 PUID/PGID를 환경변수로 주입
|
||||
- **캐시 전략**: `index.html`은 `no-store`, `assets/`는 1년 장기 캐시(immutable)
|
||||
- **Frontend 배포**: git push로 자동 배포되지 않음. 로컬 빌드 후 NAS에 수동 업로드
|
||||
- **.env 파일**: 절대 커밋 금지. `.env.example`만 레포에 포함
|
||||
- **공휴일 목록**: `stock-lab/app/holidays.json` 매년 수동 갱신 필요 (KRX 기준)
|
||||
- **Windows AI 서버 IP**: `192.168.45.59` — 공유기 DHCP 고정 예약으로 고정. Tailscale은 Synology에서 TCP 불가(userspace 모드)라 로컬 IP 사용
|
||||
- **현재가 조회**: 네이버 모바일 API → HTML 파싱 폴백, 3분 TTL 캐시 (`price_fetcher.py`)
|
||||
- **시뮬레이션 교체 방식**: `best_picks`는 교체형 — 새 시뮬레이션 실행 시 `is_active=0`으로 비활성화 후 신규 입력
|
||||
@@ -32,6 +32,20 @@ services:
|
||||
volumes:
|
||||
- ${STOCK_DATA_PATH:-./data/stock}:/app/data
|
||||
|
||||
music-lab:
|
||||
build:
|
||||
context: ./music-lab
|
||||
container_name: music-lab
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18600:8000"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Seoul}
|
||||
- MUSIC_AI_SERVER_URL=${MUSIC_AI_SERVER_URL:-}
|
||||
- MUSIC_MEDIA_BASE=${MUSIC_MEDIA_BASE:-/media/music}
|
||||
volumes:
|
||||
- ${MUSIC_DATA_PATH:-./data/music}:/app/data
|
||||
|
||||
travel-proxy:
|
||||
build: ./travel-proxy
|
||||
container_name: travel-proxy
|
||||
@@ -61,6 +75,7 @@ services:
|
||||
- ${RUNTIME_PATH}/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ${PHOTO_PATH}:/data/travel:ro
|
||||
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:ro
|
||||
- ${MUSIC_DATA_PATH:-./data/music}:/data/music:ro
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
|
||||
9
music-lab/Dockerfile
Normal file
9
music-lab/Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
||||
FROM python:3.12-alpine
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
0
music-lab/app/__init__.py
Normal file
0
music-lab/app/__init__.py
Normal file
183
music-lab/app/db.py
Normal file
183
music-lab/app/db.py
Normal file
@@ -0,0 +1,183 @@
|
||||
import os
|
||||
import sqlite3
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
DB_PATH = "/app/data/music.db"
|
||||
|
||||
|
||||
def _conn() -> sqlite3.Connection:
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS music_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
progress INTEGER NOT NULL DEFAULT 0,
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
audio_url TEXT,
|
||||
error TEXT,
|
||||
params TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_tasks_created ON music_tasks(created_at DESC)")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS music_library (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
genre TEXT NOT NULL DEFAULT '',
|
||||
moods TEXT NOT NULL DEFAULT '[]',
|
||||
instruments TEXT NOT NULL DEFAULT '[]',
|
||||
duration_sec INTEGER,
|
||||
bpm INTEGER,
|
||||
key TEXT NOT NULL DEFAULT '',
|
||||
scale TEXT NOT NULL DEFAULT '',
|
||||
prompt TEXT NOT NULL DEFAULT '',
|
||||
audio_url TEXT NOT NULL DEFAULT '',
|
||||
file_path TEXT NOT NULL DEFAULT '',
|
||||
task_id TEXT,
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_library_created ON music_library(created_at DESC)")
|
||||
|
||||
|
||||
# ── music_tasks CRUD ──────────────────────────────────────────────────────────
|
||||
|
||||
def _task_row_to_dict(r) -> Dict[str, Any]:
|
||||
return {
|
||||
"task_id": r["id"],
|
||||
"status": r["status"],
|
||||
"progress": r["progress"],
|
||||
"message": r["message"],
|
||||
"audio_url": r["audio_url"],
|
||||
"error": r["error"],
|
||||
"params": json.loads(r["params"]),
|
||||
"created_at": r["created_at"],
|
||||
"updated_at": r["updated_at"],
|
||||
}
|
||||
|
||||
|
||||
def create_task(task_id: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO music_tasks (id, params) VALUES (?, ?)",
|
||||
(task_id, json.dumps(params)),
|
||||
)
|
||||
row = conn.execute("SELECT * FROM music_tasks WHERE id = ?", (task_id,)).fetchone()
|
||||
return _task_row_to_dict(row)
|
||||
|
||||
|
||||
def update_task(
|
||||
task_id: str,
|
||||
status: str,
|
||||
progress: int,
|
||||
message: str,
|
||||
audio_url: str = None,
|
||||
error: str = None,
|
||||
) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE music_tasks
|
||||
SET status = ?, progress = ?, message = ?, audio_url = ?, error = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
||||
WHERE id = ?
|
||||
""",
|
||||
(status, progress, message, audio_url, error, task_id),
|
||||
)
|
||||
|
||||
|
||||
def get_task(task_id: str) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT * FROM music_tasks WHERE id = ?", (task_id,)).fetchone()
|
||||
return _task_row_to_dict(row) if row else None
|
||||
|
||||
|
||||
# ── music_library CRUD ────────────────────────────────────────────────────────
|
||||
|
||||
def _track_row_to_dict(r) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r["id"],
|
||||
"title": r["title"],
|
||||
"genre": r["genre"],
|
||||
"moods": json.loads(r["moods"]) if r["moods"] else [],
|
||||
"instruments": json.loads(r["instruments"]) if r["instruments"] else [],
|
||||
"duration_sec": r["duration_sec"],
|
||||
"bpm": r["bpm"],
|
||||
"key": r["key"],
|
||||
"scale": r["scale"],
|
||||
"prompt": r["prompt"],
|
||||
"audio_url": r["audio_url"],
|
||||
"file_path": r["file_path"],
|
||||
"task_id": r["task_id"],
|
||||
"tags": json.loads(r["tags"]) if r["tags"] else [],
|
||||
"created_at": r["created_at"],
|
||||
}
|
||||
|
||||
|
||||
def get_all_tracks() -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM music_library ORDER BY created_at DESC").fetchall()
|
||||
return [_track_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def add_track(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO music_library
|
||||
(title, genre, moods, instruments, duration_sec, bpm, key, scale,
|
||||
prompt, audio_url, file_path, task_id, tags)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
data.get("title", ""),
|
||||
data.get("genre", ""),
|
||||
json.dumps(data.get("moods", [])),
|
||||
json.dumps(data.get("instruments", [])),
|
||||
data.get("duration_sec"),
|
||||
data.get("bpm"),
|
||||
data.get("key", ""),
|
||||
data.get("scale", ""),
|
||||
data.get("prompt", ""),
|
||||
data.get("audio_url", ""),
|
||||
data.get("file_path", ""),
|
||||
data.get("task_id"),
|
||||
json.dumps(data.get("tags", [])),
|
||||
),
|
||||
)
|
||||
row = conn.execute("SELECT * FROM music_library WHERE rowid = last_insert_rowid()").fetchone()
|
||||
return _track_row_to_dict(row)
|
||||
|
||||
|
||||
def delete_track(track_id: int) -> bool:
|
||||
with _conn() as conn:
|
||||
# 파일 경로 먼저 조회 (삭제 후 파일도 지울 수 있도록)
|
||||
row = conn.execute("SELECT file_path FROM music_library WHERE id = ?", (track_id,)).fetchone()
|
||||
if not row:
|
||||
return False
|
||||
conn.execute("DELETE FROM music_library WHERE id = ?", (track_id,))
|
||||
return True
|
||||
|
||||
|
||||
def get_track_by_task_id(task_id: str) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT * FROM music_library WHERE task_id = ?", (task_id,)).fetchone()
|
||||
return _track_row_to_dict(row) if row else None
|
||||
|
||||
|
||||
def get_track_file_path(track_id: int) -> Optional[str]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT file_path FROM music_library WHERE id = ?", (track_id,)).fetchone()
|
||||
return row["file_path"] if row else None
|
||||
224
music-lab/app/main.py
Normal file
224
music-lab/app/main.py
Normal file
@@ -0,0 +1,224 @@
|
||||
import os
|
||||
import uuid
|
||||
import requests
|
||||
from typing import List, Optional
|
||||
from fastapi import FastAPI, HTTPException, BackgroundTasks
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .db import (
|
||||
init_db,
|
||||
create_task, update_task, get_task,
|
||||
get_all_tracks, add_track, delete_track, get_track_file_path, get_track_by_task_id,
|
||||
)
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
MUSIC_AI_SERVER_URL = os.getenv("MUSIC_AI_SERVER_URL", "")
|
||||
MUSIC_DATA_DIR = "/app/data/music"
|
||||
MUSIC_MEDIA_BASE = os.getenv("MUSIC_MEDIA_BASE", "/media/music")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
init_db()
|
||||
os.makedirs(MUSIC_DATA_DIR, exist_ok=True)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── 음악 생성 워커 ────────────────────────────────────────────────────────────
|
||||
|
||||
def _run_generation(task_id: str, params: dict) -> None:
|
||||
"""BackgroundTask: AI 서버에 생성 요청 → 파일 저장 → 라이브러리 등록"""
|
||||
try:
|
||||
update_task(task_id, "processing", 10, "AI 서버에 연결 중...")
|
||||
|
||||
if not MUSIC_AI_SERVER_URL:
|
||||
update_task(task_id, "failed", 0, "", error="MUSIC_AI_SERVER_URL이 설정되지 않았습니다")
|
||||
return
|
||||
|
||||
update_task(task_id, "processing", 30, "음악 생성 중... (수 분 소요될 수 있습니다)")
|
||||
|
||||
resp = requests.post(
|
||||
f"{MUSIC_AI_SERVER_URL}/generate",
|
||||
json=params,
|
||||
timeout=600, # 10분
|
||||
stream=True,
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
update_task(task_id, "failed", 0, "", error=f"AI 서버 오류: {resp.status_code} {resp.text[:200]}")
|
||||
return
|
||||
|
||||
update_task(task_id, "processing", 80, "파일 저장 중...")
|
||||
|
||||
# AI 서버 응답: binary audio 또는 JSON {"audio_url": "..."}
|
||||
content_type = resp.headers.get("content-type", "")
|
||||
filename = f"{task_id}.mp3"
|
||||
file_path = os.path.join(MUSIC_DATA_DIR, filename)
|
||||
|
||||
if "application/json" in content_type:
|
||||
result = resp.json()
|
||||
remote_url = result.get("audio_url") or result.get("url")
|
||||
if not remote_url:
|
||||
update_task(task_id, "failed", 0, "", error="AI 서버 응답에 audio_url이 없습니다")
|
||||
return
|
||||
# 원격 URL에서 파일 다운로드
|
||||
dl = requests.get(remote_url, timeout=120, stream=True)
|
||||
with open(file_path, "wb") as f:
|
||||
for chunk in dl.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
else:
|
||||
# binary audio 직접 저장
|
||||
with open(file_path, "wb") as f:
|
||||
for chunk in resp.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
|
||||
# audio_url은 항상 Nginx 상대경로 (Mixed Content 방지)
|
||||
audio_url = f"/media/music/{filename}"
|
||||
|
||||
# 라이브러리 자동 등록 — payload title 우선, 없으면 자동 생성
|
||||
genre = params.get("genre", "")
|
||||
moods = params.get("moods", [])
|
||||
mood_str = moods[0] if moods else "Original"
|
||||
title = params.get("title") or (f"{genre} — {mood_str} Mix" if genre else f"{mood_str} Mix")
|
||||
|
||||
add_track({
|
||||
"title": title,
|
||||
"genre": genre,
|
||||
"moods": params.get("moods", []),
|
||||
"instruments": params.get("instruments", []),
|
||||
"duration_sec": params.get("duration_sec"),
|
||||
"bpm": params.get("bpm"),
|
||||
"key": params.get("key", ""),
|
||||
"scale": params.get("scale", ""),
|
||||
"prompt": params.get("prompt", ""),
|
||||
"audio_url": audio_url,
|
||||
"file_path": file_path,
|
||||
"task_id": task_id,
|
||||
})
|
||||
|
||||
update_task(task_id, "succeeded", 100, "생성 완료", audio_url=audio_url)
|
||||
|
||||
except requests.Timeout:
|
||||
update_task(task_id, "failed", 0, "", error="AI 서버 타임아웃 (10분 초과)")
|
||||
except Exception as e:
|
||||
update_task(task_id, "failed", 0, "", error=str(e))
|
||||
|
||||
|
||||
# ── 음악 생성 API ─────────────────────────────────────────────────────────────
|
||||
|
||||
class GenerateRequest(BaseModel):
|
||||
title: str = ""
|
||||
genre: str = ""
|
||||
moods: List[str] = []
|
||||
instruments: List[str] = []
|
||||
duration_sec: Optional[int] = None
|
||||
bpm: Optional[int] = None
|
||||
key: str = ""
|
||||
scale: str = ""
|
||||
prompt: str = ""
|
||||
|
||||
|
||||
@app.post("/api/music/generate")
|
||||
def generate_music(req: GenerateRequest, background_tasks: BackgroundTasks):
|
||||
"""
|
||||
음악 생성 작업 시작. task_id 즉시 반환 후 백그라운드에서 AI 서버 호출.
|
||||
생성 완료 시 music_library에 자동 등록됨.
|
||||
"""
|
||||
task_id = str(uuid.uuid4())
|
||||
params = req.model_dump()
|
||||
create_task(task_id, params)
|
||||
background_tasks.add_task(_run_generation, task_id, params)
|
||||
return {"task_id": task_id}
|
||||
|
||||
|
||||
@app.get("/api/music/status/{task_id}")
|
||||
def get_status(task_id: str):
|
||||
"""
|
||||
생성 작업 상태 조회. 프론트는 succeeded 또는 failed가 될 때까지 폴링.
|
||||
status: queued | processing | succeeded | failed
|
||||
succeeded 시 track 메타데이터 포함 (라이브러리 별도 저장 불필요).
|
||||
"""
|
||||
task = get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
resp = {
|
||||
"status": task["status"],
|
||||
"progress": task["progress"],
|
||||
"message": task["message"],
|
||||
"audio_url": task["audio_url"],
|
||||
"error": task["error"],
|
||||
}
|
||||
|
||||
# succeeded 시 라이브러리에 저장된 트랙 메타데이터 포함
|
||||
# 프론트는 이 track 객체로 UI를 바로 업데이트하면 됨 (Save 버튼 불필요)
|
||||
if task["status"] == "succeeded":
|
||||
track = get_track_by_task_id(task_id)
|
||||
resp["track"] = track
|
||||
|
||||
return resp
|
||||
|
||||
|
||||
# ── 라이브러리 API ────────────────────────────────────────────────────────────
|
||||
|
||||
class TrackCreate(BaseModel):
|
||||
title: str = ""
|
||||
genre: str = ""
|
||||
moods: List[str] = []
|
||||
instruments: List[str] = []
|
||||
duration_sec: Optional[int] = None
|
||||
bpm: Optional[int] = None
|
||||
key: str = ""
|
||||
scale: str = ""
|
||||
prompt: str = ""
|
||||
audio_url: str = ""
|
||||
file_path: str = ""
|
||||
task_id: Optional[str] = None
|
||||
tags: List[str] = []
|
||||
|
||||
|
||||
@app.get("/api/music/library")
|
||||
def list_library():
|
||||
"""저장된 트랙 목록 전체 조회 (생성일 내림차순)"""
|
||||
return {"tracks": get_all_tracks()}
|
||||
|
||||
|
||||
@app.post("/api/music/library", status_code=201)
|
||||
def save_to_library(req: TrackCreate):
|
||||
"""트랙 수동 추가 (외부 파일 등록 또는 프론트 직접 저장용)"""
|
||||
track = add_track(req.model_dump())
|
||||
return track
|
||||
|
||||
|
||||
@app.delete("/api/music/library/{track_id}")
|
||||
def remove_from_library(track_id: int):
|
||||
"""
|
||||
라이브러리에서 트랙 삭제. 로컬 파일도 함께 삭제.
|
||||
"""
|
||||
file_path = get_track_file_path(track_id)
|
||||
ok = delete_track(track_id)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="Track not found")
|
||||
|
||||
# 생성된 파일이 있으면 함께 삭제
|
||||
if file_path and os.path.isfile(file_path):
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except OSError:
|
||||
pass # 파일 삭제 실패해도 DB에서는 이미 삭제됨
|
||||
|
||||
return {"ok": True}
|
||||
4
music-lab/requirements.txt
Normal file
4
music-lab/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.30.6
|
||||
requests==2.32.3
|
||||
python-multipart==0.0.12
|
||||
@@ -17,6 +17,28 @@ server {
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# music media — Nginx가 직접 오디오 파일 서빙
|
||||
location ^~ /media/music/ {
|
||||
alias /data/music/;
|
||||
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, max-age=2592000" always;
|
||||
add_header Accept-Ranges bytes always; # 오디오 스트리밍 범위 요청 지원
|
||||
|
||||
autoindex off;
|
||||
}
|
||||
|
||||
# music API
|
||||
location /api/music/ {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 660s; # 생성 요청 폴링 대비 (기본 60s 초과 방지)
|
||||
proxy_pass http://music-lab:8000/api/music/;
|
||||
}
|
||||
|
||||
# travel thumbnails (generated by travel-proxy, stored in /data/thumbs)
|
||||
location ^~ /media/travel/.thumb/ {
|
||||
alias /data/thumbs/;
|
||||
|
||||
Reference in New Issue
Block a user