From 09407780afda0ad9e0d023637b5b09247f2eddd8 Mon Sep 17 00:00:00 2001 From: JeongSW123 <123189509+JeongSW123@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:37:54 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(characters):=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EC=B7=A8=EC=86=8C=20=EC=8B=9C=20=EC=9D=BC=EC=9D=BC=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=ED=9A=9F=EC=88=98=20=ED=99=98=EB=B6=88=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 취소 뷰가 status=FAILED 만 찍고 환불은 태스크 체크포인트에만 의존해, 워커 유실·폴링 직후 저장 구간 레이스·이미 종료된 잡 등에서 차감된 ImgGenLog 가 남아 생성 횟수가 잘못 올라가던 문제 수정. - CharacterGenerationJob 에 img_gen_log_id 추가(취소 뷰가 환불 대상 식별) - 취소 뷰: 잡 행 잠금(select_for_update) + ImgGenLog 즉시 삭제 - 태스크 성공 저장: 잡 잠금 후 상태 재확인 → 취소면 폐기·환불(레이스 차단) - 회귀 테스트: 취소→used 환불(0), 완료 잡 취소 409·횟수 유지 --- ...3_charactergenerationjob_img_gen_log_id.py | 17 ++++++ apps/characters/models.py | 2 + apps/characters/tasks.py | 45 ++++++++------- apps/characters/views.py | 42 +++++++++----- tests/test_characters.py | 56 +++++++++++++++++++ 5 files changed, 128 insertions(+), 34 deletions(-) create mode 100644 apps/characters/migrations/0013_charactergenerationjob_img_gen_log_id.py diff --git a/apps/characters/migrations/0013_charactergenerationjob_img_gen_log_id.py b/apps/characters/migrations/0013_charactergenerationjob_img_gen_log_id.py new file mode 100644 index 0000000..d514aa2 --- /dev/null +++ b/apps/characters/migrations/0013_charactergenerationjob_img_gen_log_id.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.14 on 2026-06-25 01:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("characters", "0012_remove_character_gen_img_object_key"), + ] + + operations = [ + migrations.AddField( + model_name="charactergenerationjob", + name="img_gen_log_id", + field=models.IntegerField(blank=True, null=True), + ), + ] diff --git a/apps/characters/models.py b/apps/characters/models.py index 31a26d0..dfd3158 100644 --- a/apps/characters/models.py +++ b/apps/characters/models.py @@ -61,6 +61,8 @@ class Status(models.TextChoices): persona = models.TextField(blank=True) # AI 생성 외형 묘사. 확정 등록(CHAR-004) 시 Character.visual 로 옮긴다. appearance = models.CharField(max_length=255, blank=True) + # 제출 시 차감한 일일 생성 횟수(ImgGenLog) 의 id. 취소 시 이 행을 삭제해 환불한다. + img_gen_log_id = models.IntegerField(null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/apps/characters/tasks.py b/apps/characters/tasks.py index 28f7c8c..7bb864c 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 @@ -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,21 +222,29 @@ 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: + _refund_daily_gen(img_gen_log_id) + 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) diff --git a/apps/characters/views.py b/apps/characters/views.py index 3d4d13a..352b390 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 @@ -194,6 +195,9 @@ def post(self, request: Request) -> Response: ) img_gen_log = ImgGenLog.objects.create(user=user, gen_cnt=daily_count + 1) + # 취소 시 취소 뷰가 직접 환불할 수 있도록 차감한 로그 id 를 잡에 연결한다. + job.img_gen_log_id = img_gen_log.img_gen_log_id + job.save(update_fields=["img_gen_log_id", "updated_at"]) process_character_generation_job.delay( str(job.job_id), @@ -238,21 +242,33 @@ 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"]) + # 제출 시 차감한 일일 생성 횟수를 즉시 환불한다(태스크 체크포인트에 의존하지 + # 않으므로 워커 유실·저장 구간 레이스에서도 횟수가 남지 않는다). + if job.img_gen_log_id is not None: + ImgGenLog.objects.filter(pk=job.img_gen_log_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( From d7fa48b115098a7cf472d78b3abcf6fb026ba80c Mon Sep 17 00:00:00 2001 From: JeongSW123 <123189509+JeongSW123@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:04:36 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(characters):=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EC=B7=A8=EC=86=8C=20=EC=8B=9C=20=EC=9D=BC=EC=9D=BC=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=ED=9A=9F=EC=88=98=20=ED=99=98=EB=B6=88=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...3_charactergenerationjob_img_gen_log_id.py | 17 -------------- apps/characters/models.py | 2 -- apps/characters/tasks.py | 23 +++++++++++-------- apps/characters/views.py | 23 +++++++++++++------ 4 files changed, 30 insertions(+), 35 deletions(-) delete mode 100644 apps/characters/migrations/0013_charactergenerationjob_img_gen_log_id.py diff --git a/apps/characters/migrations/0013_charactergenerationjob_img_gen_log_id.py b/apps/characters/migrations/0013_charactergenerationjob_img_gen_log_id.py deleted file mode 100644 index d514aa2..0000000 --- a/apps/characters/migrations/0013_charactergenerationjob_img_gen_log_id.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.2.14 on 2026-06-25 01:35 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("characters", "0012_remove_character_gen_img_object_key"), - ] - - operations = [ - migrations.AddField( - model_name="charactergenerationjob", - name="img_gen_log_id", - field=models.IntegerField(blank=True, null=True), - ), - ] diff --git a/apps/characters/models.py b/apps/characters/models.py index dfd3158..31a26d0 100644 --- a/apps/characters/models.py +++ b/apps/characters/models.py @@ -61,8 +61,6 @@ class Status(models.TextChoices): persona = models.TextField(blank=True) # AI 생성 외형 묘사. 확정 등록(CHAR-004) 시 Character.visual 로 옮긴다. appearance = models.CharField(max_length=255, blank=True) - # 제출 시 차감한 일일 생성 횟수(ImgGenLog) 의 id. 취소 시 이 행을 삭제해 환불한다. - img_gen_log_id = models.IntegerField(null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/apps/characters/tasks.py b/apps/characters/tasks.py index 7bb864c..95dfc30 100644 --- a/apps/characters/tasks.py +++ b/apps/characters/tasks.py @@ -161,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 @@ -222,12 +222,11 @@ def process_character_generation_job( elapsed_seconds=round(time.monotonic() - started, 2), ) - # 저장은 잡 행을 잠그고 상태를 재확인한 뒤 수행한다. 취소 뷰의 환불과 - # 직렬화돼, 폴링 직후~저장 사이에 취소가 들어와도 결과를 폐기하고 환불한다. + # 저장은 잡 행을 잠그고 상태를 재확인한 뒤 수행한다. 취소 뷰와 직렬화돼, + # 폴링 직후~저장 사이에 취소가 들어오면 결과를 폐기한다(환불은 취소 뷰가 함). with transaction.atomic(): locked = CharacterGenerationJob.objects.select_for_update().get(pk=job_id) if locked.status == CharacterGenerationJob.Status.FAILED: - _refund_daily_gen(img_gen_log_id) return locked.gen_img_url = result.get("image_url", "") @@ -248,7 +247,13 @@ def process_character_generation_job( 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 352b390..806af26 100644 --- a/apps/characters/views.py +++ b/apps/characters/views.py @@ -195,9 +195,6 @@ def post(self, request: Request) -> Response: ) img_gen_log = ImgGenLog.objects.create(user=user, gen_cnt=daily_count + 1) - # 취소 시 취소 뷰가 직접 환불할 수 있도록 차감한 로그 id 를 잡에 연결한다. - job.img_gen_log_id = img_gen_log.img_gen_log_id - job.save(update_fields=["img_gen_log_id", "updated_at"]) process_character_generation_job.delay( str(job.job_id), @@ -264,10 +261,22 @@ def post(self, request: Request, job_id: uuid.UUID) -> Response: job.status = CharacterGenerationJob.Status.FAILED job.save(update_fields=["status", "updated_at"]) - # 제출 시 차감한 일일 생성 횟수를 즉시 환불한다(태스크 체크포인트에 의존하지 - # 않으므로 워커 유실·저장 구간 레이스에서도 횟수가 남지 않는다). - if job.img_gen_log_id is not None: - ImgGenLog.objects.filter(pk=job.img_gen_log_id).delete() + # 제출 시 차감한 일일 생성 횟수를 즉시 환불한다. 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() return Response({"job_id": job.job_id, "status": job.status})