Compare commits
3 Commits
c9737b380f
...
6a1a2c4552
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a1a2c4552 | |||
| ff975defbd | |||
| bc9ba3901e |
@@ -50,3 +50,12 @@ PGID=1000
|
|||||||
|
|
||||||
# Windows AI Server (NAS 입장에서 바라본 Windows PC IP)
|
# Windows AI Server (NAS 입장에서 바라본 Windows PC IP)
|
||||||
WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000
|
WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000
|
||||||
|
|
||||||
|
# Admin API Key (trade/order 등 민감 엔드포인트 보호, 미설정 시 인증 비활성화)
|
||||||
|
ADMIN_API_KEY=
|
||||||
|
|
||||||
|
# Anthropic API Key (AI Coach 프록시, 미설정 시 AI Coach 비활성화)
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
|
||||||
|
# CORS 허용 도메인 (콤마 구분)
|
||||||
|
CORS_ALLOW_ORIGINS=https://gahusb.synology.me,http://localhost:3007,http://localhost:8080
|
||||||
|
|||||||
6
backend/.dockerignore
Normal file
6
backend/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.git
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.md
|
||||||
6
deployer/.dockerignore
Normal file
6
deployer/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.git
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.md
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
import os, hmac, hashlib, subprocess
|
import os, hmac, hashlib, subprocess, threading
|
||||||
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
|
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
# 로깅 설정
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
|
||||||
logging.basicConfig(level=logging.INFO)
|
|
||||||
logger = logging.getLogger("deployer")
|
logger = logging.getLogger("deployer")
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
SECRET = os.getenv("WEBHOOK_SECRET", "")
|
SECRET = os.getenv("WEBHOOK_SECRET", "")
|
||||||
|
|
||||||
|
if not SECRET:
|
||||||
|
logger.warning("WEBHOOK_SECRET is not set! All webhooks will be rejected.")
|
||||||
|
|
||||||
|
_deploy_lock = threading.Lock()
|
||||||
|
|
||||||
def verify(sig: str, body: bytes) -> bool:
|
def verify(sig: str, body: bytes) -> bool:
|
||||||
if not SECRET or not sig:
|
if not SECRET or not sig:
|
||||||
return False
|
return False
|
||||||
@@ -18,10 +22,13 @@ def verify(sig: str, body: bytes) -> bool:
|
|||||||
return any(hmac.compare_digest(sig, c) for c in candidates)
|
return any(hmac.compare_digest(sig, c) for c in candidates)
|
||||||
|
|
||||||
def run_deploy_script():
|
def run_deploy_script():
|
||||||
"""배포 스크립트를 백그라운드에서 실행하고 로그를 남김"""
|
"""배포 스크립트를 백그라운드에서 실행 (동시 실행 방지)"""
|
||||||
logger.info("Starting deployment script...")
|
if not _deploy_lock.acquire(blocking=False):
|
||||||
|
logger.info("Deploy already in progress, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 타임아웃 10분 설정
|
logger.info("Starting deployment script...")
|
||||||
p = subprocess.run(["/bin/bash", "/scripts/deploy.sh"], capture_output=True, text=True, timeout=600)
|
p = subprocess.run(["/bin/bash", "/scripts/deploy.sh"], capture_output=True, text=True, timeout=600)
|
||||||
|
|
||||||
if p.returncode == 0:
|
if p.returncode == 0:
|
||||||
@@ -29,8 +36,16 @@ def run_deploy_script():
|
|||||||
else:
|
else:
|
||||||
logger.error(f"Deployment FAILED ({p.returncode}):\n{p.stdout}\n{p.stderr}")
|
logger.error(f"Deployment FAILED ({p.returncode}):\n{p.stdout}\n{p.stderr}")
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.error("Deployment TIMEOUT (10 min exceeded)")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Exception during deployment: {e}")
|
logger.exception(f"Exception during deployment: {e}")
|
||||||
|
finally:
|
||||||
|
_deploy_lock.release()
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "healthy", "service": "deployer"}
|
||||||
|
|
||||||
@app.post("/webhook")
|
@app.post("/webhook")
|
||||||
async def webhook(req: Request, background_tasks: BackgroundTasks):
|
async def webhook(req: Request, background_tasks: BackgroundTasks):
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ services:
|
|||||||
- WINDOWS_AI_SERVER_URL=${WINDOWS_AI_SERVER_URL:-http://192.168.0.5:8000}
|
- WINDOWS_AI_SERVER_URL=${WINDOWS_AI_SERVER_URL:-http://192.168.0.5:8000}
|
||||||
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
|
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
|
||||||
- GEMINI_MODEL=${GEMINI_MODEL:-gemini-1.5-flash}
|
- GEMINI_MODEL=${GEMINI_MODEL:-gemini-1.5-flash}
|
||||||
|
- ADMIN_API_KEY=${ADMIN_API_KEY:-}
|
||||||
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||||
|
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||||
volumes:
|
volumes:
|
||||||
- ${STOCK_DATA_PATH:-./data/stock}:/app/data
|
- ${STOCK_DATA_PATH:-./data/stock}:/app/data
|
||||||
|
|
||||||
@@ -45,6 +48,7 @@ services:
|
|||||||
- TZ=${TZ:-Asia/Seoul}
|
- TZ=${TZ:-Asia/Seoul}
|
||||||
- MUSIC_AI_SERVER_URL=${MUSIC_AI_SERVER_URL:-}
|
- MUSIC_AI_SERVER_URL=${MUSIC_AI_SERVER_URL:-}
|
||||||
- MUSIC_MEDIA_BASE=${MUSIC_MEDIA_BASE:-/media/music}
|
- MUSIC_MEDIA_BASE=${MUSIC_MEDIA_BASE:-/media/music}
|
||||||
|
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||||
volumes:
|
volumes:
|
||||||
- ${MUSIC_DATA_PATH:-./data/music}:/app/data
|
- ${MUSIC_DATA_PATH:-./data/music}:/app/data
|
||||||
|
|
||||||
@@ -88,7 +92,7 @@ services:
|
|||||||
container_name: webpage-deployer
|
container_name: webpage-deployer
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "19010:9000" # 외부 노출 필요 없으면 내부만 (리버스프록시로 /webhook만 노출 추천)
|
- "127.0.0.1:19010:9000" # localhost만 허용 (nginx /webhook 프록시 경유)
|
||||||
environment:
|
environment:
|
||||||
- WEBHOOK_SECRET=${WEBHOOK_SECRET}
|
- WEBHOOK_SECRET=${WEBHOOK_SECRET}
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
6
music-lab/.dockerignore
Normal file
6
music-lab/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.git
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.md
|
||||||
@@ -16,11 +16,13 @@ from .db import (
|
|||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
|
_cors_origins = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080").split(",")
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=[o.strip() for o in _cors_origins],
|
||||||
allow_methods=["*"],
|
allow_credentials=False,
|
||||||
allow_headers=["*"],
|
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||||
|
allow_headers=["Content-Type"],
|
||||||
)
|
)
|
||||||
|
|
||||||
MUSIC_AI_SERVER_URL = os.getenv("MUSIC_AI_SERVER_URL", "")
|
MUSIC_AI_SERVER_URL = os.getenv("MUSIC_AI_SERVER_URL", "")
|
||||||
|
|||||||
@@ -30,33 +30,14 @@ cd "$SRC"
|
|||||||
|
|
||||||
# 레포에서 운영으로 반영할 항목들만 복사/동기화 (필요한 것만 적기)
|
# 레포에서 운영으로 반영할 항목들만 복사/동기화 (필요한 것만 적기)
|
||||||
# backend, travel-proxy, deployer, nginx, scripts, docker-compose.yml, .env 등
|
# backend, travel-proxy, deployer, nginx, scripts, docker-compose.yml, .env 등
|
||||||
rsync -a --delete \
|
RSYNC_EXCLUDES="--exclude .git --exclude __pycache__ --exclude *.pyc --exclude data/"
|
||||||
--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 파일
|
for dir in backend travel-proxy deployer stock-lab music-lab nginx scripts; do
|
||||||
|
rsync -a --delete $RSYNC_EXCLUDES \
|
||||||
|
"$SRC/$dir/" "$DST/$dir/"
|
||||||
|
done
|
||||||
|
|
||||||
|
# compose 파일만 동기화 (.env는 절대 동기화하지 않음 — 운영 시크릿 보호)
|
||||||
rsync -a "$SRC/docker-compose.yml" "$DST/docker-compose.yml"
|
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"
|
echo "SYNC_OK"
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── 동시 배포 방지 (flock) ──
|
||||||
|
exec 200>/tmp/deploy.lock
|
||||||
|
flock -n 200 || { echo "Deploy already running, skipping"; exit 0; }
|
||||||
|
|
||||||
# 1. 자동 감지: Docker 컨테이너 내부인가?
|
# 1. 자동 감지: Docker 컨테이너 내부인가?
|
||||||
if [ -d "/repo" ] && [ -d "/runtime" ]; then
|
if [ -d "/repo" ] && [ -d "/runtime" ]; then
|
||||||
echo "Detected Docker Container environment."
|
echo "Detected Docker Container environment."
|
||||||
@@ -13,9 +17,8 @@ else
|
|||||||
set -a; source .env; set +a
|
set -a; source .env; set +a
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 환경변수가 없으면 현재 디렉토리를 SRC로
|
|
||||||
SRC="${REPO_PATH:-$(pwd)}"
|
SRC="${REPO_PATH:-$(pwd)}"
|
||||||
DST="${RUNTIME_PATH:-/volume1/docker/webpage}" # 기본값 설정
|
DST="${RUNTIME_PATH:-/volume1/docker/webpage}"
|
||||||
|
|
||||||
if [ -z "$DST" ]; then
|
if [ -z "$DST" ]; then
|
||||||
echo "Error: RUNTIME_PATH is not set."
|
echo "Error: RUNTIME_PATH is not set."
|
||||||
@@ -32,19 +35,40 @@ cd "$SRC"
|
|||||||
git fetch --all --prune
|
git fetch --all --prune
|
||||||
git pull --ff-only
|
git pull --ff-only
|
||||||
|
|
||||||
# 릴리즈 백업(롤백용): 아래 5번과 연결
|
# ── 릴리즈 백업 (롤백용) ──
|
||||||
TAG="$(date +%Y%m%d-%H%M%S)"
|
TAG="$(date +%Y%m%d-%H%M%S)"
|
||||||
mkdir -p "$DST/.releases/$TAG"
|
BACKUP_DIR="$DST/.releases/$TAG"
|
||||||
|
mkdir -p "$BACKUP_DIR.tmp"
|
||||||
rsync -a --delete \
|
rsync -a --delete \
|
||||||
--exclude ".releases" \
|
--exclude ".releases" \
|
||||||
"$DST/" "$DST/.releases/$TAG/"
|
"$DST/" "$BACKUP_DIR.tmp/"
|
||||||
|
mv "$BACKUP_DIR.tmp" "$BACKUP_DIR"
|
||||||
|
|
||||||
# 소스 → 운영 반영 (네가 이미 만든 deploy-nas.sh가 있으면 그걸 호출해도 됨)
|
# 오래된 릴리즈 정리 (최근 5개만 유지)
|
||||||
# 예: repo/scripts/deploy-nas.sh가 운영으로 복사/동기화하는 로직이라면:
|
ls -dt "$DST/.releases"/*/ 2>/dev/null | tail -n +6 | xargs -r rm -rf
|
||||||
|
|
||||||
|
# ── 소스 → 운영 반영 ──
|
||||||
bash "$SRC/scripts/deploy-nas.sh"
|
bash "$SRC/scripts/deploy-nas.sh"
|
||||||
|
|
||||||
|
# ── 컨테이너 재빌드 (deployer 제외 — 자기 자신을 재빌드하면 스크립트 중단됨) ──
|
||||||
cd "$DST"
|
cd "$DST"
|
||||||
docker compose up -d --build backend travel-proxy stock-lab frontend deployer
|
docker compose up -d --build backend travel-proxy stock-lab music-lab frontend
|
||||||
docker exec lotto-frontend nginx -s reload 2>/dev/null || true
|
docker exec lotto-frontend nginx -s reload 2>/dev/null || true
|
||||||
|
|
||||||
|
# ── 배포 후 헬스체크 ──
|
||||||
|
echo "Waiting for services to start..."
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
HEALTH_OK=true
|
||||||
|
for endpoint in "http://backend:8000/health" "http://stock-lab:8000/health" "http://travel-proxy:8000/health" "http://music-lab:8000/health"; do
|
||||||
|
if ! curl -sf --max-time 5 "$endpoint" > /dev/null 2>&1; then
|
||||||
|
echo "HEALTH_FAIL: $endpoint"
|
||||||
|
HEALTH_OK=false
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$HEALTH_OK" = false ]; then
|
||||||
|
echo "DEPLOY_WARN: Some services failed health check. Consider rollback: $TAG"
|
||||||
|
else
|
||||||
echo "DEPLOY_OK $TAG"
|
echo "DEPLOY_OK $TAG"
|
||||||
|
fi
|
||||||
|
|||||||
6
stock-lab/.dockerignore
Normal file
6
stock-lab/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.git
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.md
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from datetime import date as date_type
|
from datetime import date as date_type
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import FastAPI, Query
|
from fastapi import FastAPI, Query, Header, Depends, HTTPException
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
import requests
|
import requests
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
|
||||||
|
logger = logging.getLogger("stock-lab")
|
||||||
|
|
||||||
from .db import (
|
from .db import (
|
||||||
init_db, save_articles, get_latest_articles,
|
init_db, save_articles, get_latest_articles,
|
||||||
add_portfolio_item, get_all_portfolio, get_portfolio_item,
|
add_portfolio_item, get_all_portfolio, get_portfolio_item,
|
||||||
@@ -23,12 +27,13 @@ from .price_fetcher import get_current_prices
|
|||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
# CORS 설정 (프론트엔드 접근 허용)
|
# CORS 설정 (프론트엔드 접근 허용)
|
||||||
|
_cors_origins = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080").split(",")
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"], # 운영 시에는 구체적인 도메인으로 제한하는 것이 좋음
|
allow_origins=[o.strip() for o in _cors_origins],
|
||||||
allow_credentials=True,
|
allow_credentials=False,
|
||||||
allow_methods=["*"],
|
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||||
allow_headers=["*"],
|
allow_headers=["Content-Type"],
|
||||||
)
|
)
|
||||||
|
|
||||||
scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
|
scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
|
||||||
@@ -36,6 +41,19 @@ scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
|
|||||||
# Windows AI Server URL (NAS .env에서 설정)
|
# Windows AI Server URL (NAS .env에서 설정)
|
||||||
WINDOWS_AI_SERVER_URL = os.getenv("WINDOWS_AI_SERVER_URL", "http://192.168.0.5:8000")
|
WINDOWS_AI_SERVER_URL = os.getenv("WINDOWS_AI_SERVER_URL", "http://192.168.0.5:8000")
|
||||||
|
|
||||||
|
# Admin API Key 인증
|
||||||
|
ADMIN_API_KEY = os.getenv("ADMIN_API_KEY", "")
|
||||||
|
|
||||||
|
def verify_admin(x_admin_key: str = Header(None)):
|
||||||
|
"""admin/trade 엔드포인트 보호용 API 키 검증"""
|
||||||
|
if not ADMIN_API_KEY:
|
||||||
|
return # 키 미설정 시 인증 비활성화 (개발 환경)
|
||||||
|
if x_admin_key != ADMIN_API_KEY:
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
|
||||||
|
# Anthropic API 프록시용 키 (서버 측 보관)
|
||||||
|
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
|
||||||
|
|
||||||
# 공휴일 목록 로드
|
# 공휴일 목록 로드
|
||||||
_HOLIDAYS_PATH = os.path.join(os.path.dirname(__file__), "holidays.json")
|
_HOLIDAYS_PATH = os.path.join(os.path.dirname(__file__), "holidays.json")
|
||||||
try:
|
try:
|
||||||
@@ -123,53 +141,87 @@ def trigger_scrap():
|
|||||||
run_scraping_job()
|
run_scraping_job()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
# --- Trading API (Windows Proxy) ---
|
# --- Trading API (Windows Proxy, 인증 필요) ---
|
||||||
|
|
||||||
@app.get("/api/trade/balance")
|
@app.get("/api/trade/balance", dependencies=[Depends(verify_admin)])
|
||||||
def get_balance():
|
def get_balance():
|
||||||
"""계좌 잔고 조회 (Windows AI Server Proxy)"""
|
"""계좌 잔고 조회 (Windows AI Server Proxy)"""
|
||||||
print(f"[Proxy] Requesting Balance from {WINDOWS_AI_SERVER_URL}...")
|
logger.info(f"Requesting Balance from {WINDOWS_AI_SERVER_URL}")
|
||||||
try:
|
try:
|
||||||
resp = requests.get(f"{WINDOWS_AI_SERVER_URL}/trade/balance", timeout=5)
|
resp = requests.get(f"{WINDOWS_AI_SERVER_URL}/trade/balance", timeout=5)
|
||||||
|
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
print(f"[ProxyError] Balance Error: {resp.status_code} {resp.text}")
|
logger.error(f"Balance Error: {resp.status_code}")
|
||||||
return JSONResponse(status_code=resp.status_code, content=resp.json())
|
return JSONResponse(status_code=resp.status_code, content=resp.json())
|
||||||
|
|
||||||
print("[Proxy] Balance Success")
|
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
except requests.JSONDecodeError:
|
||||||
|
return JSONResponse(status_code=resp.status_code, content={"error": f"Upstream error {resp.status_code}"})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ProxyError] Connection Failed: {e}")
|
logger.error(f"Balance Connection Failed: {e}")
|
||||||
return JSONResponse(
|
return JSONResponse(status_code=500, content={"error": "Connection Failed"})
|
||||||
status_code=500,
|
|
||||||
content={"error": "Connection Failed", "detail": str(e), "target": WINDOWS_AI_SERVER_URL}
|
|
||||||
)
|
|
||||||
|
|
||||||
class OrderRequest(BaseModel):
|
class OrderRequest(BaseModel):
|
||||||
ticker: str # 종목 코드 (예: "005930")
|
ticker: str
|
||||||
action: str # "BUY" or "SELL"
|
action: str
|
||||||
quantity: int # 주문 수량
|
quantity: int
|
||||||
price: int = 0 # 0이면 시장가
|
price: int = 0
|
||||||
reason: Optional[str] = "Manual Order" # 주문 사유 (AI 기록용)
|
reason: Optional[str] = "Manual Order"
|
||||||
|
|
||||||
@app.post("/api/trade/order")
|
@app.post("/api/trade/order", dependencies=[Depends(verify_admin)])
|
||||||
def order_stock(req: OrderRequest):
|
def order_stock(req: OrderRequest):
|
||||||
"""주식 매수/매도 주문 (Windows AI Server Proxy)"""
|
"""주식 매수/매도 주문 (Windows AI Server Proxy)"""
|
||||||
print(f"[Proxy] Order Request: {req.dict()} to {WINDOWS_AI_SERVER_URL}...")
|
logger.info(f"Order Request: {req.action} {req.ticker} x{req.quantity}")
|
||||||
try:
|
try:
|
||||||
resp = requests.post(f"{WINDOWS_AI_SERVER_URL}/trade/order", json=req.dict(), timeout=10)
|
resp = requests.post(f"{WINDOWS_AI_SERVER_URL}/trade/order", json=req.dict(), timeout=10)
|
||||||
|
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
print(f"[ProxyError] Order Error: {resp.status_code} {resp.text}")
|
logger.error(f"Order Error: {resp.status_code}")
|
||||||
return JSONResponse(status_code=resp.status_code, content=resp.json())
|
return JSONResponse(status_code=resp.status_code, content=resp.json())
|
||||||
|
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
except requests.JSONDecodeError:
|
||||||
|
return JSONResponse(status_code=resp.status_code, content={"error": f"Upstream error {resp.status_code}"})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ProxyError] Order Connection Failed: {e}")
|
logger.error(f"Order Connection Failed: {e}")
|
||||||
return JSONResponse(
|
return JSONResponse(status_code=500, content={"error": "Connection Failed"})
|
||||||
status_code=500,
|
|
||||||
content={"error": "Connection Failed", "detail": str(e), "target": WINDOWS_AI_SERVER_URL}
|
# --- AI Coach 프록시 (API 키를 서버에 보관) ---
|
||||||
|
|
||||||
|
class AiCoachRequest(BaseModel):
|
||||||
|
model: str = "claude-haiku-4-5-20251001"
|
||||||
|
prompt: str
|
||||||
|
max_tokens: int = 1024
|
||||||
|
|
||||||
|
@app.post("/api/stock/ai-coach")
|
||||||
|
def ai_coach(req: AiCoachRequest):
|
||||||
|
"""AI 포트폴리오 코치 — Anthropic API 프록시 (API 키 서버 보관)"""
|
||||||
|
if not ANTHROPIC_API_KEY:
|
||||||
|
raise HTTPException(503, "AI Coach not configured (ANTHROPIC_API_KEY missing)")
|
||||||
|
|
||||||
|
allowed_models = {"claude-haiku-4-5-20251001", "claude-sonnet-4-6"}
|
||||||
|
model = req.model if req.model in allowed_models else "claude-haiku-4-5-20251001"
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
"https://api.anthropic.com/v1/messages",
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-api-key": ANTHROPIC_API_KEY,
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"model": model,
|
||||||
|
"max_tokens": req.max_tokens,
|
||||||
|
"messages": [{"role": "user", "content": req.prompt}],
|
||||||
|
},
|
||||||
|
timeout=30,
|
||||||
)
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.error(f"Anthropic API error: {resp.status_code}")
|
||||||
|
return JSONResponse(status_code=resp.status_code, content={"error": "AI API error"})
|
||||||
|
return resp.json()
|
||||||
|
except requests.Timeout:
|
||||||
|
return JSONResponse(status_code=504, content={"error": "AI API timeout"})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"AI Coach error: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"error": "AI Coach failed"})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
6
travel-proxy/.dockerignore
Normal file
6
travel-proxy/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.git
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.md
|
||||||
@@ -168,6 +168,10 @@ def scan_album(album: str) -> List[Dict[str, Any]]:
|
|||||||
# -----------------------------
|
# -----------------------------
|
||||||
# Routes
|
# Routes
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "healthy", "service": "travel-proxy"}
|
||||||
|
|
||||||
@app.get("/api/travel/regions")
|
@app.get("/api/travel/regions")
|
||||||
def regions():
|
def regions():
|
||||||
_meta_changed_invalidate_cache()
|
_meta_changed_invalidate_cache()
|
||||||
@@ -239,12 +243,18 @@ def photos(
|
|||||||
|
|
||||||
@app.get("/media/travel/.thumb/{album}/{filename}")
|
@app.get("/media/travel/.thumb/{album}/{filename}")
|
||||||
def get_thumb(album: str, filename: str):
|
def get_thumb(album: str, filename: str):
|
||||||
|
if ".." in album or ".." in filename:
|
||||||
|
raise HTTPException(400, "Invalid path")
|
||||||
|
|
||||||
src = (ROOT / album / filename).resolve()
|
src = (ROOT / album / filename).resolve()
|
||||||
|
if not str(src).startswith(str(ROOT)):
|
||||||
|
raise HTTPException(403, "Access denied")
|
||||||
|
if not src.exists() or not src.is_file():
|
||||||
|
raise HTTPException(404, "Source not found")
|
||||||
|
|
||||||
|
p = ensure_thumb(src, album)
|
||||||
if not p.exists() or not p.is_file():
|
if not p.exists() or not p.is_file():
|
||||||
raise HTTPException(404, "Thumbnail not found")
|
raise HTTPException(404, "Thumbnail not found")
|
||||||
|
|
||||||
# src로부터 thumb 생성/확인 (원본 확장자 유지)
|
|
||||||
p = ensure_thumb(src, album)
|
|
||||||
return FileResponse(str(p))
|
return FileResponse(str(p))
|
||||||
|
|
||||||
@app.get("/api/version")
|
@app.get("/api/version")
|
||||||
|
|||||||
Reference in New Issue
Block a user