Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 6 additions & 8 deletions apps/characters/management/commands/reset_limits.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

특정 유저의 두 가지 한도를 한 번에 푼다.
- 활성 캐릭터를 비활성화(soft-delete)해 캐릭터 생성 한도(MAX_ACTIVE_CHARACTERS)를 푼다.
- 최근 24시간 생성 로그(ImgGenLog)를 삭제해 생성 한도(MAX_DAILY_GEN)를 푼다.
(생성 실패도 로그가 쌓여 한도를 소모하므로, 실패 누적분도 함께 정리된다.)
- 오늘자 생성 로그(ImgGenLog)를 삭제해 하루 생성 한도(MAX_DAILY_GEN)를 푼다.
(생성 실패도 로그가 쌓여 하루 한도를 소모하므로, 실패 누적분도 함께 정리된다.)

사용:
python manage.py reset_limits --email [email protected]
Expand All @@ -13,7 +13,6 @@

from __future__ import annotations

from datetime import timedelta
from typing import Any

from django.contrib.auth import get_user_model
Expand All @@ -30,7 +29,7 @@


class Command(BaseCommand):
help = "특정 유저의 캐릭터/최근 24시간 생성 한도를 초기화한다(개발용)."
help = "특정 유저의 캐릭터/하루 생성 한도를 초기화한다(개발용)."

def add_arguments(self, parser: Any) -> None:
parser.add_argument("--email", required=True, help="초기화할 유저 이메일")
Expand All @@ -42,7 +41,7 @@ def add_arguments(self, parser: Any) -> None:
parser.add_argument(
"--daily-only",
action="store_true",
help="캐릭터는 건드리지 않고 최근 24시간 생성 한도만 초기화한다",
help="캐릭터는 건드리지 않고 오늘자 생성 한도만 초기화한다",
)

@transaction.atomic
Expand All @@ -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)
Expand All @@ -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
11 changes: 7 additions & 4 deletions apps/characters/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
81 changes: 31 additions & 50 deletions apps/characters/views.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

from datetime import datetime, timedelta
from datetime import timedelta
from urllib.parse import urlparse
import uuid

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
)

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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()
Expand All @@ -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):
Expand Down
54 changes: 7 additions & 47 deletions tests/test_characters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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 반환 확인
Expand Down
Loading