Feat/30 사용횟수 제한 기능 구현#116
Hidden character warning
Conversation
AiPinGenerationRateLimitService를 IssuePinDailyRateLimitService로 통합하고, uid/pin_id 기준 Redis 일일 제한(assert/record)과 GET quota 엔드포인트를 추가한다.
There was a problem hiding this comment.
Code Review
This pull request implements a daily rate limiting feature for issue pin operations (AI preview generation, pin creation, and pin editing) using Redis with KST midnight resets. It introduces the IssuePinDailyRateLimitService, configures rate limit thresholds, adds quota status retrieval endpoints, integrates rate limit checks into the issue service, and includes comprehensive unit tests and documentation. The review feedback highlights a robustness issue in IssuePinDailyRateLimitService.py where unpacking the Redis script result outside of the try...except block could cause a crash if Redis returns an unexpected response, potentially violating the fail-open policy.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request implements a daily rate limiting mechanism for issue pin operations (AI generation, creation, and editing) using Redis. It introduces the IssuePinDailyRateLimitService to manage quotas, adds endpoints to retrieve quota status, and updates existing endpoints to enforce and return quota details. The review feedback suggests returning the IssuePin object from ensure_issue_pin_edit_access to avoid redundant database lookups, refactoring seconds_until_kst_midnight to reuse next_kst_midnight and eliminate code duplication, and adding defensive type checking on the Redis Lua script output to prevent potential TypeErrors.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| 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) |
There was a problem hiding this comment.
현재 ensure_issue_pin_edit_access 메서드는 검증만 수행하고 None을 반환합니다. 하지만 이 메서드를 호출하는 곳(예: update_issue_pin 등)에서는 검증된 IssuePin 객체의 정보가 필요한 경우가 많습니다. 이 메서드가 검증된 IssuePin 객체를 반환하도록 수정하면, 호출부에서 중복으로 데이터베이스를 조회하거나 동일한 검증 로직을 중복 작성할 필요 없이 반환된 객체를 재사용할 수 있어 유지보수성과 효율성이 향상됩니다.
| 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) | |
| async def ensure_issue_pin_edit_access(self, *, uid: str, pin_id: int) -> IssuePin: | |
| 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) | |
| return issue_pin |
| 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 |
There was a problem hiding this comment.
seconds_until_kst_midnight 메서드와 next_kst_midnight 메서드는 현재 시각(current)을 구하고 다음 자정(next_midnight)을 계산하는 로직이 완전히 중복됩니다. seconds_until_kst_midnight 내부에서 next_kst_midnight을 호출하도록 수정하면 코드 중복을 제거하고 유지보수성을 높일 수 있습니다.
@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 = IssuePinDailyRateLimitService.next_kst_midnight(now=current)| if raw is None or len(raw) < 2: | ||
| raise ValueError(f"unexpected redis script result: {raw!r}") |
There was a problem hiding this comment.
Redis Lua 스크립트 실행 결과인 raw가 예상치 못한 타입(예: 에러 메시지 문자열 또는 정수 등)일 경우, len(raw) 호출 시 TypeError가 발생할 수 있습니다. try-except 블록으로 예외가 처리되기는 하지만, isinstance(raw, (list, tuple))를 통해 타입을 명확히 검증한 후 길이를 체크하는 것이 방어적 프로그래밍 관점에서 더 안전합니다.
| if raw is None or len(raw) < 2: | |
| raise ValueError(f"unexpected redis script result: {raw!r}") | |
| if not isinstance(raw, (list, tuple)) or len(raw) < 2: | |
| raise ValueError(f"unexpected redis script result: {raw!r}") |
🔗 Related Issue
✨ 작업 개요
Gemini API·DB 부하 방지를 위해 이슈 핀 AI 글 생성 / 게시 / 수정에 Redis 기반 일일 성공 횟수 제한을 추가했습니다.
POST /issues/pin/ai): uid / 일POST /issues/pin): uid / 일PATCH /issues/pin/{pin_id}): pin_id(글) / 일AI_4291,ISSUE_4291,ISSUE_4292— edit는pinId포함)request+photos형식 변경 없음result필드 유지 +rateLimitQuota추가 (프론트 선택 사용)GET /issues/pin/ai/quotaGET /issues/pin/create/quotaGET /issues/pin/edit/quota?pin_id=(본인 pin 소유 검증)주요 변경 파일
IssuePinDailyRateLimitServiceIssueService,IssueRoute,deps,config,codes,IssueDTOtests/test_issue_pin_daily_rate_limit.py(13 cases)*_RATE_LIMIT_ENABLED=false면 해당 kind만 비활성화 (assert/record/429 미적용).
Redis 미연결 시 fail-open(제한 스킵) + warning 로그.
체크리스트
📷 이미지 첨부 (선택)