[add] create member api

This commit is contained in:
kimheesu 2025-07-04 17:08:13 +09:00
parent 0e73ae0ceb
commit 56839117ca
32 changed files with 327 additions and 90 deletions

View File

@ -0,0 +1,26 @@
from sqlmodel import Session
from analysis.domain.repository.analysis_repo import IAnalysisRepository
from ulid import ULID
from analysis.domain.analysis import Analysis as AnalysisVO
from fastapi import HTTPException, status, BackgroundTasks
class AnalysisService:
def __init__(
self,
analysis_repo: IAnalysisRepository,
db_session: Session,
ulid: ULID
):
self.analysis_repo = analysis_repo
self.db_session = db_session
self.ulid = ulid
def get_analysis_list(
self,
member_id: str
)->list[AnalysisVO]:
analyses = self.analysis_repo.find_by_member_id(member_id)
if not analyses:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Analysis not found")
return analyses

View File

@ -0,0 +1,11 @@
from pydantic import BaseModel
from datetime import datetime
class Analysis(BaseModel):
id: str | None = None
member_id: str
ticker: str
status: str
created_at: datetime
updated_at: datetime
# 여기에 더 많은 필드들이 추가될 수 있습니다.

View File

@ -0,0 +1,15 @@
from abc import ABC, abstractmethod
from analysis.domain.analysis import Analysis as AnalysisVO
class IAnalysisRepository(ABC):
@abstractmethod
def find_by_member_id(self, member_id: str) -> list[AnalysisVO] | None:
raise NotImplementedError()
@abstractmethod
def update(self, analysis_id: str, updates: dict) -> AnalysisVO | None:
raise NotImplementedError()
@abstractmethod
def create(self, member_id: str) -> AnalysisVO:
raise NotImplementedError()

View File

@ -0,0 +1,57 @@
from datetime import datetime,date
from typing import TYPE_CHECKING
from sqlmodel import SQLModel, Field, JSON, Relationship
import enum
from sqlalchemy import Column
# TYPE_CHECKING을 사용해서 circular import 방지
if TYPE_CHECKING:
from member.infra.db_models.member import Member
class AnalysisStatus(str, enum.Enum):
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
class Analysis(SQLModel, table=True):
__tablename__ = "analyses"
id: str = Field(default=None, max_length=36, primary_key=True)
# 기본 분석 설정 정보
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)
# 개별 분석가 리포트들
market_report: str | None = Field(default=None, description="Market Analyst 리포트")
sentiment_report: str | None = Field(default=None, description="Social Analyst 리포트")
news_report: str | None = Field(default=None, description="News Analyst 리포트")
fundamentals_report: str | None = Field(default=None, description="Fundamentals Analyst 리포트")
# 팀별 의사결정 과정
investment_debate_state: dict | None = Field(default=None, sa_column=Column(JSON), description="Research Team 토론 과정")
trader_investment_plan: str | None = Field(default=None, description="Trading Team 계획")
risk_debate_state: dict | None = Field(default=None, sa_column=Column(JSON), description="Risk Management Team 토론 과정")
# 최종 결과물
final_trade_decision: str | None = Field(default=None, description="최종 거래 결정")
final_report: str | None = Field(default=None, description="전체 통합 리포트")
# 실행 결과 정보
error_message: str | None = None
completed_at: datetime | None = None
created_at : datetime = Field(nullable=False)
updated_at : datetime = Field(nullable=False)
# Foreign Key와 Relationship 설정
member_id: str = Field(foreign_key="members.id")
member: "Member" = Relationship(back_populates="analyses")

View File

@ -0,0 +1,35 @@
from analysis.domain.repository.analysis_repo import IAnalysisRepository
from sqlmodel import Session, select
from analysis.domain.analysis import Analysis as AnalysisVO
from analysis.infra.db_models.analysis import Analysis
from utils.db_utils import row_to_dict
from sqlalchemy.orm import selectinload
from datetime import datetime
import uuid
class AnalysisRepository(IAnalysisRepository):
def __init__(self, session: Session):
self.session = session
def find_by_member_id(self, member_id: str) -> list[AnalysisVO] | None:
query = select(Analysis).where(Analysis.member_id == member_id)
analyses = self.session.exec(query).all()
if not analyses:
return None
return [AnalysisVO(**row_to_dict(analysis)) for analysis in analyses]
def update(self, analysis_id: str, updates: AnalysisVO) -> AnalysisVO | None:
analysis : Analysis | None = self.session.get(Analysis, analysis_id)
if not analysis:
return None
analysis_data = updates.model_dump(exclude_unset=True)
analysis.sqlmodel_dump(analysis_data)
self.session.add(analysis)
self.session.flush()
self.session.refresh(analysis)
return AnalysisVO(**row_to_dict(analysis))

