feat: SWG 가스켓 전체 구성 정보 표시 개선
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled

- H/F/I/O SS304/GRAPHITE/CS/CS 패턴에서 4개 구성요소 모두 표시
- 기존 SS304 + GRAPHITE → SS304/GRAPHITE/CS/CS로 완전한 구성 표시
- 외부링/필러/내부링/추가구성 모든 정보 포함
- 구매수량 계산 모달에서 정확한 재질 정보 확인 가능
This commit is contained in:
Hyungi Ahn
2025-08-30 14:23:01 +09:00
parent 78d90c7a8f
commit 4f8e395f87
84 changed files with 16297 additions and 2161 deletions

View File

@@ -1 +1,4 @@
# API 라우터 패키지
"""
API 모듈
분리된 API 엔드포인트들
"""

View File

@@ -0,0 +1,56 @@
"""
파일 관리 API
main.py에서 분리된 파일 관련 엔드포인트들
"""
from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.orm import Session
from typing import Optional
from ..database import get_db
from ..utils.logger import get_logger
from ..schemas import FileListResponse, FileDeleteResponse, FileInfo
from ..services.file_service import get_file_service
router = APIRouter()
logger = get_logger(__name__)
@router.get("/files", response_model=FileListResponse)
async def get_files(
job_no: Optional[str] = None,
show_history: bool = False,
use_cache: bool = True,
db: Session = Depends(get_db)
) -> FileListResponse:
"""파일 목록 조회 (BOM별 그룹화)"""
file_service = get_file_service(db)
# 서비스 레이어 호출
files, cache_hit = await file_service.get_files(job_no, show_history, use_cache)
return FileListResponse(
success=True,
message="파일 목록 조회 성공" + (" (캐시)" if cache_hit else ""),
data=files,
total_count=len(files),
cache_hit=cache_hit
)
@router.delete("/files/{file_id}", response_model=FileDeleteResponse)
async def delete_file(
file_id: int,
db: Session = Depends(get_db)
) -> FileDeleteResponse:
"""파일 삭제"""
file_service = get_file_service(db)
# 서비스 레이어 호출
result = await file_service.delete_file(file_id)
return FileDeleteResponse(
success=result["success"],
message=result["message"],
deleted_file_id=result["deleted_file_id"]
)

View File

@@ -0,0 +1,63 @@
"""
인증 모듈 초기화
TK-MP-Project 인증 시스템의 모든 컴포넌트를 노출
"""
from .jwt_service import jwt_service, JWTService
from .auth_service import AuthService, get_auth_service
from .auth_controller import router as auth_router
from .middleware import (
auth_middleware,
get_current_user,
get_current_active_user,
require_admin,
require_leader_or_admin,
require_roles,
require_permissions,
get_user_from_token,
check_user_permission,
get_user_permissions_by_role,
get_current_user_optional
)
from .models import (
User,
LoginLog,
UserSession,
Permission,
RolePermission,
UserRepository
)
__all__ = [
# JWT 서비스
'jwt_service',
'JWTService',
# 인증 서비스
'AuthService',
'get_auth_service',
# 라우터
'auth_router',
# 미들웨어 및 의존성
'auth_middleware',
'get_current_user',
'get_current_active_user',
'require_admin',
'require_leader_or_admin',
'require_roles',
'require_permissions',
'get_user_from_token',
'check_user_permission',
'get_user_permissions_by_role',
'get_current_user_optional',
# 모델
'User',
'LoginLog',
'UserSession',
'Permission',
'RolePermission',
'UserRepository'
]

View File

@@ -0,0 +1,393 @@
"""
인증 컨트롤러
TK-FB-Project의 authController.js를 참고하여 FastAPI용으로 구현
"""
from fastapi import APIRouter, Depends, HTTPException, status, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from pydantic import BaseModel, EmailStr
from typing import Optional, List, Dict, Any
from ..database import get_db
from .auth_service import get_auth_service
from .jwt_service import jwt_service
from .models import UserRepository
from ..utils.logger import get_logger
logger = get_logger(__name__)
router = APIRouter()
security = HTTPBearer()
# Pydantic 모델들
class LoginRequest(BaseModel):
username: str
password: str
class RegisterRequest(BaseModel):
username: str
password: str
name: str
email: Optional[EmailStr] = None
access_level: str = 'worker'
department: Optional[str] = None
position: Optional[str] = None
phone: Optional[str] = None
class RefreshTokenRequest(BaseModel):
refresh_token: str
class LoginResponse(BaseModel):
success: bool
access_token: str
refresh_token: str
token_type: str
expires_in: int
user: Dict[str, Any]
redirect_url: str
permissions: List[str]
class RefreshTokenResponse(BaseModel):
success: bool
access_token: str
token_type: str
expires_in: int
user: Dict[str, Any]
class RegisterResponse(BaseModel):
success: bool
message: str
user_id: int
username: str
class LogoutResponse(BaseModel):
success: bool
message: str
class UserInfoResponse(BaseModel):
success: bool
user: Dict[str, Any]
permissions: List[str]
@router.post("/login", response_model=LoginResponse)
async def login(
login_data: LoginRequest,
request: Request,
db: Session = Depends(get_db)
):
"""
사용자 로그인
Args:
login_data: 로그인 정보 (사용자명, 비밀번호)
request: FastAPI Request 객체
db: 데이터베이스 세션
Returns:
LoginResponse: 로그인 결과 (토큰, 사용자 정보 등)
"""
try:
auth_service = get_auth_service(db)
result = await auth_service.login(
username=login_data.username,
password=login_data.password,
request=request
)
return LoginResponse(**result)
except Exception as e:
logger.error(f"Login endpoint error: {str(e)}")
raise
@router.post("/register", response_model=RegisterResponse)
async def register(
register_data: RegisterRequest,
db: Session = Depends(get_db)
):
"""
사용자 등록
Args:
register_data: 등록 정보
db: 데이터베이스 세션
Returns:
RegisterResponse: 등록 결과
"""
try:
auth_service = get_auth_service(db)
result = await auth_service.register(register_data.dict())
return RegisterResponse(**result)
except Exception as e:
logger.error(f"Register endpoint error: {str(e)}")
raise
@router.post("/refresh", response_model=RefreshTokenResponse)
async def refresh_token(
refresh_data: RefreshTokenRequest,
request: Request,
db: Session = Depends(get_db)
):
"""
토큰 갱신
Args:
refresh_data: 리프레시 토큰 정보
request: FastAPI Request 객체
db: 데이터베이스 세션
Returns:
RefreshTokenResponse: 새로운 토큰 정보
"""
try:
auth_service = get_auth_service(db)
result = await auth_service.refresh_token(
refresh_token=refresh_data.refresh_token,
request=request
)
return RefreshTokenResponse(**result)
except Exception as e:
logger.error(f"Refresh token endpoint error: {str(e)}")
raise
@router.post("/logout", response_model=LogoutResponse)
async def logout(
refresh_data: RefreshTokenRequest,
db: Session = Depends(get_db)
):
"""
로그아웃
Args:
refresh_data: 리프레시 토큰 정보
db: 데이터베이스 세션
Returns:
LogoutResponse: 로그아웃 결과
"""
try:
auth_service = get_auth_service(db)
result = await auth_service.logout(refresh_data.refresh_token)
return LogoutResponse(**result)
except Exception as e:
logger.error(f"Logout endpoint error: {str(e)}")
raise
@router.get("/me", response_model=UserInfoResponse)
async def get_current_user_info(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
):
"""
현재 사용자 정보 조회
Args:
credentials: JWT 토큰
db: 데이터베이스 세션
Returns:
UserInfoResponse: 사용자 정보 및 권한
"""
try:
# 토큰 검증
payload = jwt_service.verify_access_token(credentials.credentials)
user_id = payload['user_id']
# 사용자 정보 조회
user_repo = UserRepository(db)
user = user_repo.find_by_id(user_id)
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="사용자를 찾을 수 없거나 비활성화된 계정입니다"
)
# 권한 정보 조회
permissions = user_repo.get_user_permissions(user.role)
return UserInfoResponse(
success=True,
user=user.to_dict(),
permissions=permissions
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Get current user info error: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="사용자 정보 조회 중 오류가 발생했습니다"
)
@router.get("/verify")
async def verify_token(
credentials: HTTPAuthorizationCredentials = Depends(security)
):
"""
토큰 검증
Args:
credentials: JWT 토큰
Returns:
Dict: 토큰 검증 결과
"""
try:
payload = jwt_service.verify_access_token(credentials.credentials)
return {
'success': True,
'valid': True,
'user_id': payload['user_id'],
'username': payload['username'],
'role': payload['role'],
'expires_at': payload.get('exp')
}
except HTTPException as e:
return {
'success': False,
'valid': False,
'error': e.detail
}
except Exception as e:
logger.error(f"Token verification error: {str(e)}")
return {
'success': False,
'valid': False,
'error': '토큰 검증 중 오류가 발생했습니다'
}
# 관리자 전용 엔드포인트들
@router.get("/users")
async def get_all_users(
skip: int = 0,
limit: int = 100,
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
):
"""
모든 사용자 목록 조회 (관리자 전용)
Args:
skip: 건너뛸 레코드 수
limit: 조회할 레코드 수
credentials: JWT 토큰
db: 데이터베이스 세션
Returns:
Dict: 사용자 목록
"""
try:
# 토큰 검증 및 권한 확인
payload = jwt_service.verify_access_token(credentials.credentials)
if payload['role'] not in ['admin', 'system']:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="관리자 권한이 필요합니다"
)
# 사용자 목록 조회
user_repo = UserRepository(db)
users = user_repo.get_all_users(skip=skip, limit=limit)
return {
'success': True,
'users': [user.to_dict() for user in users],
'total_count': len(users)
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Get all users error: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="사용자 목록 조회 중 오류가 발생했습니다"
)
@router.delete("/users/{user_id}")
async def delete_user(
user_id: int,
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
):
"""
사용자 삭제 (관리자 전용)
Args:
user_id: 삭제할 사용자 ID
credentials: JWT 토큰
db: 데이터베이스 세션
Returns:
Dict: 삭제 결과
"""
try:
# 토큰 검증 및 권한 확인
payload = jwt_service.verify_access_token(credentials.credentials)
if payload['role'] not in ['admin', 'system']:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="관리자 권한이 필요합니다"
)
# 자기 자신 삭제 방지
if payload['user_id'] == user_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="자기 자신은 삭제할 수 없습니다"
)
# 사용자 조회 및 삭제
user_repo = UserRepository(db)
user = user_repo.find_by_id(user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="해당 사용자를 찾을 수 없습니다"
)
user_repo.delete_user(user)
logger.info(f"User deleted by admin: {user.username} (deleted by: {payload['username']})")
return {
'success': True,
'message': '사용자가 삭제되었습니다',
'deleted_user_id': user_id
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Delete user error: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="사용자 삭제 중 오류가 발생했습니다"
)

View File

@@ -0,0 +1,372 @@
"""
인증 서비스
TK-FB-Project의 auth.service.js를 참고하여 FastAPI용으로 구현
"""
from typing import Dict, Any, Optional, Tuple
from datetime import datetime, timedelta
from fastapi import HTTPException, status, Request
from sqlalchemy.orm import Session
from .models import User, UserRepository
from .jwt_service import jwt_service
from ..utils.logger import get_logger
from ..utils.error_handlers import TKMPException
logger = get_logger(__name__)
class AuthService:
"""인증 서비스 클래스"""
def __init__(self, db: Session):
self.db = db
self.user_repo = UserRepository(db)
async def login(self, username: str, password: str, request: Request) -> Dict[str, Any]:
"""
사용자 로그인
Args:
username: 사용자명
password: 비밀번호
request: FastAPI Request 객체
Returns:
Dict[str, Any]: 로그인 결과 (토큰, 사용자 정보 등)
Raises:
TKMPException: 로그인 실패 시
"""
try:
# 클라이언트 정보 추출
ip_address = self._get_client_ip(request)
user_agent = request.headers.get('user-agent', 'unknown')
logger.info(f"Login attempt for username: {username} from IP: {ip_address}")
# 입력 검증
if not username or not password:
await self._record_login_failure(None, ip_address, user_agent, 'missing_credentials')
raise TKMPException(
message="사용자명과 비밀번호를 입력해주세요",
status_code=status.HTTP_400_BAD_REQUEST
)
# 사용자 조회
user = self.user_repo.find_by_username(username)
if not user:
await self._record_login_failure(None, ip_address, user_agent, 'user_not_found')
logger.warning(f"Login failed - user not found: {username}")
raise TKMPException(
status_code=status.HTTP_401_UNAUTHORIZED,
message="아이디 또는 비밀번호가 올바르지 않습니다"
)
# 계정 활성화 상태 확인
if not user.is_active:
await self._record_login_failure(user.user_id, ip_address, user_agent, 'account_disabled')
logger.warning(f"Login failed - account disabled: {username}")
raise TKMPException(
status_code=status.HTTP_403_FORBIDDEN,
message="비활성화된 계정입니다. 관리자에게 문의하세요"
)
# 계정 잠금 상태 확인
if user.is_locked():
remaining_time = int((user.locked_until - datetime.utcnow()).total_seconds() / 60)
await self._record_login_failure(user.user_id, ip_address, user_agent, 'account_locked')
logger.warning(f"Login failed - account locked: {username}")
raise TKMPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
message=f"계정이 잠겨있습니다. {remaining_time}분 후에 다시 시도하세요"
)
# 비밀번호 확인
if not user.check_password(password):
# 로그인 실패 처리
user.increment_failed_attempts()
self.user_repo.update_user(user)
await self._record_login_failure(user.user_id, ip_address, user_agent, 'invalid_password')
logger.warning(f"Login failed - invalid password: {username}")
# 계정 잠금 확인
if user.failed_login_attempts >= 5:
logger.warning(f"Account locked due to failed attempts: {username}")
raise TKMPException(
message="아이디 또는 비밀번호가 올바르지 않습니다",
status_code=status.HTTP_401_UNAUTHORIZED
)
# 로그인 성공 처리
user.reset_failed_attempts()
user.update_last_login()
self.user_repo.update_user(user)
# 토큰 생성
user_data = user.to_dict()
access_token = jwt_service.create_access_token(user_data)
refresh_token = jwt_service.create_refresh_token(user.user_id)
# 세션 생성
expires_at = datetime.utcnow() + timedelta(days=7)
session = self.user_repo.create_session(
user_id=user.user_id,
refresh_token=refresh_token,
expires_at=expires_at,
ip_address=ip_address,
user_agent=user_agent
)
# 로그인 성공 기록
self.user_repo.record_login_log(
user_id=user.user_id,
ip_address=ip_address,
user_agent=user_agent,
status='success'
)
# 리디렉션 URL 결정
redirect_url = self._get_redirect_url(user.role)
logger.info(f"Login successful for user: {username} (role: {user.role})")
return {
'success': True,
'access_token': access_token,
'refresh_token': refresh_token,
'token_type': 'bearer',
'expires_in': 24 * 3600, # 24시간 (초)
'user': user_data,
'redirect_url': redirect_url,
'permissions': self.user_repo.get_user_permissions(user.role)
}
except TKMPException:
raise
except Exception as e:
logger.error(f"Login service error for {username}: {str(e)}")
raise TKMPException(
message="로그인 처리 중 서버 오류가 발생했습니다",
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
async def refresh_token(self, refresh_token: str, request: Request) -> Dict[str, Any]:
"""
토큰 갱신
Args:
refresh_token: 리프레시 토큰
request: FastAPI Request 객체
Returns:
Dict[str, Any]: 새로운 토큰 정보
"""
try:
# 리프레시 토큰 검증
payload = jwt_service.verify_refresh_token(refresh_token)
user_id = payload['user_id']
# 세션 확인
session = self.user_repo.find_session_by_token(refresh_token)
if not session or session.is_expired() or not session.is_active:
logger.warning(f"Invalid or expired refresh token for user_id: {user_id}")
raise TKMPException(
status_code=status.HTTP_401_UNAUTHORIZED,
message="유효하지 않거나 만료된 리프레시 토큰입니다"
)
# 사용자 조회
user = self.user_repo.find_by_id(user_id)
if not user or not user.is_active:
logger.warning(f"User not found or inactive for token refresh: {user_id}")
raise TKMPException(
status_code=status.HTTP_401_UNAUTHORIZED,
message="사용자를 찾을 수 없거나 비활성화된 계정입니다"
)
# 새 액세스 토큰 생성
user_data = user.to_dict()
new_access_token = jwt_service.create_access_token(user_data)
logger.info(f"Token refreshed for user: {user.username}")
return {
'success': True,
'access_token': new_access_token,
'token_type': 'bearer',
'expires_in': 24 * 3600,
'user': user_data
}
except TKMPException:
raise
except Exception as e:
logger.error(f"Token refresh error: {str(e)}")
raise TKMPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
message="토큰 갱신 중 서버 오류가 발생했습니다"
)
async def logout(self, refresh_token: str) -> Dict[str, Any]:
"""
로그아웃
Args:
refresh_token: 리프레시 토큰
Returns:
Dict[str, Any]: 로그아웃 결과
"""
try:
# 세션 찾기 및 비활성화
session = self.user_repo.find_session_by_token(refresh_token)
if session:
session.deactivate()
self.user_repo.update_user(session.user)
logger.info(f"User logged out: user_id {session.user_id}")
return {
'success': True,
'message': '로그아웃되었습니다'
}
except Exception as e:
logger.error(f"Logout error: {str(e)}")
raise TKMPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
message="로그아웃 처리 중 오류가 발생했습니다"
)
async def register(self, user_data: Dict[str, Any]) -> Dict[str, Any]:
"""
사용자 등록
Args:
user_data: 사용자 정보
Returns:
Dict[str, Any]: 등록 결과
"""
try:
# 필수 필드 검증
required_fields = ['username', 'password', 'name']
for field in required_fields:
if not user_data.get(field):
raise TKMPException(
status_code=status.HTTP_400_BAD_REQUEST,
message=f"{field}는 필수 입력 항목입니다"
)
# 중복 사용자명 확인
existing_user = self.user_repo.find_by_username(user_data['username'])
if existing_user:
raise TKMPException(
status_code=status.HTTP_409_CONFLICT,
message="이미 존재하는 사용자명입니다"
)
# 이메일 중복 확인 (이메일이 제공된 경우)
if user_data.get('email'):
existing_email = self.user_repo.find_by_email(user_data['email'])
if existing_email:
raise TKMPException(
status_code=status.HTTP_409_CONFLICT,
message="이미 사용 중인 이메일입니다"
)
# 역할 매핑
role_map = {
'admin': 'admin',
'system': 'system',
'group_leader': 'leader',
'support_team': 'support',
'worker': 'user'
}
access_level = user_data.get('access_level', 'worker')
role = role_map.get(access_level, 'user')
# 사용자 생성
new_user_data = {
'username': user_data['username'],
'name': user_data['name'],
'email': user_data.get('email'),
'role': role,
'access_level': access_level,
'department': user_data.get('department'),
'position': user_data.get('position'),
'phone': user_data.get('phone')
}
user = User(**new_user_data)
user.set_password(user_data['password'])
self.db.add(user)
self.db.commit()
self.db.refresh(user)
logger.info(f"User registered successfully: {user.username}")
return {
'success': True,
'message': '사용자 등록이 완료되었습니다',
'user_id': user.user_id,
'username': user.username
}
except TKMPException:
raise
except Exception as e:
self.db.rollback()
logger.error(f"User registration error: {str(e)}")
raise TKMPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
message="사용자 등록 중 서버 오류가 발생했습니다"
)
def _get_client_ip(self, request: Request) -> str:
"""클라이언트 IP 주소 추출"""
# X-Forwarded-For 헤더 확인 (프록시 환경)
forwarded_for = request.headers.get('x-forwarded-for')
if forwarded_for:
return forwarded_for.split(',')[0].strip()
# X-Real-IP 헤더 확인
real_ip = request.headers.get('x-real-ip')
if real_ip:
return real_ip
# 직접 연결된 클라이언트 IP
return request.client.host if request.client else 'unknown'
def _get_redirect_url(self, role: str) -> str:
"""역할에 따른 리디렉션 URL 결정"""
redirect_urls = {
'system': '/admin/system',
'admin': '/admin/dashboard',
'leader': '/dashboard/leader',
'support': '/dashboard/support',
'user': '/dashboard'
}
return redirect_urls.get(role, '/dashboard')
async def _record_login_failure(self, user_id: Optional[int], ip_address: str,
user_agent: str, failure_reason: str):
"""로그인 실패 기록"""
try:
if user_id:
self.user_repo.record_login_log(
user_id=user_id,
ip_address=ip_address,
user_agent=user_agent,
status='failed',
failure_reason=failure_reason
)
except Exception as e:
logger.error(f"Failed to record login failure: {str(e)}")
def get_auth_service(db: Session) -> AuthService:
"""인증 서비스 팩토리 함수"""
return AuthService(db)

View File

