From 68ecb136701aa03c58b7fa7e279c3bad0a488c80 Mon Sep 17 00:00:00 2001 From: "Junghee Im(Jion)" <109445934+bigmooon@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:37:48 +0900 Subject: [PATCH] Revert "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, 51 insertions(+), 109 deletions(-) diff --git a/apps/characters/management/commands/reset_limits.py b/apps/characters/management/commands/reset_limits.py index 625c05c..10ba979 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)를 푼다. -- 최근 24시간 생성 로그(ImgGenLog)를 삭제해 생성 한도(MAX_DAILY_GEN)를 푼다. - (생성 실패도 로그가 쌓여 한도를 소모하므로, 실패 누적분도 함께 정리된다.) +- 오늘자 생성 로그(ImgGenLog)를 삭제해 하루 생성 한도(MAX_DAILY_GEN)를 푼다. + (생성 실패도 로그가 쌓여 하루 한도를 소모하므로, 실패 누적분도 함께 정리된다.) 사용: python manage.py reset_limits --email demo@mongle.dev @@ -13,7 +13,6 @@ from __future__ import annotations -from datetime import timedelta from typing import Any from django.contrib.auth import get_user_model @@ -30,7 +29,7 @@ class Command(BaseCommand): - help = "특정 유저의 캐릭터/최근 24시간 생성 한도를 초기화한다(개발용)." + help = "특정 유저의 캐릭터/하루 생성 한도를 초기화한다(개발용)." def add_arguments(self, parser: Any) -> None: parser.add_argument("--email", required=True, help="초기화할 유저 이메일") @@ -42,7 +41,7 @@ def add_arguments(self, parser: Any) -> None: parser.add_argument( "--daily-only", action="store_true", - help="캐릭터는 건드리지 않고 최근 24시간 생성 한도만 초기화한다", + help="캐릭터는 건드리지 않고 오늘자 생성 한도만 초기화한다", ) @transaction.atomic @@ -65,7 +64,7 @@ def handle(self, *args: Any, **options: Any) -> None: self.stdout.write( f" 캐릭터 비활성화: {deactivated}개 → 현재 활성 {active_remaining}개" ) - self.stdout.write(f" 최근 24시간 생성 로그 삭제: {deleted_logs}개") + self.stdout.write(f" 오늘 생성 로그 삭제: {deleted_logs}개") def _reset_characters(self, email: str, keep_seed: bool) -> int: qs = Character.objects.filter(user__email=email, is_active=True) @@ -74,8 +73,7 @@ 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__gte=window_start + user__email=email, created_at__date=timezone.localdate() ).delete() return deleted diff --git a/apps/characters/tasks.py b/apps/characters/tasks.py index 28fced4..95dfc30 100644 --- a/apps/characters/tasks.py +++ b/apps/characters/tasks.py @@ -126,13 +126,16 @@ def _poll_character_result( @shared_task def reset_image_gen_count() -> None: """ - [스케줄] 오래된 이미지 생성 이력(ImgGenLog)을 삭제한다. - 계정당 최근 24시간 3회 제한이므로 윈도우 밖 로그는 카운트에 필요 없다. + [스케줄] 매일 자정 실행 + 오늘 이전 날짜의 이미지 재생성 이력(ImgGenLog)을 삭제하여 + 카운트를 초기화한다. 계정당 하루 3회 제한이며 다음날 다시 가능해진다. """ from apps.characters.models import ImgGenLog - window_start = timezone.now() - datetime.timedelta(hours=24) - ImgGenLog.objects.filter(created_at__lt=window_start).delete() + today_start = timezone.make_aware( + datetime.datetime.combine(timezone.localdate(), datetime.time.min) + ) + ImgGenLog.objects.filter(created_at__lt=today_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 547f0ac..806af26 100644 --- a/apps/characters/views.py +++ b/apps/characters/views.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import datetime, timedelta +from datetime import timedelta from urllib.parse import urlparse import uuid @@ -34,28 +34,6 @@ 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: @@ -166,14 +144,17 @@ def post(self, request: Request) -> Response: status=status.HTTP_422_UNPROCESSABLE_ENTITY, ) - generation_count, reset_at, retry_after_seconds = _generation_window(user) - if generation_count >= MAX_DAILY_GEN: + 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: return Response( - { - "error": "DAILY_GENERATION_LIMIT_EXCEEDED", - "reset_at": reset_at.isoformat() if reset_at else None, - "retry_after_seconds": retry_after_seconds, - }, + {"error": "DAILY_GENERATION_LIMIT_EXCEEDED"}, status=status.HTTP_429_TOO_MANY_REQUESTS, ) @@ -208,14 +189,12 @@ def post(self, request: Request) -> Response: job = CharacterGenerationJob.objects.create( user=user, source_image=source_image, - personality_keywords=_normalize_personality_keywords( - data["personality_keywords"] - ), + 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=generation_count + 1) + img_gen_log = ImgGenLog.objects.create(user=user, gen_cnt=daily_count + 1) process_character_generation_job.delay( str(job.job_id), @@ -282,14 +261,16 @@ def post(self, request: Request, job_id: uuid.UUID) -> Response: job.status = CharacterGenerationJob.Status.FAILED job.save(update_fields=["status", "updated_at"]) - # 제출 시 차감한 최근 24시간 생성 횟수를 즉시 환불한다. - # ImgGenLog 행은 카운터라 특정 행 식별 없이 최근 1행을 지운다. - # 잡 상태 전환이 잡당 1회 가드라 워커 유실·저장 레이스에도 안전. - window_start = timezone.now() - timedelta(hours=24) - refund_id = ( - ImgGenLog.objects.filter( - user=request.user, created_at__gte=window_start + # 제출 시 차감한 일일 생성 횟수를 즉시 환불한다. ImgGenLog 행은 카운터라 + # 특정 행을 식별할 필요 없이 오늘자 1행을 지운다(잡 상태 전환이 잡당 1회 + # 가드). 태스크 체크포인트에 의존하지 않아 워커 유실·저장 레이스에도 안전. + today_start = timezone.make_aware( + timezone.datetime.combine( + timezone.localdate(), timezone.datetime.min.time() ) + ) + refund_id = ( + ImgGenLog.objects.filter(user=request.user, created_at__gte=today_start) .order_by("-created_at") .values_list("pk", flat=True) .first() @@ -301,20 +282,20 @@ def post(self, request: Request, job_id: uuid.UUID) -> Response: class GenerationQuotaView(APIView): - """최근 24시간 캐릭터 생성 횟수(used)와 한도(limit)를 반환한다.""" + """오늘의 캐릭터 생성 횟수(used)와 일일 한도(limit)를 반환한다.""" permission_classes = (IsAuthenticated,) def get(self, request: Request) -> Response: - 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, - } + today_start = timezone.make_aware( + timezone.datetime.combine( + timezone.localdate(), timezone.datetime.min.time() + ) ) + 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 81b7e21..4389510 100644 --- a/tests/test_characters.py +++ b/tests/test_characters.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import date, timedelta +from datetime import date from unittest.mock import MagicMock, patch from django.test import override_settings @@ -190,42 +190,15 @@ def test_generation_job_create_success(auth_client: APIClient) -> None: assert "job_id" in data -@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 응답 확인 +# 오늘 이미 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 - 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) + for i in range(3): + ImgGenLog.objects.create(user=user, gen_cnt=i + 1) with patch("apps.characters.tasks.process_character_generation_job.delay"): response = auth_client.post( @@ -234,35 +207,22 @@ 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 -# 생성 횟수 조회 시 최근 24시간의 used 와 limit 을 반환하는지 확인 +# 생성 횟수 조회 시 오늘의 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 - 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=1) 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 - data = response.json() - assert data["used"] == 2 - assert data["limit"] == 3 - assert "reset_at" in data - assert isinstance(data["retry_after_seconds"], int) + assert response.json() == {"used": 2, "limit": 3} # 비로그인 상태에서 생성 횟수 조회 시 401 반환 확인