View File

@ -0,0 +1,35 @@
from typing import Annotated
from fastapi import APIRouter, Depends, BackgroundTasks
from analysis.interface.dto import AnalysisSessionResponse
from utils.auth import get_current_member, CurrentMember
from dependency_injector.wiring import inject, Provide
from analysis.application.analysis_service import AnalysisService
from utils.containers import Container
router = APIRouter(prefix="/analysis", tags=["analysis"])
@router.get("/")
@inject
def get_analysis_list_for_member(
current_member : Annotated[CurrentMember, Depends(get_current_member)],
analysis_service: Annotated[AnalysisService, Depends(Provide[Container.analysis_service])]
):
"""
현재 로그인한 사용자의 모든 분석 세션 목록을 조회합니다.
"""
return analysis_service.get_analysis_list(current_member.id)
@router.post("/start", status_code=201)
@inject
def start_analysis_session(
current_member : Annotated[CurrentMember, Depends(get_current_member)],
analysis_service: Annotated[AnalysisService, Depends(Provide[Container.analysis_service])],
background_tasks: BackgroundTasks
):
"""
새로운 분석 세션을 시작합니다.
"""
new_analysis = analysis_service.create_analysis(current_member.id, background_tasks)
return new_analysis

View File

@ -0,0 +1,8 @@
from pydantic import BaseModel
from datetime import date
from analysis.infra.db_models.analysis import AnalysisStatus
class AnalysisSessionResponse(BaseModel):
id : str
ticker : str
status : AnalysisStatus

View File

@ -1,11 +0,0 @@
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

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

View File

@ -1,35 +0,0 @@
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

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

View File

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

View File

@ -3,14 +3,15 @@ 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 analysis.interface.controller.analysis_controller import router as analysis_router
from member.interface.controller.member_controller import router as member_router
app = FastAPI()
app.container = Container()
app.include_router(analysis_session_router)
app.include_router(analysis_router)
app.include_router(member_router)

View File

@ -5,8 +5,9 @@ from utils.auth import Role
from member.domain.member import Member as MemberVO
from fastapi import HTTPException, status
from datetime import datetime
from utils.auth import create_access_token
from ulid import ULID
from analysis.domain.analysis import Analysis as AnalysisVO
class MemberService:
def __init__(
@ -66,4 +67,31 @@ class MemberService:
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
return member
def login(
self,
email: str,
password: str
):
member = self.member_repo.find_by_email(email)
if not member:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
if not self.crypto.verify(password, member.password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
access_token = create_access_token(
payload={"member_id": member.id, "role": member.role},
role=member.role,
)
return access_token
def get_analysis_sessions_by_member(
self,
member_id: str
)->list[AnalysisVO]:
analysis_sessions = self.member_repo.find_analysis_sessions_by_member(member_id)
return analysis_sessions

View File

@ -1,5 +1,6 @@
from abc import ABC, abstractmethod
from member.domain.member import Member as MemberVO
from analysis.domain.analysis import Analysis as AnalysisVO
class IMemberRepository(ABC):
@abstractmethod
@ -18,3 +19,6 @@ class IMemberRepository(ABC):
def get_members(self, page: int, items_per_page: int) -> tuple[int, list[MemberVO]]:
raise NotImplementedError()
@abstractmethod
def find_analysis_sessions_by_member(self, member_id: str) -> list[AnalysisVO]:
raise NotImplementedError()

View File

@ -1,10 +1,13 @@
from datetime import datetime
from typing import Optional
from sqlmodel import Field, SQLModel
from typing import Optional, TYPE_CHECKING
from sqlmodel import Field, SQLModel, Relationship
from sqlalchemy import Column, UUID, Numeric, VARCHAR # 필요한 타입들을 sqlalchemy에서 가져옵니다.
from utils.auth import Role
import uuid
# TYPE_CHECKING을 사용해서 circular import 방지
if TYPE_CHECKING:
from analysis.infra.db_models.analysis import Analysis
class Member(SQLModel, table=True):
__tablename__ = "members"
@ -15,4 +18,7 @@ class Member(SQLModel, table=True):
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)
role : Role = Field(default=Role.USER, nullable=False)
# Relationship 설정 - forward reference 사용
analyses: list["Analysis"] = Relationship(back_populates="member")

View File

@ -5,7 +5,6 @@ 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
@ -64,4 +63,4 @@ class MemberRepository(IMemberRepository):
if not member:
return None
return MemberVO(**row_to_dict(member))
return MemberVO(**row_to_dict(member))

View File

@ -1,20 +1,78 @@
from fastapi import APIRouter, status, Depends
from fastapi import APIRouter, status, Depends,HTTPException
from member.interface.dto import CreateUserBody, MemberResponse
from member.application.member_service import MemberService
from typing import Annotated
from utils.containers import Container
from dependency_injector.wiring import inject, Provide
from fastapi.security import OAuth2PasswordRequestForm
from utils.auth import get_current_member, CurrentMember, get_admin_member
router = APIRouter(prefix="/users", tags=["users"])
router = APIRouter(prefix="/members", tags=["members"])
@router.post("", status_code=status.HTTP_201_CREATED, response_model=MemberResponse)
@inject
async def create_user(
member: CreateUserBody,
member_service: Annotated[MemberService, Depends(Container.member_service)]
member_service: MemberService = Depends(Provide[Container.member_service])
):
created_member = member_service.create_member(
member.name,
member.email,
member.password,
member.role
)
)
return created_member
@router.post("/login")
@inject
def login(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
member_service: MemberService = Depends(Provide[Container.member_service])
):
access_token = member_service.login(
email=form_data.username,
password=form_data.password
)
return {
"access_token" : access_token,
"token_type" : "Bearer"
}
@router.get("/me", response_model=dict)
def get_current_user_info(
current_user: CurrentMember = Depends(get_current_member)
):
"""
현재 로그인한 사용자 정보를 조회합니다.
엔드포인트는 JWT 토큰이 필요하며, Swagger UI에서 Authorize 버튼을 활성화합니다.
"""
return {
"user_id": current_user.id,
"role": current_user.role,
"message": "Successfully authenticated"
}
@router.get("/{member_id}", response_model=MemberResponse)
@inject
def get_member(
member_id: str,
current_member: Annotated[CurrentMember | None, Depends(get_current_member)] = None,
member_service: Annotated[MemberService | None, Depends(Provide[Container.member_service])] = None
):
member = member_service.get_member(member_id)
if not member:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Member not found")
return member
# @router.get("/analysis-sessions", response_model=list[AnalysisSessionResponse])
# @inject
# def get_member_analysis_sessions(
# current_member: Annotated[CurrentMember | None, Depends(get_current_member)] = None,
# member_service: Annotated[MemberService | None, Depends(Provide[Container.member_service])] = None
# ):
# result = member_service.get_analysis_sessions_by_member(current_member.id)
# return result

