[add] frontend. 근데 분석결과 들어갈 때, 불러올 수 없다고 뜰때가 종종있음
This commit is contained in:
parent
46a1c95af1
commit
938c27a6b9
|
|
@ -15,45 +15,66 @@ class AnalysisRepository(IAnalysisRepository):
|
|||
self.session = session
|
||||
|
||||
def find_by_member_id(self, member_id: str) -> list[AnalysisVO] | None:
|
||||
query = select(Analysis).where(Analysis.member_id == member_id).order_by(Analysis.created_at.desc())
|
||||
analyses = self.session.exec(query).all()
|
||||
|
||||
if not analyses:
|
||||
return None
|
||||
|
||||
return [AnalysisVO(**row_to_dict(analysis)) for analysis in analyses]
|
||||
try:
|
||||
query = select(Analysis).where(Analysis.member_id == member_id).order_by(Analysis.created_at.desc())
|
||||
analyses = self.session.exec(query).all()
|
||||
|
||||
if not analyses:
|
||||
return None
|
||||
|
||||
return [AnalysisVO(**row_to_dict(analysis)) for analysis in analyses]
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 멤버별 분석 조회 실패: {str(e)}")
|
||||
self.session.rollback()
|
||||
raise
|
||||
|
||||
def find_by_id(self, analysis_id: str) -> AnalysisVO | None:
|
||||
analysis = self.session.get(Analysis, analysis_id)
|
||||
if not analysis:
|
||||
return None
|
||||
return AnalysisVO(**row_to_dict(analysis))
|
||||
try:
|
||||
analysis = self.session.get(Analysis, analysis_id)
|
||||
if not analysis:
|
||||
return None
|
||||
return AnalysisVO(**row_to_dict(analysis))
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 분석 ID로 조회 실패: {str(e)}")
|
||||
self.session.rollback()
|
||||
raise
|
||||
|
||||
def save(self, analysis: AnalysisVO) -> AnalysisVO:
|
||||
new_analysis = Analysis(
|
||||
**analysis.model_dump()
|
||||
)
|
||||
|
||||
self.session.add(new_analysis)
|
||||
self.session.flush()
|
||||
|
||||
|
||||
analysis.id = new_analysis.id
|
||||
return analysis
|
||||
try:
|
||||
new_analysis = Analysis(
|
||||
**analysis.model_dump()
|
||||
)
|
||||
|
||||
self.session.add(new_analysis)
|
||||
self.session.flush()
|
||||
self.session.refresh(new_analysis)
|
||||
|
||||
analysis.id = new_analysis.id
|
||||
return analysis
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 분석 저장 실패: {str(e)}")
|
||||
self.session.rollback()
|
||||
raise
|
||||
|
||||
def update(self, analysis_vo: AnalysisVO) -> AnalysisVO | None:
|
||||
analysis = self.session.get(Analysis, analysis_vo.id)
|
||||
logger.info(f"🔄 분석 업데이트 - Analysis ID: {analysis_vo.id}")
|
||||
if not analysis:
|
||||
return None
|
||||
try:
|
||||
analysis = self.session.get(Analysis, analysis_vo.id)
|
||||
logger.info(f"🔄 분석 업데이트 - Analysis ID: {analysis_vo.id}")
|
||||
if not analysis:
|
||||
return None
|
||||
|
||||
# AnalysisVO의 데이터를 SQLModel 객체에 업데이트
|
||||
analysis_data = analysis_vo.model_dump(exclude_unset=True)
|
||||
|
||||
analysis.updated_at = datetime.now()
|
||||
analysis.sqlmodel_update(analysis_data)
|
||||
|
||||
self.session.flush()
|
||||
|
||||
# AnalysisVO의 데이터를 SQLModel 객체에 업데이트
|
||||
analysis_data = analysis_vo.model_dump(exclude_unset=True)
|
||||
|
||||
analysis.updated_at = datetime.now()
|
||||
analysis.sqlmodel_update(analysis_data)
|
||||
|
||||
self.session.add(analysis)
|
||||
self.session.flush()
|
||||
self.session.refresh(analysis)
|
||||
|
||||
return AnalysisVO(**row_to_dict(analysis))
|
||||
return AnalysisVO(**row_to_dict(analysis))
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 분석 업데이트 실패: {str(e)}")
|
||||
self.session.rollback()
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -1,19 +1,82 @@
|
|||
from functools import lru_cache
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from pydantic import validator, Field
|
||||
import secrets
|
||||
import os
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=True,
|
||||
validate_default=True,
|
||||
)
|
||||
|
||||
# MySQL 데이터베이스 설정
|
||||
DB_HOST: str
|
||||
DB_PORT: int
|
||||
DB_USER: str
|
||||
DB_PASSWORD: str
|
||||
DB_NAME: str
|
||||
SECRET_KEY: str
|
||||
DB_HOST: str = Field(description="Database host")
|
||||
DB_PORT: int = Field(ge=1, le=65535, description="Database port")
|
||||
DB_USER: str = Field(min_length=1, description="Database username")
|
||||
DB_PASSWORD: str = Field(min_length=1, description="Database password")
|
||||
DB_NAME: str = Field(min_length=1, description="Database name")
|
||||
|
||||
# 보안 설정
|
||||
SECRET_KEY: str = Field(min_length=32, description="JWT secret key")
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=360, ge=1, description="Access token expiration in minutes")
|
||||
|
||||
# CORS 설정
|
||||
ALLOWED_ORIGINS: str = Field(default="http://localhost:3000", description="Allowed CORS origins (comma-separated)")
|
||||
|
||||
# 로깅 설정
|
||||
LOG_LEVEL: str = Field(default="INFO", description="Logging level")
|
||||
LOG_FILE: str = Field(default="logs/app.log", description="Log file path")
|
||||
|
||||
# API 설정
|
||||
API_V1_STR: str = Field(default="/api/v1", description="API prefix")
|
||||
PROJECT_NAME: str = Field(default="TradingAgents API", description="Project name")
|
||||
|
||||
# Rate Limiting 설정
|
||||
RATE_LIMIT_REQUESTS: int = Field(default=100, ge=1, description="Rate limit requests per minute")
|
||||
RATE_LIMIT_PERIOD: int = Field(default=60, ge=1, description="Rate limit period in seconds")
|
||||
|
||||
# 환경 설정
|
||||
ENVIRONMENT: str = Field(default="development", description="Environment (development/staging/production)")
|
||||
DEBUG: bool = Field(default=True, description="Debug mode")
|
||||
|
||||
@validator('ENVIRONMENT')
|
||||
def validate_environment(cls, v):
|
||||
allowed_envs = ['development', 'staging', 'production']
|
||||
if v not in allowed_envs:
|
||||
raise ValueError(f'Environment must be one of {allowed_envs}')
|
||||
return v
|
||||
|
||||
@validator('LOG_LEVEL')
|
||||
def validate_log_level(cls, v):
|
||||
allowed_levels = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
|
||||
if v.upper() not in allowed_levels:
|
||||
raise ValueError(f'Log level must be one of {allowed_levels}')
|
||||
return v.upper()
|
||||
|
||||
@validator('SECRET_KEY')
|
||||
def validate_secret_key(cls, v):
|
||||
if len(v) < 32:
|
||||
raise ValueError('SECRET_KEY must be at least 32 characters long')
|
||||
return v
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
return f"mysql+pymysql://{self.DB_USER}:{self.DB_PASSWORD}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}?charset=utf8mb4"
|
||||
|
||||
@property
|
||||
def allowed_origins_list(self) -> list[str]:
|
||||
return [origin.strip() for origin in self.ALLOWED_ORIGINS.split(',')]
|
||||
|
||||
@property
|
||||
def is_production(self) -> bool:
|
||||
return self.ENVIRONMENT == "production"
|
||||
|
||||
@property
|
||||
def is_development(self) -> bool:
|
||||
return self.ENVIRONMENT == "development"
|
||||
|
||||
@lru_cache
|
||||
def get_settings():
|
||||
|
|
|
|||
124
backend/main.py
124
backend/main.py
|
|
@ -1,31 +1,129 @@
|
|||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.trustedhost import TrustedHostMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from utils.database import create_db_and_tables
|
||||
from utils.containers import Container
|
||||
from utils.middlewares import RateLimitMiddleware, LoggingMiddleware, SecurityHeadersMiddleware
|
||||
from utils.exceptions import BaseAPIException
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from analysis.interface.controller.analysis_controller import router as analysis_router
|
||||
from member.interface.controller.member_controller import router as member_router
|
||||
import logging
|
||||
from utils.logger import setup_logging
|
||||
from config.config import get_settings
|
||||
|
||||
setup_logging()
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
|
||||
app = FastAPI()
|
||||
app.container = Container()
|
||||
|
||||
app.include_router(analysis_router)
|
||||
app.include_router(member_router)
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup_db_client():
|
||||
# 라이프사이클 관리
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logger = logging.getLogger(__name__)
|
||||
# 시작 시
|
||||
logger.info("🚀 FastAPI 애플리케이션 시작")
|
||||
create_db_and_tables()
|
||||
logger.info("📊 데이터베이스 초기화 완료")
|
||||
yield
|
||||
# 종료 시
|
||||
logger.info("🔄 애플리케이션 종료")
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.PROJECT_NAME,
|
||||
version="1.0.0",
|
||||
debug=settings.DEBUG,
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# 컨테이너 설정
|
||||
app.container = Container()
|
||||
|
||||
# 미들웨어 설정
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.allowed_origins_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 신뢰할 수 있는 호스트 설정
|
||||
if settings.is_production:
|
||||
app.add_middleware(TrustedHostMiddleware, allowed_hosts=["tradingagents.com", "*.tradingagents.com"])
|
||||
|
||||
# 커스텀 미들웨어 추가
|
||||
app.add_middleware(SecurityHeadersMiddleware)
|
||||
app.add_middleware(LoggingMiddleware)
|
||||
app.add_middleware(
|
||||
RateLimitMiddleware,
|
||||
requests_per_minute=settings.RATE_LIMIT_REQUESTS,
|
||||
period=settings.RATE_LIMIT_PERIOD
|
||||
)
|
||||
|
||||
# 글로벌 예외 처리기
|
||||
@app.exception_handler(BaseAPIException)
|
||||
async def api_exception_handler(request: Request, exc: BaseAPIException):
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={
|
||||
"error": {
|
||||
"code": exc.error_code,
|
||||
"message": exc.detail,
|
||||
"path": str(request.url)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler(request: Request, exc: HTTPException):
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={
|
||||
"error": {
|
||||
"code": f"HTTP_{exc.status_code}",
|
||||
"message": exc.detail,
|
||||
"path": str(request.url)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"예상치 못한 오류: {str(exc)}", exc_info=True)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"error": {
|
||||
"code": "INTERNAL_SERVER_ERROR",
|
||||
"message": "Internal server error" if settings.is_production else str(exc),
|
||||
"path": str(request.url)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# 라우터 등록
|
||||
app.include_router(analysis_router, prefix=settings.API_V1_STR)
|
||||
app.include_router(member_router, prefix=settings.API_V1_STR)
|
||||
|
||||
# 헬스 체크 엔드포인트
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {
|
||||
"status": "healthy",
|
||||
"environment": settings.ENVIRONMENT,
|
||||
"timestamp": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
async def root():
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("📍 루트 엔드포인트 호출됨")
|
||||
return {"message": "Trading Agents API"}
|
||||
return {
|
||||
"message": "Trading Agents API",
|
||||
"version": "1.0.0",
|
||||
"environment": settings.ENVIRONMENT,
|
||||
"docs_url": "/docs"
|
||||
}
|
||||
|
|
@ -26,7 +26,7 @@ class CurrentMember(BaseModel):
|
|||
def __str__(self):
|
||||
return f"{self.id}({self.role})"
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/members/login")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/members/login")
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -4,29 +4,81 @@ from sqlmodel import SQLModel, create_engine, Session
|
|||
from config.config import get_settings
|
||||
from member.infra.db_models.member import Member
|
||||
from analysis.infra.db_models.analysis import Analysis
|
||||
import logging
|
||||
|
||||
class DatabaseConnectionError(Exception):
|
||||
"""데이터베이스 연결 오류"""
|
||||
pass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
# MySQL 데이터베이스 URL 구성
|
||||
DATABASE_URL = f"mysql+pymysql://{settings.DB_USER}:{settings.DB_PASSWORD}@{settings.DB_HOST}:{settings.DB_PORT}/{settings.DB_NAME}?charset=utf8mb4"
|
||||
# 데이터베이스 연결 설정 개선
|
||||
engine_config = {
|
||||
"echo": settings.DEBUG, # 프로덕션에서는 SQL 로그 비활성화
|
||||
"pool_size": 10, # 연결 풀 크기
|
||||
"max_overflow": 20, # 최대 초과 연결 수
|
||||
"pool_pre_ping": True, # 연결 상태 확인
|
||||
"pool_recycle": 3600, # 1시간마다 연결 재사용
|
||||
"connect_args": {
|
||||
"charset": "utf8mb4",
|
||||
"connect_timeout": 10,
|
||||
"read_timeout": 30,
|
||||
"write_timeout": 30,
|
||||
"init_command": "SET sql_mode='STRICT_TRANS_TABLES'",
|
||||
}
|
||||
}
|
||||
|
||||
# MySQL 엔진 생성
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
echo=True
|
||||
)
|
||||
try:
|
||||
engine = create_engine(settings.database_url, **engine_config)
|
||||
logger.info("데이터베이스 엔진 생성 완료")
|
||||
except Exception as e:
|
||||
logger.error(f"데이터베이스 엔진 생성 실패: {str(e)}")
|
||||
raise DatabaseConnectionError()
|
||||
|
||||
def get_session():
|
||||
with Session(engine) as session:
|
||||
"""데이터베이스 세션 생성"""
|
||||
session = Session(engine)
|
||||
try:
|
||||
yield session
|
||||
session.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"데이터베이스 트랜잭션 실패: {str(e)}")
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def create_db_and_tables():
|
||||
# 테이블 생성
|
||||
# SQLModel.metadata.drop_all(engine)
|
||||
SQLModel.metadata.create_all(engine)
|
||||
"""테이블 생성"""
|
||||
try:
|
||||
# 개발 환경에서만 테이블 자동 생성
|
||||
if not settings.is_production:
|
||||
SQLModel.metadata.create_all(engine)
|
||||
logger.info("데이터베이스 테이블 생성 완료")
|
||||
else:
|
||||
logger.info("프로덕션 환경 - 테이블 자동 생성 건너뜀")
|
||||
except Exception as e:
|
||||
logger.error(f"테이블 생성 실패: {str(e)}")
|
||||
raise DatabaseConnectionError()
|
||||
|
||||
def check_db_connection():
|
||||
"""데이터베이스 연결 확인"""
|
||||
try:
|
||||
with Session(engine) as session:
|
||||
session.exec("SELECT 1")
|
||||
logger.info("데이터베이스 연결 확인 완료")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"데이터베이스 연결 실패: {str(e)}")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
create_db_and_tables()
|
||||
print(DATABASE_URL)
|
||||
if check_db_connection():
|
||||
print("✅ 데이터베이스 연결 성공")
|
||||
else:
|
||||
print("❌ 데이터베이스 연결 실패")
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
from fastapi import HTTPException, status
|
||||
from typing import Any, Dict, Optional
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class BaseAPIException(HTTPException):
|
||||
"""기본 API 예외 클래스"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
status_code: int,
|
||||
detail: str,
|
||||
error_code: str = None,
|
||||
headers: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
super().__init__(status_code=status_code, detail=detail, headers=headers)
|
||||
self.error_code = error_code or self.__class__.__name__
|
||||
logger.error(f"API Exception: {self.error_code} - {detail}")
|
||||
|
||||
# 인증/권한 관련 예외
|
||||
class AuthenticationError(BaseAPIException):
|
||||
"""인증 실패 예외"""
|
||||
def __init__(self, detail: str = "Authentication failed"):
|
||||
super().__init__(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=detail,
|
||||
error_code="AUTH_001"
|
||||
)
|
||||
|
||||
class AuthorizationError(BaseAPIException):
|
||||
"""권한 부족 예외"""
|
||||
def __init__(self, detail: str = "Insufficient permissions"):
|
||||
super().__init__(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=detail,
|
||||
error_code="AUTH_002"
|
||||
)
|
||||
|
||||
class InvalidTokenError(BaseAPIException):
|
||||
"""토큰 오류 예외"""
|
||||
def __init__(self, detail: str = "Invalid or expired token"):
|
||||
super().__init__(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=detail,
|
||||
error_code="AUTH_003"
|
||||
)
|
||||
|
||||
# 비즈니스 로직 관련 예외
|
||||
class ResourceNotFoundError(BaseAPIException):
|
||||
"""리소스 찾을 수 없음 예외"""
|
||||
def __init__(self, resource_type: str, resource_id: str = None):
|
||||
detail = f"{resource_type} not found"
|
||||
if resource_id:
|
||||
detail += f" (ID: {resource_id})"
|
||||
super().__init__(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=detail,
|
||||
error_code="BIZ_001"
|
||||
)
|
||||
|
||||
class DuplicateResourceError(BaseAPIException):
|
||||
"""중복 리소스 예외"""
|
||||
def __init__(self, resource_type: str, field: str = None):
|
||||
detail = f"{resource_type} already exists"
|
||||
if field:
|
||||
detail += f" (field: {field})"
|
||||
super().__init__(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=detail,
|
||||
error_code="BIZ_002"
|
||||
)
|
||||
|
||||
class ValidationError(BaseAPIException):
|
||||
"""입력 검증 실패 예외"""
|
||||
def __init__(self, detail: str = "Validation failed", field: str = None):
|
||||
if field:
|
||||
detail = f"Validation failed for field: {field} - {detail}"
|
||||
super().__init__(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=detail,
|
||||
error_code="VAL_001"
|
||||
)
|
||||
|
||||
class BusinessLogicError(BaseAPIException):
|
||||
"""비즈니스 로직 오류 예외"""
|
||||
def __init__(self, detail: str):
|
||||
super().__init__(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=detail,
|
||||
error_code="BIZ_003"
|
||||
)
|
||||
|
||||
# 분석 관련 예외
|
||||
class AnalysisError(BaseAPIException):
|
||||
"""분석 실행 오류 예외"""
|
||||
def __init__(self, detail: str = "Analysis execution failed"):
|
||||
super().__init__(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=detail,
|
||||
error_code="ANALYSIS_001"
|
||||
)
|
||||
|
||||
class AnalysisNotFoundError(ResourceNotFoundError):
|
||||
"""분석 세션 찾을 수 없음 예외"""
|
||||
def __init__(self, analysis_id: str = None):
|
||||
super().__init__("Analysis", analysis_id)
|
||||
self.error_code = "ANALYSIS_002"
|
||||
|
||||
class AnalysisAccessDeniedError(AuthorizationError):
|
||||
"""분석 접근 권한 없음 예외"""
|
||||
def __init__(self, analysis_id: str = None):
|
||||
detail = "Access denied to analysis"
|
||||
if analysis_id:
|
||||
detail += f" (ID: {analysis_id})"
|
||||
super().__init__(detail)
|
||||
self.error_code = "ANALYSIS_003"
|
||||
|
||||
# 멤버 관련 예외
|
||||
class MemberNotFoundError(ResourceNotFoundError):
|
||||
"""멤버 찾을 수 없음 예외"""
|
||||
def __init__(self, member_id: str = None):
|
||||
super().__init__("Member", member_id)
|
||||
self.error_code = "MEMBER_001"
|
||||
|
||||
class MemberAlreadyExistsError(DuplicateResourceError):
|
||||
"""멤버 이미 존재 예외"""
|
||||
def __init__(self, field: str = "email"):
|
||||
super().__init__("Member", field)
|
||||
self.error_code = "MEMBER_002"
|
||||
|
||||
class InvalidCredentialsError(BaseAPIException):
|
||||
"""잘못된 로그인 정보 예외"""
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid email or password",
|
||||
error_code="MEMBER_003"
|
||||
)
|
||||
|
||||
# 데이터베이스 관련 예외
|
||||
class DatabaseError(BaseAPIException):
|
||||
"""데이터베이스 오류 예외"""
|
||||
def __init__(self, detail: str = "Database operation failed"):
|
||||
super().__init__(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=detail,
|
||||
error_code="DB_001"
|
||||
)
|
||||
|
||||
class DatabaseConnectionError(DatabaseError):
|
||||
"""데이터베이스 연결 오류 예외"""
|
||||
def __init__(self):
|
||||
super().__init__("Database connection failed")
|
||||
self.error_code = "DB_002"
|
||||
|
||||
# 외부 서비스 관련 예외
|
||||
class ExternalServiceError(BaseAPIException):
|
||||
"""외부 서비스 오류 예외"""
|
||||
def __init__(self, service_name: str, detail: str = "External service error"):
|
||||
super().__init__(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"{service_name}: {detail}",
|
||||
error_code="EXT_001"
|
||||
)
|
||||
|
||||
class TradingAgentsServiceError(ExternalServiceError):
|
||||
"""TradingAgents 서비스 오류 예외"""
|
||||
def __init__(self, detail: str = "TradingAgents service error"):
|
||||
super().__init__("TradingAgents", detail)
|
||||
self.error_code = "EXT_002"
|
||||
|
||||
# 에러 핸들러 유틸리티
|
||||
def handle_database_error(e: Exception, operation: str = "database operation") -> DatabaseError:
|
||||
"""데이터베이스 예외를 처리하고 적절한 예외로 변환"""
|
||||
logger.error(f"Database error during {operation}: {str(e)}", exc_info=True)
|
||||
|
||||
# 특정 데이터베이스 오류를 더 구체적인 예외로 변환
|
||||
error_message = str(e).lower()
|
||||
|
||||
if "connection" in error_message:
|
||||
return DatabaseConnectionError()
|
||||
elif "duplicate" in error_message or "unique constraint" in error_message:
|
||||
return DuplicateResourceError("Resource", "unique field")
|
||||
elif "foreign key" in error_message:
|
||||
return ValidationError("Referenced resource does not exist")
|
||||
else:
|
||||
return DatabaseError(f"Database operation failed: {operation}")
|
||||
|
||||
def handle_validation_error(e: Exception, field: str = None) -> ValidationError:
|
||||
"""입력 검증 예외를 처리"""
|
||||
logger.warning(f"Validation error: {str(e)}")
|
||||
return ValidationError(str(e), field)
|
||||
|
||||
def handle_business_logic_error(e: Exception, context: str = None) -> BusinessLogicError:
|
||||
"""비즈니스 로직 예외를 처리"""
|
||||
detail = str(e)
|
||||
if context:
|
||||
detail = f"{context}: {detail}"
|
||||
logger.error(f"Business logic error: {detail}")
|
||||
return BusinessLogicError(detail)
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
from fastapi import Request, Response, HTTPException, status
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.types import ASGIApp
|
||||
import time
|
||||
import logging
|
||||
from typing import Callable, Dict
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
"""보안 헤더 추가 미들웨어"""
|
||||
|
||||
def __init__(self, app: ASGIApp):
|
||||
super().__init__(app)
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
response = await call_next(request)
|
||||
|
||||
# 보안 헤더 추가
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
response.headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data: https://fastapi.tiangolo.com"
|
||||
|
||||
return response
|
||||
|
||||
class LoggingMiddleware(BaseHTTPMiddleware):
|
||||
"""요청/응답 로깅 미들웨어"""
|
||||
|
||||
def __init__(self, app: ASGIApp):
|
||||
super().__init__(app)
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
start_time = time.time()
|
||||
|
||||
# 요청 로깅
|
||||
logger.info(
|
||||
f"Request started: {request.method} {request.url.path} "
|
||||
f"from {request.client.host if request.client else 'unknown'}"
|
||||
)
|
||||
|
||||
try:
|
||||
response = await call_next(request)
|
||||
|
||||
# 응답 로깅
|
||||
process_time = time.time() - start_time
|
||||
logger.info(
|
||||
f"Request completed: {request.method} {request.url.path} "
|
||||
f"- Status: {response.status_code} - Duration: {process_time:.4f}s"
|
||||
)
|
||||
|
||||
response.headers["X-Process-Time"] = str(process_time)
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
process_time = time.time() - start_time
|
||||
logger.error(
|
||||
f"Request failed: {request.method} {request.url.path} "
|
||||
f"- Error: {str(e)} - Duration: {process_time:.4f}s"
|
||||
)
|
||||
raise
|
||||
|
||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
"""간단한 Rate Limiting 미들웨어"""
|
||||
|
||||
def __init__(self, app: ASGIApp, requests_per_minute: int = 100, period: int = 60):
|
||||
super().__init__(app)
|
||||
self.requests_per_minute = requests_per_minute
|
||||
self.period = period
|
||||
self.requests: Dict[str, list] = {}
|
||||
|
||||
def _get_client_ip(self, request: Request) -> str:
|
||||
"""클라이언트 IP 주소 획득"""
|
||||
forwarded = request.headers.get("X-Forwarded-For")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
def _clean_old_requests(self, client_ip: str) -> None:
|
||||
"""오래된 요청 기록 정리"""
|
||||
now = datetime.now()
|
||||
cutoff = now - timedelta(seconds=self.period)
|
||||
|
||||
if client_ip in self.requests:
|
||||
self.requests[client_ip] = [
|
||||
req_time for req_time in self.requests[client_ip]
|
||||
if req_time > cutoff
|
||||
]
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
client_ip = self._get_client_ip(request)
|
||||
now = datetime.now()
|
||||
|
||||
# 오래된 요청 정리
|
||||
self._clean_old_requests(client_ip)
|
||||
|
||||
# 해당 클라이언트의 요청 목록 초기화
|
||||
if client_ip not in self.requests:
|
||||
self.requests[client_ip] = []
|
||||
|
||||
current_requests = len(self.requests[client_ip])
|
||||
|
||||
if current_requests >= self.requests_per_minute:
|
||||
logger.warning(f"Rate limit exceeded for client: {client_ip}")
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
content={
|
||||
"error": "Rate limit exceeded",
|
||||
"message": f"Too many requests. Max {self.requests_per_minute} requests per {self.period} seconds.",
|
||||
"retry_after": self.period
|
||||
}
|
||||
)
|
||||
|
||||
# 현재 요청 기록
|
||||
self.requests[client_ip].append(now)
|
||||
|
||||
response = await call_next(request)
|
||||
|
||||
# Rate limit 정보 헤더 추가
|
||||
response.headers["X-RateLimit-Limit"] = str(self.requests_per_minute)
|
||||
response.headers["X-RateLimit-Remaining"] = str(self.requests_per_minute - current_requests - 1)
|
||||
response.headers["X-RateLimit-Reset"] = str(int((now + timedelta(seconds=self.period)).timestamp()))
|
||||
|
||||
return response
|
||||
|
||||
class ErrorHandlingMiddleware(BaseHTTPMiddleware):
|
||||
"""전역 에러 핸들링 미들웨어"""
|
||||
|
||||
def __init__(self, app: ASGIApp):
|
||||
super().__init__(app)
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
try:
|
||||
response = await call_next(request)
|
||||
return response
|
||||
except HTTPException as e:
|
||||
# HTTPException은 FastAPI에서 자동으로 처리되므로 다시 raise
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Unhandled error in request {request.method} {request.url.path}: {str(e)}", exc_info=True)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={
|
||||
"error": "Internal Server Error",
|
||||
"message": "An unexpected error occurred. Please try again later.",
|
||||
"request_id": getattr(request.state, 'request_id', 'unknown')
|
||||
}
|
||||
)
|
||||
|
||||
def setup_cors_middleware(app, allowed_origins: list[str]):
|
||||
"""CORS 미들웨어 설정"""
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=allowed_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=["X-Process-Time", "X-RateLimit-Limit", "X-RateLimit-Remaining", "X-RateLimit-Reset"]
|
||||
)
|
||||
|
||||
def setup_all_middlewares(app, settings):
|
||||
"""모든 미들웨어 설정"""
|
||||
# 주의: 미들웨어는 나중에 등록된 것부터 먼저 실행됨
|
||||
|
||||
# 1. CORS (가장 먼저 실행되어야 함)
|
||||
setup_cors_middleware(app, settings.allowed_origins_list)
|
||||
|
||||
# 2. 보안 헤더
|
||||
app.add_middleware(SecurityHeadersMiddleware)
|
||||
|
||||
# 3. Rate Limiting
|
||||
app.add_middleware(
|
||||
RateLimitMiddleware,
|
||||
requests_per_minute=settings.RATE_LIMIT_REQUESTS,
|
||||
period=settings.RATE_LIMIT_PERIOD
|
||||
)
|
||||
|
||||
# 4. 로깅 (가장 안쪽에서 로깅)
|
||||
app.add_middleware(LoggingMiddleware)
|
||||
|
||||
# 5. 에러 핸들링 (가장 바깥쪽에서 에러 캐치)
|
||||
app.add_middleware(ErrorHandlingMiddleware)
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
REACT_APP_API_URL=http://localhost:8000
|
||||
REACT_APP_WS_URL=ws://localhost:8000
|
||||
|
|
@ -1 +1,20 @@
|
|||
node_modules
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/react
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=react
|
||||
|
||||
### react ###
|
||||
.DS_*
|
||||
*.log
|
||||
logs
|
||||
**/*.backup.*
|
||||
**/*.back.*
|
||||
|
||||
node_modules
|
||||
bower_components
|
||||
|
||||
*.sublime*
|
||||
|
||||
psd
|
||||
thumb
|
||||
sketch
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/react
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
# Trading Agents Frontend
|
||||
|
||||
React + TypeScript로 구현된 Trading Agents 프론트엔드입니다.
|
||||
|
||||
## 주요 기능
|
||||
|
||||
- **사용자 인증**: 회원가입, 로그인, JWT 토큰 관리
|
||||
- **분석 관리**: 주식 분석 요청, 진행 상황 추적, 결과 조회
|
||||
- **실시간 업데이트**: WebSocket을 통한 실시간 분석 진행 상황 업데이트
|
||||
- **반응형 디자인**: 모바일 및 데스크톱 지원
|
||||
|
||||
## 기술 스택
|
||||
|
||||
- **React 18**: 메인 프론트엔드 프레임워크
|
||||
- **TypeScript**: 타입 안정성
|
||||
- **React Router**: 라우팅
|
||||
- **Styled Components**: CSS-in-JS 스타일링
|
||||
- **React Hook Form**: 폼 관리
|
||||
- **Yup**: 폼 유효성 검사
|
||||
- **React Query**: 서버 상태 관리
|
||||
- **Axios**: HTTP 클라이언트
|
||||
- **React Hot Toast**: 토스트 알림
|
||||
|
||||
## 프로젝트 구조
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/ # 재사용 가능한 컴포넌트
|
||||
│ ├── auth/ # 인증 관련 컴포넌트
|
||||
│ ├── analysis/ # 분석 관련 컴포넌트
|
||||
│ └── common/ # 공통 컴포넌트
|
||||
├── contexts/ # React Context
|
||||
├── hooks/ # 커스텀 훅
|
||||
├── pages/ # 페이지 컴포넌트
|
||||
├── services/ # API 서비스
|
||||
├── types/ # TypeScript 타입 정의
|
||||
└── utils/ # 유틸리티 함수
|
||||
```
|
||||
|
||||
## 설치 및 실행
|
||||
|
||||
1. 의존성 설치:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. 환경 변수 설정:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
3. 개발 서버 실행:
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
4. 프로덕션 빌드:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 환경 변수
|
||||
|
||||
- `REACT_APP_API_URL`: 백엔드 API 서버 URL
|
||||
- `REACT_APP_WS_URL`: WebSocket 서버 URL
|
||||
|
||||
## 주요 컴포넌트
|
||||
|
||||
### 인증 관련
|
||||
- `LoginForm`: 로그인 폼
|
||||
- `RegisterForm`: 회원가입 폼
|
||||
- `AuthContext`: 인증 상태 관리
|
||||
|
||||
### 분석 관련
|
||||
- `AnalysisForm`: 새 분석 요청 폼
|
||||
- `AnalysisList`: 분석 세션 목록
|
||||
- `AnalysisResult`: 분석 결과 표시
|
||||
|
||||
### 공통
|
||||
- `Layout`: 공통 레이아웃
|
||||
- `ProtectedRoute`: 인증된 사용자만 접근 가능한 라우트
|
||||
|
||||
## API 연동
|
||||
|
||||
백엔드와의 통신을 위해 다음 서비스들을 사용합니다:
|
||||
|
||||
- `AuthService`: 인증 관련 API
|
||||
- `AnalysisService`: 분석 관련 API
|
||||
- WebSocket 연결을 통한 실시간 업데이트
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,24 +1,32 @@
|
|||
{
|
||||
"name": "tradingagents-web-frontend",
|
||||
"name": "trading-agents-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
"@testing-library/jest-dom": "^5.16.4",
|
||||
"@testing-library/react": "^13.3.0",
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"antd": "^5.10.0",
|
||||
"axios": "^1.5.0",
|
||||
"dayjs": "^1.11.9",
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/node": "^16.18.68",
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-router-dom": "^6.4.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"recharts": "^2.8.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"styled-components": "^6.0.8",
|
||||
"websocket": "^1.0.34"
|
||||
"typescript": "^4.9.5",
|
||||
"web-vitals": "^2.1.4",
|
||||
"react-router-dom": "^6.20.1",
|
||||
"axios": "^1.6.2",
|
||||
"react-query": "^3.39.3",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"react-hook-form": "^7.48.2",
|
||||
"@hookform/resolvers": "^3.3.2",
|
||||
"yup": "^1.3.3",
|
||||
"styled-components": "^6.1.1",
|
||||
"@types/styled-components": "^5.1.34",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"remark-gfm": "^4.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
|
|
@ -44,5 +52,7 @@
|
|||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"proxy": "http://localhost:8000"
|
||||
}
|
||||
"devDependencies": {
|
||||
"@types/react-query": "^1.2.9"
|
||||
}
|
||||
}
|
||||
|
|
@ -7,14 +7,14 @@
|
|||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="TradingAgents - Multi-Agents LLM Financial Trading Framework"
|
||||
content="Trading Agents - AI 기반 주식 분석 플랫폼"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<title>TradingAgents - AI 거래 분석 플랫폼</title>
|
||||
<title>Trading Agents</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>JavaScript를 활성화해야 이 앱을 실행할 수 있습니다.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"es6"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": "src",
|
||||
"paths": {
|
||||
"@/*": ["*"],
|
||||
"@/components/*": ["components/*"],
|
||||
"@/pages/*": ["pages/*"],
|
||||
"@/hooks/*": ["hooks/*"],
|
||||
"@/services/*": ["services/*"],
|
||||
"@/types/*": ["types/*"],
|
||||
"@/utils/*": ["utils/*"],
|
||||
"@/contexts/*": ["contexts/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue