Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions app/api/v1/deps.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
from fastapi import Depends, HTTPException, status
from fastapi import Depends
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jwt import DecodeError, ExpiredSignatureError

from app.core.exceptions import AUTH_001, AppException
from app.core.security import decode_token

bearer = HTTPBearer()
bearer = HTTPBearer(auto_error=False)


async def require_login(
credentials: HTTPAuthorizationCredentials = Depends(bearer),
credentials: HTTPAuthorizationCredentials | None = Depends(bearer),
) -> str:
if credentials is None:
raise AppException(AUTH_001)
try:
return decode_token(credentials.credentials)
except (ExpiredSignatureError, DecodeError, ValueError) as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
) from e
raise AppException(AUTH_001) from e
8 changes: 3 additions & 5 deletions app/api/v1/endpoints/auth.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi import APIRouter, Depends, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession

from app.api.v1.deps import require_login
from app.core.exceptions import AUTH_001, AppException
from app.db.session import get_db
from app.schemas.auth import (
AccessTokenResp,
Expand Down Expand Up @@ -74,10 +75,7 @@ async def register(
async def refresh_token(request: Request) -> SuccessResponse[AccessTokenResp]:
token = request.cookies.get("refresh_token")
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="λ¦¬ν”„λ ˆμ‹œ 토큰이 μ—†μŠ΅λ‹ˆλ‹€.",
)
raise AppException(AUTH_001)
access_token = auth_service.refresh_access_token(token)
return SuccessResponse(data=AccessTokenResp(accessToken=access_token))

Expand Down
3 changes: 2 additions & 1 deletion app/core/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from email.mime.text import MIMEText

from app.core.config import settings
from app.core.exceptions import MAIL_001, AppException

logger = logging.getLogger(__name__)

Expand All @@ -31,7 +32,7 @@ async def send_email(to: str, subject: str, body_html: str) -> None:
await asyncio.to_thread(_send_via_smtp, to, subject, body_html)
except smtplib.SMTPException as e:
logger.error("[EMAIL] Failed to send email to %s: %s", to, e)
raise
raise AppException(MAIL_001) from e


async def send_verification_email(to: str, code: str) -> None:
Expand Down
75 changes: 68 additions & 7 deletions app/core/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,71 @@
from fastapi import status
from fastapi.exceptions import HTTPException
from dataclasses import dataclass


class ImageConditionNotMetError(HTTPException):
@dataclass(frozen=True)
class ErrorCode:
http_status: int
code: str
message: str


class AppException(Exception):
def __init__(self, error: ErrorCode) -> None:
super().__init__(error.message)
self.status_code = error.http_status
self.code = error.code
self.message = error.message


# Auth
AUTH_001 = ErrorCode(401, "AUTH_001", "Authorization header is missing or malformed")
AUTH_002 = ErrorCode(401, "AUTH_002", "Invalid credentials")
AUTH_007 = ErrorCode(
403, "AUTH_007", "You do not have permission to perform this action"
)

# User
USER_001 = ErrorCode(409, "USER_001", "This email is already registered")
USER_002 = ErrorCode(404, "USER_002", "User not found")
USER_006 = ErrorCode(400, "USER_006", "Email verification code is invalid or expired")

# Mail
MAIL_001 = ErrorCode(500, "MAIL_001", "Failed to send email")

# Store
STORE_001 = ErrorCode(404, "STORE_001", "Store not found")

# Event
EVENT_001 = ErrorCode(404, "EVENT_001", "Event not found")

# Application
APPLICATION_001 = ErrorCode(
403, "APPLICATION_001", "You are not allowed to access this application"
)
APPLICATION_002 = ErrorCode(404, "APPLICATION_002", "Application not found")
APPLICATION_003_APPLY = ErrorCode(
409, "APPLICATION_003", "You have already applied for this event"
)
APPLICATION_003_SUBMIT = ErrorCode(
409, "APPLICATION_003", "Review has already been submitted for this application"
)

# Deposit
DEPOSIT_001 = ErrorCode(400, "DEPOSIT_001", "Deposit balance is insufficient")
DEPOSIT_002 = ErrorCode(404, "DEPOSIT_002", "Deposit record not found")

# General
GEN_003_CLOSED = ErrorCode(400, "GEN_003", "Event is already closed")
GEN_003_AMOUNT = ErrorCode(400, "GEN_003", "Amount must be a positive number")
GEN_003_STATUS = ErrorCode(
400, "GEN_003", "Application cannot be cancelled in its current status"
)
GEN_005 = ErrorCode(400, "GEN_005", "Invalid wallet address format")

# Image (server-side only, not in mock)
IMAGE_001 = ErrorCode(422, "IMAGE_001", "Image does not meet the event conditions")
IMAGE_002 = ErrorCode(400, "IMAGE_002", "Image not found or inaccessible")


class ImageConditionNotMetError(AppException):
def __init__(self) -> None:
super().__init__(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="이미지가 이벀트 쑰건을 μΆ©μ‘±ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.",
)
super().__init__(IMAGE_001)
16 changes: 16 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from app.api.v1.router import router as api_router
from app.core.config import settings
from app.core.exceptions import AppException


@asynccontextmanager
Expand All @@ -20,6 +21,21 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
app.include_router(api_router, prefix="/api")


@app.exception_handler(AppException)
async def app_exception_handler(_request: Request, exc: AppException) -> JSONResponse:
return JSONResponse(
status_code=exc.status_code,
content={
"status": exc.status_code,
"data": {
"timestamp": datetime.now(UTC).isoformat(),
"message": exc.message,
"code": exc.code,
},
},
)


@app.exception_handler(HTTPException)
async def http_exception_handler(_request: Request, exc: HTTPException) -> JSONResponse:
return JSONResponse(
Expand Down
73 changes: 31 additions & 42 deletions app/services/application.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,30 @@
import asyncio
import base64
import re
from typing import Literal, cast
from uuid import UUID

import anthropic
import boto3 # type: ignore[import-untyped]
from botocore.exceptions import ClientError # type: ignore[import-untyped]
from fastapi import HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.config import settings
from app.core.exceptions import ImageConditionNotMetError
from app.core.exceptions import (
APPLICATION_001,
APPLICATION_002,
APPLICATION_003_APPLY,
APPLICATION_003_SUBMIT,
EVENT_001,
GEN_003_CLOSED,
GEN_003_STATUS,
GEN_005,
IMAGE_002,
AppException,
ImageConditionNotMetError,
)
from app.models.application import Application
from app.models.event import Event
from app.models.review_image import ReviewImage
Expand All @@ -28,6 +40,7 @@
_SUPPORTED_MEDIA_TYPES: frozenset[str] = frozenset(
{"image/jpeg", "image/png", "image/gif", "image/webp"}
)
_ETHEREUM_ADDRESS_RE = re.compile(r"^0x[0-9a-fA-F]{40}$")


def _to_media_type(content_type: str) -> _MediaType:
Expand All @@ -47,10 +60,7 @@ def _sync() -> tuple[bytes, str]:
try:
resp = s3.get_object(Bucket=settings.s3_private_bucket, Key=image_key)
except ClientError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="이미지λ₯Ό 뢈러올 수 μ—†μŠ΅λ‹ˆλ‹€.",
) from e
raise AppException(IMAGE_002) from e
return resp["Body"].read(), str(resp.get("ContentType", "image/jpeg"))

return await asyncio.get_running_loop().run_in_executor(None, _sync)
Expand Down Expand Up @@ -98,12 +108,15 @@ async def _validate_image_condition(image_key: str, condition: str) -> None:
async def create_application(
db: AsyncSession, reviewer_id: str, data: ApplicationCreateReq
) -> None:
if not _ETHEREUM_ADDRESS_RE.fullmatch(data.walletAddress):
raise AppException(GEN_005)

event = await db.scalar(select(Event).where(Event.id == UUID(data.eventId)))
if not event:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="이벀트λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.",
)
raise AppException(EVENT_001)

if not event.is_active:
raise AppException(GEN_003_CLOSED)

existing = await db.scalar(
select(Application).where(
Expand All @@ -112,10 +125,7 @@ async def create_application(
)
)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="이미 μ‹ μ²­ν•œ μ΄λ²€νŠΈμž…λ‹ˆλ‹€.",
)
raise AppException(APPLICATION_003_APPLY)

await _validate_image_condition(data.imageKey, event.condition)

Expand All @@ -130,10 +140,7 @@ async def create_application(
await db.commit()
except IntegrityError as e:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="이미 μ‹ μ²­ν•œ μ΄λ²€νŠΈμž…λ‹ˆλ‹€.",
) from e
raise AppException(APPLICATION_003_APPLY) from e


async def list_my_applications(
Expand Down Expand Up @@ -183,20 +190,11 @@ async def cancel_application(
select(Application).where(Application.id == UUID(application_id))
)
if not application:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="신청을 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.",
)
raise AppException(APPLICATION_002)
if str(application.reviewer_id) != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="본인의 신청이 μ•„λ‹™λ‹ˆλ‹€.",
)
raise AppException(APPLICATION_001)
if application.status != "PENDING":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="PENDING μƒνƒœμ˜ μ‹ μ²­λ§Œ μ·¨μ†Œν•  수 μžˆμŠ΅λ‹ˆλ‹€.",
)
raise AppException(GEN_003_STATUS)
await db.delete(application)
await db.commit()

Expand All @@ -208,25 +206,16 @@ async def submit_review(
select(Application).where(Application.id == UUID(application_id))
)
if not application:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="신청을 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.",
)
raise AppException(APPLICATION_002)
if str(application.reviewer_id) != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="본인의 신청이 μ•„λ‹™λ‹ˆλ‹€.",
)
raise AppException(APPLICATION_001)
existing = await db.scalar(
select(ReviewSubmission).where(
ReviewSubmission.application_id == UUID(application_id)
)
)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="이미 제좜된 리뷰가 μžˆμŠ΅λ‹ˆλ‹€.",
)
raise AppException(APPLICATION_003_SUBMIT)

submission = ReviewSubmission(
application_id=UUID(application_id),
Expand Down
Loading
Loading