diff --git a/apps/characters/tasks.py b/apps/characters/tasks.py index 359d2cc..28f7c8c 100644 --- a/apps/characters/tasks.py +++ b/apps/characters/tasks.py @@ -137,6 +137,15 @@ def reset_image_gen_count() -> None: ImgGenLog.objects.filter(created_at__lt=today_start).delete() +def _refund_daily_gen(img_gen_log_id: int | None) -> None: + """제출 시점에 차감했던 일일 생성 횟수를 환불한다(해당 ImgGenLog 만 삭제).""" + if img_gen_log_id is None: + return + from apps.characters.models import ImgGenLog + + ImgGenLog.objects.filter(pk=img_gen_log_id).delete() + + @shared_task(bind=True, max_retries=3) def process_character_generation_job( self, job_id: str, name: str, persona: str, img_gen_log_id: int | None = None @@ -151,6 +160,11 @@ def process_character_generation_job( except CharacterGenerationJob.DoesNotExist: return + # 시작 전에 취소(FAILED)됐으면 처리하지 않고 환불 후 종료한다. + if job.status == CharacterGenerationJob.Status.FAILED: + _refund_daily_gen(img_gen_log_id) + return + job.status = CharacterGenerationJob.Status.IN_PROGRESS job.save(update_fields=["status", "updated_at"]) @@ -195,6 +209,12 @@ 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( @@ -228,7 +248,4 @@ def process_character_generation_job( job.status = CharacterGenerationJob.Status.FAILED job.save(update_fields=["status", "updated_at"]) # 생성 실패 시 제출 시점에 차감했던 일일 생성 횟수를 환불한다(해당 로그만 삭제). - if img_gen_log_id is not None: - from apps.characters.models import ImgGenLog - - ImgGenLog.objects.filter(pk=img_gen_log_id).delete() + _refund_daily_gen(img_gen_log_id) diff --git a/apps/characters/urls.py b/apps/characters/urls.py index 534cc26..5226733 100644 --- a/apps/characters/urls.py +++ b/apps/characters/urls.py @@ -3,6 +3,7 @@ from apps.characters.views import ( CharacterDetailView, CharacterListView, + GenerationJobCancelView, GenerationJobCreateView, GenerationJobDetailView, GenerationQuotaView, @@ -22,6 +23,11 @@ GenerationQuotaView.as_view(), name="generation-quota", ), + path( + "generation-jobs//cancel/", + GenerationJobCancelView.as_view(), + name="generation-job-cancel", + ), path( "generation-jobs//", GenerationJobDetailView.as_view(), diff --git a/apps/characters/views.py b/apps/characters/views.py index fd775cd..3d4d13a 100644 --- a/apps/characters/views.py +++ b/apps/characters/views.py @@ -227,6 +227,35 @@ def get(self, request: Request, job_id: uuid.UUID) -> Response: return Response(serializer.data) +class GenerationJobCancelView(APIView): + """생성 중인 Job을 취소한다(QUEUED/IN_PROGRESS만 가능). + + status 를 FAILED 로 표시(취소 신호)하면, 실행 중인 태스크가 체크포인트에서 + 결과를 폐기하고 제출 시점에 차감했던 일일 생성 횟수(ImgGenLog)를 환불한다. + 이미 SUCCEEDED/CONSUMED/FAILED 면 취소 불가(409). + """ + + 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) + + 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"]) + return Response({"job_id": job.job_id, "status": job.status}) + + class GenerationQuotaView(APIView): """오늘의 캐릭터 생성 횟수(used)와 일일 한도(limit)를 반환한다."""