refactor: 전체 코드베이스 감사 기반 리팩토링 — 버그 수정, 데드코드 제거, 보안 강화

P0 버그 수정:
- stock-lab: trade 엔드포인트 NameError 수정 (resp 미정의)
- deployer: 동시 배포 시 HTTP 200 → 503 반환

P1 데드코드 제거:
- stock-lab: fetch_overseas_news(), get_broker_cash() 제거
- blog-lab: 미사용 urlparse import 제거
- lotto-lab: 중복 inline import json 7곳 제거

P2 성능/효율 개선:
- lotto-lab: 가중 샘플링 3중 복사 → utils.weighted_sample_6() 통합
- lotto-lab: DB 인덱스 3개 추가 (recommendations, purchase_history)
- stock-lab: Pydantic .dict() → .model_dump() 호환
- blog-lab: 페이지네이션 상한(le=100) 추가

P3 보안/인프라:
- nginx: X-Frame-Options, X-Content-Type-Options, Referrer-Policy 헤더 추가
- docker-compose: travel-proxy CORS 와일드카드 → localhost 전용
- Dockerfile: music-lab, blog-lab, realestate-lab에 PYTHONUNBUFFERED 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-07 04:10:14 +09:00
parent 9d5583935d
commit 535ffea45a
15 changed files with 84 additions and 150 deletions

View File

