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
62 changes: 35 additions & 27 deletions apps/characters/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import logging
import time

from django.db import transaction
from django.utils import timezone
import httpx

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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)
49 changes: 37 additions & 12 deletions apps/characters/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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})


Expand Down
56 changes: 56 additions & 0 deletions tests/test_characters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading