From ac31b3816b14d5b5159ac4ed0c5017c4196d37c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B0=80=EC=9B=90=28Kimgawon=29?= <202021000@sangmyung.kr> Date: Sat, 5 Apr 2025 00:05:21 +0900 Subject: [PATCH 1/4] =?UTF-8?q?[#3]=20-=20(feat)=20RDS=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SQLAlchemy ORM 을 사용한 RDS 연결 구현 - Member 테이블과 매핑되는 모델 및 스키마 정의 - 회원 조회 API 엔드포인트 구현 (/api/members/) - 환경 변수를 통한 데이터베이스 연결 정보 관리 - 회원 ID, 이메일 기반 조회 기능 추가 --- .gitignore | 5 +++- app/__init__.py | 0 app/db/database.py | 44 +++++++++++++++++++++++++++++++ app/main.py | 5 ++-- app/models/member.py | 14 ++++++++++ app/routers/member_route.py | 48 ++++++++++++++++++++++++++++++++++ app/schemas/member_sch.py | 32 +++++++++++++++++++++++ app/services/member_service.py | 28 ++++++++++++++++++++ requirements.txt | 6 +++++ 9 files changed, 179 insertions(+), 3 deletions(-) create mode 100644 app/__init__.py create mode 100644 app/db/database.py create mode 100644 app/models/member.py create mode 100644 app/routers/member_route.py create mode 100644 app/schemas/member_sch.py create mode 100644 app/services/member_service.py 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..59cf1ca --- /dev/null +++ b/app/models/member.py @@ -0,0 +1,14 @@ +# app/models/member.py +from sqlalchemy import Column, BigInteger, String, DateTime, Enum, func +from app.db.database import Base + +class Member(Base): + __tablename__ = "members" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + email = Column(String(255), unique=True, index=True, nullable=True) + nickname = Column(String(255), nullable=False) + role = Column(Enum('ROLE_USER'), nullable=False) + social_type = Column(Enum('GOOGLE', 'NAVER'), nullable=False) + created_at = Column(DateTime(6), nullable=False, default=func.now()) + update_at = Column(DateTime(6), nullable=False, default=func.now(), onupdate=func.now()) \ 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..e632faa --- /dev/null +++ b/app/routers/member_route.py @@ -0,0 +1,48 @@ +# 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)): + 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)): + 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/role/{role}", response_model=List[MemberSchema]) +# def read_members_by_role(role: str, db: Session = Depends(get_db)): +# members = MemberService.get_members_by_role(db, role=role) +# return members + +# 더 구체적인 경로부터 정의 +@router.get("/members/email/{email}", response_model=MemberSchema) +def read_member_by_email( + email: str = Path(..., description="회원 이메일 주소"), + db: Session = Depends(get_db) +): + 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..bef2e38 --- /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 + updated_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..cf05e0a --- /dev/null +++ b/app/services/member_service.py @@ -0,0 +1,28 @@ +# app/services/member_service.py +from sqlalchemy.orm import Session +from app.models.member import Member +from typing import Optional, Type + + +class MemberService: + @staticmethod + def get_member_by_id(db: Session, member_id: int) -> Optional[Member]: + return db.query(Member).filter(Member.id == member_id).first() + + @staticmethod + def get_member_by_email(db: Session, email: str) -> Optional[Member]: + 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]]: + result = db.query(Member).offset(skip).limit(limit).all() + return list(result) # 명시적으로 list로 변환 + + # @staticmethod + # def get_members_by_role(db: Session, role: str) -> list[Type[Member]]: + # return db.query(Member).filter(Member.role == role).all() + + @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/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 From 6b78f03ffea9b778919d6703489520381100c52e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B0=80=EC=9B=90=28Kimgawon=29?= <202021000@sangmyung.kr> Date: Mon, 7 Apr 2025 16:14:10 +0900 Subject: [PATCH 2/4] =?UTF-8?q?[#3]=20-=20(fix)=20Member=20=EC=8A=A4?= =?UTF-8?q?=ED=82=A4=EB=A7=88=EC=99=80=20=EB=AA=A8=EB=8D=B8=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=9D=B4=EB=A6=84=20=EB=B6=88=EC=9D=BC=EC=B9=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pydantic 스키마의 'updated_at' 필드명을 'update_at' 으로 변경 - 데이터베이스 모델과 스키마 간의 필드명 일치시킴 - ResponseValidationError 해결 --- app/schemas/member_sch.py | 2 +- app/services/portfolio_service.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/schemas/member_sch.py b/app/schemas/member_sch.py index bef2e38..66805ff 100644 --- a/app/schemas/member_sch.py +++ b/app/schemas/member_sch.py @@ -26,7 +26,7 @@ class MemberCreate(MemberBase): class Member(MemberBase): id: int created_at: datetime - updated_at: datetime + update_at: datetime class Config: orm_mode = True \ 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 } + From 036bc988c8484ef03dec6c981126821371df40b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B0=80=EC=9B=90=28Kimgawon=29?= <202021000@sangmyung.kr> Date: Mon, 7 Apr 2025 16:55:16 +0900 Subject: [PATCH 3/4] =?UTF-8?q?[#3]=20-=20(docs)=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EB=B0=8F=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=EC=97=90=20=EC=A3=BC=EC=84=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 회원 관련 모델 (member.py) 클래스 및 필드 주석 추가 - 회원 서비스 (member_service.py) 클래스 및 메서드 주석 추가 - API 라우터 (member_route.py) 엔드포인트 주석 추가 - 코드 가독성 및 유지보수성 향상을 위해 설명 보강 --- app/models/member.py | 33 +++++++++++++++++++++++++++++++-- app/routers/member_route.py | 23 ++++++++++++++++++----- app/services/member_service.py | 31 +++++++++++++++++++++++++++---- 3 files changed, 76 insertions(+), 11 deletions(-) diff --git a/app/models/member.py b/app/models/member.py index 59cf1ca..1ec1456 100644 --- a/app/models/member.py +++ b/app/models/member.py @@ -3,12 +3,41 @@ from app.db.database import Base class Member(Base): - __tablename__ = "members" + """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()) - update_at = Column(DateTime(6), nullable=False, default=func.now(), onupdate=func.now()) \ No newline at end of file + + # 회원 정보 마지막 수정 일시 (생성 시 현재 시간, 업데이트 시 자동 갱신) + # 정밀도 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 index e632faa..5bf05d1 100644 --- a/app/routers/member_route.py +++ b/app/routers/member_route.py @@ -20,28 +20,41 @@ class SocialTypeEnum(str, Enum): @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/role/{role}", response_model=List[MemberSchema]) -# def read_members_by_role(role: str, db: Session = Depends(get_db)): -# members = MemberService.get_members_by_role(db, role=role) -# return members - # 더 구체적인 경로부터 정의 @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") diff --git a/app/services/member_service.py b/app/services/member_service.py index cf05e0a..d1fc8f1 100644 --- a/app/services/member_service.py +++ b/app/services/member_service.py @@ -5,24 +5,47 @@ 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_role(db: Session, role: str) -> list[Type[Member]]: - # return db.query(Member).filter(Member.role == role).all() - @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 From 1e4f356efd8736949cee0e48bad09722fda6e0b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B0=80=EC=9B=90=28Kimgawon=29?= <202021000@sangmyung.kr> Date: Mon, 7 Apr 2025 17:57:36 +0900 Subject: [PATCH 4/4] =?UTF-8?q?[#4]=20-=20.gitignore=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .env 파일 .gitignore 에 추가 --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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