Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||
|
||||
COMPOSE_PROJECT_NAME=webpage
|
||||
|
||||
# backend lotto collector sources
|
||||
LOTTO_ALL_URL=https://smok95.github.io/lotto/results/all.json
|
||||
LOTTO_LATEST_URL=https://smok95.github.io/lotto/results/latest.json
|
||||
|
||||
# travel-proxy
|
||||
TRAVEL_ROOT=/data/travel
|
||||
TRAVEL_THUMB_ROOT=/data/thumbs
|
||||
TRAVEL_MEDIA_BASE=/media/travel
|
||||
TRAVEL_CACHE_TTL=300
|
||||
# [SECURITY]
|
||||
WEBHOOK_SECRET=change_this_secret_in_prod
|
||||
|
||||
# CORS (travel-proxy)
|
||||
CORS_ALLOW_ORIGINS=*
|
||||
# [PATHS]
|
||||
# 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.0.5:8000
|
||||
262
README.md
262
README.md
@@ -0,0 +1,262 @@
|
||||
# 🏠 Home NAS Web Platform (webpage)
|
||||
|
||||
Synology NAS 기반의 개인 웹 플랫폼으로, 로또 분석/추천 서비스와 여행 사진 지도 서비스를 포함합니다.
|
||||
Frontend, Backend, Travel Proxy, Auto Deployer를 Docker Compose로 통합하여 운영합니다.
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ NAS 환경 정보
|
||||
|
||||
| 항목 | 값 |
|
||||
| --------------- | ------------------------------------- |
|
||||
| **NAS** | Synology NAS |
|
||||
| **OS** | Synology DSM |
|
||||
| **CPU** | Intel Celeron J4025 (2 Core, 2.0GHz) |
|
||||
| **메모리** | 18 GB |
|
||||
| **Docker** | Synology Container Manager |
|
||||
| **Reverse Proxy** | Nginx (컨테이너) |
|
||||
| **Git Server** | Gitea (self-hosted) |
|
||||
|
||||
---
|
||||
|
||||
## 📁 디렉토리 구조
|
||||
|
||||
```
|
||||
/volume1
|
||||
├── docker/
|
||||
│ └── webpage/ # 🚀 운영 런타임 (Docker Compose 기준점)
|
||||
│ ├── backend/ # lotto-backend
|
||||
│ ├── stock-lab/ # 🟪 stock-lab (Stock + AI)
|
||||
│ ├── travel-proxy/ # travel API + thumbnail generator
|
||||
│ ├── deployer/ # webhook 기반 자동 배포 컨테이너
|
||||
│ ├── frontend/ # 정적 파일 (Vite build 결과)
|
||||
│ ├── nginx/
|
||||
│ │ └── default.conf
|
||||
│ ├── scripts/
|
||||
│ │ └── deploy.sh # webhook이 호출하는 실행기
|
||||
│ ├── docker-compose.yml
|
||||
│ └── data/
|
||||
│ └── lotto.db
|
||||
│
|
||||
├── workspace/
|
||||
│ └── web-page-backend/ # 🧠 Git 레포 (backend + infra)
|
||||
│ ├── backend/
|
||||
│ ├── travel-proxy/
|
||||
│ ├── deployer/
|
||||
│ ├── nginx/
|
||||
│ ├── scripts/
|
||||
│ │ └── deploy-nas.sh # 실제 운영 반영 로직
|
||||
│ ├── docker-compose.yml
|
||||
│ ├── .env.example
|
||||
│ └── README.md
|
||||
│
|
||||
└── web/
|
||||
└── images/
|
||||
└── webPage/
|
||||
└── travel/ # 📷 원본 여행 사진 (RO)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧩 서비스 구성 개요
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
User[User Browser] -->|HTTP| Nginx
|
||||
|
||||
subgraph NAS [Synology NAS]
|
||||
Nginx -->|/api/lotto| Lotto[Lotto Backend]
|
||||
Nginx -->|/api/travel| Travel[Travel Proxy]
|
||||
Nginx -->|/api/stock| Stock[Stock Lab]
|
||||
Stock -->|Trading/Balance| KIS[KIS API (Korea Inv.)]
|
||||
Stock -->|Market News| News[News Sites]
|
||||
end
|
||||
|
||||
subgraph Windows [High-Performance PC]
|
||||
Stock -->|Analyze Request| WinServer[Windows AI Server]
|
||||
WinServer -->|LLM Inference| Ollama[Ollama (Llama 3.1)]
|
||||
WinServer -->|GPU| GPU[NVIDIA 3070 Ti]
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 개발 환경 설정 (Local Development)
|
||||
|
||||
이 프로젝트는 **Windows/Mac 로컬 환경**과 **Synology NAS 운영 환경**을 모두 지원하도록 구성되었습니다.
|
||||
|
||||
### 1. 환경 변수 설정
|
||||
`docker-compose.yml`은 환경 변수에 의존합니다.
|
||||
1. `.env.example` 파일을 복사하여 `.env` 파일을 생성하세요.
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
2. `.env` 파일의 경로(`RUNTIME_PATH`, `PHOTO_PATH` 등)를 로컬 환경에 맞게 수정하세요.
|
||||
- 기본값은 현재 디렉토리(`.`) 기준으로 설정되어 있어 바로 실행 가능합니다.
|
||||
|
||||
### 2. 로컬 실행
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
- Frontend: http://localhost:8080
|
||||
- Backend API: http://localhost:18000
|
||||
- Travel API: http://localhost:19000
|
||||
- Stock Lab API: http://localhost:18500
|
||||
|
||||
---
|
||||
|
||||
### 🟦 Frontend (lotto-frontend)
|
||||
|
||||
- **역할**: React + Vite 기반 SPA로, 로또 추천 및 여행 지도 UI를 제공합니다.
|
||||
- **특징**:
|
||||
- 정적 파일만 운영 서버에 배포합니다.
|
||||
- 장기 캐시(`assets/`)와 `index.html` 캐시 무효화 전략을 사용합니다.
|
||||
- Backend / Travel API는 Nginx에서 Reverse Proxy로 연결됩니다.
|
||||
- **배포 방식**:
|
||||
1. **로컬 개발**:
|
||||
- `.env` 파일 설정 후 `docker compose up`으로 전체 스택 실행 가능
|
||||
2. **운영 배포**:
|
||||
- Code를 Git에 Push
|
||||
- Webhook이 트리거되어 NAS가 자동 Pull & Deploy
|
||||
- (Frontend 빌드 산출물은 별도 업로드 혹은 CI 연동 필요)
|
||||
|
||||
---
|
||||
|
||||
### 🟩 Backend (lotto-backend)
|
||||
|
||||
- **역할**: 로또 데이터 수집, 분석, 추천 API를 제공하며 SQLite로 데이터를 관리합니다.
|
||||
- **주요 기능**:
|
||||
- 최신 및 특정 회차 조회
|
||||
- 추천 번호 생성 및 히스토리 관리 (중복 제거)
|
||||
- 즐겨찾기, 메모, 태그 관리
|
||||
- 배치 추천 기능
|
||||
- **기술 스택**: FastAPI, SQLite, APScheduler (정기 수집)
|
||||
- **주요 엔드포인트**:
|
||||
```http
|
||||
GET /api/lotto/latest
|
||||
GET /api/lotto/{drw_no}
|
||||
GET /api/lotto/recommend
|
||||
GET /api/lotto/recommend/batch
|
||||
GET /api/history
|
||||
PATCH /api/history/{id}
|
||||
DELETE /api/history/{id}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🟪 Stock Lab (stock-lab)
|
||||
|
||||
- **역할**: 주식 시장 분석 및 AI 기반 투자 조언 제공. NAS의 편의성과 Windows PC의 고성능을 결합한 하이브리드 아키텍처입니다.
|
||||
- **주요 기능**:
|
||||
- **시장 뉴스 스크랩**: 네이버 증권, 해외 주요 뉴스 사이트 크롤링
|
||||
- **자산 관리**: 한국투자증권(KIS) Open API 연동 (잔고 조회, 매수/매도)
|
||||
- **AI 분석**: 고성능 PC의 로컬 LLM(Llama 3.1)을 활용하여 뉴스+포트폴리오 종합 분석
|
||||
- **기술 스택**: Python, FastAPI, Ollama (Lava/Llama3), Docker
|
||||
- **연동 구조**:
|
||||
```
|
||||
[NAS Stock-Lab] <--(HTTP)--> [Windows AI Server] <--(Localhost)--> [Ollama GPU]
|
||||
```
|
||||
- **주요 엔드포인트**:
|
||||
```http
|
||||
GET /api/stock/analyze # AI 시장 분석 요청
|
||||
GET /api/stock/news # 최신 뉴스 데이터
|
||||
GET /api/trade/balance # 계좌 잔고 조회
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🟨 Travel Proxy (travel-proxy)
|
||||
|
||||
- **역할**: 여행 사진 API, 지역별 사진 매칭, 썸네일 자동 생성 및 캐시를 담당합니다.
|
||||
- **설계 포인트**:
|
||||
- 원본 사진은 읽기 전용(RO)으로 마운트합니다.
|
||||
- 썸네일은 쓰기/읽기(RW) 전용 캐시 디렉토리를 사용합니다.
|
||||
- 사진 메타데이터 변경 시 캐시가 자동으로 무효화됩니다.
|
||||
- **데이터 구조**:
|
||||
```
|
||||
/data/travel/ # 원본 사진 (RO)
|
||||
├── 24.09.jeju/
|
||||
├── 25.07.maldives/
|
||||
└── _meta/
|
||||
├── region_map.json
|
||||
└── regions.geojson
|
||||
|
||||
/data/thumbs/ # 썸네일 캐시 (RW)
|
||||
├── 24.09.jeju/
|
||||
└── 25.07.maldives/
|
||||
```
|
||||
- **주요 엔드포인트**:
|
||||
```http
|
||||
GET /api/travel/regions
|
||||
GET /api/travel/photos?region=jeju
|
||||
GET /media/travel/.thumb/{album}/{file}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🟥 Deployer (webpage-deployer)
|
||||
|
||||
- **역할**: Gitea Webhook을 수신하여 Git pull 및 Docker 재기동을 자동화합니다.
|
||||
- **흐름**: `Gitea Push` → `Webhook` → `deployer` → `/scripts/deploy.sh` → `docker compose up -d --build`
|
||||
- **보안**: HMAC SHA256 서명(`X-Gitea-Signature`)을 `WEBHOOK_SECRET` 환경변수로 검증합니다.
|
||||
- **특징**:
|
||||
- Docker socket을 마운트하여 사용합니다.
|
||||
- 롤백을 위해 `.releases/` 디렉토리에 자동 백업을 수행합니다.
|
||||
|
||||
---
|
||||
|
||||
## 🔁 배포 플로우 요약
|
||||
|
||||
- **Backend / Travel / Infra 변경**: `git push`를 통해 Gitea로 푸시하면 Webhook이 트리거되어 자동으로 배포됩니다.
|
||||
- **Frontend 변경**: 로컬에서 빌드 후, 생성된 정적 파일만 NAS로 업로드합니다.
|
||||
|
||||
---
|
||||
|
||||
## 🧪 운영 체크 포인트
|
||||
|
||||
- `/health`: Backend 서비스 상태 확인
|
||||
- `/api/travel/photos`: 응답 속도 확인
|
||||
- `/media/travel/.thumb`: 썸네일 생성 여부 확인
|
||||
- `deployer` 컨테이너 로그 확인
|
||||
|
||||
---
|
||||
|
||||
## 📝 TODO
|
||||
|
||||
### 🔥 로또 서비스 고도화
|
||||
|
||||
- [ ] 추천 결과 통계 시각화 (분포, 합계, 홀짝)
|
||||
- [ ] 추천 히스토리 필터 및 검색 기능
|
||||
- [ ] 추천 결과 즐겨찾기 UI
|
||||
- [ ] 회차 대비 추천 성능 분석
|
||||
|
||||
### 🗺️ 여행 지도 UI
|
||||
|
||||
- [ ] 지도 영역 클릭 시 해당 지역 사진 로딩
|
||||
- [ ] 사진 지연 로딩 (Lazy Load)
|
||||
- [ ] 앨범 및 연도별 필터 기능
|
||||
- [ ] 모바일 UX 개선
|
||||
|
||||
### ⚙️ 운영/인프라
|
||||
|
||||
- [ ] Docker 이미지 버전 태깅 자동화
|
||||
- [ ] 배포 실패 시 자동 롤백 기능
|
||||
- [ ] Health check 기반 배포 성공 판단 로직
|
||||
- [ ] 로그 수집 및 관리 체계 개선
|
||||
|
||||
---
|
||||
|
||||
## ✨ 철학
|
||||
|
||||
> “NAS는 서버가 아니라 집이다.”
|
||||
>
|
||||
> 그래서 안전하고, 단순하며, 복구 가능한 구조를 최우선으로 합니다.
|
||||
|
||||
---
|
||||
|
||||
## Makefile 설정 사용 예시
|
||||
|
||||
- **배포**: `make deploy`
|
||||
- **백엔드 로그**: `make logs S=backend`
|
||||
- **전체 로그**: `make logs`
|
||||
- **상태**: `make status`
|
||||
|
||||
@@ -16,3 +16,7 @@ ENV PYTHONUNBUFFERED=1
|
||||
EXPOSE 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
|
||||
|
||||
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
|
||||
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:
|
||||
# smok95 all.json / latest.json 구조
|
||||
@@ -27,20 +27,13 @@ def sync_all_from_json(all_url: str) -> Dict[str, Any]:
|
||||
r.raise_for_status()
|
||||
data = r.json() # list[dict]
|
||||
|
||||
inserted = 0
|
||||
skipped = 0
|
||||
# 정규화
|
||||
rows = [_normalize_item(item) for item in data]
|
||||
|
||||
for item in data:
|
||||
row = _normalize_item(item)
|
||||
# Bulk Insert (성능 향상)
|
||||
upsert_many_draws(rows)
|
||||
|
||||
if get_draw(row["drw_no"]):
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
upsert_draw(row)
|
||||
inserted += 1
|
||||
|
||||
return {"mode": "all_json", "url": all_url, "inserted": inserted, "skipped": skipped, "total": len(data)}
|
||||
return {"mode": "all_json", "url": all_url, "total": len(rows)}
|
||||
|
||||
def sync_latest(latest_url: str) -> Dict[str, Any]:
|
||||
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"]}
|
||||
|
||||
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}
|
||||
|
||||
|
||||
@@ -63,6 +63,17 @@ def init_db() -> None:
|
||||
_ensure_column(conn, "recommendations", "tags",
|
||||
"ALTER TABLE recommendations ADD COLUMN tags TEXT NOT NULL DEFAULT '[]';")
|
||||
|
||||
# ✅ 결과 채점용 컬럼 추가
|
||||
_ensure_column(conn, "recommendations", "rank",
|
||||
"ALTER TABLE recommendations ADD COLUMN rank INTEGER;")
|
||||
_ensure_column(conn, "recommendations", "correct_count",
|
||||
"ALTER TABLE recommendations ADD COLUMN correct_count INTEGER DEFAULT 0;")
|
||||
_ensure_column(conn, "recommendations", "has_bonus",
|
||||
"ALTER TABLE recommendations ADD COLUMN has_bonus INTEGER DEFAULT 0;")
|
||||
_ensure_column(conn, "recommendations", "checked",
|
||||
"ALTER TABLE recommendations ADD COLUMN checked INTEGER DEFAULT 0;")
|
||||
|
||||
|
||||
# ✅ UNIQUE 인덱스(중복 저장 방지)
|
||||
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS uq_reco_dedup ON recommendations(dedup_hash);")
|
||||
|
||||
@@ -88,6 +99,30 @@ def upsert_draw(row: Dict[str, Any]) -> None:
|
||||
),
|
||||
)
|
||||
|
||||
def upsert_many_draws(rows: List[Dict[str, Any]]) -> None:
|
||||
data = [
|
||||
(
|
||||
int(r["drw_no"]), str(r["drw_date"]),
|
||||
int(r["n1"]), int(r["n2"]), int(r["n3"]),
|
||||
int(r["n4"]), int(r["n5"]), int(r["n6"]),
|
||||
int(r["bonus"])
|
||||
) for r in rows
|
||||
]
|
||||
with _conn() as conn:
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO draws (drw_no, drw_date, n1, n2, n3, n4, n5, n6, bonus)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(drw_no) DO UPDATE SET
|
||||
drw_date=excluded.drw_date,
|
||||
n1=excluded.n1, n2=excluded.n2, n3=excluded.n3,
|
||||
n4=excluded.n4, n5=excluded.n5, n6=excluded.n6,
|
||||
bonus=excluded.bonus,
|
||||
updated_at=datetime('now')
|
||||
""",
|
||||
data
|
||||
)
|
||||
|
||||
def get_latest_draw() -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
r = conn.execute("SELECT * FROM draws ORDER BY drw_no DESC LIMIT 1").fetchone()
|
||||
@@ -237,3 +272,15 @@ def delete_recommendation(rec_id: int) -> bool:
|
||||
cur = conn.execute("DELETE FROM recommendations WHERE id = ?", (rec_id,))
|
||||
return cur.rowcount > 0
|
||||
|
||||
def update_recommendation_result(rec_id: int, rank: int, correct_count: int, has_bonus: bool) -> bool:
|
||||
with _conn() as conn:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
UPDATE recommendations
|
||||
SET rank = ?, correct_count = ?, has_bonus = ?, checked = 1
|
||||
WHERE id = ?
|
||||
""",
|
||||
(rank, correct_count, 1 if has_bonus else 0, rec_id)
|
||||
)
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
100
backend/app/generator.py
Normal file
100
backend/app/generator.py
Normal file
@@ -0,0 +1,100 @@
|
||||
import random
|
||||
import json
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from .db import _conn, save_recommendation_dedup, get_latest_draw, get_all_draw_numbers
|
||||
from .recommender import recommend_numbers
|
||||
from .utils import calc_metrics, calc_recent_overlap
|
||||
|
||||
# 순환 참조 방지를 위해 main.py의 calc_metrics 등을 utils.py가 아닌 여기서 재정의하거나
|
||||
# main.py에서 generator를 import할 때 함수 내부에서 하도록 처리.
|
||||
# 여기서는 코드가 중복되더라도 안전하게 독립적으로 구현하거나, db/collector만 import.
|
||||
|
||||
def _get_top_performing_params(limit: int = 20) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
최근 1~5등에 당첨된 추천들의 파라미터 조회
|
||||
"""
|
||||
sql = """
|
||||
SELECT params
|
||||
FROM recommendations
|
||||
WHERE rank > 0 AND rank <= 5
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(sql, (limit,)).fetchall()
|
||||
|
||||
return [json.loads(r["params"]) for r in rows]
|
||||
|
||||
def _perturb_param(val: float, delta: float, min_val: float, max_val: float, is_int: bool = False) -> float:
|
||||
change = random.uniform(-delta, delta)
|
||||
new_val = val + change
|
||||
new_val = max(min_val, min(new_val, max_val))
|
||||
return int(round(new_val)) if is_int else round(new_val, 2)
|
||||
|
||||
def generate_smart_recommendations(count: int = 10) -> int:
|
||||
"""
|
||||
지능형 자동 생성: 과거 성적 우수 파라미터 기반으로 생성
|
||||
"""
|
||||
draws = get_all_draw_numbers()
|
||||
if not draws:
|
||||
return 0
|
||||
|
||||
latest = get_latest_draw()
|
||||
based_on = latest["drw_no"] if latest else None
|
||||
|
||||
# 1. 성공 사례 조회 (Feedback)
|
||||
top_params = _get_top_performing_params()
|
||||
|
||||
generated_count = 0
|
||||
|
||||
for _ in range(count):
|
||||
# 전략 선택: 이력이 있으면 70% 확률로 모방(Exploitation), 30%는 랜덤(Exploration)
|
||||
use_history = (len(top_params) > 0) and (random.random() < 0.7)
|
||||
|
||||
if use_history:
|
||||
# 과거 우수 파라미터 중 하나 선택하여 변형
|
||||
base = random.choice(top_params)
|
||||
|
||||
# 파라미터 변형 (유전 알고리즘과 유사)
|
||||
p_window = _perturb_param(base.get("recent_window", 200), 50, 10, 500, True)
|
||||
p_weight = _perturb_param(base.get("recent_weight", 2.0), 1.0, 0.1, 10.0, False)
|
||||
p_avoid = _perturb_param(base.get("avoid_recent_k", 5), 2, 0, 20, True)
|
||||
|
||||
# Constraints 로직은 복잡하니 일단 랜덤성 부여하거나 유지
|
||||
# (여기서는 기본 파라미터 위주로 튜닝)
|
||||
|
||||
params = {
|
||||
"recent_window": p_window,
|
||||
"recent_weight": p_weight,
|
||||
"avoid_recent_k": p_avoid,
|
||||
"strategy": "smart_feedback"
|
||||
}
|
||||
else:
|
||||
# 완전 랜덤 탐색
|
||||
params = {
|
||||
"recent_window": random.randint(50, 400),
|
||||
"recent_weight": round(random.uniform(0.5, 5.0), 2),
|
||||
"avoid_recent_k": random.randint(0, 10),
|
||||
"strategy": "random_exploration"
|
||||
}
|
||||
|
||||
# 생성 시도
|
||||
try:
|
||||
# recommend_numbers는 db.py/main.py 로직과 독립적이므로 여기서 사용 가능
|
||||
# 단, recommend_numbers 함수가 어디 있는지 확인 (recommender.py)
|
||||
res = recommend_numbers(
|
||||
draws,
|
||||
recent_window=params["recent_window"],
|
||||
recent_weight=params["recent_weight"],
|
||||
avoid_recent_k=params["avoid_recent_k"]
|
||||
)
|
||||
|
||||
save_recommendation_dedup(based_on, res["numbers"], params)
|
||||
generated_count += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f"Gen Error: {e}")
|
||||
continue
|
||||
|
||||
return generated_count
|
||||
@@ -9,8 +9,12 @@ from .db import (
|
||||
save_recommendation_dedup, list_recommendations_ex, delete_recommendation,
|
||||
update_recommendation,
|
||||
)
|
||||
from .recommender import recommend_numbers
|
||||
from .collector import sync_latest
|
||||
from .recommender import recommend_numbers, recommend_with_heatmap
|
||||
from .collector import sync_latest, sync_ensure_all
|
||||
from .generator import generate_smart_recommendations
|
||||
from .generator import generate_smart_recommendations
|
||||
from .checker import check_results_for_draw
|
||||
from .utils import calc_metrics, calc_recent_overlap
|
||||
|
||||
app = FastAPI()
|
||||
scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
|
||||
@@ -18,68 +22,23 @@ scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
|
||||
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")
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
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"]:
|
||||
# 새로운 회차(예: 1000회)가 나오면, 999회차 기반 추천들을 채점
|
||||
check_results_for_draw(res["drawNo"])
|
||||
|
||||
scheduler.add_job(_sync_and_check, "cron", hour="9,21", minute=10)
|
||||
|
||||
# 2. 매일 아침 8시: 지능형 자동 추천 (10개씩)
|
||||
scheduler.add_job(lambda: generate_smart_recommendations(10), "cron", hour="8", minute=0)
|
||||
|
||||
scheduler.start()
|
||||
|
||||
@app.get("/health")
|
||||
@@ -96,6 +55,7 @@ def api_latest():
|
||||
"date": row["drw_date"],
|
||||
"numbers": [row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]],
|
||||
"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}")
|
||||
@@ -108,11 +68,53 @@ def api_draw(drw_no: int):
|
||||
"date": row["drw_date"],
|
||||
"numbers": [row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]],
|
||||
"bonus": row["bonus"],
|
||||
"metrics": calc_metrics([row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]]),
|
||||
}
|
||||
|
||||
@app.post("/api/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
|
||||
|
||||
@app.post("/api/admin/auto_gen")
|
||||
def admin_auto_gen(count: int = 10):
|
||||
"""지능형 자동 생성 수동 트리거"""
|
||||
n = generate_smart_recommendations(count)
|
||||
return {"generated": n}
|
||||
|
||||
@app.get("/api/lotto/stats")
|
||||
def api_stats():
|
||||
# 1. 데이터 완전성 보장 (없으면 가져옴)
|
||||
sync_ensure_all(LATEST_URL, ALL_URL)
|
||||
|
||||
# 2. 전체 데이터 조회
|
||||
draws = get_all_draw_numbers()
|
||||
if not draws:
|
||||
raise HTTPException(status_code=404, detail="No data yet")
|
||||
|
||||
# 1~45번 빈도 초기화
|
||||
frequency = {n: 0 for n in range(1, 46)}
|
||||
|
||||
total_draws = len(draws)
|
||||
|
||||
for _, nums in draws:
|
||||
for n in nums:
|
||||
frequency[n] += 1
|
||||
|
||||
# 리스트 형태로 변환 (프론트엔드 차트용)
|
||||
# x: 번호, y: 횟수
|
||||
stats = [
|
||||
{"number": n, "count": frequency[n]}
|
||||
for n in range(1, 46)
|
||||
]
|
||||
|
||||
return {
|
||||
"total_draws": total_draws,
|
||||
"frequency": stats
|
||||
}
|
||||
|
||||
# ---------- ✅ recommend (dedup save) ----------
|
||||
@app.get("/api/lotto/recommend")
|
||||
@@ -221,6 +223,124 @@ def api_recommend(
|
||||
"tries": tries,
|
||||
}
|
||||
|
||||
# ---------- ✅ heatmap-based recommend ----------
|
||||
@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,
|
||||
|
||||
# ---- optional constraints ----
|
||||
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}.",
|
||||
)
|
||||
|
||||
# ✅ dedup save
|
||||
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,
|
||||
}
|
||||
|
||||
# ---------- ✅ history list (filter/paging) ----------
|
||||
@app.get("/api/history")
|
||||
def api_history(
|
||||
@@ -322,7 +442,11 @@ def api_recommend_batch(
|
||||
return {
|
||||
"based_on_latest_draw": latest["drw_no"] if latest else None,
|
||||
"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,
|
||||
}
|
||||
|
||||
@@ -342,3 +466,7 @@ def api_recommend_batch_save(body: BatchSave):
|
||||
|
||||
return {"saved": True, "created_ids": created, "deduped_ids": deduped}
|
||||
|
||||
@app.get("/api/version")
|
||||
def version():
|
||||
import os
|
||||
return {"version": os.getenv("APP_VERSION", "dev")}
|
||||
|
||||
@@ -66,3 +66,98 @@ def recommend_numbers(
|
||||
|
||||
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,
|
||||
}
|
||||
16
deployer/Dockerfile
Normal file
16
deployer/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
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/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app.py /app/app.py
|
||||
|
||||
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:
|
||||
backend:
|
||||
build: ./backend
|
||||
build:
|
||||
context: ./backend
|
||||
args:
|
||||
APP_VERSION: ${APP_VERSION:-dev}
|
||||
container_name: lotto-backend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -12,13 +15,28 @@ services:
|
||||
- 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}
|
||||
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
|
||||
|
||||
travel-proxy:
|
||||
build: ./travel-proxy
|
||||
container_name: travel-proxy
|
||||
restart: unless-stopped
|
||||
user: "1026:100"
|
||||
user: "${PUID}:${PGID}"
|
||||
ports:
|
||||
- "19000:8000" # 내부 확인용
|
||||
environment:
|
||||
@@ -29,8 +47,8 @@ services:
|
||||
- TRAVEL_CACHE_TTL=${TRAVEL_CACHE_TTL:-300}
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-*}
|
||||
volumes:
|
||||
- /volume1/web/images/webPage/travel:/data/travel:ro
|
||||
- /volume1/docker/webpage/travel-thumbs:/data/thumbs:rw
|
||||
- ${PHOTO_PATH}:/data/travel:ro
|
||||
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:rw
|
||||
|
||||
frontend:
|
||||
image: nginx:alpine
|
||||
@@ -39,9 +57,23 @@ services:
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- /volume1/docker/webpage/frontend:/usr/share/nginx/html:ro
|
||||
- /volume1/docker/webpage/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- /volume1/web/images/webPage/travel:/data/travel:ro
|
||||
- /volume1/docker/webpage/travel-thumbs:/data/thumbs:ro
|
||||
- ${FRONTEND_PATH}:/usr/share/nginx/html:ro
|
||||
- ${RUNTIME_PATH}/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ${PHOTO_PATH}:/data/travel:ro
|
||||
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:ro
|
||||
extra_hosts:
|
||||
- "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
|
||||
|
||||
@@ -54,6 +54,27 @@ server {
|
||||
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/;
|
||||
}
|
||||
|
||||
|
||||
# API 프록시 (여기가 포인트: /api/ 중복 제거)
|
||||
location /api/ {
|
||||
proxy_http_version 1.1;
|
||||
@@ -66,6 +87,27 @@ server {
|
||||
proxy_pass http://backend:8000;
|
||||
}
|
||||
|
||||
# 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 라우팅 (마지막에 두는 게 안전)
|
||||
location / {
|
||||
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"
|
||||
59
scripts/deploy-nas.sh
Normal file
59
scripts/deploy-nas.sh
Normal file
@@ -0,0 +1,59 @@
|
||||
#!/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/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"
|
||||
64
scripts/deploy.sh
Normal file
64
scripts/deploy.sh
Normal file
@@ -0,0 +1,64 @@
|
||||
#!/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
|
||||
|
||||
# [Permission Fix]
|
||||
# deployer가 root로 돌면서 생성한 파일들의 소유권을 호스트 사용자로 변경
|
||||
# .env에 정의된 PUID:PGID가 있으면 사용, 없으면 1000:1000
|
||||
TARGET_UID=$(grep PUID .env | cut -d '=' -f2 || echo 1000)
|
||||
TARGET_GID=$(grep PGID .env | cut -d '=' -f2 || echo 1000)
|
||||
|
||||
echo "Fixing permissions to $TARGET_UID:$TARGET_GID ..."
|
||||
chown -R "$TARGET_UID:$TARGET_GID" "$DST" || true
|
||||
chmod -R 755 "$DST" || true
|
||||
# Repo 쪽도 혹시 모르니
|
||||
if [ "$SRC" != "$DST" ]; then
|
||||
chown -R "$TARGET_UID:$TARGET_GID" "$SRC" || true
|
||||
chmod -R 755 "$SRC" || true
|
||||
fi
|
||||
|
||||
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"]
|
||||
67
stock-lab/app/db.py
Normal file
67
stock-lab/app/db.py
Normal file
@@ -0,0 +1,67 @@
|
||||
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'")
|
||||
|
||||
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]
|
||||
132
stock-lab/app/main.py
Normal file
132
stock-lab/app/main.py
Normal file
@@ -0,0 +1,132 @@
|
||||
import os
|
||||
import json
|
||||
from typing import Optional
|
||||
from fastapi import FastAPI
|
||||
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
|
||||
from .scraper import fetch_market_news, fetch_major_indices, fetch_overseas_news
|
||||
|
||||
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")
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
init_db()
|
||||
|
||||
# 매일 아침 8시 뉴스 스크랩 (NAS 자체 수행)
|
||||
scheduler.add_job(run_scraping_job, "cron", hour="8", minute="0")
|
||||
|
||||
# 앱 시작 시에도 한 번 실행 (데이터 없으면)
|
||||
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")}
|
||||
|
||||
|
||||
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
|
||||
|
||||
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 []
|
||||
|
||||
items = []
|
||||
for p in album_dir.iterdir():
|
||||
if p.is_file() and p.suffix.lower() in IMAGE_EXT:
|
||||
# ✅ 썸네일 생성 보장
|
||||
ensure_thumb(p, album)
|
||||
|
||||
# os.scandir이 iterdir보다 빠름 (파일 속성 한 번에 가져옴)
|
||||
with os.scandir(album_dir) as entries:
|
||||
for entry in entries:
|
||||
if entry.is_file() and Path(entry.name).suffix.lower() in IMAGE_EXT:
|
||||
# ⚡ 성능 핵심: 여기서 썸네일 생성(ensure_thumb) 절대 하지 않음!
|
||||
# 파일 존재 여부만 확인하고 바로 리턴
|
||||
items.append({
|
||||
"album": album,
|
||||
"file": p.name,
|
||||
"url": f"{MEDIA_BASE}/{album}/{p.name}",
|
||||
"thumb": f"{MEDIA_BASE}/.thumb/{album}/{p.name}",
|
||||
"file": entry.name,
|
||||
"url": f"{MEDIA_BASE}/{album}/{entry.name}",
|
||||
"thumb": f"{MEDIA_BASE}/.thumb/{album}/{entry.name}",
|
||||
# 정렬을 위해 수정시간/이름 필요하면 여기서 저장
|
||||
"mtime": entry.stat().st_mtime
|
||||
})
|
||||
return items
|
||||
|
||||
@@ -170,19 +173,28 @@ def regions():
|
||||
_meta_changed_invalidate_cache()
|
||||
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")
|
||||
def photos(
|
||||
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()
|
||||
|
||||
# 1. 캐시 키에 페이지 정보 포함하지 않음 (전체 리스트 캐싱 후 슬라이싱 전략)
|
||||
# 왜냐하면 파일 스캔 비용이 크지, 메모리 슬라이싱 비용은 작기 때문.
|
||||
now = time.time()
|
||||
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()
|
||||
albums = _get_albums_for_region(region, region_map)
|
||||
|
||||
@@ -194,19 +206,35 @@ def photos(
|
||||
matched.append({"album": album, "count": len(items)})
|
||||
all_items.extend(items)
|
||||
|
||||
# 정렬: 앨범명 > 파일명 (또는 찍은 날짜)
|
||||
all_items.sort(key=lambda x: (x["album"], x["file"]))
|
||||
|
||||
data = {
|
||||
cached_data = {
|
||||
"region": region,
|
||||
"matched_albums": matched,
|
||||
"items": all_items[:limit],
|
||||
"items": 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}
|
||||
return data
|
||||
# 2. 페이지네이션 슬라이싱
|
||||
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}")
|
||||
@@ -218,3 +246,8 @@ def get_thumb(album: str, filename: str):
|
||||
# src로부터 thumb 생성/확인 (원본 확장자 유지)
|
||||
p = ensure_thumb(src, album)
|
||||
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