View File

@ -15,4 +15,4 @@ class MemberResponse(BaseModel):
email : str
created_at : datetime
updated_at : datetime
role : Role
role : Role

View File

@ -19,14 +19,14 @@ class Role(StrEnum):
ADMIN = "ADMIN"
USER = "USER"
class CurrentUser(BaseModel):
id : int
class CurrentMember(BaseModel):
id : str
role : Role
def __str__(self):
return f"{self.id}({self.role})"
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/users/login")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/members/login")
@ -49,21 +49,21 @@ def decode_access_token(token: str):
# ✅ 수정된 부분: Annotated 올바른 사용법
def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
def get_current_member(token: Annotated[str, Depends(oauth2_scheme)]):
payload = decode_access_token(token)
user_id = payload.get("user_id")
member_id = payload.get("member_id")
role = payload.get("role")
if not user_id or not role:
if not member_id or not role:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid token")
return CurrentUser(id=user_id, role=Role(role))
return CurrentMember(id=member_id, role=Role(role))
def get_admin_user(token: Annotated[str, Depends(oauth2_scheme)]):
def get_admin_member(token: Annotated[str, Depends(oauth2_scheme)]):
payload = decode_access_token(token)
user_id = payload.get("user_id")
member_id = payload.get("member_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))
return CurrentMember(id=member_id, role=Role(role))

View File

@ -3,11 +3,13 @@ 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 analysis.application.analysis_service import AnalysisService
from analysis.infra.repository.analysis_repo import AnalysisRepository
from ulid import ULID
class Container(containers.DeclarativeContainer):
wiring_config = containers.WiringConfiguration(
packages=["member", "session"]
packages=["member", "analysis"]
)
db_session = providers.Resource(get_session)
@ -27,3 +29,15 @@ class Container(containers.DeclarativeContainer):
ulid=ulid
)
analysis_repo = providers.Factory(
AnalysisRepository,
session=db_session
)
analysis_service = providers.Factory(
AnalysisService,
analysis_repo=analysis_repo,
db_session=db_session,
ulid=ulid
)

View File

@ -2,13 +2,13 @@ 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
from analysis.infra.db_models.analysis import Analysis
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"

View File

@ -4,11 +4,11 @@ from .embedding_providers import (
GeminiEmbeddingProvider,
OllamaEmbeddingProvider
)
from typing import Any
class EmbeddingProviderFactory:
@staticmethod
def create_provider(config : dict[str, any])->EmbeddingProvider:
def create_provider(config : dict[str, Any])->EmbeddingProvider:
backend_url = config["backend_url"]
if "generativelanguage.googleapis.com" in backend_url: