diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 00000000..3fe9bc3e --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,2 @@ +.env +wallet/ \ No newline at end of file diff --git a/backend/analysis_session/__init__.py b/backend/analysis_session/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/analysis_session/application/__init__.py b/backend/analysis_session/application/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/analysis_session/application/analysis_service.py b/backend/analysis_session/application/analysis_service.py new file mode 100644 index 00000000..74b6859c --- /dev/null +++ b/backend/analysis_session/application/analysis_service.py @@ -0,0 +1,11 @@ +from sqlmodel import Session +from analysis_session.domain.repository.analysis_session_repo import IAnalysisSessionRepository + +class AnalysisService: + def __init__( + self, + analysis_session_repo: IAnalysisSessionRepository, + db_session: Session + ): + self.analysis_session_repo = analysis_session_repo + self.db_session = db_session \ No newline at end of file diff --git a/backend/analysis_session/domain/__init__.py b/backend/analysis_session/domain/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/analysis_session/domain/repository/__init__.py b/backend/analysis_session/domain/repository/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/analysis_session/domain/repository/analysis_session_repo.py b/backend/analysis_session/domain/repository/analysis_session_repo.py new file mode 100644 index 00000000..195530c9 --- /dev/null +++ b/backend/analysis_session/domain/repository/analysis_session_repo.py @@ -0,0 +1,4 @@ +from abc import ABC, abstractmethod + +class IAnalysisSessionRepository(ABC): + pass \ No newline at end of file diff --git a/backend/analysis_session/infra/__init__.py b/backend/analysis_session/infra/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/analysis_session/infra/db_models/__init__.py b/backend/analysis_session/infra/db_models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/analysis_session/infra/db_models/analysis_session.py b/backend/analysis_session/infra/db_models/analysis_session.py new file mode 100644 index 00000000..93dd2471 --- /dev/null +++ b/backend/analysis_session/infra/db_models/analysis_session.py @@ -0,0 +1,35 @@ +from datetime import datetime,date +from sqlmodel import SQLModel, Field, JSON +import uuid +import enum +from sqlalchemy import Column +from sqlalchemy.dialects import oracle +from utils.auth import Role +import uuid + +class AnalysisStatus(str, enum.Enum): + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + + +class AnalysisSession(SQLModel, table=True): + __tablename__ = "analysis_sessions" + id: str = Field(default=None, max_length=36, primary_key=True) + member_id: str = Field(foreign_key="members.id") + ticker: str + analysis_date: date + analysts_selected: list[str] = Field(sa_column=Column(JSON)) + research_depth: int + llm_provider: str + backend_url: str + shallow_thinker: str + deep_thinker: str + status: AnalysisStatus = Field(default=AnalysisStatus.PENDING) + final_report: str | None = None + error_message: str | None = None + completed_at: datetime | None = None + created_at : datetime = Field(nullable=False) + updated_at : datetime = Field(nullable=False) \ No newline at end of file diff --git a/backend/analysis_session/infra/repository/__init__.py b/backend/analysis_session/infra/repository/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/analysis_session/infra/repository/analysis_session_repo.py b/backend/analysis_session/infra/repository/analysis_session_repo.py new file mode 100644 index 00000000..4670d12c --- /dev/null +++ b/backend/analysis_session/infra/repository/analysis_session_repo.py @@ -0,0 +1,4 @@ +from analysis_session.domain.repository.analysis_session_repo import IAnalysisSessionRepository + +class AnalysisSessionRepository(IAnalysisSessionRepository): + pass \ No newline at end of file diff --git a/backend/analysis_session/interface/__init__.py b/backend/analysis_session/interface/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/analysis_session/interface/controller/__init__.py b/backend/analysis_session/interface/controller/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/analysis_session/interface/controller/analysis_session_controller.py b/backend/analysis_session/interface/controller/analysis_session_controller.py new file mode 100644 index 00000000..be238380 --- /dev/null +++ b/backend/analysis_session/interface/controller/analysis_session_controller.py @@ -0,0 +1,6 @@ + +from typing import Annotated +from fastapi import APIRouter + +router = APIRouter(prefix="/analysis_session", tags=["analysis_session"]) + diff --git a/backend/config/__init__.py b/backend/config/__init__.py new file mode 100644 index 00000000..85d2e72a --- /dev/null +++ b/backend/config/__init__.py @@ -0,0 +1 @@ +from .config import get_settings \ No newline at end of file diff --git a/backend/config/config.py b/backend/config/config.py new file mode 100644 index 00000000..bdbffa9c --- /dev/null +++ b/backend/config/config.py @@ -0,0 +1,20 @@ +from functools import lru_cache +from pydantic_settings import BaseSettings, SettingsConfigDict + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + ) + + # MySQL 데이터베이스 설정 + DB_HOST: str + DB_PORT: int + DB_USER: str + DB_PASSWORD: str + DB_NAME: str + SECRET_KEY: str + +@lru_cache +def get_settings(): + return Settings() \ No newline at end of file diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 00000000..ccf5dac6 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,19 @@ +from fastapi import FastAPI +from utils.database import create_db_and_tables +from utils.containers import Container + + +from analysis_session.interface.controller.analysis_session_controller import router as analysis_session_router +from member.interface.controller.member_controller import router as member_router + + + +app = FastAPI() + +app.include_router(analysis_session_router) +app.include_router(member_router) + + +@app.on_event("startup") +def startup_db_client(): + create_db_and_tables() \ No newline at end of file diff --git a/backend/member/__init__.py b/backend/member/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/member/application/__init__.py b/backend/member/application/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/member/application/member_service.py b/backend/member/application/member_service.py new file mode 100644 index 00000000..a350c140 --- /dev/null +++ b/backend/member/application/member_service.py @@ -0,0 +1,69 @@ +from sqlmodel import Session +from utils.crypto import Crypto +from member.domain.repository.member_repo import IMemberRepository +from utils.auth import Role +from member.domain.member import Member as MemberVO +from fastapi import HTTPException, status +from datetime import datetime + +from ulid import ULID + +class MemberService: + def __init__( + self, + member_repo: IMemberRepository, + crypto: Crypto, + db_session: Session, + ulid: ULID + ): + self.member_repo = member_repo + self.crypto = crypto + self.db_session = db_session + self.ulid = ulid + + def create_member( + self, + name: str, + email: str, + password: str, + role: Role + ): + try: + if self.member_repo.find_by_email(email): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already exists") + except Exception as e: + self.db_session.rollback() + raise e + + now = datetime.now() + member_vo = MemberVO( + id=self.ulid.generate(), + name=name, + email=email, + password=self.crypto.encrypt(password), + created_at=now, + updated_at=now, + role=role + ) + + saved_member = self.member_repo.save(member_vo) + self.db_session.commit() + + return saved_member + + + def get_members( + self, + page: int, + items_per_page: int + )->tuple[int, list[MemberVO]] : + return self.member_repo.get_members(page, items_per_page) + + def get_member( + self, + id: str + )->MemberVO | None: + member = self.member_repo.find_by_id(id) + if not member: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Member not found") + return member \ No newline at end of file diff --git a/backend/member/domain/__init__.py b/backend/member/domain/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/member/domain/member.py b/backend/member/domain/member.py new file mode 100644 index 00000000..d2703df6 --- /dev/null +++ b/backend/member/domain/member.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel +from utils.auth import Role +from datetime import datetime + +class Member(BaseModel): + id: str | None = None + name: str + email: str + password: str + role: Role + created_at: datetime + updated_at: datetime \ No newline at end of file diff --git a/backend/member/domain/repository/__init__.py b/backend/member/domain/repository/__init__.py new file mode 100644 index 00000000..be6bb364 --- /dev/null +++ b/backend/member/domain/repository/__init__.py @@ -0,0 +1,4 @@ +from abc import ABC, abstractmethod + +class IMemberRepository(ABC): + pass \ No newline at end of file diff --git a/backend/member/domain/repository/member_repo.py b/backend/member/domain/repository/member_repo.py new file mode 100644 index 00000000..23e63ff4 --- /dev/null +++ b/backend/member/domain/repository/member_repo.py @@ -0,0 +1,20 @@ +from abc import ABC, abstractmethod +from member.domain.member import Member as MemberVO + +class IMemberRepository(ABC): + @abstractmethod + def find_by_email(self, email: str) -> MemberVO | None: + raise NotImplementedError() + + @abstractmethod + def save(self, member: MemberVO) -> MemberVO: + raise NotImplementedError() + + @abstractmethod + def find_by_id(self, id: str) -> MemberVO | None: + raise NotImplementedError() + + @abstractmethod + def get_members(self, page: int, items_per_page: int) -> tuple[int, list[MemberVO]]: + raise NotImplementedError() + diff --git a/backend/member/infra/__init__.py b/backend/member/infra/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/member/infra/db_models/__init__.py b/backend/member/infra/db_models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/member/infra/db_models/member.py b/backend/member/infra/db_models/member.py new file mode 100644 index 00000000..af934ad7 --- /dev/null +++ b/backend/member/infra/db_models/member.py @@ -0,0 +1,18 @@ +from datetime import datetime +from typing import Optional +from sqlmodel import Field, SQLModel +from sqlalchemy import Column, UUID, Numeric, VARCHAR # 필요한 타입들을 sqlalchemy에서 가져옵니다. +from utils.auth import Role +import uuid + + +class Member(SQLModel, table=True): + __tablename__ = "members" + id : str = Field(default=None, max_length=36, primary_key=True) + email : str = Field(max_length=64, unique=True, nullable=False) + name : str = Field(max_length=32, nullable=False) + password : str = Field(max_length=64, nullable=False) + is_active : bool = Field(default=True, nullable=False) + created_at : datetime = Field(nullable=False) + updated_at : datetime = Field(nullable=False) + role : Role = Field(default=Role.USER, nullable=False) \ No newline at end of file diff --git a/backend/member/infra/repository/__init__.py b/backend/member/infra/repository/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/member/infra/repository/member_repo.py b/backend/member/infra/repository/member_repo.py new file mode 100644 index 00000000..425123d4 --- /dev/null +++ b/backend/member/infra/repository/member_repo.py @@ -0,0 +1,67 @@ +from member.domain.repository import IMemberRepository +from sqlmodel import Session, select +from member.domain.member import Member as MemberVO +from member.infra.db_models.member import Member +from utils.db_utils import row_to_dict +from sqlalchemy import func + + +class MemberRepository(IMemberRepository): + def __init__(self, session: Session): + self.session = session + + def find_by_email(self, email: str) -> MemberVO | None: + query = select(Member).where(Member.email == email) + member = self.session.exec(query).first() + + if not member: + return None + + return MemberVO(**row_to_dict(member)) + + def save(self, member: MemberVO) -> MemberVO: + new_member = Member( + id=member.id, + email=member.email, + name=member.name, + password=member.password, + role=member.role, + created_at=member.created_at, + updated_at=member.updated_at + ) + + self.session.add(new_member) + self.session.flush() + self.session.refresh(new_member) + + member.id = new_member.id + return member + + + def get_members(self, page: int, items_per_page: int) -> tuple[int, list[MemberVO]]: + offset = (page - 1) * items_per_page + total_count_query = select(func.count(Member.id)) + total_count = self.session.exec(total_count_query).one() + + if total_count == 0: + return 0, [] + + query = ( + select(Member) + .order_by(Member.created_at.desc()) + .offset(offset) + .limit(items_per_page) + ) + + members = self.session.exec(query).all() + + return total_count, [MemberVO(**row_to_dict(member)) for member in members] + + def find_by_id(self, id: str) -> MemberVO | None: + query = select(Member).where(Member.id == id) + member = self.session.exec(query).first() + + if not member: + return None + + return MemberVO(**row_to_dict(member)) \ No newline at end of file diff --git a/backend/member/interface/__init__.py b/backend/member/interface/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/member/interface/controller/__init__.py b/backend/member/interface/controller/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/member/interface/controller/member_controller.py b/backend/member/interface/controller/member_controller.py new file mode 100644 index 00000000..da4f0245 --- /dev/null +++ b/backend/member/interface/controller/member_controller.py @@ -0,0 +1,20 @@ +from fastapi import APIRouter, status, Depends +from member.interface.dto import CreateUserBody, MemberResponse +from member.application.member_service import MemberService +from typing import Annotated +from utils.containers import Container + + +router = APIRouter(prefix="/users", tags=["users"]) + +@router.post("", status_code=status.HTTP_201_CREATED, response_model=MemberResponse) +async def create_user( + member: CreateUserBody, + member_service: Annotated[MemberService, Depends(Container.member_service)] +): + created_member = member_service.create_member( + member.name, + member.email, + member.password, + member.role + ) \ No newline at end of file diff --git a/backend/member/interface/dto.py b/backend/member/interface/dto.py new file mode 100644 index 00000000..f47bcf41 --- /dev/null +++ b/backend/member/interface/dto.py @@ -0,0 +1,18 @@ +from typing import Annotated +from pydantic import BaseModel, Field, EmailStr +from utils.auth import Role +from datetime import datetime + +class CreateUserBody(BaseModel): + name : Annotated[str, Field(min_length=1, max_length=32)] + email : Annotated[EmailStr, Field(max_length=32)] + password : Annotated[str, Field(max_length=32)] + role : Annotated[Role, Field(default=Role.USER)] + +class MemberResponse(BaseModel): + id : str + name : str | None = None + email : str + created_at : datetime + updated_at : datetime + role : Role \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 00000000..61b8a863 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,75 @@ +amqp==5.3.1 +annotated-types==0.7.0 +anyio==4.9.0 +bcrypt==4.0.1 +billiard==4.2.1 +celery==5.5.3 +certifi==2025.6.15 +cffi==1.17.1 +click==8.2.1 +click-didyoumean==0.3.1 +click-plugins==1.1.1.2 +click-repl==0.3.0 +colorama==0.4.6 +cryptography==45.0.4 +dependency-injector==4.48.1 +dnspython==2.7.0 +ecdsa==0.19.1 +email_validator==2.2.0 +eventlet==0.40.1 +fastapi==0.115.14 +fastapi-cli==0.0.7 +freezegun==1.5.2 +greenlet==3.2.3 +h11==0.16.0 +httpcore==1.0.9 +httptools==0.6.4 +httpx==0.28.1 +idna==3.10 +iniconfig==2.1.1 +Jinja2==3.1.6 +kombu==5.5.4 +markdown-it-py==3.0.0 +MarkupSafe==3.0.2 +mdurl==0.1.2 +mocker==1.1.1 +packaging==25.0 +passlib==1.7.4 +pluggy==1.6.0 +prompt_toolkit==3.0.51 +py-ulid==1.0.3 +pyasn1==0.6.1 +pycparser==2.22 +pydantic==2.11.7 +pydantic-settings==2.10.1 +pydantic_core==2.33.2 +Pygments==2.19.2 +PyMySQL==1.1.1 +pytest==8.4.1 +pytest-mock==3.14.1 +python-dateutil==2.9.0.post0 +python-dotenv==1.1.1 +python-jose==3.5.0 +python-multipart==0.0.20 +PyYAML==6.0.2 +redis==5.2.1 +rich==14.0.0 +rich-toolkit==0.14.7 +rsa==4.9.1 +setuptools==78.1.1 +shellingham==1.5.4 +six==1.17.0 +sniffio==1.3.1 +SQLAlchemy==2.0.41 +sqlmodel==0.0.24 +starlette==0.46.2 +typer==0.16.0 +typing-inspection==0.4.1 +typing_extensions==4.14.0 +tzdata==2025.2 +uvicorn==0.34.3 +vine==5.1.0 +watchfiles==1.1.0 +wcwidth==0.2.13 +websockets==15.0.1 +wheel==0.45.1 diff --git a/backend/utils/__init__.py b/backend/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/utils/auth.py b/backend/utils/auth.py new file mode 100644 index 00000000..3ce203cc --- /dev/null +++ b/backend/utils/auth.py @@ -0,0 +1,69 @@ +from datetime import datetime, timedelta +from fastapi import HTTPException, status, Depends +from jose import jwt, JWTError +from fastapi.security import OAuth2PasswordBearer +import os +from dotenv import load_dotenv +from enum import StrEnum +from pydantic import BaseModel +from typing import Annotated + +from config import get_settings + +settings = get_settings() + +SECRET_KEY = settings.SECRET_KEY +ALGORITHM = "HS256" + +class Role(StrEnum): + ADMIN = "ADMIN" + USER = "USER" + +class CurrentUser(BaseModel): + id : int + role : Role + + def __str__(self): + return f"{self.id}({self.role})" + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/users/login") + + + +def create_access_token( + payload: dict, + role: Role, + expires_delta: timedelta = timedelta(hours=6) +): + expire = datetime.utcnow() + expires_delta + payload.update({"exp": expire, "role": role}) + encoded_jwt = jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM) + + return encoded_jwt + +def decode_access_token(token: str): + try: + return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + except JWTError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + + +# ✅ 수정된 부분: Annotated 올바른 사용법 +def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]): + payload = decode_access_token(token) + user_id = payload.get("user_id") + role = payload.get("role") + if not user_id or not role: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid token") + + return CurrentUser(id=user_id, role=Role(role)) + +def get_admin_user(token: Annotated[str, Depends(oauth2_scheme)]): + payload = decode_access_token(token) + user_id = payload.get("user_id") + role = payload.get("role") + + if not role or role != Role.ADMIN: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid token") + + return CurrentUser(id=user_id, role=Role(role)) \ No newline at end of file diff --git a/backend/utils/containers.py b/backend/utils/containers.py new file mode 100644 index 00000000..da4562b7 --- /dev/null +++ b/backend/utils/containers.py @@ -0,0 +1,29 @@ +from dependency_injector import containers, providers +from utils.database import get_session +from utils.crypto import Crypto +from member.infra.repository.member_repo import MemberRepository +from member.application.member_service import MemberService +from ulid import ULID + +class Container(containers.DeclarativeContainer): + wiring_config = containers.WiringConfiguration( + packages=["member", "session"] + ) + + db_session = providers.Resource(get_session) + crypto = providers.Factory(Crypto) + ulid = providers.Factory(ULID) + + member_repo = providers.Factory( + MemberRepository, + session=db_session + ) + + member_service = providers.Factory( + MemberService, + member_repo=member_repo, + crypto=crypto, + db_session=db_session, + ulid=ulid + ) + diff --git a/backend/utils/crypto.py b/backend/utils/crypto.py new file mode 100644 index 00000000..63d30ceb --- /dev/null +++ b/backend/utils/crypto.py @@ -0,0 +1,12 @@ +from passlib.context import CryptContext + +class Crypto: + def __init__(self): + self.pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + def encrypt(self, secret): + return self.pwd_context.hash(secret) + + def verify(self, secret, hash): + return self.pwd_context.verify(secret, hash) + \ No newline at end of file diff --git a/backend/utils/database.py b/backend/utils/database.py new file mode 100644 index 00000000..9ac8a18e --- /dev/null +++ b/backend/utils/database.py @@ -0,0 +1,32 @@ +import os +from pathlib import Path +from sqlmodel import SQLModel, create_engine, Session +from config.config import get_settings +from analysis_session.infra.db_models.analysis_session import AnalysisSession +from member.infra.db_models.member import Member + +settings = get_settings() + +BASE_DIR = Path(__file__).resolve().parent.parent +print(settings) +# MySQL 데이터베이스 URL 구성 +DATABASE_URL = f"mysql+pymysql://{settings.DB_USER}:{settings.DB_PASSWORD}@{settings.DB_HOST}:{settings.DB_PORT}/{settings.DB_NAME}?charset=utf8mb4" + +# MySQL 엔진 생성 +engine = create_engine( + DATABASE_URL, + echo=True +) + +def get_session(): + with Session(engine) as session: + yield session + +def create_db_and_tables(): + # 테이블 생성 + # SQLModel.metadata.drop_all(engine) + SQLModel.metadata.create_all(engine) + +if __name__ == "__main__": + create_db_and_tables() + print(DATABASE_URL) \ No newline at end of file diff --git a/backend/utils/db_utils.py b/backend/utils/db_utils.py new file mode 100644 index 00000000..6fecf154 --- /dev/null +++ b/backend/utils/db_utils.py @@ -0,0 +1,4 @@ +from sqlalchemy import inspect + +def row_to_dict(row)->dict: + return {key : getattr(row, key) for key in inspect(row).attrs.keys()} \ No newline at end of file diff --git a/backend/utils/middlewares.py b/backend/utils/middlewares.py new file mode 100644 index 00000000..e69de29b