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
25 changes: 21 additions & 4 deletions apps/characters/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"])

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
6 changes: 6 additions & 0 deletions apps/characters/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from apps.characters.views import (
CharacterDetailView,
CharacterListView,
GenerationJobCancelView,
GenerationJobCreateView,
GenerationJobDetailView,
GenerationQuotaView,
Expand All @@ -22,6 +23,11 @@
GenerationQuotaView.as_view(),
name="generation-quota",
),
path(
"generation-jobs/<uuid:job_id>/cancel/",
GenerationJobCancelView.as_view(),
name="generation-job-cancel",
),
path(
"generation-jobs/<uuid:job_id>/",
GenerationJobDetailView.as_view(),
Expand Down
29 changes: 29 additions & 0 deletions apps/characters/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)를 반환한다."""

Expand Down
Loading