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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ pip install -r requirements.txt
# REDIS_LOCAL_HOST, REDIS_LOCAL_PORT
# JWT_SECRET, JWT_ALGORITHM
# GEMINI_API_KEY
# REDIS_LOCAL_HOST, REDIS_LOCAL_PORT (일일 사용 제한; local)
# AI_PIN_GENERATION_* / ISSUE_PIN_CREATE_* / ISSUE_PIN_EDIT_* (rate limit, docs/issue-pin-create-and-reliability.md §9)
# AWS_ACCESS_KEY, AWS_SECRET_KEY, AWS_BUCKET (이미지 업로드 시)
# LOCATION_CORE_BASE_URL=http://localhost:8080

Expand Down
30 changes: 30 additions & 0 deletions app/core/codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,21 @@ class ErrorCode(Enum):

# VLM (Gemini)
VLM_NOT_CONFIGURED = (status.HTTP_503_SERVICE_UNAVAILABLE, "VLM_5031", "VLM(Gemini) API 키가 설정되지 않았습니다.")
AI_PIN_GENERATION_RATE_LIMIT_EXCEEDED = (
status.HTTP_429_TOO_MANY_REQUESTS,
"AI_4291",
"오늘 AI 글 생성(미리보기) 성공 횟수를 초과했습니다.",
)
ISSUE_PIN_CREATE_RATE_LIMIT_EXCEEDED = (
status.HTTP_429_TOO_MANY_REQUESTS,
"ISSUE_4291",
"오늘 이슈 핀 게시 성공 횟수를 초과했습니다.",
)
ISSUE_PIN_EDIT_RATE_LIMIT_EXCEEDED = (
status.HTTP_429_TOO_MANY_REQUESTS,
"ISSUE_4292",
"오늘 이슈 핀 수정 성공 횟수를 초과했습니다.",
)

