[add] backend

This commit is contained in:
kimheesu 2025-07-03 17:22:52 +09:00
parent 0fe588f164
commit 0edd2c615b
42 changed files with 551 additions and 0 deletions

2
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
.env
wallet/

View File

View File

@ -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

View File

@ -0,0 +1,4 @@
from abc import ABC, abstractmethod
class IAnalysisSessionRepository(ABC):
pass

View File

@ -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)

View File

@ -0,0 +1,4 @@
from analysis_session.domain.repository.analysis_session_repo import IAnalysisSessionRepository
class AnalysisSessionRepository(IAnalysisSessionRepository):
pass

View File

@ -0,0 +1,6 @@
from typing import Annotated
from fastapi import APIRouter
router = APIRouter(prefix="/analysis_session", tags=["analysis_session"])

View File

@ -0,0 +1 @@
from .config import get_settings

20
backend/config/config.py Normal file
View File

@ -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()

19
backend/main.py Normal file
View File

@ -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()

View File

View File

View File

@ -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

View File

View File

@ -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

View File

@ -0,0 +1,4 @@
from abc import ABC, abstractmethod
class IMemberRepository(ABC):
pass

View File

@ -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()

View File

View File

@ -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)

View File

@ -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))

View File

View File

@ -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
)

View File

@ -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

75
backend/requirements.txt Normal file
View File

@ -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

View File

69
backend/utils/auth.py Normal file
View File

@ -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))

View File

@ -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
)

12
backend/utils/crypto.py Normal file
View File

@ -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)

32
backend/utils/database.py Normal file
View File

@ -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)

View File

@ -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()}

View File