From 70e7b67f488831e902cfe9ed34a9ecfcb5a37e19 Mon Sep 17 00:00:00 2001 From: aprkaos56 Date: Thu, 25 Jun 2026 16:17:05 +0900 Subject: [PATCH] fix(character): improve generation retry timing --- .../management/commands/reset_limits.py | 14 ++-- apps/characters/tasks.py | 11 +-- apps/characters/views.py | 81 ++++++++++++------- tests/test_characters.py | 54 +++++++++++-- 4 files changed, 109 insertions(+), 51 deletions(-) diff --git a/apps/characters/management/commands/reset_limits.py b/apps/characters/management/commands/reset_limits.py index 10ba979..625c05c 100644 --- a/apps/characters/management/commands/reset_limits.py +++ b/apps/characters/management/commands/reset_limits.py @@ -2,8 +2,8 @@ 특정 유저의 두 가지 한도를 한 번에 푼다. - 활성 캐릭터를 비활성화(soft-delete)해 캐릭터 생성 한도(MAX_ACTIVE_CHARACTERS)를 푼다. -- 오늘자 생성 로그(ImgGenLog)를 삭제해 하루 생성 한도(MAX_DAILY_GEN)를 푼다. - (생성 실패도 로그가 쌓여 하루 한도를 소모하므로, 실패 누적분도 함께 정리된다.) +- 최근 24시간 생성 로그(ImgGenLog)를 삭제해 생성 한도(MAX_DAILY_GEN)를 푼다. + (생성 실패도 로그가 쌓여 한도를 소모하므로, 실패 누적분도 함께 정리된다.) 사용: python manage.py reset_limits --email demo@mongle.dev @@ -13,6 +13,7 @@ from __future__ import annotations +from datetime import timedelta from typing import Any from django.contrib.auth import get_user_model @@ -29,7 +30,7 @@ class Command(BaseCommand): - help = "특정 유저의 캐릭터/하루 생성 한도를 초기화한다(개발용)." + help = "특정 유저의 캐릭터/최근 24시간 생성 한도를 초기화한다(개발용)." def add_arguments(self, parser: Any) -> None: parser.add_argument("--email", required=True, help="초기화할 유저 이메일") @@ -41,7 +42,7 @@ def add_arguments(self, parser: Any) -> None: parser.add_argument( "--daily-only", action="store_true", - help="캐릭터는 건드리지 않고 오늘자 생성 한도만 초기화한다", + help="캐릭터는 건드리지 않고 최근 24시간 생성 한도만 초기화한다", ) @transaction.atomic @@ -64,7 +65,7 @@ def handle(self, *args: Any, **options: Any) -> None: self.stdout.write( f" 캐릭터 비활성화: {deactivated}개 → 현재 활성 {active_remaining}개" ) - self.stdout.write(f" 오늘 생성 로그 삭제: {deleted_logs}개") + self.stdout.write(f" 최근 24시간 생성 로그 삭제: {deleted_logs}개") def _reset_characters(self, email: str, keep_seed: bool) -> int: qs = Character.objects.filter(user__email=email, is_active=True) @@ -73,7 +74,8 @@ def _reset_characters(self, email: str, keep_seed: bool) -> int: return qs.update(is_active=False) def _reset_daily_generation(self, email: str) -> int: + window_start = timezone.now() - timedelta(hours=24) deleted, _ = ImgGenLog.objects.filter( - user__email=email, created_at__date=timezone.localdate() + user__email=email, created_at__gte=window_start ).delete() return deleted diff --git a/apps/characters/tasks.py b/apps/characters/tasks.py index 95dfc30..28fced4 100644 --- a/apps/characters/tasks.py +++ b/apps/characters/tasks.py @@ -126,16 +126,13 @@ def _poll_character_result( @shared_task def reset_image_gen_count() -> None: """ - [스케줄] 매일 자정 실행 - 오늘 이전 날짜의 이미지 재생성 이력(ImgGenLog)을 삭제하여 - 카운트를 초기화한다. 계정당 하루 3회 제한이며 다음날 다시 가능해진다. + [스케줄] 오래된 이미지 생성 이력(ImgGenLog)을 삭제한다. + 계정당 최근 24시간 3회 제한이므로 윈도우 밖 로그는 카운트에 필요 없다. """ from apps.characters.models import ImgGenLog - today_start = timezone.make_aware( - datetime.datetime.combine(timezone.localdate(), datetime.time.min) - ) - ImgGenLog.objects.filter(created_at__lt=today_start).delete() + window_start = timezone.now() - datetime.timedelta(hours=24) + ImgGenLog.objects.filter(created_at__lt=window_start).delete() def _refund_daily_gen(img_gen_log_id: int | None) -> None: diff --git a/apps/characters/views.py b/apps/characters/views.py index 806af26..547f0ac 100644 --- a/apps/characters/views.py +++ b/apps/characters/views.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta from urllib.parse import urlparse import uuid @@ -34,6 +34,28 @@ MAX_ACTIVE_CHARACTERS = 10 MAX_DAILY_GEN = 3 PRESIGNED_URL_EXPIRY_SECONDS = 600 +PERSONALITY_KEYWORD_ALIASES = { + "호기심 많은": "호기심많은", +} + + +def _generation_window(user) -> tuple[int, datetime | None, int | None]: + now = timezone.now() + window_start = now - timedelta(hours=24) + logs = list( + ImgGenLog.objects.filter(user=user, created_at__gte=window_start).order_by( + "created_at" + ) + ) + reset_at = logs[0].created_at + timedelta(hours=24) if logs else None + retry_after_seconds = ( + max(0, int((reset_at - now).total_seconds())) if reset_at else None + ) + return len(logs), reset_at, retry_after_seconds + + +def _normalize_personality_keywords(keywords: list[str]) -> list[str]: + return [PERSONALITY_KEYWORD_ALIASES.get(keyword, keyword) for keyword in keywords] def _extract_object_key(url: str) -> str: @@ -144,17 +166,14 @@ def post(self, request: Request) -> Response: status=status.HTTP_422_UNPROCESSABLE_ENTITY, ) - today_start = timezone.make_aware( - timezone.datetime.combine( - timezone.localdate(), timezone.datetime.min.time() - ) - ) - daily_count = ImgGenLog.objects.filter( - user=user, created_at__gte=today_start - ).count() - if daily_count >= MAX_DAILY_GEN: + generation_count, reset_at, retry_after_seconds = _generation_window(user) + if generation_count >= MAX_DAILY_GEN: return Response( - {"error": "DAILY_GENERATION_LIMIT_EXCEEDED"}, + { + "error": "DAILY_GENERATION_LIMIT_EXCEEDED", + "reset_at": reset_at.isoformat() if reset_at else None, + "retry_after_seconds": retry_after_seconds, + }, status=status.HTTP_429_TOO_MANY_REQUESTS, ) @@ -189,12 +208,14 @@ def post(self, request: Request) -> Response: job = CharacterGenerationJob.objects.create( user=user, source_image=source_image, - personality_keywords=data["personality_keywords"], + personality_keywords=_normalize_personality_keywords( + data["personality_keywords"] + ), custom_prompt=data.get("custom_prompt", ""), status=CharacterGenerationJob.Status.QUEUED, ) - img_gen_log = ImgGenLog.objects.create(user=user, gen_cnt=daily_count + 1) + img_gen_log = ImgGenLog.objects.create(user=user, gen_cnt=generation_count + 1) process_character_generation_job.delay( str(job.job_id), @@ -261,16 +282,14 @@ def post(self, request: Request, job_id: uuid.UUID) -> Response: job.status = CharacterGenerationJob.Status.FAILED job.save(update_fields=["status", "updated_at"]) - # 제출 시 차감한 일일 생성 횟수를 즉시 환불한다. ImgGenLog 행은 카운터라 - # 특정 행을 식별할 필요 없이 오늘자 1행을 지운다(잡 상태 전환이 잡당 1회 - # 가드). 태스크 체크포인트에 의존하지 않아 워커 유실·저장 레이스에도 안전. - today_start = timezone.make_aware( - timezone.datetime.combine( - timezone.localdate(), timezone.datetime.min.time() - ) - ) + # 제출 시 차감한 최근 24시간 생성 횟수를 즉시 환불한다. + # ImgGenLog 행은 카운터라 특정 행 식별 없이 최근 1행을 지운다. + # 잡 상태 전환이 잡당 1회 가드라 워커 유실·저장 레이스에도 안전. + window_start = timezone.now() - timedelta(hours=24) refund_id = ( - ImgGenLog.objects.filter(user=request.user, created_at__gte=today_start) + ImgGenLog.objects.filter( + user=request.user, created_at__gte=window_start + ) .order_by("-created_at") .values_list("pk", flat=True) .first() @@ -282,20 +301,20 @@ def post(self, request: Request, job_id: uuid.UUID) -> Response: class GenerationQuotaView(APIView): - """오늘의 캐릭터 생성 횟수(used)와 일일 한도(limit)를 반환한다.""" + """최근 24시간 캐릭터 생성 횟수(used)와 한도(limit)를 반환한다.""" permission_classes = (IsAuthenticated,) def get(self, request: Request) -> Response: - today_start = timezone.make_aware( - timezone.datetime.combine( - timezone.localdate(), timezone.datetime.min.time() - ) + used, reset_at, retry_after_seconds = _generation_window(request.user) + return Response( + { + "used": used, + "limit": MAX_DAILY_GEN, + "reset_at": reset_at.isoformat() if reset_at else None, + "retry_after_seconds": retry_after_seconds, + } ) - used = ImgGenLog.objects.filter( - user=request.user, created_at__gte=today_start - ).count() - return Response({"used": used, "limit": MAX_DAILY_GEN}) class CharacterListView(APIView): diff --git a/tests/test_characters.py b/tests/test_characters.py index 4389510..81b7e21 100644 --- a/tests/test_characters.py +++ b/tests/test_characters.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import date +from datetime import date, timedelta from unittest.mock import MagicMock, patch from django.test import override_settings @@ -190,15 +190,42 @@ def test_generation_job_create_success(auth_client: APIClient) -> None: assert "job_id" in data -# 오늘 이미 3회 생성한 경우 429 응답 확인 (일일 생성 한도 초과) +@pytest.mark.django_db +def test_generation_job_normalizes_personality_keyword_alias( + auth_client: APIClient, +) -> None: + with patch("apps.characters.tasks.process_character_generation_job.delay"): + response = auth_client.post( + "/api/v1/characters/generation-jobs/", + { + "name": "몽글", + "persona": "궁금한 게 많은 곰", + "personality_keywords": ["호기심 많은"], + }, + format="json", + ) + + assert response.status_code == 202 + job = CharacterGenerationJob.objects.get(job_id=response.json()["job_id"]) + assert job.personality_keywords == ["호기심많은"] + + +# 최근 24시간 안에 이미 3회 생성한 경우 429 응답 확인 @pytest.mark.django_db def test_generation_job_create_daily_limit_exceeded( auth_client: APIClient, user: User ) -> None: from apps.characters.models import ImgGenLog - for i in range(3): - ImgGenLog.objects.create(user=user, gen_cnt=i + 1) + now = timezone.now() + created_at_values = [ + now - timedelta(hours=3), + now - timedelta(hours=2), + now - timedelta(hours=1), + ] + for i, created_at in enumerate(created_at_values): + log = ImgGenLog.objects.create(user=user, gen_cnt=i + 1) + ImgGenLog.objects.filter(pk=log.pk).update(created_at=created_at) with patch("apps.characters.tasks.process_character_generation_job.delay"): response = auth_client.post( @@ -207,22 +234,35 @@ def test_generation_job_create_daily_limit_exceeded( format="json", ) assert response.status_code == 429 + data = response.json() + assert data["error"] == "DAILY_GENERATION_LIMIT_EXCEEDED" + assert "reset_at" in data + assert isinstance(data["retry_after_seconds"], int) + assert 20 * 60 * 60 < data["retry_after_seconds"] <= 21 * 60 * 60 -# 생성 횟수 조회 시 오늘의 used 와 일일 limit 을 반환하는지 확인 +# 생성 횟수 조회 시 최근 24시간의 used 와 limit 을 반환하는지 확인 @pytest.mark.django_db def test_generation_quota_returns_used_and_limit( auth_client: APIClient, user: User ) -> None: from apps.characters.models import ImgGenLog - ImgGenLog.objects.create(user=user, gen_cnt=1) + old_log = ImgGenLog.objects.create(user=user, gen_cnt=1) + ImgGenLog.objects.filter(pk=old_log.pk).update( + created_at=timezone.now() - timedelta(hours=25) + ) ImgGenLog.objects.create(user=user, gen_cnt=2) + ImgGenLog.objects.create(user=user, gen_cnt=3) response = auth_client.get("/api/v1/characters/generation-jobs/quota/") assert response.status_code == 200 - assert response.json() == {"used": 2, "limit": 3} + data = response.json() + assert data["used"] == 2 + assert data["limit"] == 3 + assert "reset_at" in data + assert isinstance(data["retry_after_seconds"], int) # 비로그인 상태에서 생성 횟수 조회 시 401 반환 확인