Compare commits
71 Commits
v0.1.0
...
4589592b67
| Author | SHA1 | Date | |
|---|---|---|---|
| 4589592b67 | |||
| c7e12ea9fe | |||
| 438aba1dd1 | |||
| 7ab0733400 | |||
| 14236f355a | |||
| f1e72e2829 | |||
| 868020f7ed | |||
| f1eab292a2 | |||
| 732d78becc | |||
| 2ce118baba | |||
| 05e7ffdfd9 | |||
| c7401c5d9f | |||
| 5d6fe2f04b | |||
| 2926770d6f | |||
| 197d451d5f | |||
| f45041d46c | |||
| 483963b463 | |||
| 11423e5106 | |||
| de7468b256 | |||
| d6d2eb0787 | |||
| 136aea8aee | |||
| ea9eb749aa | |||
| 71d9d7a571 | |||
| c96815c2e3 | |||
| 4035432c54 | |||
| d28c291a55 | |||
| 21a8173963 | |||
| f6fcff0faf | |||
| 55863d7744 | |||
| a330a5271c | |||
| e27fbfada1 | |||
| 7fb55a7be7 | |||
| 9a8df4908a | |||
| a8cbef75db | |||
| b6fd444dba | |||
| f2e23c1241 | |||
| c6850da4ac | |||
| 8283dab0de | |||
| 9faa1c5715 | |||
| 0e2d241e18 | |||
| 84c5877207 | |||
| cbafc1f959 | |||
| dce6b3e692 | |||
| 3d0dd24f27 | |||
| 2fafce0327 | |||
| 25ede4f478 | |||
| 2493bc72fb | |||
| dd6435eb86 | |||
| 94db1da045 | |||
| d8e4e0461c | |||
| 421e52b205 | |||
| 526d6a53e5 | |||
| 432840a38d | |||
| 597353e6d4 | |||
| bd43c99221 | |||
| 2c95fe49f3 | |||
| 8ccfc32749 | |||
| 67ef3c4bbf | |||
| ee54458bf0 | |||
| e1c3168d5c | |||
| 2d5972c25d | |||
| 1ddbd4ad0e | |||
| f75bf5d3e5 | |||
| 0fde916120 | |||
| 64c526488a | |||
| c655b655c9 | |||
| 005c0261c2 | |||
| 879bb2f25d | |||
| 82cbae7ae2 | |||
| a8b661b304 | |||
| b815c37064 |
59
.env.example
59
.env.example
@@ -1,17 +1,52 @@
|
|||||||
# timezone
|
# ---------------------------------------------------------------------------
|
||||||
|
# [Environment Configuration]
|
||||||
|
# 이 파일을 복사하여 .env 파일을 생성하고, 환경에 맞게 주석을 해제/수정하여 사용하세요.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# [COMMON]
|
||||||
|
APP_VERSION=dev
|
||||||
TZ=Asia/Seoul
|
TZ=Asia/Seoul
|
||||||
|
|
||||||
COMPOSE_PROJECT_NAME=webpage
|
|
||||||
|
|
||||||
# backend lotto collector sources
|
|
||||||
LOTTO_ALL_URL=https://smok95.github.io/lotto/results/all.json
|
LOTTO_ALL_URL=https://smok95.github.io/lotto/results/all.json
|
||||||
LOTTO_LATEST_URL=https://smok95.github.io/lotto/results/latest.json
|
LOTTO_LATEST_URL=https://smok95.github.io/lotto/results/latest.json
|
||||||
|
|
||||||
# travel-proxy
|
# [SECURITY]
|
||||||
TRAVEL_ROOT=/data/travel
|
WEBHOOK_SECRET=change_this_secret_in_prod
|
||||||
TRAVEL_THUMB_ROOT=/data/thumbs
|
|
||||||
TRAVEL_MEDIA_BASE=/media/travel
|
|
||||||
TRAVEL_CACHE_TTL=300
|
|
||||||
|
|
||||||
# CORS (travel-proxy)
|
# [PATHS]
|
||||||
CORS_ALLOW_ORIGINS=*
|
# 1. 런타임 데이터 루트 (docker-compose.yml이 실행되는 위치)
|
||||||
|
# NAS: /volume1/docker/webpage
|
||||||
|
# Local: . (현재 프로젝트 루트)
|
||||||
|
RUNTIME_PATH=.
|
||||||
|
|
||||||
|
# 2. Git 저장소 루트
|
||||||
|
# NAS: /volume1/workspace/web-page-backend
|
||||||
|
# Local: .
|
||||||
|
REPO_PATH=.
|
||||||
|
|
||||||
|
# 3. Frontend 정적 파일 경로
|
||||||
|
# NAS: /volume1/docker/webpage/frontend (업로드된 파일)
|
||||||
|
# Local: ./frontend/dist (빌드된 결과물)
|
||||||
|
FRONTEND_PATH=./frontend/dist
|
||||||
|
|
||||||
|
# 4. 여행 사진 원본 경로
|
||||||
|
# NAS: /volume1/web/images/webPage/travel
|
||||||
|
# Local: ./mock_data/photos
|
||||||
|
PHOTO_PATH=./mock_data/photos
|
||||||
|
|
||||||
|
# 5. 주식 데이터 저장 경로
|
||||||
|
# NAS: /volume1/docker/webpage/data/stock
|
||||||
|
# Local: ./data/stock
|
||||||
|
STOCK_DATA_PATH=./data/stock
|
||||||
|
|
||||||
|
# [PERMISSIONS]
|
||||||
|
# NAS: 1026:100
|
||||||
|
# Local: 1000:1000 (Windows Docker Desktop의 경우 크게 중요하지 않음)
|
||||||
|
PUID=1000
|
||||||
|
PGID=1000
|
||||||
|
|
||||||
|
# [STOCK LAB]
|
||||||
|
# NAS는 Windows AI Server로 요청을 중계(Proxy)하는 역할만 수행합니다.
|
||||||
|
# 실제 KIS API 호출 및 AI 분석은 Windows PC에서 수행됩니다.
|
||||||
|
|
||||||
|
# Windows AI Server (NAS 입장에서 바라본 Windows PC IP)
|
||||||
|
WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000
|
||||||
|
|||||||
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`으로 비활성화 후 신규 입력
|
||||||
334
README.md
334
README.md
@@ -0,0 +1,334 @@
|
|||||||
|
# web-backend
|
||||||
|
|
||||||
|
Synology NAS 기반 개인 웹 플랫폼 백엔드 모노레포.
|
||||||
|
로또 분석, 주식 포트폴리오, 여행 앨범, 블로그, 투두리스트를 하나의 서비스로 운영한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 서비스 구성
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ lotto-frontend (Nginx:8080) │
|
||||||
|
│ ├── 정적 SPA 서빙 (React + Vite) │
|
||||||
|
│ └── API 리버스 프록시 │
|
||||||
|
│ ├── /api/ → lotto-backend:8000 │
|
||||||
|
│ ├── /api/stock/ → stock-lab:8000 │
|
||||||
|
│ ├── /api/trade/ → stock-lab:8000 │
|
||||||
|
│ ├── /api/portfolio → stock-lab:8000 │
|
||||||
|
│ ├── /api/travel/ → travel-proxy:8000 │
|
||||||
|
│ └── /webhook → deployer:9000 │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
| 컨테이너 | 포트 | 역할 |
|
||||||
|
|---------|------|------|
|
||||||
|
| `lotto-backend` | 18000 | 로또·블로그·투두 API |
|
||||||
|
| `stock-lab` | 18500 | 주식 뉴스·포트폴리오·자산 추적 |
|
||||||
|
| `travel-proxy` | 19000 | 여행 사진 API + 썸네일 생성 |
|
||||||
|
| `lotto-frontend` | 8080 | SPA 서빙 + 리버스 프록시 |
|
||||||
|
| `webpage-deployer` | 19010 | Gitea Webhook → 자동 배포 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 디렉토리 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
web-backend/
|
||||||
|
├── backend/ # lotto-backend 서비스 (Python/FastAPI)
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── main.py # 라우터, 스케줄러
|
||||||
|
│ │ ├── db.py # SQLite CRUD (7개 테이블)
|
||||||
|
│ │ ├── generator.py # 몬테카를로 시뮬레이션 엔진
|
||||||
|
│ │ ├── analyzer.py # 5가지 통계 분석
|
||||||
|
│ │ ├── checker.py # 당첨 결과 채점
|
||||||
|
│ │ ├── collector.py # 로또 데이터 수집
|
||||||
|
│ │ ├── recommender.py # 추천 알고리즘
|
||||||
|
│ │ └── utils.py # 메트릭 계산
|
||||||
|
│ └── Dockerfile
|
||||||
|
│
|
||||||
|
├── stock-lab/ # stock-lab 서비스 (Python/FastAPI)
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── main.py # 라우터, 스케줄러
|
||||||
|
│ │ ├── db.py # SQLite CRUD (4개 테이블)
|
||||||
|
│ │ ├── scraper.py # 네이버 금융 뉴스 크롤링
|
||||||
|
│ │ ├── price_fetcher.py # 현재가 조회 (3분 캐시)
|
||||||
|
│ │ └── holidays.json # 한국 주식시장 휴장일
|
||||||
|
│ └── Dockerfile
|
||||||
|
│
|
||||||
|
├── travel-proxy/ # travel-proxy 서비스 (Python/FastAPI)
|
||||||
|
│ ├── app/
|
||||||
|
│ │ └── main.py # 사진 API, 썸네일 생성 (Pillow)
|
||||||
|
│ └── Dockerfile
|
||||||
|
│
|
||||||
|
├── deployer/ # Gitea Webhook 수신 → 자동 배포
|
||||||
|
│ ├── app.py # HMAC SHA256 검증 + 배포 트리거
|
||||||
|
│ └── Dockerfile
|
||||||
|
│
|
||||||
|
├── nginx/
|
||||||
|
│ └── default.conf # 리버스 프록시 + SPA + 캐시
|
||||||
|
│
|
||||||
|
├── scripts/
|
||||||
|
│ ├── deploy.sh # 운영 배포 (git pull → rsync → compose up)
|
||||||
|
│ ├── deploy-nas.sh # rsync 전용 스크립트
|
||||||
|
│ └── healthcheck.sh # 전체 서비스 헬스 체크
|
||||||
|
│
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── .env.example
|
||||||
|
└── CLAUDE.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 빠른 시작 (로컬 개발)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 환경변수 설정
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# 2. 컨테이너 실행 (.env 기본값으로 즉시 실행 가능)
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# 3. 확인
|
||||||
|
curl http://localhost:18000/health
|
||||||
|
curl http://localhost:18500/health
|
||||||
|
```
|
||||||
|
|
||||||
|
| 서비스 | 로컬 URL |
|
||||||
|
|--------|----------|
|
||||||
|
| Frontend + API | http://localhost:8080 |
|
||||||
|
| lotto-backend | http://localhost:18000 |
|
||||||
|
| stock-lab | http://localhost:18500 |
|
||||||
|
| travel-proxy | http://localhost:19000 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API 목록
|
||||||
|
|
||||||
|
### lotto-backend (`/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/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}` | 개별 삭제 |
|
||||||
|
|
||||||
|
> ⚠️ `/done` 라우트는 반드시 `/{id}` 보다 먼저 등록해야 함
|
||||||
|
|
||||||
|
#### 블로그
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/blog/posts` | 글 목록 (`{"posts": [...]}`, date DESC) |
|
||||||
|
| POST | `/api/blog/posts` | 글 생성 (date 미입력 시 오늘 날짜) |
|
||||||
|
| PUT | `/api/blog/posts/{id}` | 글 수정 |
|
||||||
|
| DELETE | `/api/blog/posts/{id}` | 글 삭제 |
|
||||||
|
|
||||||
|
블로그 포스트 구조: `{ id, title, tags[], body, date, excerpt, created_at, updated_at }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### stock-lab (`/api/stock/`, `/api/trade/`, `/api/portfolio`)
|
||||||
|
|
||||||
|
#### 뉴스 & 지표
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/stock/news` | 뉴스 목록 (limit, category) |
|
||||||
|
| GET | `/api/stock/indices` | 주요 지표 (KOSPI 등) |
|
||||||
|
| POST | `/api/stock/scrap` | 뉴스 수동 스크랩 |
|
||||||
|
|
||||||
|
#### 실계좌 (Windows AI 서버 프록시)
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/trade/balance` | 실계좌 잔고 조회 |
|
||||||
|
| POST | `/api/trade/order` | 주문 (BUY\|SELL, price=0이면 시장가) |
|
||||||
|
|
||||||
|
#### 포트폴리오
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| 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: 전체) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### travel-proxy (`/api/travel/`)
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/travel/regions` | 지역 GeoJSON |
|
||||||
|
| GET | `/api/travel/photos` | 사진 목록 (region, page, size) |
|
||||||
|
| POST | `/api/travel/reload` | 캐시 초기화 |
|
||||||
|
|
||||||
|
- 썸네일: `/media/travel/.thumb/{album}/{file}` (nginx 직접 서빙, 30일 캐시)
|
||||||
|
- 원본: `/media/travel/{album}/{file}` (nginx 직접 서빙, 7일 캐시)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 핵심 로직
|
||||||
|
|
||||||
|
### 몬테카를로 시뮬레이션 (lotto-backend)
|
||||||
|
|
||||||
|
```
|
||||||
|
역대 당첨번호 분석 → 번호별 가중치 산출
|
||||||
|
→ 가중 확률 샘플링으로 후보 20,000개 생성
|
||||||
|
→ 5가지 기법으로 각 조합 점수화
|
||||||
|
→ 상위 100개 DB 저장 → best_picks 20개 교체
|
||||||
|
```
|
||||||
|
|
||||||
|
**5가지 채점 기법:**
|
||||||
|
|
||||||
|
| 기법 | 가중치 | 내용 |
|
||||||
|
|------|--------|------|
|
||||||
|
| 빈도 Z-score | 25% | 번호 출현 빈도의 표준편차 |
|
||||||
|
| 조합 지문 | 30% | 합계 정규분포 + 홀짝 비율 + 구간분포 |
|
||||||
|
| 갭 분석 | 20% | 마지막 출현 이후 경과 회차 |
|
||||||
|
| 공동 출현 | 15% | 번호 쌍 동시 출현 빈도 |
|
||||||
|
| 다양성 | 10% | 연속번호·범위·구간 커버리지 |
|
||||||
|
|
||||||
|
**스케줄:** 매일 0, 4, 8, 12, 16, 20시 (하루 6회, 각 5분)
|
||||||
|
|
||||||
|
### 총 자산 스냅샷 (stock-lab)
|
||||||
|
|
||||||
|
```
|
||||||
|
평일 15:40 자동 실행 → holidays.json으로 공휴일 스킵
|
||||||
|
→ 포트폴리오 현재가 조회 → total_eval
|
||||||
|
→ 예수금 합계 → total_cash
|
||||||
|
→ asset_snapshots upsert (date UNIQUE, 같은 날 중복 시 덮어씀)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 현재가 조회 (stock-lab)
|
||||||
|
|
||||||
|
- 네이버 모바일 API 우선 (`m.stock.naver.com/api/stock/{ticker}/basic`)
|
||||||
|
- 실패 시 네이버 금융 HTML 파싱 폴백
|
||||||
|
- 3분 TTL 메모리 캐시
|
||||||
|
|
||||||
|
### 여행 사진 썸네일 (travel-proxy)
|
||||||
|
|
||||||
|
- 480×480 리사이징 (Pillow), 확장자 유지 (JPEG/PNG/WEBP)
|
||||||
|
- 온디맨드 생성 후 `/data/thumbs/` 영구 캐시
|
||||||
|
- 원자성 보장: tmp 파일 작성 후 rename
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 자동 배포
|
||||||
|
|
||||||
|
```
|
||||||
|
git push → Gitea → X-Gitea-Signature (HMAC SHA256)
|
||||||
|
→ deployer:9000/webhook (서명 검증, compare_digest 사용)
|
||||||
|
→ BackgroundTask: scripts/deploy.sh (10분 타임아웃)
|
||||||
|
1. git pull
|
||||||
|
2. .releases/{timestamp}/ 백업
|
||||||
|
3. rsync (repo → runtime)
|
||||||
|
4. docker compose up -d --build
|
||||||
|
5. chown PUID:PGID
|
||||||
|
```
|
||||||
|
|
||||||
|
> 프론트엔드는 **자동 배포 안 됨** — 로컬 빌드 후 NAS에 수동 업로드
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 데이터베이스
|
||||||
|
|
||||||
|
### lotto.db (`/app/data/lotto.db`)
|
||||||
|
|
||||||
|
| 테이블 | 설명 |
|
||||||
|
|--------|------|
|
||||||
|
| `draws` | 로또 당첨번호 |
|
||||||
|
| `recommendations` | 추천 이력 (즐겨찾기·태그·채점 포함) |
|
||||||
|
| `simulation_runs` | 시뮬레이션 실행 기록 |
|
||||||
|
| `simulation_candidates` | 시뮬레이션 후보 (점수 5종) |
|
||||||
|
| `best_picks` | 현재 활성 최적 번호 20개 (is_active 플래그) |
|
||||||
|
| `todos` | 투두리스트 (UUID PK) |
|
||||||
|
| `blog_posts` | 블로그 글 (tags: JSON 배열) |
|
||||||
|
|
||||||
|
### stock.db (`/app/data/stock.db`)
|
||||||
|
|
||||||
|
| 테이블 | 설명 |
|
||||||
|
|--------|------|
|
||||||
|
| `articles` | 뉴스 기사 (hash UNIQUE, category: domestic\|overseas) |
|
||||||
|
| `portfolio` | 보유 종목 (broker, ticker, quantity, avg_price) |
|
||||||
|
| `broker_cash` | 증권사별 예수금 (broker UNIQUE) |
|
||||||
|
| `asset_snapshots` | 일별 총 자산 스냅샷 (date UNIQUE) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 환경변수
|
||||||
|
|
||||||
|
```env
|
||||||
|
# 경로 설정
|
||||||
|
RUNTIME_PATH=.
|
||||||
|
REPO_PATH=.
|
||||||
|
FRONTEND_PATH=./frontend/dist
|
||||||
|
PHOTO_PATH=./mock_data/photos
|
||||||
|
|
||||||
|
# NAS 파일 권한
|
||||||
|
PUID=1000
|
||||||
|
PGID=1000
|
||||||
|
|
||||||
|
# 외부 서비스
|
||||||
|
WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000
|
||||||
|
WEBHOOK_SECRET=your_secret_here
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 인프라
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|------|----|
|
||||||
|
| 장비 | Synology NAS (Intel Celeron J4025, 18GB RAM) |
|
||||||
|
| Docker | Synology Container Manager |
|
||||||
|
| Git 서버 | Gitea (NAS 내부 self-hosted) |
|
||||||
|
| AI 서버 | Windows PC (192.168.45.59:8000) — RTX 3070 Ti + Ollama |
|
||||||
|
| Python | 3.12 (`slim` / `alpine` 기반 이미지) |
|
||||||
|
| DB | SQLite (볼륨 마운트로 영속 저장) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 주의사항
|
||||||
|
|
||||||
|
- **`.env` 파일** — 절대 커밋 금지. `.env.example`만 레포에 포함
|
||||||
|
- **Nginx trailing slash** — `/api/portfolio`는 두 location 블록으로 처리 (trailing slash 유무 모두 매칭)
|
||||||
|
- **라우트 순서** — `/api/todos/done`은 `/api/todos/{id}` 보다 먼저 등록 필수
|
||||||
|
- **캐시 전략** — `index.html`: no-store / `assets/`: 1년 immutable
|
||||||
|
- **PUID/PGID** — travel-proxy는 NAS 파일 권한을 위해 환경변수 주입 필수
|
||||||
|
- **공휴일 목록** — `stock-lab/app/holidays.json` 매년 수동 갱신 필요 (KRX 기준)
|
||||||
|
- **Windows AI 서버** — IP 192.168.45.59 (공유기 DHCP 고정 예약)
|
||||||
|
|||||||
@@ -16,3 +16,7 @@ ENV PYTHONUNBUFFERED=1
|
|||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|
||||||
|
ARG APP_VERSION=dev
|
||||||
|
ENV APP_VERSION=$APP_VERSION
|
||||||
|
LABEL org.opencontainers.image.version=$APP_VERSION
|
||||||
|
|||||||
529
backend/app/analyzer.py
Normal file
529
backend/app/analyzer.py
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
"""
|
||||||
|
통계 분석 엔진 - lotto-lab 고도화
|
||||||
|
|
||||||
|
[팀 회의 합의 기반 5가지 통계 기법]
|
||||||
|
1. 빈도 Z-score 분석: 각 번호의 출현 빈도가 기댓값에서 얼마나 벗어났는지
|
||||||
|
2. 조합 지문(Fingerprint): 조합의 합계, 홀짝 비율, 구간 분포가 역대 당첨번호와 유사한지
|
||||||
|
3. 갭 분석(Gap): 각 번호의 마지막 출현으로부터 경과 회차 수 기반 점수
|
||||||
|
4. 공동 출현 행렬(Co-occurrence): 번호 쌍이 역대에 함께 나온 빈도 기반 점수
|
||||||
|
5. 다양성(Diversity): 연속 번호, 범위, 구간 분포 다양성
|
||||||
|
|
||||||
|
[통계 근거]
|
||||||
|
- 1~45번 각각의 이론적 출현 확률: 6/45 ≈ 13.33% per draw
|
||||||
|
- 기댓값 합계: E[sum] = 6 × E[1..45] = 6 × 23 = 138
|
||||||
|
- 표준편차 합계: std ≈ sqrt(6 × Var[uniform 1..45]) ≈ 31
|
||||||
|
- 홀수 23개 (1,3,...,45), 짝수 22개 (2,4,...,44)
|
||||||
|
- 번호 쌍 공동 출현 확률: C(43,4)/C(45,6) ≈ 1.516% per draw
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import List, Tuple, Dict, Any, Optional
|
||||||
|
|
||||||
|
# 구간 정의: (시작, 끝) 포함
|
||||||
|
ZONE_RANGES: List[Tuple[int, int]] = [
|
||||||
|
(1, 9),
|
||||||
|
(10, 19),
|
||||||
|
(20, 29),
|
||||||
|
(30, 39),
|
||||||
|
(40, 45),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_zone(n: int) -> int:
|
||||||
|
"""번호가 속하는 구간 인덱스 (0-4)"""
|
||||||
|
for z, (lo, hi) in enumerate(ZONE_RANGES):
|
||||||
|
if lo <= n <= hi:
|
||||||
|
return z
|
||||||
|
return 4
|
||||||
|
|
||||||
|
|
||||||
|
def build_analysis_cache(draws: List[Tuple[int, List[int]]]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
역대 당첨번호 데이터 기반 통계 분석 캐시 구성.
|
||||||
|
시뮬레이션 실행 시 한 번만 호출하여 재사용 (성능 최적화).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
draws: [(drw_no, [n1,n2,n3,n4,n5,n6]), ...] 오름차순
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
통계 캐시 딕셔너리
|
||||||
|
"""
|
||||||
|
if not draws:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
total_draws = len(draws)
|
||||||
|
all_nums_list = [n for _, nums in draws for n in nums]
|
||||||
|
freq_all = Counter(all_nums_list)
|
||||||
|
|
||||||
|
# ── 1. 빈도 Z-score ──────────────────────────────────────────────────────
|
||||||
|
freq_values = [freq_all.get(n, 0) for n in range(1, 46)]
|
||||||
|
mean_freq = sum(freq_values) / 45.0
|
||||||
|
variance_freq = sum((f - mean_freq) ** 2 for f in freq_values) / 45.0
|
||||||
|
std_freq = math.sqrt(variance_freq)
|
||||||
|
|
||||||
|
z_scores: Dict[int, float] = {}
|
||||||
|
for n in range(1, 46):
|
||||||
|
z_scores[n] = (freq_all.get(n, 0) - mean_freq) / max(std_freq, 0.001)
|
||||||
|
|
||||||
|
# ── 2. 갭 분석: 마지막 출현 이후 경과 회차 ──────────────────────────────
|
||||||
|
# gap = 0: 가장 최근 회차에 출현, gap = k: k회 전에 마지막 출현
|
||||||
|
last_seen_gap: Dict[int, int] = {}
|
||||||
|
for gap_idx, (_, nums) in enumerate(reversed(draws)):
|
||||||
|
for n in nums:
|
||||||
|
if n not in last_seen_gap:
|
||||||
|
last_seen_gap[n] = gap_idx
|
||||||
|
for n in range(1, 46):
|
||||||
|
if n not in last_seen_gap:
|
||||||
|
last_seen_gap[n] = total_draws # 한 번도 안 나옴 (이론상 거의 불가)
|
||||||
|
|
||||||
|
# ── 3. 공동 출현 행렬 ────────────────────────────────────────────────────
|
||||||
|
# cooccur[(i,j)] = 번호 i와 j가 같은 회차에 함께 출현한 횟수 (i < j)
|
||||||
|
cooccur: Dict[Tuple[int, int], int] = defaultdict(int)
|
||||||
|
for _, nums in draws:
|
||||||
|
s = sorted(nums)
|
||||||
|
for i in range(len(s)):
|
||||||
|
for j in range(i + 1, len(s)):
|
||||||
|
cooccur[(s[i], s[j])] += 1
|
||||||
|
|
||||||
|
# 번호 쌍 공동 출현 기댓값: C(43,4)/C(45,6) × total_draws
|
||||||
|
# C(43,4) = 123,410 / C(45,6) = 8,145,060
|
||||||
|
expected_cooccur = total_draws * 123410.0 / 8145060.0
|
||||||
|
|
||||||
|
# ── 4. 역대 조합 통계 (합계, 홀수 개수) ──────────────────────────────────
|
||||||
|
historical_sums = [sum(nums) for _, nums in draws]
|
||||||
|
mean_sum = sum(historical_sums) / total_draws
|
||||||
|
std_sum = math.sqrt(
|
||||||
|
sum((s - mean_sum) ** 2 for s in historical_sums) / total_draws
|
||||||
|
)
|
||||||
|
std_sum = max(std_sum, 1.0) # 0 나누기 방지
|
||||||
|
|
||||||
|
historical_odds = [sum(1 for n in nums if n % 2 == 1) for _, nums in draws]
|
||||||
|
odd_dist = Counter(historical_odds)
|
||||||
|
odd_prob: Dict[int, float] = {k: v / total_draws for k, v in odd_dist.items()}
|
||||||
|
max_odd_prob = max(odd_prob.values()) if odd_prob else 1.0
|
||||||
|
|
||||||
|
# ── 5. 구간별 분포 통계 ───────────────────────────────────────────────────
|
||||||
|
# 각 구간에 몇 개 포함되는지의 역대 분포
|
||||||
|
zone_counts = [Counter() for _ in ZONE_RANGES]
|
||||||
|
for _, nums in draws:
|
||||||
|
for z_idx, (lo, hi) in enumerate(ZONE_RANGES):
|
||||||
|
cnt = sum(1 for n in nums if lo <= n <= hi)
|
||||||
|
zone_counts[z_idx][cnt] += 1
|
||||||
|
|
||||||
|
zone_probs: List[Dict[int, float]] = []
|
||||||
|
for zc in zone_counts:
|
||||||
|
total_z = sum(zc.values())
|
||||||
|
zone_probs.append({k: v / total_z for k, v in zc.items()})
|
||||||
|
|
||||||
|
max_zone_probs = [max(zp.values()) if zp else 1.0 for zp in zone_probs]
|
||||||
|
|
||||||
|
# ── 6. 최근 빈도 (후보 생성 가중치용) ────────────────────────────────────
|
||||||
|
recent_100 = draws[-100:] if len(draws) >= 100 else draws
|
||||||
|
freq_recent = Counter(n for _, nums in recent_100 for n in nums)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_draws": total_draws,
|
||||||
|
"freq_all": freq_all,
|
||||||
|
"z_scores": z_scores,
|
||||||
|
"last_seen_gap": last_seen_gap,
|
||||||
|
"cooccur": dict(cooccur),
|
||||||
|
"expected_cooccur": expected_cooccur,
|
||||||
|
"mean_sum": mean_sum,
|
||||||
|
"std_sum": std_sum,
|
||||||
|
"odd_prob": odd_prob,
|
||||||
|
"max_odd_prob": max_odd_prob,
|
||||||
|
"zone_probs": zone_probs,
|
||||||
|
"max_zone_probs": max_zone_probs,
|
||||||
|
"freq_recent": freq_recent,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_number_weights(cache: Dict[str, Any]) -> Dict[int, float]:
|
||||||
|
"""
|
||||||
|
몬테카를로 시뮬레이션의 후보 생성에 사용할 번호별 샘플링 가중치.
|
||||||
|
빈도 + 최근 빈도 + 갭 분석을 반영하여 '좋은' 번호가 더 자주 선택되도록 유도.
|
||||||
|
"""
|
||||||
|
freq_all = cache["freq_all"]
|
||||||
|
last_seen_gap = cache["last_seen_gap"]
|
||||||
|
freq_recent = cache["freq_recent"]
|
||||||
|
|
||||||
|
weights: Dict[int, float] = {}
|
||||||
|
for n in range(1, 46):
|
||||||
|
w = freq_all.get(n, 0) + 1.5 * freq_recent.get(n, 0)
|
||||||
|
|
||||||
|
gap = last_seen_gap.get(n, 0)
|
||||||
|
if gap <= 1:
|
||||||
|
gap_factor = 0.50 # 바로 직전 등장 → 패널티
|
||||||
|
elif gap <= 3:
|
||||||
|
gap_factor = 0.75
|
||||||
|
elif gap <= 12:
|
||||||
|
gap_factor = 1.00 # 적정 범위
|
||||||
|
elif gap <= 25:
|
||||||
|
gap_factor = 1.10 # 약간 오래된 번호 → 소폭 보너스
|
||||||
|
else:
|
||||||
|
gap_factor = 1.20 # 오래된 번호 → 보너스
|
||||||
|
|
||||||
|
weights[n] = max(w * gap_factor, 0.5)
|
||||||
|
|
||||||
|
return weights
|
||||||
|
|
||||||
|
|
||||||
|
def score_combination(numbers: List[int], cache: Dict[str, Any]) -> Dict[str, float]:
|
||||||
|
"""
|
||||||
|
6개 번호 조합의 통계적 품질 점수 계산 (0~1 범위 정규화).
|
||||||
|
|
||||||
|
5가지 기법별 점수:
|
||||||
|
- score_frequency (25%): 빈도 Z-score
|
||||||
|
- score_fingerprint(30%): 조합의 통계적 지문 (합계, 홀짝, 구간)
|
||||||
|
- score_gap (20%): 갭 분석
|
||||||
|
- score_cooccur (15%): 공동 출현 기댓값 대비
|
||||||
|
- score_diversity (10%): 연속번호, 범위, 구간 다양성
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"score_total": ..., "score_frequency": ..., ...}
|
||||||
|
"""
|
||||||
|
nums = sorted(numbers)
|
||||||
|
|
||||||
|
# ── 1. 빈도 점수 (Frequency Score) ────────────────────────────────────────
|
||||||
|
z_scores = cache["z_scores"]
|
||||||
|
avg_z = sum(z_scores.get(n, 0.0) for n in nums) / 6.0
|
||||||
|
# Sigmoid 정규화: avg_z > 0이면 0.5 이상
|
||||||
|
score_frequency = 1.0 / (1.0 + math.exp(-avg_z / 1.5))
|
||||||
|
|
||||||
|
# ── 2. 조합 지문 점수 (Fingerprint Score) ─────────────────────────────────
|
||||||
|
# 2a. 합계 정규분포 점수
|
||||||
|
total = sum(nums)
|
||||||
|
mean_sum = cache["mean_sum"]
|
||||||
|
std_sum = cache["std_sum"]
|
||||||
|
z_sum = (total - mean_sum) / std_sum
|
||||||
|
sum_score = math.exp(-0.5 * z_sum ** 2) # 정규분포 밀도 (peak=1 at mean)
|
||||||
|
|
||||||
|
# 2b. 홀짝 비율 점수
|
||||||
|
odd_count = sum(1 for n in nums if n % 2 == 1)
|
||||||
|
odd_prob = cache["odd_prob"]
|
||||||
|
max_odd_prob = cache["max_odd_prob"]
|
||||||
|
odd_score = odd_prob.get(odd_count, 0.01) / max_odd_prob
|
||||||
|
|
||||||
|
# 2c. 구간 분포 점수
|
||||||
|
zone_probs = cache["zone_probs"]
|
||||||
|
max_zone_probs = cache["max_zone_probs"]
|
||||||
|
zone_score = 0.0
|
||||||
|
for z_idx, (lo, hi) in enumerate(ZONE_RANGES):
|
||||||
|
cnt = sum(1 for n in nums if lo <= n <= hi)
|
||||||
|
zp = zone_probs[z_idx]
|
||||||
|
mzp = max_zone_probs[z_idx]
|
||||||
|
zone_score += zp.get(cnt, 0.01) / mzp
|
||||||
|
zone_score /= len(ZONE_RANGES)
|
||||||
|
|
||||||
|
score_fingerprint = sum_score * 0.50 + odd_score * 0.30 + zone_score * 0.20
|
||||||
|
|
||||||
|
# ── 3. 갭 점수 (Gap Score) ────────────────────────────────────────────────
|
||||||
|
last_seen_gap = cache["last_seen_gap"]
|
||||||
|
gap_scores: List[float] = []
|
||||||
|
for n in nums:
|
||||||
|
gap = last_seen_gap.get(n, 0)
|
||||||
|
if gap <= 1:
|
||||||
|
gs = 0.20 # 직전 등장 번호 - 강한 패널티
|
||||||
|
elif gap <= 3:
|
||||||
|
gs = 0.55
|
||||||
|
elif gap <= 7:
|
||||||
|
gs = 0.85
|
||||||
|
elif gap <= 15:
|
||||||
|
gs = 1.00 # 최적 범위
|
||||||
|
elif gap <= 25:
|
||||||
|
gs = 0.90
|
||||||
|
else:
|
||||||
|
gs = 0.75 # 오래된 번호 - 여전히 양호
|
||||||
|
gap_scores.append(gs)
|
||||||
|
score_gap = sum(gap_scores) / 6.0
|
||||||
|
|
||||||
|
# ── 4. 공동 출현 점수 (Co-occurrence Score) ───────────────────────────────
|
||||||
|
cooccur = cache["cooccur"]
|
||||||
|
expected_cooccur = cache["expected_cooccur"]
|
||||||
|
|
||||||
|
pair_scores: List[float] = []
|
||||||
|
for i in range(len(nums)):
|
||||||
|
for j in range(i + 1, len(nums)):
|
||||||
|
actual = cooccur.get((nums[i], nums[j]), 0)
|
||||||
|
ratio = actual / max(expected_cooccur, 0.001)
|
||||||
|
# Sigmoid: ratio = 1에서 0.5, ratio > 1이면 > 0.5
|
||||||
|
ps = 1.0 / (1.0 + math.exp(-2.0 * (ratio - 1.0)))
|
||||||
|
pair_scores.append(ps)
|
||||||
|
score_cooccur = sum(pair_scores) / max(len(pair_scores), 1)
|
||||||
|
|
||||||
|
# ── 5. 다양성 점수 (Diversity Score) ─────────────────────────────────────
|
||||||
|
# 5a. 연속 번호 포함 여부 (역대 당첨번호 약 52%에 최소 1쌍 포함)
|
||||||
|
has_consecutive = any(nums[i + 1] - nums[i] == 1 for i in range(len(nums) - 1))
|
||||||
|
consecutive_score = 0.65 if has_consecutive else 0.40
|
||||||
|
|
||||||
|
# 5b. 범위 점수 (최소~최대 차이)
|
||||||
|
num_range = nums[-1] - nums[0]
|
||||||
|
if 28 <= num_range <= 43:
|
||||||
|
spread_score = 1.00
|
||||||
|
elif 20 <= num_range < 28:
|
||||||
|
spread_score = 0.85
|
||||||
|
elif 13 <= num_range < 20:
|
||||||
|
spread_score = 0.65
|
||||||
|
elif num_range < 13:
|
||||||
|
spread_score = 0.25
|
||||||
|
else: # > 43 (최대 44: 1~45)
|
||||||
|
spread_score = 0.95
|
||||||
|
|
||||||
|
# 5c. 구간 커버리지 (몇 개 구간에 걸쳐 있는가)
|
||||||
|
zones_used = set(_get_zone(n) for n in nums)
|
||||||
|
zone_coverage = (len(zones_used) - 1) / 4.0 # 0~1
|
||||||
|
|
||||||
|
score_diversity = (
|
||||||
|
consecutive_score * 0.35
|
||||||
|
+ spread_score * 0.35
|
||||||
|
+ zone_coverage * 0.30
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 최종 가중 합산 ────────────────────────────────────────────────────────
|
||||||
|
score_total = (
|
||||||
|
score_frequency * 0.25
|
||||||
|
+ score_fingerprint * 0.30
|
||||||
|
+ score_gap * 0.20
|
||||||
|
+ score_cooccur * 0.15
|
||||||
|
+ score_diversity * 0.10
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"score_total": round(score_total, 6),
|
||||||
|
"score_frequency": round(score_frequency, 6),
|
||||||
|
"score_fingerprint": round(score_fingerprint, 6),
|
||||||
|
"score_gap": round(score_gap, 6),
|
||||||
|
"score_cooccur": round(score_cooccur, 6),
|
||||||
|
"score_diversity": round(score_diversity, 6),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_statistical_report(draws: List[Tuple[int, List[int]]]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
통계 분석 리포트 생성 (GET /api/lotto/analysis 응답용).
|
||||||
|
각 번호의 빈도, Z-score, 갭, 히트/콜드/오버듀 분류를 반환.
|
||||||
|
"""
|
||||||
|
if not draws:
|
||||||
|
return {"error": "데이터 없음"}
|
||||||
|
|
||||||
|
cache = build_analysis_cache(draws)
|
||||||
|
total_draws = cache["total_draws"]
|
||||||
|
freq_all = cache["freq_all"]
|
||||||
|
z_scores = cache["z_scores"]
|
||||||
|
last_seen_gap = cache["last_seen_gap"]
|
||||||
|
|
||||||
|
number_stats = []
|
||||||
|
for n in range(1, 46):
|
||||||
|
freq = freq_all.get(n, 0)
|
||||||
|
expected = total_draws * 6.0 / 45.0
|
||||||
|
number_stats.append({
|
||||||
|
"number": n,
|
||||||
|
"frequency": freq,
|
||||||
|
"expected": round(expected, 1),
|
||||||
|
"frequency_pct": round(freq / (total_draws * 6) * 100, 2),
|
||||||
|
"z_score": round(z_scores.get(n, 0.0), 3),
|
||||||
|
"gap": last_seen_gap.get(n, total_draws),
|
||||||
|
"zone": _get_zone(n),
|
||||||
|
})
|
||||||
|
|
||||||
|
sorted_by_freq = sorted(number_stats, key=lambda x: -x["frequency"])
|
||||||
|
sorted_by_gap = sorted(number_stats, key=lambda x: -x["gap"])
|
||||||
|
|
||||||
|
# 역대 합계 분포 요약
|
||||||
|
hist_sums = [sum(nums) for _, nums in draws]
|
||||||
|
sum_buckets: Dict[str, int] = {}
|
||||||
|
for lo in range(21, 256, 20):
|
||||||
|
hi = lo + 19
|
||||||
|
key = f"{lo}-{hi}"
|
||||||
|
sum_buckets[key] = sum(1 for s in hist_sums if lo <= s <= hi)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_draws": total_draws,
|
||||||
|
"mean_sum": round(cache["mean_sum"], 2),
|
||||||
|
"std_sum": round(cache["std_sum"], 2),
|
||||||
|
"odd_distribution": {
|
||||||
|
str(k): round(v * 100, 1)
|
||||||
|
for k, v in sorted(cache["odd_prob"].items())
|
||||||
|
},
|
||||||
|
"number_stats": number_stats,
|
||||||
|
"hot_numbers": [x["number"] for x in sorted_by_freq[:10]],
|
||||||
|
"cold_numbers": [x["number"] for x in sorted_by_freq[-10:]],
|
||||||
|
"overdue_numbers": [x["number"] for x in sorted_by_gap[:10]],
|
||||||
|
"sum_distribution": sum_buckets,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_personal_patterns(
|
||||||
|
all_numbers: List[List[int]],
|
||||||
|
draws: List[Tuple[int, List[int]]],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
사용자 추천 이력 기반 개인 패턴 분석.
|
||||||
|
all_numbers: 저장된 모든 추천 번호 리스트 (각 원소는 6개짜리 리스트)
|
||||||
|
draws: 역대 당첨번호 (홀짝/합계 평균 비교용)
|
||||||
|
"""
|
||||||
|
if not all_numbers:
|
||||||
|
return {"total_analyzed": 0, "message": "추천 이력이 없습니다"}
|
||||||
|
|
||||||
|
total = len(all_numbers)
|
||||||
|
flat = [n for nums in all_numbers for n in nums]
|
||||||
|
freq = Counter(flat)
|
||||||
|
|
||||||
|
# 번호별 선택 빈도
|
||||||
|
number_frequency = {n: freq.get(n, 0) for n in range(1, 46)}
|
||||||
|
top_picks = sorted(range(1, 46), key=lambda n: -freq.get(n, 0))[:10]
|
||||||
|
least_picks = [n for n in sorted(range(1, 46), key=lambda n: freq.get(n, 0)) if freq.get(n, 0) == 0][:10]
|
||||||
|
|
||||||
|
# 패턴 지표
|
||||||
|
odd_counts = [sum(1 for n in nums if n % 2 == 1) for nums in all_numbers]
|
||||||
|
sums = [sum(nums) for nums in all_numbers]
|
||||||
|
ranges = [max(nums) - min(nums) for nums in all_numbers]
|
||||||
|
consecutive_count = sum(
|
||||||
|
1 for nums in all_numbers
|
||||||
|
if any(sorted(nums)[i + 1] - sorted(nums)[i] == 1 for i in range(5))
|
||||||
|
)
|
||||||
|
|
||||||
|
zone_totals = {k: 0 for k in ["1-9", "10-19", "20-29", "30-39", "40-45"]}
|
||||||
|
zone_ranges = [("1-9", 1, 9), ("10-19", 10, 19), ("20-29", 20, 29), ("30-39", 30, 39), ("40-45", 40, 45)]
|
||||||
|
for nums in all_numbers:
|
||||||
|
for label, lo, hi in zone_ranges:
|
||||||
|
zone_totals[label] += sum(1 for n in nums if lo <= n <= hi)
|
||||||
|
zone_avg = {k: round(v / total, 2) for k, v in zone_totals.items()}
|
||||||
|
|
||||||
|
avg_odd = sum(odd_counts) / total
|
||||||
|
avg_sum = sum(sums) / total
|
||||||
|
avg_range = sum(ranges) / total
|
||||||
|
|
||||||
|
# 역대 당첨번호 평균과 비교
|
||||||
|
if draws:
|
||||||
|
draw_odd_avg = sum(sum(1 for n in nums if n % 2 == 1) for _, nums in draws) / len(draws)
|
||||||
|
draw_sum_avg = sum(sum(nums) for _, nums in draws) / len(draws)
|
||||||
|
else:
|
||||||
|
draw_odd_avg = 3.0
|
||||||
|
draw_sum_avg = 138.0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_analyzed": total,
|
||||||
|
"number_frequency": number_frequency,
|
||||||
|
"top_picks": top_picks,
|
||||||
|
"least_picks": least_picks,
|
||||||
|
"pattern": {
|
||||||
|
"avg_odd_count": round(avg_odd, 2),
|
||||||
|
"avg_sum": round(avg_sum, 1),
|
||||||
|
"avg_range": round(avg_range, 1),
|
||||||
|
"consecutive_rate": round(consecutive_count / total, 3),
|
||||||
|
"zone_avg": zone_avg,
|
||||||
|
},
|
||||||
|
"vs_draw_avg": {
|
||||||
|
"odd_diff": round(avg_odd - draw_odd_avg, 2),
|
||||||
|
"sum_diff": round(avg_sum - draw_sum_avg, 1),
|
||||||
|
"odd_tendency": "홀수 선호" if avg_odd > draw_odd_avg + 0.2 else ("짝수 선호" if avg_odd < draw_odd_avg - 0.2 else "균형"),
|
||||||
|
"sum_tendency": "고합계 선호" if avg_sum > draw_sum_avg + 5 else ("저합계 선호" if avg_sum < draw_sum_avg - 5 else "균형"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_weekly_report(draws: List[Tuple[int, List[int]]], target_drw_no: int) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
특정 회차 공략 리포트 생성.
|
||||||
|
target_drw_no: 공략 대상 회차 (아직 추첨 안 된 회차)
|
||||||
|
draws: target_drw_no 이전까지의 당첨번호 (오름차순)
|
||||||
|
"""
|
||||||
|
if not draws:
|
||||||
|
return {"error": "데이터 없음"}
|
||||||
|
|
||||||
|
cache = build_analysis_cache(draws)
|
||||||
|
total_draws = cache["total_draws"]
|
||||||
|
freq_all = cache["freq_all"]
|
||||||
|
last_seen_gap = cache["last_seen_gap"]
|
||||||
|
|
||||||
|
recent_10 = draws[-10:] if len(draws) >= 10 else draws
|
||||||
|
recent_3 = draws[-3:] if len(draws) >= 3 else draws
|
||||||
|
|
||||||
|
# 과출현: 최근 10회에 2회 이상 출현 번호 (출현 많은 순)
|
||||||
|
r10_nums = [n for _, nums in recent_10 for n in nums]
|
||||||
|
r10_freq = Counter(r10_nums)
|
||||||
|
hot_numbers = [n for n, _ in sorted(r10_freq.items(), key=lambda x: -x[1]) if r10_freq[n] >= 2]
|
||||||
|
|
||||||
|
# 냉각: 역대 출현 빈도 낮은 번호
|
||||||
|
cold_numbers = sorted(range(1, 46), key=lambda n: freq_all.get(n, 0))[:10]
|
||||||
|
|
||||||
|
# 오버듀: 가장 오래 미출현 번호
|
||||||
|
overdue_numbers = sorted(range(1, 46), key=lambda n: -last_seen_gap.get(n, 0))[:10]
|
||||||
|
|
||||||
|
# 최근 3회 연속 출현 (2회 이상)
|
||||||
|
r3_nums = [n for _, nums in recent_3 for n in nums]
|
||||||
|
r3_freq = Counter(r3_nums)
|
||||||
|
triple_appear = sorted(n for n, cnt in r3_freq.items() if cnt >= 2)
|
||||||
|
|
||||||
|
recent_sums = [sum(nums) for _, nums in recent_10]
|
||||||
|
recent_odd = [sum(1 for n in nums if n % 2 == 1) for _, nums in recent_10]
|
||||||
|
|
||||||
|
# 갭 기반 가중치 (오래된 번호일수록 높음)
|
||||||
|
gap_w = {n: last_seen_gap.get(n, 0) for n in range(1, 46)}
|
||||||
|
|
||||||
|
def _pick(exclude=None, prefer=None, n=6):
|
||||||
|
ex = set(exclude or [])
|
||||||
|
chosen = []
|
||||||
|
# prefer에서 최대 3개 우선 선택
|
||||||
|
for p in (prefer or []):
|
||||||
|
if p not in ex and len(chosen) < 3:
|
||||||
|
chosen.append(p)
|
||||||
|
# 구간별 1개씩 (갭 우선)
|
||||||
|
for lo, hi in [(1, 9), (10, 19), (20, 29), (30, 39), (40, 45)]:
|
||||||
|
if len(chosen) >= n:
|
||||||
|
break
|
||||||
|
cands = [x for x in range(lo, hi + 1) if x not in ex and x not in chosen]
|
||||||
|
if cands:
|
||||||
|
chosen.append(max(cands, key=lambda x: gap_w.get(x, 0)))
|
||||||
|
# 부족하면 나머지에서 갭 순
|
||||||
|
rest = sorted(
|
||||||
|
[x for x in range(1, 46) if x not in ex and x not in chosen],
|
||||||
|
key=lambda x: -gap_w.get(x, 0),
|
||||||
|
)
|
||||||
|
while len(chosen) < n and rest:
|
||||||
|
chosen.append(rest.pop(0))
|
||||||
|
return sorted(chosen[:n])
|
||||||
|
|
||||||
|
set1 = _pick(exclude=hot_numbers[:5], prefer=overdue_numbers[:5])
|
||||||
|
set2 = _pick()
|
||||||
|
set3 = _pick(exclude=hot_numbers)
|
||||||
|
|
||||||
|
# 신뢰도 점수
|
||||||
|
data_vol = min(total_draws / 500, 1.0)
|
||||||
|
if len(recent_sums) > 1:
|
||||||
|
avg_s = sum(recent_sums) / len(recent_sums)
|
||||||
|
std_s = (sum((s - avg_s) ** 2 for s in recent_sums) / len(recent_sums)) ** 0.5
|
||||||
|
pattern = max(0.0, 1.0 - std_s / 60.0)
|
||||||
|
else:
|
||||||
|
pattern = 0.5
|
||||||
|
trend = max(0.0, 1.0 - len(hot_numbers) / max(len(r10_nums), 1))
|
||||||
|
confidence = round((data_vol * 0.4 + pattern * 0.35 + trend * 0.25) * 100)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"target_drw_no": target_drw_no,
|
||||||
|
"based_on_draw": draws[-1][0],
|
||||||
|
"generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
"hot_numbers": hot_numbers[:8],
|
||||||
|
"cold_numbers": cold_numbers,
|
||||||
|
"overdue_numbers": overdue_numbers,
|
||||||
|
"recent_pattern": {
|
||||||
|
"last3_numbers": sorted(set(r3_nums)),
|
||||||
|
"triple_appear": triple_appear,
|
||||||
|
"recent_sum_avg": round(sum(recent_sums) / len(recent_sums), 1) if recent_sums else 0,
|
||||||
|
"recent_odd_avg": round(sum(recent_odd) / len(recent_odd), 1) if recent_odd else 0,
|
||||||
|
},
|
||||||
|
"recommended_sets": [
|
||||||
|
{"strategy": "냉각번호 중심", "numbers": set1, "description": "오랫동안 미출현 번호 위주 + 과출현 제외"},
|
||||||
|
{"strategy": "균형형", "numbers": set2, "description": "구간 균형 + 갭 최적화"},
|
||||||
|
{"strategy": "과출현 피하기", "numbers": set3, "description": "최근 자주 나온 번호 완전 제외"},
|
||||||
|
],
|
||||||
|
"confidence_score": confidence,
|
||||||
|
"confidence_factors": {
|
||||||
|
"data_volume": round(data_vol * 100),
|
||||||
|
"pattern_consistency": round(pattern * 100),
|
||||||
|
"recent_trend": round(trend * 100),
|
||||||
|
},
|
||||||
|
}
|
||||||
66
backend/app/checker.py
Normal file
66
backend/app/checker.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import json
|
||||||
|
from .db import (
|
||||||
|
_conn, get_draw, update_recommendation_result
|
||||||
|
)
|
||||||
|
|
||||||
|
def _calc_rank(my_nums: list[int], win_nums: list[int], bonus: int) -> tuple[int, int, bool]:
|
||||||
|
"""
|
||||||
|
(rank, correct_cnt, has_bonus) 반환
|
||||||
|
rank: 1~5 (1등~5등), 0 (낙첨)
|
||||||
|
"""
|
||||||
|
matched = set(my_nums) & set(win_nums)
|
||||||
|
cnt = len(matched)
|
||||||
|
has_bonus = bonus in my_nums
|
||||||
|
|
||||||
|
if cnt == 6:
|
||||||
|
return 1, cnt, has_bonus
|
||||||
|
if cnt == 5 and has_bonus:
|
||||||
|
return 2, cnt, has_bonus
|
||||||
|
if cnt == 5:
|
||||||
|
return 3, cnt, has_bonus
|
||||||
|
if cnt == 4:
|
||||||
|
return 4, cnt, has_bonus
|
||||||
|
if cnt == 3:
|
||||||
|
return 5, cnt, has_bonus
|
||||||
|
|
||||||
|
return 0, cnt, has_bonus
|
||||||
|
|
||||||
|
def check_results_for_draw(drw_no: int) -> int:
|
||||||
|
"""
|
||||||
|
특정 회차(drw_no) 결과가 나왔을 때,
|
||||||
|
해당 회차를 타겟으로 했던(based_on_draw == drw_no - 1) 추천들을 채점한다.
|
||||||
|
반환값: 채점한 개수
|
||||||
|
"""
|
||||||
|
win_row = get_draw(drw_no)
|
||||||
|
if not win_row:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
win_nums = [
|
||||||
|
win_row["n1"], win_row["n2"], win_row["n3"],
|
||||||
|
win_row["n4"], win_row["n5"], win_row["n6"]
|
||||||
|
]
|
||||||
|
bonus = win_row["bonus"]
|
||||||
|
|
||||||
|
# based_on_draw가 (이번회차 - 1)인 것들 조회
|
||||||
|
# 즉, 1000회차 추첨 결과로는, 999회차 데이터를 바탕으로 1000회차를 예측한 것들을 채점
|
||||||
|
target_based_on = drw_no - 1
|
||||||
|
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, numbers
|
||||||
|
FROM recommendations
|
||||||
|
WHERE based_on_draw = ? AND checked = 0
|
||||||
|
""",
|
||||||
|
(target_based_on,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for r in rows:
|
||||||
|
my_nums = json.loads(r["numbers"])
|
||||||
|
rank, correct, has_bonus = _calc_rank(my_nums, win_nums, bonus)
|
||||||
|
|
||||||
|
update_recommendation_result(r["id"], rank, correct, has_bonus)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
return count
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import requests
|
import requests
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
|
|
||||||
from .db import get_draw, upsert_draw
|
from .db import get_draw, upsert_draw, upsert_many_draws, get_latest_draw, count_draws
|
||||||
|
|
||||||
def _normalize_item(item: dict) -> dict:
|
def _normalize_item(item: dict) -> dict:
|
||||||
# smok95 all.json / latest.json 구조
|
# smok95 all.json / latest.json 구조
|
||||||
@@ -27,20 +27,13 @@ def sync_all_from_json(all_url: str) -> Dict[str, Any]:
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json() # list[dict]
|
data = r.json() # list[dict]
|
||||||
|
|
||||||
inserted = 0
|
# 정규화
|
||||||
skipped = 0
|
rows = [_normalize_item(item) for item in data]
|
||||||
|
|
||||||
for item in data:
|
# Bulk Insert (성능 향상)
|
||||||
row = _normalize_item(item)
|
upsert_many_draws(rows)
|
||||||
|
|
||||||
if get_draw(row["drw_no"]):
|
return {"mode": "all_json", "url": all_url, "total": len(rows)}
|
||||||
skipped += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
upsert_draw(row)
|
|
||||||
inserted += 1
|
|
||||||
|
|
||||||
return {"mode": "all_json", "url": all_url, "inserted": inserted, "skipped": skipped, "total": len(data)}
|
|
||||||
|
|
||||||
def sync_latest(latest_url: str) -> Dict[str, Any]:
|
def sync_latest(latest_url: str) -> Dict[str, Any]:
|
||||||
r = requests.get(latest_url, timeout=30)
|
r = requests.get(latest_url, timeout=30)
|
||||||
@@ -53,3 +46,40 @@ def sync_latest(latest_url: str) -> Dict[str, Any]:
|
|||||||
|
|
||||||
return {"mode": "latest_json", "url": latest_url, "was_new": (before is None), "drawNo": row["drw_no"]}
|
return {"mode": "latest_json", "url": latest_url, "was_new": (before is None), "drawNo": row["drw_no"]}
|
||||||
|
|
||||||
|
def sync_ensure_all(latest_url: str, all_url: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
1회부터 최신 회차까지 빠짐없이 있는지 확인하고, 없으면 전체 동기화 수행.
|
||||||
|
반환값: {"synced": bool, "reason": str, ...}
|
||||||
|
"""
|
||||||
|
# 1. 원격 최신 회차 확인
|
||||||
|
try:
|
||||||
|
r = requests.get(latest_url, timeout=10)
|
||||||
|
r.raise_for_status()
|
||||||
|
remote_item = r.json()
|
||||||
|
remote_no = int(remote_item["draw_no"])
|
||||||
|
except Exception as e:
|
||||||
|
# 외부 통신 실패 시, 그냥 로컬 데이터로 진행하도록 에러 억제 (혹은 에러 발생)
|
||||||
|
# 여기서는 통계 기능 작동이 우선이므로 로그만 남기고 pass하고 싶지만,
|
||||||
|
# 확실한 동기화를 위해 에러를 던지거나 False 리턴
|
||||||
|
return {"synced": False, "error": str(e)}
|
||||||
|
|
||||||
|
# 2. 로컬 상태 확인
|
||||||
|
local_latest_row = get_latest_draw()
|
||||||
|
local_no = local_latest_row["drw_no"] if local_latest_row else 0
|
||||||
|
local_cnt = count_draws()
|
||||||
|
|
||||||
|
# 3. 동기화 필요 여부 판단
|
||||||
|
# - 전체 개수가 최신 회차 번호보다 적으면 중간에 빈 것 (1회부터 시작한다고 가정)
|
||||||
|
# - 혹은 DB 최신 번호가 원격보다 낮으면 업데이트 필요
|
||||||
|
need_sync = (local_no < remote_no) or (local_cnt < local_no)
|
||||||
|
|
||||||
|
if not need_sync:
|
||||||
|
return {"synced": True, "updated": False, "local_no": local_no}
|
||||||
|
|
||||||
|
# 4. 전체 동기화 실행
|
||||||
|
# (단순 latest sync로는 중간 구멍을 못 채우므로, 구멍이 있거나 차이가 크면 all_sync 수행)
|
||||||
|
# 만약 차이가 1회차 뿐이고 구멍이 없다면 sync_latest만 해도 되지만,
|
||||||
|
# 로직 단순화를 위해 missing 감지 시 그냥 all_sync (Bulk Insert라 빠름)
|
||||||
|
res = sync_all_from_json(all_url)
|
||||||
|
return {"synced": True, "updated": True, "detail": res}
|
||||||
|
|
||||||
|
|||||||
1089
backend/app/db.py
1089
backend/app/db.py
File diff suppressed because it is too large
Load Diff
154
backend/app/generator.py
Normal file
154
backend/app/generator.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
"""
|
||||||
|
시뮬레이션 엔진 - lotto-lab 고도화
|
||||||
|
|
||||||
|
[몬테카를로 시뮬레이션 흐름]
|
||||||
|
1. 역대 당첨번호 기반 통계 캐시 구성 (build_analysis_cache)
|
||||||
|
2. 통계 가중치로 N개 후보 조합 생성 (weighted sampling)
|
||||||
|
3. 5가지 기법으로 각 후보 스코어링 (score_combination)
|
||||||
|
4. 상위 top_k개 선별하여 DB 저장 (simulation_candidates, best_picks 교체)
|
||||||
|
|
||||||
|
[시뮬레이션 파라미터]
|
||||||
|
- n_candidates: 1회 시뮬레이션당 생성 후보 수 (기본 20,000)
|
||||||
|
- top_k: 선별 및 저장할 상위 개수 (기본 100)
|
||||||
|
- best_n: best_picks에 올릴 최상위 개수 (기본 20)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import random
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
|
||||||
|
from .db import (
|
||||||
|
get_latest_draw,
|
||||||
|
get_all_draw_numbers,
|
||||||
|
save_simulation_run,
|
||||||
|
save_simulation_candidates_bulk,
|
||||||
|
replace_best_picks,
|
||||||
|
)
|
||||||
|
from .analyzer import build_analysis_cache, build_number_weights, score_combination
|
||||||
|
|
||||||
|
|
||||||
|
def _weighted_sample_6(weights: Dict[int, float]) -> List[int]:
|
||||||
|
"""
|
||||||
|
가중 확률 샘플링으로 중복 없이 6개 번호 추출.
|
||||||
|
weights: {1: w1, 2: w2, ..., 45: w45}
|
||||||
|
"""
|
||||||
|
pool = list(range(1, 46))
|
||||||
|
chosen: List[int] = []
|
||||||
|
for _ in range(6):
|
||||||
|
total = sum(weights[n] for n in pool)
|
||||||
|
r = random.random() * total
|
||||||
|
acc = 0.0
|
||||||
|
for n in pool:
|
||||||
|
acc += weights[n]
|
||||||
|
if acc >= r:
|
||||||
|
chosen.append(n)
|
||||||
|
pool.remove(n)
|
||||||
|
break
|
||||||
|
return chosen
|
||||||
|
|
||||||
|
|
||||||
|
def run_simulation(
|
||||||
|
n_candidates: int = 20000,
|
||||||
|
top_k: int = 100,
|
||||||
|
best_n: int = 20,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
몬테카를로 시뮬레이션 실행 메인 함수.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
n_candidates: 생성할 후보 조합 수 (기본 20,000)
|
||||||
|
top_k: DB에 저장할 상위 후보 수 (기본 100)
|
||||||
|
best_n: best_picks에 올릴 최상위 수 (기본 20)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{run_id, total_generated, top_k_selected, avg_score, best_score, based_on_draw}
|
||||||
|
또는 {"error": ...}
|
||||||
|
"""
|
||||||
|
draws = get_all_draw_numbers()
|
||||||
|
if not draws:
|
||||||
|
return {"error": "당첨번호 데이터가 없습니다. 먼저 동기화를 실행하세요."}
|
||||||
|
|
||||||
|
latest = get_latest_draw()
|
||||||
|
based_on_draw = latest["drw_no"] if latest else None
|
||||||
|
|
||||||
|
# ── 1. 통계 캐시 및 가중치 구성 (시뮬레이션 전체에서 재사용) ────────────
|
||||||
|
cache = build_analysis_cache(draws)
|
||||||
|
weights = build_number_weights(cache)
|
||||||
|
|
||||||
|
# ── 2. 후보 생성 및 스코어링 ──────────────────────────────────────────────
|
||||||
|
candidates: List[Dict[str, Any]] = []
|
||||||
|
seen_keys: set = set()
|
||||||
|
max_attempts = n_candidates * 3 # 중복 제거 여유분
|
||||||
|
|
||||||
|
attempts = 0
|
||||||
|
while len(candidates) < n_candidates and attempts < max_attempts:
|
||||||
|
attempts += 1
|
||||||
|
nums = _weighted_sample_6(weights)
|
||||||
|
key = tuple(sorted(nums))
|
||||||
|
if key in seen_keys:
|
||||||
|
continue
|
||||||
|
seen_keys.add(key)
|
||||||
|
|
||||||
|
scores = score_combination(nums, cache)
|
||||||
|
candidates.append({
|
||||||
|
"numbers": sorted(nums),
|
||||||
|
**scores,
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── 3. 점수 내림차순 정렬 및 상위 선별 ──────────────────────────────────
|
||||||
|
candidates.sort(key=lambda x: -x["score_total"])
|
||||||
|
top_candidates = candidates[:top_k]
|
||||||
|
|
||||||
|
# is_best 플래그 표시
|
||||||
|
best_keys = {tuple(c["numbers"]) for c in top_candidates[:best_n]}
|
||||||
|
for c in top_candidates:
|
||||||
|
c["is_best"] = tuple(c["numbers"]) in best_keys
|
||||||
|
|
||||||
|
avg_score = (
|
||||||
|
sum(c["score_total"] for c in top_candidates) / len(top_candidates)
|
||||||
|
if top_candidates else 0.0
|
||||||
|
)
|
||||||
|
best_score = top_candidates[0]["score_total"] if top_candidates else 0.0
|
||||||
|
|
||||||
|
# ── 4. DB 저장 ────────────────────────────────────────────────────────────
|
||||||
|
run_id = save_simulation_run(
|
||||||
|
strategy="monte_carlo",
|
||||||
|
total_generated=len(candidates),
|
||||||
|
top_k_selected=len(top_candidates),
|
||||||
|
avg_score=avg_score,
|
||||||
|
notes=f"based_on_draw={based_on_draw}, history={len(draws)}회",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 상위 top_k개만 DB에 저장 (전체 20,000개는 메모리에서만 처리)
|
||||||
|
save_simulation_candidates_bulk(run_id, top_candidates, based_on_draw)
|
||||||
|
|
||||||
|
# best_picks 교체 (상위 best_n개)
|
||||||
|
best_picks_data = [
|
||||||
|
{
|
||||||
|
"numbers": c["numbers"],
|
||||||
|
"score_total": c["score_total"],
|
||||||
|
"rank_in_run": i + 1,
|
||||||
|
}
|
||||||
|
for i, c in enumerate(top_candidates[:best_n])
|
||||||
|
]
|
||||||
|
replace_best_picks(best_picks_data, run_id, based_on_draw)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"run_id": run_id,
|
||||||
|
"total_generated": len(candidates),
|
||||||
|
"top_k_selected": len(top_candidates),
|
||||||
|
"best_n_saved": len(best_picks_data),
|
||||||
|
"avg_score": round(avg_score, 6),
|
||||||
|
"best_score": round(best_score, 6),
|
||||||
|
"based_on_draw": based_on_draw,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_smart_recommendations(count: int = 10) -> int:
|
||||||
|
"""
|
||||||
|
하위 호환성 유지용 래퍼.
|
||||||
|
내부적으로 run_simulation을 호출하며, 기존 /api/admin/auto_gen 등에서 계속 사용 가능.
|
||||||
|
"""
|
||||||
|
result = run_simulation(n_candidates=5000, top_k=count, best_n=count)
|
||||||
|
if "error" in result:
|
||||||
|
return 0
|
||||||
|
return result.get("best_n_saved", 0)
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
import time
|
||||||
from typing import Optional, List, Dict, Any, Tuple
|
from typing import Optional, List, Dict, Any, Tuple
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -8,9 +9,33 @@ from .db import (
|
|||||||
init_db, get_draw, get_latest_draw, get_all_draw_numbers,
|
init_db, get_draw, get_latest_draw, get_all_draw_numbers,
|
||||||
save_recommendation_dedup, list_recommendations_ex, delete_recommendation,
|
save_recommendation_dedup, list_recommendations_ex, delete_recommendation,
|
||||||
update_recommendation,
|
update_recommendation,
|
||||||
|
# 시뮬레이션 관련
|
||||||
|
get_best_picks, get_simulation_runs, get_simulation_candidates,
|
||||||
|
# todos
|
||||||
|
get_all_todos, create_todo, update_todo, delete_todo, delete_done_todos,
|
||||||
|
# blog
|
||||||
|
get_all_posts, create_post, update_post, delete_post,
|
||||||
|
# realestate
|
||||||
|
get_all_complexes, get_complex, create_complex, update_complex, delete_complex,
|
||||||
|
# subscription
|
||||||
|
get_all_subscription_items, create_subscription_item,
|
||||||
|
update_subscription_item, delete_subscription_item,
|
||||||
|
get_subscription_profile, upsert_subscription_profile,
|
||||||
|
# 성과 통계
|
||||||
|
get_recommendation_performance,
|
||||||
|
# Phase 2: 구매 이력
|
||||||
|
add_purchase, get_purchases, update_purchase, delete_purchase, get_purchase_stats,
|
||||||
|
# Phase 2: 주간 리포트 캐시
|
||||||
|
save_weekly_report, get_weekly_report_list, get_weekly_report,
|
||||||
|
# Phase 2: 개인 패턴 분석
|
||||||
|
get_all_recommendation_numbers,
|
||||||
)
|
)
|
||||||
from .recommender import recommend_numbers
|
from .recommender import recommend_numbers, recommend_with_heatmap
|
||||||
from .collector import sync_latest
|
from .collector import sync_latest, sync_ensure_all
|
||||||
|
from .generator import run_simulation, generate_smart_recommendations
|
||||||
|
from .checker import check_results_for_draw
|
||||||
|
from .utils import calc_metrics, calc_recent_overlap
|
||||||
|
from .analyzer import get_statistical_report, generate_weekly_report, analyze_personal_patterns
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
|
scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
|
||||||
@@ -18,74 +43,61 @@ scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
|
|||||||
ALL_URL = os.getenv("LOTTO_ALL_URL", "https://smok95.github.io/lotto/results/all.json")
|
ALL_URL = os.getenv("LOTTO_ALL_URL", "https://smok95.github.io/lotto/results/all.json")
|
||||||
LATEST_URL = os.getenv("LOTTO_LATEST_URL", "https://smok95.github.io/lotto/results/latest.json")
|
LATEST_URL = os.getenv("LOTTO_LATEST_URL", "https://smok95.github.io/lotto/results/latest.json")
|
||||||
|
|
||||||
def calc_metrics(numbers: List[int]) -> Dict[str, Any]:
|
# ── 성과 통계 인메모리 캐시 ───────────────────────────────────────────────────
|
||||||
nums = sorted(numbers)
|
# 채점 데이터는 하루 2번 스케줄러 실행 시에만 갱신되므로 인메모리 캐시로 충분
|
||||||
s = sum(nums)
|
_PERF_CACHE: Dict[str, Any] = {"data": None, "at": 0.0}
|
||||||
odd = sum(1 for x in nums if x % 2 == 1)
|
_PERF_CACHE_TTL = 3600 # 1시간 (스케줄러 미실행 상황 대비 폴백)
|
||||||
even = len(nums) - odd
|
|
||||||
mn, mx = nums[0], nums[-1]
|
|
||||||
rng = mx - mn
|
|
||||||
|
|
||||||
# 1-10, 11-20, 21-30, 31-40, 41-45
|
|
||||||
buckets = {
|
|
||||||
"1-10": 0,
|
|
||||||
"11-20": 0,
|
|
||||||
"21-30": 0,
|
|
||||||
"31-40": 0,
|
|
||||||
"41-45": 0,
|
|
||||||
}
|
|
||||||
for x in nums:
|
|
||||||
if 1 <= x <= 10:
|
|
||||||
buckets["1-10"] += 1
|
|
||||||
elif 11 <= x <= 20:
|
|
||||||
buckets["11-20"] += 1
|
|
||||||
elif 21 <= x <= 30:
|
|
||||||
buckets["21-30"] += 1
|
|
||||||
elif 31 <= x <= 40:
|
|
||||||
buckets["31-40"] += 1
|
|
||||||
else:
|
|
||||||
buckets["41-45"] += 1
|
|
||||||
|
|
||||||
return {
|
def _refresh_perf_cache() -> None:
|
||||||
"sum": s,
|
_PERF_CACHE["data"] = get_recommendation_performance()
|
||||||
"odd": odd,
|
_PERF_CACHE["at"] = time.time()
|
||||||
"even": even,
|
print("[PerfCache] 성과 통계 캐시 갱신")
|
||||||
"min": mn,
|
|
||||||
"max": mx,
|
|
||||||
"range": rng,
|
|
||||||
"buckets": buckets,
|
|
||||||
}
|
|
||||||
|
|
||||||
def calc_recent_overlap(numbers: List[int], draws: List[Tuple[int, List[int]]], last_k: int) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
draws: [(drw_no, [n1..n6]), ...] 오름차순
|
|
||||||
last_k: 최근 k회 기준 중복
|
|
||||||
"""
|
|
||||||
if last_k <= 0:
|
|
||||||
return {"last_k": 0, "repeats": 0, "repeated_numbers": []}
|
|
||||||
|
|
||||||
recent = draws[-last_k:] if len(draws) >= last_k else draws
|
|
||||||
recent_set = set()
|
|
||||||
for _, nums in recent:
|
|
||||||
recent_set.update(nums)
|
|
||||||
|
|
||||||
repeated = sorted(set(numbers) & recent_set)
|
|
||||||
return {
|
|
||||||
"last_k": len(recent),
|
|
||||||
"repeats": len(repeated),
|
|
||||||
"repeated_numbers": repeated,
|
|
||||||
}
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
def on_startup():
|
def on_startup():
|
||||||
init_db()
|
init_db()
|
||||||
scheduler.add_job(lambda: sync_latest(LATEST_URL), "cron", hour="9,21", minute=10)
|
|
||||||
|
# 1. 로또 당첨번호 동기화 (매일 9시, 21시 10분)
|
||||||
|
# 동기화 후 새로운 회차가 있으면 채점(check)까지 수행
|
||||||
|
def _sync_and_check():
|
||||||
|
res = sync_latest(LATEST_URL)
|
||||||
|
if res["was_new"]:
|
||||||
|
check_results_for_draw(res["drawNo"])
|
||||||
|
_refresh_perf_cache() # 새 채점 결과 반영 → 즉시 갱신
|
||||||
|
|
||||||
|
scheduler.add_job(_sync_and_check, "cron", hour="9,21", minute=10)
|
||||||
|
|
||||||
|
# 2. 몬테카를로 시뮬레이션 (하루 6회: 0, 4, 8, 12, 16, 20시)
|
||||||
|
# 20,000개 후보 생성 → 스코어링 → 상위 100개 저장 → best_picks 교체
|
||||||
|
def _run_simulation_job():
|
||||||
|
run_simulation(n_candidates=20000, top_k=100, best_n=20)
|
||||||
|
|
||||||
|
scheduler.add_job(_run_simulation_job, "cron", hour="0,4,8,12,16,20", minute=5)
|
||||||
|
|
||||||
|
# 3. 토요일 오전 9시 — 다음 회차 공략 리포트 자동 캐싱
|
||||||
|
def _save_weekly_report_job():
|
||||||
|
import json as _json
|
||||||
|
draws = get_all_draw_numbers()
|
||||||
|
latest = get_latest_draw()
|
||||||
|
if not draws or not latest:
|
||||||
|
return
|
||||||
|
target = latest["drw_no"] + 1
|
||||||
|
report = generate_weekly_report(draws, target)
|
||||||
|
save_weekly_report(target, _json.dumps(report, ensure_ascii=False))
|
||||||
|
print(f"[WeeklyReport] {target}회차 리포트 저장 완료")
|
||||||
|
|
||||||
|
scheduler.add_job(_save_weekly_report_job, "cron", day_of_week="sat", hour=9, minute=0)
|
||||||
|
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health():
|
def health():
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/lotto/latest")
|
@app.get("/api/lotto/latest")
|
||||||
def api_latest():
|
def api_latest():
|
||||||
row = get_latest_draw()
|
row = get_latest_draw()
|
||||||
@@ -96,8 +108,10 @@ def api_latest():
|
|||||||
"date": row["drw_date"],
|
"date": row["drw_date"],
|
||||||
"numbers": [row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]],
|
"numbers": [row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]],
|
||||||
"bonus": row["bonus"],
|
"bonus": row["bonus"],
|
||||||
|
"metrics": calc_metrics([row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]]),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/lotto/{drw_no:int}")
|
@app.get("/api/lotto/{drw_no:int}")
|
||||||
def api_draw(drw_no: int):
|
def api_draw(drw_no: int):
|
||||||
row = get_draw(drw_no)
|
row = get_draw(drw_no)
|
||||||
@@ -108,28 +122,287 @@ def api_draw(drw_no: int):
|
|||||||
"date": row["drw_date"],
|
"date": row["drw_date"],
|
||||||
"numbers": [row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]],
|
"numbers": [row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]],
|
||||||
"bonus": row["bonus"],
|
"bonus": row["bonus"],
|
||||||
|
"metrics": calc_metrics([row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]]),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/sync_latest")
|
@app.post("/api/admin/sync_latest")
|
||||||
def admin_sync_latest():
|
def admin_sync_latest():
|
||||||
return sync_latest(LATEST_URL)
|
res = sync_latest(LATEST_URL)
|
||||||
|
if res["was_new"]:
|
||||||
|
check_results_for_draw(res["drawNo"])
|
||||||
|
return res
|
||||||
|
|
||||||
# ---------- ✅ recommend (dedup save) ----------
|
|
||||||
|
@app.post("/api/admin/auto_gen")
|
||||||
|
def admin_auto_gen(count: int = 10):
|
||||||
|
"""기존 호환 유지: 소규모 시뮬레이션 수동 트리거"""
|
||||||
|
n = generate_smart_recommendations(count)
|
||||||
|
return {"generated": n}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/admin/simulate")
|
||||||
|
def admin_simulate(n_candidates: int = 20000, top_k: int = 100, best_n: int = 20):
|
||||||
|
"""
|
||||||
|
몬테카를로 시뮬레이션 수동 트리거.
|
||||||
|
백그라운드 스케줄과 동일한 동작을 즉시 실행.
|
||||||
|
"""
|
||||||
|
result = run_simulation(
|
||||||
|
n_candidates=max(1000, min(n_candidates, 50000)),
|
||||||
|
top_k=max(10, min(top_k, 500)),
|
||||||
|
best_n=max(10, min(best_n, 50)),
|
||||||
|
)
|
||||||
|
if "error" in result:
|
||||||
|
raise HTTPException(status_code=500, detail=result["error"])
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/lotto/stats")
|
||||||
|
def api_stats():
|
||||||
|
sync_ensure_all(LATEST_URL, ALL_URL)
|
||||||
|
|
||||||
|
draws = get_all_draw_numbers()
|
||||||
|
if not draws:
|
||||||
|
raise HTTPException(status_code=404, detail="No data yet")
|
||||||
|
|
||||||
|
frequency = {n: 0 for n in range(1, 46)}
|
||||||
|
total_draws = len(draws)
|
||||||
|
|
||||||
|
for _, nums in draws:
|
||||||
|
for n in nums:
|
||||||
|
frequency[n] += 1
|
||||||
|
|
||||||
|
stats = [
|
||||||
|
{"number": n, "count": frequency[n]}
|
||||||
|
for n in range(1, 46)
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_draws": total_draws,
|
||||||
|
"frequency": stats,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 추천 성과 통계 (Phase 1, 인메모리 캐시) ──────────────────────────────────
|
||||||
|
@app.get("/api/lotto/stats/performance")
|
||||||
|
def api_performance_stats():
|
||||||
|
"""
|
||||||
|
채점된 추천 이력 기반 성과 통계 (캐시 반환).
|
||||||
|
캐시 갱신 시점: 새 회차 채점 직후 | TTL 1시간 만료 시
|
||||||
|
"""
|
||||||
|
if _PERF_CACHE["data"] is None or time.time() - _PERF_CACHE["at"] > _PERF_CACHE_TTL:
|
||||||
|
_refresh_perf_cache()
|
||||||
|
return _PERF_CACHE["data"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── 회차 공략 리포트 (Phase 1) ────────────────────────────────────────────────
|
||||||
|
@app.get("/api/lotto/report/latest")
|
||||||
|
def api_report_latest():
|
||||||
|
"""
|
||||||
|
다음 회차 공략 리포트 (최신 회차 기준으로 자동 계산).
|
||||||
|
- 과출현/냉각/오버듀 번호 분석
|
||||||
|
- 최근 3회 패턴
|
||||||
|
- 3가지 전략별 추천 번호
|
||||||
|
- AI 신뢰도 점수
|
||||||
|
"""
|
||||||
|
draws = get_all_draw_numbers()
|
||||||
|
if not draws:
|
||||||
|
raise HTTPException(status_code=404, detail="No data yet")
|
||||||
|
latest = get_latest_draw()
|
||||||
|
target = latest["drw_no"] + 1
|
||||||
|
return generate_weekly_report(draws, target)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/lotto/report/history")
|
||||||
|
def api_report_history(limit: int = 10):
|
||||||
|
"""저장된 주간 리포트 목록 (자동 저장된 것만)"""
|
||||||
|
return {"reports": get_weekly_report_list(limit=min(limit, 52))}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/lotto/report/{drw_no}")
|
||||||
|
def api_report_by_draw(drw_no: int):
|
||||||
|
"""
|
||||||
|
특정 회차 공략 리포트 (캐시 우선, 없으면 실시간 생성).
|
||||||
|
"""
|
||||||
|
cached = get_weekly_report(drw_no)
|
||||||
|
if cached:
|
||||||
|
return {**cached, "cached": True}
|
||||||
|
|
||||||
|
draws = get_all_draw_numbers()
|
||||||
|
if not draws:
|
||||||
|
raise HTTPException(status_code=404, detail="No data yet")
|
||||||
|
base_draws = [(no, nums) for no, nums in draws if no < drw_no]
|
||||||
|
if not base_draws:
|
||||||
|
raise HTTPException(status_code=400, detail=f"{drw_no}회차 이전 데이터가 없습니다")
|
||||||
|
return {**generate_weekly_report(base_draws, drw_no), "cached": False}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 개인 패턴 분석 (Phase 2) ─────────────────────────────────────────────────
|
||||||
|
@app.get("/api/lotto/analysis/personal")
|
||||||
|
def api_personal_analysis():
|
||||||
|
"""
|
||||||
|
저장된 추천 이력 기반 개인 패턴 분석.
|
||||||
|
- 자주 선택한 번호 TOP 10 / 한 번도 선택 안 한 번호
|
||||||
|
- 홀짝 비율, 합계, 범위, 연속번호 포함률
|
||||||
|
- 구간별 분포, 역대 당첨번호 평균과 비교
|
||||||
|
"""
|
||||||
|
all_numbers = get_all_recommendation_numbers()
|
||||||
|
draws = get_all_draw_numbers()
|
||||||
|
return analyze_personal_patterns(all_numbers, draws)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 구매 이력 API (Phase 2) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class PurchaseCreate(BaseModel):
|
||||||
|
draw_no: int
|
||||||
|
amount: int
|
||||||
|
sets: int = 1
|
||||||
|
prize: int = 0
|
||||||
|
note: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseUpdate(BaseModel):
|
||||||
|
draw_no: Optional[int] = None
|
||||||
|
amount: Optional[int] = None
|
||||||
|
sets: Optional[int] = None
|
||||||
|
prize: Optional[int] = None
|
||||||
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/lotto/purchase/stats")
|
||||||
|
def api_purchase_stats():
|
||||||
|
"""투자 수익률 통계 (총 투자금, 총 당첨금, 수익률 등)"""
|
||||||
|
return get_purchase_stats()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/lotto/purchase")
|
||||||
|
def api_purchase_list(draw_no: Optional[int] = None, days: Optional[int] = None):
|
||||||
|
"""구매 이력 조회 (draw_no, days 필터 선택)"""
|
||||||
|
return {"records": get_purchases(draw_no=draw_no, days=days)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/lotto/purchase", status_code=201)
|
||||||
|
def api_purchase_create(body: PurchaseCreate):
|
||||||
|
"""구매 이력 추가"""
|
||||||
|
return add_purchase(body.draw_no, body.amount, body.sets, body.prize, body.note)
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/lotto/purchase/{purchase_id}")
|
||||||
|
def api_purchase_update(purchase_id: int, body: PurchaseUpdate):
|
||||||
|
"""구매 이력 수정 (당첨금 업데이트 등)"""
|
||||||
|
updated = update_purchase(purchase_id, body.model_dump(exclude_none=True))
|
||||||
|
if updated is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Purchase not found")
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/lotto/purchase/{purchase_id}")
|
||||||
|
def api_purchase_delete(purchase_id: int):
|
||||||
|
"""구매 이력 삭제"""
|
||||||
|
if not delete_purchase(purchase_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Purchase not found")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 통계 분석 리포트 ────────────────────────────────────────────────────────
|
||||||
|
@app.get("/api/lotto/analysis")
|
||||||
|
def api_analysis():
|
||||||
|
"""
|
||||||
|
5가지 통계 기법 기반 분석 리포트.
|
||||||
|
- 번호별 빈도, Z-score, 갭
|
||||||
|
- 핫/콜드/오버듀 번호
|
||||||
|
- 역대 합계 분포, 홀짝 분포
|
||||||
|
"""
|
||||||
|
draws = get_all_draw_numbers()
|
||||||
|
if not draws:
|
||||||
|
raise HTTPException(status_code=404, detail="No data yet")
|
||||||
|
return get_statistical_report(draws)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 시뮬레이션 best_picks (메인 추천 엔드포인트) ────────────────────────────
|
||||||
|
@app.get("/api/lotto/best")
|
||||||
|
def api_best_picks(limit: int = 20):
|
||||||
|
"""
|
||||||
|
시뮬레이션을 통해 선별된 최적 번호 조합 반환 (기본 20쌍).
|
||||||
|
하루 6회 시뮬레이션 후 자동 갱신됨.
|
||||||
|
각 조합에 점수 및 메트릭 포함.
|
||||||
|
"""
|
||||||
|
limit = max(1, min(limit, 50))
|
||||||
|
picks = get_best_picks(limit=limit)
|
||||||
|
if not picks:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="시뮬레이션 결과가 없습니다. /api/admin/simulate로 먼저 실행하세요.",
|
||||||
|
)
|
||||||
|
|
||||||
|
draws = get_all_draw_numbers()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for p in picks:
|
||||||
|
nums = p["numbers"]
|
||||||
|
result.append({
|
||||||
|
"rank": p["rank_in_run"],
|
||||||
|
"numbers": nums,
|
||||||
|
"score_total": p["score_total"],
|
||||||
|
"based_on_draw": p["based_on_draw"],
|
||||||
|
"simulation_run_id": p["source_run_id"],
|
||||||
|
"created_at": p["created_at"],
|
||||||
|
"metrics": calc_metrics(nums),
|
||||||
|
})
|
||||||
|
|
||||||
|
latest = get_latest_draw()
|
||||||
|
return {
|
||||||
|
"based_on_draw": latest["drw_no"] if latest else None,
|
||||||
|
"count": len(result),
|
||||||
|
"items": result,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 시뮬레이션 전체 결과 조회 (상세 API) ────────────────────────────────────
|
||||||
|
@app.get("/api/lotto/simulation")
|
||||||
|
def api_simulation(run_id: Optional[int] = None, runs_limit: int = 5):
|
||||||
|
"""
|
||||||
|
시뮬레이션 실행 기록 및 상위 후보 상세 조회.
|
||||||
|
run_id 미지정 시: 최근 runs_limit개 실행 기록 + 가장 최근 run의 후보 반환.
|
||||||
|
run_id 지정 시: 해당 run의 후보만 반환.
|
||||||
|
"""
|
||||||
|
runs = get_simulation_runs(limit=runs_limit)
|
||||||
|
if not runs:
|
||||||
|
raise HTTPException(status_code=404, detail="시뮬레이션 기록이 없습니다.")
|
||||||
|
|
||||||
|
target_run_id = run_id if run_id is not None else runs[0]["id"]
|
||||||
|
candidates = get_simulation_candidates(target_run_id, limit=100)
|
||||||
|
|
||||||
|
# 후보에 메트릭 추가
|
||||||
|
enriched = []
|
||||||
|
for c in candidates:
|
||||||
|
enriched.append({
|
||||||
|
**c,
|
||||||
|
"metrics": calc_metrics(c["numbers"]),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"runs": runs,
|
||||||
|
"selected_run_id": target_run_id,
|
||||||
|
"candidates_count": len(enriched),
|
||||||
|
"candidates": enriched,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 기존 수동 추천 API (하위 호환 유지) ─────────────────────────────────────
|
||||||
@app.get("/api/lotto/recommend")
|
@app.get("/api/lotto/recommend")
|
||||||
def api_recommend(
|
def api_recommend(
|
||||||
recent_window: int = 200,
|
recent_window: int = 200,
|
||||||
recent_weight: float = 2.0,
|
recent_weight: float = 2.0,
|
||||||
avoid_recent_k: int = 5,
|
avoid_recent_k: int = 5,
|
||||||
|
|
||||||
# ---- optional constraints (Lotto Lab) ----
|
|
||||||
sum_min: Optional[int] = None,
|
sum_min: Optional[int] = None,
|
||||||
sum_max: Optional[int] = None,
|
sum_max: Optional[int] = None,
|
||||||
odd_min: Optional[int] = None,
|
odd_min: Optional[int] = None,
|
||||||
odd_max: Optional[int] = None,
|
odd_max: Optional[int] = None,
|
||||||
range_min: Optional[int] = None,
|
range_min: Optional[int] = None,
|
||||||
range_max: Optional[int] = None,
|
range_max: Optional[int] = None,
|
||||||
max_overlap_latest: Optional[int] = None, # 최근 avoid_recent_k 회차와 중복 허용 개수
|
max_overlap_latest: Optional[int] = None,
|
||||||
max_try: int = 200, # 조건 맞는 조합 찾기 재시도
|
max_try: int = 200,
|
||||||
):
|
):
|
||||||
draws = get_all_draw_numbers()
|
draws = get_all_draw_numbers()
|
||||||
if not draws:
|
if not draws:
|
||||||
@@ -141,7 +414,6 @@ def api_recommend(
|
|||||||
"recent_window": recent_window,
|
"recent_window": recent_window,
|
||||||
"recent_weight": float(recent_weight),
|
"recent_weight": float(recent_weight),
|
||||||
"avoid_recent_k": avoid_recent_k,
|
"avoid_recent_k": avoid_recent_k,
|
||||||
|
|
||||||
"sum_min": sum_min,
|
"sum_min": sum_min,
|
||||||
"sum_max": sum_max,
|
"sum_max": sum_max,
|
||||||
"odd_min": odd_min,
|
"odd_min": odd_min,
|
||||||
@@ -166,7 +438,6 @@ def api_recommend(
|
|||||||
return False
|
return False
|
||||||
if range_max is not None and m["range"] > range_max:
|
if range_max is not None and m["range"] > range_max:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if max_overlap_latest is not None:
|
if max_overlap_latest is not None:
|
||||||
ov = calc_recent_overlap(nums, draws, last_k=avoid_recent_k)
|
ov = calc_recent_overlap(nums, draws, last_k=avoid_recent_k)
|
||||||
if ov["repeats"] > max_overlap_latest:
|
if ov["repeats"] > max_overlap_latest:
|
||||||
@@ -194,11 +465,9 @@ def api_recommend(
|
|||||||
if chosen is None:
|
if chosen is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"Constraints too strict. No valid set found in max_try={max_try}. "
|
detail=f"Constraints too strict. No valid set found in max_try={max_try}.",
|
||||||
f"Try relaxing sum/odd/range/overlap constraints.",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# ✅ dedup save
|
|
||||||
saved = save_recommendation_dedup(
|
saved = save_recommendation_dedup(
|
||||||
latest["drw_no"] if latest else None,
|
latest["drw_no"] if latest else None,
|
||||||
chosen,
|
chosen,
|
||||||
@@ -221,7 +490,118 @@ def api_recommend(
|
|||||||
"tries": tries,
|
"tries": tries,
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------- ✅ history list (filter/paging) ----------
|
|
||||||
|
# ── 히트맵 기반 추천 (하위 호환 유지) ────────────────────────────────────────
|
||||||
|
@app.get("/api/lotto/recommend/heatmap")
|
||||||
|
def api_recommend_heatmap(
|
||||||
|
heatmap_window: int = 20,
|
||||||
|
heatmap_weight: float = 1.5,
|
||||||
|
recent_window: int = 200,
|
||||||
|
recent_weight: float = 2.0,
|
||||||
|
avoid_recent_k: int = 5,
|
||||||
|
sum_min: Optional[int] = None,
|
||||||
|
sum_max: Optional[int] = None,
|
||||||
|
odd_min: Optional[int] = None,
|
||||||
|
odd_max: Optional[int] = None,
|
||||||
|
range_min: Optional[int] = None,
|
||||||
|
range_max: Optional[int] = None,
|
||||||
|
max_overlap_latest: Optional[int] = None,
|
||||||
|
max_try: int = 200,
|
||||||
|
):
|
||||||
|
draws = get_all_draw_numbers()
|
||||||
|
if not draws:
|
||||||
|
raise HTTPException(status_code=404, detail="No data yet")
|
||||||
|
|
||||||
|
past_recs = list_recommendations_ex(limit=100, sort="id_desc")
|
||||||
|
latest = get_latest_draw()
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"heatmap_window": heatmap_window,
|
||||||
|
"heatmap_weight": float(heatmap_weight),
|
||||||
|
"recent_window": recent_window,
|
||||||
|
"recent_weight": float(recent_weight),
|
||||||
|
"avoid_recent_k": avoid_recent_k,
|
||||||
|
"sum_min": sum_min,
|
||||||
|
"sum_max": sum_max,
|
||||||
|
"odd_min": odd_min,
|
||||||
|
"odd_max": odd_max,
|
||||||
|
"range_min": range_min,
|
||||||
|
"range_max": range_max,
|
||||||
|
"max_overlap_latest": max_overlap_latest,
|
||||||
|
"max_try": int(max_try),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _accept(nums: List[int]) -> bool:
|
||||||
|
m = calc_metrics(nums)
|
||||||
|
if sum_min is not None and m["sum"] < sum_min:
|
||||||
|
return False
|
||||||
|
if sum_max is not None and m["sum"] > sum_max:
|
||||||
|
return False
|
||||||
|
if odd_min is not None and m["odd"] < odd_min:
|
||||||
|
return False
|
||||||
|
if odd_max is not None and m["odd"] > odd_max:
|
||||||
|
return False
|
||||||
|
if range_min is not None and m["range"] < range_min:
|
||||||
|
return False
|
||||||
|
if range_max is not None and m["range"] > range_max:
|
||||||
|
return False
|
||||||
|
if max_overlap_latest is not None:
|
||||||
|
ov = calc_recent_overlap(nums, draws, last_k=avoid_recent_k)
|
||||||
|
if ov["repeats"] > max_overlap_latest:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
chosen = None
|
||||||
|
explain = None
|
||||||
|
|
||||||
|
tries = 0
|
||||||
|
while tries < max_try:
|
||||||
|
tries += 1
|
||||||
|
result = recommend_with_heatmap(
|
||||||
|
draws,
|
||||||
|
past_recs,
|
||||||
|
heatmap_window=heatmap_window,
|
||||||
|
heatmap_weight=heatmap_weight,
|
||||||
|
recent_window=recent_window,
|
||||||
|
recent_weight=recent_weight,
|
||||||
|
avoid_recent_k=avoid_recent_k,
|
||||||
|
)
|
||||||
|
nums = result["numbers"]
|
||||||
|
if _accept(nums):
|
||||||
|
chosen = nums
|
||||||
|
explain = result["explain"]
|
||||||
|
break
|
||||||
|
|
||||||
|
if chosen is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Constraints too strict. No valid set found in max_try={max_try}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
saved = save_recommendation_dedup(
|
||||||
|
latest["drw_no"] if latest else None,
|
||||||
|
chosen,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
|
||||||
|
metrics = calc_metrics(chosen)
|
||||||
|
overlap = calc_recent_overlap(chosen, draws, last_k=avoid_recent_k)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": saved["id"],
|
||||||
|
"saved": saved["saved"],
|
||||||
|
"deduped": saved["deduped"],
|
||||||
|
"based_on_latest_draw": latest["drw_no"] if latest else None,
|
||||||
|
"numbers": chosen,
|
||||||
|
"explain": explain,
|
||||||
|
"params": params,
|
||||||
|
"metrics": metrics,
|
||||||
|
"recent_overlap": overlap,
|
||||||
|
"tries": tries,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 추천 이력 ────────────────────────────────────────────────────────────────
|
||||||
@app.get("/api/history")
|
@app.get("/api/history")
|
||||||
def api_history(
|
def api_history(
|
||||||
limit: int = 30,
|
limit: int = 30,
|
||||||
@@ -260,6 +640,7 @@ def api_history(
|
|||||||
"filters": {"favorite": favorite, "tag": tag, "q": q, "sort": sort},
|
"filters": {"favorite": favorite, "tag": tag, "q": q, "sort": sort},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/history/{rec_id:int}")
|
@app.delete("/api/history/{rec_id:int}")
|
||||||
def api_history_delete(rec_id: int):
|
def api_history_delete(rec_id: int):
|
||||||
ok = delete_recommendation(rec_id)
|
ok = delete_recommendation(rec_id)
|
||||||
@@ -267,12 +648,13 @@ def api_history_delete(rec_id: int):
|
|||||||
raise HTTPException(status_code=404, detail="Not found")
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
return {"deleted": True, "id": rec_id}
|
return {"deleted": True, "id": rec_id}
|
||||||
|
|
||||||
# ---------- ✅ history update (favorite/note/tags) ----------
|
|
||||||
class HistoryUpdate(BaseModel):
|
class HistoryUpdate(BaseModel):
|
||||||
favorite: Optional[bool] = None
|
favorite: Optional[bool] = None
|
||||||
note: Optional[str] = None
|
note: Optional[str] = None
|
||||||
tags: Optional[List[str]] = None
|
tags: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
@app.patch("/api/history/{rec_id:int}")
|
@app.patch("/api/history/{rec_id:int}")
|
||||||
def api_history_patch(rec_id: int, body: HistoryUpdate):
|
def api_history_patch(rec_id: int, body: HistoryUpdate):
|
||||||
ok = update_recommendation(rec_id, favorite=body.favorite, note=body.note, tags=body.tags)
|
ok = update_recommendation(rec_id, favorite=body.favorite, note=body.note, tags=body.tags)
|
||||||
@@ -280,11 +662,11 @@ def api_history_patch(rec_id: int, body: HistoryUpdate):
|
|||||||
raise HTTPException(status_code=404, detail="Not found or no changes")
|
raise HTTPException(status_code=404, detail="Not found or no changes")
|
||||||
return {"updated": True, "id": rec_id}
|
return {"updated": True, "id": rec_id}
|
||||||
|
|
||||||
# ---------- ✅ batch recommend ----------
|
|
||||||
|
# ── 배치 추천 (하위 호환 유지) ───────────────────────────────────────────────
|
||||||
def _batch_unique(draws, count: int, recent_window: int, recent_weight: float, avoid_recent_k: int, max_try: int = 200):
|
def _batch_unique(draws, count: int, recent_window: int, recent_weight: float, avoid_recent_k: int, max_try: int = 200):
|
||||||
items = []
|
items = []
|
||||||
seen = set()
|
seen = set()
|
||||||
|
|
||||||
tries = 0
|
tries = 0
|
||||||
while len(items) < count and tries < max_try:
|
while len(items) < count and tries < max_try:
|
||||||
tries += 1
|
tries += 1
|
||||||
@@ -294,9 +676,9 @@ def _batch_unique(draws, count: int, recent_window: int, recent_weight: float, a
|
|||||||
continue
|
continue
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
items.append(r)
|
items.append(r)
|
||||||
|
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/lotto/recommend/batch")
|
@app.get("/api/lotto/recommend/batch")
|
||||||
def api_recommend_batch(
|
def api_recommend_batch(
|
||||||
count: int = 5,
|
count: int = 5,
|
||||||
@@ -322,14 +704,20 @@ def api_recommend_batch(
|
|||||||
return {
|
return {
|
||||||
"based_on_latest_draw": latest["drw_no"] if latest else None,
|
"based_on_latest_draw": latest["drw_no"] if latest else None,
|
||||||
"count": count,
|
"count": count,
|
||||||
"items": [{"numbers": it["numbers"], "explain": it["explain"]} for it in items],
|
"items": [{
|
||||||
|
"numbers": it["numbers"],
|
||||||
|
"explain": it["explain"],
|
||||||
|
"metrics": calc_metrics(it["numbers"]),
|
||||||
|
} for it in items],
|
||||||
"params": params,
|
"params": params,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class BatchSave(BaseModel):
|
class BatchSave(BaseModel):
|
||||||
items: List[List[int]]
|
items: List[List[int]]
|
||||||
params: dict
|
params: dict
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/lotto/recommend/batch")
|
@app.post("/api/lotto/recommend/batch")
|
||||||
def api_recommend_batch_save(body: BatchSave):
|
def api_recommend_batch_save(body: BatchSave):
|
||||||
latest = get_latest_draw()
|
latest = get_latest_draw()
|
||||||
@@ -342,3 +730,291 @@ def api_recommend_batch_save(body: BatchSave):
|
|||||||
|
|
||||||
return {"saved": True, "created_ids": created, "deduped_ids": deduped}
|
return {"saved": True, "created_ids": created, "deduped_ids": deduped}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/version")
|
||||||
|
def version():
|
||||||
|
return {"version": os.getenv("APP_VERSION", "dev")}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Todos API ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TodoCreate(BaseModel):
|
||||||
|
title: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
status: str = "todo"
|
||||||
|
|
||||||
|
|
||||||
|
class TodoUpdate(BaseModel):
|
||||||
|
title: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/todos")
|
||||||
|
def api_todos_list():
|
||||||
|
return get_all_todos()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/todos", status_code=201)
|
||||||
|
def api_todos_create(body: TodoCreate):
|
||||||
|
if body.status not in ("todo", "in_progress", "done"):
|
||||||
|
raise HTTPException(status_code=422, detail="status must be todo | in_progress | done")
|
||||||
|
return create_todo(body.title, body.description, body.status)
|
||||||
|
|
||||||
|
|
||||||
|
# ⚠️ /done 라우트를 /{todo_id} 보다 먼저 등록해야 done이 id로 매칭되지 않음
|
||||||
|
@app.delete("/api/todos/done")
|
||||||
|
def api_todos_delete_done():
|
||||||
|
deleted = delete_done_todos()
|
||||||
|
return {"deleted": deleted}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/todos/{todo_id}")
|
||||||
|
def api_todos_update(todo_id: str, body: TodoUpdate):
|
||||||
|
if body.status is not None and body.status not in ("todo", "in_progress", "done"):
|
||||||
|
raise HTTPException(status_code=422, detail="status must be todo | in_progress | done")
|
||||||
|
updated = update_todo(todo_id, body.model_dump(exclude_none=True))
|
||||||
|
if updated is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Todo not found")
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/todos/{todo_id}")
|
||||||
|
def api_todos_delete(todo_id: str):
|
||||||
|
ok = delete_todo(todo_id)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=404, detail="Todo not found")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Blog API ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class BlogPostCreate(BaseModel):
|
||||||
|
title: str
|
||||||
|
body: str = ""
|
||||||
|
excerpt: str = ""
|
||||||
|
tags: List[str] = []
|
||||||
|
date: str = "" # 빈 문자열이면 오늘 날짜 사용
|
||||||
|
|
||||||
|
|
||||||
|
class BlogPostUpdate(BaseModel):
|
||||||
|
title: Optional[str] = None
|
||||||
|
body: Optional[str] = None
|
||||||
|
excerpt: Optional[str] = None
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
date: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/blog/posts")
|
||||||
|
def api_blog_list():
|
||||||
|
return {"posts": get_all_posts()}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/blog/posts", status_code=201)
|
||||||
|
def api_blog_create(body: BlogPostCreate):
|
||||||
|
from datetime import date as _date
|
||||||
|
post_date = body.date if body.date else _date.today().isoformat()
|
||||||
|
post = create_post(body.title, body.body, body.excerpt, body.tags, post_date)
|
||||||
|
return post
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/blog/posts/{post_id}")
|
||||||
|
def api_blog_update(post_id: int, body: BlogPostUpdate):
|
||||||
|
updated = update_post(post_id, body.model_dump(exclude_none=True))
|
||||||
|
if updated is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Post not found")
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/blog/posts/{post_id}")
|
||||||
|
def api_blog_delete(post_id: int):
|
||||||
|
ok = delete_post(post_id)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=404, detail="Post not found")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── RealEstate API ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
VALID_STATUSES = {"청약예정", "청약중", "결과발표", "완료"}
|
||||||
|
VALID_PRIORITIES = {"high", "normal", "low"}
|
||||||
|
|
||||||
|
|
||||||
|
class ComplexCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
address: str = ""
|
||||||
|
lat: Optional[float] = None
|
||||||
|
lng: Optional[float] = None
|
||||||
|
units: Optional[int] = None
|
||||||
|
types: List[str] = []
|
||||||
|
avgPricePerPyeong: Optional[int] = None
|
||||||
|
subscriptionStart: Optional[str] = None
|
||||||
|
subscriptionEnd: Optional[str] = None
|
||||||
|
resultDate: Optional[str] = None
|
||||||
|
status: str = "청약예정"
|
||||||
|
priority: str = "normal"
|
||||||
|
tags: List[str] = []
|
||||||
|
naverUrl: str = ""
|
||||||
|
floorPlanUrl: str = ""
|
||||||
|
memo: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class ComplexUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
address: Optional[str] = None
|
||||||
|
lat: Optional[float] = None
|
||||||
|
lng: Optional[float] = None
|
||||||
|
units: Optional[int] = None
|
||||||
|
types: Optional[List[str]] = None
|
||||||
|
avgPricePerPyeong: Optional[int] = None
|
||||||
|
subscriptionStart: Optional[str] = None
|
||||||
|
subscriptionEnd: Optional[str] = None
|
||||||
|
resultDate: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
priority: Optional[str] = None
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
naverUrl: Optional[str] = None
|
||||||
|
floorPlanUrl: Optional[str] = None
|
||||||
|
memo: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/realestate/complexes")
|
||||||
|
def api_realestate_list():
|
||||||
|
return get_all_complexes()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/realestate/complexes", status_code=201)
|
||||||
|
def api_realestate_create(body: ComplexCreate):
|
||||||
|
if body.status not in VALID_STATUSES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"status must be one of {VALID_STATUSES}")
|
||||||
|
if body.priority not in VALID_PRIORITIES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"priority must be one of {VALID_PRIORITIES}")
|
||||||
|
return create_complex(body.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/realestate/complexes/{complex_id}")
|
||||||
|
def api_realestate_update(complex_id: int, body: ComplexUpdate):
|
||||||
|
data = body.model_dump(exclude_none=True)
|
||||||
|
if "status" in data and data["status"] not in VALID_STATUSES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"status must be one of {VALID_STATUSES}")
|
||||||
|
if "priority" in data and data["priority"] not in VALID_PRIORITIES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"priority must be one of {VALID_PRIORITIES}")
|
||||||
|
updated = update_complex(complex_id, data)
|
||||||
|
if updated is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Complex not found")
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/realestate/complexes/{complex_id}")
|
||||||
|
def api_realestate_delete(complex_id: int):
|
||||||
|
ok = delete_complex(complex_id)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=404, detail="Complex not found")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Subscription API ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class SubscriptionItemCreate(BaseModel):
|
||||||
|
complexName: str
|
||||||
|
address: str = ""
|
||||||
|
pyeong: Optional[str] = None
|
||||||
|
totalPrice: Optional[int] = None
|
||||||
|
type: Optional[str] = None
|
||||||
|
specialType: Optional[str] = None
|
||||||
|
supplyType: Optional[str] = None
|
||||||
|
status: str = "검토중"
|
||||||
|
minScore: Optional[int] = None
|
||||||
|
maxIncome: Optional[int] = None
|
||||||
|
homelessRequired: Optional[int] = None
|
||||||
|
subscriptionStart: Optional[str] = None
|
||||||
|
subscriptionEnd: Optional[str] = None
|
||||||
|
contractDate: Optional[str] = None
|
||||||
|
interimDate: Optional[str] = None
|
||||||
|
balanceDate: Optional[str] = None
|
||||||
|
resultDate: Optional[str] = None
|
||||||
|
depositRate: int = 10
|
||||||
|
interimRate: int = 60
|
||||||
|
balanceRate: int = 30
|
||||||
|
loanType: Optional[str] = None
|
||||||
|
loanRate: Optional[float] = None
|
||||||
|
memo: str = ""
|
||||||
|
naverUrl: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionItemUpdate(BaseModel):
|
||||||
|
complexName: Optional[str] = None
|
||||||
|
address: Optional[str] = None
|
||||||
|
pyeong: Optional[str] = None
|
||||||
|
totalPrice: Optional[int] = None
|
||||||
|
type: Optional[str] = None
|
||||||
|
specialType: Optional[str] = None
|
||||||
|
supplyType: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
minScore: Optional[int] = None
|
||||||
|
maxIncome: Optional[int] = None
|
||||||
|
homelessRequired: Optional[int] = None
|
||||||
|
subscriptionStart: Optional[str] = None
|
||||||
|
subscriptionEnd: Optional[str] = None
|
||||||
|
contractDate: Optional[str] = None
|
||||||
|
interimDate: Optional[str] = None
|
||||||
|
balanceDate: Optional[str] = None
|
||||||
|
resultDate: Optional[str] = None
|
||||||
|
depositRate: Optional[int] = None
|
||||||
|
interimRate: Optional[int] = None
|
||||||
|
balanceRate: Optional[int] = None
|
||||||
|
loanType: Optional[str] = None
|
||||||
|
loanRate: Optional[float] = None
|
||||||
|
memo: Optional[str] = None
|
||||||
|
naverUrl: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionProfile(BaseModel):
|
||||||
|
isHouseholdHead: Optional[bool] = None
|
||||||
|
isHomeless: Optional[bool] = None
|
||||||
|
homelessPeriod: Optional[int] = None
|
||||||
|
savingsMonths: Optional[int] = None
|
||||||
|
savingsCount: Optional[int] = None
|
||||||
|
dependents: Optional[int] = None
|
||||||
|
residencyArea: Optional[str] = None
|
||||||
|
isMarried: Optional[bool] = None
|
||||||
|
marriageMonths: Optional[int] = None
|
||||||
|
monthlyIncome: Optional[int] = None
|
||||||
|
specialQuals: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/subscription/items")
|
||||||
|
def api_subscription_list():
|
||||||
|
return get_all_subscription_items()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/subscription/items", status_code=201)
|
||||||
|
def api_subscription_create(body: SubscriptionItemCreate):
|
||||||
|
return create_subscription_item(body.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/subscription/items/{item_id}")
|
||||||
|
def api_subscription_update(item_id: int, body: SubscriptionItemUpdate):
|
||||||
|
updated = update_subscription_item(item_id, body.model_dump(exclude_none=True))
|
||||||
|
if updated is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/subscription/items/{item_id}")
|
||||||
|
def api_subscription_delete(item_id: int):
|
||||||
|
ok = delete_subscription_item(item_id)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/subscription/profile")
|
||||||
|
def api_subscription_profile_get():
|
||||||
|
profile = get_subscription_profile()
|
||||||
|
return profile if profile is not None else {}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/subscription/profile")
|
||||||
|
def api_subscription_profile_put(body: SubscriptionProfile):
|
||||||
|
return upsert_subscription_profile(body.model_dump(exclude_none=True))
|
||||||
|
|||||||
@@ -66,3 +66,98 @@ def recommend_numbers(
|
|||||||
|
|
||||||
return {"numbers": chosen_sorted, "explain": explain}
|
return {"numbers": chosen_sorted, "explain": explain}
|
||||||
|
|
||||||
|
|
||||||
|
def recommend_with_heatmap(
|
||||||
|
draws: List[Tuple[int, List[int]]],
|
||||||
|
past_recommendations: List[Dict[str, Any]],
|
||||||
|
*,
|
||||||
|
heatmap_window: int = 10,
|
||||||
|
heatmap_weight: float = 1.5,
|
||||||
|
recent_window: int = 200,
|
||||||
|
recent_weight: float = 2.0,
|
||||||
|
avoid_recent_k: int = 5,
|
||||||
|
seed: int | None = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
히트맵 기반 가중치 추천:
|
||||||
|
- 과거 추천 번호들의 적중률을 분석하여 잘 맞춘 번호에 가중치 부여
|
||||||
|
- 기존 통계 기반 추천과 결합
|
||||||
|
|
||||||
|
Args:
|
||||||
|
draws: 실제 당첨 번호 리스트 [(회차, [번호들]), ...]
|
||||||
|
past_recommendations: 과거 추천 데이터 [{"numbers": [...], "correct_count": N, "based_on_draw": M}, ...]
|
||||||
|
heatmap_window: 히트맵 분석할 최근 추천 개수
|
||||||
|
heatmap_weight: 히트맵 가중치 (높을수록 과거 적중 번호 선호)
|
||||||
|
"""
|
||||||
|
if seed is not None:
|
||||||
|
random.seed(seed)
|
||||||
|
|
||||||
|
# 1. 기존 통계 기반 가중치 계산
|
||||||
|
all_nums = [n for _, nums in draws for n in nums]
|
||||||
|
freq_all = Counter(all_nums)
|
||||||
|
|
||||||
|
recent = draws[-recent_window:] if len(draws) >= recent_window else draws
|
||||||
|
recent_nums = [n for _, nums in recent for n in nums]
|
||||||
|
freq_recent = Counter(recent_nums)
|
||||||
|
|
||||||
|
last_k = draws[-avoid_recent_k:] if len(draws) >= avoid_recent_k else draws
|
||||||
|
last_k_nums = set(n for _, nums in last_k for n in nums)
|
||||||
|
|
||||||
|
# 2. 히트맵 생성: 과거 추천에서 적중한 번호들의 빈도
|
||||||
|
heatmap = Counter()
|
||||||
|
recent_recs = past_recommendations[-heatmap_window:] if len(past_recommendations) >= heatmap_window else past_recommendations
|
||||||
|
|
||||||
|
for rec in recent_recs:
|
||||||
|
if rec.get("correct_count", 0) > 0: # 적중한 추천만
|
||||||
|
# 적중 개수에 비례해서 가중치 부여 (많이 맞춘 추천일수록 높은 가중)
|
||||||
|
weight = rec["correct_count"] ** 1.5 # 제곱으로 강조
|
||||||
|
for num in rec["numbers"]:
|
||||||
|
heatmap[num] += weight
|
||||||
|
|
||||||
|
# 3. 최종 가중치 = 기존 통계 + 히트맵
|
||||||
|
weights = {}
|
||||||
|
for n in range(1, 46):
|
||||||
|
w = freq_all[n] + recent_weight * freq_recent[n]
|
||||||
|
|
||||||
|
# 히트맵 가중치 추가
|
||||||
|
if n in heatmap:
|
||||||
|
w += heatmap_weight * heatmap[n]
|
||||||
|
|
||||||
|
# 최근 출현 번호 패널티
|
||||||
|
if n in last_k_nums:
|
||||||
|
w *= 0.6
|
||||||
|
|
||||||
|
weights[n] = max(w, 0.1)
|
||||||
|
|
||||||
|
# 4. 가중 샘플링으로 6개 선택
|
||||||
|
chosen = []
|
||||||
|
pool = list(range(1, 46))
|
||||||
|
for _ in range(6):
|
||||||
|
total = sum(weights[n] for n in pool)
|
||||||
|
r = random.random() * total
|
||||||
|
acc = 0.0
|
||||||
|
for n in pool:
|
||||||
|
acc += weights[n]
|
||||||
|
if acc >= r:
|
||||||
|
chosen.append(n)
|
||||||
|
pool.remove(n)
|
||||||
|
break
|
||||||
|
|
||||||
|
chosen_sorted = sorted(chosen)
|
||||||
|
|
||||||
|
# 5. 설명 데이터
|
||||||
|
explain = {
|
||||||
|
"recent_window": recent_window,
|
||||||
|
"recent_weight": recent_weight,
|
||||||
|
"avoid_recent_k": avoid_recent_k,
|
||||||
|
"heatmap_window": heatmap_window,
|
||||||
|
"heatmap_weight": heatmap_weight,
|
||||||
|
"top_all": [n for n, _ in freq_all.most_common(10)],
|
||||||
|
"top_recent": [n for n, _ in freq_recent.most_common(10)],
|
||||||
|
"top_heatmap": [n for n, _ in heatmap.most_common(10)],
|
||||||
|
"last_k_draws": [d for d, _ in last_k],
|
||||||
|
"analyzed_recommendations": len(recent_recs),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"numbers": chosen_sorted, "explain": explain}
|
||||||
|
|
||||||
|
|||||||
59
backend/app/utils.py
Normal file
59
backend/app/utils.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
from typing import List, Dict, Any, Tuple
|
||||||
|
|
||||||
|
def calc_metrics(numbers: List[int]) -> Dict[str, Any]:
|
||||||
|
nums = sorted(numbers)
|
||||||
|
s = sum(nums)
|
||||||
|
odd = sum(1 for x in nums if x % 2 == 1)
|
||||||
|
even = len(nums) - odd
|
||||||
|
mn, mx = nums[0], nums[-1]
|
||||||
|
rng = mx - mn
|
||||||
|
|
||||||
|
# 1-10, 11-20, 21-30, 31-40, 41-45
|
||||||
|
buckets = {
|
||||||
|
"1-10": 0,
|
||||||
|
"11-20": 0,
|
||||||
|
"21-30": 0,
|
||||||
|
"31-40": 0,
|
||||||
|
"41-45": 0,
|
||||||
|
}
|
||||||
|
for x in nums:
|
||||||
|
if 1 <= x <= 10:
|
||||||
|
buckets["1-10"] += 1
|
||||||
|
elif 11 <= x <= 20:
|
||||||
|
buckets["11-20"] += 1
|
||||||
|
elif 21 <= x <= 30:
|
||||||
|
buckets["21-30"] += 1
|
||||||
|
elif 31 <= x <= 40:
|
||||||
|
buckets["31-40"] += 1
|
||||||
|
else:
|
||||||
|
buckets["41-45"] += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"sum": s,
|
||||||
|
"odd": odd,
|
||||||
|
"even": even,
|
||||||
|
"min": mn,
|
||||||
|
"max": mx,
|
||||||
|
"range": rng,
|
||||||
|
"buckets": buckets,
|
||||||
|
}
|
||||||
|
|
||||||
|
def calc_recent_overlap(numbers: List[int], draws: List[Tuple[int, List[int]]], last_k: int) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
draws: [(drw_no, [n1..n6]), ...] 오름차순
|
||||||
|
last_k: 최근 k회 기준 중복
|
||||||
|
"""
|
||||||
|
if last_k <= 0:
|
||||||
|
return {"last_k": 0, "repeats": 0, "repeated_numbers": []}
|
||||||
|
|
||||||
|
recent = draws[-last_k:] if len(draws) >= last_k else draws
|
||||||
|
recent_set = set()
|
||||||
|
for _, nums in recent:
|
||||||
|
recent_set.update(nums)
|
||||||
|
|
||||||
|
repeated = sorted(set(numbers) & recent_set)
|
||||||
|
return {
|
||||||
|
"last_k": len(recent),
|
||||||
|
"repeats": len(repeated),
|
||||||
|
"repeated_numbers": repeated,
|
||||||
|
}
|
||||||
22
deployer/Dockerfile
Normal file
22
deployer/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
git rsync ca-certificates curl \
|
||||||
|
docker.io \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN groupadd -g 100 nasusers 2>/dev/null || true \
|
||||||
|
&& groupadd -g 65540 dockerhost \
|
||||||
|
&& useradd -u 1026 -g 100 -G 65540 -m deployer
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app.py /app/app.py
|
||||||
|
|
||||||
|
USER deployer
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
EXPOSE 9000
|
||||||
|
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "9000"]
|
||||||
52
deployer/app.py
Normal file
52
deployer/app.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import os, hmac, hashlib, subprocess
|
||||||
|
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# 로깅 설정
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger("deployer")
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
SECRET = os.getenv("WEBHOOK_SECRET", "")
|
||||||
|
|
||||||
|
def verify(sig: str, body: bytes) -> bool:
|
||||||
|
if not SECRET or not sig:
|
||||||
|
return False
|
||||||
|
|
||||||
|
mac = hmac.new(SECRET.encode(), msg=body, digestmod=hashlib.sha256).hexdigest()
|
||||||
|
candidates = {mac, f"sha256={mac}"}
|
||||||
|
return any(hmac.compare_digest(sig, c) for c in candidates)
|
||||||
|
|
||||||
|
def run_deploy_script():
|
||||||
|
"""배포 스크립트를 백그라운드에서 실행하고 로그를 남김"""
|
||||||
|
logger.info("Starting deployment script...")
|
||||||
|
try:
|
||||||
|
# 타임아웃 10분 설정
|
||||||
|
p = subprocess.run(["/bin/bash", "/scripts/deploy.sh"], capture_output=True, text=True, timeout=600)
|
||||||
|
|
||||||
|
if p.returncode == 0:
|
||||||
|
logger.info(f"Deployment SUCCESS:\n{p.stdout}")
|
||||||
|
else:
|
||||||
|
logger.error(f"Deployment FAILED ({p.returncode}):\n{p.stdout}\n{p.stderr}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Exception during deployment: {e}")
|
||||||
|
|
||||||
|
@app.post("/webhook")
|
||||||
|
async def webhook(req: Request, background_tasks: BackgroundTasks):
|
||||||
|
body = await req.body()
|
||||||
|
|
||||||
|
sig = (
|
||||||
|
req.headers.get("X-Gitea-Signature")
|
||||||
|
or req.headers.get("X-Hub-Signature-256")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
|
||||||
|
if not verify(sig, body):
|
||||||
|
raise HTTPException(401, "bad signature")
|
||||||
|
|
||||||
|
# ✅ 비동기 실행: Gitea에게는 즉시 OK 응답을 주고, 배포는 뒤에서 실행
|
||||||
|
background_tasks.add_task(run_deploy_script)
|
||||||
|
|
||||||
|
return {"ok": True, "message": "Deployment started in background"}
|
||||||
|
|
||||||
2
deployer/requirements.txt
Normal file
2
deployer/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn
|
||||||
@@ -2,7 +2,10 @@ version: "3.8"
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
build: ./backend
|
build:
|
||||||
|
context: ./backend
|
||||||
|
args:
|
||||||
|
APP_VERSION: ${APP_VERSION:-dev}
|
||||||
container_name: lotto-backend
|
container_name: lotto-backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -12,13 +15,42 @@ services:
|
|||||||
- LOTTO_ALL_URL=${LOTTO_ALL_URL:-https://smok95.github.io/lotto/results/all.json}
|
- LOTTO_ALL_URL=${LOTTO_ALL_URL:-https://smok95.github.io/lotto/results/all.json}
|
||||||
- LOTTO_LATEST_URL=${LOTTO_LATEST_URL:-https://smok95.github.io/lotto/results/latest.json}
|
- LOTTO_LATEST_URL=${LOTTO_LATEST_URL:-https://smok95.github.io/lotto/results/latest.json}
|
||||||
volumes:
|
volumes:
|
||||||
- /volume1/docker/webpage/data:/app/data
|
- ${RUNTIME_PATH}/data:/app/data
|
||||||
|
|
||||||
|
stock-lab:
|
||||||
|
build:
|
||||||
|
context: ./stock-lab
|
||||||
|
args:
|
||||||
|
APP_VERSION: ${APP_VERSION:-dev}
|
||||||
|
container_name: stock-lab
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "18500:8000"
|
||||||
|
environment:
|
||||||
|
- TZ=${TZ:-Asia/Seoul}
|
||||||
|
- WINDOWS_AI_SERVER_URL=${WINDOWS_AI_SERVER_URL:-http://192.168.0.5:8000}
|
||||||
|
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:
|
travel-proxy:
|
||||||
build: ./travel-proxy
|
build: ./travel-proxy
|
||||||
container_name: travel-proxy
|
container_name: travel-proxy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
user: "1026:100"
|
user: "${PUID}:${PGID}"
|
||||||
ports:
|
ports:
|
||||||
- "19000:8000" # 내부 확인용
|
- "19000:8000" # 내부 확인용
|
||||||
environment:
|
environment:
|
||||||
@@ -29,19 +61,36 @@ services:
|
|||||||
- TRAVEL_CACHE_TTL=${TRAVEL_CACHE_TTL:-300}
|
- TRAVEL_CACHE_TTL=${TRAVEL_CACHE_TTL:-300}
|
||||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-*}
|
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-*}
|
||||||
volumes:
|
volumes:
|
||||||
- /volume1/web/images/webPage/travel:/data/travel:ro
|
- ${PHOTO_PATH}:/data/travel:ro
|
||||||
- /volume1/docker/webpage/travel-thumbs:/data/thumbs:rw
|
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:rw
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: nginx:alpine
|
image: nginx:alpine
|
||||||
container_name: lotto-frontend
|
container_name: lotto-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- music-lab
|
||||||
ports:
|
ports:
|
||||||
- "8080:80"
|
- "8080:80"
|
||||||
volumes:
|
volumes:
|
||||||
- /volume1/docker/webpage/frontend:/usr/share/nginx/html:ro
|
- ${FRONTEND_PATH}:/usr/share/nginx/html:ro
|
||||||
- /volume1/docker/webpage/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
- ${RUNTIME_PATH}/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
- /volume1/web/images/webPage/travel:/data/travel:ro
|
- ${PHOTO_PATH}:/data/travel:ro
|
||||||
- /volume1/docker/webpage/travel-thumbs:/data/thumbs:ro
|
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:ro
|
||||||
|
- ${MUSIC_DATA_PATH:-./data/music}:/data/music:ro
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
|
|
||||||
|
deployer:
|
||||||
|
build: ./deployer
|
||||||
|
container_name: webpage-deployer
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "19010:9000" # 외부 노출 필요 없으면 내부만 (리버스프록시로 /webhook만 노출 추천)
|
||||||
|
environment:
|
||||||
|
- WEBHOOK_SECRET=${WEBHOOK_SECRET}
|
||||||
|
volumes:
|
||||||
|
- ${REPO_PATH}:/repo:rw
|
||||||
|
- ${RUNTIME_PATH}:/runtime:rw
|
||||||
|
- ${RUNTIME_PATH}/scripts:/scripts:ro
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
|||||||
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,31 @@ server {
|
|||||||
try_files $uri =404;
|
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 — 변수 기반 proxy_pass + $request_uri로 전체 경로 전달
|
||||||
|
location /api/music/ {
|
||||||
|
resolver 127.0.0.11 valid=10s;
|
||||||
|
set $music_backend music-lab:8000;
|
||||||
|
|
||||||
|
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;
|
||||||
|
proxy_pass http://$music_backend$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
# travel thumbnails (generated by travel-proxy, stored in /data/thumbs)
|
# travel thumbnails (generated by travel-proxy, stored in /data/thumbs)
|
||||||
location ^~ /media/travel/.thumb/ {
|
location ^~ /media/travel/.thumb/ {
|
||||||
alias /data/thumbs/;
|
alias /data/thumbs/;
|
||||||
@@ -54,6 +79,37 @@ server {
|
|||||||
proxy_pass http://travel-proxy:8000/api/travel/;
|
proxy_pass http://travel-proxy:8000/api/travel/;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# stock API
|
||||||
|
location /api/stock/ {
|
||||||
|
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_pass http://stock-lab:8000/api/stock/;
|
||||||
|
}
|
||||||
|
|
||||||
|
# trade API (Stock Lab Proxy)
|
||||||
|
location /api/trade/ {
|
||||||
|
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_pass http://stock-lab:8000/api/trade/;
|
||||||
|
}
|
||||||
|
|
||||||
|
# portfolio API (Stock Lab) — trailing slash 유무 모두 매칭
|
||||||
|
location /api/portfolio {
|
||||||
|
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_pass http://stock-lab:8000/api/portfolio;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# API 프록시 (여기가 포인트: /api/ 중복 제거)
|
# API 프록시 (여기가 포인트: /api/ 중복 제거)
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -66,6 +122,62 @@ server {
|
|||||||
proxy_pass http://backend:8000;
|
proxy_pass http://backend:8000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Fear & Greed Index (CNN 공개 API)
|
||||||
|
# 프로덕션 nginx에서는 아래 proxy_pass 추가 필요:
|
||||||
|
location /ext/feargreed {
|
||||||
|
proxy_pass https://production.dataviz.cnn.io/index/fearandgreed/graphdata;
|
||||||
|
proxy_set_header Host production.dataviz.cnn.io;
|
||||||
|
}
|
||||||
|
|
||||||
|
# VIX (CBOE 변동성 지수) — Yahoo Finance 공개 API
|
||||||
|
# 프로덕션 nginx에서는 아래 proxy_pass 추가 필요:
|
||||||
|
location /ext/vix {
|
||||||
|
proxy_pass https://query1.finance.yahoo.com/v8/finance/chart/%5EVIX?interval=1d&range=1d;
|
||||||
|
proxy_set_header Host query1.finance.yahoo.com;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 미국 10년물 국채 금리 (^TNX) — Yahoo Finance
|
||||||
|
# 프로덕션 nginx 설정 필요:
|
||||||
|
location /ext/treasury {
|
||||||
|
proxy_pass https://query1.finance.yahoo.com/v8/finance/chart/%5ETNX?interval=1d&range=1d;
|
||||||
|
proxy_set_header Host query1.finance.yahoo.com;
|
||||||
|
}
|
||||||
|
|
||||||
|
# WTI 원유 선물 (CL=F) — Yahoo Finance
|
||||||
|
# 프로덕션 nginx 설정 필요:
|
||||||
|
location /ext/wti {
|
||||||
|
proxy_pass https://query1.finance.yahoo.com/v8/finance/chart/CL%3DF?interval=1d&range=1d;
|
||||||
|
proxy_set_header Host query1.finance.yahoo.com;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Brent 원유 선물 (BZ=F) — Yahoo Finance
|
||||||
|
# 프로덕션 nginx 설정 필요:
|
||||||
|
location /ext/brent {
|
||||||
|
proxy_pass https://query1.finance.yahoo.com/v8/finance/chart/BZ%3DF?interval=1d&range=1d;
|
||||||
|
proxy_set_header Host query1.finance.yahoo.com;
|
||||||
|
}
|
||||||
|
|
||||||
|
# webhook receiver (handle both /webhook and /webhook/)
|
||||||
|
location = /webhook {
|
||||||
|
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_pass http://deployer:9000/webhook;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /webhook/ {
|
||||||
|
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_pass http://deployer:9000/webhook;
|
||||||
|
}
|
||||||
|
|
||||||
# SPA 라우팅 (마지막에 두는 게 안전)
|
# SPA 라우팅 (마지막에 두는 게 안전)
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ROOT="/volume1/docker/webpage"
|
|
||||||
|
|
||||||
cd "$ROOT"
|
|
||||||
|
|
||||||
echo "[1/5] git fetch + pull"
|
|
||||||
git fetch --all --prune
|
|
||||||
git pull --ff-only
|
|
||||||
|
|
||||||
echo "[2/5] docker compose build"
|
|
||||||
docker compose build --pull
|
|
||||||
|
|
||||||
echo "[3/5] docker compose up"
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
echo "[4/5] status"
|
|
||||||
docker compose ps
|
|
||||||
|
|
||||||
echo "[5/5] done"
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
BASE="http://127.0.0.1"
|
|
||||||
|
|
||||||
echo "backend health:"
|
|
||||||
curl -fsS "${BASE}:18000/health" | sed 's/^/ /'
|
|
||||||
|
|
||||||
echo "backend latest:"
|
|
||||||
curl -fsS "${BASE}:18000/api/lotto/latest" | head -c 200; echo
|
|
||||||
|
|
||||||
echo "travel regions:"
|
|
||||||
curl -fsS "${BASE}:19000/api/travel/regions" | head -c 200; echo
|
|
||||||
|
|
||||||
echo "OK"
|
|
||||||
62
scripts/deploy-nas.sh
Normal file
62
scripts/deploy-nas.sh
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# 1. 자동 감지: Docker 컨테이너 내부인가?
|
||||||
|
if [ -d "/repo" ] && [ -d "/runtime" ]; then
|
||||||
|
echo "Detected Docker Container environment."
|
||||||
|
SRC="/repo"
|
||||||
|
DST="/runtime"
|
||||||
|
else
|
||||||
|
# 2. Host 환경: .env 로드 시도
|
||||||
|
if [ -f ".env" ]; then
|
||||||
|
echo "Loading .env file..."
|
||||||
|
set -a; source .env; set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 환경변수가 없으면 현재 디렉토리를 SRC로
|
||||||
|
SRC="${REPO_PATH:-$(pwd)}"
|
||||||
|
DST="${RUNTIME_PATH:-}"
|
||||||
|
|
||||||
|
if [ -z "$DST" ]; then
|
||||||
|
echo "Error: RUNTIME_PATH is not set. Please create .env file with RUNTIME_PATH defined."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Source: $SRC"
|
||||||
|
echo "Target: $DST"
|
||||||
|
|
||||||
|
cd "$SRC"
|
||||||
|
|
||||||
|
# 레포에서 운영으로 반영할 항목들만 복사/동기화 (필요한 것만 적기)
|
||||||
|
# backend, travel-proxy, deployer, nginx, scripts, docker-compose.yml, .env 등
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude ".git" \
|
||||||
|
--exclude ".releases" \
|
||||||
|
"$SRC/backend/" "$DST/backend/"
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude ".git" \
|
||||||
|
"$SRC/travel-proxy/" "$DST/travel-proxy/"
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude ".git" \
|
||||||
|
"$SRC/deployer/" "$DST/deployer/"
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude ".git" \
|
||||||
|
"$SRC/stock-lab/" "$DST/stock-lab/"
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude ".git" \
|
||||||
|
"$SRC/music-lab/" "$DST/music-lab/"
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude ".git" \
|
||||||
|
"$SRC/nginx/" "$DST/nginx/"
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude ".git" \
|
||||||
|
"$SRC/scripts/" "$DST/scripts/"
|
||||||
|
|
||||||
|
# compose 파일 / env 파일
|
||||||
|
rsync -a "$SRC/docker-compose.yml" "$DST/docker-compose.yml"
|
||||||
|
if [ -f "$SRC/.env" ]; then
|
||||||
|
rsync -a "$SRC/.env" "$DST/.env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "SYNC_OK"
|
||||||
50
scripts/deploy.sh
Normal file
50
scripts/deploy.sh
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# 1. 자동 감지: Docker 컨테이너 내부인가?
|
||||||
|
if [ -d "/repo" ] && [ -d "/runtime" ]; then
|
||||||
|
echo "Detected Docker Container environment."
|
||||||
|
SRC="/repo"
|
||||||
|
DST="/runtime"
|
||||||
|
else
|
||||||
|
# 2. Host 환경: .env 로드 시도
|
||||||
|
if [ -f ".env" ]; then
|
||||||
|
echo "Loading .env file..."
|
||||||
|
set -a; source .env; set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 환경변수가 없으면 현재 디렉토리를 SRC로
|
||||||
|
SRC="${REPO_PATH:-$(pwd)}"
|
||||||
|
DST="${RUNTIME_PATH:-/volume1/docker/webpage}" # 기본값 설정
|
||||||
|
|
||||||
|
if [ -z "$DST" ]; then
|
||||||
|
echo "Error: RUNTIME_PATH is not set."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Source: $SRC"
|
||||||
|
echo "Target: $DST"
|
||||||
|
|
||||||
|
git config --global --add safe.directory "$SRC"
|
||||||
|
|
||||||
|
cd "$SRC"
|
||||||
|
git fetch --all --prune
|
||||||
|
git pull --ff-only
|
||||||
|
|
||||||
|
# 릴리즈 백업(롤백용): 아래 5번과 연결
|
||||||
|
TAG="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
mkdir -p "$DST/.releases/$TAG"
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude ".releases" \
|
||||||
|
"$DST/" "$DST/.releases/$TAG/"
|
||||||
|
|
||||||
|
# 소스 → 운영 반영 (네가 이미 만든 deploy-nas.sh가 있으면 그걸 호출해도 됨)
|
||||||
|
# 예: repo/scripts/deploy-nas.sh가 운영으로 복사/동기화하는 로직이라면:
|
||||||
|
bash "$SRC/scripts/deploy-nas.sh"
|
||||||
|
|
||||||
|
cd "$DST"
|
||||||
|
docker-compose up -d --build backend travel-proxy stock-lab frontend deployer
|
||||||
|
docker exec lotto-frontend nginx -s reload 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "DEPLOY_OK $TAG"
|
||||||
59
scripts/healthcheck.sh
Normal file
59
scripts/healthcheck.sh
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# NAS 내부 헬스체크용 (localhost 사용)
|
||||||
|
# 포트: backend(18000), travel-proxy(19000), frontend(8080)
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
echo "======================================"
|
||||||
|
echo " Starting Health Check..."
|
||||||
|
echo "======================================"
|
||||||
|
|
||||||
|
check_url() {
|
||||||
|
local name="$1"
|
||||||
|
local url="$2"
|
||||||
|
|
||||||
|
# HTTP 상태 코드만 가져옴 (타임아웃 5초)
|
||||||
|
status=$(curl -o /dev/null -s -w "%{http_code}" --max-time 5 "$url" || echo "FAIL")
|
||||||
|
|
||||||
|
if [[ "$status" == "200" ]]; then
|
||||||
|
echo -e "[${GREEN}OK${NC}] $name ($url) -> $status"
|
||||||
|
else
|
||||||
|
echo -e "[${RED}XX${NC}] $name ($url) -> $status"
|
||||||
|
# 하나라도 실패하면 exit 1 (CI/CD용)
|
||||||
|
# exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--- 1. Backend Service ---"
|
||||||
|
check_url "Backend Health" "http://localhost:18000/health"
|
||||||
|
check_url "Lotto Latest" "http://localhost:18000/api/lotto/latest"
|
||||||
|
check_url "Stats API" "http://localhost:18000/api/lotto/stats"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--- 2. Travel Proxy Service ---"
|
||||||
|
# Travel Proxy는 Main.py에서 루트(/) 엔드포인트가 없을 수 있어서 regions 체크
|
||||||
|
check_url "Travel Regions" "http://localhost:19000/api/travel/regions"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--- 3. Stock Lab Service ---"
|
||||||
|
check_url "Stock Health" "http://localhost:18500/health"
|
||||||
|
check_url "Stock News" "http://localhost:18500/api/stock/news"
|
||||||
|
check_url "Stock Indices" "http://localhost:18500/api/stock/indices"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--- 4. Frontend (Nginx) ---"
|
||||||
|
# 외부 포트 8080으로 접속 테스트
|
||||||
|
check_url "Frontend Home" "http://localhost:8080"
|
||||||
|
# Nginx가 Backend로 잘 프록시하는지 체크 (실제 존재하는 api 호출)
|
||||||
|
check_url "Nginx->Backend Proxy" "http://localhost:8080/api/lotto/latest"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "======================================"
|
||||||
|
echo " Health Check Completed."
|
||||||
|
echo "======================================"
|
||||||
9
stock-lab/Dockerfile
Normal file
9
stock-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"]
|
||||||
276
stock-lab/app/db.py
Normal file
276
stock-lab/app/db.py
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
import hashlib
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
|
DB_PATH = "/app/data/stock.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():
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS articles (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
hash TEXT UNIQUE NOT NULL,
|
||||||
|
category TEXT DEFAULT 'domestic',
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
link TEXT,
|
||||||
|
summary TEXT,
|
||||||
|
press TEXT,
|
||||||
|
pub_date TEXT,
|
||||||
|
crawled_at TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_crawled ON articles(crawled_at DESC)")
|
||||||
|
|
||||||
|
# 컬럼 추가 (기존 테이블 마이그레이션)
|
||||||
|
cols = {r["name"] for r in conn.execute("PRAGMA table_info(articles)").fetchall()}
|
||||||
|
if "category" not in cols:
|
||||||
|
conn.execute("ALTER TABLE articles ADD COLUMN category TEXT DEFAULT 'domestic'")
|
||||||
|
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS portfolio (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
broker TEXT NOT NULL,
|
||||||
|
ticker TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
quantity INTEGER NOT NULL,
|
||||||
|
avg_price INTEGER NOT NULL,
|
||||||
|
created_at TEXT DEFAULT (datetime('now','localtime')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now','localtime'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS broker_cash (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
broker TEXT UNIQUE NOT NULL,
|
||||||
|
cash INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at TEXT DEFAULT (datetime('now','localtime'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS asset_snapshots (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
date TEXT UNIQUE NOT NULL,
|
||||||
|
total_eval INTEGER NOT NULL,
|
||||||
|
total_cash INTEGER NOT NULL,
|
||||||
|
total_assets INTEGER NOT NULL,
|
||||||
|
created_at TEXT DEFAULT (datetime('now','localtime'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS sell_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
broker TEXT NOT NULL,
|
||||||
|
ticker TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
quantity INTEGER NOT NULL,
|
||||||
|
avg_price REAL NOT NULL,
|
||||||
|
sell_price REAL NOT NULL,
|
||||||
|
buy_amount REAL NOT NULL,
|
||||||
|
sell_amount REAL NOT NULL,
|
||||||
|
realized_profit REAL NOT NULL,
|
||||||
|
realized_rate REAL NOT NULL,
|
||||||
|
sold_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
def save_articles(articles: List[Dict[str, str]]) -> int:
|
||||||
|
count = 0
|
||||||
|
with _conn() as conn:
|
||||||
|
for a in articles:
|
||||||
|
# 중복 체크용 해시 (제목+링크)
|
||||||
|
unique_str = f"{a['title']}|{a['link']}"
|
||||||
|
h = hashlib.md5(unique_str.encode()).hexdigest()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cat = a.get("category", "domestic")
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO articles (hash, category, title, link, summary, press, pub_date, crawled_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (h, cat, a['title'], a['link'], a['summary'], a['press'], a['date'], a['crawled_at']))
|
||||||
|
count += 1
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
pass # 이미 존재함
|
||||||
|
return count
|
||||||
|
|
||||||
|
def get_latest_articles(limit: int = 20, category: str = None) -> List[Dict[str, Any]]:
|
||||||
|
with _conn() as conn:
|
||||||
|
if category:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM articles WHERE category = ? ORDER BY crawled_at DESC, id DESC LIMIT ?",
|
||||||
|
(category, limit)
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM articles ORDER BY crawled_at DESC, id DESC LIMIT ?",
|
||||||
|
(limit,)
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Portfolio CRUD ---
|
||||||
|
|
||||||
|
def add_portfolio_item(broker: str, ticker: str, name: str, quantity: int, avg_price: int) -> int:
|
||||||
|
with _conn() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO portfolio (broker, ticker, name, quantity, avg_price) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(broker, ticker, name, quantity, avg_price),
|
||||||
|
)
|
||||||
|
return cur.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_portfolio() -> List[Dict[str, Any]]:
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute("SELECT * FROM portfolio ORDER BY id").fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_portfolio_item(item_id: int) -> Dict[str, Any] | None:
|
||||||
|
with _conn() as conn:
|
||||||
|
row = conn.execute("SELECT * FROM portfolio WHERE id = ?", (item_id,)).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def update_portfolio_item(item_id: int, **kwargs) -> bool:
|
||||||
|
allowed = {"broker", "ticker", "name", "quantity", "avg_price"}
|
||||||
|
fields = {k: v for k, v in kwargs.items() if k in allowed and v is not None}
|
||||||
|
if not fields:
|
||||||
|
return False
|
||||||
|
fields["updated_at"] = __import__("datetime").datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
set_clause = ", ".join(f"{k} = ?" for k in fields)
|
||||||
|
values = list(fields.values()) + [item_id]
|
||||||
|
with _conn() as conn:
|
||||||
|
cur = conn.execute(f"UPDATE portfolio SET {set_clause} WHERE id = ?", values)
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def delete_portfolio_item(item_id: int) -> bool:
|
||||||
|
with _conn() as conn:
|
||||||
|
cur = conn.execute("DELETE FROM portfolio WHERE id = ?", (item_id,))
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
# --- Broker Cash CRUD ---
|
||||||
|
|
||||||
|
def upsert_broker_cash(broker: str, cash: int) -> None:
|
||||||
|
now = __import__("datetime").datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO broker_cash (broker, cash, updated_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(broker) DO UPDATE SET cash = excluded.cash, updated_at = excluded.updated_at
|
||||||
|
""", (broker, cash, now))
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_broker_cash() -> List[Dict[str, Any]]:
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute("SELECT * FROM broker_cash ORDER BY broker").fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_broker_cash(broker: str) -> Dict[str, Any] | None:
|
||||||
|
with _conn() as conn:
|
||||||
|
row = conn.execute("SELECT * FROM broker_cash WHERE broker = ?", (broker,)).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def delete_broker_cash(broker: str) -> bool:
|
||||||
|
with _conn() as conn:
|
||||||
|
cur = conn.execute("DELETE FROM broker_cash WHERE broker = ?", (broker,))
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
# --- Asset Snapshot CRUD ---
|
||||||
|
|
||||||
|
def upsert_asset_snapshot(date: str, total_eval: int, total_cash: int, total_assets: int) -> None:
|
||||||
|
now = __import__("datetime").datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO asset_snapshots (date, total_eval, total_cash, total_assets, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(date) DO UPDATE SET
|
||||||
|
total_eval = excluded.total_eval,
|
||||||
|
total_cash = excluded.total_cash,
|
||||||
|
total_assets = excluded.total_assets,
|
||||||
|
created_at = excluded.created_at
|
||||||
|
""", (date, total_eval, total_cash, total_assets, now))
|
||||||
|
|
||||||
|
|
||||||
|
# --- Sell History CRUD ---
|
||||||
|
|
||||||
|
def add_sell_history(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
with _conn() as conn:
|
||||||
|
cur = conn.execute("""
|
||||||
|
INSERT INTO sell_history
|
||||||
|
(broker, ticker, name, quantity, avg_price, sell_price,
|
||||||
|
buy_amount, sell_amount, realized_profit, realized_rate, sold_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (
|
||||||
|
data["broker"], data["ticker"], data["name"], data["quantity"],
|
||||||
|
data["avg_price"], data["sell_price"], data["buy_amount"],
|
||||||
|
data["sell_amount"], data["realized_profit"], data["realized_rate"],
|
||||||
|
data["sold_at"],
|
||||||
|
))
|
||||||
|
row = conn.execute("SELECT * FROM sell_history WHERE id = ?", (cur.lastrowid,)).fetchone()
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
def get_sell_history(broker: str = None, days: int = None) -> List[Dict[str, Any]]:
|
||||||
|
conditions = []
|
||||||
|
params = []
|
||||||
|
if broker:
|
||||||
|
conditions.append("broker = ?")
|
||||||
|
params.append(broker)
|
||||||
|
if days:
|
||||||
|
conditions.append("sold_at >= datetime('now', ? || ' days')")
|
||||||
|
params.append(f"-{days}")
|
||||||
|
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT * FROM sell_history {where} ORDER BY sold_at DESC",
|
||||||
|
params,
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def update_sell_history(record_id: int, data: Dict[str, Any]) -> Dict[str, Any] | None:
|
||||||
|
fields = ["broker", "ticker", "name", "quantity", "avg_price", "sell_price",
|
||||||
|
"buy_amount", "sell_amount", "realized_profit", "realized_rate", "sold_at"]
|
||||||
|
set_clause = ", ".join(f"{f} = ?" for f in fields)
|
||||||
|
values = [data[f] for f in fields] + [record_id]
|
||||||
|
with _conn() as conn:
|
||||||
|
cur = conn.execute(f"UPDATE sell_history SET {set_clause} WHERE id = ?", values)
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
return None
|
||||||
|
row = conn.execute("SELECT * FROM sell_history WHERE id = ?", (record_id,)).fetchone()
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_sell_history(record_id: int) -> bool:
|
||||||
|
with _conn() as conn:
|
||||||
|
cur = conn.execute("DELETE FROM sell_history WHERE id = ?", (record_id,))
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_asset_snapshots(days: int = 30) -> List[Dict[str, Any]]:
|
||||||
|
with _conn() as conn:
|
||||||
|
if days == 0:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT date, total_eval, total_cash, total_assets FROM asset_snapshots ORDER BY date ASC"
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT date, total_eval, total_cash, total_assets FROM asset_snapshots ORDER BY date DESC LIMIT ?",
|
||||||
|
(days,)
|
||||||
|
).fetchall()
|
||||||
|
rows = list(reversed(rows))
|
||||||
|
return [dict(r) for r in rows]
|
||||||
18
stock-lab/app/holidays.json
Normal file
18
stock-lab/app/holidays.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
[
|
||||||
|
"2026-01-01",
|
||||||
|
"2026-01-28",
|
||||||
|
"2026-01-29",
|
||||||
|
"2026-01-30",
|
||||||
|
"2026-03-01",
|
||||||
|
"2026-05-05",
|
||||||
|
"2026-05-25",
|
||||||
|
"2026-06-06",
|
||||||
|
"2026-08-15",
|
||||||
|
"2026-09-24",
|
||||||
|
"2026-09-25",
|
||||||
|
"2026-09-26",
|
||||||
|
"2026-10-03",
|
||||||
|
"2026-10-09",
|
||||||
|
"2026-12-25",
|
||||||
|
"2026-12-31"
|
||||||
|
]
|
||||||
411
stock-lab/app/main.py
Normal file
411
stock-lab/app/main.py
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
from datetime import date as date_type
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import FastAPI, Query
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
import requests
|
||||||
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from .db import (
|
||||||
|
init_db, save_articles, get_latest_articles,
|
||||||
|
add_portfolio_item, get_all_portfolio, get_portfolio_item,
|
||||||
|
update_portfolio_item, delete_portfolio_item,
|
||||||
|
upsert_broker_cash, get_all_broker_cash, get_broker_cash, delete_broker_cash,
|
||||||
|
upsert_asset_snapshot, get_asset_snapshots,
|
||||||
|
add_sell_history, get_sell_history, update_sell_history, delete_sell_history,
|
||||||
|
)
|
||||||
|
from .scraper import fetch_market_news, fetch_major_indices, fetch_overseas_news
|
||||||
|
from .price_fetcher import get_current_prices
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
# CORS 설정 (프론트엔드 접근 허용)
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"], # 운영 시에는 구체적인 도메인으로 제한하는 것이 좋음
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
|
||||||
|
|
||||||
|
# Windows AI Server URL (NAS .env에서 설정)
|
||||||
|
WINDOWS_AI_SERVER_URL = os.getenv("WINDOWS_AI_SERVER_URL", "http://192.168.0.5:8000")
|
||||||
|
|
||||||
|
# 공휴일 목록 로드
|
||||||
|
_HOLIDAYS_PATH = os.path.join(os.path.dirname(__file__), "holidays.json")
|
||||||
|
try:
|
||||||
|
with open(_HOLIDAYS_PATH, "r") as f:
|
||||||
|
_HOLIDAYS: set = set(json.load(f))
|
||||||
|
except Exception:
|
||||||
|
_HOLIDAYS = set()
|
||||||
|
|
||||||
|
def is_market_open(d: date_type) -> bool:
|
||||||
|
return d.weekday() < 5 and d.strftime("%Y-%m-%d") not in _HOLIDAYS
|
||||||
|
|
||||||
|
|
||||||
|
def save_daily_snapshot():
|
||||||
|
today = date_type.today()
|
||||||
|
if not is_market_open(today):
|
||||||
|
print(f"[Snapshot] {today} 휴장일 — 스킵")
|
||||||
|
return
|
||||||
|
|
||||||
|
today_str = today.strftime("%Y-%m-%d")
|
||||||
|
items = get_all_portfolio()
|
||||||
|
cash_rows = get_all_broker_cash()
|
||||||
|
total_cash = sum(r["cash"] for r in cash_rows)
|
||||||
|
|
||||||
|
if items:
|
||||||
|
tickers = list({item["ticker"] for item in items})
|
||||||
|
prices = get_current_prices(tickers)
|
||||||
|
total_eval = sum(
|
||||||
|
prices.get(item["ticker"], item["avg_price"]) * item["quantity"]
|
||||||
|
for item in items
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
total_eval = 0
|
||||||
|
|
||||||
|
total_assets = total_eval + total_cash
|
||||||
|
upsert_asset_snapshot(today_str, total_eval, total_cash, total_assets)
|
||||||
|
print(f"[Snapshot] {today_str} 저장 완료: eval={total_eval}, cash={total_cash}, total={total_assets}")
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def on_startup():
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
# 매일 아침 8시 뉴스 스크랩 (NAS 자체 수행)
|
||||||
|
scheduler.add_job(run_scraping_job, "cron", hour="8", minute="0")
|
||||||
|
|
||||||
|
# 평일 15:40 총 자산 스냅샷 저장
|
||||||
|
scheduler.add_job(save_daily_snapshot, "cron", day_of_week="mon-fri", hour=15, minute=40)
|
||||||
|
|
||||||
|
# 앱 시작 시에도 한 번 실행 (데이터 없으면)
|
||||||
|
if not get_latest_articles(1):
|
||||||
|
run_scraping_job()
|
||||||
|
|
||||||
|
scheduler.start()
|
||||||
|
|
||||||
|
def run_scraping_job():
|
||||||
|
print("[StockLab] Starting news scraping...")
|
||||||
|
|
||||||
|
# 1. 국내
|
||||||
|
articles_kr = fetch_market_news()
|
||||||
|
count_kr = save_articles(articles_kr)
|
||||||
|
|
||||||
|
# 2. 해외 (임시 차단)
|
||||||
|
# articles_world = fetch_overseas_news()
|
||||||
|
# count_world = save_articles(articles_world)
|
||||||
|
count_world = 0
|
||||||
|
|
||||||
|
print(f"[StockLab] Saved {count_kr} domestic, {count_world} overseas articles.")
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@app.get("/api/stock/news")
|
||||||
|
def get_news(limit: int = 20, category: str = None):
|
||||||
|
"""최신 주식 뉴스 조회 (category: 'domestic' | 'overseas')"""
|
||||||
|
return get_latest_articles(limit, category)
|
||||||
|
|
||||||
|
@app.get("/api/stock/indices")
|
||||||
|
def get_indices():
|
||||||
|
"""주요 지표(KOSPI 등) 실시간 크롤링 조회"""
|
||||||
|
return fetch_major_indices()
|
||||||
|
|
||||||
|
@app.post("/api/stock/scrap")
|
||||||
|
def trigger_scrap():
|
||||||
|
"""수동 스크랩 트리거"""
|
||||||
|
run_scraping_job()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
# --- Trading API (Windows Proxy) ---
|
||||||
|
|
||||||
|
@app.get("/api/trade/balance")
|
||||||
|
def get_balance():
|
||||||
|
"""계좌 잔고 조회 (Windows AI Server Proxy)"""
|
||||||
|
print(f"[Proxy] Requesting Balance from {WINDOWS_AI_SERVER_URL}...")
|
||||||
|
try:
|
||||||
|
resp = requests.get(f"{WINDOWS_AI_SERVER_URL}/trade/balance", timeout=5)
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
print(f"[ProxyError] Balance Error: {resp.status_code} {resp.text}")
|
||||||
|
return JSONResponse(status_code=resp.status_code, content=resp.json())
|
||||||
|
|
||||||
|
print("[Proxy] Balance Success")
|
||||||
|
return resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ProxyError] Connection Failed: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"error": "Connection Failed", "detail": str(e), "target": WINDOWS_AI_SERVER_URL}
|
||||||
|
)
|
||||||
|
|
||||||
|
class OrderRequest(BaseModel):
|
||||||
|
ticker: str # 종목 코드 (예: "005930")
|
||||||
|
action: str # "BUY" or "SELL"
|
||||||
|
quantity: int # 주문 수량
|
||||||
|
price: int = 0 # 0이면 시장가
|
||||||
|
reason: Optional[str] = "Manual Order" # 주문 사유 (AI 기록용)
|
||||||
|
|
||||||
|
@app.post("/api/trade/order")
|
||||||
|
def order_stock(req: OrderRequest):
|
||||||
|
"""주식 매수/매도 주문 (Windows AI Server Proxy)"""
|
||||||
|
print(f"[Proxy] Order Request: {req.dict()} to {WINDOWS_AI_SERVER_URL}...")
|
||||||
|
try:
|
||||||
|
resp = requests.post(f"{WINDOWS_AI_SERVER_URL}/trade/order", json=req.dict(), timeout=10)
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
print(f"[ProxyError] Order Error: {resp.status_code} {resp.text}")
|
||||||
|
return JSONResponse(status_code=resp.status_code, content=resp.json())
|
||||||
|
|
||||||
|
return resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ProxyError] Order Connection Failed: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"error": "Connection Failed", "detail": str(e), "target": WINDOWS_AI_SERVER_URL}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/version")
|
||||||
|
def version():
|
||||||
|
return {"version": os.getenv("APP_VERSION", "dev")}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Portfolio API ---
|
||||||
|
|
||||||
|
class PortfolioItemRequest(BaseModel):
|
||||||
|
broker: str
|
||||||
|
ticker: str
|
||||||
|
name: str
|
||||||
|
quantity: int
|
||||||
|
avg_price: int
|
||||||
|
|
||||||
|
|
||||||
|
class PortfolioUpdateRequest(BaseModel):
|
||||||
|
broker: Optional[str] = None
|
||||||
|
ticker: Optional[str] = None
|
||||||
|
name: Optional[str] = None
|
||||||
|
quantity: Optional[int] = None
|
||||||
|
avg_price: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/portfolio")
|
||||||
|
def get_portfolio():
|
||||||
|
"""전체 포트폴리오 조회 (현재가 + 손익 + 예수금 포함)"""
|
||||||
|
items = get_all_portfolio()
|
||||||
|
cash_rows = get_all_broker_cash()
|
||||||
|
total_cash = sum(r["cash"] for r in cash_rows)
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
return {
|
||||||
|
"holdings": [],
|
||||||
|
"cash": cash_rows,
|
||||||
|
"summary": {
|
||||||
|
"total_buy": 0,
|
||||||
|
"total_eval": 0,
|
||||||
|
"total_profit": 0,
|
||||||
|
"total_profit_rate": 0.0,
|
||||||
|
"total_cash": total_cash,
|
||||||
|
"total_assets": total_cash,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tickers = list({item["ticker"] for item in items})
|
||||||
|
prices = get_current_prices(tickers)
|
||||||
|
|
||||||
|
holdings = []
|
||||||
|
total_buy = 0
|
||||||
|
total_eval = 0
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
current_price = prices.get(item["ticker"])
|
||||||
|
buy_amount = item["avg_price"] * item["quantity"]
|
||||||
|
eval_amount = current_price * item["quantity"] if current_price is not None else None
|
||||||
|
profit_amount = (eval_amount - buy_amount) if eval_amount is not None else None
|
||||||
|
profit_rate = round((profit_amount / buy_amount) * 100, 2) if (profit_amount is not None and buy_amount) else None
|
||||||
|
|
||||||
|
holdings.append({
|
||||||
|
"id": item["id"],
|
||||||
|
"broker": item["broker"],
|
||||||
|
"ticker": item["ticker"],
|
||||||
|
"name": item["name"],
|
||||||
|
"quantity": item["quantity"],
|
||||||
|
"avg_price": item["avg_price"],
|
||||||
|
"current_price": current_price,
|
||||||
|
"eval_amount": eval_amount,
|
||||||
|
"profit_amount": profit_amount,
|
||||||
|
"profit_rate": profit_rate,
|
||||||
|
})
|
||||||
|
|
||||||
|
total_buy += buy_amount
|
||||||
|
if eval_amount is not None:
|
||||||
|
total_eval += eval_amount
|
||||||
|
|
||||||
|
total_profit = total_eval - total_buy
|
||||||
|
total_profit_rate = round((total_profit / total_buy) * 100, 2) if total_buy else 0.0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"holdings": holdings,
|
||||||
|
"cash": cash_rows,
|
||||||
|
"summary": {
|
||||||
|
"total_buy": total_buy,
|
||||||
|
"total_eval": total_eval,
|
||||||
|
"total_profit": total_profit,
|
||||||
|
"total_profit_rate": total_profit_rate,
|
||||||
|
"total_cash": total_cash,
|
||||||
|
"total_assets": total_eval + total_cash,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/portfolio", status_code=201)
|
||||||
|
def create_portfolio_item(req: PortfolioItemRequest):
|
||||||
|
"""포트폴리오 종목 추가"""
|
||||||
|
item_id = add_portfolio_item(req.broker, req.ticker, req.name, req.quantity, req.avg_price)
|
||||||
|
return {"id": item_id, "ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Broker Cash API ---
|
||||||
|
# /{item_id} 라우트보다 반드시 먼저 정의해야 /cash가 item_id로 매칭되지 않음
|
||||||
|
|
||||||
|
class BrokerCashRequest(BaseModel):
|
||||||
|
broker: str
|
||||||
|
cash: int
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/portfolio/cash")
|
||||||
|
def list_broker_cash():
|
||||||
|
"""증권사별 예수금 전체 조회"""
|
||||||
|
return get_all_broker_cash()
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/portfolio/cash")
|
||||||
|
def set_broker_cash(req: BrokerCashRequest):
|
||||||
|
"""증권사 예수금 등록 또는 수정 (upsert)"""
|
||||||
|
upsert_broker_cash(req.broker, req.cash)
|
||||||
|
return {"ok": True, "broker": req.broker, "cash": req.cash}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/portfolio/cash/{broker}")
|
||||||
|
def remove_broker_cash(broker: str):
|
||||||
|
"""증권사 예수금 삭제"""
|
||||||
|
if not delete_broker_cash(broker):
|
||||||
|
return JSONResponse(status_code=404, content={"error": "Broker not found"})
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/portfolio/{item_id}")
|
||||||
|
def update_portfolio(item_id: int, req: PortfolioUpdateRequest):
|
||||||
|
"""포트폴리오 종목 수정"""
|
||||||
|
if get_portfolio_item(item_id) is None:
|
||||||
|
return JSONResponse(status_code=404, content={"error": "Item not found"})
|
||||||
|
update_portfolio_item(item_id, **req.dict())
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/portfolio/{item_id}")
|
||||||
|
def delete_portfolio(item_id: int):
|
||||||
|
"""포트폴리오 종목 삭제"""
|
||||||
|
if not delete_portfolio_item(item_id):
|
||||||
|
return JSONResponse(status_code=404, content={"error": "Item not found"})
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Asset Snapshot API ---
|
||||||
|
|
||||||
|
@app.post("/api/portfolio/snapshot")
|
||||||
|
def create_snapshot():
|
||||||
|
"""총 자산 스냅샷 수동 저장 (오늘 날짜 기준)"""
|
||||||
|
today = date_type.today()
|
||||||
|
today_str = today.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
items = get_all_portfolio()
|
||||||
|
cash_rows = get_all_broker_cash()
|
||||||
|
total_cash = sum(r["cash"] for r in cash_rows)
|
||||||
|
|
||||||
|
if items:
|
||||||
|
tickers = list({item["ticker"] for item in items})
|
||||||
|
prices = get_current_prices(tickers)
|
||||||
|
total_eval = sum(
|
||||||
|
prices.get(item["ticker"], item["avg_price"]) * item["quantity"]
|
||||||
|
for item in items
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
total_eval = 0
|
||||||
|
|
||||||
|
total_assets = total_eval + total_cash
|
||||||
|
upsert_asset_snapshot(today_str, total_eval, total_cash, total_assets)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"snapshot": {
|
||||||
|
"date": today_str,
|
||||||
|
"total_eval": total_eval,
|
||||||
|
"total_cash": total_cash,
|
||||||
|
"total_assets": total_assets,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/portfolio/snapshot/history")
|
||||||
|
def get_snapshot_history(days: int = Query(30, ge=0)):
|
||||||
|
"""총 자산 스냅샷 이력 조회 (days=0: 전체, days=N: 최근 N일)"""
|
||||||
|
snapshots = get_asset_snapshots(days)
|
||||||
|
return {"snapshots": snapshots}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Sell History API ---
|
||||||
|
|
||||||
|
class SellHistoryRequest(BaseModel):
|
||||||
|
broker: str
|
||||||
|
ticker: str
|
||||||
|
name: str
|
||||||
|
quantity: int
|
||||||
|
avg_price: float
|
||||||
|
sell_price: float
|
||||||
|
buy_amount: float
|
||||||
|
sell_amount: float
|
||||||
|
realized_profit: float
|
||||||
|
realized_rate: float
|
||||||
|
sold_at: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/portfolio/sell-history")
|
||||||
|
def list_sell_history(broker: Optional[str] = None, days: Optional[int] = None):
|
||||||
|
"""매도 내역 조회 (broker, days 필터 선택)"""
|
||||||
|
records = get_sell_history(broker=broker, days=days)
|
||||||
|
return {"records": records}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/portfolio/sell-history")
|
||||||
|
def create_sell_history(req: SellHistoryRequest):
|
||||||
|
"""매도 기록 저장"""
|
||||||
|
record = add_sell_history(req.model_dump())
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/portfolio/sell-history/{record_id}")
|
||||||
|
def modify_sell_history(record_id: int, req: SellHistoryRequest):
|
||||||
|
"""매도 기록 수정"""
|
||||||
|
record = update_sell_history(record_id, req.model_dump())
|
||||||
|
if record is None:
|
||||||
|
return JSONResponse(status_code=404, content={"error": "not found"})
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/portfolio/sell-history/{record_id}")
|
||||||
|
def remove_sell_history(record_id: int):
|
||||||
|
"""매도 기록 삭제"""
|
||||||
|
if not delete_sell_history(record_id):
|
||||||
|
return JSONResponse(status_code=404, content={"error": "not found"})
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
68
stock-lab/app/price_fetcher.py
Normal file
68
stock-lab/app/price_fetcher.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import time
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
_cache: dict[str, tuple[Optional[int], float]] = {} # ticker -> (price, timestamp)
|
||||||
|
_CACHE_TTL = 180 # 3분
|
||||||
|
|
||||||
|
_HEADERS = {
|
||||||
|
"User-Agent": (
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/90.0.4430.93 Safari/537.36"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_from_mobile_api(ticker: str) -> Optional[int]:
|
||||||
|
"""네이버 모바일 주식 API로 현재가 조회"""
|
||||||
|
url = f"https://m.stock.naver.com/api/stock/{ticker}/basic"
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, headers=_HEADERS, timeout=5)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
price_str = data.get("closePrice") or data.get("stockEndPrice") or ""
|
||||||
|
price_str = str(price_str).replace(",", "").strip()
|
||||||
|
return int(price_str) if price_str.isdigit() else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_from_html_fallback(ticker: str) -> Optional[int]:
|
||||||
|
"""네이버 금융 HTML 폴백 (.no_today .blind 파싱)"""
|
||||||
|
url = f"https://finance.naver.com/item/main.naver?code={ticker}"
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, headers=_HEADERS, timeout=5)
|
||||||
|
resp.raise_for_status()
|
||||||
|
soup = BeautifulSoup(resp.content, "html.parser", from_encoding="cp949")
|
||||||
|
tag = soup.select_one(".no_today .blind")
|
||||||
|
if tag:
|
||||||
|
price_str = tag.get_text(strip=True).replace(",", "")
|
||||||
|
return int(price_str) if price_str.isdigit() else None
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_price(ticker: str) -> Optional[int]:
|
||||||
|
"""단건 현재가 조회 (3분 캐시)"""
|
||||||
|
now = time.time()
|
||||||
|
cached = _cache.get(ticker)
|
||||||
|
if cached and (now - cached[1]) < _CACHE_TTL:
|
||||||
|
return cached[0]
|
||||||
|
|
||||||
|
price = _fetch_from_mobile_api(ticker)
|
||||||
|
if price is None:
|
||||||
|
price = _fetch_from_html_fallback(ticker)
|
||||||
|
|
||||||
|
_cache[ticker] = (price, now)
|
||||||
|
return price
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_prices(tickers: list[str]) -> dict[str, Optional[int]]:
|
||||||
|
"""배치 현재가 조회 (캐시 미스 종목만 실제 호출)"""
|
||||||
|
result: dict[str, Optional[int]] = {}
|
||||||
|
for ticker in tickers:
|
||||||
|
result[ticker] = get_current_price(ticker)
|
||||||
|
return result
|
||||||
278
stock-lab/app/scraper.py
Normal file
278
stock-lab/app/scraper.py
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
import time
|
||||||
|
|
||||||
|
# 네이버 파이낸스 주요 뉴스
|
||||||
|
NAVER_FINANCE_NEWS_URL = "https://finance.naver.com/news/mainnews.naver"
|
||||||
|
# 해외증시 뉴스 (모바일 API 사용)
|
||||||
|
# NAVER_FINANCE_WORLD_NEWS_URL 사용 안함.
|
||||||
|
|
||||||
|
# 해외증시 메인 (지수용)
|
||||||
|
NAVER_FINANCE_WORLD_URL = "https://finance.naver.com/world/"
|
||||||
|
|
||||||
|
def fetch_market_news() -> List[Dict[str, str]]:
|
||||||
|
"""
|
||||||
|
네이버 금융 '주요 뉴스' 크롤링
|
||||||
|
반환: [{"title": "...", "link": "...", "summary": "...", "date": "..."}, ...]
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
headers = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36"
|
||||||
|
}
|
||||||
|
resp = requests.get(NAVER_FINANCE_NEWS_URL, headers=headers, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
soup = BeautifulSoup(resp.content, "html.parser", from_encoding="cp949")
|
||||||
|
|
||||||
|
# 주요 뉴스 리스트 추출
|
||||||
|
# 구조: div.mainNewsList > ul > li
|
||||||
|
articles = []
|
||||||
|
news_list = soup.select(".mainNewsList ul li")
|
||||||
|
|
||||||
|
for li in news_list:
|
||||||
|
# 썸네일 있을 수도 있고 없을 수도 있음
|
||||||
|
dl = li.select_one("dl")
|
||||||
|
if not dl:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 제목 (dd.articleSubject > a)
|
||||||
|
subject_tag = dl.select_one(".articleSubject a")
|
||||||
|
if not subject_tag:
|
||||||
|
continue
|
||||||
|
|
||||||
|
title = subject_tag.get_text(strip=True)
|
||||||
|
link = "https://finance.naver.com" + subject_tag["href"]
|
||||||
|
|
||||||
|
# 요약 (dd.articleSummary)
|
||||||
|
summary_tag = dl.select_one(".articleSummary")
|
||||||
|
summary = ""
|
||||||
|
press = ""
|
||||||
|
date = ""
|
||||||
|
|
||||||
|
if summary_tag:
|
||||||
|
# 불필요한 태그 제거
|
||||||
|
for child in summary_tag.select(".press, .wdate"):
|
||||||
|
if "press" in child.get("class", []):
|
||||||
|
press = child.get_text(strip=True)
|
||||||
|
if "wdate" in child.get("class", []):
|
||||||
|
date = child.get_text(strip=True)
|
||||||
|
child.extract()
|
||||||
|
summary = summary_tag.get_text(strip=True)
|
||||||
|
|
||||||
|
articles.append({
|
||||||
|
"title": title,
|
||||||
|
"link": link,
|
||||||
|
"summary": summary,
|
||||||
|
"press": press,
|
||||||
|
"date": date,
|
||||||
|
"crawled_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"category": "domestic"
|
||||||
|
})
|
||||||
|
|
||||||
|
return articles
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[StockLab] Scraping failed: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def fetch_overseas_news() -> List[Dict[str, str]]:
|
||||||
|
"""
|
||||||
|
네이버 금융 해외증시 뉴스 크롤링 (모바일 API 사용)
|
||||||
|
"""
|
||||||
|
api_url = "https://api.stock.naver.com/news/overseas/mainnews"
|
||||||
|
try:
|
||||||
|
headers = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)"
|
||||||
|
}
|
||||||
|
resp = requests.get(api_url, headers=headers, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
if isinstance(data, list):
|
||||||
|
items = data
|
||||||
|
else:
|
||||||
|
items = data.get("result", [])
|
||||||
|
|
||||||
|
articles = []
|
||||||
|
for item in items:
|
||||||
|
# API 키 매핑 (subject/title/tit, summary/subContent/sub_tit 등)
|
||||||
|
title = item.get("subject") or item.get("title") or item.get("tit") or ""
|
||||||
|
summary = item.get("summary") or item.get("subContent") or item.get("sub_tit") or ""
|
||||||
|
press = item.get("officeName") or item.get("office_name") or item.get("cp_name") or ""
|
||||||
|
|
||||||
|
# 날짜 포맷팅 (20260126123000 -> 2026-01-26 12:30:00)
|
||||||
|
raw_dt = str(item.get("dt", ""))
|
||||||
|
if len(raw_dt) == 14:
|
||||||
|
date = f"{raw_dt[:4]}-{raw_dt[4:6]}-{raw_dt[6:8]} {raw_dt[8:10]}:{raw_dt[10:12]}:{raw_dt[12:]}"
|
||||||
|
else:
|
||||||
|
date = raw_dt
|
||||||
|
|
||||||
|
# 링크 생성
|
||||||
|
aid = item.get("articleId")
|
||||||
|
oid = item.get("officeId")
|
||||||
|
link = f"https://m.stock.naver.com/worldstock/news/read/{oid}/{aid}"
|
||||||
|
|
||||||
|
articles.append({
|
||||||
|
"title": title,
|
||||||
|
"link": link,
|
||||||
|
"summary": summary,
|
||||||
|
"press": press,
|
||||||
|
"date": date,
|
||||||
|
"crawled_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"category": "overseas"
|
||||||
|
})
|
||||||
|
|
||||||
|
return articles
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[StockLab] Overseas news failed: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def fetch_major_indices() -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
KOSPI, KOSDAQ, KOSPI200 등 주요 지표 (네이버 금융 홈)
|
||||||
|
"""
|
||||||
|
url = "https://finance.naver.com/"
|
||||||
|
headers = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)"
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
targets = [
|
||||||
|
{"key": "KOSPI", "selector": ".kospi_area", "url": "https://finance.naver.com/"},
|
||||||
|
{"key": "KOSDAQ", "selector": ".kosdaq_area", "url": "https://finance.naver.com/"},
|
||||||
|
{"key": "KOSPI200", "selector": ".kospi200_area", "url": "https://finance.naver.com/"},
|
||||||
|
]
|
||||||
|
|
||||||
|
# 해외 지수 (네이버 금융 해외 메인) - 여기서는 별도 URL 호출 필요하거나, 메인에 있는지 확인
|
||||||
|
# 네이버 메인에는 해외지수가 안 나옴. https://finance.naver.com/world/ 에서 긁어야 함
|
||||||
|
# 그러나 한 번에 처리하기 위해 함수 내에서 추가 호출
|
||||||
|
|
||||||
|
indices = []
|
||||||
|
|
||||||
|
# --- 국내 ---
|
||||||
|
resp_kr = requests.get("https://finance.naver.com/", headers=headers, timeout=5)
|
||||||
|
soup_kr = BeautifulSoup(resp_kr.content, "html.parser", from_encoding="cp949")
|
||||||
|
|
||||||
|
for t in targets:
|
||||||
|
area = soup_kr.select_one(t["selector"])
|
||||||
|
if not area: continue
|
||||||
|
|
||||||
|
# (기존 파싱 로직)
|
||||||
|
num_tag = area.select_one(".num")
|
||||||
|
value = num_tag.get_text(strip=True) if num_tag else ""
|
||||||
|
|
||||||
|
change_val_tag = area.select_one(".num2")
|
||||||
|
change_pct_tag = area.select_one(".num3")
|
||||||
|
change_val = change_val_tag.get_text(strip=True) if change_val_tag else ""
|
||||||
|
change_pct = change_pct_tag.get_text(strip=True) if change_pct_tag else ""
|
||||||
|
|
||||||
|
direction = ""
|
||||||
|
if area.select_one(".bu_p"): direction = "red"
|
||||||
|
elif area.select_one(".bu_m"): direction = "blue"
|
||||||
|
|
||||||
|
indices.append({
|
||||||
|
"name": t["key"],
|
||||||
|
"value": value,
|
||||||
|
"change_value": change_val,
|
||||||
|
"change_percent": change_pct,
|
||||||
|
"direction": direction,
|
||||||
|
"type": "domestic"
|
||||||
|
})
|
||||||
|
|
||||||
|
# --- 해외 (DJI, NAS, SPI) ---
|
||||||
|
try:
|
||||||
|
resp_world = requests.get(NAVER_FINANCE_WORLD_URL, headers=headers, timeout=5)
|
||||||
|
soup_world = BeautifulSoup(resp_world.content, "html.parser", from_encoding="cp949")
|
||||||
|
|
||||||
|
world_targets = [
|
||||||
|
{"key": "DJI", "name": "다우산업", "sym": "DJI@DJI"},
|
||||||
|
{"key": "NAS", "name": "나스닥", "sym": "NAS@IXIC"},
|
||||||
|
{"key": "SPI", "name": "S&P500", "sym": "SPI@SPX"},
|
||||||
|
]
|
||||||
|
|
||||||
|
for wt in world_targets:
|
||||||
|
# 심볼 링크로 찾기 (가장 정확함)
|
||||||
|
a_tag = soup_world.select_one(f"a[href*='symbol={wt['sym']}']")
|
||||||
|
if not a_tag:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 상위 dl 태그 찾기
|
||||||
|
dl = a_tag.find_parent("dl")
|
||||||
|
if not dl:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 값 파싱 (dd.point_status)
|
||||||
|
status_dd = dl.select_one("dd.point_status")
|
||||||
|
if not status_dd:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 1. 현재가 (strong)
|
||||||
|
val_tag = status_dd.select_one("strong")
|
||||||
|
value = val_tag.get_text(strip=True) if val_tag else ""
|
||||||
|
|
||||||
|
# 2. 등락폭 (em)
|
||||||
|
change_val_tag = status_dd.select_one("em")
|
||||||
|
change_val = change_val_tag.get_text(strip=True) if change_val_tag else ""
|
||||||
|
|
||||||
|
# 3. 등락률 (span)
|
||||||
|
change_pct_tag = status_dd.select_one("span")
|
||||||
|
change_pct = change_pct_tag.get_text(strip=True) if change_pct_tag else ""
|
||||||
|
|
||||||
|
# 4. 방향 (dl 클래스 활용)
|
||||||
|
direction = ""
|
||||||
|
dl_classes = dl.get("class", [])
|
||||||
|
if "point_up" in dl_classes:
|
||||||
|
direction = "red"
|
||||||
|
elif "point_dn" in dl_classes:
|
||||||
|
direction = "blue"
|
||||||
|
|
||||||
|
indices.append({
|
||||||
|
"name": wt["name"], # 한글 이름 사용
|
||||||
|
"value": value,
|
||||||
|
"change_value": change_val,
|
||||||
|
"change_percent": change_pct,
|
||||||
|
"direction": direction,
|
||||||
|
"type": "overseas"
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[StockLab] World indices failed: {e}")
|
||||||
|
|
||||||
|
# --- 환율 (USD/KRW) ---
|
||||||
|
try:
|
||||||
|
resp_ex = requests.get("https://finance.naver.com/marketindex/", headers=headers, timeout=5)
|
||||||
|
soup_ex = BeautifulSoup(resp_ex.content, "html.parser", from_encoding="cp949")
|
||||||
|
|
||||||
|
usd_item = soup_ex.select_one("#exchangeList li.on > a.head.usd")
|
||||||
|
if usd_item:
|
||||||
|
value = usd_item.select_one(".value").get_text(strip=True)
|
||||||
|
change_val = usd_item.select_one(".change").get_text(strip=True)
|
||||||
|
|
||||||
|
# 방향 (blind 텍스트: 상승, 하락)
|
||||||
|
direction = ""
|
||||||
|
blind_txt = usd_item.select_one(".blind").get_text(strip=True)
|
||||||
|
if "상승" in blind_txt: direction = "red"
|
||||||
|
elif "하락" in blind_txt: direction = "blue"
|
||||||
|
|
||||||
|
# 등락률은 리스트에는 안나오고 상세에 나오지만, 여기선 생략하거나 계산 가능.
|
||||||
|
# 일단 UI 통일성을 위해 빈값 혹은 계산된 값 등 처리.
|
||||||
|
# 네이버 메인 환율 영역엔 등락률이 텍스트로 바로 안보임 (title 속성 등에 있을수 있음).
|
||||||
|
# 여기서는 간단히 값만 처리.
|
||||||
|
|
||||||
|
indices.append({
|
||||||
|
"name": "원달러 환율",
|
||||||
|
"value": value,
|
||||||
|
"change_value": change_val,
|
||||||
|
"change_percent": "", # 메인 리스트에서 바로 안보임
|
||||||
|
"direction": direction,
|
||||||
|
"type": "exchange"
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[StockLab] Exchange rate failed: {e}")
|
||||||
|
|
||||||
|
return {"indices": indices, "crawled_at": time.strftime("%Y-%m-%d %H:%M:%S")}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[StockLab] Indices scraping failed: {e}")
|
||||||
|
return {"indices": [], "error": str(e)}
|
||||||
7
stock-lab/requirements.txt
Normal file
7
stock-lab/requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# 주식 서비스용 라이브러리
|
||||||
|
requests==2.32.3
|
||||||
|
beautifulsoup4==4.12.3
|
||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.30.6
|
||||||
|
apscheduler==3.10.4
|
||||||
|
python-dotenv==1.0.1
|
||||||
@@ -20,3 +20,7 @@ EXPOSE 8000
|
|||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|
||||||
|
ARG APP_VERSION=dev
|
||||||
|
ENV APP_VERSION=$APP_VERSION
|
||||||
|
LABEL org.opencontainers.image.version=$APP_VERSION
|
||||||
|
|||||||
@@ -149,16 +149,19 @@ def scan_album(album: str) -> List[Dict[str, Any]]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
for p in album_dir.iterdir():
|
# os.scandir이 iterdir보다 빠름 (파일 속성 한 번에 가져옴)
|
||||||
if p.is_file() and p.suffix.lower() in IMAGE_EXT:
|
with os.scandir(album_dir) as entries:
|
||||||
# ✅ 썸네일 생성 보장
|
for entry in entries:
|
||||||
ensure_thumb(p, album)
|
if entry.is_file() and Path(entry.name).suffix.lower() in IMAGE_EXT:
|
||||||
|
# ⚡ 성능 핵심: 여기서 썸네일 생성(ensure_thumb) 절대 하지 않음!
|
||||||
|
# 파일 존재 여부만 확인하고 바로 리턴
|
||||||
items.append({
|
items.append({
|
||||||
"album": album,
|
"album": album,
|
||||||
"file": p.name,
|
"file": entry.name,
|
||||||
"url": f"{MEDIA_BASE}/{album}/{p.name}",
|
"url": f"{MEDIA_BASE}/{album}/{entry.name}",
|
||||||
"thumb": f"{MEDIA_BASE}/.thumb/{album}/{p.name}",
|
"thumb": f"{MEDIA_BASE}/.thumb/{album}/{entry.name}",
|
||||||
|
# 정렬을 위해 수정시간/이름 필요하면 여기서 저장
|
||||||
|
"mtime": entry.stat().st_mtime
|
||||||
})
|
})
|
||||||
return items
|
return items
|
||||||
|
|
||||||
@@ -170,19 +173,28 @@ def regions():
|
|||||||
_meta_changed_invalidate_cache()
|
_meta_changed_invalidate_cache()
|
||||||
return load_regions_geojson()
|
return load_regions_geojson()
|
||||||
|
|
||||||
|
@app.post("/api/travel/reload")
|
||||||
|
def reload_cache():
|
||||||
|
"""강제로 캐시를 비워서 새로고침"""
|
||||||
|
CACHE.clear()
|
||||||
|
META_MTIME_CACHE.clear()
|
||||||
|
return {"ok": True, "message": "Cache cleared"}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/travel/photos")
|
@app.get("/api/travel/photos")
|
||||||
def photos(
|
def photos(
|
||||||
region: str = Query(...),
|
region: str = Query(...),
|
||||||
limit: int = Query(500, le=5000),
|
page: int = Query(1, ge=1),
|
||||||
|
size: int = Query(20, ge=1, le=100),
|
||||||
):
|
):
|
||||||
_meta_changed_invalidate_cache()
|
_meta_changed_invalidate_cache()
|
||||||
|
|
||||||
|
# 1. 캐시 키에 페이지 정보 포함하지 않음 (전체 리스트 캐싱 후 슬라이싱 전략)
|
||||||
|
# 왜냐하면 파일 스캔 비용이 크지, 메모리 슬라이싱 비용은 작기 때문.
|
||||||
now = time.time()
|
now = time.time()
|
||||||
cached = CACHE.get(region)
|
cached = CACHE.get(region)
|
||||||
if cached and now - cached["ts"] < CACHE_TTL:
|
|
||||||
return cached["data"]
|
|
||||||
|
|
||||||
|
if not cached or now - cached["ts"] >= CACHE_TTL:
|
||||||
region_map = load_region_map()
|
region_map = load_region_map()
|
||||||
albums = _get_albums_for_region(region, region_map)
|
albums = _get_albums_for_region(region, region_map)
|
||||||
|
|
||||||
@@ -194,19 +206,35 @@ def photos(
|
|||||||
matched.append({"album": album, "count": len(items)})
|
matched.append({"album": album, "count": len(items)})
|
||||||
all_items.extend(items)
|
all_items.extend(items)
|
||||||
|
|
||||||
|
# 정렬: 앨범명 > 파일명 (또는 찍은 날짜)
|
||||||
all_items.sort(key=lambda x: (x["album"], x["file"]))
|
all_items.sort(key=lambda x: (x["album"], x["file"]))
|
||||||
|
|
||||||
data = {
|
cached_data = {
|
||||||
"region": region,
|
"region": region,
|
||||||
"matched_albums": matched,
|
"matched_albums": matched,
|
||||||
"items": all_items[:limit],
|
"items": all_items,
|
||||||
"total": len(all_items),
|
"total": len(all_items),
|
||||||
"cached_at": int(now),
|
|
||||||
"cache_ttl": CACHE_TTL,
|
|
||||||
}
|
}
|
||||||
|
CACHE[region] = {"ts": now, "data": cached_data}
|
||||||
|
else:
|
||||||
|
cached_data = cached["data"]
|
||||||
|
|
||||||
CACHE[region] = {"ts": now, "data": data}
|
# 2. 페이지네이션 슬라이싱
|
||||||
return data
|
all_items = cached_data["items"]
|
||||||
|
total = len(all_items)
|
||||||
|
start = (page - 1) * size
|
||||||
|
end = start + size
|
||||||
|
|
||||||
|
paged_items = all_items[start:end]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"region": region,
|
||||||
|
"page": page,
|
||||||
|
"size": size,
|
||||||
|
"total": total,
|
||||||
|
"has_next": end < total,
|
||||||
|
"items": paged_items,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/media/travel/.thumb/{album}/{filename}")
|
@app.get("/media/travel/.thumb/{album}/{filename}")
|
||||||
@@ -218,3 +246,8 @@ def get_thumb(album: str, filename: str):
|
|||||||
# src로부터 thumb 생성/확인 (원본 확장자 유지)
|
# src로부터 thumb 생성/확인 (원본 확장자 유지)
|
||||||
p = ensure_thumb(src, album)
|
p = ensure_thumb(src, album)
|
||||||
return FileResponse(str(p))
|
return FileResponse(str(p))
|
||||||
|
|
||||||
|
@app.get("/api/version")
|
||||||
|
def version():
|
||||||
|
import os
|
||||||
|
return {"version": os.getenv("APP_VERSION", "dev")}
|
||||||
|
|||||||
Reference in New Issue
Block a user