@@ -0,0 +1,251 @@
"""
JWT 토큰 관리 서비스
TK-FB-Project의 JWT 구현을 참고하여 FastAPI용으로 구현
"""
import jwt
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
from fastapi import HTTPException, status
import os
from ..config import get_settings
from ..utils.logger import get_logger
settings = get_settings()
logger = get_logger(__name__)
# JWT 설정
JWT_SECRET = os.getenv('JWT_SECRET', 'tkmp-secret-key-2025')
JWT_REFRESH_SECRET = os.getenv('JWT_REFRESH_SECRET', 'tkmp-refresh-secret-2025')
JWT_ALGORITHM = 'HS256'
ACCESS_TOKEN_EXPIRE_HOURS = int(os.getenv('JWT_EXPIRES_IN_HOURS', '24'))
REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv('JWT_REFRESH_EXPIRES_IN_DAYS', '7'))
class JWTService:
"""JWT 토큰 관리 서비스"""
@staticmethod
def create_access_token(user_data: Dict[str, Any]) -> str:
"""
Access Token 생성
Args:
user_data: 사용자 정보 딕셔너리
Returns:
str: JWT Access Token
"""
try:
# 토큰 만료 시간 설정
expire = datetime.utcnow() + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS)
# 토큰 페이로드 구성
payload = {
'user_id': user_data['user_id'],
'username': user_data['username'],
'name': user_data['name'],
'role': user_data['role'],
'access_level': user_data['access_level'],
'exp': expire,
'iat': datetime.utcnow(),
'type': 'access'
}
# JWT 토큰 생성
token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
logger.debug(f"Access token created for user: {user_data['username']}")
return token
except Exception as e:
logger.error(f"Access token creation failed: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="토큰 생성에 실패했습니다"
)
@staticmethod
def create_refresh_token(user_id: int) -> str:
"""
Refresh Token 생성
Args:
user_id: 사용자 ID
Returns:
str: JWT Refresh Token
"""
try:
# 토큰 만료 시간 설정
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
# 토큰 페이로드 구성
payload = {
'user_id': user_id,
'exp': expire,
'iat': datetime.utcnow(),
'type': 'refresh'
}
# JWT 토큰 생성
token = jwt.encode(payload, JWT_REFRESH_SECRET, algorithm=JWT_ALGORITHM)
logger.debug(f"Refresh token created for user_id: {user_id}")
return token
except Exception as e:
logger.error(f"Refresh token creation failed: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="리프레시 토큰 생성에 실패했습니다"
)
@staticmethod
def verify_access_token(token: str) -> Dict[str, Any]:
"""
Access Token 검증
Args:
token: JWT Access Token
Returns:
Dict[str, Any]: 토큰 페이로드
Raises:
HTTPException: 토큰 검증 실패 시
"""
try:
# JWT 토큰 디코딩
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
# 토큰 타입 확인
if payload.get('type') != 'access':
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="잘못된 토큰 타입입니다"
)
# 필수 필드 확인
required_fields = ['user_id', 'username', 'role']
for field in required_fields:
if field not in payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"토큰에 {field} 정보가 없습니다"
)
logger.debug(f"Access token verified for user: {payload['username']}")
return payload
except jwt.ExpiredSignatureError:
logger.warning("Access token expired")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="토큰이 만료되었습니다"
)
except jwt.InvalidTokenError as e:
logger.warning(f"Invalid access token: {str(e)}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="유효하지 않은 토큰입니다"
)
except Exception as e:
logger.error(f"Access token verification failed: {str(e)}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="토큰 검증에 실패했습니다"
)
@staticmethod
def verify_refresh_token(token: str) -> Dict[str, Any]:
"""
Refresh Token 검증
Args:
token: JWT Refresh Token
Returns:
Dict[str, Any]: 토큰 페이로드
Raises:
HTTPException: 토큰 검증 실패 시
"""
try:
# JWT 토큰 디코딩
payload = jwt.decode(token, JWT_REFRESH_SECRET, algorithms=[JWT_ALGORITHM])
# 토큰 타입 확인
if payload.get('type') != 'refresh':
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="잘못된 리프레시 토큰 타입입니다"
)
# 필수 필드 확인
if 'user_id' not in payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="토큰에 사용자 정보가 없습니다"
)
logger.debug(f"Refresh token verified for user_id: {payload['user_id']}")
return payload
except jwt.ExpiredSignatureError:
logger.warning("Refresh token expired")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="리프레시 토큰이 만료되었습니다"
)
except jwt.InvalidTokenError as e:
logger.warning(f"Invalid refresh token: {str(e)}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="유효하지 않은 리프레시 토큰입니다"
)
except Exception as e:
logger.error(f"Refresh token verification failed: {str(e)}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="리프레시 토큰 검증에 실패했습니다"
)
@staticmethod
def get_token_expiry_info(token: str, token_type: str = 'access') -> Dict[str, Any]:
"""
토큰 만료 정보 조회
Args:
token: JWT Token
token_type: 토큰 타입 ('access' 또는 'refresh')
Returns:
Dict[str, Any]: 토큰 만료 정보
"""
try:
secret = JWT_SECRET if token_type == 'access' else JWT_REFRESH_SECRET
payload = jwt.decode(token, secret, algorithms=[JWT_ALGORITHM])
exp_timestamp = payload.get('exp')
iat_timestamp = payload.get('iat')
if exp_timestamp:
exp_datetime = datetime.fromtimestamp(exp_timestamp)
remaining_time = exp_datetime - datetime.utcnow()
return {
'expires_at': exp_datetime,
'issued_at': datetime.fromtimestamp(iat_timestamp) if iat_timestamp else None,
'remaining_seconds': int(remaining_time.total_seconds()),
'is_expired': remaining_time.total_seconds() <= 0
}
return {'error': '토큰에 만료 시간 정보가 없습니다'}
except Exception as e:
logger.error(f"Token expiry info retrieval failed: {str(e)}")
return {'error': str(e)}
# JWT 서비스 인스턴스
jwt_service = JWTService()

View File

@@ -0,0 +1,305 @@
"""
인증 및 권한 미들웨어
JWT 토큰 기반 인증과 역할 기반 접근 제어(RBAC) 구현
"""
from fastapi import Depends, HTTPException, status, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from typing import List, Optional, Callable, Any
from functools import wraps
import inspect
from ..database import get_db
from .jwt_service import jwt_service
from .models import UserRepository
from ..utils.logger import get_logger
logger = get_logger(__name__)
security = HTTPBearer()
class AuthMiddleware:
"""인증 미들웨어 클래스"""
def __init__(self):
self.security = HTTPBearer()
async def get_current_user(
self,
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
) -> dict:
"""
현재 사용자 정보 조회
Args:
credentials: JWT 토큰
db: 데이터베이스 세션
Returns:
dict: 사용자 정보
Raises:
HTTPException: 인증 실패 시
"""
try:
# 토큰 검증
payload = jwt_service.verify_access_token(credentials.credentials)
user_id = payload['user_id']
# 사용자 정보 조회
user_repo = UserRepository(db)
user = user_repo.find_by_id(user_id)
if not user:
logger.warning(f"User not found for token: user_id {user_id}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="사용자를 찾을 수 없습니다"
)
if not user.is_active:
logger.warning(f"Inactive user attempted access: {user.username}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="비활성화된 계정입니다"
)
if user.is_locked():
logger.warning(f"Locked user attempted access: {user.username}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="잠긴 계정입니다"
)
# 사용자 정보와 토큰 페이로드 결합
user_info = user.to_dict()
user_info.update({
'token_user_id': payload['user_id'],
'token_username': payload['username'],
'token_role': payload['role']
})
return user_info
except HTTPException:
raise
except Exception as e:
logger.error(f"Get current user error: {str(e)}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="인증 처리 중 오류가 발생했습니다"
)
async def get_current_active_user(
self,
current_user: dict = Depends(get_current_user)
) -> dict:
"""
현재 활성 사용자 정보 조회 (별칭)
Args:
current_user: 현재 사용자 정보
Returns:
dict: 사용자 정보
"""
return current_user
def require_roles(self, allowed_roles: List[str]):
"""
특정 역할을 요구하는 의존성 함수 생성
Args:
allowed_roles: 허용된 역할 목록
Returns:
Callable: 의존성 함수
"""
async def role_checker(
current_user: dict = Depends(self.get_current_user)
) -> dict:
user_role = current_user.get('role')
if user_role not in allowed_roles:
logger.warning(
f"Access denied for user {current_user.get('username')} "
f"with role {user_role}. Required roles: {allowed_roles}"
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"이 기능을 사용하려면 다음 권한이 필요합니다: {', '.join(allowed_roles)}"
)
return current_user
return role_checker
def require_permissions(self, required_permissions: List[str]):
"""
특정 권한을 요구하는 의존성 함수 생성
Args:
required_permissions: 필요한 권한 목록
Returns:
Callable: 의존성 함수
"""
async def permission_checker(
current_user: dict = Depends(self.get_current_user),
db: Session = Depends(get_db)
) -> dict:
user_role = current_user.get('role')
# 사용자 권한 조회
user_repo = UserRepository(db)
user_permissions = user_repo.get_user_permissions(user_role)
# 필요한 권한 확인
missing_permissions = []
for permission in required_permissions:
if permission not in user_permissions:
missing_permissions.append(permission)
if missing_permissions:
logger.warning(
f"Permission denied for user {current_user.get('username')} "
f"with role {user_role}. Missing permissions: {missing_permissions}"
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"이 기능을 사용하려면 다음 권한이 필요합니다: {', '.join(missing_permissions)}"
)
# 사용자 정보에 권한 정보 추가
current_user['permissions'] = user_permissions
return current_user
return permission_checker
def require_admin(self):
"""관리자 권한을 요구하는 의존성 함수"""
return self.require_roles(['admin', 'system'])
def require_leader_or_admin(self):
"""팀장 이상 권한을 요구하는 의존성 함수"""
return self.require_roles(['admin', 'system', 'leader'])
# 전역 인증 미들웨어 인스턴스
auth_middleware = AuthMiddleware()
# 편의를 위한 의존성 함수들
get_current_user = auth_middleware.get_current_user
get_current_active_user = auth_middleware.get_current_active_user
require_admin = auth_middleware.require_admin
require_leader_or_admin = auth_middleware.require_leader_or_admin
def require_roles(allowed_roles: List[str]):
"""역할 기반 접근 제어 데코레이터"""
return auth_middleware.require_roles(allowed_roles)
def require_permissions(required_permissions: List[str]):
"""권한 기반 접근 제어 데코레이터"""
return auth_middleware.require_permissions(required_permissions)
# 추가 유틸리티 함수들
async def get_user_from_token(token: str, db: Session) -> Optional[dict]:
"""
토큰에서 사용자 정보 추출 (미들웨어 없이 직접 사용)
Args:
token: JWT 토큰
db: 데이터베이스 세션
Returns:
Optional[dict]: 사용자 정보 또는 None
"""
try:
payload = jwt_service.verify_access_token(token)
user_id = payload['user_id']
user_repo = UserRepository(db)
user = user_repo.find_by_id(user_id)
if user and user.is_active and not user.is_locked():
return user.to_dict()
return None
except Exception as e:
logger.error(f"Get user from token error: {str(e)}")
return None
def check_user_permission(user_role: str, required_permission: str, db: Session) -> bool:
"""
사용자 권한 확인
Args:
user_role: 사용자 역할
required_permission: 필요한 권한
db: 데이터베이스 세션
Returns:
bool: 권한 보유 여부
"""
try:
user_repo = UserRepository(db)
user_permissions = user_repo.get_user_permissions(user_role)
return required_permission in user_permissions
except Exception as e:
logger.error(f"Check user permission error: {str(e)}")
return False
def get_user_permissions_by_role(role: str, db: Session) -> List[str]:
"""
역할별 권한 목록 조회
Args:
role: 사용자 역할
db: 데이터베이스 세션
Returns:
List[str]: 권한 목록
"""
try:
user_repo = UserRepository(db)
return user_repo.get_user_permissions(role)
except Exception as e:
logger.error(f"Get user permissions by role error: {str(e)}")
return []
# 선택적 인증 (토큰이 있으면 검증, 없으면 None 반환)
async def get_current_user_optional(
request: Request,
db: Session = Depends(get_db)
) -> Optional[dict]:
"""
선택적 사용자 인증 (토큰이 있으면 검증, 없으면 None)
Args:
request: FastAPI Request 객체
db: 데이터베이스 세션
Returns:
Optional[dict]: 사용자 정보 또는 None
"""
try:
# Authorization 헤더 확인
authorization = request.headers.get('authorization')
if not authorization or not authorization.startswith('Bearer '):
return None
token = authorization.split(' ')[1]
return await get_user_from_token(token, db)
except Exception as e:
logger.debug(f"Optional auth failed: {str(e)}")
return None

354
backend/app/auth/models.py Normal file
View File

@@ -0,0 +1,354 @@
"""
인증 시스템 모델
TK-FB-Project의 사용자 모델을 참고하여 SQLAlchemy 기반으로 구현
"""
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy import text
import bcrypt
from ..database import Base
from ..utils.logger import get_logger
logger = get_logger(__name__)
class User(Base):
"""사용자 모델"""
__tablename__ = "users"
user_id = Column(Integer, primary_key=True, index=True)
username = Column(String(50), unique=True, index=True, nullable=False)
password = Column(String(255), nullable=False)
name = Column(String(100), nullable=False)
email = Column(String(100), index=True)
# 권한 관리
role = Column(String(20), default='user', nullable=False)
access_level = Column(String(20), default='worker', nullable=False)
# 계정 상태 관리
is_active = Column(Boolean, default=True, nullable=False)
failed_login_attempts = Column(Integer, default=0)
locked_until = Column(DateTime, nullable=True)
# 추가 정보
department = Column(String(50))
position = Column(String(50))
phone = Column(String(20))
# 타임스탬프
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
last_login_at = Column(DateTime, nullable=True)
# 관계 설정
login_logs = relationship("LoginLog", back_populates="user", cascade="all, delete-orphan")
sessions = relationship("UserSession", back_populates="user", cascade="all, delete-orphan")
def __repr__(self):
return f"<User(username='{self.username}', name='{self.name}', role='{self.role}')>"
def to_dict(self) -> Dict[str, Any]:
"""사용자 정보를 딕셔너리로 변환 (비밀번호 제외)"""
return {
'user_id': self.user_id,
'username': self.username,
'name': self.name,
'email': self.email,
'role': self.role,
'access_level': self.access_level,
'is_active': self.is_active,
'department': self.department,
'position': self.position,
'phone': self.phone,
'created_at': self.created_at,
'last_login_at': self.last_login_at
}
def check_password(self, password: str) -> bool:
"""비밀번호 확인"""
try:
return bcrypt.checkpw(password.encode('utf-8'), self.password.encode('utf-8'))
except Exception as e:
logger.error(f"Password check failed for user {self.username}: {str(e)}")
return False
def set_password(self, password: str):
"""비밀번호 설정 (해싱)"""
try:
salt = bcrypt.gensalt()
self.password = bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
except Exception as e:
logger.error(f"Password hashing failed for user {self.username}: {str(e)}")
raise
def is_locked(self) -> bool:
"""계정 잠금 상태 확인"""
if self.locked_until is None:
return False
return datetime.utcnow() < self.locked_until
def lock_account(self, minutes: int = 15):
"""계정 잠금"""
self.locked_until = datetime.utcnow() + timedelta(minutes=minutes)
logger.warning(f"User account locked: {self.username} for {minutes} minutes")
def unlock_account(self):
"""계정 잠금 해제"""
self.locked_until = None
self.failed_login_attempts = 0
logger.info(f"User account unlocked: {self.username}")
def increment_failed_attempts(self):
"""로그인 실패 횟수 증가"""
self.failed_login_attempts += 1
if self.failed_login_attempts >= 5:
self.lock_account()
def reset_failed_attempts(self):
"""로그인 실패 횟수 초기화"""
self.failed_login_attempts = 0
self.locked_until = None
def update_last_login(self):
"""마지막 로그인 시간 업데이트"""
self.last_login_at = datetime.utcnow()
class LoginLog(Base):
"""로그인 이력 모델"""
__tablename__ = "login_logs"
log_id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False)
login_time = Column(DateTime, default=func.now())
ip_address = Column(String(45))
user_agent = Column(Text)
login_status = Column(String(20), nullable=False) # 'success' or 'failed'
failure_reason = Column(String(100))
session_duration = Column(Integer) # 세션 지속 시간 (초)
created_at = Column(DateTime, default=func.now())
# 관계 설정
user = relationship("User", back_populates="login_logs")
def __repr__(self):
return f"<LoginLog(user_id={self.user_id}, status='{self.login_status}', time='{self.login_time}')>"
class UserSession(Base):
"""사용자 세션 모델 (Refresh Token 관리)"""
__tablename__ = "user_sessions"
session_id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False)
refresh_token = Column(String(500), nullable=False, index=True)
expires_at = Column(DateTime, nullable=False)
ip_address = Column(String(45))
user_agent = Column(Text)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
# 관계 설정
user = relationship("User", back_populates="sessions")
def __repr__(self):
return f"<UserSession(user_id={self.user_id}, expires_at='{self.expires_at}')>"
def is_expired(self) -> bool:
"""세션 만료 여부 확인"""
return datetime.utcnow() > self.expires_at
def deactivate(self):
"""세션 비활성화"""
self.is_active = False
class Permission(Base):
"""권한 모델"""
__tablename__ = "permissions"
permission_id = Column(Integer, primary_key=True, index=True)
permission_name = Column(String(50), unique=True, nullable=False)
description = Column(Text)
module = Column(String(30), index=True) # 모듈별 권한 관리
created_at = Column(DateTime, default=func.now())
def __repr__(self):
return f"<Permission(name='{self.permission_name}', module='{self.module}')>"
class RolePermission(Base):
"""역할-권한 매핑 모델"""
__tablename__ = "role_permissions"
role_permission_id = Column(Integer, primary_key=True, index=True)
role = Column(String(20), nullable=False, index=True)
permission_id = Column(Integer, ForeignKey("permissions.permission_id", ondelete="CASCADE"))
created_at = Column(DateTime, default=func.now())
# 관계 설정
permission = relationship("Permission")
def __repr__(self):
return f"<RolePermission(role='{self.role}', permission_id={self.permission_id})>"
class UserRepository:
"""사용자 데이터 접근 계층"""
def __init__(self, db: Session):
self.db = db
def find_by_username(self, username: str) -> Optional[User]:
"""사용자명으로 사용자 조회"""
try:
return self.db.query(User).filter(User.username == username).first()
except Exception as e:
logger.error(f"Failed to find user by username {username}: {str(e)}")
return None
def find_by_id(self, user_id: int) -> Optional[User]:
"""사용자 ID로 사용자 조회"""
try:
return self.db.query(User).filter(User.user_id == user_id).first()
except Exception as e:
logger.error(f"Failed to find user by id {user_id}: {str(e)}")
return None
def find_by_email(self, email: str) -> Optional[User]:
"""이메일로 사용자 조회"""
try:
return self.db.query(User).filter(User.email == email).first()
except Exception as e:
logger.error(f"Failed to find user by email {email}: {str(e)}")
return None
def create_user(self, user_data: Dict[str, Any]) -> User:
"""새 사용자 생성"""
try:
user = User(**user_data)
self.db.add(user)
self.db.commit()
self.db.refresh(user)
logger.info(f"User created: {user.username}")
return user
except Exception as e:
self.db.rollback()
logger.error(f"Failed to create user: {str(e)}")
raise
def update_user(self, user: User) -> User:
"""사용자 정보 업데이트"""
try:
self.db.commit()
self.db.refresh(user)
logger.info(f"User updated: {user.username}")
return user
except Exception as e:
self.db.rollback()
logger.error(f"Failed to update user {user.username}: {str(e)}")
raise
def delete_user(self, user: User):
"""사용자 삭제"""
try:
self.db.delete(user)
self.db.commit()
logger.info(f"User deleted: {user.username}")
except Exception as e:
self.db.rollback()
logger.error(f"Failed to delete user {user.username}: {str(e)}")
raise
def get_all_users(self, skip: int = 0, limit: int = 100) -> List[User]:
"""모든 사용자 조회"""
try:
return self.db.query(User).offset(skip).limit(limit).all()
except Exception as e:
logger.error(f"Failed to get all users: {str(e)}")
return []
def get_user_permissions(self, role: str) -> List[str]:
"""사용자 역할에 따른 권한 목록 조회"""
try:
query = text("""
SELECT p.permission_name
FROM permissions p
JOIN role_permissions rp ON p.permission_id = rp.permission_id
WHERE rp.role = :role
""")
result = self.db.execute(query, {"role": role})
return [row[0] for row in result.fetchall()]
except Exception as e:
logger.error(f"Failed to get permissions for role {role}: {str(e)}")
return []
def record_login_log(self, user_id: int, ip_address: str, user_agent: str,
status: str, failure_reason: str = None):
"""로그인 이력 기록"""
try:
log = LoginLog(
user_id=user_id,
ip_address=ip_address,
user_agent=user_agent,
login_status=status,
failure_reason=failure_reason
)
self.db.add(log)
self.db.commit()
logger.debug(f"Login log recorded for user_id {user_id}: {status}")
except Exception as e:
self.db.rollback()
logger.error(f"Failed to record login log: {str(e)}")
def create_session(self, user_id: int, refresh_token: str, expires_at: datetime,
ip_address: str, user_agent: str) -> UserSession:
"""사용자 세션 생성"""
try:
session = UserSession(
user_id=user_id,
refresh_token=refresh_token,
expires_at=expires_at,
ip_address=ip_address,
user_agent=user_agent
)
self.db.add(session)
self.db.commit()
self.db.refresh(session)
logger.debug(f"Session created for user_id {user_id}")
return session
except Exception as e:
self.db.rollback()
logger.error(f"Failed to create session: {str(e)}")
raise
def find_session_by_token(self, refresh_token: str) -> Optional[UserSession]:
"""리프레시 토큰으로 세션 조회"""
try:
return self.db.query(UserSession).filter(
UserSession.refresh_token == refresh_token,
UserSession.is_active == True
).first()
except Exception as e:
logger.error(f"Failed to find session by token: {str(e)}")
return None
def deactivate_user_sessions(self, user_id: int):
"""사용자의 모든 세션 비활성화"""
try:
self.db.query(UserSession).filter(
UserSession.user_id == user_id
).update({"is_active": False})
self.db.commit()
logger.info(f"All sessions deactivated for user_id {user_id}")
except Exception as e:
self.db.rollback()
logger.error(f"Failed to deactivate sessions for user_id {user_id}: {str(e)}")
raise

