From 535ffea45a8dc02b4ebb15f11ac08137d990aa0d Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 7 Apr 2026 04:10:14 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EC=A0=84=EC=B2=B4=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=EB=B2=A0=EC=9D=B4=EC=8A=A4=20=EA=B0=90=EC=82=AC=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20?= =?UTF-8?q?=E2=80=94=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95,=20?= =?UTF-8?q?=EB=8D=B0=EB=93=9C=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0,=20?= =?UTF-8?q?=EB=B3=B4=EC=95=88=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/db.py | 37 ++++++++++++++------------ backend/app/generator.py | 23 ++-------------- backend/app/recommender.py | 32 +++------------------- backend/app/utils.py | 21 +++++++++++++++ blog-lab/Dockerfile | 1 + blog-lab/app/main.py | 8 +++--- blog-lab/app/web_crawler.py | 2 -- deployer/app.py | 10 ++++++- docker-compose.yml | 2 +- music-lab/Dockerfile | 1 + nginx/default.conf | 5 ++++ realestate-lab/Dockerfile | 1 + stock-lab/app/db.py | 6 ----- stock-lab/app/main.py | 32 +++++++++++----------- stock-lab/app/scraper.py | 53 ------------------------------------- 15 files changed, 84 insertions(+), 150 deletions(-) diff --git a/backend/app/db.py b/backend/app/db.py index 7d01721..b6e7774 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -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), ) diff --git a/backend/app/generator.py b/backend/app/generator.py index c0dffde..3fac585 100644 --- a/backend/app/generator.py +++ b/backend/app/generator.py @@ -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 diff --git a/backend/app/recommender.py b/backend/app/recommender.py index 7f53a8b..609b885 100644 --- a/backend/app/recommender.py +++ b/backend/app/recommender.py @@ -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 = { diff --git a/backend/app/utils.py b/backend/app/utils.py index 07ddd98..73a2c70 100644 --- a/backend/app/utils.py +++ b/backend/app/utils.py @@ -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) diff --git a/blog-lab/Dockerfile b/blog-lab/Dockerfile index d92b17c..0481198 100644 --- a/blog-lab/Dockerfile +++ b/blog-lab/Dockerfile @@ -1,4 +1,5 @@ FROM python:3.12-alpine +ENV PYTHONUNBUFFERED=1 WORKDIR /app diff --git a/blog-lab/app/main.py b/blog-lab/app/main.py index 3d9fc5c..a932731 100644 --- a/blog-lab/app/main.py +++ b/blog-lab/app/main.py @@ -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)} diff --git a/blog-lab/app/web_crawler.py b/blog-lab/app/web_crawler.py index 0927a6a..2bbd139 100644 --- a/blog-lab/app/web_crawler.py +++ b/blog-lab/app/web_crawler.py @@ -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 diff --git a/deployer/app.py b/deployer/app.py index 05e0563..90166b6 100644 --- a/deployer/app.py +++ b/deployer/app.py @@ -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"} diff --git a/docker-compose.yml b/docker-compose.yml index 2d53f90..55c47a3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/music-lab/Dockerfile b/music-lab/Dockerfile index 4345e2e..c05ee7c 100644 --- a/music-lab/Dockerfile +++ b/music-lab/Dockerfile @@ -1,4 +1,5 @@ FROM python:3.12-alpine +ENV PYTHONUNBUFFERED=1 WORKDIR /app COPY requirements.txt . diff --git a/nginx/default.conf b/nginx/default.conf index 5284dc3..8123e20 100644 --- a/nginx/default.conf +++ b/nginx/default.conf @@ -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; diff --git a/realestate-lab/Dockerfile b/realestate-lab/Dockerfile index 4345e2e..c05ee7c 100644 --- a/realestate-lab/Dockerfile +++ b/realestate-lab/Dockerfile @@ -1,4 +1,5 @@ FROM python:3.12-alpine +ENV PYTHONUNBUFFERED=1 WORKDIR /app COPY requirements.txt . diff --git a/stock-lab/app/db.py b/stock-lab/app/db.py index 3c7b4e5..fbb6e4e 100644 --- a/stock-lab/app/db.py +++ b/stock-lab/app/db.py @@ -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,)) diff --git a/stock-lab/app/main.py b/stock-lab/app/main.py index 39efd19..fea0eb0 100644 --- a/stock-lab/app/main.py +++ b/stock-lab/app/main.py @@ -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} diff --git a/stock-lab/app/scraper.py b/stock-lab/app/scraper.py index b684c4d..4b6bbf7 100644 --- a/stock-lab/app/scraper.py +++ b/stock-lab/app/scraper.py @@ -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 등 주요 지표 (네이버 금융 홈)