From ff841ce4dd70f75fcd799a7c86dd17bc16e75117 Mon Sep 17 00:00:00 2001 From: selnem Date: Wed, 3 Jun 2026 22:14:15 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EC=9D=B4=EC=8A=88=20=ED=95=80=20AI?= =?UTF-8?q?=20=EC=9D=BC=EC=9D=BC=20=EC=A0=9C=ED=95=9C=20Redis=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EC=B6=94=EA=B0=80=20(WIP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/codes.py | 10 + app/core/config.py | 12 + app/core/deps.py | 14 ++ app/routes/IssueRoute.py | 42 +++- app/schemas/IssueDTO.py | 10 + app/services/IssueService.py | 53 ++++- .../AiPinGenerationRateLimitService.py | 205 ++++++++++++++++++ tests/test_ai_pin_generation_rate_limit.py | 154 +++++++++++++ 8 files changed, 496 insertions(+), 4 deletions(-) create mode 100644 app/services/internal/AiPinGenerationRateLimitService.py create mode 100644 tests/test_ai_pin_generation_rate_limit.py diff --git a/app/core/codes.py b/app/core/codes.py index 13be2a6..55ab9ee 100644 --- a/app/core/codes.py +++ b/app/core/codes.py @@ -31,6 +31,11 @@ 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 ISSUE_LOW_RELIABILITY = (status.HTTP_422_UNPROCESSABLE_ENTITY, "ISSUE_4221", "신뢰도가 낮아 이슈 핀을 생성할 수 없습니다.") @@ -130,6 +135,11 @@ class SuccessCode(Enum): "ISSUE_2002", "이슈 핀 신뢰도 조회에 성공했습니다.", ) + ISSUE_PIN_AI_QUOTA_GET_SUCCESS = ( + status.HTTP_200_OK, + "ISSUE_2003", + "AI 글 생성·수정 제한 횟수 조회에 성공했습니다.", + ) 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..bb09063 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -255,6 +255,18 @@ 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_confidence_basis_max_chars: int = Field( default=2000, ge=200, diff --git a/app/core/deps.py b/app/core/deps.py index 24f8838..bd137ae 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.AiPinGenerationRateLimitService import AiPinGenerationRateLimitService from app.services.internal.IssuePinBackgroundRunner import IssuePinBackgroundRunner from app.services.UserService import UserService from app.services.ComplaintEmailService import ComplaintEmailService @@ -368,6 +369,17 @@ def get_issue_pin_background_runner( ] +def get_ai_pin_generation_rate_limit_service(request: Request) -> AiPinGenerationRateLimitService: + redis_client = getattr(request.app.state, "async_redis_client", None) + return AiPinGenerationRateLimitService(redis_client=redis_client) + + +AiPinGenerationRateLimitServiceDep = Annotated[ + AiPinGenerationRateLimitService, + Depends(get_ai_pin_generation_rate_limit_service), +] + + def get_issue_service( vector_store_service: VectorStoreServiceDep, issue_rag_planner_service: IssueRagPlannerServiceDep, @@ -382,6 +394,7 @@ def get_issue_service( user_repo: UserRepoDep, s3_util: S3UtilDep, background_runner: IssuePinBackgroundRunnerDep, + ai_pin_generation_rate_limit_service: AiPinGenerationRateLimitServiceDep, ) -> IssueService: return IssueService( vector_store_service=vector_store_service, @@ -397,6 +410,7 @@ def get_issue_service( user_repo=user_repo, s3_util=s3_util, background_runner=background_runner, + ai_pin_generation_rate_limit_service=ai_pin_generation_rate_limit_service, ) diff --git a/app/routes/IssueRoute.py b/app/routes/IssueRoute.py index a024622..bffff14 100644 --- a/app/routes/IssueRoute.py +++ b/app/routes/IssueRoute.py @@ -4,13 +4,18 @@ from pydantic import ValidationError from app.core.codes import ErrorCode, SuccessCode -from app.core.deps import CurrentUserIdDep, IssueServiceDep +from app.core.deps import ( + AiPinGenerationRateLimitServiceDep, + CurrentUserIdDep, + 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, + IssuePinAiQuotaResponse, UpdateIssuePinMultipartRequest, ) @@ -23,6 +28,22 @@ async def get_tone_types(): 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: AiPinGenerationRateLimitServiceDep, +): + quota = await rate_limit_service.get_daily_quota_status(uid=uid) + result = IssuePinAiQuotaResponse.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.post("/pin/ai") async def create_issue_pin_ai( uid: CurrentUserIdDep, @@ -47,6 +68,25 @@ async def create_issue_pin_ai( return success_response(result=result, success_code=SuccessCode.CREATED) +@router.patch("/pin/{pin_id}/ai") +async def update_issue_pin_ai( + pin_id: int, + uid: CurrentUserIdDep, + issue_service: IssueServiceDep, + title: str = Form(...), + content: str = Form(...), + tone: ToneType = Form(ToneType.NONE), +): + result = await issue_service.issue_pin_ai_edit( + uid=uid, + pin_id=pin_id, + title=title, + content=content, + tone=tone, + ) + return success_response(result=result, success_code=SuccessCode.CREATED) + + @router.post("/pin") async def create_issue_pin( uid: CurrentUserIdDep, diff --git a/app/schemas/IssueDTO.py b/app/schemas/IssueDTO.py index 35a219f..18b8729 100644 --- a/app/schemas/IssueDTO.py +++ b/app/schemas/IssueDTO.py @@ -84,6 +84,16 @@ class IssueAnalysisResult(BaseModel): content: str +class IssuePinAiQuotaResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + enabled: bool + daily_limit: int = Field(serialization_alias="dailyLimit") + used_count: int = Field(serialization_alias="usedCount") + remaining_count: int = Field(serialization_alias="remainingCount") + reset_at: str = Field(serialization_alias="resetAt") + + class CreateIssuePinResponse(BaseModel): pin_id: int issue_pin_id: int diff --git a/app/services/IssueService.py b/app/services/IssueService.py index 4cc4fee..1c11ec9 100644 --- a/app/services/IssueService.py +++ b/app/services/IssueService.py @@ -47,6 +47,7 @@ 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.AiPinGenerationRateLimitService import AiPinGenerationRateLimitService from app.services.internal.IssuePinBackgroundRunner import IssuePinBackgroundRunner from app.services.internal.ai.IssuePinLLMService import IssuePinLLMService from app.services.internal.ai.IssueRagPlannerService import IssueRagPlannerService @@ -86,6 +87,7 @@ def __init__( user_repo: UserRepo, s3_util: S3Util, background_runner: IssuePinBackgroundRunner, + ai_pin_generation_rate_limit_service: AiPinGenerationRateLimitService, ) -> None: self._vector_store_service = vector_store_service self._issue_rag_planner_service = issue_rag_planner_service @@ -100,6 +102,7 @@ def __init__( self._community_repo = community_repo self._user_repo = user_repo self._background_runner = background_runner + self._ai_pin_generation_rate_limit_service = ai_pin_generation_rate_limit_service @staticmethod def _format_pin_datetime(value: datetime | None) -> str | None: @@ -216,13 +219,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 +288,52 @@ async def issue_pin_ai_make( content=pin_copy["content"], ) + async def issue_pin_ai_make( + self, + *, + uid: str, + request: CreateIssuePinRequest, + ) -> IssueAnalysisResult: + await self._ai_pin_generation_rate_limit_service.consume_daily_quota(uid=uid) + return await self._generate_issue_pin_ai_copy(request=request) + + async def issue_pin_ai_edit( + self, + *, + uid: str, + pin_id: int, + title: str, + content: str, + tone: ToneType = ToneType.NONE, + ) -> IssueAnalysisResult: + 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) + + pin_location = pin.pin_location + if pin_location is None: + raise_business_exception(ErrorCode.ISSUE_PIN_EDIT_FAILED) + + latitude, longitude = wgs84_from_pin_point(pin_location.pin_point) + await self._ai_pin_generation_rate_limit_service.consume_daily_quota(uid=uid) + return await self._generate_issue_pin_ai_copy( + request=CreateIssuePinRequest( + title=title, + content=content, + tone=tone, + latitude=latitude, + longitude=longitude, + ), + ) + async def _upload_snapshot_to_s3(self, snap: ImageSnapshot) -> dict[str, str]: return await self._s3_util.upload_bytes( snap.data, diff --git a/app/services/internal/AiPinGenerationRateLimitService.py b/app/services/internal/AiPinGenerationRateLimitService.py new file mode 100644 index 0000000..a0b5212 --- /dev/null +++ b/app/services/internal/AiPinGenerationRateLimitService.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass +from datetime import datetime, timedelta +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") +_DAILY_KEY_PREFIX = "issue_pin:ai:daily" +_EXPIRE_BUFFER_SECONDS = 60 * 60 + +_CONSUME_DAILY_QUOTA_LUA = """ +local n = redis.call('INCR', KEYS[1]) +if n == 1 then + redis.call('EXPIRE', KEYS[1], ARGV[1]) +end +if n > tonumber(ARGV[2]) then + return {0, n} +end +return {1, n} +""" + + +@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 AiPinGenerationQuotaStatus: + 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 AiPinGenerationRateLimitService: + def __init__(self, *, redis_client: AsyncRedis | None = None) -> None: + self._redis_client = redis_client + self._consume_script = ( + redis_client.register_script(_CONSUME_DAILY_QUOTA_LUA) + if redis_client is not None + else None + ) + + @staticmethod + def daily_key(*, uid: str, kst_date: datetime) -> str: + return f"{_DAILY_KEY_PREFIX}:{uid}:{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, + ) + + async def consume_daily_quota(self, *, uid: str) -> RateLimitSnapshot | None: + if not settings.ai_pin_generation_rate_limit_enabled: + return None + + daily_limit = settings.ai_pin_generation_daily_limit + if daily_limit < 1: + return None + + if self._redis_client is None or self._consume_script is None: + logger.warning( + "AI pin generation rate limit skipped: Redis client unavailable uid=%s", + uid, + ) + return None + + now_kst = datetime.now(_KST) + key = self.daily_key(uid=uid, 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._consume_script( + keys=[key], + args=[ttl_seconds, daily_limit], + ) + except Exception: + logger.warning( + "AI pin generation rate limit skipped: Redis error uid=%s", + uid, + exc_info=True, + ) + return None + + allowed = int(raw[0]) == 1 + used_count = int(raw[1]) + snapshot = RateLimitSnapshot( + daily_limit=daily_limit, + used_count=used_count, + reset_at=reset_at, + ) + if not allowed: + raise_business_exception( + ErrorCode.AI_PIN_GENERATION_RATE_LIMIT_EXCEEDED, + **snapshot.to_result_dict(), + ) + return snapshot + + async def get_daily_quota_status(self, *, uid: str) -> AiPinGenerationQuotaStatus: + daily_limit = settings.ai_pin_generation_daily_limit + now_kst = datetime.now(_KST) + reset_at = self.next_kst_midnight(now=now_kst) + + if not settings.ai_pin_generation_rate_limit_enabled or daily_limit < 1: + return AiPinGenerationQuotaStatus( + enabled=False, + daily_limit=daily_limit, + used_count=0, + reset_at=reset_at, + ) + + if self._redis_client is None: + logger.warning( + "AI pin generation quota read skipped: Redis client unavailable uid=%s", + uid, + ) + return AiPinGenerationQuotaStatus( + enabled=False, + daily_limit=daily_limit, + used_count=0, + reset_at=reset_at, + ) + + key = self.daily_key(uid=uid, 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( + "AI pin generation quota read failed uid=%s", + uid, + exc_info=True, + ) + + return AiPinGenerationQuotaStatus( + enabled=True, + daily_limit=daily_limit, + used_count=used_count, + reset_at=reset_at, + ) diff --git a/tests/test_ai_pin_generation_rate_limit.py b/tests/test_ai_pin_generation_rate_limit.py new file mode 100644 index 0000000..a0317fc --- /dev/null +++ b/tests/test_ai_pin_generation_rate_limit.py @@ -0,0 +1,154 @@ +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.AiPinGenerationRateLimitService import AiPinGenerationRateLimitService + +_KST = ZoneInfo("Asia/Seoul") + + +class AiPinGenerationRateLimitHelpersTest(unittest.TestCase): + def test_daily_key_uses_kst_date_suffix(self) -> None: + kst_date = datetime(2026, 6, 3, 15, 30, tzinfo=_KST) + key = AiPinGenerationRateLimitService.daily_key(uid="user-1", kst_date=kst_date) + self.assertEqual(key, "issue_pin:ai: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 = AiPinGenerationRateLimitService.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 = AiPinGenerationRateLimitService.next_kst_midnight(now=now) + self.assertEqual(reset_at, datetime(2026, 6, 4, 0, 0, 0, tzinfo=_KST)) + + +class AiPinGenerationRateLimitServiceTest(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + self.redis_client = MagicMock() + self.consume_script = AsyncMock() + self.redis_client.register_script.return_value = self.consume_script + self.service = AiPinGenerationRateLimitService(redis_client=self.redis_client) + + @patch("app.services.internal.AiPinGenerationRateLimitService.settings") + async def test_consume_allows_up_to_daily_limit(self, mock_settings: MagicMock) -> None: + mock_settings.ai_pin_generation_rate_limit_enabled = True + mock_settings.ai_pin_generation_daily_limit = 3 + + for used in range(1, 4): + self.consume_script.return_value = [1, used] + snapshot = await self.service.consume_daily_quota(uid="user-1") + assert snapshot is not None + self.assertEqual(snapshot.used_count, used) + self.assertEqual(snapshot.daily_limit, 3) + + self.assertEqual(self.consume_script.await_count, 3) + + @patch("app.services.internal.AiPinGenerationRateLimitService.settings") + async def test_consume_raises_when_limit_exceeded(self, mock_settings: MagicMock) -> None: + mock_settings.ai_pin_generation_rate_limit_enabled = True + mock_settings.ai_pin_generation_daily_limit = 10 + self.consume_script.return_value = [0, 11] + + with self.assertRaises(BusinessException) as ctx: + await self.service.consume_daily_quota(uid="user-1") + + exc = ctx.exception + self.assertEqual(exc.error_code, ErrorCode.AI_PIN_GENERATION_RATE_LIMIT_EXCEEDED) + self.assertEqual(exc.extra["dailyLimit"], 10) + self.assertEqual(exc.extra["usedCount"], 11) + self.assertIn("resetAt", exc.extra) + + @patch("app.services.internal.AiPinGenerationRateLimitService.settings") + async def test_consume_skips_when_disabled(self, mock_settings: MagicMock) -> None: + mock_settings.ai_pin_generation_rate_limit_enabled = False + + result = await self.service.consume_daily_quota(uid="user-1") + + self.assertIsNone(result) + self.consume_script.assert_not_awaited() + + @patch("app.services.internal.AiPinGenerationRateLimitService.settings") + async def test_consume_skips_when_redis_unavailable(self, mock_settings: MagicMock) -> None: + mock_settings.ai_pin_generation_rate_limit_enabled = True + mock_settings.ai_pin_generation_daily_limit = 10 + service = AiPinGenerationRateLimitService(redis_client=None) + + result = await service.consume_daily_quota(uid="user-1") + + self.assertIsNone(result) + + @patch("app.services.internal.AiPinGenerationRateLimitService.settings") + async def test_consume_skips_on_redis_error(self, mock_settings: MagicMock) -> None: + mock_settings.ai_pin_generation_rate_limit_enabled = True + mock_settings.ai_pin_generation_daily_limit = 10 + self.consume_script.side_effect = RuntimeError("redis down") + + result = await self.service.consume_daily_quota(uid="user-1") + + self.assertIsNone(result) + + @patch("app.services.internal.AiPinGenerationRateLimitService.settings") + async def test_consume_passes_key_ttl_and_limit_to_script(self, mock_settings: MagicMock) -> None: + mock_settings.ai_pin_generation_rate_limit_enabled = True + mock_settings.ai_pin_generation_daily_limit = 10 + self.consume_script.return_value = [1, 1] + fixed_now = datetime(2026, 6, 3, 12, 0, 0, tzinfo=_KST) + + with patch( + "app.services.internal.AiPinGenerationRateLimitService.datetime", + ) as mock_datetime: + mock_datetime.now.return_value = fixed_now + await self.service.consume_daily_quota(uid="user-42") + + self.consume_script.assert_awaited_once() + call_kwargs = self.consume_script.await_args.kwargs + self.assertEqual(call_kwargs["keys"], ["issue_pin:ai:daily:user-42:20260603"]) + self.assertEqual(call_kwargs["args"][1], 10) + self.assertGreater(call_kwargs["args"][0], 0) + + @patch("app.services.internal.AiPinGenerationRateLimitService.settings") + async def test_get_quota_when_enabled(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 = AsyncMock(return_value="3") + + quota = await self.service.get_daily_quota_status(uid="user-1") + + self.assertTrue(quota.enabled) + self.assertEqual(quota.used_count, 3) + self.assertEqual(quota.remaining_count, 7) + self.assertEqual(quota.to_result_dict()["remainingCount"], 7) + + @patch("app.services.internal.AiPinGenerationRateLimitService.settings") + async def test_get_quota_when_disabled(self, mock_settings: MagicMock) -> None: + mock_settings.ai_pin_generation_rate_limit_enabled = False + mock_settings.ai_pin_generation_daily_limit = 10 + + quota = await self.service.get_daily_quota_status(uid="user-1") + + self.assertFalse(quota.enabled) + self.assertEqual(quota.used_count, 0) + self.assertEqual(quota.remaining_count, 10) + self.redis_client.get.assert_not_called() + + @patch("app.services.internal.AiPinGenerationRateLimitService.settings") + async def test_get_quota_when_redis_unavailable(self, mock_settings: MagicMock) -> None: + mock_settings.ai_pin_generation_rate_limit_enabled = True + mock_settings.ai_pin_generation_daily_limit = 10 + service = AiPinGenerationRateLimitService(redis_client=None) + + quota = await service.get_daily_quota_status(uid="user-1") + + self.assertFalse(quota.enabled) + self.assertEqual(quota.remaining_count, 10) + + +if __name__ == "__main__": + unittest.main() From 82ab564d9cb91f9ffdd1153fd369b14705d70c20 Mon Sep 17 00:00:00 2001 From: selnem Date: Thu, 4 Jun 2026 01:53:09 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=EC=9D=B4=EC=8A=88=20=ED=95=80=20AI?= =?UTF-8?q?=C2=B7=EA=B2=8C=EC=8B=9C=C2=B7=EC=88=98=EC=A0=95=20=EC=9D=BC?= =?UTF-8?q?=EC=9D=BC=20=EC=82=AC=EC=9A=A9=20=EC=A0=9C=ED=95=9C=20=EB=B0=8F?= =?UTF-8?q?=20quota=20API=20=ED=99=95=EC=9E=A5=20AiPinGenerationRateLimitS?= =?UTF-8?q?ervice=EB=A5=BC=20IssuePinDailyRateLimitService=EB=A1=9C=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=ED=95=98=EA=B3=A0,=20uid/pin=5Fid=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=20Redis=20=EC=9D=BC=EC=9D=BC=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C(assert/record)=EA=B3=BC=20GET=20quota=20=EC=97=94?= =?UTF-8?q?=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/codes.py | 24 +- app/core/config.py | 26 +- app/core/deps.py | 20 +- app/routes/IssueRoute.py | 105 +++-- app/schemas/IssueDTO.py | 14 +- app/services/IssueService.py | 144 ++++--- .../AiPinGenerationRateLimitService.py | 205 ---------- .../internal/IssuePinDailyRateLimitService.py | 369 ++++++++++++++++++ rag/scripts/test_create_issue_pin_once.py | 5 +- tests/test_ai_pin_generation_rate_limit.py | 154 -------- tests/test_issue_pin_daily_rate_limit.py | 177 +++++++++ 11 files changed, 787 insertions(+), 456 deletions(-) delete mode 100644 app/services/internal/AiPinGenerationRateLimitService.py create mode 100644 app/services/internal/IssuePinDailyRateLimitService.py delete mode 100644 tests/test_ai_pin_generation_rate_limit.py create mode 100644 tests/test_issue_pin_daily_rate_limit.py diff --git a/app/core/codes.py b/app/core/codes.py index 55ab9ee..64fdd8e 100644 --- a/app/core/codes.py +++ b/app/core/codes.py @@ -34,7 +34,17 @@ class ErrorCode(Enum): AI_PIN_GENERATION_RATE_LIMIT_EXCEEDED = ( status.HTTP_429_TOO_MANY_REQUESTS, "AI_4291", - "오늘 AI 글 생성·수정 횟수를 초과했습니다.", + "오늘 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 @@ -138,7 +148,17 @@ class SuccessCode(Enum): ISSUE_PIN_AI_QUOTA_GET_SUCCESS = ( status.HTTP_200_OK, "ISSUE_2003", - "AI 글 생성·수정 제한 횟수 조회에 성공했습니다.", + "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, diff --git a/app/core/config.py b/app/core/config.py index bb09063..07305cf 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -260,13 +260,37 @@ def _empty_pdf_korean_font_paths_to_none(cls, value: object) -> object | None: ge=1, le=1000, alias="AI_PIN_GENERATION_DAILY_LIMIT", - description="uid당 이슈 핀 AI 글 생성·수정(미리보기) 일일 허용 횟수", + 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 bd137ae..2b69e12 100644 --- a/app/core/deps.py +++ b/app/core/deps.py @@ -24,7 +24,7 @@ from app.repositories.PinRepo import PinRepo from app.repositories.UserRepo import UserRepo from app.services.IssueService import IssueService -from app.services.internal.AiPinGenerationRateLimitService import AiPinGenerationRateLimitService +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 @@ -369,17 +369,21 @@ def get_issue_pin_background_runner( ] -def get_ai_pin_generation_rate_limit_service(request: Request) -> AiPinGenerationRateLimitService: +def get_issue_pin_daily_rate_limit_service(request: Request) -> IssuePinDailyRateLimitService: redis_client = getattr(request.app.state, "async_redis_client", None) - return AiPinGenerationRateLimitService(redis_client=redis_client) + return IssuePinDailyRateLimitService(redis_client=redis_client) -AiPinGenerationRateLimitServiceDep = Annotated[ - AiPinGenerationRateLimitService, - Depends(get_ai_pin_generation_rate_limit_service), +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, @@ -394,7 +398,7 @@ def get_issue_service( user_repo: UserRepoDep, s3_util: S3UtilDep, background_runner: IssuePinBackgroundRunnerDep, - ai_pin_generation_rate_limit_service: AiPinGenerationRateLimitServiceDep, + issue_pin_daily_rate_limit_service: IssuePinDailyRateLimitServiceDep, ) -> IssueService: return IssueService( vector_store_service=vector_store_service, @@ -410,7 +414,7 @@ def get_issue_service( user_repo=user_repo, s3_util=s3_util, background_runner=background_runner, - ai_pin_generation_rate_limit_service=ai_pin_generation_rate_limit_service, + 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 bffff14..bad1cf0 100644 --- a/app/routes/IssueRoute.py +++ b/app/routes/IssueRoute.py @@ -1,12 +1,12 @@ -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 ( - AiPinGenerationRateLimitServiceDep, CurrentUserIdDep, + IssuePinDailyRateLimitServiceDep, IssueServiceDep, ) from app.core.exceptions import raise_business_exception @@ -15,13 +15,24 @@ from app.schemas.IssueDTO import ( CreateIssuePinMultipartRequest, CreateIssuePinRequest, - IssuePinAiQuotaResponse, + 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] @@ -30,20 +41,59 @@ async def get_tone_types(): @router.get( "/pin/ai/quota", - summary="AI 글 생성·수정 일일 제한 횟수 조회", + summary="AI 글 생성(미리보기) 일일 성공 제한 횟수 조회", ) async def get_issue_pin_ai_quota( uid: CurrentUserIdDep, - rate_limit_service: AiPinGenerationRateLimitServiceDep, + rate_limit_service: IssuePinDailyRateLimitServiceDep, ): - quota = await rate_limit_service.get_daily_quota_status(uid=uid) - result = IssuePinAiQuotaResponse.model_validate(quota.to_result_dict()) + 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, @@ -61,29 +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, ) - return success_response(result=result, success_code=SuccessCode.CREATED) - - -@router.patch("/pin/{pin_id}/ai") -async def update_issue_pin_ai( - pin_id: int, - uid: CurrentUserIdDep, - issue_service: IssueServiceDep, - title: str = Form(...), - content: str = Form(...), - tone: ToneType = Form(ToneType.NONE), -): - result = await issue_service.issue_pin_ai_edit( - uid=uid, - pin_id=pin_id, - title=title, - content=content, - tone=tone, - ) + 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) @@ -100,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, ) @@ -142,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 18b8729..4b2d3c1 100644 --- a/app/schemas/IssueDTO.py +++ b/app/schemas/IssueDTO.py @@ -84,14 +84,18 @@ class IssueAnalysisResult(BaseModel): content: str -class IssuePinAiQuotaResponse(BaseModel): +class IssuePinQuotaResponse(BaseModel): model_config = ConfigDict(populate_by_name=True) enabled: bool - daily_limit: int = Field(serialization_alias="dailyLimit") - used_count: int = Field(serialization_alias="usedCount") - remaining_count: int = Field(serialization_alias="remainingCount") - reset_at: str = Field(serialization_alias="resetAt") + 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): diff --git a/app/services/IssueService.py b/app/services/IssueService.py index 1c11ec9..3b26a2d 100644 --- a/app/services/IssueService.py +++ b/app/services/IssueService.py @@ -47,7 +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.AiPinGenerationRateLimitService import AiPinGenerationRateLimitService +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 @@ -63,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 @@ -87,7 +103,7 @@ def __init__( user_repo: UserRepo, s3_util: S3Util, background_runner: IssuePinBackgroundRunner, - ai_pin_generation_rate_limit_service: AiPinGenerationRateLimitService, + issue_pin_daily_rate_limit_service: IssuePinDailyRateLimitService, ) -> None: self._vector_store_service = vector_store_service self._issue_rag_planner_service = issue_rag_planner_service @@ -102,7 +118,40 @@ def __init__( self._community_repo = community_repo self._user_repo = user_repo self._background_runner = background_runner - self._ai_pin_generation_rate_limit_service = ai_pin_generation_rate_limit_service + 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: @@ -293,46 +342,16 @@ async def issue_pin_ai_make( *, uid: str, request: CreateIssuePinRequest, - ) -> IssueAnalysisResult: - await self._ai_pin_generation_rate_limit_service.consume_daily_quota(uid=uid) - return await self._generate_issue_pin_ai_copy(request=request) - - async def issue_pin_ai_edit( - self, - *, - uid: str, - pin_id: int, - title: str, - content: str, - tone: ToneType = ToneType.NONE, - ) -> IssueAnalysisResult: - 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) - - pin_location = pin.pin_location - if pin_location is None: - raise_business_exception(ErrorCode.ISSUE_PIN_EDIT_FAILED) - - latitude, longitude = wgs84_from_pin_point(pin_location.pin_point) - await self._ai_pin_generation_rate_limit_service.consume_daily_quota(uid=uid) - return await self._generate_issue_pin_ai_copy( - request=CreateIssuePinRequest( - title=title, - content=content, - tone=tone, - latitude=latitude, - longitude=longitude, - ), + ) -> 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( @@ -635,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, @@ -729,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, @@ -742,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, @@ -752,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, @@ -761,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: @@ -787,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, @@ -829,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, @@ -849,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/AiPinGenerationRateLimitService.py b/app/services/internal/AiPinGenerationRateLimitService.py deleted file mode 100644 index a0b5212..0000000 --- a/app/services/internal/AiPinGenerationRateLimitService.py +++ /dev/null @@ -1,205 +0,0 @@ -from __future__ import annotations - -import logging -from dataclasses import dataclass -from datetime import datetime, timedelta -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") -_DAILY_KEY_PREFIX = "issue_pin:ai:daily" -_EXPIRE_BUFFER_SECONDS = 60 * 60 - -_CONSUME_DAILY_QUOTA_LUA = """ -local n = redis.call('INCR', KEYS[1]) -if n == 1 then - redis.call('EXPIRE', KEYS[1], ARGV[1]) -end -if n > tonumber(ARGV[2]) then - return {0, n} -end -return {1, n} -""" - - -@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 AiPinGenerationQuotaStatus: - 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 AiPinGenerationRateLimitService: - def __init__(self, *, redis_client: AsyncRedis | None = None) -> None: - self._redis_client = redis_client - self._consume_script = ( - redis_client.register_script(_CONSUME_DAILY_QUOTA_LUA) - if redis_client is not None - else None - ) - - @staticmethod - def daily_key(*, uid: str, kst_date: datetime) -> str: - return f"{_DAILY_KEY_PREFIX}:{uid}:{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, - ) - - async def consume_daily_quota(self, *, uid: str) -> RateLimitSnapshot | None: - if not settings.ai_pin_generation_rate_limit_enabled: - return None - - daily_limit = settings.ai_pin_generation_daily_limit - if daily_limit < 1: - return None - - if self._redis_client is None or self._consume_script is None: - logger.warning( - "AI pin generation rate limit skipped: Redis client unavailable uid=%s", - uid, - ) - return None - - now_kst = datetime.now(_KST) - key = self.daily_key(uid=uid, 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._consume_script( - keys=[key], - args=[ttl_seconds, daily_limit], - ) - except Exception: - logger.warning( - "AI pin generation rate limit skipped: Redis error uid=%s", - uid, - exc_info=True, - ) - return None - - allowed = int(raw[0]) == 1 - used_count = int(raw[1]) - snapshot = RateLimitSnapshot( - daily_limit=daily_limit, - used_count=used_count, - reset_at=reset_at, - ) - if not allowed: - raise_business_exception( - ErrorCode.AI_PIN_GENERATION_RATE_LIMIT_EXCEEDED, - **snapshot.to_result_dict(), - ) - return snapshot - - async def get_daily_quota_status(self, *, uid: str) -> AiPinGenerationQuotaStatus: - daily_limit = settings.ai_pin_generation_daily_limit - now_kst = datetime.now(_KST) - reset_at = self.next_kst_midnight(now=now_kst) - - if not settings.ai_pin_generation_rate_limit_enabled or daily_limit < 1: - return AiPinGenerationQuotaStatus( - enabled=False, - daily_limit=daily_limit, - used_count=0, - reset_at=reset_at, - ) - - if self._redis_client is None: - logger.warning( - "AI pin generation quota read skipped: Redis client unavailable uid=%s", - uid, - ) - return AiPinGenerationQuotaStatus( - enabled=False, - daily_limit=daily_limit, - used_count=0, - reset_at=reset_at, - ) - - key = self.daily_key(uid=uid, 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( - "AI pin generation quota read failed uid=%s", - uid, - exc_info=True, - ) - - return AiPinGenerationQuotaStatus( - enabled=True, - daily_limit=daily_limit, - used_count=used_count, - reset_at=reset_at, - ) diff --git a/app/services/internal/IssuePinDailyRateLimitService.py b/app/services/internal/IssuePinDailyRateLimitService.py new file mode 100644 index 0000000..698fd5a --- /dev/null +++ b/app/services/internal/IssuePinDailyRateLimitService.py @@ -0,0 +1,369 @@ +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], + ) + except Exception: + logger.warning( + "Issue pin success record skipped: Redis error kind=%s uid=%s", + kind, + uid, + exc_info=True, + ) + return None + + recorded = int(raw[0]) == 1 + used_count = int(raw[1]) + 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/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_ai_pin_generation_rate_limit.py b/tests/test_ai_pin_generation_rate_limit.py deleted file mode 100644 index a0317fc..0000000 --- a/tests/test_ai_pin_generation_rate_limit.py +++ /dev/null @@ -1,154 +0,0 @@ -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.AiPinGenerationRateLimitService import AiPinGenerationRateLimitService - -_KST = ZoneInfo("Asia/Seoul") - - -class AiPinGenerationRateLimitHelpersTest(unittest.TestCase): - def test_daily_key_uses_kst_date_suffix(self) -> None: - kst_date = datetime(2026, 6, 3, 15, 30, tzinfo=_KST) - key = AiPinGenerationRateLimitService.daily_key(uid="user-1", kst_date=kst_date) - self.assertEqual(key, "issue_pin:ai: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 = AiPinGenerationRateLimitService.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 = AiPinGenerationRateLimitService.next_kst_midnight(now=now) - self.assertEqual(reset_at, datetime(2026, 6, 4, 0, 0, 0, tzinfo=_KST)) - - -class AiPinGenerationRateLimitServiceTest(unittest.IsolatedAsyncioTestCase): - async def asyncSetUp(self) -> None: - self.redis_client = MagicMock() - self.consume_script = AsyncMock() - self.redis_client.register_script.return_value = self.consume_script - self.service = AiPinGenerationRateLimitService(redis_client=self.redis_client) - - @patch("app.services.internal.AiPinGenerationRateLimitService.settings") - async def test_consume_allows_up_to_daily_limit(self, mock_settings: MagicMock) -> None: - mock_settings.ai_pin_generation_rate_limit_enabled = True - mock_settings.ai_pin_generation_daily_limit = 3 - - for used in range(1, 4): - self.consume_script.return_value = [1, used] - snapshot = await self.service.consume_daily_quota(uid="user-1") - assert snapshot is not None - self.assertEqual(snapshot.used_count, used) - self.assertEqual(snapshot.daily_limit, 3) - - self.assertEqual(self.consume_script.await_count, 3) - - @patch("app.services.internal.AiPinGenerationRateLimitService.settings") - async def test_consume_raises_when_limit_exceeded(self, mock_settings: MagicMock) -> None: - mock_settings.ai_pin_generation_rate_limit_enabled = True - mock_settings.ai_pin_generation_daily_limit = 10 - self.consume_script.return_value = [0, 11] - - with self.assertRaises(BusinessException) as ctx: - await self.service.consume_daily_quota(uid="user-1") - - exc = ctx.exception - self.assertEqual(exc.error_code, ErrorCode.AI_PIN_GENERATION_RATE_LIMIT_EXCEEDED) - self.assertEqual(exc.extra["dailyLimit"], 10) - self.assertEqual(exc.extra["usedCount"], 11) - self.assertIn("resetAt", exc.extra) - - @patch("app.services.internal.AiPinGenerationRateLimitService.settings") - async def test_consume_skips_when_disabled(self, mock_settings: MagicMock) -> None: - mock_settings.ai_pin_generation_rate_limit_enabled = False - - result = await self.service.consume_daily_quota(uid="user-1") - - self.assertIsNone(result) - self.consume_script.assert_not_awaited() - - @patch("app.services.internal.AiPinGenerationRateLimitService.settings") - async def test_consume_skips_when_redis_unavailable(self, mock_settings: MagicMock) -> None: - mock_settings.ai_pin_generation_rate_limit_enabled = True - mock_settings.ai_pin_generation_daily_limit = 10 - service = AiPinGenerationRateLimitService(redis_client=None) - - result = await service.consume_daily_quota(uid="user-1") - - self.assertIsNone(result) - - @patch("app.services.internal.AiPinGenerationRateLimitService.settings") - async def test_consume_skips_on_redis_error(self, mock_settings: MagicMock) -> None: - mock_settings.ai_pin_generation_rate_limit_enabled = True - mock_settings.ai_pin_generation_daily_limit = 10 - self.consume_script.side_effect = RuntimeError("redis down") - - result = await self.service.consume_daily_quota(uid="user-1") - - self.assertIsNone(result) - - @patch("app.services.internal.AiPinGenerationRateLimitService.settings") - async def test_consume_passes_key_ttl_and_limit_to_script(self, mock_settings: MagicMock) -> None: - mock_settings.ai_pin_generation_rate_limit_enabled = True - mock_settings.ai_pin_generation_daily_limit = 10 - self.consume_script.return_value = [1, 1] - fixed_now = datetime(2026, 6, 3, 12, 0, 0, tzinfo=_KST) - - with patch( - "app.services.internal.AiPinGenerationRateLimitService.datetime", - ) as mock_datetime: - mock_datetime.now.return_value = fixed_now - await self.service.consume_daily_quota(uid="user-42") - - self.consume_script.assert_awaited_once() - call_kwargs = self.consume_script.await_args.kwargs - self.assertEqual(call_kwargs["keys"], ["issue_pin:ai:daily:user-42:20260603"]) - self.assertEqual(call_kwargs["args"][1], 10) - self.assertGreater(call_kwargs["args"][0], 0) - - @patch("app.services.internal.AiPinGenerationRateLimitService.settings") - async def test_get_quota_when_enabled(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 = AsyncMock(return_value="3") - - quota = await self.service.get_daily_quota_status(uid="user-1") - - self.assertTrue(quota.enabled) - self.assertEqual(quota.used_count, 3) - self.assertEqual(quota.remaining_count, 7) - self.assertEqual(quota.to_result_dict()["remainingCount"], 7) - - @patch("app.services.internal.AiPinGenerationRateLimitService.settings") - async def test_get_quota_when_disabled(self, mock_settings: MagicMock) -> None: - mock_settings.ai_pin_generation_rate_limit_enabled = False - mock_settings.ai_pin_generation_daily_limit = 10 - - quota = await self.service.get_daily_quota_status(uid="user-1") - - self.assertFalse(quota.enabled) - self.assertEqual(quota.used_count, 0) - self.assertEqual(quota.remaining_count, 10) - self.redis_client.get.assert_not_called() - - @patch("app.services.internal.AiPinGenerationRateLimitService.settings") - async def test_get_quota_when_redis_unavailable(self, mock_settings: MagicMock) -> None: - mock_settings.ai_pin_generation_rate_limit_enabled = True - mock_settings.ai_pin_generation_daily_limit = 10 - service = AiPinGenerationRateLimitService(redis_client=None) - - quota = await service.get_daily_quota_status(uid="user-1") - - self.assertFalse(quota.enabled) - self.assertEqual(quota.remaining_count, 10) - - -if __name__ == "__main__": - unittest.main() 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..512f052 --- /dev/null +++ b/tests/test_issue_pin_daily_rate_limit.py @@ -0,0 +1,177 @@ +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_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() From d93e5d6820441846e64666cc2dc6477bd27774a5 Mon Sep 17 00:00:00 2001 From: selnem Date: Thu, 4 Jun 2026 02:01:00 +0900 Subject: [PATCH 3/4] =?UTF-8?q?docs:=20=EC=9D=B4=EC=8A=88=20=ED=95=80=20ra?= =?UTF-8?q?te=20limit=C2=B7quota=20API=20=EB=AC=B8=EC=84=9C=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 + docs/issue-pin-create-and-reliability.md | 166 ++++++++++++++++++++--- 2 files changed, 149 insertions(+), 19 deletions(-) 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/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. 미구현·향후 작업 From eca69b06a87c2d034f6770089d572ee7b7b7c872 Mon Sep 17 00:00:00 2001 From: selnem Date: Thu, 4 Jun 2026 02:07:06 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20Redis=20=EC=8A=A4=ED=81=AC=EB=A6=BD?= =?UTF-8?q?=ED=8A=B8=20=EA=B2=B0=EA=B3=BC=20=EC=96=B8=ED=8C=A8=ED=82=B9=20?= =?UTF-8?q?fail-open=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/IssuePinDailyRateLimitService.py | 6 ++++-- tests/test_issue_pin_daily_rate_limit.py | 20 +++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/app/services/internal/IssuePinDailyRateLimitService.py b/app/services/internal/IssuePinDailyRateLimitService.py index 698fd5a..1f4be45 100644 --- a/app/services/internal/IssuePinDailyRateLimitService.py +++ b/app/services/internal/IssuePinDailyRateLimitService.py @@ -279,6 +279,10 @@ async def record_daily_quota_success( 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", @@ -288,8 +292,6 @@ async def record_daily_quota_success( ) return None - recorded = int(raw[0]) == 1 - used_count = int(raw[1]) snapshot = RateLimitSnapshot( daily_limit=daily_limit, used_count=used_count, diff --git a/tests/test_issue_pin_daily_rate_limit.py b/tests/test_issue_pin_daily_rate_limit.py index 512f052..7ac12d7 100644 --- a/tests/test_issue_pin_daily_rate_limit.py +++ b/tests/test_issue_pin_daily_rate_limit.py @@ -133,6 +133,26 @@ async def test_record_skips_when_disabled(self, mock_settings: MagicMock) -> Non 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