464 lines
15 KiB
Python
464 lines
15 KiB
Python
from datetime import datetime
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Body, Depends, status
|
|
from pydantic import BaseModel, EmailStr, Field
|
|
from sqlalchemy import text
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database.session import get_session
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# ============================================================
|
|
# Pydantic Models for Request/Response
|
|
# ============================================================
|
|
|
|
|
|
class SigninRequest(BaseModel):
|
|
"""회원가입 요청 스키마"""
|
|
|
|
email: EmailStr = Field(
|
|
..., description="사용자 이메일 주소", example="user@example.com"
|
|
)
|
|
password: str = Field(
|
|
..., min_length=8, description="비밀번호 (최소 8자)", example="password123"
|
|
)
|
|
name: str = Field(
|
|
..., min_length=2, max_length=50, description="사용자 이름", example="홍길동"
|
|
)
|
|
phone: Optional[str] = Field(
|
|
None,
|
|
pattern=r"^\d{3}-\d{4}-\d{4}$",
|
|
description="전화번호 (형식: 010-1234-5678)",
|
|
example="010-1234-5678",
|
|
)
|
|
|
|
|
|
class SigninResponse(BaseModel):
|
|
"""회원가입 응답 스키마"""
|
|
|
|
success: bool = Field(..., description="요청 성공 여부")
|
|
message: str = Field(..., description="응답 메시지")
|
|
user_id: int = Field(..., description="생성된 사용자 ID")
|
|
email: EmailStr = Field(..., description="등록된 이메일")
|
|
created_at: datetime = Field(..., description="계정 생성 시간")
|
|
|
|
|
|
class LoginRequest(BaseModel):
|
|
"""로그인 요청 스키마"""
|
|
|
|
email: EmailStr = Field(
|
|
..., description="사용자 이메일 주소", example="user@example.com"
|
|
)
|
|
password: str = Field(..., description="비밀번호", example="password123")
|
|
|
|
|
|
class LoginResponse(BaseModel):
|
|
"""로그인 응답 스키마"""
|
|
|
|
success: bool = Field(..., description="로그인 성공 여부")
|
|
message: str = Field(..., description="응답 메시지")
|
|
access_token: str = Field(..., description="JWT 액세스 토큰")
|
|
refresh_token: str = Field(..., description="JWT 리프레시 토큰")
|
|
token_type: str = Field(default="bearer", description="토큰 타입")
|
|
expires_in: int = Field(..., description="토큰 만료 시간 (초)")
|
|
|
|
|
|
class LogoutResponse(BaseModel):
|
|
"""로그아웃 응답 스키마"""
|
|
|
|
success: bool = Field(..., description="로그아웃 성공 여부")
|
|
message: str = Field(..., description="응답 메시지")
|
|
|
|
|
|
class ProfileResponse(BaseModel):
|
|
"""프로필 조회 응답 스키마"""
|
|
|
|
user_id: int = Field(..., description="사용자 ID")
|
|
email: EmailStr = Field(..., description="이메일 주소")
|
|
name: str = Field(..., description="사용자 이름")
|
|
phone: Optional[str] = Field(None, description="전화번호")
|
|
profile_image: Optional[str] = Field(None, description="프로필 이미지 URL")
|
|
created_at: datetime = Field(..., description="계정 생성 시간")
|
|
last_login: Optional[datetime] = Field(None, description="마지막 로그인 시간")
|
|
|
|
|
|
class HomeResponse(BaseModel):
|
|
"""홈 응답 스키마"""
|
|
|
|
message: str = Field(..., description="환영 메시지")
|
|
version: str = Field(..., description="API 버전")
|
|
status: str = Field(..., description="서비스 상태")
|
|
timestamp: datetime = Field(..., description="응답 시간")
|
|
|
|
|
|
class ErrorResponse(BaseModel):
|
|
"""에러 응답 스키마"""
|
|
|
|
success: bool = Field(default=False, description="요청 성공 여부")
|
|
error_code: str = Field(..., description="에러 코드")
|
|
message: str = Field(..., description="에러 메시지")
|
|
detail: Optional[str] = Field(None, description="상세 에러 정보")
|
|
|
|
|
|
# ============================================================
|
|
# Dummy Data
|
|
# ============================================================
|
|
|
|
DUMMY_USER = {
|
|
"user_id": 1,
|
|
"email": "user@example.com",
|
|
"name": "홍길동",
|
|
"phone": "010-1234-5678",
|
|
"profile_image": "https://example.com/images/profile/default.png",
|
|
"created_at": datetime(2024, 1, 15, 10, 30, 0),
|
|
"last_login": datetime(2024, 12, 18, 9, 0, 0),
|
|
}
|
|
|
|
DUMMY_TOKENS = {
|
|
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzM0NTAwMDAwfQ.dummy_signature",
|
|
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzM1MDAwMDAwfQ.dummy_refresh",
|
|
"token_type": "bearer",
|
|
"expires_in": 3600,
|
|
}
|
|
|
|
|
|
# ============================================================
|
|
# Endpoints
|
|
# ============================================================
|
|
|
|
|
|
@router.get(
|
|
"/db",
|
|
summary="데이터베이스 상태 확인",
|
|
description="데이터베이스 연결 상태를 확인합니다. 간단한 쿼리를 실행하여 DB 연결이 정상인지 테스트합니다.",
|
|
response_description="데이터베이스 연결 상태 정보",
|
|
responses={
|
|
200: {
|
|
"description": "데이터베이스 연결 정상",
|
|
"content": {
|
|
"application/json": {
|
|
"example": {
|
|
"status": "healthy",
|
|
"database": "connected",
|
|
"test_query": 1,
|
|
}
|
|
}
|
|
},
|
|
},
|
|
500: {"description": "데이터베이스 연결 실패", "model": ErrorResponse},
|
|
},
|
|
tags=["health"],
|
|
)
|
|
async def db_health_check(session: AsyncSession = Depends(get_session)):
|
|
"""DB 연결 상태 확인"""
|
|
try:
|
|
result = await session.execute(text("SELECT 1"))
|
|
return {
|
|
"status": "healthy",
|
|
"database": "connected",
|
|
"test_query": result.scalar(),
|
|
}
|
|
except Exception as e:
|
|
return {"status": "unhealthy", "database": "disconnected", "error": str(e)}
|
|
|
|
|
|
@router.get(
|
|
"/",
|
|
summary="홈 엔드포인트",
|
|
description="API 서비스의 기본 정보를 반환합니다. 서비스 상태, 버전, 현재 시간 등의 정보를 확인할 수 있습니다.",
|
|
response_model=HomeResponse,
|
|
response_description="서비스 기본 정보",
|
|
responses={
|
|
200: {
|
|
"description": "성공적으로 홈 정보 반환",
|
|
"content": {
|
|
"application/json": {
|
|
"example": {
|
|
"message": "CASTAD API 서비스에 오신 것을 환영합니다.",
|
|
"version": "0.1.0",
|
|
"status": "running",
|
|
"timestamp": "2024-12-18T10:00:00",
|
|
}
|
|
}
|
|
},
|
|
}
|
|
},
|
|
tags=["home"],
|
|
)
|
|
async def home() -> HomeResponse:
|
|
"""홈 페이지 - API 기본 정보 반환"""
|
|
return HomeResponse(
|
|
message="CASTAD API 서비스에 오신 것을 환영합니다.",
|
|
version="0.1.0",
|
|
status="running",
|
|
timestamp=datetime.now(),
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/signin",
|
|
summary="회원가입",
|
|
description="""
|
|
새로운 사용자 계정을 생성합니다.
|
|
|
|
## 요청 필드
|
|
- **email**: 유효한 이메일 주소 (필수)
|
|
- **password**: 최소 8자 이상의 비밀번호 (필수)
|
|
- **name**: 2~50자 사이의 사용자 이름 (필수)
|
|
- **phone**: 전화번호 (선택, 형식: 010-1234-5678)
|
|
|
|
## 비밀번호 정책
|
|
- 최소 8자 이상
|
|
- 영문, 숫자 조합 권장
|
|
""",
|
|
response_model=SigninResponse,
|
|
response_description="회원가입 결과",
|
|
status_code=status.HTTP_201_CREATED,
|
|
responses={
|
|
201: {"description": "회원가입 성공", "model": SigninResponse},
|
|
400: {
|
|
"description": "잘못된 요청 (유효성 검사 실패)",
|
|
"model": ErrorResponse,
|
|
"content": {
|
|
"application/json": {
|
|
"example": {
|
|
"success": False,
|
|
"error_code": "VALIDATION_ERROR",
|
|
"message": "입력값이 유효하지 않습니다.",
|
|
"detail": "이메일 형식이 올바르지 않습니다.",
|
|
}
|
|
}
|
|
},
|
|
},
|
|
409: {
|
|
"description": "이미 존재하는 이메일",
|
|
"model": ErrorResponse,
|
|
"content": {
|
|
"application/json": {
|
|
"example": {
|
|
"success": False,
|
|
"error_code": "EMAIL_EXISTS",
|
|
"message": "이미 등록된 이메일입니다.",
|
|
"detail": None,
|
|
}
|
|
}
|
|
},
|
|
},
|
|
},
|
|
tags=["auth"],
|
|
)
|
|
async def signin(
|
|
request_body: SigninRequest = Body(
|
|
...,
|
|
description="회원가입에 필요한 사용자 정보",
|
|
openapi_examples={
|
|
"기본 예시": {
|
|
"summary": "필수 필드만 입력",
|
|
"description": "이메일, 비밀번호, 이름만 입력하는 경우",
|
|
"value": {
|
|
"email": "newuser@example.com",
|
|
"password": "securepass123",
|
|
"name": "김철수",
|
|
},
|
|
},
|
|
"전체 필드 예시": {
|
|
"summary": "모든 필드 입력",
|
|
"description": "선택 필드를 포함한 전체 입력",
|
|
"value": {
|
|
"email": "newuser@example.com",
|
|
"password": "securepass123",
|
|
"name": "김철수",
|
|
"phone": "010-9876-5432",
|
|
},
|
|
},
|
|
},
|
|
),
|
|
) -> SigninResponse:
|
|
"""새로운 사용자 회원가입 처리"""
|
|
return SigninResponse(
|
|
success=True,
|
|
message="회원가입이 완료되었습니다.",
|
|
user_id=2,
|
|
email=request_body.email,
|
|
created_at=datetime.now(),
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/login",
|
|
summary="로그인",
|
|
description="""
|
|
사용자 인증을 수행하고 JWT 토큰을 발급합니다.
|
|
|
|
## 인증 방식
|
|
이메일과 비밀번호를 사용한 기본 인증을 수행합니다.
|
|
인증 성공 시 액세스 토큰과 리프레시 토큰이 발급됩니다.
|
|
|
|
## 토큰 정보
|
|
- **access_token**: API 요청 시 사용 (유효기간: 1시간)
|
|
- **refresh_token**: 액세스 토큰 갱신 시 사용 (유효기간: 7일)
|
|
""",
|
|
response_model=LoginResponse,
|
|
response_description="로그인 결과 및 토큰 정보",
|
|
responses={
|
|
200: {"description": "로그인 성공", "model": LoginResponse},
|
|
401: {
|
|
"description": "인증 실패",
|
|
"model": ErrorResponse,
|
|
"content": {
|
|
"application/json": {
|
|
"example": {
|
|
"success": False,
|
|
"error_code": "INVALID_CREDENTIALS",
|
|
"message": "이메일 또는 비밀번호가 올바르지 않습니다.",
|
|
"detail": None,
|
|
}
|
|
}
|
|
},
|
|
},
|
|
403: {
|
|
"description": "계정 비활성화",
|
|
"model": ErrorResponse,
|
|
"content": {
|
|
"application/json": {
|
|
"example": {
|
|
"success": False,
|
|
"error_code": "ACCOUNT_DISABLED",
|
|
"message": "비활성화된 계정입니다.",
|
|
"detail": "관리자에게 문의하세요.",
|
|
}
|
|
}
|
|
},
|
|
},
|
|
},
|
|
tags=["auth"],
|
|
)
|
|
async def login(
|
|
request_body: LoginRequest = Body(
|
|
...,
|
|
description="로그인 인증 정보",
|
|
openapi_examples={
|
|
"로그인 예시": {
|
|
"summary": "일반 로그인",
|
|
"description": "이메일과 비밀번호로 로그인",
|
|
"value": {"email": "user@example.com", "password": "password123"},
|
|
}
|
|
},
|
|
),
|
|
) -> LoginResponse:
|
|
"""사용자 로그인 및 토큰 발급"""
|
|
return LoginResponse(
|
|
success=True,
|
|
message="로그인에 성공했습니다.",
|
|
access_token=DUMMY_TOKENS["access_token"],
|
|
refresh_token=DUMMY_TOKENS["refresh_token"],
|
|
token_type=DUMMY_TOKENS["token_type"],
|
|
expires_in=DUMMY_TOKENS["expires_in"],
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/logout",
|
|
summary="로그아웃",
|
|
description="""
|
|
현재 세션을 종료하고 토큰을 무효화합니다.
|
|
|
|
## 동작 방식
|
|
- 서버 측 토큰 블랙리스트에 현재 토큰 등록
|
|
- 클라이언트 측 토큰 삭제 권장
|
|
|
|
## 주의사항
|
|
로그아웃 후에는 동일한 토큰으로 API 요청이 불가능합니다.
|
|
""",
|
|
response_model=LogoutResponse,
|
|
response_description="로그아웃 결과",
|
|
responses={
|
|
200: {"description": "로그아웃 성공", "model": LogoutResponse},
|
|
401: {
|
|
"description": "인증되지 않은 요청",
|
|
"model": ErrorResponse,
|
|
"content": {
|
|
"application/json": {
|
|
"example": {
|
|
"success": False,
|
|
"error_code": "UNAUTHORIZED",
|
|
"message": "인증이 필요합니다.",
|
|
"detail": "유효한 토큰을 제공해주세요.",
|
|
}
|
|
}
|
|
},
|
|
},
|
|
},
|
|
tags=["auth"],
|
|
)
|
|
async def logout() -> LogoutResponse:
|
|
"""사용자 로그아웃 처리"""
|
|
return LogoutResponse(success=True, message="로그아웃되었습니다.")
|
|
|
|
|
|
@router.get(
|
|
"/profile",
|
|
summary="프로필 조회",
|
|
description="""
|
|
현재 로그인한 사용자의 프로필 정보를 조회합니다.
|
|
|
|
## 반환 정보
|
|
- 기본 정보: 사용자 ID, 이메일, 이름
|
|
- 연락처 정보: 전화번호
|
|
- 프로필 이미지 URL
|
|
- 계정 정보: 생성일, 마지막 로그인 시간
|
|
|
|
## 인증 필요
|
|
이 엔드포인트는 유효한 액세스 토큰이 필요합니다.
|
|
Authorization 헤더에 Bearer 토큰을 포함해주세요.
|
|
""",
|
|
response_model=ProfileResponse,
|
|
response_description="사용자 프로필 정보",
|
|
responses={
|
|
200: {"description": "프로필 조회 성공", "model": ProfileResponse},
|
|
401: {
|
|
"description": "인증되지 않은 요청",
|
|
"model": ErrorResponse,
|
|
"content": {
|
|
"application/json": {
|
|
"example": {
|
|
"success": False,
|
|
"error_code": "UNAUTHORIZED",
|
|
"message": "인증이 필요합니다.",
|
|
"detail": "유효한 토큰을 제공해주세요.",
|
|
}
|
|
}
|
|
},
|
|
},
|
|
404: {
|
|
"description": "사용자를 찾을 수 없음",
|
|
"model": ErrorResponse,
|
|
"content": {
|
|
"application/json": {
|
|
"example": {
|
|
"success": False,
|
|
"error_code": "USER_NOT_FOUND",
|
|
"message": "사용자를 찾을 수 없습니다.",
|
|
"detail": None,
|
|
}
|
|
}
|
|
},
|
|
},
|
|
},
|
|
tags=["user"],
|
|
)
|
|
async def profile() -> ProfileResponse:
|
|
"""현재 사용자 프로필 조회"""
|
|
return ProfileResponse(
|
|
user_id=DUMMY_USER["user_id"],
|
|
email=DUMMY_USER["email"],
|
|
name=DUMMY_USER["name"],
|
|
phone=DUMMY_USER["phone"],
|
|
profile_image=DUMMY_USER["profile_image"],
|
|
created_at=DUMMY_USER["created_at"],
|
|
last_login=DUMMY_USER["last_login"],
|
|
)
|