diff --git a/app/routers/dependencies.py b/app/routers/dependencies.py index 8454ff6a..d368f38c 100644 --- a/app/routers/dependencies.py +++ b/app/routers/dependencies.py @@ -2,13 +2,16 @@ Common router dependencies and constants. """ +from collections.abc import Callable + from fastapi import Depends, HTTPException, Request, status from sqlalchemy.ext.asyncio import AsyncSession from app.auth.constants import AUTH_REQUIRED_MESSAGE +from app.auth.role_hierarchy import has_min_role from app.auth.web_router import get_current_user_from_session from app.core.db import get_db -from app.models.trading import User +from app.models.trading import User, UserRole async def get_authenticated_user( @@ -27,3 +30,35 @@ async def get_authenticated_user( status_code=status.HTTP_401_UNAUTHORIZED, detail=AUTH_REQUIRED_MESSAGE, ) + + +def require_min_role_user(min_role: UserRole) -> Callable: + """Factory that creates a dependency requiring a minimum role. + + Returns a dependency that: + 1. Gets the authenticated user + 2. Checks if the user has at least the required role + 3. Returns the user if authorized, raises HTTPException(403) otherwise + + Usage: + require_trader_user = require_min_role_user(UserRole.trader) + + @router.get("/protected") + async def protected_route(user: User = Depends(require_trader_user)): + ... + """ + + async def _dependency(request: Request, db: AsyncSession = Depends(get_db)) -> User: + user = await get_authenticated_user(request, db) + if not has_min_role(user.role, min_role): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"이 기능에 접근하려면 '{min_role.value}' 이상의 권한이 필요합니다.", + ) + return user + + return _dependency + + +# Pre-configured dependencies for common role requirements +require_trader_user = require_min_role_user(UserRole.trader) diff --git a/app/routers/kis_domestic_trading.py b/app/routers/kis_domestic_trading.py index d641811e..a823efd2 100644 --- a/app/routers/kis_domestic_trading.py +++ b/app/routers/kis_domestic_trading.py @@ -3,6 +3,8 @@ - 보유 주식 조회 (KIS + 수동 잔고 통합) - AI 분석 실행 - 자동 매수/매도 주문 (Placeholder) + +접근 정책: trader, admin만 접근 가능 (viewer 차단) """ import logging @@ -14,7 +16,7 @@ from app.core.db import get_db from app.core.templates import templates from app.models.trading import User -from app.routers.dependencies import get_authenticated_user +from app.routers.dependencies import require_trader_user from app.services.kis import KISClient from app.services.merged_portfolio_service import MergedPortfolioService @@ -23,14 +25,15 @@ @router.get("/", response_class=HTMLResponse) -async def kis_domestic_trading_dashboard(request: Request): - """KIS 국내주식 자동 매매 대시보드 페이지""" - user = getattr(request.state, "user", None) +async def kis_domestic_trading_dashboard( + request: Request, current_user: User = Depends(require_trader_user) +): + """KIS 국내주식 자동 매매 대시보드 페이지 (trader/admin 전용)""" return templates.TemplateResponse( "kis_domestic_trading_dashboard.html", { "request": request, - "user": user, + "user": current_user, }, ) @@ -38,7 +41,7 @@ async def kis_domestic_trading_dashboard(request: Request): @router.get("/api/my-stocks") async def get_my_domestic_stocks( db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_authenticated_user), + current_user: User = Depends(require_trader_user), ): try: kis = KISClient() @@ -109,10 +112,12 @@ async def get_my_domestic_stocks( @router.post("/api/analyze-stocks") -async def analyze_my_domestic_stocks(): +async def analyze_my_domestic_stocks(current_user: User = Depends(require_trader_user)): """보유 국내 주식 AI 분석 실행 (Celery)""" try: - async_result = celery_app.send_task("kis.run_analysis_for_my_domestic_stocks") + async_result = celery_app.send_task( + "kis.run_analysis_for_my_domestic_stocks", args=[current_user.id] + ) return { "success": True, @@ -124,7 +129,9 @@ async def analyze_my_domestic_stocks(): @router.get("/api/analyze-task/{task_id}") -async def get_analyze_task_status(task_id: str): +async def get_analyze_task_status( + task_id: str, current_user: User = Depends(require_trader_user) +): """Celery 작업 상태 조회 API""" result = celery_app.AsyncResult(task_id) @@ -149,10 +156,12 @@ async def get_analyze_task_status(task_id: str): @router.post("/api/buy-orders") -async def execute_buy_orders(): +async def execute_buy_orders(current_user: User = Depends(require_trader_user)): """보유 국내 주식 자동 매수 주문 실행 (Celery)""" try: - async_result = celery_app.send_task("kis.execute_domestic_buy_orders") + async_result = celery_app.send_task( + "kis.execute_domestic_buy_orders", args=[current_user.id] + ) return { "success": True, "message": "매수 주문이 시작되었습니다.", @@ -163,10 +172,12 @@ async def execute_buy_orders(): @router.post("/api/sell-orders") -async def execute_sell_orders(): +async def execute_sell_orders(current_user: User = Depends(require_trader_user)): """보유 국내 주식 자동 매도 주문 실행 (Celery)""" try: - async_result = celery_app.send_task("kis.execute_domestic_sell_orders") + async_result = celery_app.send_task( + "kis.execute_domestic_sell_orders", args=[current_user.id] + ) return { "success": True, "message": "매도 주문이 시작되었습니다.", @@ -177,9 +188,11 @@ async def execute_sell_orders(): @router.post("/api/automation/per-stock") -async def run_per_stock_automation(): +async def run_per_stock_automation(current_user: User = Depends(require_trader_user)): """보유 종목별 자동 실행 (분석 -> 매수 -> 매도)""" - task = celery_app.send_task("kis.run_per_domestic_stock_automation") + task = celery_app.send_task( + "kis.run_per_domestic_stock_automation", args=[current_user.id] + ) return { "success": True, "message": "종목별 자동 실행이 시작되었습니다.", @@ -188,21 +201,27 @@ async def run_per_stock_automation(): @router.post("/api/analyze-stock/{symbol}") -async def analyze_stock(symbol: str): +async def analyze_stock(symbol: str, current_user: User = Depends(require_trader_user)): """단일 종목 분석 요청""" - task = celery_app.send_task("kis.analyze_domestic_stock_task", args=[symbol]) + task = celery_app.send_task( + "kis.analyze_domestic_stock_task", args=[symbol, current_user.id] + ) return {"success": True, "message": f"{symbol} 분석 요청 완료", "task_id": task.id} @router.post("/api/buy-order/{symbol}") -async def buy_order(symbol: str): +async def buy_order(symbol: str, current_user: User = Depends(require_trader_user)): """단일 종목 매수 요청""" - task = celery_app.send_task("kis.execute_domestic_buy_order_task", args=[symbol]) + task = celery_app.send_task( + "kis.execute_domestic_buy_order_task", args=[symbol, current_user.id] + ) return {"success": True, "message": f"{symbol} 매수 요청 완료", "task_id": task.id} @router.post("/api/sell-order/{symbol}") -async def sell_order(symbol: str): +async def sell_order(symbol: str, current_user: User = Depends(require_trader_user)): """단일 종목 매도 요청""" - task = celery_app.send_task("kis.execute_domestic_sell_order_task", args=[symbol]) + task = celery_app.send_task( + "kis.execute_domestic_sell_order_task", args=[symbol, current_user.id] + ) return {"success": True, "message": f"{symbol} 매도 요청 완료", "task_id": task.id} diff --git a/app/routers/kis_overseas_trading.py b/app/routers/kis_overseas_trading.py index 6c21cf8b..3bb13b6c 100644 --- a/app/routers/kis_overseas_trading.py +++ b/app/routers/kis_overseas_trading.py @@ -3,6 +3,8 @@ - 보유 주식 조회 (KIS + 수동 잔고 통합) - AI 분석 실행 - 자동 매수/매도 주문 (Placeholder) + +접근 정책: trader, admin만 접근 가능 (viewer 차단) """ import logging @@ -14,7 +16,7 @@ from app.core.db import get_db from app.core.templates import templates from app.models.trading import User -from app.routers.dependencies import get_authenticated_user +from app.routers.dependencies import require_trader_user from app.services.kis import KISClient from app.services.merged_portfolio_service import MergedPortfolioService @@ -23,14 +25,15 @@ @router.get("/", response_class=HTMLResponse) -async def kis_overseas_trading_dashboard(request: Request): - """KIS 해외주식 자동 매매 대시보드 페이지""" - user = getattr(request.state, "user", None) +async def kis_overseas_trading_dashboard( + request: Request, current_user: User = Depends(require_trader_user) +): + """KIS 해외주식 자동 매매 대시보드 페이지 (trader/admin 전용)""" return templates.TemplateResponse( "kis_overseas_trading_dashboard.html", { "request": request, - "user": user, + "user": current_user, }, ) @@ -76,7 +79,7 @@ def _orderable_amount(row: dict) -> float: @router.get("/api/my-stocks") async def get_my_overseas_stocks( db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_authenticated_user), + current_user: User = Depends(require_trader_user), ): try: kis = KISClient() @@ -148,10 +151,12 @@ async def get_my_overseas_stocks( @router.post("/api/analyze-stocks") -async def analyze_my_overseas_stocks(): +async def analyze_my_overseas_stocks(current_user: User = Depends(require_trader_user)): """보유 해외 주식 AI 분석 실행 (Celery)""" try: - async_result = celery_app.send_task("kis.run_analysis_for_my_overseas_stocks") + async_result = celery_app.send_task( + "kis.run_analysis_for_my_overseas_stocks", args=[current_user.id] + ) return { "success": True, @@ -163,7 +168,9 @@ async def analyze_my_overseas_stocks(): @router.get("/api/analyze-task/{task_id}") -async def get_analyze_task_status(task_id: str): +async def get_analyze_task_status( + task_id: str, current_user: User = Depends(require_trader_user) +): """Celery 작업 상태 조회 API""" result = celery_app.AsyncResult(task_id) @@ -188,10 +195,12 @@ async def get_analyze_task_status(task_id: str): @router.post("/api/buy-orders") -async def execute_buy_orders(): +async def execute_buy_orders(current_user: User = Depends(require_trader_user)): """보유 해외 주식 자동 매수 주문 실행 (Celery)""" try: - async_result = celery_app.send_task("kis.execute_overseas_buy_orders") + async_result = celery_app.send_task( + "kis.execute_overseas_buy_orders", args=[current_user.id] + ) return { "success": True, "message": "매수 주문이 시작되었습니다.", @@ -202,10 +211,12 @@ async def execute_buy_orders(): @router.post("/api/sell-orders") -async def execute_sell_orders(): +async def execute_sell_orders(current_user: User = Depends(require_trader_user)): """보유 해외 주식 자동 매도 주문 실행 (Celery)""" try: - async_result = celery_app.send_task("kis.execute_overseas_sell_orders") + async_result = celery_app.send_task( + "kis.execute_overseas_sell_orders", args=[current_user.id] + ) return { "success": True, "message": "매도 주문이 시작되었습니다.", @@ -216,9 +227,11 @@ async def execute_sell_orders(): @router.post("/api/automation/per-stock") -async def run_per_stock_automation(): +async def run_per_stock_automation(current_user: User = Depends(require_trader_user)): """보유 종목별 자동 실행 (분석 -> 매수 -> 매도)""" - task = celery_app.send_task("kis.run_per_overseas_stock_automation") + task = celery_app.send_task( + "kis.run_per_overseas_stock_automation", args=[current_user.id] + ) return { "success": True, "message": "종목별 자동 실행이 시작되었습니다.", @@ -227,21 +240,27 @@ async def run_per_stock_automation(): @router.post("/api/analyze-stock/{symbol}") -async def analyze_stock(symbol: str): +async def analyze_stock(symbol: str, current_user: User = Depends(require_trader_user)): """단일 종목 분석 요청""" - task = celery_app.send_task("kis.analyze_overseas_stock_task", args=[symbol]) + task = celery_app.send_task( + "kis.analyze_overseas_stock_task", args=[symbol, current_user.id] + ) return {"success": True, "message": f"{symbol} 분석 요청 완료", "task_id": task.id} @router.post("/api/buy-order/{symbol}") -async def buy_order(symbol: str): +async def buy_order(symbol: str, current_user: User = Depends(require_trader_user)): """단일 종목 매수 요청""" - task = celery_app.send_task("kis.execute_overseas_buy_order_task", args=[symbol]) + task = celery_app.send_task( + "kis.execute_overseas_buy_order_task", args=[symbol, current_user.id] + ) return {"success": True, "message": f"{symbol} 매수 요청 완료", "task_id": task.id} @router.post("/api/sell-order/{symbol}") -async def sell_order(symbol: str): +async def sell_order(symbol: str, current_user: User = Depends(require_trader_user)): """단일 종목 매도 요청""" - task = celery_app.send_task("kis.execute_overseas_sell_order_task", args=[symbol]) + task = celery_app.send_task( + "kis.execute_overseas_sell_order_task", args=[symbol, current_user.id] + ) return {"success": True, "message": f"{symbol} 매도 요청 완료", "task_id": task.id} diff --git a/app/tasks/kis.py b/app/tasks/kis.py index 99e9cdc2..72941cf5 100644 --- a/app/tasks/kis.py +++ b/app/tasks/kis.py @@ -164,12 +164,13 @@ async def _send_toss_recommendation_async( async def _analyze_domestic_stock_async( - code: str, progress_cb: ProgressCallback = None + code: str, progress_cb: ProgressCallback = None, user_id: int | None = None ) -> dict[str, object]: """단일 국내 주식 분석 비동기 헬퍼""" if not code: return {"status": "failed", "error": "종목 코드가 필요합니다."} + effective_user_id = user_id if user_id is not None else 1 kis = KISClient() analyzer = KISAnalyzer() @@ -224,9 +225,6 @@ async def _analyze_domestic_stock_async( ) async with AsyncSessionLocal() as db: - # USER_ID는 현재 1로 고정 (추후 다중 사용자 지원 시 변경 필요) - user_id = 1 - # 매수/매도 추천 가격 추출 recommended_buy_price = None recommended_sell_price = None @@ -235,17 +233,15 @@ async def _analyze_domestic_stock_async( if result.decision == "buy" and hasattr( result, "appropriate_buy_min" ): - # 4개 구간 중 가장 적절한 매수가 (appropriate_buy_min) recommended_buy_price = float(result.appropriate_buy_min) elif result.decision == "sell" and hasattr( result, "appropriate_sell_min" ): - # 4개 구간 중 가장 적절한 매도가 (appropriate_sell_min) recommended_sell_price = float(result.appropriate_sell_min) await send_toss_notification_if_needed( db=db, - user_id=user_id, + user_id=effective_user_id, ticker=code, name=name, market_type=MarketType.KR, @@ -271,7 +267,7 @@ async def _analyze_domestic_stock_async( @shared_task(name="kis.run_analysis_for_my_domestic_stocks", bind=True) -def run_analysis_for_my_domestic_stocks(self) -> dict: +def run_analysis_for_my_domestic_stocks(self, user_id: int | None = None) -> dict: """보유 국내 주식 AI 분석 실행""" async def _run() -> dict: @@ -356,7 +352,7 @@ async def _run() -> dict: @shared_task(name="kis.execute_domestic_buy_orders", bind=True) -def execute_domestic_buy_orders(self) -> dict: +def execute_domestic_buy_orders(self, user_id: int | None = None) -> dict: """국내 주식 자동 매수 주문 실행""" async def _run() -> dict: @@ -437,7 +433,7 @@ async def _run() -> dict: @shared_task(name="kis.execute_domestic_sell_orders", bind=True) -def execute_domestic_sell_orders(self) -> dict: +def execute_domestic_sell_orders(self, user_id: int | None = None) -> dict: """국내 주식 자동 매도 주문 실행""" async def _run() -> dict: @@ -583,7 +579,7 @@ async def _cancel_domestic_pending_orders( @shared_task(name="kis.run_per_domestic_stock_automation", bind=True) -def run_per_domestic_stock_automation(self) -> dict: +def run_per_domestic_stock_automation(self, user_id: int | None = None) -> dict: """국내 주식 종목별 자동 실행 (미체결취소 -> 분석 -> 매수 -> 매도)""" async def _run() -> dict: @@ -591,6 +587,7 @@ async def _run() -> dict: from app.models.manual_holdings import MarketType from app.services.manual_holdings_service import ManualHoldingsService + effective_user_id = user_id if user_id is not None else 1 kis = KISClient() analyzer = KISAnalyzer() @@ -606,10 +603,8 @@ async def _run() -> dict: # 2. 수동 잔고(토스 등) 국내 주식 조회 async with AsyncSessionLocal() as db: manual_service = ManualHoldingsService(db) - # USER_ID는 현재 1로 고정 (추후 다중 사용자 지원 시 변경 필요) - user_id = 1 manual_holdings = await manual_service.get_holdings_by_user( - user_id=user_id, market_type=MarketType.KR + user_id=effective_user_id, market_type=MarketType.KR ) # 3. 수동 잔고 종목을 한투 형식으로 변환하여 병합 @@ -1017,13 +1012,15 @@ async def _run() -> dict: @shared_task(name="kis.analyze_domestic_stock_task", bind=True) -def analyze_domestic_stock_task(self, symbol: str) -> dict: +def analyze_domestic_stock_task(self, symbol: str, user_id: int | None = None) -> dict: """단일 국내 주식 분석 실행""" - return asyncio.run(_analyze_domestic_stock_async(symbol)) + return asyncio.run(_analyze_domestic_stock_async(symbol, user_id=user_id)) @shared_task(name="kis.execute_domestic_buy_order_task", bind=True) -def execute_domestic_buy_order_task(self, symbol: str) -> dict: +def execute_domestic_buy_order_task( + self, symbol: str, user_id: int | None = None +) -> dict: """단일 국내 주식 매수 주문 실행""" async def _run() -> dict: @@ -1053,7 +1050,9 @@ async def _run() -> dict: @shared_task(name="kis.execute_domestic_sell_order_task", bind=True) -def execute_domestic_sell_order_task(self, symbol: str) -> dict: +def execute_domestic_sell_order_task( + self, symbol: str, user_id: int | None = None +) -> dict: """단일 국내 주식 매도 주문 실행""" async def _run() -> dict: @@ -1080,13 +1079,15 @@ async def _run() -> dict: @shared_task(name="kis.analyze_overseas_stock_task", bind=True) -def analyze_overseas_stock_task(self, symbol: str) -> dict: +def analyze_overseas_stock_task(self, symbol: str, user_id: int | None = None) -> dict: """단일 해외 주식 분석 실행""" - return asyncio.run(_analyze_overseas_stock_async(symbol)) + return asyncio.run(_analyze_overseas_stock_async(symbol, user_id=user_id)) @shared_task(name="kis.execute_overseas_buy_order_task", bind=True) -def execute_overseas_buy_order_task(self, symbol: str) -> dict: +def execute_overseas_buy_order_task( + self, symbol: str, user_id: int | None = None +) -> dict: """단일 해외 주식 매수 주문 실행""" async def _run() -> dict: @@ -1130,7 +1131,9 @@ async def _run() -> dict: @shared_task(name="kis.execute_overseas_sell_order_task", bind=True) -def execute_overseas_sell_order_task(self, symbol: str) -> dict: +def execute_overseas_sell_order_task( + self, symbol: str, user_id: int | None = None +) -> dict: """단일 해외 주식 매도 주문 실행""" async def _run() -> dict: @@ -1177,12 +1180,13 @@ async def _run() -> dict: async def _analyze_overseas_stock_async( - symbol: str, progress_cb: ProgressCallback = None + symbol: str, progress_cb: ProgressCallback = None, user_id: int | None = None ) -> dict[str, object]: """단일 해외 주식 분석 비동기 헬퍼""" if not symbol: return {"status": "failed", "error": "심볼이 필요합니다."} + effective_user_id = user_id if user_id is not None else 1 from app.analysis.service_analyzers import YahooAnalyzer from app.services import yahoo @@ -1244,9 +1248,6 @@ async def _analyze_overseas_stock_async( logger.warning(f"현재가 조회 실패 ({symbol}): {price_error}") async with AsyncSessionLocal() as db: - # USER_ID는 현재 1로 고정 (추후 다중 사용자 지원 시 변경 필요) - user_id = 1 - # 매수/매도 추천 가격 추출 recommended_buy_price = None recommended_sell_price = None @@ -1255,17 +1256,15 @@ async def _analyze_overseas_stock_async( if result.decision == "buy" and hasattr( result, "appropriate_buy_min" ): - # 4개 구간 중 가장 적절한 매수가 (appropriate_buy_min) recommended_buy_price = float(result.appropriate_buy_min) elif result.decision == "sell" and hasattr( result, "appropriate_sell_min" ): - # 4개 구간 중 가장 적절한 매도가 (appropriate_sell_min) recommended_sell_price = float(result.appropriate_sell_min) await send_toss_notification_if_needed( db=db, - user_id=user_id, + user_id=effective_user_id, ticker=symbol, name=symbol, market_type=MarketType.US, @@ -1290,7 +1289,7 @@ async def _analyze_overseas_stock_async( @shared_task(name="kis.run_analysis_for_my_overseas_stocks", bind=True) -def run_analysis_for_my_overseas_stocks(self) -> dict: +def run_analysis_for_my_overseas_stocks(self, user_id: int | None = None) -> dict: """보유 해외 주식 AI 분석 실행""" async def _run() -> dict: @@ -1383,7 +1382,7 @@ async def _run() -> dict: @shared_task(name="kis.execute_overseas_buy_orders", bind=True) -def execute_overseas_buy_orders(self) -> dict: +def execute_overseas_buy_orders(self, user_id: int | None = None) -> dict: """해외 주식 자동 매수 주문 실행""" async def _run() -> dict: @@ -1488,7 +1487,7 @@ async def _run() -> dict: @shared_task(name="kis.execute_overseas_sell_orders", bind=True) -def execute_overseas_sell_orders(self) -> dict: +def execute_overseas_sell_orders(self, user_id: int | None = None) -> dict: """해외 주식 자동 매도 주문 실행""" async def _run() -> dict: @@ -1671,7 +1670,7 @@ async def _cancel_overseas_pending_orders( @shared_task(name="kis.run_per_overseas_stock_automation", bind=True) -def run_per_overseas_stock_automation(self) -> dict: +def run_per_overseas_stock_automation(self, user_id: int | None = None) -> dict: """해외 주식 종목별 자동 실행 (미체결취소 -> 분석 -> 매수 -> 매도)""" async def _run() -> dict: @@ -1679,6 +1678,7 @@ async def _run() -> dict: from app.models.manual_holdings import MarketType from app.services.manual_holdings_service import ManualHoldingsService + effective_user_id = user_id if user_id is not None else 1 kis = KISClient() from app.analysis.service_analyzers import YahooAnalyzer @@ -1696,9 +1696,8 @@ async def _run() -> dict: # 2. 수동 잔고(토스 등) 해외 주식 조회 async with AsyncSessionLocal() as db: manual_service = ManualHoldingsService(db) - user_id = 1 # USER_ID는 현재 1로 고정 manual_holdings = await manual_service.get_holdings_by_user( - user_id=user_id, market_type=MarketType.US + user_id=effective_user_id, market_type=MarketType.US ) # 3. 수동 잔고 종목을 KIS 형식으로 변환하여 병합 diff --git a/app/templates/nav.html b/app/templates/nav.html index 3fe1e559..b26490bf 100644 --- a/app/templates/nav.html +++ b/app/templates/nav.html @@ -25,6 +25,7 @@ Upbit 거래 + {% if user and user.role and user.role.value in ('trader', 'admin') %}