diff --git a/.gitignore b/.gitignore index fdef7e6..d1fdf59 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,7 @@ *.pyd # 가상환경 -venv \ No newline at end of file +venv + +# .env 파일 +/.env \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db/database.py b/app/db/database.py new file mode 100644 index 0000000..db20b68 --- /dev/null +++ b/app/db/database.py @@ -0,0 +1,44 @@ +# app/db/database.py +import os + +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from dotenv import load_dotenv + +# 환경 변수 로드 +load_dotenv() + +# 데이터베이스 연결 정보 +RDS_HOST = os.getenv("RDS_HOST") +RDS_PORT = os.getenv("RDS_PORT", "3306") +RDS_USER = os.getenv("RDS_USER") +RDS_PASSWORD = os.getenv("RDS_PASSWORD") +RDS_DB = os.getenv("RDS_DB") + +# 모든 필수 환경 변수가 설정되었는지 확인 +if not all([RDS_HOST, RDS_USER, RDS_PASSWORD, RDS_DB]): + raise ValueError("RDS 연결 정보가 불완전합니다. 환경 변수를 확인하세요.") + +# 연결 문자열 구성 +DATABASE_URL = f"mysql+pymysql://{RDS_USER}:{RDS_PASSWORD}@{RDS_HOST}:{RDS_PORT}/{RDS_DB}" + +# 엔진 생성 +engine = create_engine( + DATABASE_URL, + pool_size=5, + max_overflow=10, + pool_timeout=30, + pool_recycle=1800, +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + +# DB 세션 의존성 +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/app/main.py b/app/main.py index 069cdb6..8b979ea 100644 --- a/app/main.py +++ b/app/main.py @@ -1,8 +1,9 @@ from fastapi import FastAPI -from app.routers import stock, portfolio +from app.routers import member_route, stock, portfolio app = FastAPI() # 라우터 등록 +app.include_router(member_route.router, prefix="/api", tags=["Members"]) app.include_router(stock.router, prefix="/stocks", tags=["Stocks"]) -app.include_router(portfolio.router, prefix="/portfolio", tags=["Portfolio"]) \ No newline at end of file +app.include_router(portfolio.router, prefix="/portfolio", tags=["Portfolio"]) diff --git a/app/models/member.py b/app/models/member.py new file mode 100644 index 0000000..1ec1456 --- /dev/null +++ b/app/models/member.py @@ -0,0 +1,43 @@ +# app/models/member.py +from sqlalchemy import Column, BigInteger, String, DateTime, Enum, func +from app.db.database import Base + +class Member(Base): + """Member 데이터베이스 모델 + + 'members' 테이블과 매핑되는 SQLAlchemy ORM 모델 클래스 + 사용자 계정 정보와 관련된 속성들을 포함 + """ + __tablename__ = "members" # 데이터베이스 테이블 이름 + + # 회원 고유 식별자 (기본 키) + id = Column(BigInteger, primary_key=True, autoincrement=True) + + # 회원 이메일 주소 (고유 값, 인덱스 생성, NULL 허용) + email = Column(String(255), unique=True, index=True, nullable=True) + + # 회원 닉네임 (NULL 허용 안 함) + nickname = Column(String(255), nullable=False) + + # 회원 역할 (ENUM 타입, ROLE_USER만 허용) + # 인증 및 권한 관리에 사용됨 + role = Column(Enum('ROLE_USER'), nullable=False) + + # 소셜 로그인 타입 (ENUM 타입, GOOGLE 또는 NAVER 허용) + # 소셜 로그인 제공자를 구분하는 데 사용됨 + social_type = Column(Enum('GOOGLE', 'NAVER'), nullable=False) + + # 회원 생성 일시 (자동으로 현재 시간 설정) + # 정밀도 6의 DateTime 타입 사용 + created_at = Column(DateTime(6), nullable=False, default=func.now()) + + # 회원 정보 마지막 수정 일시 (생성 시 현재 시간, 업데이트 시 자동 갱신) + # 정밀도 6의 DateTime 타입 사용 + update_at = Column(DateTime(6), nullable=False, default=func.now(), onupdate=func.now()) + + def __repr__(self): + """ + 객체의 문자열 표현을 반환합니다. + 디버깅 및 로깅에 유용합니다. + """ + return f"" \ No newline at end of file diff --git a/app/routers/member_route.py b/app/routers/member_route.py new file mode 100644 index 0000000..5bf05d1 --- /dev/null +++ b/app/routers/member_route.py @@ -0,0 +1,61 @@ +# app/routers/member.py +from fastapi import APIRouter, Depends, HTTPException, Path +from sqlalchemy.orm import Session +from typing import List +from enum import Enum + +from app.db.database import get_db +from app.services.member_service import MemberService +from app.schemas.member_sch import Member as MemberSchema + +router = APIRouter() + +# ENUM 검증을 위한 클래스 +class RoleEnum(str, Enum): + ROLE_USER = "ROLE_USER" + +class SocialTypeEnum(str, Enum): + GOOGLE = "GOOGLE" + NAVER = "NAVER" + +@router.get("/members/", response_model = List[MemberSchema]) +def read_members(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): + """전체 회원 목록 조회 API + :param skip: 건너뛸 레코드 수 (기본값 : 0) + :param limit: 최대 반환할 레코드 수 (기본값 : 100) + :param db: 데이터베이스 세션 (의존성 주입) + :return: List[MemberSchema] : 회원 객체 목록 + """ + members = MemberService.get_all_members(db, skip=skip, limit=limit) + return members + +@router.get("/members/{member_id}", response_model=MemberSchema) +def read_member(member_id: int, db: Session = Depends(get_db)): + """특정 ID의 회원 정보 조회 API + :param member_id: (int) 조회할 회원의 ID + :param db: 데이터베이스 세션 (의존성 주입) + :return: MemberScheman : 회원 객체 + :raise HTTPException: 회원을 찾을 수 없는 경우 404 에러 발생 + """ + db_member = MemberService.get_member_by_id(db, member_id=member_id) + + if db_member is None: + raise HTTPException(status_code=404, detail="Member not found") + return db_member + +# 더 구체적인 경로부터 정의 +@router.get("/members/email/{email}", response_model=MemberSchema) +def read_member_by_email( + email: str = Path(..., description="회원 이메일 주소"), + db: Session = Depends(get_db) +): + """이메일로 회원 정보 조회 API + :param email: (str) 조회할 회원의 이메일 주소 + :param db: 데이터베이스 세션(의존성 주입) + :return: MemberSchema: 회원 객체 + :raise: HTTPException : 회원을 찾을 수 없는 경우 404 에러 발생 + """ + db_member = MemberService.get_member_by_email(db, email=email) + if db_member is None: + raise HTTPException(status_code=404, detail="Member not found") + return db_member diff --git a/app/schemas/member_sch.py b/app/schemas/member_sch.py new file mode 100644 index 0000000..66805ff --- /dev/null +++ b/app/schemas/member_sch.py @@ -0,0 +1,32 @@ +# app/schemas/member_sch.py +''' +1. 데이터 검증 +API 를 통해 받거나 반환하는 Member 데이터의 형식과 타입을 검증한다. +예를 들어, Email 필드는 EmailStr 타입이어야 하며, 유효한 이메일 형식인지 자동으로 검증한다. + +2. API 문서화 +FastAPI 의 자동 문서화 시스템(Swagger/OpenAPI)에서 API 요청/응답의 스키마를 생성한다. + +3. 데이터 반환 +데이터베이스 모델 (SQLAlchemy)과 API 요청/응답 사이의 데이터 변환을 담당한다. +''' +from pydantic import BaseModel, EmailStr +from datetime import datetime +from typing import Optional + +class MemberBase(BaseModel): + email: EmailStr + nickname: str + role: str + social_type: str + +class MemberCreate(MemberBase): + pass + +class Member(MemberBase): + id: int + created_at: datetime + update_at: datetime + + class Config: + orm_mode = True \ No newline at end of file diff --git a/app/services/member_service.py b/app/services/member_service.py new file mode 100644 index 0000000..d1fc8f1 --- /dev/null +++ b/app/services/member_service.py @@ -0,0 +1,51 @@ +# app/services/member_service.py +from sqlalchemy.orm import Session +from app.models.member import Member +from typing import Optional, Type + + +class MemberService: + """ + 회원 정보 관련 서비스 클래스 + + - 이 클래스는 데이터베이스에서 회원 정보를 조회하는 메서드를 제공 + - 모든 메서드는 정적 메서드 (staticmethod)로 구현되어 있어 인스턴스 생성 없이 직접 호출 가능 + """ + @staticmethod + def get_member_by_id(db: Session, member_id: int) -> Optional[Member]: + + """ID 로 회원 정보를 조회 + :param db: SQLAlchemy 데이터베이스 세션 + :param member_id: 조회할 회원의 ID + :return: Optional[Member] : 해당 ID의 회원이 존재하면 Member 객체, 없으면 None + """ + + return db.query(Member).filter(Member.id == member_id).first() + + @staticmethod + def get_member_by_email(db: Session, email: str) -> Optional[Member]: + + """이메일로 회원 정보를 조회 + :param db: SQLAlchemy 데이터베이스 세션 + :param email: (str) 조회할 회원의 이메일 주소 + :return: Optional[Member] : 해당 이메일의 회원이 존재하면 Member, 없으면 None + """ + + return db.query(Member).filter(Member.email == email).first() + + # 명시적 변환 방법 + @staticmethod + def get_all_members(db: Session, skip: int = 0, limit: int = 100) -> list[Type[Member]]: + """모든 회원 정보를 페이지네이션하여 조회 + :param db: (Session) SQLAlchemy 데이터베이스 세션 + :param skip: (int, optional) 건너뛸 레코드 수, 기본값은 0 + :param limit: (int, optional) 최대 반영할 레코드 수, 기본값은 100 + :return: sequence[Memnber] : 조회된 회원 목록 + """ + + result = db.query(Member).offset(skip).limit(limit).all() + return list(result) # 명시적으로 list로 변환 + + @staticmethod + def get_members_by_social_type(db: Session, social_type: str) -> list[Type[Member]]: + return db.query(Member).filter(Member.social_type == social_type).all() \ No newline at end of file diff --git a/app/services/portfolio_service.py b/app/services/portfolio_service.py index e31788c..4cf0027 100644 --- a/app/services/portfolio_service.py +++ b/app/services/portfolio_service.py @@ -119,3 +119,4 @@ def calculate_portfolio_performance(portfolio: Dict[str, Any]) -> Dict[str, Any] "total_profit_loss_percent": round(total_profit_loss_percent, 2), "stocks": stocks_data } + diff --git a/requirements.txt b/requirements.txt index 4b0b1cc..e6d1eba 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,9 @@ yfinance pandas requests PyPortfolioOpt +sqlalchemy>=1.4.0 +pymysql>=1.0.2 +pydantic>=2.0.0 +pydantic[email]>=2.0.0 +email-validator>=2.0.0 # 이메일 검증을 위해 추가 +python-dotenv>=1.0.0 \ No newline at end of file