From c77c28201909a9392244d2e8a7129a7238a2b809 Mon Sep 17 00:00:00 2001 From: goblurry Date: Mon, 17 Nov 2025 00:58:06 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=9D=B4=EB=A1=A0=ED=95=99?= =?UTF-8?q?=EC=8A=B5=20=EC=B9=B4=EB=93=9C=20API=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/main.py | 8 +- app/models/learning.py | 62 ++++ app/routers/__init__.py | 3 +- app/routers/learning.py | 297 ++++++++++++++++++ app/services/learning/__init__.py | 2 + .../learning/learning_cards_repository.py | 275 ++++++++++++++++ 6 files changed, 645 insertions(+), 2 deletions(-) create mode 100644 app/models/learning.py create mode 100644 app/routers/learning.py create mode 100644 app/services/learning/__init__.py create mode 100644 app/services/learning/learning_cards_repository.py diff --git a/app/main.py b/app/main.py index 7c7eb75..fde19ef 100644 --- a/app/main.py +++ b/app/main.py @@ -11,8 +11,9 @@ from apscheduler.triggers.cron import CronTrigger from fastapi import HTTPException from app.config import get_settings -from app.routers import report_router, news_router +from app.routers import report_router, news_router, learning_router from app.routers.news import init_services as init_news_services +from app.routers.learning import init_repository as init_learning_repository from app.services.database import DatabaseService from app.services.analysis import LiquidStocksService, CorrelationService from app.services.news import NewsColumnService @@ -155,6 +156,10 @@ async def lifespan(app: FastAPI): init_news_services(db_service, liquid_stocks_service, correlation_service, news_column_service, limiter) logger.info("news_router_initialized") + # Learning 카드 Repository 초기화 + init_learning_repository(db_service) + logger.info("learning_repository_initialized") + # 앱 상태에 DB 서비스 저장 app.state.db_service = db_service logger.info("db_service_stored_in_app_state") @@ -286,6 +291,7 @@ async def global_exception_handler(request: Request, exc: Exception): # 라우터 등록 app.include_router(report_router) app.include_router(news_router) +app.include_router(learning_router) @app.get( diff --git a/app/models/learning.py b/app/models/learning.py new file mode 100644 index 0000000..9ca4696 --- /dev/null +++ b/app/models/learning.py @@ -0,0 +1,62 @@ +from typing import Optional, List, Any, Dict +from pydantic import BaseModel, Field, ConfigDict + + +class LearningCardBase(BaseModel): + """학습 카드 기본 모델""" + title: str = Field(..., description="카드 제목") + description: str = Field(..., description="카드 설명") + contents: Any = Field(..., description="카드 내용 (JSON 또는 문자열)") + category: str = Field(..., description="카테고리") + level: int = Field(..., description="레벨 (1-5)", ge=1, le=5) + keywords: Optional[List[str]] = Field(None, description="키워드 리스트") + image_urls: Optional[List[str]] = Field(None, description="이미지 URL 리스트") + + +class LearningCardCreate(LearningCardBase): + """학습 카드 생성 요청 모델""" + pass + + +class LearningCardUpdate(BaseModel): + """학습 카드 수정 요청 모델""" + title: Optional[str] = Field(None, description="카드 제목") + description: Optional[str] = Field(None, description="카드 설명") + contents: Optional[Any] = Field(None, description="카드 내용") + category: Optional[str] = Field(None, description="카테고리") + level: Optional[int] = Field(None, description="레벨", ge=1, le=5) + keywords: Optional[List[str]] = Field(None, description="키워드 리스트") + image_urls: Optional[List[str]] = Field(None, description="이미지 URL 리스트") + + +class LearningCardResponse(BaseModel): + """학습 카드 응답 모델""" + model_config = ConfigDict( + populate_by_name=True, + from_attributes=True + ) + + id: int = Field(..., description="카드 ID") + title: str = Field(..., description="카드 제목") + description: str = Field(..., description="카드 설명") + contents: Any = Field(..., description="카드 내용") + category: str = Field(..., description="카테고리") + level: int = Field(..., description="레벨") + keywords: Optional[List[str]] = Field(None, description="키워드 리스트") + image_urls: Optional[List[str]] = Field(None, description="이미지 URL 리스트") + created_at: str = Field(..., description="생성 시간") + updated_at: str = Field(..., description="수정 시간") + + +class LearningCardsListResponse(BaseModel): + """학습 카드 목록 응답 모델""" + success: bool = Field(..., description="성공 여부") + total_count: int = Field(..., description="전체 개수") + cards: List[LearningCardResponse] = Field(..., description="카드 목록") + + +class LearningCardDetailResponse(BaseModel): + """학습 카드 상세 응답 모델""" + success: bool = Field(..., description="성공 여부") + card: LearningCardResponse = Field(..., description="카드 정보") + diff --git a/app/routers/__init__.py b/app/routers/__init__.py index 43cb9eb..6fb7d05 100644 --- a/app/routers/__init__.py +++ b/app/routers/__init__.py @@ -1,5 +1,6 @@ from .report import router as report_router from .news import router as news_router +from .learning import router as learning_router -__all__ = ["report_router", "news_router"] +__all__ = ["report_router", "news_router", "learning_router"] diff --git a/app/routers/learning.py b/app/routers/learning.py new file mode 100644 index 0000000..c5d5371 --- /dev/null +++ b/app/routers/learning.py @@ -0,0 +1,297 @@ +# 이론학습 카드 API 라우터 +from typing import Optional +from fastapi import APIRouter, HTTPException, status, Request +import structlog + +from app.services.database import DatabaseService +from app.services.learning.learning_cards_repository import LearningCardsRepository +from app.models.learning import ( + LearningCardCreate, + LearningCardUpdate, + LearningCardResponse, + LearningCardsListResponse, + LearningCardDetailResponse +) + +logger = structlog.get_logger() + +# 라우터 생성 +router = APIRouter(prefix="/learning-cards", tags=["learning-cards"]) + +# Repository 인스턴스 (main.py에서 초기화) +repository: Optional[LearningCardsRepository] = None + + +def init_repository(db_service: DatabaseService): + """Repository 초기화 (main.py에서 호출)""" + global repository + repository = LearningCardsRepository(db_service) + + +def get_repository() -> LearningCardsRepository: + """Repository 가져오기""" + if not repository: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="학습 카드 서비스가 초기화되지 않았습니다." + ) + return repository + + +@router.get( + "", + response_model=LearningCardsListResponse, + summary="학습 카드 목록 조회", + description="전체 학습 카드 목록을 조회합니다. 카테고리, 레벨로 필터링 가능합니다.", +) +async def get_learning_cards( + category: Optional[str] = None, + level: Optional[int] = None, + limit: Optional[int] = None, + request: Request = None +) -> LearningCardsListResponse: + """ + 학습 카드 목록 조회 + + Args: + category: 카테고리 필터 (예: "투자기초", "시스템") + level: 레벨 필터 (1-5) + limit: 조회할 최대 개수 + + Returns: + 학습 카드 목록 + """ + try: + repo = get_repository() + cards = await repo.get_all_cards( + category=category, + level=level, + limit=limit + ) + + return LearningCardsListResponse( + success=True, + total_count=len(cards), + cards=cards + ) + + except HTTPException: + raise + except Exception as e: + logger.error("get_learning_cards_failed", error=str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"학습 카드 목록 조회 실패: {str(e)}" + ) + + +@router.get( + "/{card_id}", + response_model=LearningCardDetailResponse, + summary="학습 카드 상세 조회", + description="특정 ID의 학습 카드를 조회합니다.", +) +async def get_learning_card( + card_id: int, + request: Request = None +) -> LearningCardDetailResponse: + """ + 학습 카드 상세 조회 + + Args: + card_id: 카드 ID + + Returns: + 학습 카드 상세 정보 + """ + try: + repo = get_repository() + card = await repo.get_card(card_id) + + if not card: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"ID {card_id}의 학습 카드를 찾을 수 없습니다." + ) + + return LearningCardDetailResponse( + success=True, + card=card + ) + + except HTTPException: + raise + except Exception as e: + logger.error("get_learning_card_failed", card_id=card_id, error=str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"학습 카드 조회 실패: {str(e)}" + ) + + +@router.post( + "", + response_model=LearningCardDetailResponse, + status_code=status.HTTP_201_CREATED, + summary="학습 카드 생성", + description="새로운 학습 카드를 생성합니다. (관리자용)", +) +async def create_learning_card( + card_data: LearningCardCreate, + request: Request = None +) -> LearningCardDetailResponse: + """ + 학습 카드 생성 + + Args: + card_data: 카드 생성 데이터 + + Returns: + 생성된 학습 카드 정보 + """ + try: + repo = get_repository() + card_id = await repo.create_card(card_data.model_dump()) + + if not card_id: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="학습 카드 생성에 실패했습니다." + ) + + card = await repo.get_card(card_id) + if not card: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="생성된 학습 카드를 조회할 수 없습니다." + ) + + return LearningCardDetailResponse( + success=True, + card=card + ) + + except HTTPException: + raise + except Exception as e: + logger.error("create_learning_card_failed", error=str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"학습 카드 생성 실패: {str(e)}" + ) + + +@router.put( + "/{card_id}", + response_model=LearningCardDetailResponse, + summary="학습 카드 수정", + description="기존 학습 카드를 수정합니다. (관리자용)", +) +async def update_learning_card( + card_id: int, + card_data: LearningCardUpdate, + request: Request = None +) -> LearningCardDetailResponse: + """ + 학습 카드 수정 + + Args: + card_id: 카드 ID + card_data: 수정할 카드 데이터 (None인 필드는 수정하지 않음) + + Returns: + 수정된 학습 카드 정보 + """ + try: + repo = get_repository() + + # 카드 존재 확인 + existing_card = await repo.get_card(card_id) + if not existing_card: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"ID {card_id}의 학습 카드를 찾을 수 없습니다." + ) + + # None이 아닌 필드만 업데이트 + update_data = {k: v for k, v in card_data.model_dump().items() if v is not None} + + success = await repo.update_card(card_id, update_data) + if not success: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="학습 카드 수정에 실패했습니다." + ) + + card = await repo.get_card(card_id) + if not card: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="수정된 학습 카드를 조회할 수 없습니다." + ) + + return LearningCardDetailResponse( + success=True, + card=card + ) + + except HTTPException: + raise + except Exception as e: + logger.error("update_learning_card_failed", card_id=card_id, error=str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"학습 카드 수정 실패: {str(e)}" + ) + + +@router.delete( + "/{card_id}", + summary="학습 카드 삭제", + description="학습 카드를 삭제합니다. (관리자용)", +) +async def delete_learning_card( + card_id: int, + request: Request = None +) -> dict: + """ + 학습 카드 삭제 + + Args: + card_id: 카드 ID + + Returns: + 삭제 성공 메시지 + """ + try: + repo = get_repository() + + # 카드 존재 확인 + existing_card = await repo.get_card(card_id) + if not existing_card: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"ID {card_id}의 학습 카드를 찾을 수 없습니다." + ) + + success = await repo.delete_card(card_id) + if not success: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="학습 카드 삭제에 실패했습니다." + ) + + return { + "success": True, + "message": f"ID {card_id}의 학습 카드가 삭제되었습니다." + } + + except HTTPException: + raise + except Exception as e: + logger.error("delete_learning_card_failed", card_id=card_id, error=str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"학습 카드 삭제 실패: {str(e)}" + ) + diff --git a/app/services/learning/__init__.py b/app/services/learning/__init__.py new file mode 100644 index 0000000..245f4e4 --- /dev/null +++ b/app/services/learning/__init__.py @@ -0,0 +1,2 @@ +# Learning 서비스 모듈 + diff --git a/app/services/learning/learning_cards_repository.py b/app/services/learning/learning_cards_repository.py new file mode 100644 index 0000000..275312b --- /dev/null +++ b/app/services/learning/learning_cards_repository.py @@ -0,0 +1,275 @@ +import json +import structlog +from typing import Optional, Dict, Any, List +from app.services.database import DatabaseService + +# 이론학습 카드 DB Repository: 학습 카드 데이터 저장/조회 +logger = structlog.get_logger() + + +class LearningCardsRepository: + + def __init__(self, db_service: DatabaseService): + self.db_service = db_service + + # 전체 카드 조회 (필터링 옵션) + async def get_all_cards( + self, + category: Optional[str] = None, + level: Optional[int] = None, + limit: Optional[int] = None + ) -> List[Dict[str, Any]]: + try: + query = """ + SELECT id, title, description, contents, category, level, + keywords, image_urls, created_at, updated_at + FROM learning_cards + WHERE 1=1 + """ + params = [] + param_index = 1 + + if category: + query += f" AND category = ${param_index}" + params.append(category) + param_index += 1 + + if level is not None: + query += f" AND level = ${param_index}" + params.append(level) + param_index += 1 + + query += " ORDER BY level ASC, id ASC" + + if limit: + query += f" LIMIT ${param_index}" + params.append(limit) + + rows = await self.db_service.fetch(query, *params) + + results = [] + for row in rows: + # contents가 JSON 문자열인 경우 파싱 + contents = row["contents"] + if isinstance(contents, str): + try: + contents = json.loads(contents) + except json.JSONDecodeError: + pass + + # keywords가 JSON 문자열인 경우 파싱 + keywords = row["keywords"] + if isinstance(keywords, str): + try: + keywords = json.loads(keywords) + except json.JSONDecodeError: + pass + + # image_urls가 JSON 문자열인 경우 파싱 + image_urls = row["image_urls"] + if isinstance(image_urls, str): + try: + image_urls = json.loads(image_urls) + except json.JSONDecodeError: + pass + + results.append({ + "id": row["id"], + "title": row["title"], + "description": row["description"], + "contents": contents, + "category": row["category"], + "level": row["level"], + "keywords": keywords, + "image_urls": image_urls, + "created_at": str(row["created_at"]), + "updated_at": str(row["updated_at"]) + }) + + return results + + except Exception as e: + logger.error("get_all_cards_failed", error=str(e)) + return [] + + # 특정 카드 조회 + async def get_card(self, card_id: int) -> Optional[Dict[str, Any]]: + try: + query = """ + SELECT id, title, description, contents, category, level, + keywords, image_urls, created_at, updated_at + FROM learning_cards + WHERE id = $1 + """ + + row = await self.db_service.fetchrow(query, card_id) + + if not row: + return None + + # contents가 JSON 문자열인 경우 파싱 + contents = row["contents"] + if isinstance(contents, str): + try: + contents = json.loads(contents) + except json.JSONDecodeError: + pass + + # keywords가 JSON 문자열인 경우 파싱 + keywords = row["keywords"] + if isinstance(keywords, str): + try: + keywords = json.loads(keywords) + except json.JSONDecodeError: + pass + + # image_urls가 JSON 문자열인 경우 파싱 + image_urls = row["image_urls"] + if isinstance(image_urls, str): + try: + image_urls = json.loads(image_urls) + except json.JSONDecodeError: + pass + + return { + "id": row["id"], + "title": row["title"], + "description": row["description"], + "contents": contents, + "category": row["category"], + "level": row["level"], + "keywords": keywords, + "image_urls": image_urls, + "created_at": str(row["created_at"]), + "updated_at": str(row["updated_at"]) + } + + except Exception as e: + logger.error("get_card_failed", card_id=card_id, error=str(e)) + return None + + # 카드 생성 + async def create_card(self, card_data: Dict[str, Any]) -> Optional[int]: + try: + title = card_data.get("title") + description = card_data.get("description") + contents = card_data.get("contents") + category = card_data.get("category") + level = card_data.get("level") + keywords = card_data.get("keywords") + image_urls = card_data.get("image_urls") + + # contents를 JSON 문자열로 변환 + if contents and not isinstance(contents, str): + contents = json.dumps(contents, ensure_ascii=False) + + # keywords를 JSON 문자열로 변환 + if keywords and not isinstance(keywords, str): + keywords = json.dumps(keywords, ensure_ascii=False) + + # image_urls를 JSON 문자열로 변환 + if image_urls and not isinstance(image_urls, str): + image_urls = json.dumps(image_urls, ensure_ascii=False) + + query = """ + INSERT INTO learning_cards ( + title, description, contents, category, level, + keywords, image_urls, created_at, updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW()) + RETURNING id + """ + + card_id = await self.db_service.fetchval( + query, + title, + description, + contents, + category, + level, + keywords, + image_urls + ) + + logger.info("card_created", card_id=card_id, title=title[:30] if title else "") + return card_id + + except Exception as e: + logger.error( + "create_card_failed", + error=str(e), + error_type=type(e).__name__ + ) + return None + + # 카드 수정 + async def update_card(self, card_id: int, card_data: Dict[str, Any]) -> bool: + try: + title = card_data.get("title") + description = card_data.get("description") + contents = card_data.get("contents") + category = card_data.get("category") + level = card_data.get("level") + keywords = card_data.get("keywords") + image_urls = card_data.get("image_urls") + + # contents를 JSON 문자열로 변환 + if contents and not isinstance(contents, str): + contents = json.dumps(contents, ensure_ascii=False) + + # keywords를 JSON 문자열로 변환 + if keywords and not isinstance(keywords, str): + keywords = json.dumps(keywords, ensure_ascii=False) + + # image_urls를 JSON 문자열로 변환 + if image_urls and not isinstance(image_urls, str): + image_urls = json.dumps(image_urls, ensure_ascii=False) + + query = """ + UPDATE learning_cards + SET title = COALESCE($1, title), + description = COALESCE($2, description), + contents = COALESCE($3, contents), + category = COALESCE($4, category), + level = COALESCE($5, level), + keywords = COALESCE($6, keywords), + image_urls = COALESCE($7, image_urls), + updated_at = NOW() + WHERE id = $8 + """ + + await self.db_service.execute( + query, + title, + description, + contents, + category, + level, + keywords, + image_urls, + card_id + ) + + logger.info("card_updated", card_id=card_id) + return True + + except Exception as e: + logger.error( + "update_card_failed", + card_id=card_id, + error=str(e), + error_type=type(e).__name__ + ) + return False + + # 카드 삭제 + async def delete_card(self, card_id: int) -> bool: + try: + query = "DELETE FROM learning_cards WHERE id = $1" + await self.db_service.execute(query, card_id) + logger.info("card_deleted", card_id=card_id) + return True + except Exception as e: + logger.error("delete_card_failed", card_id=card_id, error=str(e)) + return False + From c0289ceac78c2b944e930d744e06389468706604 Mon Sep 17 00:00:00 2001 From: goblurry Date: Mon, 17 Nov 2025 01:02:22 +0900 Subject: [PATCH 2/2] =?UTF-8?q?chore:=20=EB=B2=84=EC=A0=84=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8=201.3.3=20->=201.3.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/config.py b/app/config.py index 9cada9b..954be02 100644 --- a/app/config.py +++ b/app/config.py @@ -41,7 +41,7 @@ class Settings(BaseSettings): # 애플리케이션 버전 # deploy브랜치로 병합할 때 - app_version: str = "1.3.3" + app_version: str = "1.3.4" @property def allowed_origins_list(self) -> list[str]: