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
34 changes: 17 additions & 17 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,18 @@ FastAPI 기반 백엔드 — 상점·이벤트·리뷰 신청 플랫폼

## Tech Stack

| 분류 | 라이브러리 |
|------|-----------|
| 웹 프레임워크 | FastAPI |
| ORM | SQLAlchemy 2.0 (async) |
| DB | PostgreSQL (asyncpg) |
| 마이그레이션 | Alembic |
| 인증 | PyJWT (추가 필요) + passlib/bcrypt |
| 파일 스토리지 | boto3 (S3) |
| 스키마 검증 | Pydantic v2 |
| 패키지 관리 | uv |
| Lint/Format | ruff |
| 타입 검사 | mypy strict |
| 분류 | 라이브러리 |
| ------------- | ---------------------------------- |
| 웹 프레임워크 | FastAPI |
| ORM | SQLAlchemy 2.0 (async) |
| DB | PostgreSQL (asyncpg) |
| 마이그레이션 | Alembic |
| 인증 | PyJWT (추가 필요) + passlib/bcrypt |
| 파일 스토리지 | boto3 (S3) |
| 스키마 검증 | Pydantic v2 |
| 패키지 관리 | uv |
| Lint/Format | ruff |
| 타입 검사 | mypy strict |

## 디렉토리 구조

Expand Down Expand Up @@ -92,9 +92,9 @@ API 명세 경로는 `/api/auth/...`, `/api/store/...` 형태 (v1 없음).

작업 대상 패키지의 CLAUDE.md를 함께 읽을 것.

| 작업 | 참조 파일 |
|------|----------|
| 모델 정의 | `app/models/CLAUDE.md` |
| 스키마 정의 | `app/schemas/CLAUDE.md` |
| 서비스 로직 | `app/services/CLAUDE.md` |
| 작업 | 참조 파일 |
| ------------------- | -------------------------------- |
| 모델 정의 | `app/models/CLAUDE.md` |
| 스키마 정의 | `app/schemas/CLAUDE.md` |
| 서비스 로직 | `app/services/CLAUDE.md` |
| 엔드포인트·API 명세 | `app/api/v1/endpoints/CLAUDE.md` |
2 changes: 2 additions & 0 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ class Settings(BaseSettings):
s3_public_bucket: str = ""
s3_presigned_expiry: int = 3600

anthropic_api_key: str = ""

model_config = {"env_file": ".env"}


Expand Down
10 changes: 10 additions & 0 deletions app/core/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from fastapi import status
from fastapi.exceptions import HTTPException


class ImageConditionNotMetError(HTTPException):
def __init__(self) -> None:
super().__init__(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="이미지가 이벤트 조건을 충족하지 않습니다.",
)
80 changes: 80 additions & 0 deletions app/services/application.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import asyncio
import base64
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.models.application import Application
from app.models.event import Event
from app.models.review_image import ReviewImage
Expand All @@ -16,6 +24,76 @@
ReviewSubmissionReq,
)

_MediaType = Literal["image/jpeg", "image/png", "image/gif", "image/webp"]
_SUPPORTED_MEDIA_TYPES: frozenset[str] = frozenset(
{"image/jpeg", "image/png", "image/gif", "image/webp"}
)


def _to_media_type(content_type: str) -> _MediaType:
if content_type in _SUPPORTED_MEDIA_TYPES:
return cast(_MediaType, content_type)
return "image/jpeg"


async def _download_image(image_key: str) -> tuple[bytes, str]:
def _sync() -> tuple[bytes, str]:
s3 = boto3.client(
"s3",
aws_access_key_id=settings.aws_access_key_id,
aws_secret_access_key=settings.aws_secret_access_key,
region_name=settings.aws_region,
)
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
return resp["Body"].read(), str(resp.get("ContentType", "image/jpeg"))

return await asyncio.get_running_loop().run_in_executor(None, _sync)


async def _validate_image_condition(image_key: str, condition: str) -> None:
image_bytes, content_type = await _download_image(image_key)
media_type = _to_media_type(content_type)
encoded = base64.standard_b64encode(image_bytes).decode("utf-8")

client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
response = await client.messages.create(
model="claude-opus-4-8",
max_tokens=128,
messages=[
{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": encoded,
},
},
{
"type": "text",
"text": (
f"이미지가 아래 조건을 충족하는지 판단하세요.\n"
f"조건: {condition}\n\n"
"충족하면 'PASS', 충족하지 않으면 'FAIL'로만 응답하세요."
),
},
],
}
],
)

block = response.content[0]
if not isinstance(block, anthropic.types.TextBlock) or "FAIL" in block.text.upper():
raise ImageConditionNotMetError()