284
backend/app/config.py Normal file
View File

@@ -0,0 +1,284 @@
"""
TK-MP-Project 설정 관리
환경별 설정을 중앙화하여 관리
"""
import os
from typing import List, Optional, Dict, Any
from pathlib import Path
from pydantic_settings import BaseSettings
from pydantic import Field, validator
import json
class DatabaseSettings(BaseSettings):
"""데이터베이스 설정"""
url: str = Field(
default="postgresql://tkmp_user:tkmp_password_2025@postgres:5432/tk_mp_bom",
description="데이터베이스 연결 URL"
)
pool_size: int = Field(default=10, description="연결 풀 크기")
max_overflow: int = Field(default=20, description="최대 오버플로우")
pool_timeout: int = Field(default=30, description="연결 타임아웃 (초)")
pool_recycle: int = Field(default=3600, description="연결 재활용 시간 (초)")
echo: bool = Field(default=False, description="SQL 로그 출력 여부")
class Config:
env_prefix = "DB_"
class RedisSettings(BaseSettings):
"""Redis 설정"""
url: str = Field(default="redis://redis:6379", description="Redis 연결 URL")
max_connections: int = Field(default=20, description="최대 연결 수")
socket_timeout: int = Field(default=5, description="소켓 타임아웃 (초)")
socket_connect_timeout: int = Field(default=5, description="연결 타임아웃 (초)")
retry_on_timeout: bool = Field(default=True, description="타임아웃 시 재시도")
decode_responses: bool = Field(default=False, description="응답 디코딩 여부")
class Config:
env_prefix = "REDIS_"
class SecuritySettings(BaseSettings):
"""보안 설정"""
cors_origins: List[str] = Field(default=[], description="CORS 허용 도메인")
cors_methods: List[str] = Field(
default=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
description="CORS 허용 메서드"
)
cors_headers: List[str] = Field(
default=["*"],
description="CORS 허용 헤더"
)
cors_credentials: bool = Field(default=True, description="CORS 자격증명 허용")
# 파일 업로드 보안
max_file_size: int = Field(default=50 * 1024 * 1024, description="최대 파일 크기 (bytes)")
allowed_file_extensions: List[str] = Field(
default=['.xlsx', '.xls', '.csv'],
description="허용된 파일 확장자"
)
upload_path: str = Field(default="uploads", description="업로드 경로")
# API 보안
api_key_header: str = Field(default="X-API-Key", description="API 키 헤더명")
rate_limit_per_minute: int = Field(default=100, description="분당 요청 제한")
class Config:
env_prefix = "SECURITY_"
class LoggingSettings(BaseSettings):
"""로깅 설정"""
level: str = Field(default="INFO", description="로그 레벨")
file_path: str = Field(default="logs/app.log", description="로그 파일 경로")
max_file_size: int = Field(default=10 * 1024 * 1024, description="로그 파일 최대 크기")
backup_count: int = Field(default=5, description="백업 파일 수")
format: str = Field(
default="%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s",
description="로그 포맷"
)
date_format: str = Field(default="%Y-%m-%d %H:%M:%S", description="날짜 포맷")
# 환경별 로그 레벨
development_level: str = Field(default="DEBUG", description="개발 환경 로그 레벨")
production_level: str = Field(default="INFO", description="운영 환경 로그 레벨")
test_level: str = Field(default="WARNING", description="테스트 환경 로그 레벨")
class Config:
env_prefix = "LOG_"
class PerformanceSettings(BaseSettings):
"""성능 설정"""
# 캐시 설정
cache_ttl_default: int = Field(default=3600, description="기본 캐시 TTL (초)")
cache_ttl_files: int = Field(default=300, description="파일 목록 캐시 TTL")
cache_ttl_materials: int = Field(default=600, description="자재 목록 캐시 TTL")
cache_ttl_jobs: int = Field(default=1800, description="작업 목록 캐시 TTL")
cache_ttl_classification: int = Field(default=3600, description="분류 결과 캐시 TTL")
cache_ttl_statistics: int = Field(default=900, description="통계 데이터 캐시 TTL")
# 파일 처리 설정
chunk_size: int = Field(default=1000, description="파일 처리 청크 크기")
max_workers: int = Field(default=4, description="최대 워커 수")
memory_limit_mb: int = Field(default=512, description="메모리 제한 (MB)")
class Config:
env_prefix = "PERF_"
class Settings(BaseSettings):
"""메인 애플리케이션 설정"""
# 기본 설정
app_name: str = Field(default="TK-MP BOM Management API", description="애플리케이션 이름")
app_version: str = Field(default="1.0.0", description="애플리케이션 버전")
app_description: str = Field(
default="자재 분류 및 프로젝트 관리 시스템",
description="애플리케이션 설명"
)
debug: bool = Field(default=False, description="디버그 모드")
# 환경 설정
environment: str = Field(
default="development",
description="실행 환경 (development, production, test, synology)"
)
# 서버 설정
host: str = Field(default="0.0.0.0", description="서버 호스트")
port: int = Field(default=8000, description="서버 포트")
reload: bool = Field(default=False, description="자동 재로드")
workers: int = Field(default=1, description="워커 프로세스 수")
# 하위 설정들
database: DatabaseSettings = Field(default_factory=DatabaseSettings)
redis: RedisSettings = Field(default_factory=RedisSettings)
security: SecuritySettings = Field(default_factory=SecuritySettings)
logging: LoggingSettings = Field(default_factory=LoggingSettings)
performance: PerformanceSettings = Field(default_factory=PerformanceSettings)
# 추가 설정
timezone: str = Field(default="Asia/Seoul", description="시간대")
language: str = Field(default="ko", description="기본 언어")
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
case_sensitive = False
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._setup_environment_specific_settings()
self._setup_cors_origins()
self._validate_settings()
@validator('environment')
def validate_environment(cls, v):
"""환경 값 검증"""
allowed_environments = ['development', 'production', 'test', 'synology']
if v not in allowed_environments:
raise ValueError(f'Environment must be one of: {allowed_environments}')
return v
@validator('port')
def validate_port(cls, v):
"""포트 번호 검증"""
if not 1 <= v <= 65535:
raise ValueError('Port must be between 1 and 65535')
return v
def _setup_environment_specific_settings(self):
"""환경별 특정 설정 적용"""
if self.environment == "development":
self.debug = True
self.reload = True
self.database.echo = True
self.logging.level = self.logging.development_level
elif self.environment == "production":
self.debug = False
self.reload = False
self.database.echo = False
self.logging.level = self.logging.production_level
self.workers = max(2, os.cpu_count() or 1)
elif self.environment == "test":
self.debug = False
self.reload = False
self.database.echo = False
self.logging.level = self.logging.test_level
# 테스트용 인메모리 데이터베이스
self.database.url = "sqlite:///:memory:"
elif self.environment == "synology":
self.debug = False
self.reload = False
self.host = "0.0.0.0"
self.port = 10080
def _setup_cors_origins(self):
"""환경별 CORS origins 설정"""
if not self.security.cors_origins:
cors_config = {
"development": [
"http://localhost:3000",
"http://localhost:5173",
"http://127.0.0.1:3000",
"http://127.0.0.1:5173"
],
"production": [
"https://your-domain.com",
"https://api.your-domain.com"
],
"synology": [
"http://192.168.0.3:10173",
"http://localhost:10173"
],
"test": [
"http://testserver"
]
}
self.security.cors_origins = cors_config.get(
self.environment,
cors_config["development"]
)
def _validate_settings(self):
"""설정 검증"""
# 로그 디렉토리 생성
log_dir = Path(self.logging.file_path).parent
log_dir.mkdir(parents=True, exist_ok=True)
# 업로드 디렉토리 생성
upload_dir = Path(self.security.upload_path)
upload_dir.mkdir(parents=True, exist_ok=True)
def get_database_url(self) -> str:
"""데이터베이스 URL 반환"""
return self.database.url
def get_redis_url(self) -> str:
"""Redis URL 반환"""
return self.redis.url
def is_development(self) -> bool:
"""개발 환경 여부"""
return self.environment == "development"
def is_production(self) -> bool:
"""운영 환경 여부"""
return self.environment == "production"
def is_test(self) -> bool:
"""테스트 환경 여부"""
return self.environment == "test"
def get_cors_config(self) -> Dict[str, Any]:
"""CORS 설정 반환"""
return {
"allow_origins": self.security.cors_origins,
"allow_methods": self.security.cors_methods,
"allow_headers": self.security.cors_headers,
"allow_credentials": self.security.cors_credentials
}
def to_dict(self) -> Dict[str, Any]:
"""설정을 딕셔너리로 변환"""
return self.dict()
def save_to_file(self, file_path: str):
"""설정을 파일로 저장"""
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(self.to_dict(), f, indent=2, ensure_ascii=False, default=str)
# 전역 설정 인스턴스
settings = Settings()
def get_settings() -> Settings:
"""설정 인스턴스 반환"""
return settings

View File

@@ -7,162 +7,83 @@ from fastapi import Depends
from typing import Optional, List, Dict
import os
import shutil
# 설정 및 로깅 import
from .config import get_settings
from .utils.logger import get_logger
from .utils.error_handlers import setup_error_handlers
# 설정 로드
settings = get_settings()
# 로거 설정
logger = get_logger(__name__)
# FastAPI 앱 생성
app = FastAPI(
title="TK-MP BOM Management API",
title=settings.app_name,
description="자재 분류 및 프로젝트 관리 시스템",
version="1.0.0"
version=settings.app_version,
debug=settings.debug
)
# CORS 설정
# 에러 핸들러 설정
setup_error_handlers(app)
# CORS 설정 (환경별 분리)
cors_config = settings.get_cors_config()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
**cors_config
)
logger.info(f"CORS origins configured for {settings.environment}: {settings.security.cors_origins}")
# 라우터들 import 및 등록
try:
from .routers import files
app.include_router(files.router, prefix="/files", tags=["files"])
except ImportError:
print("files 라우터를 찾을 수 없습니다")
logger.warning("files 라우터를 찾을 수 없습니다")
try:
from .routers import jobs
app.include_router(jobs.router, prefix="/jobs", tags=["jobs"])
except ImportError:
print("jobs 라우터를 찾을 수 없습니다")
logger.warning("jobs 라우터를 찾을 수 없습니다")
try:
from .routers import purchase
app.include_router(purchase.router, tags=["purchase"])
except ImportError:
print("purchase 라우터를 찾을 수 없습니다")
logger.warning("purchase 라우터를 찾을 수 없습니다")
try:
from .routers import material_comparison
app.include_router(material_comparison.router, tags=["material-comparison"])
except ImportError:
print("material_comparison 라우터를 찾을 수 없습니다")
logger.warning("material_comparison 라우터를 찾을 수 없습니다")
# 파일 목록 조회 API
@app.get("/files")
async def get_files(
job_no: Optional[str] = None, # project_id 대신 job_no 사용
show_history: bool = False, # 이력 표시 여부
db: Session = Depends(get_db)
):
"""파일 목록 조회 (BOM별 그룹화)"""
try:
if show_history:
# 전체 이력 표시
query = "SELECT * FROM files"
params = {}
if job_no:
query += " WHERE job_no = :job_no"
params["job_no"] = job_no
query += " ORDER BY original_filename, revision DESC"
else:
# 최신 리비전만 표시
if job_no:
query = """
SELECT f1.* FROM files f1
INNER JOIN (
SELECT original_filename, MAX(revision) as max_revision
FROM files
WHERE job_no = :job_no
GROUP BY original_filename
) f2 ON f1.original_filename = f2.original_filename
AND f1.revision = f2.max_revision
WHERE f1.job_no = :job_no
ORDER BY f1.upload_date DESC
"""
params = {"job_no": job_no}
else:
# job_no가 없으면 전체 파일 조회
query = "SELECT * FROM files ORDER BY upload_date DESC"
params = {}
result = db.execute(text(query), params)
files = result.fetchall()
return [
{
"id": f.id,
"filename": f.original_filename,
"original_filename": f.original_filename,
"name": f.original_filename,
"job_no": f.job_no, # job_no 사용
"bom_name": f.bom_name or f.original_filename, # 실제 bom_name 값 사용, 없으면 파일명
"revision": f.revision or "Rev.0", # 실제 리비전 또는 기본값
"parsed_count": f.parsed_count or 0, # 파싱된 자재 수
"bom_type": f.file_type or "unknown", # file_type을 BOM 종류로 사용
"status": "active" if f.is_active else "inactive", # is_active 상태
"file_size": f.file_size,
"created_at": f.upload_date,
"upload_date": f.upload_date,
"description": f"파일: {f.original_filename}"
}
for f in files
]
except Exception as e:
print(f"파일 목록 조회 에러: {str(e)}")
return {"error": f"파일 목록 조회 실패: {str(e)}"}
try:
from .routers import tubing
app.include_router(tubing.router, prefix="/tubing", tags=["tubing"])
except ImportError:
logger.warning("tubing 라우터를 찾을 수 없습니다")
# 파일 삭제 API
@app.delete("/files/{file_id}")
async def delete_file(
file_id: int,
db: Session = Depends(get_db)
):
"""파일 삭제"""
try:
# 먼저 파일 정보 조회
file_query = text("SELECT * FROM files WHERE id = :file_id")
file_result = db.execute(file_query, {"file_id": file_id})
file = file_result.fetchone()
if not file:
return {"error": "파일을 찾을 수 없습니다"}
# 먼저 상세 테이블의 데이터 삭제 (외래 키 제약 조건 때문)
# 각 자재 타입별 상세 테이블 데이터 삭제
detail_tables = [
'pipe_details', 'fitting_details', 'valve_details',
'flange_details', 'bolt_details', 'gasket_details',
'instrument_details'
]
# 해당 파일의 materials ID 조회
material_ids_query = text("SELECT id FROM materials WHERE file_id = :file_id")
material_ids_result = db.execute(material_ids_query, {"file_id": file_id})
material_ids = [row[0] for row in material_ids_result]
if material_ids:
# 각 상세 테이블에서 관련 데이터 삭제
for table in detail_tables:
delete_detail_query = text(f"DELETE FROM {table} WHERE material_id = ANY(:material_ids)")
db.execute(delete_detail_query, {"material_ids": material_ids})
# materials 테이블 데이터 삭제
materials_query = text("DELETE FROM materials WHERE file_id = :file_id")
db.execute(materials_query, {"file_id": file_id})
# 파일 삭제
delete_query = text("DELETE FROM files WHERE id = :file_id")
db.execute(delete_query, {"file_id": file_id})
db.commit()
return {"success": True, "message": "파일과 관련 데이터가 삭제되었습니다"}
except Exception as e:
db.rollback()
return {"error": f"파일 삭제 실패: {str(e)}"}
# 파일 관리 API 라우터 등록
try:
from .api import file_management
app.include_router(file_management.router, tags=["file-management"])
logger.info("파일 관리 API 라우터 등록 완료")
except ImportError as e:
logger.warning(f"파일 관리 라우터를 찾을 수 없습니다: {e}")
# 인증 API 라우터 등록
try:
from .auth import auth_router
app.include_router(auth_router, prefix="/auth", tags=["authentication"])
logger.info("인증 API 라우터 등록 완료")
except ImportError as e:
logger.warning(f"인증 라우터를 찾을 수 없습니다: {e}")
# 프로젝트 관리 API (비활성화 - jobs 테이블 사용)
# projects 테이블은 더 이상 사용하지 않음

View File

@@ -276,8 +276,7 @@ class RequirementType(Base):
created_at = Column(DateTime, default=datetime.utcnow)
# 관계 설정
requirements = relationship("UserRequirement", back_populates="requirement_type")
# 관계 설정은 문자열 기반이므로 제거
class UserRequirement(Base):
"""사용자 추가 요구사항"""
@@ -308,4 +307,145 @@ class UserRequirement(Base):
# 관계 설정
file = relationship("File", backref="user_requirements")
requirement_type_rel = relationship("RequirementType", back_populates="requirements")
# ========== Tubing 시스템 모델들 ==========
class TubingCategory(Base):
"""Tubing 카테고리 (일반, VCR, 위생용 등)"""
__tablename__ = "tubing_categories"
id = Column(Integer, primary_key=True, index=True)
category_code = Column(String(20), unique=True, nullable=False)
category_name = Column(String(100), nullable=False)
description = Column(Text)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow)
# 관계 설정
specifications = relationship("TubingSpecification", back_populates="category")
class TubingSpecification(Base):
"""Tubing 규격 마스터"""
__tablename__ = "tubing_specifications"
id = Column(Integer, primary_key=True, index=True)
category_id = Column(Integer, ForeignKey("tubing_categories.id"))
spec_code = Column(String(50), unique=True, nullable=False)
spec_name = Column(String(200), nullable=False)
# 물리적 규격
outer_diameter_mm = Column(Numeric(8, 3))
wall_thickness_mm = Column(Numeric(6, 3))
inner_diameter_mm = Column(Numeric(8, 3))
# 재질 정보
material_grade = Column(String(100))
material_standard = Column(String(100))
# 압력/온도 등급
max_pressure_bar = Column(Numeric(8, 2))
max_temperature_c = Column(Numeric(6, 2))
min_temperature_c = Column(Numeric(6, 2))
# 표준 규격
standard_length_m = Column(Numeric(8, 3))
bend_radius_min_mm = Column(Numeric(8, 2))
# 기타 정보
surface_finish = Column(String(100))
hardness = Column(String(50))
notes = Column(Text)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow)
# 관계 설정
category = relationship("TubingCategory", back_populates="specifications")
products = relationship("TubingProduct", back_populates="specification")
class TubingManufacturer(Base):
"""Tubing 제조사"""
__tablename__ = "tubing_manufacturers"
id = Column(Integer, primary_key=True, index=True)
manufacturer_code = Column(String(20), unique=True, nullable=False)
manufacturer_name = Column(String(200), nullable=False)
country = Column(String(100))
website = Column(String(500))
contact_info = Column(JSON) # JSONB 타입
quality_certs = Column(JSON) # JSONB 타입
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow)
# 관계 설정
products = relationship("TubingProduct", back_populates="manufacturer")
class TubingProduct(Base):
"""제조사별 Tubing 제품 (품목번호 매핑)"""
__tablename__ = "tubing_products"
id = Column(Integer, primary_key=True, index=True)
specification_id = Column(Integer, ForeignKey("tubing_specifications.id"))
manufacturer_id = Column(Integer, ForeignKey("tubing_manufacturers.id"))
# 제조사 품목번호 정보
manufacturer_part_number = Column(String(200), nullable=False)
manufacturer_product_name = Column(String(300))
# 가격/공급 정보
list_price = Column(Numeric(12, 2))
currency = Column(String(10), default='KRW')
lead_time_days = Column(Integer)
minimum_order_qty = Column(Numeric(10, 3))
standard_packaging_qty = Column(Numeric(10, 3))
# 가용성 정보
availability_status = Column(String(50))
last_price_update = Column(DateTime)
# 추가 정보
datasheet_url = Column(String(500))
catalog_page = Column(String(100))
notes = Column(Text)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow)
# 관계 설정
specification = relationship("TubingSpecification", back_populates="products")
manufacturer = relationship("TubingManufacturer", back_populates="products")
material_mappings = relationship("MaterialTubingMapping", back_populates="tubing_product")
class MaterialTubingMapping(Base):
"""BOM 자재와 Tubing 제품 매핑"""
__tablename__ = "material_tubing_mapping"
id = Column(Integer, primary_key=True, index=True)
material_id = Column(Integer, ForeignKey("materials.id", ondelete="CASCADE"))
tubing_product_id = Column(Integer, ForeignKey("tubing_products.id"))
# 매핑 정보
confidence_score = Column(Numeric(3, 2))
mapping_method = Column(String(50))
mapped_by = Column(String(100))
mapped_at = Column(DateTime, default=datetime.utcnow)
# 수량 정보
required_length_m = Column(Numeric(10, 3))
calculated_quantity = Column(Numeric(10, 3))
# 검증 정보
is_verified = Column(Boolean, default=False)
verified_by = Column(String(100))
verified_at = Column(DateTime)
notes = Column(Text)
created_at = Column(DateTime, default=datetime.utcnow)
# 관계 설정
material = relationship("Material", backref="tubing_mappings")
tubing_product = relationship("TubingProduct", back_populates="material_mappings")

