[add] backend
This commit is contained in:
parent
0fe588f164
commit
0edd2c615b
|
|
@ -0,0 +1,2 @@
|
|||
.env
|
||||
wallet/
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
from abc import ABC, abstractmethod
|
||||
|
||||
class IAnalysisSessionRepository(ABC):
|
||||
pass
|
||||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
from analysis_session.domain.repository.analysis_session_repo import IAnalysisSessionRepository
|
||||
|
||||
class AnalysisSessionRepository(IAnalysisSessionRepository):
|
||||
pass
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(prefix="/analysis_session", tags=["analysis_session"])
|
||||
|
||||
|
|
@ -0,0 +1 @@
|
|||
from .config import get_settings
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
from abc import ABC, abstractmethod
|
||||
|
||||
class IMemberRepository(ABC):
|
||||
pass
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
@ -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))
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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))
|
||||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()}
|
||||
Loading…
Reference in New Issue