async def create_application(
db: AsyncSession, reviewer_id: str, data: ApplicationCreateReq
Expand All @@ -39,6 +117,8 @@ async def create_application(
detail="이미 신청한 이벤트입니다.",
)

await _validate_image_condition(data.imageKey, event.condition)

application = Application(
event_id=UUID(data.eventId),
reviewer_id=UUID(reviewer_id),
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ dependencies = [
"aiosqlite>=0.22.1",
"alembic>=1.18.4",
"bcrypt>=4.0.0,<5.0.0",
"anthropic>=0.50.0",
"boto3>=1.43.24",
"fastapi>=0.136.3",
"passlib>=1.7.4",
Expand Down
135 changes: 135 additions & 0 deletions tests/test_application.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4

import anthropic
import pytest
from httpx import AsyncClient
from sqlalchemy import select
Expand All @@ -19,6 +22,14 @@

@pytest.mark.asyncio
class TestCreateApplication:
@pytest.fixture(autouse=True)
def mock_image_validation(self) -> Generator[None, None, None]:
with patch(
"app.services.application._validate_image_condition",
new_callable=AsyncMock,
):
yield

async def test_reviewer_creates_application_returns_200(
self, client: AsyncClient, db: AsyncSession
) -> None:
Expand Down Expand Up @@ -394,3 +405,127 @@ async def test_no_auth_returns_4xx(
f"/api/application/{application.id}/submission", json=body
)
assert res.status_code in (401, 403)


@pytest.mark.asyncio
class TestCreateApplicationImageValidation:
def _make_claude_mock(self, verdict: str) -> tuple[MagicMock, AsyncMock]:
block = anthropic.types.TextBlock(text=verdict, type="text")
response = MagicMock()
response.content = [block]
mock_client = AsyncMock()
mock_client.messages.create = AsyncMock(return_value=response)
return response, mock_client

async def test_image_condition_pass_returns_200(
self, client: AsyncClient, db: AsyncSession
) -> None:
reviewer = await create_user(db, role="REVIEWER")
owner = await create_user(db, email="owner@example.com", role="OWNER")
store = await create_store(db, owner.id)
event = await create_event(db, store.id, condition="음식 사진이어야 합니다.")
body = {
"eventId": str(event.id),
"walletAddress": "0xABC",
"imageKey": "reviews/food.jpg",
}
_, mock_client = self._make_claude_mock("PASS")
with (
patch(
"app.services.application._download_image",
new_callable=AsyncMock,
return_value=(b"fake-image", "image/jpeg"),
),
patch("app.services.application.anthropic.AsyncAnthropic") as mock_cls,
):
mock_cls.return_value = mock_client
res = await client.post(
"/api/applications", json=body, headers=auth_headers(reviewer.id)
)
assert res.status_code == 200
assert res.json() == {"status": 200, "data": None}

async def test_image_condition_fail_returns_422(
self, client: AsyncClient, db: AsyncSession
) -> None:
reviewer = await create_user(db, role="REVIEWER")
owner = await create_user(db, email="owner@example.com", role="OWNER")
store = await create_store(db, owner.id)
event = await create_event(db, store.id, condition="음식 사진이어야 합니다.")
body = {
"eventId": str(event.id),
"walletAddress": "0xABC",
"imageKey": "reviews/not_food.jpg",
}
_, mock_client = self._make_claude_mock("FAIL")
with (
patch(
"app.services.application._download_image",
new_callable=AsyncMock,
return_value=(b"fake-image", "image/jpeg"),
),
patch("app.services.application.anthropic.AsyncAnthropic") as mock_cls,
):
mock_cls.return_value = mock_client
res = await client.post(
"/api/applications", json=body, headers=auth_headers(reviewer.id)
)
assert res.status_code == 422
data = res.json()["data"]
assert "조건" in data["message"]

async def test_application_not_saved_when_condition_fails(
self, client: AsyncClient, db: AsyncSession
) -> None:
reviewer = await create_user(db, role="REVIEWER")
owner = await create_user(db, email="owner@example.com", role="OWNER")
store = await create_store(db, owner.id)
event = await create_event(db, store.id)
body = {
"eventId": str(event.id),
"walletAddress": "0xABC",
"imageKey": "reviews/bad.jpg",
}
_, mock_client = self._make_claude_mock("FAIL")
with (
patch(
"app.services.application._download_image",
new_callable=AsyncMock,
return_value=(b"fake-image", "image/jpeg"),
),
patch("app.services.application.anthropic.AsyncAnthropic") as mock_cls,
):
mock_cls.return_value = mock_client
await client.post(
"/api/applications", json=body, headers=auth_headers(reviewer.id)
)
saved = await db.scalar(
select(Application).where(Application.reviewer_id == reviewer.id)
)
assert saved is None

async def test_s3_download_error_returns_400(
self, client: AsyncClient, db: AsyncSession
) -> None:
from fastapi import HTTPException

reviewer = await create_user(db, role="REVIEWER")
owner = await create_user(db, email="owner@example.com", role="OWNER")
store = await create_store(db, owner.id)
event = await create_event(db, store.id)
body = {
"eventId": str(event.id),
"walletAddress": "0xABC",
"imageKey": "reviews/missing.jpg",
}
with patch(
"app.services.application._download_image",
new_callable=AsyncMock,
side_effect=HTTPException(
status_code=400, detail="이미지를 불러올 수 없습니다."
),
):
res = await client.post(
"/api/applications", json=body, headers=auth_headers(reviewer.id)
)
assert res.status_code == 400
Loading
Loading