View File

@@ -12,7 +12,11 @@ from pathlib import Path
import json
from ..database import get_db
from ..utils.logger import get_logger
from app.services.material_classifier import classify_material
# 로거 설정
logger = get_logger(__name__)
from app.services.integrated_classifier import classify_material_integrated, should_exclude_material
from app.services.bolt_classifier import classify_bolt
from app.services.flange_classifier import classify_flange
@@ -664,10 +668,15 @@ async def upload_file(
else:
gasket_type = str(gasket_type_info) if gasket_type_info else "UNKNOWN"
# 가스켓 소재 (GRAPHITE, PTFE 등)
# 가스켓 소재 - SWG의 경우 메탈 부분을 우선으로
material_type = ""
if isinstance(gasket_material_info, dict):
material_type = gasket_material_info.get("material", "UNKNOWN")
# SWG 상세 정보가 있으면 메탈 부분을 material_type으로 사용
swg_details = gasket_material_info.get("swg_details", {})
if swg_details and swg_details.get("outer_ring"):
material_type = swg_details.get("outer_ring", "UNKNOWN") # SS304
else:
material_type = gasket_material_info.get("material", "UNKNOWN")
else:
material_type = str(gasket_material_info) if gasket_material_info else "UNKNOWN"
@@ -978,7 +987,7 @@ async def get_files(
try:
query = """
SELECT id, filename, original_filename, job_no, revision,
description, file_size, parsed_count, created_at, is_active
description, file_size, parsed_count, upload_date, is_active
FROM files
WHERE is_active = TRUE
"""
@@ -988,7 +997,7 @@ async def get_files(
query += " AND job_no = :job_no"
params["job_no"] = job_no
query += " ORDER BY created_at DESC"
query += " ORDER BY upload_date DESC"
result = db.execute(text(query), params)
files = result.fetchall()
@@ -1003,7 +1012,7 @@ async def get_files(
"description": file.description,
"file_size": file.file_size,
"parsed_count": file.parsed_count,
"created_at": file.created_at,
"created_at": file.upload_date,
"is_active": file.is_active
}
for file in files
@@ -1012,6 +1021,47 @@ async def get_files(
except Exception as e:
raise HTTPException(status_code=500, detail=f"파일 목록 조회 실패: {str(e)}")
@router.get("/stats")
async def get_files_stats(db: Session = Depends(get_db)):
"""파일 및 자재 통계 조회"""
try:
# 총 파일 수
files_query = text("SELECT COUNT(*) FROM files WHERE is_active = true")
total_files = db.execute(files_query).fetchone()[0]
# 총 자재 수
materials_query = text("SELECT COUNT(*) FROM materials")
total_materials = db.execute(materials_query).fetchone()[0]
# 최근 업로드 (최근 5개)
recent_query = text("""
SELECT f.original_filename, f.upload_date, f.parsed_count, j.job_name
FROM files f
LEFT JOIN jobs j ON f.job_no = j.job_no
WHERE f.is_active = true
ORDER BY f.upload_date DESC
LIMIT 5
""")
recent_uploads = db.execute(recent_query).fetchall()
return {
"success": True,
"totalFiles": total_files,
"totalMaterials": total_materials,
"recentUploads": [
{
"filename": upload.original_filename,
"created_at": upload.upload_date,
"parsed_count": upload.parsed_count or 0,
"project_name": upload.job_name or "Unknown"
}
for upload in recent_uploads
]
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"통계 조회 실패: {str(e)}")
@router.delete("/files/{file_id}")
async def delete_file(file_id: int, db: Session = Depends(get_db)):
"""파일 삭제"""

View File

@@ -20,6 +20,7 @@ class JobCreate(BaseModel):
contract_date: Optional[date] = None
delivery_date: Optional[date] = None
delivery_terms: Optional[str] = None
project_type: Optional[str] = "냉동기"
description: Optional[str] = None
@router.get("/")
@@ -34,7 +35,7 @@ async def get_jobs(
query = """
SELECT job_no, job_name, client_name, end_user, epc_company,
project_site, contract_date, delivery_date, delivery_terms,
status, description, created_by, created_at, updated_at, is_active
project_type, status, description, created_by, created_at, updated_at, is_active
FROM jobs
WHERE is_active = true
"""
@@ -66,6 +67,7 @@ async def get_jobs(
"contract_date": job.contract_date,
"delivery_date": job.delivery_date,
"delivery_terms": job.delivery_terms,
"project_type": job.project_type,
"status": job.status,
"description": job.description,
"created_at": job.created_at,
@@ -85,7 +87,7 @@ async def get_job(job_no: str, db: Session = Depends(get_db)):
query = text("""
SELECT job_no, job_name, client_name, end_user, epc_company,
project_site, contract_date, delivery_date, delivery_terms,
status, description, created_by, created_at, updated_at, is_active
project_type, status, description, created_by, created_at, updated_at, is_active
FROM jobs
WHERE job_no = :job_no AND is_active = true
""")
@@ -108,6 +110,7 @@ async def get_job(job_no: str, db: Session = Depends(get_db)):
"contract_date": job.contract_date,
"delivery_date": job.delivery_date,
"delivery_terms": job.delivery_terms,
"project_type": job.project_type,
"status": job.status,
"description": job.description,
"created_by": job.created_by,
@@ -139,14 +142,14 @@ async def create_job(job: JobCreate, db: Session = Depends(get_db)):
INSERT INTO jobs (
job_no, job_name, client_name, end_user, epc_company,
project_site, contract_date, delivery_date, delivery_terms,
description, created_by, status, is_active
project_type, description, created_by, status, is_active
)
VALUES (
:job_no, :job_name, :client_name, :end_user, :epc_company,
:project_site, :contract_date, :delivery_date, :delivery_terms,
:description, :created_by, :status, :is_active
:project_type, :description, :created_by, :status, :is_active
)
RETURNING job_no, job_name, client_name
RETURNING job_no, job_name, client_name, project_type
""")
result = db.execute(insert_query, {
@@ -165,7 +168,8 @@ async def create_job(job: JobCreate, db: Session = Depends(get_db)):
"job": {
"job_no": new_job.job_no,
"job_name": new_job.job_name,
"client_name": new_job.client_name
"client_name": new_job.client_name,
"project_type": new_job.project_type
}
}

View File

@@ -0,0 +1,538 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import text
from typing import Optional, List
from datetime import datetime, date
from pydantic import BaseModel
from decimal import Decimal
from ..database import get_db
from ..models import (
TubingCategory, TubingSpecification, TubingManufacturer,
TubingProduct, MaterialTubingMapping, Material
)
router = APIRouter()
# ================================
# Pydantic 모델들
# ================================
class TubingCategoryResponse(BaseModel):
id: int
category_code: str
category_name: str
description: Optional[str] = None
is_active: bool
created_at: datetime
class Config:
from_attributes = True
class TubingManufacturerResponse(BaseModel):
id: int
manufacturer_code: str
manufacturer_name: str
country: Optional[str] = None
website: Optional[str] = None
is_active: bool
class Config:
from_attributes = True
class TubingSpecificationResponse(BaseModel):
id: int
spec_code: str
spec_name: str
category_name: Optional[str] = None
outer_diameter_mm: Optional[float] = None
wall_thickness_mm: Optional[float] = None
inner_diameter_mm: Optional[float] = None
material_grade: Optional[str] = None
material_standard: Optional[str] = None
max_pressure_bar: Optional[float] = None
max_temperature_c: Optional[float] = None
min_temperature_c: Optional[float] = None
standard_length_m: Optional[float] = None
surface_finish: Optional[str] = None
is_active: bool
class Config:
from_attributes = True
class TubingProductResponse(BaseModel):
id: int
specification_id: int
manufacturer_id: int
manufacturer_part_number: str
manufacturer_product_name: Optional[str] = None
spec_name: Optional[str] = None
manufacturer_name: Optional[str] = None
list_price: Optional[float] = None
currency: Optional[str] = 'KRW'
lead_time_days: Optional[int] = None
availability_status: Optional[str] = None
datasheet_url: Optional[str] = None
notes: Optional[str] = None
is_active: bool
class Config:
from_attributes = True
class TubingProductCreate(BaseModel):
specification_id: int
manufacturer_id: int
manufacturer_part_number: str
manufacturer_product_name: Optional[str] = None
list_price: Optional[float] = None
currency: str = 'KRW'
lead_time_days: Optional[int] = None
minimum_order_qty: Optional[float] = None
standard_packaging_qty: Optional[float] = None
availability_status: Optional[str] = None
datasheet_url: Optional[str] = None
catalog_page: Optional[str] = None
notes: Optional[str] = None
class MaterialTubingMappingCreate(BaseModel):
material_id: int
tubing_product_id: int
confidence_score: Optional[float] = None
mapping_method: str = 'manual'
required_length_m: Optional[float] = None
calculated_quantity: Optional[float] = None
notes: Optional[str] = None
# ================================
# API 엔드포인트들
# ================================
@router.get("/categories", response_model=List[TubingCategoryResponse])
async def get_tubing_categories(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
db: Session = Depends(get_db)
):
"""Tubing 카테고리 목록 조회"""
try:
categories = db.query(TubingCategory)\
.filter(TubingCategory.is_active == True)\
.offset(skip)\
.limit(limit)\
.all()
return categories
except Exception as e:
raise HTTPException(status_code=500, detail=f"카테고리 조회 실패: {str(e)}")
@router.get("/manufacturers", response_model=List[TubingManufacturerResponse])
async def get_tubing_manufacturers(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
search: Optional[str] = Query(None),
country: Optional[str] = Query(None),
db: Session = Depends(get_db)
):
"""Tubing 제조사 목록 조회"""
try:
query = db.query(TubingManufacturer)\
.filter(TubingManufacturer.is_active == True)
if search:
query = query.filter(
TubingManufacturer.manufacturer_name.ilike(f"%{search}%") |
TubingManufacturer.manufacturer_code.ilike(f"%{search}%")
)
if country:
query = query.filter(TubingManufacturer.country.ilike(f"%{country}%"))
manufacturers = query.offset(skip).limit(limit).all()
return manufacturers
except Exception as e:
raise HTTPException(status_code=500, detail=f"제조사 조회 실패: {str(e)}")
@router.get("/specifications", response_model=List[TubingSpecificationResponse])
async def get_tubing_specifications(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
category_id: Optional[int] = Query(None),
material_grade: Optional[str] = Query(None),
outer_diameter_min: Optional[float] = Query(None),
outer_diameter_max: Optional[float] = Query(None),
search: Optional[str] = Query(None),
db: Session = Depends(get_db)
):
"""Tubing 규격 목록 조회"""
try:
query = db.query(TubingSpecification)\
.options(joinedload(TubingSpecification.category))\
.filter(TubingSpecification.is_active == True)
if category_id:
query = query.filter(TubingSpecification.category_id == category_id)
if material_grade:
query = query.filter(TubingSpecification.material_grade.ilike(f"%{material_grade}%"))
if outer_diameter_min:
query = query.filter(TubingSpecification.outer_diameter_mm >= outer_diameter_min)
if outer_diameter_max:
query = query.filter(TubingSpecification.outer_diameter_mm <= outer_diameter_max)
if search:
query = query.filter(
TubingSpecification.spec_name.ilike(f"%{search}%") |
TubingSpecification.spec_code.ilike(f"%{search}%") |
TubingSpecification.material_grade.ilike(f"%{search}%")
)
specifications = query.offset(skip).limit(limit).all()
# 응답 데이터 변환
result = []
for spec in specifications:
spec_dict = {
"id": spec.id,
"spec_code": spec.spec_code,
"spec_name": spec.spec_name,
"category_name": spec.category.category_name if spec.category else None,
"outer_diameter_mm": float(spec.outer_diameter_mm) if spec.outer_diameter_mm else None,
"wall_thickness_mm": float(spec.wall_thickness_mm) if spec.wall_thickness_mm else None,
"inner_diameter_mm": float(spec.inner_diameter_mm) if spec.inner_diameter_mm else None,
"material_grade": spec.material_grade,
"material_standard": spec.material_standard,
"max_pressure_bar": float(spec.max_pressure_bar) if spec.max_pressure_bar else None,
"max_temperature_c": float(spec.max_temperature_c) if spec.max_temperature_c else None,
"min_temperature_c": float(spec.min_temperature_c) if spec.min_temperature_c else None,
"standard_length_m": float(spec.standard_length_m) if spec.standard_length_m else None,
"surface_finish": spec.surface_finish,
"is_active": spec.is_active
}
result.append(spec_dict)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=f"규격 조회 실패: {str(e)}")
@router.get("/products", response_model=List[TubingProductResponse])
async def get_tubing_products(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
specification_id: Optional[int] = Query(None),
manufacturer_id: Optional[int] = Query(None),
search: Optional[str] = Query(None),
db: Session = Depends(get_db)
):
"""Tubing 제품 목록 조회"""
try:
query = db.query(TubingProduct)\
.options(
joinedload(TubingProduct.specification),
joinedload(TubingProduct.manufacturer)
)\
.filter(TubingProduct.is_active == True)
if specification_id:
query = query.filter(TubingProduct.specification_id == specification_id)
if manufacturer_id:
query = query.filter(TubingProduct.manufacturer_id == manufacturer_id)
if search:
query = query.filter(
TubingProduct.manufacturer_part_number.ilike(f"%{search}%") |
TubingProduct.manufacturer_product_name.ilike(f"%{search}%")
)
products = query.offset(skip).limit(limit).all()
# 응답 데이터 변환
result = []
for product in products:
product_dict = {
"id": product.id,
"specification_id": product.specification_id,
"manufacturer_id": product.manufacturer_id,
"manufacturer_part_number": product.manufacturer_part_number,
"manufacturer_product_name": product.manufacturer_product_name,
"spec_name": product.specification.spec_name if product.specification else None,
"manufacturer_name": product.manufacturer.manufacturer_name if product.manufacturer else None,
"list_price": float(product.list_price) if product.list_price else None,
"currency": product.currency,
"lead_time_days": product.lead_time_days,
"availability_status": product.availability_status,
"datasheet_url": product.datasheet_url,
"notes": product.notes,
"is_active": product.is_active
}
result.append(product_dict)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=f"제품 조회 실패: {str(e)}")
@router.post("/products", response_model=TubingProductResponse)
async def create_tubing_product(
product_data: TubingProductCreate,
db: Session = Depends(get_db)
):
"""새 Tubing 제품 등록"""
try:
# 중복 확인
existing = db.query(TubingProduct)\
.filter(
TubingProduct.specification_id == product_data.specification_id,
TubingProduct.manufacturer_id == product_data.manufacturer_id,
TubingProduct.manufacturer_part_number == product_data.manufacturer_part_number
).first()
if existing:
raise HTTPException(
status_code=400,
detail=f"동일한 제품이 이미 등록되어 있습니다: {product_data.manufacturer_part_number}"
)
# 새 제품 생성
new_product = TubingProduct(**product_data.dict())
db.add(new_product)
db.commit()
db.refresh(new_product)
# 관련 정보와 함께 조회
product_with_relations = db.query(TubingProduct)\
.options(
joinedload(TubingProduct.specification),
joinedload(TubingProduct.manufacturer)
)\
.filter(TubingProduct.id == new_product.id)\
.first()
return {
"id": product_with_relations.id,
"specification_id": product_with_relations.specification_id,
"manufacturer_id": product_with_relations.manufacturer_id,
"manufacturer_part_number": product_with_relations.manufacturer_part_number,
"manufacturer_product_name": product_with_relations.manufacturer_product_name,
"spec_name": product_with_relations.specification.spec_name if product_with_relations.specification else None,
"manufacturer_name": product_with_relations.manufacturer.manufacturer_name if product_with_relations.manufacturer else None,
"list_price": float(product_with_relations.list_price) if product_with_relations.list_price else None,
"currency": product_with_relations.currency,
"lead_time_days": product_with_relations.lead_time_days,
"availability_status": product_with_relations.availability_status,
"datasheet_url": product_with_relations.datasheet_url,
"notes": product_with_relations.notes,
"is_active": product_with_relations.is_active
}
except HTTPException:
db.rollback()
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"제품 등록 실패: {str(e)}")
@router.post("/material-mapping")
async def create_material_tubing_mapping(
mapping_data: MaterialTubingMappingCreate,
mapped_by: str = "admin",
db: Session = Depends(get_db)
):
"""BOM 자재와 Tubing 제품 매핑 생성"""
try:
# 기존 매핑 확인
existing = db.query(MaterialTubingMapping)\
.filter(
MaterialTubingMapping.material_id == mapping_data.material_id,
MaterialTubingMapping.tubing_product_id == mapping_data.tubing_product_id
).first()
if existing:
raise HTTPException(
status_code=400,
detail="이미 매핑된 자재와 제품입니다"
)
# 새 매핑 생성
new_mapping = MaterialTubingMapping(
**mapping_data.dict(),
mapped_by=mapped_by
)
db.add(new_mapping)
db.commit()
db.refresh(new_mapping)
return {
"success": True,
"message": "매핑이 성공적으로 생성되었습니다",
"mapping_id": new_mapping.id
}
except HTTPException:
db.rollback()
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"매핑 생성 실패: {str(e)}")
@router.get("/material-mappings/{material_id}")
async def get_material_tubing_mappings(
material_id: int,
db: Session = Depends(get_db)
):
"""특정 자재의 Tubing 매핑 조회"""
try:
mappings = db.query(MaterialTubingMapping)\
.options(
joinedload(MaterialTubingMapping.tubing_product)
.joinedload(TubingProduct.specification),
joinedload(MaterialTubingMapping.tubing_product)
.joinedload(TubingProduct.manufacturer)
)\
.filter(MaterialTubingMapping.material_id == material_id)\
.all()
result = []
for mapping in mappings:
product = mapping.tubing_product
mapping_dict = {
"mapping_id": mapping.id,
"confidence_score": float(mapping.confidence_score) if mapping.confidence_score else None,
"mapping_method": mapping.mapping_method,
"mapped_by": mapping.mapped_by,
"mapped_at": mapping.mapped_at,
"required_length_m": float(mapping.required_length_m) if mapping.required_length_m else None,
"calculated_quantity": float(mapping.calculated_quantity) if mapping.calculated_quantity else None,
"is_verified": mapping.is_verified,
"tubing_product": {
"id": product.id,
"manufacturer_part_number": product.manufacturer_part_number,
"manufacturer_product_name": product.manufacturer_product_name,
"spec_name": product.specification.spec_name if product.specification else None,
"manufacturer_name": product.manufacturer.manufacturer_name if product.manufacturer else None,
"list_price": float(product.list_price) if product.list_price else None,
"currency": product.currency,
"availability_status": product.availability_status
}
}
result.append(mapping_dict)
return {
"success": True,
"material_id": material_id,
"mappings": result
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"매핑 조회 실패: {str(e)}")
@router.get("/search")
async def search_tubing_products(
query: str = Query(..., min_length=2),
category: Optional[str] = Query(None),
manufacturer: Optional[str] = Query(None),
min_diameter: Optional[float] = Query(None),
max_diameter: Optional[float] = Query(None),
material_grade: Optional[str] = Query(None),
limit: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db)
):
"""통합 Tubing 검색 (규격, 제품, 제조사)"""
try:
# SQL 쿼리로 복합 검색
sql_query = """
SELECT DISTINCT
tp.id as product_id,
tp.manufacturer_part_number,
tp.manufacturer_product_name,
tp.list_price,
tp.currency,
tp.availability_status,
ts.spec_code,
ts.spec_name,
ts.outer_diameter_mm,
ts.wall_thickness_mm,
ts.material_grade,
tc.category_name,
tm.manufacturer_name,
tm.country
FROM tubing_products tp
JOIN tubing_specifications ts ON tp.specification_id = ts.id
JOIN tubing_categories tc ON ts.category_id = tc.id
JOIN tubing_manufacturers tm ON tp.manufacturer_id = tm.id
WHERE tp.is_active = true
AND ts.is_active = true
AND tc.is_active = true
AND tm.is_active = true
AND (
tp.manufacturer_part_number ILIKE :query OR
tp.manufacturer_product_name ILIKE :query OR
ts.spec_name ILIKE :query OR
ts.spec_code ILIKE :query OR
ts.material_grade ILIKE :query OR
tm.manufacturer_name ILIKE :query
)
"""
params = {"query": f"%{query}%"}
# 필터 조건 추가
if category:
sql_query += " AND tc.category_code = :category"
params["category"] = category
if manufacturer:
sql_query += " AND tm.manufacturer_code = :manufacturer"
params["manufacturer"] = manufacturer
if min_diameter:
sql_query += " AND ts.outer_diameter_mm >= :min_diameter"
params["min_diameter"] = min_diameter
if max_diameter:
sql_query += " AND ts.outer_diameter_mm <= :max_diameter"
params["max_diameter"] = max_diameter
if material_grade:
sql_query += " AND ts.material_grade ILIKE :material_grade"
params["material_grade"] = f"%{material_grade}%"
sql_query += " ORDER BY tp.manufacturer_part_number LIMIT :limit"
params["limit"] = limit
result = db.execute(text(sql_query), params)
products = result.fetchall()
search_results = []
for product in products:
product_dict = {
"product_id": product.product_id,
"manufacturer_part_number": product.manufacturer_part_number,
"manufacturer_product_name": product.manufacturer_product_name,
"list_price": float(product.list_price) if product.list_price else None,
"currency": product.currency,
"availability_status": product.availability_status,
"spec_code": product.spec_code,
"spec_name": product.spec_name,
"outer_diameter_mm": float(product.outer_diameter_mm) if product.outer_diameter_mm else None,
"wall_thickness_mm": float(product.wall_thickness_mm) if product.wall_thickness_mm else None,
"material_grade": product.material_grade,
"category_name": product.category_name,
"manufacturer_name": product.manufacturer_name,
"country": product.country
}
search_results.append(product_dict)
return {
"success": True,
"query": query,
"total_results": len(search_results),
"results": search_results
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"검색 실패: {str(e)}")

View File

@@ -0,0 +1,69 @@
"""
스키마 모듈
API 요청/응답 모델 정의
"""
from .response_models import (
BaseResponse,
ErrorResponse,
SuccessResponse,
FileInfo,
FileListResponse,
FileDeleteResponse,
MaterialInfo,
MaterialListResponse,
JobInfo,
JobListResponse,
ClassificationResult,
ClassificationResponse,
MaterialStatistics,
ProjectStatistics,
StatisticsResponse,
CacheInfo,
SystemHealthResponse,
APIResponse,
# 열거형
FileStatus,
MaterialCategory,
JobStatus
)
__all__ = [
# 기본 응답 모델
"BaseResponse",
"ErrorResponse",
"SuccessResponse",
# 파일 관련
"FileInfo",
"FileListResponse",
"FileDeleteResponse",
# 자재 관련
"MaterialInfo",
"MaterialListResponse",
# 작업 관련
"JobInfo",
"JobListResponse",
# 분류 관련
"ClassificationResult",
"ClassificationResponse",
# 통계 관련
"MaterialStatistics",
"ProjectStatistics",
"StatisticsResponse",
# 시스템 관련
"CacheInfo",
"SystemHealthResponse",
# 유니온 타입
"APIResponse",
# 열거형
"FileStatus",
"MaterialCategory",
"JobStatus"
]

View File

@@ -0,0 +1,354 @@
"""
API 응답 모델 정의
타입 안정성 및 API 문서화를 위한 Pydantic 모델들
"""
from pydantic import BaseModel, Field, ConfigDict
from typing import List, Optional, Dict, Any, Union
from datetime import datetime
from enum import Enum
# ================================
# 기본 응답 모델
# ================================
class BaseResponse(BaseModel):
"""기본 응답 모델"""
success: bool = Field(description="요청 성공 여부")
message: Optional[str] = Field(None, description="응답 메시지")
timestamp: datetime = Field(default_factory=datetime.now, description="응답 시간")
class ErrorResponse(BaseResponse):
"""에러 응답 모델"""
success: bool = Field(False, description="요청 성공 여부")
error: Dict[str, Any] = Field(description="에러 정보")
model_config = ConfigDict(
json_schema_extra={
"example": {
"success": False,
"message": "요청 처리 중 오류가 발생했습니다",
"error": {
"code": "VALIDATION_ERROR",
"details": "입력 데이터가 올바르지 않습니다"
},
"timestamp": "2025-01-01T12:00:00"
}
}
)
class SuccessResponse(BaseResponse):
"""성공 응답 모델"""
success: bool = Field(True, description="요청 성공 여부")
data: Optional[Any] = Field(None, description="응답 데이터")
# ================================
# 열거형 정의
# ================================
class FileStatus(str, Enum):
"""파일 상태"""
ACTIVE = "active"
INACTIVE = "inactive"
PROCESSING = "processing"
ERROR = "error"
class MaterialCategory(str, Enum):
"""자재 카테고리"""
PIPE = "PIPE"
FITTING = "FITTING"
VALVE = "VALVE"
FLANGE = "FLANGE"
BOLT = "BOLT"
GASKET = "GASKET"
INSTRUMENT = "INSTRUMENT"
EXCLUDE = "EXCLUDE"
class JobStatus(str, Enum):
"""작업 상태"""
ACTIVE = "active"
COMPLETED = "completed"
ON_HOLD = "on_hold"
CANCELLED = "cancelled"
# ================================
# 파일 관련 모델
# ================================
class FileInfo(BaseModel):
"""파일 정보 모델"""
id: int = Field(description="파일 ID")
filename: str = Field(description="파일명")
original_filename: str = Field(description="원본 파일명")
job_no: Optional[str] = Field(None, description="작업 번호")
bom_name: Optional[str] = Field(None, description="BOM 이름")
revision: str = Field(default="Rev.0", description="리비전")
parsed_count: int = Field(default=0, description="파싱된 자재 수")
bom_type: str = Field(default="unknown", description="BOM 타입")
status: FileStatus = Field(description="파일 상태")
file_size: Optional[int] = Field(None, description="파일 크기 (bytes)")
upload_date: datetime = Field(description="업로드 일시")
description: Optional[str] = Field(None, description="파일 설명")
model_config = ConfigDict(
json_schema_extra={
"example": {
"id": 1,
"filename": "BOM_Rev1.xlsx",
"original_filename": "BOM_Rev1.xlsx",
"job_no": "TK-2025-001",
"bom_name": "메인 BOM",
"revision": "Rev.1",
"parsed_count": 150,
"bom_type": "excel",
"status": "active",
"file_size": 2048576,
"upload_date": "2025-01-01T12:00:00",
"description": "파일: BOM_Rev1.xlsx"
}
}
)
class FileListResponse(BaseResponse):
"""파일 목록 응답 모델"""
success: bool = Field(True, description="요청 성공 여부")
data: List[FileInfo] = Field(description="파일 목록")
total_count: int = Field(description="전체 파일 수")
cache_hit: bool = Field(default=False, description="캐시 히트 여부")
class FileDeleteResponse(BaseResponse):
"""파일 삭제 응답 모델"""
success: bool = Field(True, description="삭제 성공 여부")
message: str = Field(description="삭제 결과 메시지")
deleted_file_id: int = Field(description="삭제된 파일 ID")
# ================================
# 자재 관련 모델
# ================================
class MaterialInfo(BaseModel):
"""자재 정보 모델"""
id: int = Field(description="자재 ID")
file_id: int = Field(description="파일 ID")
line_number: Optional[int] = Field(None, description="엑셀 행 번호")
original_description: str = Field(description="원본 품명")
classified_category: Optional[MaterialCategory] = Field(None, description="분류된 카테고리")
classified_subcategory: Optional[str] = Field(None, description="세부 분류")
material_grade: Optional[str] = Field(None, description="재질 등급")
schedule: Optional[str] = Field(None, description="스케줄")
size_spec: Optional[str] = Field(None, description="사이즈 규격")
quantity: float = Field(description="수량")
unit: str = Field(description="단위")
classification_confidence: Optional[float] = Field(None, description="분류 신뢰도")
is_verified: bool = Field(default=False, description="검증 여부")
model_config = ConfigDict(
json_schema_extra={
"example": {
"id": 1,
"file_id": 1,
"line_number": 5,
"original_description": "PIPE, SEAMLESS, A333-6, 6\", SCH40",
"classified_category": "PIPE",
"classified_subcategory": "SEAMLESS",
"material_grade": "A333-6",
"schedule": "SCH40",
"size_spec": "6\"",
"quantity": 12.5,
"unit": "EA",
"classification_confidence": 0.95,
"is_verified": False
}
}
)
class MaterialListResponse(BaseResponse):
"""자재 목록 응답 모델"""
success: bool = Field(True, description="요청 성공 여부")
data: List[MaterialInfo] = Field(description="자재 목록")
total_count: int = Field(description="전체 자재 수")
file_info: Optional[FileInfo] = Field(None, description="파일 정보")
cache_hit: bool = Field(default=False, description="캐시 히트 여부")
# ================================
# 작업 관련 모델
# ================================
class JobInfo(BaseModel):
"""작업 정보 모델"""
job_no: str = Field(description="작업 번호")
job_name: str = Field(description="작업명")
client_name: Optional[str] = Field(None, description="고객사명")
end_user: Optional[str] = Field(None, description="최종 사용자")
epc_company: Optional[str] = Field(None, description="EPC 회사")
status: JobStatus = Field(description="작업 상태")
created_at: datetime = Field(description="생성 일시")
file_count: int = Field(default=0, description="파일 수")
material_count: int = Field(default=0, description="자재 수")
model_config = ConfigDict(
json_schema_extra={
"example": {
"job_no": "TK-2025-001",
"job_name": "석유화학 플랜트 배관 프로젝트",
"client_name": "한국석유화학",
"end_user": "울산공장",
"epc_company": "현대엔지니어링",
"status": "active",
"created_at": "2025-01-01T09:00:00",
"file_count": 3,
"material_count": 450
}
}
)
class JobListResponse(BaseResponse):
"""작업 목록 응답 모델"""
success: bool = Field(True, description="요청 성공 여부")
data: List[JobInfo] = Field(description="작업 목록")
total_count: int = Field(description="전체 작업 수")
cache_hit: bool = Field(default=False, description="캐시 히트 여부")
# ================================
# 분류 관련 모델
# ================================
class ClassificationResult(BaseModel):
"""분류 결과 모델"""
category: MaterialCategory = Field(description="분류된 카테고리")
subcategory: Optional[str] = Field(None, description="세부 분류")
confidence: float = Field(description="분류 신뢰도 (0.0-1.0)")
material_grade: Optional[str] = Field(None, description="재질 등급")
size_spec: Optional[str] = Field(None, description="사이즈 규격")
schedule: Optional[str] = Field(None, description="스케줄")
details: Optional[Dict[str, Any]] = Field(None, description="분류 상세 정보")
model_config = ConfigDict(
json_schema_extra={
"example": {
"category": "PIPE",
"subcategory": "SEAMLESS",
"confidence": 0.95,
"material_grade": "A333-6",
"size_spec": "6\"",
"schedule": "SCH40",
"details": {
"matched_keywords": ["PIPE", "SEAMLESS", "A333-6"],
"size_detected": True,
"material_detected": True
}
}
}
)
class ClassificationResponse(BaseResponse):
"""분류 응답 모델"""
success: bool = Field(True, description="분류 성공 여부")
data: ClassificationResult = Field(description="분류 결과")
processing_time: float = Field(description="처리 시간 (초)")
cache_hit: bool = Field(default=False, description="캐시 히트 여부")
# ================================
# 통계 관련 모델
# ================================
class MaterialStatistics(BaseModel):
"""자재 통계 모델"""
category: MaterialCategory = Field(description="자재 카테고리")
count: int = Field(description="개수")
percentage: float = Field(description="비율 (%)")
total_quantity: float = Field(description="총 수량")
unique_items: int = Field(description="고유 항목 수")
class ProjectStatistics(BaseModel):
"""프로젝트 통계 모델"""
job_no: str = Field(description="작업 번호")
total_materials: int = Field(description="총 자재 수")
total_files: int = Field(description="총 파일 수")
category_breakdown: List[MaterialStatistics] = Field(description="카테고리별 분석")
classification_accuracy: float = Field(description="분류 정확도")
verified_percentage: float = Field(description="검증 완료율")
model_config = ConfigDict(
json_schema_extra={
"example": {
"job_no": "TK-2025-001",
"total_materials": 450,
"total_files": 3,
"category_breakdown": [
{
"category": "PIPE",
"count": 180,
"percentage": 40.0,
"total_quantity": 1250.5,
"unique_items": 45
}
],
"classification_accuracy": 0.92,
"verified_percentage": 0.75
}
}
)
class StatisticsResponse(BaseResponse):
"""통계 응답 모델"""
success: bool = Field(True, description="요청 성공 여부")
data: ProjectStatistics = Field(description="통계 데이터")
cache_hit: bool = Field(default=False, description="캐시 히트 여부")
# ================================
# 시스템 관련 모델
# ================================
class CacheInfo(BaseModel):
"""캐시 정보 모델"""
status: str = Field(description="캐시 상태")
used_memory: str = Field(description="사용 메모리")
connected_clients: int = Field(description="연결된 클라이언트 수")
hit_rate: float = Field(description="캐시 히트율 (%)")
total_commands: int = Field(description="총 명령 수")
class SystemHealthResponse(BaseResponse):
"""시스템 상태 응답 모델"""
success: bool = Field(True, description="요청 성공 여부")
data: Dict[str, Any] = Field(description="시스템 상태 정보")
cache_info: Optional[CacheInfo] = Field(None, description="캐시 정보")
database_status: str = Field(description="데이터베이스 상태")
api_version: str = Field(description="API 버전")
# ================================
# 유니온 타입 (여러 응답 타입)
# ================================
# API 응답으로 사용할 수 있는 모든 타입
APIResponse = Union[
SuccessResponse,
ErrorResponse,
FileListResponse,
FileDeleteResponse,
MaterialListResponse,
JobListResponse,
ClassificationResponse,
StatisticsResponse,
SystemHealthResponse
]

View File

@@ -0,0 +1,333 @@
"""
파일 관리 비즈니스 로직
API 레이어에서 분리된 핵심 비즈니스 로직
"""
from typing import List, Dict, Optional, Tuple
from sqlalchemy.orm import Session
from sqlalchemy import text
from fastapi import HTTPException
from ..utils.logger import get_logger
from ..utils.cache_manager import tkmp_cache
from ..utils.transaction_manager import TransactionManager, async_transactional
from ..schemas.response_models import FileInfo
from ..config import get_settings
logger = get_logger(__name__)
settings = get_settings()
class FileService:
"""파일 관리 서비스"""
def __init__(self, db: Session):
self.db = db
self.transaction_manager = TransactionManager(db)
async def get_files(
self,
job_no: Optional[str] = None,
show_history: bool = False,
use_cache: bool = True
) -> Tuple[List[Dict], bool]:
"""
파일 목록 조회
Args:
job_no: 작업 번호
show_history: 이력 표시 여부
use_cache: 캐시 사용 여부
Returns:
Tuple[List[Dict], bool]: (파일 목록, 캐시 히트 여부)
"""
try:
logger.info(f"파일 목록 조회 - job_no: {job_no}, show_history: {show_history}")
# 캐시 확인
if use_cache:
cached_files = tkmp_cache.get_file_list(job_no, show_history)
if cached_files:
logger.info(f"캐시에서 파일 목록 반환 - {len(cached_files)}개 파일")
return cached_files, True
# 데이터베이스에서 조회
query, params = self._build_file_query(job_no, show_history)
result = self.db.execute(text(query), params)
files = result.fetchall()
# 결과 변환
file_list = self._convert_files_to_dict(files)
# 캐시에 저장
if use_cache:
tkmp_cache.set_file_list(file_list, job_no, show_history)
logger.debug("파일 목록 캐시 저장 완료")
logger.info(f"파일 목록 조회 완료 - {len(file_list)}개 파일 반환")
return file_list, False
except Exception as e:
logger.error(f"파일 목록 조회 실패: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"파일 목록 조회 실패: {str(e)}")
def _build_file_query(self, job_no: Optional[str], show_history: bool) -> Tuple[str, Dict]:
"""파일 조회 쿼리 생성"""
if show_history:
# 전체 이력 표시
query = "SELECT * FROM files"
params = {}
if job_no:
query += " WHERE job_no = :job_no"
params["job_no"] = job_no
query += " ORDER BY original_filename, revision DESC"
else:
# 최신 리비전만 표시
if job_no:
query = """
SELECT f1.* FROM files f1
INNER JOIN (
SELECT original_filename, MAX(revision) as max_revision
FROM files
WHERE job_no = :job_no
GROUP BY original_filename
) f2 ON f1.original_filename = f2.original_filename
AND f1.revision = f2.max_revision
WHERE f1.job_no = :job_no
ORDER BY f1.upload_date DESC
"""
params = {"job_no": job_no}
else:
query = "SELECT * FROM files ORDER BY upload_date DESC"
params = {}
return query, params
def _convert_files_to_dict(self, files) -> List[Dict]:
"""파일 결과를 딕셔너리로 변환"""
return [
{
"id": f.id,
"filename": f.original_filename,
"original_filename": f.original_filename,
"name": f.original_filename,
"job_no": f.job_no,
"bom_name": f.bom_name or f.original_filename,
"revision": f.revision or "Rev.0",
"parsed_count": f.parsed_count or 0,
"bom_type": f.file_type or "unknown",
"status": "active" if f.is_active else "inactive",
"file_size": f.file_size,
"created_at": f.upload_date,
"upload_date": f.upload_date,
"description": f"파일: {f.original_filename}"
}
for f in files
]
async def delete_file(self, file_id: int) -> Dict:
"""
파일 삭제 (트랜잭션 관리 적용)
Args:
file_id: 파일 ID
Returns:
Dict: 삭제 결과
"""
try:
logger.info(f"파일 삭제 요청 - file_id: {file_id}")
# 트랜잭션 내에서 삭제 작업 수행
with self.transaction_manager.transaction():
# 파일 정보 조회
file_info = self._get_file_info(file_id)
if not file_info:
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다")
# 관련 데이터 삭제 (세이브포인트 사용)
with self.transaction_manager.savepoint("delete_related_data"):
self._delete_related_data(file_id)
# 파일 삭제
with self.transaction_manager.savepoint("delete_file_record"):
self._delete_file_record(file_id)
# 트랜잭션이 성공적으로 완료되면 캐시 무효화
self._invalidate_file_cache(file_id, file_info)
logger.info(f"파일 삭제 완료 - file_id: {file_id}, filename: {file_info.original_filename}")
return {
"success": True,
"message": "파일과 관련 데이터가 삭제되었습니다",
"deleted_file_id": file_id
}
except HTTPException:
raise
except Exception as e:
logger.error(f"파일 삭제 실패 - file_id: {file_id}, error: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"파일 삭제 실패: {str(e)}")
def _get_file_info(self, file_id: int):
"""파일 정보 조회"""
file_query = text("SELECT * FROM files WHERE id = :file_id")
file_result = self.db.execute(file_query, {"file_id": file_id})
return file_result.fetchone()
def _delete_related_data(self, file_id: int):
"""관련 데이터 삭제"""
# 상세 테이블 목록
detail_tables = [
'pipe_details', 'fitting_details', 'valve_details',
'flange_details', 'bolt_details', 'gasket_details',
'instrument_details'
]
# 해당 파일의 materials ID 조회
material_ids_query = text("SELECT id FROM materials WHERE file_id = :file_id")
material_ids_result = self.db.execute(material_ids_query, {"file_id": file_id})
material_ids = [row[0] for row in material_ids_result]
if material_ids:
logger.info(f"관련 자재 데이터 삭제 - {len(material_ids)}개 자재")
# 각 상세 테이블에서 관련 데이터 삭제
for table in detail_tables:
delete_detail_query = text(f"DELETE FROM {table} WHERE material_id = ANY(:material_ids)")
self.db.execute(delete_detail_query, {"material_ids": material_ids})
# materials 테이블 데이터 삭제
materials_query = text("DELETE FROM materials WHERE file_id = :file_id")
self.db.execute(materials_query, {"file_id": file_id})
def _delete_file_record(self, file_id: int):
"""파일 레코드 삭제"""
delete_query = text("DELETE FROM files WHERE id = :file_id")
self.db.execute(delete_query, {"file_id": file_id})
def _invalidate_file_cache(self, file_id: int, file_info):
"""파일 관련 캐시 무효화"""
tkmp_cache.invalidate_file_cache(file_id)
if hasattr(file_info, 'job_no') and file_info.job_no:
tkmp_cache.invalidate_job_cache(file_info.job_no)
async def get_file_statistics(self, job_no: Optional[str] = None) -> Dict:
"""
파일 통계 조회
Args:
job_no: 작업 번호
Returns:
Dict: 파일 통계
"""
try:
# 캐시 확인
if job_no:
cached_stats = tkmp_cache.get_statistics(job_no, "file_stats")
if cached_stats:
return cached_stats
# 통계 쿼리 실행
stats_query = self._build_statistics_query(job_no)
result = self.db.execute(text(stats_query["query"]), stats_query["params"])
stats_data = result.fetchall()
# 통계 데이터 변환
statistics = self._convert_statistics_data(stats_data)
# 캐시에 저장
if job_no:
tkmp_cache.set_statistics(statistics, job_no, "file_stats")
return statistics
except Exception as e:
logger.error(f"파일 통계 조회 실패: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"파일 통계 조회 실패: {str(e)}")
def _build_statistics_query(self, job_no: Optional[str]) -> Dict:
"""통계 쿼리 생성"""
base_query = """
SELECT
COUNT(*) as total_files,
COUNT(DISTINCT job_no) as total_jobs,
SUM(CASE WHEN is_active = true THEN 1 ELSE 0 END) as active_files,
SUM(file_size) as total_size,
AVG(file_size) as avg_size,
MAX(upload_date) as latest_upload,
MIN(upload_date) as earliest_upload
FROM files
"""
params = {}
if job_no:
base_query += " WHERE job_no = :job_no"
params["job_no"] = job_no
return {"query": base_query, "params": params}
def _convert_statistics_data(self, stats_data) -> Dict:
"""통계 데이터 변환"""
if not stats_data:
return {
"total_files": 0,
"total_jobs": 0,
"active_files": 0,
"total_size": 0,
"avg_size": 0,
"latest_upload": None,
"earliest_upload": None
}
stats = stats_data[0]
return {
"total_files": stats.total_files or 0,
"total_jobs": stats.total_jobs or 0,
"active_files": stats.active_files or 0,
"total_size": stats.total_size or 0,
"total_size_mb": round((stats.total_size or 0) / (1024 * 1024), 2),
"avg_size": stats.avg_size or 0,
"avg_size_mb": round((stats.avg_size or 0) / (1024 * 1024), 2),
"latest_upload": stats.latest_upload,
"earliest_upload": stats.earliest_upload
}
async def validate_file_access(self, file_id: int, user_id: Optional[str] = None) -> bool:
"""
파일 접근 권한 검증
Args:
file_id: 파일 ID
user_id: 사용자 ID
Returns:
bool: 접근 권한 여부
"""
try:
# 파일 존재 여부 확인
file_info = self._get_file_info(file_id)
if not file_info:
return False
# 파일이 활성 상태인지 확인
if not file_info.is_active:
logger.warning(f"비활성 파일 접근 시도 - file_id: {file_id}")
return False
# 추가 권한 검증 로직 (필요시 구현)
# 예: 사용자별 프로젝트 접근 권한 등
return True
except Exception as e:
logger.error(f"파일 접근 권한 검증 실패: {str(e)}", exc_info=True)
return False
def get_file_service(db: Session) -> FileService:
"""파일 서비스 팩토리 함수"""
return FileService(db)

View File

@@ -10,26 +10,26 @@ from typing import Dict, List, Tuple
from sqlalchemy.orm import Session
from sqlalchemy import text
# 자재별 기본 여유율
# 자재별 기본 여유율 (올바른 규칙으로 수정)
SAFETY_FACTORS = {
'PIPE': 1.15, # 15% 추가 (절단 손실)
'FITTING': 1.10, # 10% 추가 (연결 오차)
'VALVE': 1.50, # 50% 추가 (예비품)
'FLANGE': 1.10, # 10% 추가
'BOLT': 1.20, # 20% 추가 (분실율)
'GASKET': 1.25, # 25% 추가 (교체주기)
'INSTRUMENT': 1.00, # 0% 추가 (정확한 수량)
'DEFAULT': 1.10 # 기본 10% 추가
'PIPE': 1.00, # 0% 추가 (절단 손실은 별도 계산)
'FITTING': 1.00, # 0% 추가 (BOM 수량 그대로)
'VALVE': 1.00, # 0% 추가 (BOM 수량 그대로)
'FLANGE': 1.00, # 0% 추가 (BOM 수량 그대로)
'BOLT': 1.05, # 5% 추가 (분실율)
'GASKET': 1.00, # 0% 추가 (5의 배수 올림으로 처리)
'INSTRUMENT': 1.00, # 0% 추가 (BOM 수량 그대로)
'DEFAULT': 1.00 # 기본 0% 추가
}
# 최소 주문 수량 (자재별)
# 최소 주문 수량 (자재별) - 올바른 규칙으로 수정
MINIMUM_ORDER_QTY = {
'PIPE': 6000, # 6M 단위
'FITTING': 1, # 개별 주문 가능
'VALVE': 1, # 개별 주문 가능
'FLANGE': 1, # 개별 주문 가능
'BOLT': 50, # 박스 단위 (50개)
'GASKET': 10, # 세트 단위
'BOLT': 4, # 4의 배수 단위
'GASKET': 5, # 5의 배수 단위
'INSTRUMENT': 1, # 개별 주문 가능
'DEFAULT': 1
}
@@ -37,7 +37,7 @@ MINIMUM_ORDER_QTY = {
def calculate_pipe_purchase_quantity(materials: List[Dict]) -> Dict:
"""
PIPE 구매 수량 계산
- 각 절단마다 3mm 손실
- 각 절단마다 2mm 손실 (올바른 규칙)
- 6,000mm (6M) 단위로 올림
"""
total_bom_length = 0
@@ -45,19 +45,23 @@ def calculate_pipe_purchase_quantity(materials: List[Dict]) -> Dict:
pipe_details = []
for material in materials:
# 길이 정보 추출
# 길이 정보 추출 (Decimal 타입 처리)
length_mm = float(material.get('length_mm', 0) or 0)
quantity = float(material.get('quantity', 1) or 1)
if length_mm > 0:
total_bom_length += length_mm
cutting_count += 1
total_length = length_mm * quantity # 총 길이 = 단위길이 × 수량
total_bom_length += total_length
cutting_count += quantity # 절단 횟수 = 수량
pipe_details.append({
'description': material.get('original_description', ''),
'length_mm': length_mm,
'quantity': material.get('quantity', 1)
'quantity': quantity,
'total_length': total_length
})
# 절단 손실 계산 (각 절단마다 3mm)
cutting_loss = cutting_count * 3
# 절단 손실 계산 (각 절단마다 2mm - 올바른 규칙)
cutting_loss = cutting_count * 2
# 총 필요 길이 = BOM 길이 + 절단 손실
required_length = total_bom_length + cutting_loss
@@ -92,7 +96,8 @@ def calculate_standard_purchase_quantity(category: str, bom_quantity: float,
if safety_factor is None:
safety_factor = SAFETY_FACTORS.get(category, SAFETY_FACTORS['DEFAULT'])
# 1단계: 여유율 적용
# 1단계: 여유율 적용 (Decimal 타입 처리)
bom_quantity = float(bom_quantity) if bom_quantity else 0.0
safety_qty = bom_quantity * safety_factor
# 2단계: 최소 주문 수량 확인
@@ -101,9 +106,13 @@ def calculate_standard_purchase_quantity(category: str, bom_quantity: float,
# 3단계: 최소 주문 수량과 비교하여 큰 값 선택
calculated_qty = max(safety_qty, min_order_qty)
# 4단계: 특별 처리 (BOLT는 박스 단위로 올림)
if category == 'BOLT' and calculated_qty > min_order_qty:
calculated_qty = math.ceil(calculated_qty / min_order_qty) * min_order_qty
# 4단계: 특별 처리 (올바른 규칙 적용)
if category == 'BOLT':
# BOLT: 5% 여유율 후 4의 배수로 올림
calculated_qty = math.ceil(safety_qty / min_order_qty) * min_order_qty
elif category == 'GASKET':
# GASKET: 5의 배수로 올림 (여유율 없음)
calculated_qty = math.ceil(bom_quantity / min_order_qty) * min_order_qty
return {
'bom_quantity': bom_quantity,
@@ -120,16 +129,19 @@ def generate_purchase_items_from_materials(db: Session, file_id: int,
"""
자재 데이터로부터 구매 품목 생성
"""
# 1. 파일의 모든 자재 조회
# 1. 파일의 모든 자재 조회 (상세 테이블 정보 포함)
materials_query = text("""
SELECT m.*,
pd.length_mm, pd.outer_diameter, pd.schedule, pd.material_spec as pipe_material_spec,
fd.fitting_type, fd.connection_method as fitting_connection,
fd.fitting_type, fd.connection_method as fitting_connection, fd.main_size as fitting_main_size,
fd.reduced_size as fitting_reduced_size, fd.material_grade as fitting_material_grade,
vd.valve_type, vd.connection_method as valve_connection, vd.pressure_rating as valve_pressure,
fl.flange_type, fl.pressure_rating as flange_pressure,
gd.gasket_type, gd.material_type as gasket_material,
bd.bolt_type, bd.material_standard, bd.diameter,
id.instrument_type
vd.size_inches as valve_size,
fl.flange_type, fl.pressure_rating as flange_pressure, fl.size_inches as flange_size,
gd.gasket_type, gd.gasket_subtype, gd.material_type as gasket_material, gd.filler_material,
gd.size_inches as gasket_size, gd.pressure_rating as gasket_pressure, gd.thickness as gasket_thickness,
bd.bolt_type, bd.material_standard, bd.diameter as bolt_diameter, bd.length as bolt_length,
id.instrument_type, id.connection_size as instrument_size
FROM materials m
LEFT JOIN pipe_details pd ON m.id = pd.material_id
LEFT JOIN fitting_details fd ON m.id = fd.material_id
@@ -144,13 +156,65 @@ def generate_purchase_items_from_materials(db: Session, file_id: int,
materials = db.execute(materials_query, {"file_id": file_id}).fetchall()
# 2. 카테고리별로 그룹핑
grouped_materials = {}
for material in materials:
category = material.classified_category or 'OTHER'
if category not in grouped_materials:
grouped_materials[category] = []
grouped_materials[category].append(dict(material))
# Row 객체를 딕셔너리로 안전하게 변환
material_dict = {
'id': material.id,
'file_id': material.file_id,
'original_description': material.original_description,
'quantity': material.quantity,
'unit': material.unit,
'size_spec': material.size_spec,
'material_grade': material.material_grade,
'classified_category': material.classified_category,
'line_number': material.line_number,
# PIPE 상세 정보
'length_mm': getattr(material, 'length_mm', None),
'outer_diameter': getattr(material, 'outer_diameter', None),
'schedule': getattr(material, 'schedule', None),
'pipe_material_spec': getattr(material, 'pipe_material_spec', None),
# FITTING 상세 정보
'fitting_type': getattr(material, 'fitting_type', None),
'fitting_connection': getattr(material, 'fitting_connection', None),
'fitting_main_size': getattr(material, 'fitting_main_size', None),
'fitting_reduced_size': getattr(material, 'fitting_reduced_size', None),
'fitting_material_grade': getattr(material, 'fitting_material_grade', None),
# VALVE 상세 정보
'valve_type': getattr(material, 'valve_type', None),
'valve_connection': getattr(material, 'valve_connection', None),
'valve_pressure': getattr(material, 'valve_pressure', None),
'valve_size': getattr(material, 'valve_size', None),
# FLANGE 상세 정보
'flange_type': getattr(material, 'flange_type', None),
'flange_pressure': getattr(material, 'flange_pressure', None),
'flange_size': getattr(material, 'flange_size', None),
# GASKET 상세 정보
'gasket_type': getattr(material, 'gasket_type', None),
'gasket_subtype': getattr(material, 'gasket_subtype', None),
'gasket_material': getattr(material, 'gasket_material', None),
'filler_material': getattr(material, 'filler_material', None),
'gasket_size': getattr(material, 'gasket_size', None),
'gasket_pressure': getattr(material, 'gasket_pressure', None),
'gasket_thickness': getattr(material, 'gasket_thickness', None),
# BOLT 상세 정보
'bolt_type': getattr(material, 'bolt_type', None),
'material_standard': getattr(material, 'material_standard', None),
'bolt_diameter': getattr(material, 'bolt_diameter', None),
'bolt_length': getattr(material, 'bolt_length', None),
# INSTRUMENT 상세 정보
'instrument_type': getattr(material, 'instrument_type', None),
'instrument_size': getattr(material, 'instrument_size', None)
}
grouped_materials[category].append(material_dict)
# 3. 각 카테고리별로 구매 품목 생성
purchase_items = []
@@ -249,13 +313,41 @@ def generate_material_specs_for_category(materials: List[Dict], category: str) -
if category == 'FITTING':
fitting_type = material.get('fitting_type', 'FITTING')
connection_method = material.get('fitting_connection', '')
material_spec = material.get('material_grade', '')
main_nom = material.get('main_nom', '')
red_nom = material.get('red_nom', '')
size_display = f"{main_nom} x {red_nom}" if red_nom else main_nom
# 상세 테이블의 재질 정보 우선 사용
material_spec = material.get('fitting_material_grade') or material.get('material_grade', '')
# 상세 테이블의 사이즈 정보 사용
main_size = material.get('fitting_main_size', '')
reduced_size = material.get('fitting_reduced_size', '')
# 사이즈 표시 생성 (축소형인 경우 main x reduced 형태)
if main_size and reduced_size and main_size != reduced_size:
size_display = f"{main_size} x {reduced_size}"
else:
size_display = main_size or material.get('size_spec', '')
# 기존 분류기 방식: 피팅 타입 + 연결방식 + 압력등급
# 예: "ELBOW, SOCKET WELD, 3000LB"
fitting_display = fitting_type.replace('_', ' ') if fitting_type else 'FITTING'
spec_parts = [fitting_display]
# 연결방식 추가
if connection_method and connection_method != 'UNKNOWN':
connection_display = connection_method.replace('_', ' ')
spec_parts.append(connection_display)
# 압력등급 추출 (description에서)
description = material.get('original_description', '').upper()
import re
pressure_match = re.search(r'(\d+)LB', description)
if pressure_match:
spec_parts.append(f"{pressure_match.group(1)}LB")
# 스케줄 정보 추출 (니플 등에 중요)
schedule_match = re.search(r'SCH\s*(\d+)', description)
if schedule_match:
spec_parts.append(f"SCH {schedule_match.group(1)}")
spec_parts = [fitting_type]
if connection_method: spec_parts.append(connection_method)
full_spec = ', '.join(spec_parts)
spec_key = f"FITTING|{full_spec}|{material_spec}|{size_display}"
@@ -272,19 +364,20 @@ def generate_material_specs_for_category(materials: List[Dict], category: str) -
connection_method = material.get('valve_connection', '')
pressure_rating = material.get('valve_pressure', '')
material_spec = material.get('material_grade', '')
main_nom = material.get('main_nom', '')
# 상세 테이블의 사이즈 정보 우선 사용
size_display = material.get('valve_size') or material.get('size_spec', '')
spec_parts = [valve_type.replace('_', ' ')]
if connection_method: spec_parts.append(connection_method.replace('_', ' '))
if pressure_rating: spec_parts.append(pressure_rating)
full_spec = ', '.join(spec_parts)
spec_key = f"VALVE|{full_spec}|{material_spec}|{main_nom}"
spec_key = f"VALVE|{full_spec}|{material_spec}|{size_display}"
spec_data = {
'category': 'VALVE',
'category': 'VALVE',
'full_spec': full_spec,
'material_spec': material_spec,
'size_display': main_nom,
'size_display': size_display,
'unit': 'EA'
}
@@ -292,102 +385,198 @@ def generate_material_specs_for_category(materials: List[Dict], category: str) -
flange_type = material.get('flange_type', 'FLANGE')
pressure_rating = material.get('flange_pressure', '')
material_spec = material.get('material_grade', '')
main_nom = material.get('main_nom', '')
# 상세 테이블의 사이즈 정보 우선 사용
size_display = material.get('flange_size') or material.get('size_spec', '')
spec_parts = [flange_type]
spec_parts = [flange_type.replace('_', ' ')]
if pressure_rating: spec_parts.append(pressure_rating)
full_spec = ', '.join(spec_parts)
spec_key = f"FLANGE|{full_spec}|{material_spec}|{main_nom}"
spec_key = f"FLANGE|{full_spec}|{material_spec}|{size_display}"
spec_data = {
'category': 'FLANGE',
'full_spec': full_spec,
'material_spec': material_spec,
'size_display': main_nom,
'size_display': size_display,
'unit': 'EA'
}
elif category == 'BOLT':
bolt_type = material.get('bolt_type', 'BOLT')
material_standard = material.get('material_standard', '')
diameter = material.get('diameter', material.get('main_nom', ''))
# 상세 테이블의 사이즈 정보 우선 사용
diameter = material.get('bolt_diameter') or material.get('size_spec', '')
length = material.get('bolt_length', '')
material_spec = material_standard or material.get('material_grade', '')
# 분수 사이즈 정보 추출 (새로 추가된 분류기 정보)
size_fraction = material.get('size_fraction', diameter)
surface_treatment = material.get('surface_treatment', '')
# 기존 분류기 방식에 따른 사이즈 표시 (분수 형태)
# 소수점을 분수로 변환 (예: 0.625 -> 5/8)
size_display = diameter
if diameter and '.' in diameter:
try:
decimal_val = float(diameter)
# 일반적인 볼트 사이즈 분수 변환
fraction_map = {
0.25: "1/4\"", 0.3125: "5/16\"", 0.375: "3/8\"",
0.4375: "7/16\"", 0.5: "1/2\"", 0.5625: "9/16\"",
0.625: "5/8\"", 0.6875: "11/16\"", 0.75: "3/4\"",
0.8125: "13/16\"", 0.875: "7/8\"", 1.0: "1\""
}
if decimal_val in fraction_map:
size_display = fraction_map[decimal_val]
except:
pass
# 특수 용도 정보 추출 (PSV, LT, CK)
special_applications = {
'PSV': 0,
'LT': 0,
'CK': 0
}
# 설명에서 특수 용도 키워드 확인 (간단한 방법)
description = material.get('original_description', '').upper()
if 'PSV' in description or 'PRESSURE SAFETY VALVE' in description:
special_applications['PSV'] = material.get('quantity', 0)
if any(keyword in description for keyword in ['LT', 'LOW TEMP', '저온용']):
special_applications['LT'] = material.get('quantity', 0)
if 'CK' in description or 'CHECK VALVE' in description:
special_applications['CK'] = material.get('quantity', 0)
# 길이 정보 포함한 사이즈 표시 (예: 5/8" x 165L)
if length:
# 길이에서 숫자만 추출
import re
length_match = re.search(r'(\d+(?:\.\d+)?)', str(length))
if length_match:
length_num = length_match.group(1)
size_display_with_length = f"{size_display} x {length_num}L"
else:
size_display_with_length = f"{size_display} x {length}"
else:
size_display_with_length = size_display
spec_parts = [bolt_type.replace('_', ' ')]
if material_standard: spec_parts.append(material_standard)
full_spec = ', '.join(spec_parts)
# 특수 용도와 관계없이 사이즈+길이로 합산 (구매는 동일하므로)
# 길이 정보가 있으면 포함
length_info = material.get('length', '')
if length_info:
diameter_key = f"{diameter}L{length_info}"
else:
diameter_key = diameter
spec_key = f"BOLT|{full_spec}|{material_spec}|{diameter_key}"
# 사이즈+길이로 그룹핑
spec_key = f"BOLT|{full_spec}|{material_spec}|{size_display_with_length}"
spec_data = {
'category': 'BOLT',
'full_spec': full_spec,
'material_spec': material_spec,
'size_display': diameter,
'size_fraction': size_fraction,
'surface_treatment': surface_treatment,
'size_display': size_display_with_length,
'unit': 'EA'
}
elif category == 'GASKET':
# 상세 테이블 정보 우선 사용
gasket_type = material.get('gasket_type', 'GASKET')
gasket_subtype = material.get('gasket_subtype', '')
gasket_material = material.get('gasket_material', '')
material_spec = gasket_material or material.get('material_grade', '')
main_nom = material.get('main_nom', '')
filler_material = material.get('filler_material', '')
gasket_pressure = material.get('gasket_pressure', '')
gasket_thickness = material.get('gasket_thickness', '')
# 상세 테이블의 사이즈 정보 우선 사용
size_display = material.get('gasket_size') or material.get('size_spec', '')
# 기존 분류기 방식: 가스켓 타입 + 압력등급 + 재질
# 예: "SPIRAL WOUND, 150LB, 304SS + GRAPHITE"
spec_parts = [gasket_type.replace('_', ' ')]
# 서브타입 추가 (있는 경우)
if gasket_subtype and gasket_subtype != gasket_type:
spec_parts.append(gasket_subtype.replace('_', ' '))
# 상세 테이블의 압력등급 우선 사용, 없으면 description에서 추출
if gasket_pressure:
spec_parts.append(gasket_pressure)
else:
description = material.get('original_description', '').upper()
import re
pressure_match = re.search(r'(\d+)LB', description)
if pressure_match:
spec_parts.append(f"{pressure_match.group(1)}LB")
# 재질 정보 구성 (상세 테이블 정보 활용)
material_spec_parts = []
# SWG의 경우 메탈 + 필러 형태로 구성
if gasket_type == 'SPIRAL_WOUND':
# 기존 저장된 데이터가 부정확한 경우, 원본 description에서 직접 파싱
description = material.get('original_description', '').upper()
# SS304/GRAPHITE/CS/CS 패턴 파싱 (H/F/I/O 다음에 오는 재질 정보)
import re
material_spec = None
# H/F/I/O SS304/GRAPHITE/CS/CS 패턴 (전체 구성 표시)
hfio_material_match = re.search(r'H/F/I/O\s+([A-Z0-9]+)/([A-Z]+)/([A-Z0-9]+)/([A-Z0-9]+)', description)
if hfio_material_match:
part1 = hfio_material_match.group(1) # SS304
part2 = hfio_material_match.group(2) # GRAPHITE
part3 = hfio_material_match.group(3) # CS
part4 = hfio_material_match.group(4) # CS
material_spec = f"{part1}/{part2}/{part3}/{part4}"
else:
# 단순 SS304/GRAPHITE/CS/CS 패턴 (전체 구성 표시)
simple_material_match = re.search(r'([A-Z0-9]+)/([A-Z]+)/([A-Z0-9]+)/([A-Z0-9]+)', description)
if simple_material_match:
part1 = simple_material_match.group(1) # SS304
part2 = simple_material_match.group(2) # GRAPHITE
part3 = simple_material_match.group(3) # CS
part4 = simple_material_match.group(4) # CS
material_spec = f"{part1}/{part2}/{part3}/{part4}"
if not material_spec:
# 상세 테이블 정보 사용
if gasket_material and gasket_material != 'GRAPHITE': # 메탈 부분
material_spec_parts.append(gasket_material)
elif gasket_material == 'GRAPHITE':
# GRAPHITE만 있는 경우 description에서 메탈 부분 찾기
metal_match = re.search(r'(SS\d+|CS|INCONEL\d*)', description)
if metal_match:
material_spec_parts.append(metal_match.group(1))
if filler_material and filler_material != gasket_material: # 필러 부분
material_spec_parts.append(filler_material)
elif 'GRAPHITE' in description and 'GRAPHITE' not in material_spec_parts:
material_spec_parts.append('GRAPHITE')
if material_spec_parts:
material_spec = ' + '.join(material_spec_parts) # SS304 + GRAPHITE
else:
material_spec = material.get('material_grade', '')
else:
# 일반 가스켓의 경우
if gasket_material:
material_spec_parts.append(gasket_material)
if filler_material and filler_material != gasket_material:
material_spec_parts.append(filler_material)
if material_spec_parts:
material_spec = ', '.join(material_spec_parts)
else:
material_spec = material.get('material_grade', '')
if material_spec:
spec_parts.append(material_spec)
# 두께 정보 추가 (있는 경우)
if gasket_thickness:
spec_parts.append(f"THK {gasket_thickness}")
spec_parts = [gasket_type]
if gasket_material: spec_parts.append(gasket_material)
full_spec = ', '.join(spec_parts)
spec_key = f"GASKET|{full_spec}|{material_spec}|{main_nom}"
spec_key = f"GASKET|{full_spec}|{material_spec}|{size_display}"
spec_data = {
'category': 'GASKET',
'full_spec': full_spec,
'material_spec': material_spec,
'size_display': main_nom,
'size_display': size_display,
'unit': 'EA'
}
elif category == 'INSTRUMENT':
instrument_type = material.get('instrument_type', 'INSTRUMENT')
material_spec = material.get('material_grade', '')
main_nom = material.get('main_nom', '')
# 상세 테이블의 사이즈 정보 우선 사용
size_display = material.get('instrument_size') or material.get('size_spec', '')
full_spec = instrument_type.replace('_', ' ')
spec_key = f"INSTRUMENT|{full_spec}|{material_spec}|{main_nom}"
spec_key = f"INSTRUMENT|{full_spec}|{material_spec}|{size_display}"
spec_data = {
'category': 'INSTRUMENT',
'full_spec': full_spec,
'material_spec': material_spec,
'size_display': main_nom,
'size_display': size_display,
'unit': 'EA'
}

View File

@@ -0,0 +1,12 @@
"""
유틸리티 모듈
"""
from .logger import get_logger, setup_logger, app_logger
from .file_validator import file_validator, validate_uploaded_file
from .error_handlers import ErrorResponse, TKMPException, setup_error_handlers
__all__ = [
"get_logger", "setup_logger", "app_logger",
"file_validator", "validate_uploaded_file",
"ErrorResponse", "TKMPException", "setup_error_handlers"
]

View File

@@ -0,0 +1,266 @@
"""
Redis 캐시 관리 유틸리티
성능 향상을 위한 캐싱 전략 구현
"""
import json
import redis
from typing import Any, Optional, Dict, List
from datetime import timedelta
import hashlib
import pickle
from ..config import get_settings
from .logger import get_logger
settings = get_settings()
logger = get_logger(__name__)
class CacheManager:
"""Redis 캐시 관리 클래스"""
def __init__(self):
try:
# Redis 연결 설정
self.redis_client = redis.from_url(
settings.redis.url,
decode_responses=False, # 바이너리 데이터 지원
socket_connect_timeout=5,
socket_timeout=5,
retry_on_timeout=True
)
# 연결 테스트
self.redis_client.ping()
logger.info("Redis 연결 성공")
except Exception as e:
logger.error(f"Redis 연결 실패: {e}")
self.redis_client = None
def _generate_key(self, prefix: str, *args, **kwargs) -> str:
"""캐시 키 생성"""
# 인자들을 문자열로 변환하여 해시 생성
key_parts = [str(arg) for arg in args]
key_parts.extend([f"{k}:{v}" for k, v in sorted(kwargs.items())])
if key_parts:
key_hash = hashlib.md5("|".join(key_parts).encode()).hexdigest()[:8]
return f"tkmp:{prefix}:{key_hash}"
else:
return f"tkmp:{prefix}"
def get(self, key: str) -> Optional[Any]:
"""캐시에서 데이터 조회"""
if not self.redis_client:
return None
try:
data = self.redis_client.get(key)
if data:
return pickle.loads(data)
return None
except Exception as e:
logger.warning(f"캐시 조회 실패 - key: {key}, error: {e}")
return None
def set(self, key: str, value: Any, expire: int = 3600) -> bool:
"""캐시에 데이터 저장"""
if not self.redis_client:
return False
try:
serialized_data = pickle.dumps(value)
result = self.redis_client.setex(key, expire, serialized_data)
logger.debug(f"캐시 저장 - key: {key}, expire: {expire}s")
return result
except Exception as e:
logger.warning(f"캐시 저장 실패 - key: {key}, error: {e}")
return False
def delete(self, key: str) -> bool:
"""캐시에서 데이터 삭제"""
if not self.redis_client:
return False
try:
result = self.redis_client.delete(key)
logger.debug(f"캐시 삭제 - key: {key}")
return bool(result)
except Exception as e:
logger.warning(f"캐시 삭제 실패 - key: {key}, error: {e}")
return False
def delete_pattern(self, pattern: str) -> int:
"""패턴에 맞는 캐시 키들 삭제"""
if not self.redis_client:
return 0
try:
keys = self.redis_client.keys(pattern)
if keys:
deleted = self.redis_client.delete(*keys)
logger.info(f"패턴 캐시 삭제 - pattern: {pattern}, deleted: {deleted}")
return deleted
return 0
except Exception as e:
logger.warning(f"패턴 캐시 삭제 실패 - pattern: {pattern}, error: {e}")
return 0
def exists(self, key: str) -> bool:
"""캐시 키 존재 여부 확인"""
if not self.redis_client:
return False
try:
return bool(self.redis_client.exists(key))
except Exception as e:
logger.warning(f"캐시 존재 확인 실패 - key: {key}, error: {e}")
return False
def get_ttl(self, key: str) -> int:
"""캐시 TTL 조회"""
if not self.redis_client:
return -1
try:
return self.redis_client.ttl(key)
except Exception as e:
logger.warning(f"캐시 TTL 조회 실패 - key: {key}, error: {e}")
return -1
class TKMPCache:
"""TK-MP 프로젝트 전용 캐시 래퍼"""
def __init__(self):
self.cache = CacheManager()
# 캐시 TTL 설정 (초 단위)
self.ttl_config = {
"file_list": 300, # 5분 - 파일 목록
"material_list": 600, # 10분 - 자재 목록
"job_list": 1800, # 30분 - 작업 목록
"classification": 3600, # 1시간 - 분류 결과
"statistics": 900, # 15분 - 통계 데이터
"comparison": 1800, # 30분 - 리비전 비교
}
def get_file_list(self, job_no: Optional[str] = None, show_history: bool = False) -> Optional[List[Dict]]:
"""파일 목록 캐시 조회"""
key = self.cache._generate_key("files", job_no=job_no, history=show_history)
return self.cache.get(key)
def set_file_list(self, files: List[Dict], job_no: Optional[str] = None, show_history: bool = False) -> bool:
"""파일 목록 캐시 저장"""
key = self.cache._generate_key("files", job_no=job_no, history=show_history)
return self.cache.set(key, files, self.ttl_config["file_list"])
def get_material_list(self, file_id: int) -> Optional[List[Dict]]:
"""자재 목록 캐시 조회"""
key = self.cache._generate_key("materials", file_id=file_id)
return self.cache.get(key)
def set_material_list(self, materials: List[Dict], file_id: int) -> bool:
"""자재 목록 캐시 저장"""
key = self.cache._generate_key("materials", file_id=file_id)
return self.cache.set(key, materials, self.ttl_config["material_list"])
def get_job_list(self) -> Optional[List[Dict]]:
"""작업 목록 캐시 조회"""
key = self.cache._generate_key("jobs")
return self.cache.get(key)
def set_job_list(self, jobs: List[Dict]) -> bool:
"""작업 목록 캐시 저장"""
key = self.cache._generate_key("jobs")
return self.cache.set(key, jobs, self.ttl_config["job_list"])
def get_classification_result(self, description: str, category: str) -> Optional[Dict]:
"""분류 결과 캐시 조회"""
key = self.cache._generate_key("classification", desc=description, cat=category)
return self.cache.get(key)
def set_classification_result(self, result: Dict, description: str, category: str) -> bool:
"""분류 결과 캐시 저장"""
key = self.cache._generate_key("classification", desc=description, cat=category)
return self.cache.set(key, result, self.ttl_config["classification"])
def get_statistics(self, job_no: str, stat_type: str) -> Optional[Dict]:
"""통계 데이터 캐시 조회"""
key = self.cache._generate_key("stats", job_no=job_no, type=stat_type)
return self.cache.get(key)
def set_statistics(self, stats: Dict, job_no: str, stat_type: str) -> bool:
"""통계 데이터 캐시 저장"""
key = self.cache._generate_key("stats", job_no=job_no, type=stat_type)
return self.cache.set(key, stats, self.ttl_config["statistics"])
def get_revision_comparison(self, job_no: str, rev1: str, rev2: str) -> Optional[Dict]:
"""리비전 비교 결과 캐시 조회"""
key = self.cache._generate_key("comparison", job_no=job_no, rev1=rev1, rev2=rev2)
return self.cache.get(key)
def set_revision_comparison(self, comparison: Dict, job_no: str, rev1: str, rev2: str) -> bool:
"""리비전 비교 결과 캐시 저장"""
key = self.cache._generate_key("comparison", job_no=job_no, rev1=rev1, rev2=rev2)
return self.cache.set(key, comparison, self.ttl_config["comparison"])
def invalidate_job_cache(self, job_no: str):
"""특정 작업의 모든 캐시 무효화"""
patterns = [
f"tkmp:files:*job_no:{job_no}*",
f"tkmp:materials:*job_no:{job_no}*",
f"tkmp:stats:*job_no:{job_no}*",
f"tkmp:comparison:*job_no:{job_no}*"
]
total_deleted = 0
for pattern in patterns:
deleted = self.cache.delete_pattern(pattern)
total_deleted += deleted
logger.info(f"작업 캐시 무효화 완료 - job_no: {job_no}, deleted: {total_deleted}")
return total_deleted
def invalidate_file_cache(self, file_id: int):
"""특정 파일의 모든 캐시 무효화"""
patterns = [
f"tkmp:materials:*file_id:{file_id}*",
f"tkmp:files:*" # 파일 목록도 갱신 필요
]
total_deleted = 0
for pattern in patterns:
deleted = self.cache.delete_pattern(pattern)
total_deleted += deleted
logger.info(f"파일 캐시 무효화 완료 - file_id: {file_id}, deleted: {total_deleted}")
return total_deleted
def get_cache_info(self) -> Dict[str, Any]:
"""캐시 상태 정보 조회"""
if not self.cache.redis_client:
return {"status": "disconnected"}
try:
info = self.cache.redis_client.info()
return {
"status": "connected",
"used_memory": info.get("used_memory_human", "N/A"),
"connected_clients": info.get("connected_clients", 0),
"total_commands_processed": info.get("total_commands_processed", 0),
"keyspace_hits": info.get("keyspace_hits", 0),
"keyspace_misses": info.get("keyspace_misses", 0),
"hit_rate": round(
info.get("keyspace_hits", 0) /
max(info.get("keyspace_hits", 0) + info.get("keyspace_misses", 0), 1) * 100, 2
)
}
except Exception as e:
logger.error(f"캐시 정보 조회 실패: {e}")
return {"status": "error", "error": str(e)}
# 전역 캐시 인스턴스
tkmp_cache = TKMPCache()

View File

@@ -0,0 +1,139 @@
"""
에러 처리 유틸리티
표준화된 에러 응답 및 예외 처리
"""
from fastapi import HTTPException, Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from sqlalchemy.exc import SQLAlchemyError
from typing import Dict, Any
import traceback
from .logger import get_logger
logger = get_logger(__name__)
class TKMPException(Exception):
"""TK-MP 프로젝트 커스텀 예외"""
def __init__(self, message: str, error_code: str = "TKMP_ERROR", status_code: int = 500):
self.message = message
self.error_code = error_code
self.status_code = status_code
super().__init__(self.message)
class ErrorResponse:
"""표준화된 에러 응답 생성기"""
@staticmethod
def create_error_response(
message: str,
error_code: str = "INTERNAL_ERROR",
status_code: int = 500,
details: Dict[str, Any] = None
) -> Dict[str, Any]:
"""표준화된 에러 응답 생성"""
response = {
"success": False,
"error": {
"code": error_code,
"message": message,
"timestamp": "2025-01-01T00:00:00Z" # 실제로는 datetime.utcnow().isoformat()
}
}
if details:
response["error"]["details"] = details
return response
@staticmethod
def validation_error_response(errors: list) -> Dict[str, Any]:
"""검증 에러 응답"""
return ErrorResponse.create_error_response(
message="입력 데이터 검증에 실패했습니다.",
error_code="VALIDATION_ERROR",
status_code=422,
details={"validation_errors": errors}
)
@staticmethod
def database_error_response(error: str) -> Dict[str, Any]:
"""데이터베이스 에러 응답"""
return ErrorResponse.create_error_response(
message="데이터베이스 작업 중 오류가 발생했습니다.",
error_code="DATABASE_ERROR",
status_code=500,
details={"db_error": error}
)
@staticmethod
def file_error_response(error: str) -> Dict[str, Any]:
"""파일 처리 에러 응답"""
return ErrorResponse.create_error_response(
message="파일 처리 중 오류가 발생했습니다.",
error_code="FILE_ERROR",
status_code=400,
details={"file_error": error}
)
async def tkmp_exception_handler(request: Request, exc: TKMPException):
"""TK-MP 커스텀 예외 핸들러"""
logger.error(f"TK-MP 예외 발생: {exc.message} (코드: {exc.error_code})")
return JSONResponse(
status_code=exc.status_code,
content=ErrorResponse.create_error_response(
message=exc.message,
error_code=exc.error_code,
status_code=exc.status_code
)
)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
"""검증 예외 핸들러"""
logger.warning(f"검증 오류: {exc.errors()}")
return JSONResponse(
status_code=422,
content=ErrorResponse.validation_error_response(exc.errors())
)
async def sqlalchemy_exception_handler(request: Request, exc: SQLAlchemyError):
"""SQLAlchemy 예외 핸들러"""
logger.error(f"데이터베이스 오류: {str(exc)}", exc_info=True)
return JSONResponse(
status_code=500,
content=ErrorResponse.database_error_response(str(exc))
)
async def general_exception_handler(request: Request, exc: Exception):
"""일반 예외 핸들러"""
logger.error(f"예상치 못한 오류: {str(exc)}", exc_info=True)
return JSONResponse(
status_code=500,
content=ErrorResponse.create_error_response(
message="서버 내부 오류가 발생했습니다.",
error_code="INTERNAL_SERVER_ERROR",
status_code=500,
details={"error": str(exc)} if logger.level <= 10 else None # DEBUG 레벨일 때만 상세 에러 표시
)
)
def setup_error_handlers(app):
"""FastAPI 앱에 에러 핸들러 등록"""
app.add_exception_handler(TKMPException, tkmp_exception_handler)
app.add_exception_handler(RequestValidationError, validation_exception_handler)
app.add_exception_handler(SQLAlchemyError, sqlalchemy_exception_handler)
app.add_exception_handler(Exception, general_exception_handler)
logger.info("에러 핸들러 등록 완료")

View File

@@ -0,0 +1,335 @@
"""
대용량 파일 처리 최적화 유틸리티
메모리 효율적인 파일 처리 및 청크 기반 처리
"""
import pandas as pd
import asyncio
from typing import Iterator, List, Dict, Any, Optional, Callable
from pathlib import Path
import tempfile
import os
from concurrent.futures import ThreadPoolExecutor
import gc
from .logger import get_logger
from ..config import get_settings
logger = get_logger(__name__)
settings = get_settings()
class FileProcessor:
"""대용량 파일 처리 최적화 클래스"""
def __init__(self, chunk_size: int = 1000, max_workers: int = 4):
self.chunk_size = chunk_size
self.max_workers = max_workers
self.executor = ThreadPoolExecutor(max_workers=max_workers)
def read_excel_chunks(self, file_path: str, sheet_name: str = None) -> Iterator[pd.DataFrame]:
"""
엑셀 파일을 청크 단위로 읽기
Args:
file_path: 파일 경로
sheet_name: 시트명 (None이면 첫 번째 시트)
Yields:
DataFrame: 청크 단위 데이터
"""
try:
# 파일 크기 확인
file_size = os.path.getsize(file_path)
logger.info(f"엑셀 파일 처리 시작 - 파일: {file_path}, 크기: {file_size} bytes")
# 전체 행 수 확인 (메모리 효율적으로)
with pd.ExcelFile(file_path) as xls:
if sheet_name is None:
sheet_name = xls.sheet_names[0]
# 첫 번째 청크로 컬럼 정보 확인
first_chunk = pd.read_excel(xls, sheet_name=sheet_name, nrows=self.chunk_size)
total_rows = len(first_chunk)
# 전체 데이터를 청크로 나누어 처리
processed_rows = 0
chunk_num = 0
while processed_rows < total_rows:
try:
# 청크 읽기
chunk = pd.read_excel(
xls,
sheet_name=sheet_name,
skiprows=processed_rows + 1 if processed_rows > 0 else 0,
nrows=self.chunk_size,
header=0 if processed_rows == 0 else None
)
if chunk.empty:
break
# 첫 번째 청크가 아닌 경우 컬럼명 설정
if processed_rows > 0:
chunk.columns = first_chunk.columns
chunk_num += 1
processed_rows += len(chunk)
logger.debug(f"청크 {chunk_num} 처리 - 행 수: {len(chunk)}, 누적: {processed_rows}")
yield chunk
# 메모리 정리
del chunk
gc.collect()
except Exception as e:
logger.error(f"청크 {chunk_num} 처리 중 오류: {e}")
break
logger.info(f"엑셀 파일 처리 완료 - 총 {chunk_num}개 청크, {processed_rows}행 처리")
except Exception as e:
logger.error(f"엑셀 파일 읽기 실패: {e}")
raise
def read_csv_chunks(self, file_path: str, encoding: str = 'utf-8') -> Iterator[pd.DataFrame]:
"""
CSV 파일을 청크 단위로 읽기
Args:
file_path: 파일 경로
encoding: 인코딩 (기본: utf-8)
Yields:
DataFrame: 청크 단위 데이터
"""
try:
file_size = os.path.getsize(file_path)
logger.info(f"CSV 파일 처리 시작 - 파일: {file_path}, 크기: {file_size} bytes")
chunk_num = 0
total_rows = 0
# pandas의 chunksize 옵션 사용
for chunk in pd.read_csv(file_path, chunksize=self.chunk_size, encoding=encoding):
chunk_num += 1
total_rows += len(chunk)
logger.debug(f"CSV 청크 {chunk_num} 처리 - 행 수: {len(chunk)}, 누적: {total_rows}")
yield chunk
# 메모리 정리
gc.collect()
logger.info(f"CSV 파일 처리 완료 - 총 {chunk_num}개 청크, {total_rows}행 처리")
except Exception as e:
logger.error(f"CSV 파일 읽기 실패: {e}")
raise
async def process_file_async(
self,
file_path: str,
processor_func: Callable[[pd.DataFrame], List[Dict]],
file_type: str = "excel"
) -> List[Dict]:
"""
파일을 비동기적으로 처리
Args:
file_path: 파일 경로
processor_func: 각 청크를 처리할 함수
file_type: 파일 타입 ("excel" 또는 "csv")
Returns:
List[Dict]: 처리된 결과 리스트
"""
try:
logger.info(f"비동기 파일 처리 시작 - {file_path}")
results = []
chunk_futures = []
# 파일 타입에 따른 청크 리더 선택
if file_type.lower() == "csv":
chunk_reader = self.read_csv_chunks(file_path)
else:
chunk_reader = self.read_excel_chunks(file_path)
# 청크별 비동기 처리
for chunk in chunk_reader:
# 스레드 풀에서 청크 처리
future = asyncio.get_event_loop().run_in_executor(
self.executor,
processor_func,
chunk
)
chunk_futures.append(future)
# 너무 많은 청크가 동시에 처리되지 않도록 제한
if len(chunk_futures) >= self.max_workers:
# 완료된 작업들 수집
completed_results = await asyncio.gather(*chunk_futures)
for result in completed_results:
if result:
results.extend(result)
chunk_futures = []
gc.collect()
# 남은 청크들 처리
if chunk_futures:
completed_results = await asyncio.gather(*chunk_futures)
for result in completed_results:
if result:
results.extend(result)
logger.info(f"비동기 파일 처리 완료 - 총 {len(results)}개 항목 처리")
return results
except Exception as e:
logger.error(f"비동기 파일 처리 실패: {e}")
raise
def optimize_dataframe_memory(self, df: pd.DataFrame) -> pd.DataFrame:
"""
DataFrame 메모리 사용량 최적화
Args:
df: 최적화할 DataFrame
Returns:
DataFrame: 최적화된 DataFrame
"""
try:
original_memory = df.memory_usage(deep=True).sum()
# 수치형 컬럼 최적화
for col in df.select_dtypes(include=['int64']).columns:
col_min = df[col].min()
col_max = df[col].max()
if col_min >= -128 and col_max <= 127:
df[col] = df[col].astype('int8')
elif col_min >= -32768 and col_max <= 32767:
df[col] = df[col].astype('int16')
elif col_min >= -2147483648 and col_max <= 2147483647:
df[col] = df[col].astype('int32')
# 실수형 컬럼 최적화
for col in df.select_dtypes(include=['float64']).columns:
df[col] = pd.to_numeric(df[col], downcast='float')
# 문자열 컬럼 최적화 (카테고리형으로 변환)
for col in df.select_dtypes(include=['object']).columns:
if df[col].nunique() / len(df) < 0.5: # 고유값이 50% 미만인 경우
df[col] = df[col].astype('category')
optimized_memory = df.memory_usage(deep=True).sum()
memory_reduction = (original_memory - optimized_memory) / original_memory * 100
logger.debug(f"DataFrame 메모리 최적화 완료 - 감소율: {memory_reduction:.1f}%")
return df
except Exception as e:
logger.warning(f"DataFrame 메모리 최적화 실패: {e}")
return df
def create_temp_file(self, suffix: str = '.tmp') -> str:
"""
임시 파일 생성
Args:
suffix: 파일 확장자
Returns:
str: 임시 파일 경로
"""
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
temp_file.close()
logger.debug(f"임시 파일 생성: {temp_file.name}")
return temp_file.name
def cleanup_temp_file(self, file_path: str):
"""
임시 파일 정리
Args:
file_path: 삭제할 파일 경로
"""
try:
if os.path.exists(file_path):
os.unlink(file_path)
logger.debug(f"임시 파일 삭제: {file_path}")
except Exception as e:
logger.warning(f"임시 파일 삭제 실패: {file_path}, error: {e}")
def get_file_info(self, file_path: str) -> Dict[str, Any]:
"""
파일 정보 조회
Args:
file_path: 파일 경로
Returns:
Dict: 파일 정보
"""
try:
file_stat = os.stat(file_path)
file_ext = Path(file_path).suffix.lower()
info = {
"file_path": file_path,
"file_size": file_stat.st_size,
"file_size_mb": round(file_stat.st_size / (1024 * 1024), 2),
"file_extension": file_ext,
"is_large_file": file_stat.st_size > 10 * 1024 * 1024, # 10MB 이상
"recommended_chunk_size": self._calculate_optimal_chunk_size(file_stat.st_size)
}
# 파일 타입별 추가 정보
if file_ext in ['.xlsx', '.xls']:
info["file_type"] = "excel"
info["processing_method"] = "chunk_based" if info["is_large_file"] else "full_load"
elif file_ext == '.csv':
info["file_type"] = "csv"
info["processing_method"] = "chunk_based" if info["is_large_file"] else "full_load"
return info
except Exception as e:
logger.error(f"파일 정보 조회 실패: {e}")
return {"error": str(e)}
def _calculate_optimal_chunk_size(self, file_size: int) -> int:
"""
파일 크기에 따른 최적 청크 크기 계산
Args:
file_size: 파일 크기 (bytes)
Returns:
int: 최적 청크 크기
"""
# 파일 크기에 따른 청크 크기 조정
if file_size < 1024 * 1024: # 1MB 미만
return 500
elif file_size < 10 * 1024 * 1024: # 10MB 미만
return 1000
elif file_size < 50 * 1024 * 1024: # 50MB 미만
return 2000
else: # 50MB 이상
return 5000
def __del__(self):
"""소멸자 - 스레드 풀 정리"""
if hasattr(self, 'executor'):
self.executor.shutdown(wait=True)
# 전역 파일 프로세서 인스턴스
file_processor = FileProcessor()

View File

@@ -0,0 +1,169 @@
"""
파일 업로드 검증 유틸리티
보안 강화를 위한 파일 검증 로직
"""
import os
import magic
from pathlib import Path
from typing import List, Optional, Tuple
from fastapi import UploadFile, HTTPException
from ..config import get_settings
from .logger import get_logger
settings = get_settings()
logger = get_logger(__name__)
class FileValidator:
"""파일 업로드 검증 클래스"""
def __init__(self):
self.max_file_size = settings.security.max_file_size
self.allowed_extensions = settings.security.allowed_file_extensions
# MIME 타입 매핑
self.mime_type_mapping = {
'.xlsx': [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/octet-stream' # 일부 브라우저에서 xlsx를 이렇게 인식
],
'.xls': [
'application/vnd.ms-excel',
'application/octet-stream'
],
'.csv': [
'text/csv',
'text/plain',
'application/csv'
]
}
def validate_file_extension(self, filename: str) -> bool:
"""파일 확장자 검증"""
file_ext = Path(filename).suffix.lower()
is_valid = file_ext in self.allowed_extensions
if not is_valid:
logger.warning(f"허용되지 않은 파일 확장자: {file_ext}, 파일: {filename}")
return is_valid
def validate_file_size(self, file_size: int) -> bool:
"""파일 크기 검증"""
is_valid = file_size <= self.max_file_size
if not is_valid:
logger.warning(f"파일 크기 초과: {file_size} bytes (최대: {self.max_file_size} bytes)")
return is_valid
def validate_filename(self, filename: str) -> bool:
"""파일명 검증 (보안 위험 문자 체크)"""
# 위험한 문자들
dangerous_chars = ['..', '/', '\\', ':', '*', '?', '"', '<', '>', '|']
for char in dangerous_chars:
if char in filename:
logger.warning(f"위험한 문자 포함된 파일명: {filename}")
return False
# 파일명 길이 체크 (255자 제한)
if len(filename) > 255:
logger.warning(f"파일명이 너무 긺: {len(filename)} 문자")
return False
return True
def validate_mime_type(self, file_content: bytes, filename: str) -> bool:
"""MIME 타입 검증 (파일 내용 기반)"""
try:
# python-magic을 사용한 MIME 타입 검증
detected_mime = magic.from_buffer(file_content, mime=True)
file_ext = Path(filename).suffix.lower()
expected_mimes = self.mime_type_mapping.get(file_ext, [])
if detected_mime in expected_mimes:
return True
logger.warning(f"MIME 타입 불일치 - 파일: {filename}, 감지된 타입: {detected_mime}, 예상 타입: {expected_mimes}")
return False
except Exception as e:
logger.error(f"MIME 타입 검증 실패: {e}")
# magic 라이브러리 오류 시 확장자 검증으로 대체
return self.validate_file_extension(filename)
def sanitize_filename(self, filename: str) -> str:
"""파일명 정화 (안전한 파일명으로 변환)"""
# 위험한 문자들을 언더스코어로 대체
dangerous_chars = ['..', '/', '\\', ':', '*', '?', '"', '<', '>', '|']
sanitized = filename
for char in dangerous_chars:
sanitized = sanitized.replace(char, '_')
# 연속된 언더스코어 제거
while '__' in sanitized:
sanitized = sanitized.replace('__', '_')
# 앞뒤 공백 및 점 제거
sanitized = sanitized.strip(' .')
return sanitized
async def validate_upload_file(self, file: UploadFile) -> Tuple[bool, Optional[str]]:
"""
업로드 파일 종합 검증
Returns:
Tuple[bool, Optional[str]]: (검증 성공 여부, 에러 메시지)
"""
try:
# 1. 파일명 검증
if not self.validate_filename(file.filename):
return False, f"유효하지 않은 파일명: {file.filename}"
# 2. 확장자 검증
if not self.validate_file_extension(file.filename):
return False, f"허용되지 않은 파일 형식입니다. 허용 형식: {', '.join(self.allowed_extensions)}"
# 3. 파일 내용 읽기
file_content = await file.read()
await file.seek(0) # 파일 포인터 리셋
# 4. 파일 크기 검증
if not self.validate_file_size(len(file_content)):
return False, f"파일 크기가 너무 큽니다. 최대 크기: {self.max_file_size // (1024*1024)}MB"
# 5. MIME 타입 검증
if not self.validate_mime_type(file_content, file.filename):
return False, "파일 형식이 올바르지 않습니다."
logger.info(f"파일 검증 성공: {file.filename} ({len(file_content)} bytes)")
return True, None
except Exception as e:
logger.error(f"파일 검증 중 오류 발생: {e}", exc_info=True)
return False, f"파일 검증 중 오류가 발생했습니다: {str(e)}"
# 전역 파일 검증기 인스턴스
file_validator = FileValidator()
async def validate_uploaded_file(file: UploadFile) -> None:
"""
파일 검증 헬퍼 함수 (HTTPException 발생)
Args:
file: 업로드된 파일
Raises:
HTTPException: 검증 실패 시
"""
is_valid, error_message = await file_validator.validate_upload_file(file)
if not is_valid:
raise HTTPException(status_code=400, detail=error_message)

View File

@@ -0,0 +1,87 @@
"""
로깅 유틸리티 모듈
중앙화된 로깅 설정 및 관리
"""
import logging
import os
from logging.handlers import RotatingFileHandler
from typing import Optional
from ..config import get_settings
settings = get_settings()
def setup_logger(
name: str,
log_file: Optional[str] = None,
level: str = None
) -> logging.Logger:
"""
로거 설정 및 반환
Args:
name: 로거 이름
log_file: 로그 파일 경로 (선택사항)
level: 로그 레벨 (선택사항)
Returns:
설정된 로거 인스턴스
"""
logger = logging.getLogger(name)
# 이미 핸들러가 설정된 경우 중복 방지
if logger.handlers:
return logger
# 로그 레벨 설정
log_level = level or settings.logging.level
logger.setLevel(getattr(logging, log_level.upper()))
# 포맷터 설정
formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s'
)
# 콘솔 핸들러
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
# 파일 핸들러 (선택사항)
if log_file or settings.logging.file_path:
file_path = log_file or settings.logging.file_path
# 로그 디렉토리 생성
log_dir = os.path.dirname(file_path)
if log_dir and not os.path.exists(log_dir):
os.makedirs(log_dir, exist_ok=True)
# 로테이팅 파일 핸들러 (10MB, 5개 파일 유지)
file_handler = RotatingFileHandler(
file_path,
maxBytes=10*1024*1024, # 10MB
backupCount=5,
encoding='utf-8'
)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
return logger
def get_logger(name: str) -> logging.Logger:
"""
로거 인스턴스 반환 (간편 함수)
Args:
name: 로거 이름
Returns:
로거 인스턴스
"""
return setup_logger(name)
# 애플리케이션 전역 로거
app_logger = setup_logger("tk_mp_app", settings.logging.file_path)

View File

@@ -0,0 +1,355 @@
"""
트랜잭션 관리 유틸리티
데이터 일관성을 위한 트랜잭션 관리 및 데코레이터
"""
import functools
from typing import Any, Callable, Optional, TypeVar, Generic
from contextlib import contextmanager
from sqlalchemy.orm import Session
from sqlalchemy.exc import SQLAlchemyError
import asyncio
from .logger import get_logger
logger = get_logger(__name__)
T = TypeVar('T')
class TransactionManager:
"""트랜잭션 관리 클래스"""
def __init__(self, db: Session):
self.db = db
@contextmanager
def transaction(self, rollback_on_exception: bool = True):
"""
트랜잭션 컨텍스트 매니저
Args:
rollback_on_exception: 예외 발생 시 롤백 여부
"""
try:
logger.debug("트랜잭션 시작")
yield self.db
self.db.commit()
logger.debug("트랜잭션 커밋 완료")
except Exception as e:
if rollback_on_exception:
self.db.rollback()
logger.warning(f"트랜잭션 롤백 - 에러: {str(e)}")
else:
logger.error(f"트랜잭션 에러 (롤백 안함) - 에러: {str(e)}")
raise
@contextmanager
def savepoint(self, name: Optional[str] = None):
"""
세이브포인트 컨텍스트 매니저
Args:
name: 세이브포인트 이름
"""
savepoint_name = name or f"sp_{id(self)}"
try:
# 세이브포인트 생성
savepoint = self.db.begin_nested()
logger.debug(f"세이브포인트 생성: {savepoint_name}")
yield self.db
# 세이브포인트 커밋
savepoint.commit()
logger.debug(f"세이브포인트 커밋: {savepoint_name}")
except Exception as e:
# 세이브포인트 롤백
savepoint.rollback()
logger.warning(f"세이브포인트 롤백: {savepoint_name} - 에러: {str(e)}")
raise
def execute_in_transaction(self, func: Callable[..., T], *args, **kwargs) -> T:
"""
함수를 트랜잭션 내에서 실행
Args:
func: 실행할 함수
*args: 함수 인자
**kwargs: 함수 키워드 인자
Returns:
함수 실행 결과
"""
with self.transaction():
return func(*args, **kwargs)
async def execute_in_transaction_async(self, func: Callable[..., T], *args, **kwargs) -> T:
"""
비동기 함수를 트랜잭션 내에서 실행
Args:
func: 실행할 비동기 함수
*args: 함수 인자
**kwargs: 함수 키워드 인자
Returns:
함수 실행 결과
"""
with self.transaction():
if asyncio.iscoroutinefunction(func):
return await func(*args, **kwargs)
else:
return func(*args, **kwargs)
def transactional(rollback_on_exception: bool = True):
"""
트랜잭션 데코레이터
Args:
rollback_on_exception: 예외 발생 시 롤백 여부
"""
def decorator(func: Callable) -> Callable:
@functools.wraps(func)
def wrapper(*args, **kwargs):
# 첫 번째 인자가 Session인지 확인
if args and isinstance(args[0], Session):
db = args[0]
transaction_manager = TransactionManager(db)
try:
with transaction_manager.transaction(rollback_on_exception):
return func(*args, **kwargs)
except Exception as e:
logger.error(f"트랜잭션 함수 실행 실패: {func.__name__} - {str(e)}")
raise
else:
# Session이 없으면 일반 함수로 실행
return func(*args, **kwargs)
return wrapper
return decorator
def async_transactional(rollback_on_exception: bool = True):
"""
비동기 트랜잭션 데코레이터
Args:
rollback_on_exception: 예외 발생 시 롤백 여부
"""
def decorator(func: Callable) -> Callable:
@functools.wraps(func)
async def wrapper(*args, **kwargs):
# 첫 번째 인자가 Session인지 확인
if args and isinstance(args[0], Session):
db = args[0]
transaction_manager = TransactionManager(db)
try:
with transaction_manager.transaction(rollback_on_exception):
if asyncio.iscoroutinefunction(func):
return await func(*args, **kwargs)
else:
return func(*args, **kwargs)
except Exception as e:
logger.error(f"비동기 트랜잭션 함수 실행 실패: {func.__name__} - {str(e)}")
raise
else:
# Session이 없으면 일반 함수로 실행
if asyncio.iscoroutinefunction(func):
return await func(*args, **kwargs)
else:
return func(*args, **kwargs)
return wrapper
return decorator
class BatchProcessor:
"""배치 처리를 위한 트랜잭션 관리"""
def __init__(self, db: Session, batch_size: int = 1000):
self.db = db
self.batch_size = batch_size
self.transaction_manager = TransactionManager(db)
def process_in_batches(
self,
items: list,
process_func: Callable,
commit_per_batch: bool = True
):
"""
아이템들을 배치 단위로 처리
Args:
items: 처리할 아이템 리스트
process_func: 각 아이템을 처리할 함수
commit_per_batch: 배치마다 커밋 여부
"""
total_items = len(items)
processed_count = 0
failed_count = 0
logger.info(f"배치 처리 시작 - 총 {total_items}개 아이템, 배치 크기: {self.batch_size}")
for i in range(0, total_items, self.batch_size):
batch = items[i:i + self.batch_size]
batch_num = (i // self.batch_size) + 1
try:
if commit_per_batch:
with self.transaction_manager.transaction():
self._process_batch(batch, process_func)
else:
self._process_batch(batch, process_func)
processed_count += len(batch)
logger.debug(f"배치 {batch_num} 처리 완료 - {len(batch)}개 아이템")
except Exception as e:
failed_count += len(batch)
logger.error(f"배치 {batch_num} 처리 실패 - {str(e)}")
# 개별 아이템 처리 시도
if commit_per_batch:
self._process_batch_individually(batch, process_func)
# 전체 커밋 (배치마다 커밋하지 않은 경우)
if not commit_per_batch:
try:
self.db.commit()
logger.info("전체 배치 처리 커밋 완료")
except Exception as e:
self.db.rollback()
logger.error(f"전체 배치 처리 커밋 실패: {str(e)}")
raise
logger.info(f"배치 처리 완료 - 성공: {processed_count}, 실패: {failed_count}")
return {
"total_items": total_items,
"processed_count": processed_count,
"failed_count": failed_count,
"success_rate": (processed_count / total_items) * 100 if total_items > 0 else 0
}
def _process_batch(self, batch: list, process_func: Callable):
"""배치 처리"""
for item in batch:
process_func(item)
def _process_batch_individually(self, batch: list, process_func: Callable):
"""배치 내 아이템을 개별적으로 처리 (에러 복구용)"""
for item in batch:
try:
with self.transaction_manager.savepoint():
process_func(item)
except Exception as e:
logger.warning(f"개별 아이템 처리 실패: {str(e)}")
class DatabaseLock:
"""데이터베이스 레벨 락 관리"""
def __init__(self, db: Session):
self.db = db
@contextmanager
def advisory_lock(self, lock_id: int):
"""
PostgreSQL Advisory Lock
Args:
lock_id: 락 ID
"""
try:
# Advisory Lock 획득
result = self.db.execute(f"SELECT pg_advisory_lock({lock_id})")
logger.debug(f"Advisory Lock 획득: {lock_id}")
yield
finally:
# Advisory Lock 해제
self.db.execute(f"SELECT pg_advisory_unlock({lock_id})")
logger.debug(f"Advisory Lock 해제: {lock_id}")
@contextmanager
def table_lock(self, table_name: str, lock_mode: str = "ACCESS EXCLUSIVE"):
"""
테이블 레벨 락
Args:
table_name: 테이블명
lock_mode: 락 모드
"""
try:
# 테이블 락 획득
self.db.execute(f"LOCK TABLE {table_name} IN {lock_mode} MODE")
logger.debug(f"테이블 락 획득: {table_name} ({lock_mode})")
yield
except Exception as e:
logger.error(f"테이블 락 실패: {table_name} - {str(e)}")
raise
class TransactionStats:
"""트랜잭션 통계 수집"""
def __init__(self):
self.stats = {
"total_transactions": 0,
"successful_transactions": 0,
"failed_transactions": 0,
"rollback_count": 0,
"savepoint_count": 0
}
def record_transaction_start(self):
"""트랜잭션 시작 기록"""
self.stats["total_transactions"] += 1
def record_transaction_success(self):
"""트랜잭션 성공 기록"""
self.stats["successful_transactions"] += 1
def record_transaction_failure(self):
"""트랜잭션 실패 기록"""
self.stats["failed_transactions"] += 1
def record_rollback(self):
"""롤백 기록"""
self.stats["rollback_count"] += 1
def record_savepoint(self):
"""세이브포인트 기록"""
self.stats["savepoint_count"] += 1
def get_stats(self) -> dict:
"""통계 반환"""
total = self.stats["total_transactions"]
if total > 0:
self.stats["success_rate"] = (self.stats["successful_transactions"] / total) * 100
self.stats["failure_rate"] = (self.stats["failed_transactions"] / total) * 100
else:
self.stats["success_rate"] = 0
self.stats["failure_rate"] = 0
return self.stats.copy()
def reset_stats(self):
"""통계 초기화"""
for key in self.stats:
if key not in ["success_rate", "failure_rate"]:
self.stats[key] = 0
# 전역 트랜잭션 통계 인스턴스
transaction_stats = TransactionStats()