@@ -272,6 +272,11 @@ def init_db() -> None:
"""
)
# ── 추가 인덱스 ───────────────────────────────────────────────────────
conn.execute("CREATE INDEX IF NOT EXISTS idx_reco_based_checked ON recommendations(based_on_draw, checked)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_purchase_strategy ON purchase_history(source_strategy)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_purchase_checked ON purchase_history(draw_no, checked)")
# ── todos CRUD ───────────────────────────────────────────────────────────────
@@ -512,8 +517,6 @@ def list_recommendations_ex(
q: Optional[str] = None,
sort: str = "id_desc", # id_desc|created_desc|favorite_desc
) -> List[Dict[str, Any]]:
import json
where = []
args: list[Any] = []
@@ -810,7 +813,7 @@ def get_simulation_candidates(run_id: int, limit: int = 100) -> List[Dict[str, A
# ── purchase_history CRUD ─────────────────────────────────────────────────────
def _purchase_row_to_dict(r) -> Dict[str, Any]:
import json as _json
keys = r.keys()
numbers_raw = r["numbers"] if "numbers" in keys else "[]"
detail_raw = r["source_detail"] if "source_detail" in keys else "{}"
@@ -823,12 +826,12 @@ def _purchase_row_to_dict(r) -> Dict[str, Any]:
"prize": r["prize"],
"note": r["note"],
"created_at": r["created_at"],
"numbers": _json.loads(numbers_raw) if numbers_raw else [],
"numbers": json.loads(numbers_raw) if numbers_raw else [],
"is_real": r["is_real"] if "is_real" in keys else 1,
"source_strategy": r["source_strategy"] if "source_strategy" in keys else "manual",
"source_detail": _json.loads(detail_raw) if detail_raw else {},
"source_detail": json.loads(detail_raw) if detail_raw else {},
"checked": r["checked"] if "checked" in keys else 0,
"results": _json.loads(results_raw) if results_raw else [],
"results": json.loads(results_raw) if results_raw else [],
"total_prize": r["total_prize"] if "total_prize" in keys else 0,
}
@@ -836,9 +839,9 @@ def _purchase_row_to_dict(r) -> Dict[str, Any]:
def add_purchase(draw_no: int, amount: int, sets: int, prize: int = 0, note: str = "",
numbers: list = None, is_real: bool = True,
source_strategy: str = "manual", source_detail: dict = None) -> Dict[str, Any]:
import json as _json
numbers_json = _json.dumps(numbers or [], ensure_ascii=False)
detail_json = _json.dumps(source_detail or {}, ensure_ascii=False)
numbers_json = json.dumps(numbers or [], ensure_ascii=False)
detail_json = json.dumps(source_detail or {}, ensure_ascii=False)
is_real_int = 1 if is_real else 0
with _conn() as conn:
conn.execute(
@@ -880,7 +883,7 @@ def get_purchases(draw_no: int = None, days: int = None,
def update_purchase(purchase_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
import json as _json
allowed = {"draw_no", "amount", "sets", "prize", "note", "numbers", "is_real", "source_strategy"}
updates = {k: v for k, v in data.items() if k in allowed}
if not updates:
@@ -889,7 +892,7 @@ def update_purchase(purchase_id: int, data: Dict[str, Any]) -> Optional[Dict[str
return _purchase_row_to_dict(row) if row else None
# SQLite에 전달 전 타입 변환
if "numbers" in updates:
updates["numbers"] = _json.dumps(updates["numbers"], ensure_ascii=False)
updates["numbers"] = json.dumps(updates["numbers"], ensure_ascii=False)
if "is_real" in updates:
updates["is_real"] = 1 if updates["is_real"] else 0
set_clause = ", ".join(f"{k} = ?" for k in updates)
@@ -911,7 +914,7 @@ def delete_purchase(purchase_id: int) -> bool:
def get_purchase_stats() -> Dict[str, Any]:
import json as _json
def _calc_group(rows):
if not rows:
@@ -951,7 +954,7 @@ def get_purchase_stats() -> Dict[str, Any]:
for r in srows:
results_raw = r.get("results", "[]")
try:
results = _json.loads(results_raw) if isinstance(results_raw, str) else (results_raw or [])
results = json.loads(results_raw) if isinstance(results_raw, str) else (results_raw or [])
except Exception:
results = []
for res in results:
@@ -1015,8 +1018,8 @@ def get_weekly_report(drw_no: int) -> Optional[Dict[str, Any]]:
).fetchone()
if not row:
return None
import json as _json
return {"drw_no": row["drw_no"], "generated_at": row["generated_at"], **_json.loads(row["report"])}
return {"drw_no": row["drw_no"], "generated_at": row["generated_at"], **json.loads(row["report"])}
def get_all_recommendation_numbers() -> List[List[int]]:
@@ -1086,10 +1089,10 @@ def update_strategy_weight(strategy: str, weight: float, ema_score: float,
def update_purchase_results(purchase_id: int, results: list, total_prize: int) -> None:
"""구매 건의 결과를 갱신 (체커 호출 후)"""
import json as _json
with _conn() as conn:
conn.execute(
"UPDATE purchase_history SET results=?, total_prize=?, checked=1 WHERE id=?",
(_json.dumps(results, ensure_ascii=False), total_prize, purchase_id),
(json.dumps(results, ensure_ascii=False), total_prize, purchase_id),
)

View File

@@ -24,26 +24,7 @@ from .db import (
replace_best_picks,
)
from .analyzer import build_analysis_cache, build_number_weights, score_combination
def _weighted_sample_6(weights: Dict[int, float]) -> List[int]:
"""
가중 확률 샘플링으로 중복 없이 6개 번호 추출.
weights: {1: w1, 2: w2, ..., 45: w45}
"""
pool = list(range(1, 46))
chosen: List[int] = []
for _ in range(6):
total = sum(weights[n] for n in pool)
r = random.random() * total
acc = 0.0
for n in pool:
acc += weights[n]
if acc >= r:
chosen.append(n)
pool.remove(n)
break
return chosen
from .utils import weighted_sample_6
def run_simulation(
@@ -82,7 +63,7 @@ def run_simulation(
attempts = 0
while len(candidates) < n_candidates and attempts < max_attempts:
attempts += 1
nums = _weighted_sample_6(weights)
nums = weighted_sample_6(weights)
key = tuple(sorted(nums))
if key in seen_keys:
continue

View File

@@ -2,6 +2,8 @@ import random
from collections import Counter
from typing import Dict, Any, List, Tuple
from .utils import weighted_sample_6
def recommend_numbers(
draws: List[Tuple[int, List[int]]],
*,
@@ -40,20 +42,7 @@ def recommend_numbers(
weights[n] = max(w, 0.1)
# 중복 없이 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)
chosen_sorted = sorted(weighted_sample_6(weights))
explain = {
"recent_window": recent_window,
@@ -130,20 +119,7 @@ def recommend_with_heatmap(
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)
chosen_sorted = sorted(weighted_sample_6(weights))
# 5. 설명 데이터
explain = {

View File

@@ -1,5 +1,26 @@
import random
from typing import List, Dict, Any, Tuple
def weighted_sample_6(weights: Dict[int, float]) -> List[int]:
"""
가중 확률 샘플링으로 중복 없이 6개 번호 추출.
weights: {1: w1, 2: w2, ..., 45: w45}
"""
pool = list(range(1, 46))
chosen: List[int] = []
for _ in range(6):
total = sum(weights[n] for n in pool)
r = random.random() * total
acc = 0.0
for n in pool:
acc += weights[n]
if acc >= r:
chosen.append(n)
pool.remove(n)
break
return chosen
def calc_metrics(numbers: List[int]) -> Dict[str, Any]:
nums = sorted(numbers)
s = sum(nums)

View File

@@ -1,4 +1,5 @@
FROM python:3.12-alpine
ENV PYTHONUNBUFFERED=1
WORKDIR /app

View File

@@ -1,7 +1,7 @@
import os
import uuid
import logging
from fastapi import FastAPI, HTTPException, BackgroundTasks
from fastapi import FastAPI, HTTPException, BackgroundTasks, Query
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import List, Optional
@@ -94,7 +94,7 @@ def start_research(req: ResearchRequest, background_tasks: BackgroundTasks):
@app.get("/api/blog-marketing/research/history")
def list_research(limit: int = 30):
def list_research(limit: int = Query(30, ge=1, le=100)):
return {"analyses": get_keyword_analyses(limit)}
@@ -285,7 +285,7 @@ def start_regenerate(post_id: int, background_tasks: BackgroundTasks):
# ── 포스트 CRUD API ──────────────────────────────────────────────────────────
@app.get("/api/blog-marketing/posts")
def list_posts(status: str = None, limit: int = 50):
def list_posts(status: str = None, limit: int = Query(50, ge=1, le=100)):
return {"posts": get_posts(status=status, limit=limit)}
@@ -409,7 +409,7 @@ def start_market(post_id: int, background_tasks: BackgroundTasks):
# ── 수익 추적 API ────────────────────────────────────────────────────────────
@app.get("/api/blog-marketing/commissions")
def list_commissions(post_id: int = None, limit: int = 100):
def list_commissions(post_id: int = None, limit: int = Query(100, ge=1, le=100)):
return {"commissions": get_commissions(post_id=post_id, limit=limit)}

View File

@@ -4,8 +4,6 @@ import asyncio
import logging
import re
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urlparse
import httpx
from bs4 import BeautifulSoup

View File

@@ -1,5 +1,6 @@
import os, hmac, hashlib, subprocess, threading
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
from fastapi.responses import JSONResponse
import logging
logging.basicConfig(
@@ -64,8 +65,15 @@ async def webhook(req: Request, background_tasks: BackgroundTasks):
if not verify(sig, body):
raise HTTPException(401, "bad signature")
# 동시 배포 방지: 이미 진행 중이면 503 반환
if _deploy_lock.locked():
return JSONResponse(
status_code=503,
content={"ok": False, "message": "Deploy already in progress"},
)
# ✅ 비동기 실행: Gitea에게는 즉시 OK 응답을 주고, 배포는 뒤에서 실행
background_tasks.add_task(run_deploy_script)
return {"ok": True, "message": "Deployment started in background"}

View File

@@ -121,7 +121,7 @@ services:
- TRAVEL_THUMB_ROOT=${TRAVEL_THUMB_ROOT:-/data/thumbs}
- TRAVEL_MEDIA_BASE=${TRAVEL_MEDIA_BASE:-/media/travel}
- TRAVEL_CACHE_TTL=${TRAVEL_CACHE_TTL:-300}
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-*}
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
volumes:
- ${PHOTO_PATH}:/data/travel:ro
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:rw

View File

@@ -1,4 +1,5 @@
FROM python:3.12-alpine
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .

View File

@@ -2,6 +2,11 @@ server {
listen 80;
server_name _;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
root /usr/share/nginx/html;
index index.html;

View File

@@ -1,4 +1,5 @@
FROM python:3.12-alpine
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .

View File

@@ -183,12 +183,6 @@ def get_all_broker_cash() -> List[Dict[str, Any]]:
return [dict(r) for r in rows]
def get_broker_cash(broker: str) -> Dict[str, Any] | None:
with _conn() as conn:
row = conn.execute("SELECT * FROM broker_cash WHERE broker = ?", (broker,)).fetchone()
return dict(row) if row else None
def delete_broker_cash(broker: str) -> bool:
with _conn() as conn:
cur = conn.execute("DELETE FROM broker_cash WHERE broker = ?", (broker,))

View File

@@ -17,11 +17,11 @@ from .db import (
init_db, save_articles, get_latest_articles,
add_portfolio_item, get_all_portfolio, get_portfolio_item,
update_portfolio_item, delete_portfolio_item,
upsert_broker_cash, get_all_broker_cash, get_broker_cash, delete_broker_cash,
upsert_broker_cash, get_all_broker_cash, delete_broker_cash,
upsert_asset_snapshot, get_asset_snapshots,
add_sell_history, get_sell_history, update_sell_history, delete_sell_history,
)
from .scraper import fetch_market_news, fetch_major_indices, fetch_overseas_news
from .scraper import fetch_market_news, fetch_major_indices
from .price_fetcher import get_current_prices
app = FastAPI()
@@ -118,17 +118,11 @@ def on_startup():
def run_scraping_job():
logger.info("뉴스 스크래핑 시작")
# 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
logger.info(f"스크래핑 완료: 국내 {count_kr}건, 해외 {count_world}")
logger.info(f"스크래핑 완료: 국내 {count_kr}")
@app.get("/health")
def health():
@@ -156,14 +150,16 @@ def trigger_scrap():
def get_balance():
"""계좌 잔고 조회 (Windows AI Server Proxy)"""
logger.info(f"Requesting Balance from {WINDOWS_AI_SERVER_URL}")
resp = None
try:
resp = requests.get(f"{WINDOWS_AI_SERVER_URL}/trade/balance", timeout=5)
if resp.status_code != 200:
logger.error(f"Balance Error: {resp.status_code}")
return JSONResponse(status_code=resp.status_code, content=resp.json())
return resp.json()
except requests.JSONDecodeError:
return JSONResponse(status_code=resp.status_code, content={"error": f"Upstream error {resp.status_code}"})
except ValueError:
status = resp.status_code if resp is not None else 502
return JSONResponse(status_code=status, content={"error": f"Upstream error {status}"})
except Exception as e:
logger.error(f"Balance Connection Failed: {e}")
return JSONResponse(status_code=500, content={"error": "Connection Failed"})
@@ -179,14 +175,16 @@ class OrderRequest(BaseModel):
def order_stock(req: OrderRequest):
"""주식 매수/매도 주문 (Windows AI Server Proxy)"""
logger.info(f"Order Request: {req.action} {req.ticker} x{req.quantity}")
resp = None
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.model_dump(), timeout=10)
if resp.status_code != 200:
logger.error(f"Order Error: {resp.status_code}")
return JSONResponse(status_code=resp.status_code, content=resp.json())
return resp.json()
except requests.JSONDecodeError:
return JSONResponse(status_code=resp.status_code, content={"error": f"Upstream error {resp.status_code}"})
except ValueError:
status = resp.status_code if resp is not None else 502
return JSONResponse(status_code=status, content={"error": f"Upstream error {status}"})
except Exception as e:
logger.error(f"Order Connection Failed: {e}")
return JSONResponse(status_code=500, content={"error": "Connection Failed"})
@@ -368,7 +366,7 @@ def update_portfolio(item_id: int, req: PortfolioUpdateRequest):
"""포트폴리오 종목 수정"""
if get_portfolio_item(item_id) is None:
return JSONResponse(status_code=404, content={"error": "Item not found"})
update_portfolio_item(item_id, **req.dict())
update_portfolio_item(item_id, **req.model_dump())
return {"ok": True}

View File

@@ -79,59 +79,6 @@ def fetch_market_news() -> List[Dict[str, str]]:
logger.error(f"국내 뉴스 스크래핑 실패: {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:
logger.error(f"해외 뉴스 스크래핑 실패: {e}")
return []
def fetch_major_indices() -> Dict[str, Any]:
"""
KOSPI, KOSDAQ, KOSPI200 등 주요 지표 (네이버 금융 홈)