diff --git a/apps/characters/tasks.py b/apps/characters/tasks.py index 28f7c8c..95dfc30 100644 --- a/apps/characters/tasks.py +++ b/apps/characters/tasks.py @@ -4,6 +4,7 @@ import logging import time +from django.db import transaction from django.utils import timezone import httpx @@ -160,9 +161,9 @@ def process_character_generation_job( except CharacterGenerationJob.DoesNotExist: return - # 시작 전에 취소(FAILED)됐으면 처리하지 않고 환불 후 종료한다. + # 시작 전에 취소(FAILED)됐으면 처리하지 않고 종료한다. + # 환불(ImgGenLog 삭제)은 취소 뷰가 이미 처리하므로 여기서 하지 않는다. if job.status == CharacterGenerationJob.Status.FAILED: - _refund_daily_gen(img_gen_log_id) return job.status = CharacterGenerationJob.Status.IN_PROGRESS @@ -209,12 +210,6 @@ def process_character_generation_job( base_url=base_url, ai_job_id=ai_job_id, headers=headers ) - # 생성 도중 사용자가 취소(FAILED)했으면 결과를 폐기하고 환불 후 종료한다. - job.refresh_from_db(fields=["status"]) - if job.status == CharacterGenerationJob.Status.FAILED: - _refund_daily_gen(img_gen_log_id) - return - # compose 단계에서 버려지는 구조화 데이터(유저 원본 입력 + LLM raw 출력)를 # 감사 로그로 S3 에 보존한다. best-effort — 실패해도 생성 잡은 계속 진행한다. _save_generation_audit_log( @@ -227,25 +222,38 @@ def process_character_generation_job( elapsed_seconds=round(time.monotonic() - started, 2), ) - job.gen_img_url = result.get("image_url", "") - job.gen_img_object_key = result.get("gen_img_object_key", "") - job.appearance = result.get("appearance", "") - job.persona = _compose_persona(result) - job.status = CharacterGenerationJob.Status.SUCCEEDED - job.save( - update_fields=[ - "gen_img_url", - "gen_img_object_key", - "appearance", - "persona", - "status", - "updated_at", - ] - ) + # 저장은 잡 행을 잠그고 상태를 재확인한 뒤 수행한다. 취소 뷰와 직렬화돼, + # 폴링 직후~저장 사이에 취소가 들어오면 결과를 폐기한다(환불은 취소 뷰가 함). + with transaction.atomic(): + locked = CharacterGenerationJob.objects.select_for_update().get(pk=job_id) + if locked.status == CharacterGenerationJob.Status.FAILED: + return + + locked.gen_img_url = result.get("image_url", "") + locked.gen_img_object_key = result.get("gen_img_object_key", "") + locked.appearance = result.get("appearance", "") + locked.persona = _compose_persona(result) + locked.status = CharacterGenerationJob.Status.SUCCEEDED + locked.save( + update_fields=[ + "gen_img_url", + "gen_img_object_key", + "appearance", + "persona", + "status", + "updated_at", + ] + ) except Exception: logger.exception("character generation job failed: job_id=%s", job_id) - job.status = CharacterGenerationJob.Status.FAILED - job.save(update_fields=["status", "updated_at"]) - # 생성 실패 시 제출 시점에 차감했던 일일 생성 횟수를 환불한다(해당 로그만 삭제). - _refund_daily_gen(img_gen_log_id) + # 이미 사용자가 취소(FAILED)했으면 취소 뷰가 환불했으므로 중복 환불하지 않는다. + with transaction.atomic(): + locked = CharacterGenerationJob.objects.select_for_update().get(pk=job_id) + was_cancelled = locked.status == CharacterGenerationJob.Status.FAILED + if not was_cancelled: + locked.status = CharacterGenerationJob.Status.FAILED + locked.save(update_fields=["status", "updated_at"]) + if not was_cancelled: + # 생성 실패 시 제출 시점에 차감했던 일일 생성 횟수를 환불한다. + _refund_daily_gen(img_gen_log_id) diff --git a/apps/characters/views.py b/apps/characters/views.py index 3d4d13a..806af26 100644 --- a/apps/characters/views.py +++ b/apps/characters/views.py @@ -4,6 +4,7 @@ from urllib.parse import urlparse import uuid +from django.db import transaction from django.db.models import Count, Q from django.utils import timezone from rest_framework import status @@ -238,21 +239,45 @@ class GenerationJobCancelView(APIView): permission_classes = (IsAuthenticated,) def post(self, request: Request, job_id: uuid.UUID) -> Response: - try: - job = CharacterGenerationJob.objects.get(job_id=job_id, user=request.user) - except CharacterGenerationJob.DoesNotExist: - return Response({"error": "NOT_FOUND"}, status=status.HTTP_404_NOT_FOUND) + # 잡 행을 잠그고 상태 전환 + 환불을 원자적으로 처리한다. 실행 중인 태스크의 + # 성공 저장(select_for_update)과 직렬화돼 취소-환불/성공-유지 중 하나만 이긴다. + with transaction.atomic(): + try: + job = CharacterGenerationJob.objects.select_for_update().get( + job_id=job_id, user=request.user + ) + except CharacterGenerationJob.DoesNotExist: + return Response( + {"error": "NOT_FOUND"}, status=status.HTTP_404_NOT_FOUND + ) - if job.status not in ( - CharacterGenerationJob.Status.QUEUED, - CharacterGenerationJob.Status.IN_PROGRESS, - ): - return Response( - {"error": "JOB_NOT_CANCELABLE"}, status=status.HTTP_409_CONFLICT + if job.status not in ( + CharacterGenerationJob.Status.QUEUED, + CharacterGenerationJob.Status.IN_PROGRESS, + ): + return Response( + {"error": "JOB_NOT_CANCELABLE"}, status=status.HTTP_409_CONFLICT + ) + + 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() + ) ) + refund_id = ( + ImgGenLog.objects.filter(user=request.user, created_at__gte=today_start) + .order_by("-created_at") + .values_list("pk", flat=True) + .first() + ) + if refund_id is not None: + ImgGenLog.objects.filter(pk=refund_id).delete() - job.status = CharacterGenerationJob.Status.FAILED - job.save(update_fields=["status", "updated_at"]) return Response({"job_id": job.job_id, "status": job.status}) diff --git a/tests/test_characters.py b/tests/test_characters.py index e93087f..4389510 100644 --- a/tests/test_characters.py +++ b/tests/test_characters.py @@ -232,6 +232,62 @@ def test_generation_quota_unauthenticated() -> None: assert response.status_code == 401 +def _create_generation_job(auth_client: APIClient) -> str: + """생성 잡을 만들고(태스크 실행은 막음) job_id 를 반환한다.""" + 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 + return str(response.json()["job_id"]) + + +def _used(auth_client: APIClient) -> int: + return int( + auth_client.get("/api/v1/characters/generation-jobs/quota/").json()["used"] + ) + + +# 생성 중 취소하면 차감했던 일일 생성 횟수가 환불(ImgGenLog 삭제)되는지 확인 +@pytest.mark.django_db +def test_cancel_refunds_daily_generation_count( + auth_client: APIClient, user: User +) -> None: + from apps.characters.models import ImgGenLog + + job_id = _create_generation_job(auth_client) + assert _used(auth_client) == 1 # 제출 시 1회 차감 + + cancel = auth_client.post(f"/api/v1/characters/generation-jobs/{job_id}/cancel/") + + assert cancel.status_code == 200 + assert cancel.json()["status"] == "FAILED" + assert _used(auth_client) == 0 # 환불되어 0 + assert ImgGenLog.objects.filter(user=user).count() == 0 + + +# 이미 완료(SUCCEEDED)된 잡은 취소 불가(409)이고 횟수도 유지되는지 확인 +@pytest.mark.django_db +def test_cancel_rejected_and_count_kept_when_succeeded( + auth_client: APIClient, user: User +) -> None: + from apps.characters.models import ImgGenLog + + job_id = _create_generation_job(auth_client) + job = CharacterGenerationJob.objects.get(job_id=job_id) + job.status = CharacterGenerationJob.Status.SUCCEEDED + job.save(update_fields=["status"]) + + cancel = auth_client.post(f"/api/v1/characters/generation-jobs/{job_id}/cancel/") + + assert cancel.status_code == 409 + # 캐릭터가 만들어진 잡이므로 횟수는 유지되어야 한다. + assert _used(auth_client) == 1 + assert ImgGenLog.objects.filter(user=user).count() == 1 + + # 이미 활성 캐릭터가 10개인 경우 422 응답 확인 (캐릭터 최대 보유 수 초과) @pytest.mark.django_db def test_generation_job_create_character_limit_exceeded(