# Issue
ISSUE_LOW_RELIABILITY = (status.HTTP_422_UNPROCESSABLE_ENTITY, "ISSUE_4221", "신뢰도가 낮아 이슈 핀을 생성할 수 없습니다.")
Expand Down Expand Up @@ -130,6 +145,21 @@ class SuccessCode(Enum):
"ISSUE_2002",
"이슈 핀 신뢰도 조회에 성공했습니다.",
)
ISSUE_PIN_AI_QUOTA_GET_SUCCESS = (
status.HTTP_200_OK,
"ISSUE_2003",
"AI 글 생성(미리보기) 성공 제한 횟수 조회에 성공했습니다.",
)
ISSUE_PIN_CREATE_QUOTA_GET_SUCCESS = (
status.HTTP_200_OK,
"ISSUE_2004",
"이슈 핀 게시 성공 제한 횟수 조회에 성공했습니다.",
)
ISSUE_PIN_EDIT_QUOTA_GET_SUCCESS = (
status.HTTP_200_OK,
"ISSUE_2005",
"이슈 핀 수정 성공 제한 횟수 조회에 성공했습니다.",
)
ISSUE_PIN_EDIT_SUCCESS = (
status.HTTP_200_OK,
"ISSUE_PIN_EDIT_200",
Expand Down
36 changes: 36 additions & 0 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,42 @@ def _empty_pdf_korean_font_paths_to_none(cls, value: object) -> object | None:
alias="PDF_KOREAN_FONT_PATHS",
)
issue_pin_max_images: int = Field(default=5, ge=0, le=20, alias="ISSUE_PIN_MAX_IMAGES")
ai_pin_generation_daily_limit: int = Field(
default=10,
ge=1,
le=1000,
alias="AI_PIN_GENERATION_DAILY_LIMIT",
description="uid당 이슈 핀 AI 글 생성(미리보기) 일일 성공 허용 횟수",
)
ai_pin_generation_rate_limit_enabled: bool = Field(
default=True,
alias="AI_PIN_GENERATION_RATE_LIMIT_ENABLED",
description="false면 AI 글 생성 일일 제한 비활성화",
)
issue_pin_create_daily_limit: int = Field(
default=10,
ge=1,
le=1000,
alias="ISSUE_PIN_CREATE_DAILY_LIMIT",
description="uid당 이슈 핀 게시 일일 성공 허용 횟수",
)
issue_pin_create_rate_limit_enabled: bool = Field(
default=True,
alias="ISSUE_PIN_CREATE_RATE_LIMIT_ENABLED",
description="false면 이슈 핀 게시 일일 제한 비활성화",
)
issue_pin_edit_daily_limit: int = Field(
default=10,
ge=1,
le=1000,
alias="ISSUE_PIN_EDIT_DAILY_LIMIT",
description="pin_id(글)당 이슈 핀 수정 일일 성공 허용 횟수",
)
issue_pin_edit_rate_limit_enabled: bool = Field(
default=True,
alias="ISSUE_PIN_EDIT_RATE_LIMIT_ENABLED",
description="false면 이슈 핀 수정 일일 제한 비활성화",
)
issue_confidence_basis_max_chars: int = Field(
default=2000,
ge=200,
Expand Down
18 changes: 18 additions & 0 deletions app/core/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from app.repositories.PinRepo import PinRepo
from app.repositories.UserRepo import UserRepo
from app.services.IssueService import IssueService
from app.services.internal.IssuePinDailyRateLimitService import IssuePinDailyRateLimitService
from app.services.internal.IssuePinBackgroundRunner import IssuePinBackgroundRunner
from app.services.UserService import UserService
from app.services.ComplaintEmailService import ComplaintEmailService
Expand Down Expand Up @@ -368,6 +369,21 @@ def get_issue_pin_background_runner(
]


def get_issue_pin_daily_rate_limit_service(request: Request) -> IssuePinDailyRateLimitService:
redis_client = getattr(request.app.state, "async_redis_client", None)
return IssuePinDailyRateLimitService(redis_client=redis_client)


IssuePinDailyRateLimitServiceDep = Annotated[
IssuePinDailyRateLimitService,
Depends(get_issue_pin_daily_rate_limit_service),
]


# backward-compatible alias
AiPinGenerationRateLimitServiceDep = IssuePinDailyRateLimitServiceDep


def get_issue_service(
vector_store_service: VectorStoreServiceDep,
issue_rag_planner_service: IssueRagPlannerServiceDep,
Expand All @@ -382,6 +398,7 @@ def get_issue_service(
user_repo: UserRepoDep,
s3_util: S3UtilDep,
background_runner: IssuePinBackgroundRunnerDep,
issue_pin_daily_rate_limit_service: IssuePinDailyRateLimitServiceDep,
) -> IssueService:
return IssueService(
vector_store_service=vector_store_service,
Expand All @@ -397,6 +414,7 @@ def get_issue_service(
user_repo=user_repo,
s3_util=s3_util,
background_runner=background_runner,
issue_pin_daily_rate_limit_service=issue_pin_daily_rate_limit_service,
)


Expand Down
97 changes: 89 additions & 8 deletions app/routes/IssueRoute.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,99 @@
from typing import Annotated
from typing import Annotated, Any

from fastapi import APIRouter, BackgroundTasks, File, Form, UploadFile
from fastapi import APIRouter, BackgroundTasks, File, Form, Query, UploadFile
from pydantic import ValidationError

from app.core.codes import ErrorCode, SuccessCode
from app.core.deps import CurrentUserIdDep, IssueServiceDep
from app.core.deps import (
CurrentUserIdDep,
IssuePinDailyRateLimitServiceDep,
IssueServiceDep,
)
from app.core.exceptions import raise_business_exception
from app.core.responses import success_response
from app.models.enum.ToneType import ToneType
from app.schemas.IssueDTO import (
CreateIssuePinMultipartRequest,
CreateIssuePinRequest,
IssuePinQuotaResponse,
UpdateIssuePinMultipartRequest,
)
from app.services.internal.IssuePinDailyRateLimitService import RateLimitKind

router = APIRouter(prefix="/issues", tags=["issue"])


def _merge_rate_limit_quota(
result: dict[str, Any],
*,
quota: dict[str, bool | int | str],
) -> dict[str, Any]:
merged = dict(result)
merged["rateLimitQuota"] = quota
return merged


@router.get("/tone-types")
async def get_tone_types():
result = [{"key": t.name, "label": t.value} for t in ToneType]
return success_response(result=result, success_code=SuccessCode.OK)


@router.get(
"/pin/ai/quota",
summary="AI 글 생성(미리보기) 일일 성공 제한 횟수 조회",
)
async def get_issue_pin_ai_quota(
uid: CurrentUserIdDep,
rate_limit_service: IssuePinDailyRateLimitServiceDep,
):
quota = await rate_limit_service.get_daily_quota_status(RateLimitKind.AI, uid=uid)
result = IssuePinQuotaResponse.model_validate(quota.to_result_dict())
return success_response(
result=result.model_dump(by_alias=True),
success_code=SuccessCode.ISSUE_PIN_AI_QUOTA_GET_SUCCESS,
)


@router.get(
"/pin/create/quota",
summary="이슈 핀 게시 일일 성공 제한 횟수 조회",
)
async def get_issue_pin_create_quota(
uid: CurrentUserIdDep,
rate_limit_service: IssuePinDailyRateLimitServiceDep,
):
quota = await rate_limit_service.get_daily_quota_status(RateLimitKind.CREATE, uid=uid)
result = IssuePinQuotaResponse.model_validate(quota.to_result_dict())
return success_response(
result=result.model_dump(by_alias=True),
success_code=SuccessCode.ISSUE_PIN_CREATE_QUOTA_GET_SUCCESS,
)


@router.get(
"/pin/edit/quota",
summary="이슈 핀 수정 일일 성공 제한 횟수 조회 (pin_id 글당)",
)
async def get_issue_pin_edit_quota(
uid: CurrentUserIdDep,
issue_service: IssueServiceDep,
rate_limit_service: IssuePinDailyRateLimitServiceDep,
pin_id: int = Query(..., ge=1),
):
await issue_service.ensure_issue_pin_edit_access(uid=uid, pin_id=pin_id)
quota = await rate_limit_service.get_daily_quota_status(
RateLimitKind.EDIT,
uid=uid,
pin_id=pin_id,
)
result = IssuePinQuotaResponse.model_validate(quota.to_result_dict())
return success_response(
result=result.model_dump(by_alias=True),
success_code=SuccessCode.ISSUE_PIN_EDIT_QUOTA_GET_SUCCESS,
)


@router.post("/pin/ai")
async def create_issue_pin_ai(
uid: CurrentUserIdDep,
Expand All @@ -40,10 +111,12 @@ async def create_issue_pin_ai(
latitude=latitude,
longitude=longitude,
)
result = await issue_service.issue_pin_ai_make(
outcome = await issue_service.issue_pin_ai_make(
uid=uid,
request=request,
)
result = outcome.analysis.model_dump()
result = _merge_rate_limit_quota(result, quota=outcome.rate_limit_quota)
return success_response(result=result, success_code=SuccessCode.CREATED)


Expand All @@ -60,14 +133,18 @@ async def create_issue_pin(
except ValidationError:
raise_business_exception(ErrorCode.ISSUE_PIN_IMPORT_VALIDATION)

result = await issue_service.create_issue_pin(
outcome = await issue_service.create_issue_pin(
uid=uid,
request=body,
photos=photos,
background_tasks=background_tasks,
)
result = _merge_rate_limit_quota(
outcome.detail.model_dump(by_alias=True, exclude_none=False),
quota=outcome.rate_limit_quota,
)
return success_response(
result=result.model_dump(by_alias=True, exclude_none=False),
result=result,
success_code=SuccessCode.ISSUE_PIN_IMPORT_SUCCESS,
)

Expand Down Expand Up @@ -102,15 +179,19 @@ async def update_issue_pin(
except ValidationError:
raise_business_exception(ErrorCode.ISSUE_PIN_EDIT_VALIDATION)

result = await issue_service.update_issue_pin(
outcome = await issue_service.update_issue_pin(
uid=uid,
pin_id=pin_id,
request=body,
photos=photos,
background_tasks=background_tasks,
)
result = _merge_rate_limit_quota(
outcome.detail.model_dump(by_alias=True, exclude_none=False),
quota=outcome.rate_limit_quota,
)
return success_response(
result=result.model_dump(by_alias=True, exclude_none=False),
result=result,
success_code=SuccessCode.ISSUE_PIN_EDIT_SUCCESS,
)

Expand Down
14 changes: 14 additions & 0 deletions app/schemas/IssueDTO.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,20 @@ class IssueAnalysisResult(BaseModel):
content: str


class IssuePinQuotaResponse(BaseModel):
model_config = ConfigDict(populate_by_name=True)

enabled: bool
daily_limit: int = Field(alias="dailyLimit")
used_count: int = Field(alias="usedCount")
remaining_count: int = Field(alias="remainingCount")
reset_at: str = Field(alias="resetAt")


# backward-compatible alias
IssuePinAiQuotaResponse = IssuePinQuotaResponse


class CreateIssuePinResponse(BaseModel):
pin_id: int
issue_pin_id: int
Expand Down
Loading
Loading