diff --git a/README.md b/README.md index c03a55b..89f5943 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/core/codes.py b/app/core/codes.py index 13be2a6..64fdd8e 100644 --- a/app/core/codes.py +++ b/app/core/codes.py @@ -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", "신뢰도가 낮아 이슈 핀을 생성할 수 없습니다.") @@ -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", diff --git a/app/core/config.py b/app/core/config.py index c1e9d67..07305cf 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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, diff --git a/app/core/deps.py b/app/core/deps.py index 24f8838..2b69e12 100644 --- a/app/core/deps.py +++ b/app/core/deps.py @@ -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 @@ -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, @@ -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, @@ -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, ) diff --git a/app/routes/IssueRoute.py b/app/routes/IssueRoute.py index a024622..bad1cf0 100644 --- a/app/routes/IssueRoute.py +++ b/app/routes/IssueRoute.py @@ -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, @@ -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) @@ -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, ) @@ -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, ) diff --git a/app/schemas/IssueDTO.py b/app/schemas/IssueDTO.py index 35a219f..4b2d3c1 100644 --- a/app/schemas/IssueDTO.py +++ b/app/schemas/IssueDTO.py @@ -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 diff --git a/app/services/IssueService.py b/app/services/IssueService.py index 4cc4fee..3b26a2d 100644 --- a/app/services/IssueService.py +++ b/app/services/IssueService.py @@ -47,6 +47,11 @@ from app.services.internal.issue_confidence_basis import is_failed_reliability_content from app.schemas.issue_pin_job import ImageSnapshot, IssuePinReliabilityJob from app.services.VectorStoreService import VectorStoreService +from app.services.internal.IssuePinDailyRateLimitService import ( + IssuePinDailyRateLimitService, + RateLimitKind, + RateLimitSnapshot, +) from app.services.internal.IssuePinBackgroundRunner import IssuePinBackgroundRunner from app.services.internal.ai.IssuePinLLMService import IssuePinLLMService from app.services.internal.ai.IssueRagPlannerService import IssueRagPlannerService @@ -62,6 +67,18 @@ MAX_PIN_IMAGE_TOTAL_BYTES = 50 * 1024 * 1024 +@dataclass(frozen=True, slots=True) +class IssuePinMutationResult: + detail: IssuePinHomeDetailResponse + rate_limit_quota: dict[str, bool | int | str] + + +@dataclass(frozen=True, slots=True) +class IssuePinAiMakeResult: + analysis: IssueAnalysisResult + rate_limit_quota: dict[str, bool | int | str] + + @dataclass(frozen=True, slots=True) class _UpdatePinImagePlan: images_unchanged: bool @@ -86,6 +103,7 @@ def __init__( user_repo: UserRepo, s3_util: S3Util, background_runner: IssuePinBackgroundRunner, + issue_pin_daily_rate_limit_service: IssuePinDailyRateLimitService, ) -> None: self._vector_store_service = vector_store_service self._issue_rag_planner_service = issue_rag_planner_service @@ -100,6 +118,40 @@ def __init__( self._community_repo = community_repo self._user_repo = user_repo self._background_runner = background_runner + self._rate_limit = issue_pin_daily_rate_limit_service + + async def _build_rate_limit_quota_dict( + self, + *, + kind: RateLimitKind, + uid: str, + snapshot: RateLimitSnapshot | None, + pin_id: int | None = None, + ) -> dict[str, bool | int | str]: + if snapshot is not None: + status = self._rate_limit.quota_status_from_snapshot(snapshot, kind=kind) + else: + status = await self._rate_limit.get_daily_quota_status( + kind, + uid=uid, + pin_id=pin_id, + ) + return status.to_result_dict() + + async def ensure_issue_pin_edit_access(self, *, uid: str, pin_id: int) -> None: + issue_pin = await self._issue_pin_repo.get_by_pin_id(pin_id) + if issue_pin is None or issue_pin.pin is None: + raise_business_exception(ErrorCode.ISSUE_NOT_FOUND) + + pin = issue_pin.pin + if pin.pin_type != PinType.ISSUE: + raise_business_exception(ErrorCode.ISSUE_NOT_FOUND) + if pin.uid != uid: + raise_business_exception(ErrorCode.FORBIDDEN) + if issue_pin.issue_pin_state != IssuePinState.BEFORE_PROGRESS: + raise_business_exception(ErrorCode.ISSUE_PIN_EDIT_VALIDATION) + if pin.pin_location is None: + raise_business_exception(ErrorCode.ISSUE_PIN_EDIT_FAILED) @staticmethod def _format_pin_datetime(value: datetime | None) -> str | None: @@ -216,13 +268,11 @@ def _sanitize_generated_title(*, generated_title: str, fallback_title: str) -> s candidate = IssueService._normalize_title_text(fallback_title, max_length=max_length) return candidate or "민원 제보" - async def issue_pin_ai_make( + async def _generate_issue_pin_ai_copy( self, *, - uid: str, request: CreateIssuePinRequest, ) -> IssueAnalysisResult: - _ = uid safe_title = request.title.strip() safe_content = request.content.strip() user_content = f"title:{safe_title}\ncontent:{safe_content}\n".strip() @@ -287,6 +337,22 @@ async def issue_pin_ai_make( content=pin_copy["content"], ) + async def issue_pin_ai_make( + self, + *, + uid: str, + request: CreateIssuePinRequest, + ) -> IssuePinAiMakeResult: + await self._rate_limit.assert_daily_quota_available(RateLimitKind.AI, uid=uid) + analysis = await self._generate_issue_pin_ai_copy(request=request) + snapshot = await self._rate_limit.record_daily_quota_success(RateLimitKind.AI, uid=uid) + quota = await self._build_rate_limit_quota_dict( + kind=RateLimitKind.AI, + uid=uid, + snapshot=snapshot, + ) + return IssuePinAiMakeResult(analysis=analysis, rate_limit_quota=quota) + async def _upload_snapshot_to_s3(self, snap: ImageSnapshot) -> dict[str, str]: return await self._s3_util.upload_bytes( snap.data, @@ -588,8 +654,9 @@ async def create_issue_pin( request: CreateIssuePinMultipartRequest, photos: list[UploadFile] | None = None, background_tasks: BackgroundTasks | None = None, - ) -> IssuePinHomeDetailResponse: + ) -> IssuePinMutationResult: uploads = photos or [] + await self._rate_limit.assert_daily_quota_available(RateLimitKind.CREATE, uid=uid) safe_title, safe_content = self._validate_pin_text( pin_title=request.pin_title, pin_content=request.pin_content, @@ -682,6 +749,16 @@ async def create_issue_pin( logger.exception("issue pin create commit failed uid=%s", uid) raise_business_exception(ErrorCode.ISSUE_PIN_IMPORT_FAILED) + snapshot = await self._rate_limit.record_daily_quota_success( + RateLimitKind.CREATE, + uid=uid, + ) + quota = await self._build_rate_limit_quota_dict( + kind=RateLimitKind.CREATE, + uid=uid, + snapshot=snapshot, + ) + await self._background_runner.schedule( IssuePinReliabilityJob( issue_pin_id=issue_pin.issue_pin_id, @@ -695,7 +772,7 @@ async def create_issue_pin( ) image_status = ImageUploadStatus.COMPLETED if saved_images else ImageUploadStatus.NONE - return await self._build_issue_pin_home_response( + detail = await self._build_issue_pin_home_response( uid=uid, pin=pin, issue_pin=issue_pin, @@ -705,6 +782,7 @@ async def create_issue_pin( reliability_status=ReliabilityStatus.PENDING, image_upload_status=image_status, ) + return IssuePinMutationResult(detail=detail, rate_limit_quota=quota) async def update_issue_pin( self, @@ -714,7 +792,7 @@ async def update_issue_pin( request: UpdateIssuePinMultipartRequest, photos: list[UploadFile] | None = None, background_tasks: BackgroundTasks | None = None, - ) -> IssuePinHomeDetailResponse: + ) -> IssuePinMutationResult: uploads = photos or [] user = await self._user_repo.get_by_uid(uid) if user is None: @@ -740,6 +818,12 @@ async def update_issue_pin( if pin_location is None: raise_business_exception(ErrorCode.ISSUE_PIN_EDIT_FAILED) + await self._rate_limit.assert_daily_quota_available( + RateLimitKind.EDIT, + uid=uid, + pin_id=pin_id, + ) + existing_images = list(pin.pin_images or []) image_plan = await self._build_update_pin_image_plan( pin_id=pin.pin_id, @@ -782,6 +866,18 @@ async def update_issue_pin( logger.exception("issue pin update failed pin_id=%s", pin.pin_id) raise_business_exception(ErrorCode.ISSUE_PIN_EDIT_FAILED) + snapshot = await self._rate_limit.record_daily_quota_success( + RateLimitKind.EDIT, + uid=uid, + pin_id=pin_id, + ) + quota = await self._build_rate_limit_quota_dict( + kind=RateLimitKind.EDIT, + uid=uid, + snapshot=snapshot, + pin_id=pin_id, + ) + await self._cleanup_removed_pin_images_best_effort( pin_id=pin.pin_id, removed_s3_keys=image_plan.removed_s3_keys, @@ -802,16 +898,17 @@ async def update_issue_pin( ImageUploadStatus.COMPLETED if saved_images else ImageUploadStatus.NONE ) - return await self._build_issue_pin_home_response( + detail = await self._build_issue_pin_home_response( uid=uid, pin=pin, issue_pin=issue_pin, pin_location=pin_location, - pin_user_nickname=pin.user.nickname if pin.user is not None else None, + pin_user_nickname=user.nickname, pin_images=saved_images, reliability_status=ReliabilityStatus.PENDING, image_upload_status=image_status, ) + return IssuePinMutationResult(detail=detail, rate_limit_quota=quota) async def _build_issue_pin_home_response( self, diff --git a/app/services/internal/IssuePinDailyRateLimitService.py b/app/services/internal/IssuePinDailyRateLimitService.py new file mode 100644 index 0000000..1f4be45 --- /dev/null +++ b/app/services/internal/IssuePinDailyRateLimitService.py @@ -0,0 +1,371 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass +from datetime import datetime, timedelta +from enum import StrEnum +from typing import Any +from zoneinfo import ZoneInfo + +from redis.asyncio import Redis as AsyncRedis + +from app.core.codes import ErrorCode +from app.core.config import settings +from app.core.exceptions import raise_business_exception + +logger = logging.getLogger(__name__) + +_KST = ZoneInfo("Asia/Seoul") +_EXPIRE_BUFFER_SECONDS = 60 * 60 + +_RECORD_DAILY_SUCCESS_LUA = """ +local n = tonumber(redis.call('GET', KEYS[1]) or '0') +if n >= tonumber(ARGV[2]) then + return {0, n} +end +n = redis.call('INCR', KEYS[1]) +if n == 1 then + redis.call('EXPIRE', KEYS[1], ARGV[1]) +end +return {1, n} +""" + + +class RateLimitKind(StrEnum): + AI = "ai" + CREATE = "create" + EDIT = "edit" + + +_KEY_PREFIX_BY_KIND: dict[RateLimitKind, str] = { + RateLimitKind.AI: "issue_pin:ai:daily", + RateLimitKind.CREATE: "issue_pin:create:daily", + RateLimitKind.EDIT: "issue_pin:edit:daily", +} + +_ERROR_CODE_BY_KIND: dict[RateLimitKind, ErrorCode] = { + RateLimitKind.AI: ErrorCode.AI_PIN_GENERATION_RATE_LIMIT_EXCEEDED, + RateLimitKind.CREATE: ErrorCode.ISSUE_PIN_CREATE_RATE_LIMIT_EXCEEDED, + RateLimitKind.EDIT: ErrorCode.ISSUE_PIN_EDIT_RATE_LIMIT_EXCEEDED, +} + + +@dataclass(frozen=True, slots=True) +class RateLimitSnapshot: + daily_limit: int + used_count: int + reset_at: datetime + + @property + def remaining_count(self) -> int: + return max(0, self.daily_limit - self.used_count) + + def to_result_dict(self) -> dict[str, int | str]: + return { + "dailyLimit": self.daily_limit, + "usedCount": self.used_count, + "resetAt": self.reset_at.isoformat(), + } + + +@dataclass(frozen=True, slots=True) +class IssuePinQuotaStatus: + enabled: bool + daily_limit: int + used_count: int + reset_at: datetime + + @property + def remaining_count(self) -> int: + if not self.enabled: + return self.daily_limit + return max(0, self.daily_limit - self.used_count) + + def to_result_dict(self) -> dict[str, bool | int | str]: + return { + "enabled": self.enabled, + "dailyLimit": self.daily_limit, + "usedCount": self.used_count, + "remainingCount": self.remaining_count, + "resetAt": self.reset_at.isoformat(), + } + + +class IssuePinDailyRateLimitService: + def __init__(self, *, redis_client: AsyncRedis | None = None) -> None: + self._redis_client = redis_client + self._record_success_script = ( + redis_client.register_script(_RECORD_DAILY_SUCCESS_LUA) + if redis_client is not None + else None + ) + + @staticmethod + def daily_key( + *, + kind: RateLimitKind, + subject_id: str, + kst_date: datetime, + ) -> str: + prefix = _KEY_PREFIX_BY_KIND[kind] + return f"{prefix}:{subject_id}:{kst_date.strftime('%Y%m%d')}" + + @staticmethod + def seconds_until_kst_midnight(*, now: datetime | None = None) -> int: + current = now or datetime.now(_KST) + if current.tzinfo is None: + current = current.replace(tzinfo=_KST) + else: + current = current.astimezone(_KST) + next_midnight = (current + timedelta(days=1)).replace( + hour=0, + minute=0, + second=0, + microsecond=0, + ) + return int((next_midnight - current).total_seconds()) + _EXPIRE_BUFFER_SECONDS + + @staticmethod + def next_kst_midnight(*, now: datetime | None = None) -> datetime: + current = now or datetime.now(_KST) + if current.tzinfo is None: + current = current.replace(tzinfo=_KST) + else: + current = current.astimezone(_KST) + return (current + timedelta(days=1)).replace( + hour=0, + minute=0, + second=0, + microsecond=0, + ) + + @staticmethod + def _resolve_subject_id( + *, + kind: RateLimitKind, + uid: str, + pin_id: int | None, + ) -> str: + if kind == RateLimitKind.EDIT: + if pin_id is None: + raise ValueError("pin_id is required for EDIT rate limit") + return str(pin_id) + return uid + + @staticmethod + def _policy(kind: RateLimitKind) -> tuple[bool, int]: + if kind == RateLimitKind.AI: + return ( + settings.ai_pin_generation_rate_limit_enabled, + settings.ai_pin_generation_daily_limit, + ) + if kind == RateLimitKind.CREATE: + return ( + settings.issue_pin_create_rate_limit_enabled, + settings.issue_pin_create_daily_limit, + ) + return ( + settings.issue_pin_edit_rate_limit_enabled, + settings.issue_pin_edit_daily_limit, + ) + + def _is_enforcement_active(self, kind: RateLimitKind) -> bool: + enabled, daily_limit = self._policy(kind) + return enabled and daily_limit >= 1 + + def _disabled_quota_status(self, *, kind: RateLimitKind, reset_at: datetime) -> IssuePinQuotaStatus: + _, daily_limit = self._policy(kind) + return IssuePinQuotaStatus( + enabled=False, + daily_limit=daily_limit, + used_count=0, + reset_at=reset_at, + ) + + def _raise_quota_exceeded( + self, + *, + kind: RateLimitKind, + daily_limit: int, + used_count: int, + reset_at: datetime, + pin_id: int | None = None, + ) -> None: + snapshot = RateLimitSnapshot( + daily_limit=daily_limit, + used_count=used_count, + reset_at=reset_at, + ) + extra: dict[str, Any] = snapshot.to_result_dict() + if kind == RateLimitKind.EDIT and pin_id is not None: + extra["pinId"] = pin_id + raise_business_exception( + _ERROR_CODE_BY_KIND[kind], + **extra, + ) + + async def assert_daily_quota_available( + self, + kind: RateLimitKind, + *, + uid: str, + pin_id: int | None = None, + ) -> None: + if not self._is_enforcement_active(kind): + return + + if self._redis_client is None: + logger.warning( + "Issue pin rate limit skipped: Redis client unavailable kind=%s uid=%s", + kind, + uid, + ) + return + + _, daily_limit = self._policy(kind) + subject_id = self._resolve_subject_id(kind=kind, uid=uid, pin_id=pin_id) + now_kst = datetime.now(_KST) + key = self.daily_key(kind=kind, subject_id=subject_id, kst_date=now_kst) + reset_at = self.next_kst_midnight(now=now_kst) + + try: + raw = await self._redis_client.get(key) + used_count = int(raw) if raw is not None else 0 + except Exception: + logger.warning( + "Issue pin rate limit skipped: Redis error kind=%s uid=%s", + kind, + uid, + exc_info=True, + ) + return + + if used_count >= daily_limit: + self._raise_quota_exceeded( + kind=kind, + daily_limit=daily_limit, + used_count=used_count, + reset_at=reset_at, + pin_id=pin_id, + ) + + async def record_daily_quota_success( + self, + kind: RateLimitKind, + *, + uid: str, + pin_id: int | None = None, + ) -> RateLimitSnapshot | None: + if not self._is_enforcement_active(kind): + return None + + _, daily_limit = self._policy(kind) + if self._redis_client is None or self._record_success_script is None: + logger.warning( + "Issue pin success record skipped: Redis client unavailable kind=%s uid=%s", + kind, + uid, + ) + return None + + subject_id = self._resolve_subject_id(kind=kind, uid=uid, pin_id=pin_id) + now_kst = datetime.now(_KST) + key = self.daily_key(kind=kind, subject_id=subject_id, kst_date=now_kst) + ttl_seconds = self.seconds_until_kst_midnight(now=now_kst) + reset_at = self.next_kst_midnight(now=now_kst) + + try: + raw = await self._record_success_script( + keys=[key], + args=[ttl_seconds, daily_limit], + ) + if raw is None or len(raw) < 2: + raise ValueError(f"unexpected redis script result: {raw!r}") + recorded = int(raw[0]) == 1 + used_count = int(raw[1]) + except Exception: + logger.warning( + "Issue pin success record skipped: Redis error kind=%s uid=%s", + kind, + uid, + exc_info=True, + ) + return None + + snapshot = RateLimitSnapshot( + daily_limit=daily_limit, + used_count=used_count, + reset_at=reset_at, + ) + if not recorded: + logger.warning( + "Issue pin success record skipped: concurrent quota race kind=%s subject=%s used=%d limit=%d", + kind, + subject_id, + used_count, + daily_limit, + ) + return snapshot + + async def get_daily_quota_status( + self, + kind: RateLimitKind, + *, + uid: str, + pin_id: int | None = None, + ) -> IssuePinQuotaStatus: + enabled, daily_limit = self._policy(kind) + now_kst = datetime.now(_KST) + reset_at = self.next_kst_midnight(now=now_kst) + + if not enabled or daily_limit < 1: + return self._disabled_quota_status(kind=kind, reset_at=reset_at) + + if self._redis_client is None: + logger.warning( + "Issue pin quota read skipped: Redis client unavailable kind=%s uid=%s", + kind, + uid, + ) + return self._disabled_quota_status(kind=kind, reset_at=reset_at) + + subject_id = self._resolve_subject_id(kind=kind, uid=uid, pin_id=pin_id) + key = self.daily_key(kind=kind, subject_id=subject_id, kst_date=now_kst) + used_count = 0 + try: + raw = await self._redis_client.get(key) + if raw is not None: + used_count = int(raw) + except Exception: + logger.warning( + "Issue pin quota read failed kind=%s uid=%s", + kind, + uid, + exc_info=True, + ) + + return IssuePinQuotaStatus( + enabled=True, + daily_limit=daily_limit, + used_count=used_count, + reset_at=reset_at, + ) + + def quota_status_from_snapshot(self, snapshot: RateLimitSnapshot | None, *, kind: RateLimitKind) -> IssuePinQuotaStatus: + if snapshot is not None: + return IssuePinQuotaStatus( + enabled=True, + daily_limit=snapshot.daily_limit, + used_count=snapshot.used_count, + reset_at=snapshot.reset_at, + ) + _, daily_limit = self._policy(kind) + reset_at = self.next_kst_midnight() + if not self._is_enforcement_active(kind): + return self._disabled_quota_status(kind=kind, reset_at=reset_at) + return IssuePinQuotaStatus( + enabled=True, + daily_limit=daily_limit, + used_count=0, + reset_at=reset_at, + ) diff --git a/docs/issue-pin-create-and-reliability.md b/docs/issue-pin-create-and-reliability.md index 33fa528..45ee297 100644 --- a/docs/issue-pin-create-and-reliability.md +++ b/docs/issue-pin-create-and-reliability.md @@ -42,6 +42,7 @@ | **신뢰도** | **비동기** VLM + RAG → `issue_pin.issue_confidence`, `confidence_content` UPDATE | | **근거 문구** | 일반 시민이 읽는 한국어 bullet, 줄바꿈 `\n` 유지 | | **S3 ↔ 신뢰도** | 백그라운드는 **메모리 스냅샷이 아니라 DB `pin_image` → S3 다운로드** 로 이미지 로드 | +| **일일 사용 제한** | Redis, KST 자정 리셋 — AI 생성·게시(uid) / 수정(pin_id), **성공 시에만** 카운트 | --- @@ -78,6 +79,24 @@ **해결**: 백그라운드는 **`pin_image.pin_s3_key` → `S3Util.download_bytes`** 로 이미지를 다시 읽은 뒤 VLM에 전달. S3 업로드가 끝난 뒤에만 신뢰도 job이 의미 있게 동작. +### 2.5 일일 사용 제한 (rate limit) + +Gemini·DB 부하 방지를 위해 아래 API에 **일일 성공 횟수 제한**을 둡니다 (`IssuePinDailyRateLimitService`, Redis). + +| API | 제한 단위 | 카운트 시점 | +|-----|-----------|-------------| +| `POST /issues/pin/ai` | **uid** / 일 | LLM 성공 후 | +| `POST /issues/pin` | **uid** / 일 | DB commit 성공 후 | +| `PATCH /issues/pin/{pin_id}` | **pin_id(글)** / 일 | DB commit 성공 후 | + +- **리셋**: KST 자정 (`resetAt`은 다음 자정 ISO8601) +- **실패 미집계**: LLM 실패, 검증 실패, DB 롤백 시 Redis INCR 없음 +- **429**: 한도 초과 시에만 (`AI_4291`, `ISSUE_4291`, `ISSUE_4292`) +- **ON/OFF**: env `*_RATE_LIMIT_ENABLED=false`면 해당 kind만 비활성 (assert·record·429 미적용) +- **Redis 미연결**: fail-open(제한 스킵) + warning 로그 +- **성공 응답**: 기존 `result` 유지 + **`rateLimitQuota`** 필드 additive (프론트 선택 사용) +- **사전 조회**: `GET /issues/pin/ai/quota`, `create/quota`, `edit/quota?pin_id=` + --- ## 3. 전체 흐름도 @@ -149,7 +168,17 @@ AI 미리보기. **DB 저장 없음.** | latitude | O | 위도 | | longitude | O | 경도 | -**처리**: RAG planner → 벡터 검색 → `IssuePinLLMService` → `IssueAnalysisResult { title, content }` +**처리**: 작업 전 `assert_daily_quota_available(AI)` → RAG planner → 벡터 검색 → `IssuePinLLMService` → LLM 성공 시 `record_daily_quota_success(AI)` + +**성공 응답**: 기존 AI 필드 + `rateLimitQuota` (`enabled`, `dailyLimit`, `usedCount`, `remainingCount`, `resetAt`) + +**에러 코드** + +| code | HTTP | 상황 | +|------|------|------| +| `AI_4291` | 429 | uid 일일 AI 생성 성공 횟수 초과 | + +429 `result` 예: `{ dailyLimit, usedCount, resetAt }` (pinId 없음) --- @@ -182,17 +211,21 @@ AI 미리보기. **DB 저장 없음.** **에러 코드** -| code | 상황 | -|------|------| -| `ISSUE_PIN_IMPORT_400_1` | 필수값/개수/isMain 검증 실패 | -| `ISSUE_PIN_IMPORT_400_2` | 역지오코딩·DB 저장 실패 | -| `PIN_IMAGE_400_1` | photos 합계 50MB 초과 | -| `PIN_IMAGE_400_2` | 업로드/검증/S3 실패 | -| `PIN_IMAGE_400_3` | 5장 초과 | -| `USER_4041` | 사용자 없음 | +| code | HTTP | 상황 | +|------|------|------| +| `ISSUE_PIN_IMPORT_400_1` | 400 | 필수값/개수/isMain 검증 실패 | +| `ISSUE_PIN_IMPORT_400_2` | 400 | 역지오코딩·DB 저장 실패 | +| `PIN_IMAGE_400_1` | 400 | photos 합계 50MB 초과 | +| `PIN_IMAGE_400_2` | 400 | 업로드/검증/S3 실패 | +| `PIN_IMAGE_400_3` | 400 | 5장 초과 | +| `USER_4041` | 404 | 사용자 없음 | +| `ISSUE_4291` | 429 | uid 일일 게시 성공 횟수 초과 | + +**성공 응답**: `IssuePinHomeDetailResponse` + `rateLimitQuota` **동기 처리 순서** (`IssueService.create_issue_pin`): +0. `assert_daily_quota_available(CREATE)` (uid) 1. payload 검증 (제목/본문, photos↔pinImages, isMain, 5장/50MB) 2. 사용자 존재 확인 3. `LocationResolveClient.resolve_wgs84` → 실패 시 `ISSUE_PIN_IMPORT_400_2` @@ -201,9 +234,9 @@ AI 미리보기. **DB 저장 없음.** 6. `IssuePin` INSERT (`issue_confidence=None`, `confidence_content=None`, `BEFORE_PROGRESS`) 7. `PinLocation` INSERT (`location_id`, `detail_address`, `pin_point` WKT) 8. `_upload_new_pin_images_with_is_main` — S3 + `pin_image` (`isMain` per `pinImages`) -9. `commit` (실패 시 S3 롤백) +9. `commit` (실패 시 S3 롤백) → 성공 시 `record_daily_quota_success(CREATE)` 10. `IssuePinBackgroundRunner.schedule(job, background_tasks=...)` -11. 홈 상세 응답 조립 (`reliabilityStatus=pending`) +11. 홈 상세 응답 조립 (`reliabilityStatus=pending`) + `rateLimitQuota` --- @@ -264,14 +297,21 @@ AI 미리보기. **DB 저장 없음.** **에러 코드** -| code | 상황 | -|------|------| -| `ISSUE_PIN_EDIT_400_1` | 필수값/isMain/개수/URL 검증 실패 | -| `ISSUE_PIN_EDIT_400_2` | DB 저장 등 처리 실패 | -| `PIN_IMAGE_400_1` | 신규 photos 합계 50MB 초과 | -| `PIN_IMAGE_400_2` | 업로드/검증/S3 실패 | -| `PIN_IMAGE_400_3` | 최종 5장 초과 | -| `USER_4041` | 사용자 없음 | +| code | HTTP | 상황 | +|------|------|------| +| `ISSUE_PIN_EDIT_400_1` | 400 | 필수값/isMain/개수/URL 검증 실패 | +| `ISSUE_PIN_EDIT_400_2` | 400 | DB 저장 등 처리 실패 | +| `PIN_IMAGE_400_1` | 400 | 신규 photos 합계 50MB 초과 | +| `PIN_IMAGE_400_2` | 400 | 업로드/검증/S3 실패 | +| `PIN_IMAGE_400_3` | 400 | 최종 5장 초과 | +| `USER_4041` | 404 | 사용자 없음 | +| `ISSUE_4292` | 429 | pin_id(글) 일일 수정 성공 횟수 초과 | + +429 `result` 예: `{ dailyLimit, usedCount, resetAt, pinId }` — **edit만** `pinId` 포함 + +**성공 응답**: `IssuePinHomeDetailResponse` + `rateLimitQuota` + +**처리**: `assert_daily_quota_available(EDIT, pin_id=…)` → … → commit 성공 후 `record_daily_quota_success(EDIT, pin_id=…)` **신뢰도 파이프라인** - 수정 후 `issue_confidence`/`confidence_content` NULL 초기화 + 재스케줄 (기존 GPS는 `pin_location.pin_point`에서 추출) @@ -279,6 +319,34 @@ AI 미리보기. **DB 저장 없음.** --- +### 4.7 quota 조회 (일일 사용 제한) + +게시·수정·AI 호출 **전** 남은 횟수 확인용. 인증 필수. + +| API | 제한 단위 | 성공 코드 | +|-----|-----------|-----------| +| `GET /issues/pin/ai/quota` | uid | `ISSUE_PIN_AI_QUOTA_GET_SUCCESS` | +| `GET /issues/pin/create/quota` | uid | `ISSUE_PIN_CREATE_QUOTA_GET_SUCCESS` | +| `GET /issues/pin/edit/quota?pin_id=` | pin_id | `ISSUE_PIN_EDIT_QUOTA_GET_SUCCESS` | + +edit quota는 `ensure_issue_pin_edit_access`로 **본인 pin**만 조회 가능. + +**응답** (`IssuePinQuotaResponse`, camelCase): + +```json +{ + "enabled": true, + "dailyLimit": 10, + "usedCount": 3, + "remainingCount": 7, + "resetAt": "2026-06-04T00:00:00+09:00" +} +``` + +`enabled: false` (env OFF 또는 `dailyLimit < 1`)일 때: `usedCount: 0`, `remainingCount: dailyLimit`. + +--- + ## 5. 응답 DTO 및 상태 값 ### 5.1 `ReliabilityStatus` @@ -306,6 +374,19 @@ AI 미리보기. **DB 저장 없음.** `pinId`, `issuePinId`, `pinTitle`, `pinContent`, `issuePinState`, `pinDetailAddress`, `pinImageUrls[]`, `reliabilityStatus`, `imageUploadStatus`, `issueConfidence`는 상세에 직접 넣지 않고 reliability API 사용 권장. +### 5.4 `rateLimitQuota` / `IssuePinQuotaResponse` + +| 필드 | 설명 | +|------|------| +| `enabled` | env로 해당 kind 제한 활성 여부 | +| `dailyLimit` | 일일 허용 성공 횟수 | +| `usedCount` | 오늘(KST) 이미 성공한 횟수 | +| `remainingCount` | `max(0, dailyLimit - usedCount)` (비활성 시 `dailyLimit`) | +| `resetAt` | 다음 KST 자정 (ISO8601) | + +- `POST /issues/pin/ai`, `POST /issues/pin`, `PATCH /issues/pin/{pin_id}` **성공** 시 `result` 하단에 포함 +- quota GET API 응답 body와 동일 구조 + --- ## 6. 동기 게시 경로 (`POST /issues/pin`) @@ -475,6 +556,16 @@ class IssuePinReliabilityJob: | `ISSUE_PIN_RELIABILITY_VLM_TIMEOUT_SECONDS` | `180` | VLM 단계 | | `PIN_TITLE_MAX_LENGTH` | `100` | | | `PIN_CONTENT_MAX_LENGTH` | `10000` | | +| `REDIS_LOCAL_HOST` / `REDIS_LOCAL_PORT` | `6379` | local 환경 rate limit (또는 `LOCAL_REDIS_*` → CI에서 alias) | +| `VALKEY_HOST` / `VALKEY_PORT` / `VALKEY_DB` / `VALKEY_PASSWORD` / `VALKEY_TLS` | - | dev/prod Redis(Valkey) | +| `AI_PIN_GENERATION_DAILY_LIMIT` | `10` | uid당 AI 미리보기 일일 성공 허용 | +| `AI_PIN_GENERATION_RATE_LIMIT_ENABLED` | `true` | `false`면 AI 제한 OFF | +| `ISSUE_PIN_CREATE_DAILY_LIMIT` | `10` | uid당 게시 일일 성공 허용 | +| `ISSUE_PIN_CREATE_RATE_LIMIT_ENABLED` | `true` | `false`면 게시 제한 OFF | +| `ISSUE_PIN_EDIT_DAILY_LIMIT` | `10` | pin_id(글)당 수정 일일 성공 허용 | +| `ISSUE_PIN_EDIT_RATE_LIMIT_ENABLED` | `true` | `false`면 수정 제한 OFF | + +Redis key 예: `issue_pin:ai:daily:{uid}:{YYYYMMDD}`, `issue_pin:create:daily:{uid}:{YYYYMMDD}`, `issue_pin:edit:daily:{pin_id}:{YYYYMMDD}` --- @@ -530,6 +621,8 @@ class IssuePinReliabilityJob: | `app/repositories/CommunityRepo.py` | community | | `app/models/Community.py` | community ORM | | `app/models/PinLike.py` | pin_like ORM | +| `app/services/internal/IssuePinDailyRateLimitService.py` | AI/CREATE/EDIT 일일 quota (Redis, KST) | +| `tests/test_issue_pin_daily_rate_limit.py` | rate limit 단위 테스트 | --- @@ -541,6 +634,8 @@ class IssuePinReliabilityJob: - `PATCH /issues/pin/{pin_id}` 추가 (`multipart/form-data`, `request` JSON + `photos`, URL 재사용·선택 삭제) - `GET /issues/pin/{issue_pin_id}` 추가 - `GET /issues/pin/{pin_id}/reliability` 추가 +- `GET /issues/pin/ai/quota`, `create/quota`, `edit/quota` 추가 +- 성공 응답에 `rateLimitQuota` merge ### 12.2 `app/services/IssueService.py` @@ -550,17 +645,21 @@ class IssuePinReliabilityJob: - `_build_issue_pin_home_response`, `get_issue_pin_detail`, `get_issue_pin_reliability` - `_derive_reliability_status`, `_derive_image_upload_status` - 의존성: PinLocationRepo, PinImageRepo, PinLikeRepo, CommunityRepo, S3Util, IssuePinBackgroundRunner +- `issue_pin_ai_make` / `create_issue_pin` / `update_issue_pin`: assert → 작업 → commit·LLM 성공 후 record +- `ensure_issue_pin_edit_access`: edit quota·수정 API 공통 소유권 검증 ### 12.3 `app/core/deps.py` - Repo·S3·BackgroundRunner DI - `get_issue_pin_background_runner`: VLM, EXIF, S3, VectorStore, RAG planner 주입 +- `get_issue_pin_daily_rate_limit_service`: Redis 클라이언트 주입 ### 12.4 `app/core/config.py` - Gemini VLM/핀 텍스트 모델 분리 - 신뢰도 파이프라인 타임아웃·planner 생략·재시도 횟수 설정 - `issue_pin_max_images`, `pin_title_max_length`, `pin_content_max_length` +- `AI_PIN_GENERATION_*`, `ISSUE_PIN_CREATE_*`, `ISSUE_PIN_EDIT_*` rate limit env ### 12.5 `app/core/codes.py` @@ -688,6 +787,14 @@ class IssuePinReliabilityJob: | `PIN_IMAGE_400_2` | 업로드/검증/S3 실패 | | `PIN_IMAGE_400_3` | 5장 초과 | +### 15.1.1 일일 사용 제한 (429) + +| 코드 | API | 비고 | +|------|-----|------| +| `AI_4291` | POST /issues/pin/ai | uid 기준 | +| `ISSUE_4291` | POST /issues/pin | uid 기준 | +| `ISSUE_4292` | PATCH /issues/pin/{pin_id} | pin_id 기준, `result.pinId` 포함 | + **미사용**: `ISSUE_4221` (신뢰도 낮아 게시 거절) — 정책상 게시는 항상 허용 ### 15.2 성공 @@ -698,6 +805,9 @@ class IssuePinReliabilityJob: | `ISSUE_PIN_IMPORT_201` | POST /issues/pin | | `ISSUE_2001` | GET /issues/pin/{id} | | `ISSUE_2002` | GET /issues/pin/{id}/reliability | +| `ISSUE_PIN_AI_QUOTA_GET_SUCCESS` | GET /issues/pin/ai/quota | +| `ISSUE_PIN_CREATE_QUOTA_GET_SUCCESS` | GET /issues/pin/create/quota | +| `ISSUE_PIN_EDIT_QUOTA_GET_SUCCESS` | GET /issues/pin/edit/quota | --- @@ -719,6 +829,9 @@ class IssuePinReliabilityJob: - [ ] 이미지 0장 게시 지원 (`pinImages: []`) - [ ] 게시 시 JSON `lat`/`lng` 필수, `pinImages[].isMain` (이미지 1장 이상) - [ ] `/pin/ai`만 `tone` Form (한국어 label) +- [ ] (선택) 화면 진입 시 `GET …/quota`로 남은 횟수 표시 +- [ ] 429 (`AI_4291` / `ISSUE_4291` / `ISSUE_4292`) 시 `result.resetAt` 기준 안내 +- [ ] 성공 응답 `rateLimitQuota`로 버튼 상태 갱신 (미사용 시 quota GET만으로도 가능) --- @@ -762,6 +875,21 @@ FROM issue_pin ORDER BY issue_pin_id DESC LIMIT 10; - 과거 `sanitize`가 `\n`을 공백으로 합침 → `normalize_display_line_breaks`로 수정 - 프론트 `pre-line` 필요 +### 18.7 rate limit이 동작하지 않음 + +**원인 후보**: + +1. `*_RATE_LIMIT_ENABLED=false` — 해당 kind 비활성 +2. Redis 미연결 — fail-open으로 제한 스킵 (서버 로그 `Redis client unavailable` 확인) +3. env 변경 후 **서버 미재시작** +4. 로컬에서 `REDIS_LOCAL_HOST` 미설정 (`LOCAL_REDIS_HOST`만 있으면 CI alias 필요) + +**확인**: + +```bash +redis-cli GET "issue_pin:ai:daily:{uid}:{YYYYMMDD}" +``` + --- ## 19. 미구현·향후 작업 diff --git a/rag/scripts/test_create_issue_pin_once.py b/rag/scripts/test_create_issue_pin_once.py index 1375c69..2a68453 100644 --- a/rag/scripts/test_create_issue_pin_once.py +++ b/rag/scripts/test_create_issue_pin_once.py @@ -128,14 +128,15 @@ async def _run( user_repo=user_repo, s3_util=MagicMock(), background_runner=MagicMock(), + issue_pin_daily_rate_limit_service=MagicMock(), ) - result = await service.create_issue_pin( + outcome = await service.create_issue_pin( uid="script-test-user", photos=[upload], request=request, ) - print(result.model_dump_json(indent=2, ensure_ascii=False)) + print(outcome.detail.model_dump_json(indent=2, ensure_ascii=False)) def main() -> None: diff --git a/tests/test_issue_pin_daily_rate_limit.py b/tests/test_issue_pin_daily_rate_limit.py new file mode 100644 index 0000000..7ac12d7 --- /dev/null +++ b/tests/test_issue_pin_daily_rate_limit.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +import unittest +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock, patch +from zoneinfo import ZoneInfo + +from app.core.codes import ErrorCode +from app.core.exceptions import BusinessException +from app.services.internal.IssuePinDailyRateLimitService import ( + IssuePinDailyRateLimitService, + RateLimitKind, +) + +_KST = ZoneInfo("Asia/Seoul") + + +class IssuePinDailyRateLimitHelpersTest(unittest.TestCase): + def test_daily_key_ai_uses_uid(self) -> None: + kst_date = datetime(2026, 6, 3, 15, 30, tzinfo=_KST) + key = IssuePinDailyRateLimitService.daily_key( + kind=RateLimitKind.AI, + subject_id="user-1", + kst_date=kst_date, + ) + self.assertEqual(key, "issue_pin:ai:daily:user-1:20260603") + + def test_daily_key_edit_uses_pin_id(self) -> None: + kst_date = datetime(2026, 6, 3, 15, 30, tzinfo=_KST) + key = IssuePinDailyRateLimitService.daily_key( + kind=RateLimitKind.EDIT, + subject_id="42", + kst_date=kst_date, + ) + self.assertEqual(key, "issue_pin:edit:daily:42:20260603") + + def test_daily_key_create_uses_uid(self) -> None: + kst_date = datetime(2026, 6, 3, 15, 30, tzinfo=_KST) + key = IssuePinDailyRateLimitService.daily_key( + kind=RateLimitKind.CREATE, + subject_id="user-1", + kst_date=kst_date, + ) + self.assertEqual(key, "issue_pin:create:daily:user-1:20260603") + + def test_seconds_until_kst_midnight_includes_buffer(self) -> None: + now = datetime(2026, 6, 3, 23, 0, 0, tzinfo=_KST) + seconds = IssuePinDailyRateLimitService.seconds_until_kst_midnight(now=now) + self.assertEqual(seconds, 3600 + 3600) + + def test_next_kst_midnight(self) -> None: + now = datetime(2026, 6, 3, 15, 0, 0, tzinfo=_KST) + reset_at = IssuePinDailyRateLimitService.next_kst_midnight(now=now) + self.assertEqual(reset_at, datetime(2026, 6, 4, 0, 0, 0, tzinfo=_KST)) + + +class IssuePinDailyRateLimitServiceTest(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + self.redis_client = MagicMock() + self.record_script = AsyncMock() + self.redis_client.register_script.return_value = self.record_script + self.redis_client.get = AsyncMock(return_value=None) + self.service = IssuePinDailyRateLimitService(redis_client=self.redis_client) + + @patch("app.services.internal.IssuePinDailyRateLimitService.settings") + async def test_assert_ai_allows_when_under_daily_limit(self, mock_settings: MagicMock) -> None: + mock_settings.ai_pin_generation_rate_limit_enabled = True + mock_settings.ai_pin_generation_daily_limit = 10 + self.redis_client.get.return_value = "9" + + await self.service.assert_daily_quota_available(RateLimitKind.AI, uid="user-1") + + self.redis_client.get.assert_awaited_once() + + @patch("app.services.internal.IssuePinDailyRateLimitService.settings") + async def test_assert_create_raises_without_increment(self, mock_settings: MagicMock) -> None: + mock_settings.issue_pin_create_rate_limit_enabled = True + mock_settings.issue_pin_create_daily_limit = 10 + self.redis_client.get.return_value = "10" + + with self.assertRaises(BusinessException) as ctx: + await self.service.assert_daily_quota_available(RateLimitKind.CREATE, uid="user-1") + + exc = ctx.exception + self.assertEqual(exc.error_code, ErrorCode.ISSUE_PIN_CREATE_RATE_LIMIT_EXCEEDED) + self.record_script.assert_not_awaited() + + @patch("app.services.internal.IssuePinDailyRateLimitService.settings") + async def test_assert_edit_raises_with_pin_id(self, mock_settings: MagicMock) -> None: + mock_settings.issue_pin_edit_rate_limit_enabled = True + mock_settings.issue_pin_edit_daily_limit = 3 + self.redis_client.get.return_value = "3" + + with self.assertRaises(BusinessException) as ctx: + await self.service.assert_daily_quota_available( + RateLimitKind.EDIT, + uid="user-1", + pin_id=99, + ) + + exc = ctx.exception + self.assertEqual(exc.error_code, ErrorCode.ISSUE_PIN_EDIT_RATE_LIMIT_EXCEEDED) + self.assertEqual(exc.extra["pinId"], 99) + + @patch("app.services.internal.IssuePinDailyRateLimitService.settings") + async def test_record_edit_increments_pin_key(self, mock_settings: MagicMock) -> None: + mock_settings.issue_pin_edit_rate_limit_enabled = True + mock_settings.issue_pin_edit_daily_limit = 5 + self.record_script.return_value = [1, 2] + fixed_now = datetime(2026, 6, 3, 12, 0, 0, tzinfo=_KST) + + with patch( + "app.services.internal.IssuePinDailyRateLimitService.datetime", + ) as mock_datetime: + mock_datetime.now.return_value = fixed_now + snapshot = await self.service.record_daily_quota_success( + RateLimitKind.EDIT, + uid="user-1", + pin_id=77, + ) + + assert snapshot is not None + self.assertEqual(snapshot.used_count, 2) + call_kwargs = self.record_script.await_args.kwargs + self.assertEqual(call_kwargs["keys"], ["issue_pin:edit:daily:77:20260603"]) + + @patch("app.services.internal.IssuePinDailyRateLimitService.settings") + async def test_record_skips_when_disabled(self, mock_settings: MagicMock) -> None: + mock_settings.issue_pin_create_rate_limit_enabled = False + + result = await self.service.record_daily_quota_success(RateLimitKind.CREATE, uid="user-1") + + self.assertIsNone(result) + self.record_script.assert_not_awaited() + + @patch("app.services.internal.IssuePinDailyRateLimitService.settings") + async def test_record_skips_on_malformed_script_result(self, mock_settings: MagicMock) -> None: + mock_settings.issue_pin_create_rate_limit_enabled = True + mock_settings.issue_pin_create_daily_limit = 10 + self.record_script.return_value = None + + result = await self.service.record_daily_quota_success(RateLimitKind.CREATE, uid="user-1") + + self.assertIsNone(result) + + @patch("app.services.internal.IssuePinDailyRateLimitService.settings") + async def test_record_skips_on_short_script_result(self, mock_settings: MagicMock) -> None: + mock_settings.issue_pin_create_rate_limit_enabled = True + mock_settings.issue_pin_create_daily_limit = 10 + self.record_script.return_value = [1] + + result = await self.service.record_daily_quota_success(RateLimitKind.CREATE, uid="user-1") + + self.assertIsNone(result) + + @patch("app.services.internal.IssuePinDailyRateLimitService.settings") + async def test_get_quota_create_when_enabled(self, mock_settings: MagicMock) -> None: + mock_settings.issue_pin_create_rate_limit_enabled = True + mock_settings.issue_pin_create_daily_limit = 10 + self.redis_client.get = AsyncMock(return_value="4") + + quota = await self.service.get_daily_quota_status(RateLimitKind.CREATE, uid="user-1") + + self.assertTrue(quota.enabled) + self.assertEqual(quota.remaining_count, 6) + self.assertEqual(quota.to_result_dict()["remainingCount"], 6) + + @patch("app.services.internal.IssuePinDailyRateLimitService.settings") + async def test_get_quota_edit_when_disabled(self, mock_settings: MagicMock) -> None: + mock_settings.issue_pin_edit_rate_limit_enabled = False + mock_settings.issue_pin_edit_daily_limit = 10 + + quota = await self.service.get_daily_quota_status( + RateLimitKind.EDIT, + uid="user-1", + pin_id=1, + ) + + self.assertFalse(quota.enabled) + self.assertEqual(quota.remaining_count, 10) + + @patch("app.services.internal.IssuePinDailyRateLimitService.settings") + async def test_quota_status_from_snapshot(self, mock_settings: MagicMock) -> None: + mock_settings.ai_pin_generation_rate_limit_enabled = True + mock_settings.ai_pin_generation_daily_limit = 10 + reset_at = datetime(2026, 6, 4, 0, 0, 0, tzinfo=_KST) + from app.services.internal.IssuePinDailyRateLimitService import RateLimitSnapshot + + snapshot = RateLimitSnapshot(daily_limit=10, used_count=3, reset_at=reset_at) + status = self.service.quota_status_from_snapshot(snapshot, kind=RateLimitKind.AI) + + self.assertEqual(status.remaining_count, 7) + self.assertEqual(status.to_result_dict()["remainingCount"], 7) + + +if __name__ == "__main__": + unittest.main()