From 8c9b4abc72caa8eb1f150527d1977eb5c45060a8 Mon Sep 17 00:00:00 2001 From: shuim Date: Tue, 24 Feb 2026 17:36:24 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9E=90=EC=82=B0API=20DB=EC=BA=90=EC=89=AC=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +- backend/app/api/endpoints/portfolio.py | 75 +++++++++++++++++++ docker-compose.yml | 1 + frontend/src/components/PortfolioPanel.tsx | 8 +- .../src/components/RecommendationPanel.tsx | 8 +- frontend/src/pages/Dashboard.tsx | 65 ++++++---------- 6 files changed, 116 insertions(+), 45 deletions(-) diff --git a/.gitignore b/.gitignore index cd1dfec..fcfeb48 100644 --- a/.gitignore +++ b/.gitignore @@ -52,7 +52,9 @@ build/ *.db *.sqlite *.sqlite3 - +*.sql +*.sql.gz +*.dump # Logs logs/ *.log diff --git a/backend/app/api/endpoints/portfolio.py b/backend/app/api/endpoints/portfolio.py index 4fdcf49..597e882 100644 --- a/backend/app/api/endpoints/portfolio.py +++ b/backend/app/api/endpoints/portfolio.py @@ -488,6 +488,81 @@ async def get_portfolio_summary(db: AsyncSession = Depends(get_db)): }) return resp +async def _sync_portfolio_background(): + """백그라운드 포트폴리오 KIS 동기화 (비차단). 이미 동기화 중이면 건너뜀.""" + if portfolio_cache.sync_lock.locked(): + return + try: + async with AsyncSessionLocal() as db: + await sync_portfolio(db, force=False) + except Exception as e: + logger.debug(f"Background portfolio sync: {e}") + + +def _build_combined_resp() -> dict: + """portfolio_cache에서 combined 응답 딕셔너리 생성""" + base = portfolio_cache.summary or { + "total_stocks": 0, "total_value": 0, "total_investment": 0, + "total_profit_loss": 0, "total_profit_loss_rate": 0, + "available_cash": 0, "total_assets": 0, + } + resp = dict(base) + resp.update({ + "sync_status": "ok" if portfolio_cache.last_error is None else "error", + "last_synced": portfolio_cache.last_synced.isoformat() if portfolio_cache.last_synced else None, + "last_error": portfolio_cache.last_error, + }) + return { + "holdings": [h for h in portfolio_cache.holdings if h.quantity > 0], + "summary": resp, + } + + +@router.get("/combined") +async def get_portfolio_combined(db: AsyncSession = Depends(get_db)): + """보유종목 목록 + 요약을 반환. + + 응답 우선순위: + ① 신선한 캐시 → 즉시 반환 + ② 캐시 만료 + 매매 변경 없음 → 기존 캐시 즉시 반환 + 백그라운드 KIS 갱신 + ③ 서버 재시작 등 캐시 비어있음 → DB 즉시 반환 + 백그라운드 KIS 갱신 + ④ 매매 발생(change_flag) 또는 DB도 비어있음 → KIS API 동기 호출 (필수) + """ + latest_trade_id = await _get_latest_executed_trade_id(db) + if latest_trade_id and portfolio_cache.last_trade_id != latest_trade_id: + portfolio_cache.mark_changed() + + # ① 신선한 캐시 + if portfolio_cache.holdings and portfolio_cache.summary and not portfolio_cache.should_sync(): + logger.debug("Portfolio cache hit (combined)") + return _build_combined_resp() + + # ② 캐시 만료, 매매 변경 없음 → stale 데이터 즉시 반환 + 백그라운드 갱신 + if portfolio_cache.holdings and portfolio_cache.summary and not portfolio_cache.change_flag: + logger.debug("Portfolio cache stale (no trade) → returning cached, syncing in background") + asyncio.create_task(_sync_portfolio_background()) + return _build_combined_resp() + + # ③ 캐시 비어있음 (서버 재시작) → DB에서 즉시 로드 + 백그라운드 KIS 갱신 + if not portfolio_cache.holdings: + result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0)) + db_holdings = list(result.scalars().all()) + if db_holdings: + portfolio_cache.holdings = db_holdings + portfolio_cache.cache_summary() # available_cash=0이지만 holdings는 즉시 표시 + logger.info(f"Portfolio cold-start: DB {len(db_holdings)}종목 즉시 반환, KIS 갱신 백그라운드 시작") + asyncio.create_task(_sync_portfolio_background()) + return _build_combined_resp() + + # ④ 매매 발생 또는 DB도 비어있음 → KIS API 동기 호출 + try: + await sync_portfolio(db, force=False) + except HTTPException: + logger.warning("get_portfolio_combined: sync failed, returning cached data") + + return _build_combined_resp() + + @router.post("/refresh") async def refresh_portfolio(db: AsyncSession = Depends(get_db)): """강제 캐시 무효화 및 재동기화""" diff --git a/docker-compose.yml b/docker-compose.yml index 2733263..66638b8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -111,6 +111,7 @@ services: volumes: - ./backend:/app - /app/__pycache__ + - ./logs:/logs networks: - autostock-network depends_on: diff --git a/frontend/src/components/PortfolioPanel.tsx b/frontend/src/components/PortfolioPanel.tsx index 8a38fe7..336b965 100644 --- a/frontend/src/components/PortfolioPanel.tsx +++ b/frontend/src/components/PortfolioPanel.tsx @@ -56,7 +56,13 @@ const PortfolioPanel = React.memo(({ className={`w-full p-3 hover:bg-gray-700/30 transition-all duration-200 group ${selectedStock === stock.stock_code ? 'bg-blue-500/10 border-l-2 border-blue-500' : 'border-l-2 border-transparent'}`} >
{ onSelectStock(stock.stock_code); onRefetchAiAnalysis() }} + onClick={() => { + if (selectedStock === stock.stock_code) { + onRefetchAiAnalysis() // 같은 종목 재클릭 → 강제 새로고침 + } else { + onSelectStock(stock.stock_code) // 새 종목 → queryKey 변경으로 자동 fetch + } + }} className="cursor-pointer" >
diff --git a/frontend/src/components/RecommendationPanel.tsx b/frontend/src/components/RecommendationPanel.tsx index d6b29f5..efee1b4 100644 --- a/frontend/src/components/RecommendationPanel.tsx +++ b/frontend/src/components/RecommendationPanel.tsx @@ -77,7 +77,13 @@ const RecommendationPanel = React.memo(({ return (