diff --git a/apps/characters/migrations/0013_add_appearance_payload.py b/apps/characters/migrations/0013_add_appearance_payload.py new file mode 100644 index 0000000..71eb6bc --- /dev/null +++ b/apps/characters/migrations/0013_add_appearance_payload.py @@ -0,0 +1,20 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("characters", "0012_remove_character_gen_img_object_key"), + ] + + operations = [ + migrations.AddField( + model_name="character", + name="appearance_payload", + field=models.JSONField(blank=True, null=True), + ), + migrations.AddField( + model_name="charactergenerationjob", + name="appearance_payload", + field=models.JSONField(blank=True, null=True), + ), + ] diff --git a/apps/characters/models.py b/apps/characters/models.py index 31a26d0..f9a8916 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) + # 이미지 워커가 반환한 정규화 외형. 사용자가 나중에 캐릭터를 확정할 때 복사한다. + appearance_payload = models.JSONField(null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -93,6 +95,8 @@ class Character(models.Model): gen_img_url = models.TextField() persona = models.TextField() visual = models.CharField(max_length=255, blank=True) + # 피드 생성 시 동일한 캐릭터 외형을 재현하기 위한 정규화 데이터. + appearance_payload = models.JSONField(null=True, blank=True) is_active = models.BooleanField(default=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 95dfc30..0dc32e3 100644 --- a/apps/characters/tasks.py +++ b/apps/characters/tasks.py @@ -232,6 +232,7 @@ def process_character_generation_job( 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.appearance_payload = result.get("appearance_payload") locked.persona = _compose_persona(result) locked.status = CharacterGenerationJob.Status.SUCCEEDED locked.save( @@ -239,6 +240,7 @@ def process_character_generation_job( "gen_img_url", "gen_img_object_key", "appearance", + "appearance_payload", "persona", "status", "updated_at", diff --git a/apps/characters/views.py b/apps/characters/views.py index 806af26..450433f 100644 --- a/apps/characters/views.py +++ b/apps/characters/views.py @@ -356,6 +356,7 @@ def post(self, request: Request) -> Response: gen_img_url=gen_img_object_key, persona=job.persona, visual=job.appearance, + appearance_payload=job.appearance_payload, ) job.status = CharacterGenerationJob.Status.CONSUMED diff --git a/apps/posts/tasks.py b/apps/posts/tasks.py index c7a05d3..8930c0d 100644 --- a/apps/posts/tasks.py +++ b/apps/posts/tasks.py @@ -39,6 +39,7 @@ def generate_feed_post(self, quest_id: str) -> None: "personality": character.persona, "speech_style": "", "appearance_keywords": [character.visual] if character.visual else [], + "appearance_payload": character.appearance_payload, # gen_img_url 은 S3 object key 라 AI fetch 용 presigned URL 로 변환. "image_url": _resolve_gen_img_url(character.gen_img_url), }, diff --git a/docs/api-character-generation.md b/docs/api-character-generation.md index 41610a8..f7a6600 100644 --- a/docs/api-character-generation.md +++ b/docs/api-character-generation.md @@ -201,7 +201,8 @@ sequenceDiagram 3. `CharacterGenerationJob` 조회(`gen_job_id`, 본인 소유). 없으면 `404 JOB_NOT_FOUND`. 4. `status==CONSUMED` → `409 JOB_ALREADY_CONSUMED` (한 잡은 한 번만 등록 가능). 5. `status!=SUCCEEDED` → `400 JOB_NOT_SUCCEEDED`. -6. `Character` 생성: `generation_job=job`, `character_name=name`, `gen_img_url=job.gen_img_url`, `persona=persona`, **`visual=job.custom_prompt`**(잡 생성 시 입력한 외형 묘사를 캐릭터로 보존). +6. `Character` 생성: `generation_job=job`, `character_name=name`, `gen_img_url=job.gen_img_url`, `persona=job.persona`, `visual=job.appearance`, `appearance_payload=job.appearance_payload`. + - 생성 완료와 확정 등록은 서로 다른 시점이므로, 구조화 외형 데이터는 먼저 Job에 보관했다가 이 단계에서 Character로 복사한다. 7. `job.status = CONSUMED`. 8. `201` 응답 + (권장) `Location: /api/v1/characters/:id`. @@ -243,8 +244,12 @@ sequenceDiagram - 헤더 `X-API-Key: ` (⚠️ Bearer 아님) - Body `{user_id, name, persona, personality_keywords, source_image_url}` - `timeout = 120초` -4. 응답 envelope에서 **`result`** 키 파싱(⚠️ `data` 아님): `result.image_url` → `job.gen_img_url`, `result.gen_img_object_key` → `job.gen_img_object_key`. -5. `status = SUCCEEDED` 저장. +4. 완료 응답 envelope에서 **`result`** 키 파싱(⚠️ `data` 아님): + - `result.image_url` → `job.gen_img_url` + - `result.gen_img_object_key` → `job.gen_img_object_key` + - `result.appearance` → `job.appearance` + - `result.appearance_payload` → `job.appearance_payload` +5. `status = SUCCEEDED` 저장. 사용자가 나중에 확정 등록할 때 Job의 외형 데이터를 Character로 복사한다. 6. 예외 발생 시 `status = FAILED`, `error_code/error_message` 저장. **mongle-ai 응답 예시** @@ -257,13 +262,20 @@ sequenceDiagram "persona": "...", "personality": "...", "speech_style": "...", - "background": "..." + "background": "...", + "appearance": "round brown bear with a red ribbon", + "appearance_payload": { + "character_type": "bear", + "main_colors": ["brown"], + "accessories": ["red ribbon"] + } }, "error": null } ``` - ⚠️ 개인화 필드(`personality`/`speech_style`/`background`)는 현재 **저장하지 않는다**(향후 챗봇 메모리 기능에서 도입 예정). +- `appearance_payload`는 프론트 표시용이 아니라 피드 이미지에서 동일 캐릭터를 재현하기 위한 내부 데이터다. 캐릭터 확정 후 `Character`에 보관하며, 이후 피드 생성 태스크가 `POST /v1/feed/generate`의 `character.appearance_payload`로 전달한다. - ⚠️ **운영 주의**: 콜드스타트 시 AI 응답이 `timeout(120초)`을 초과할 수 있다(RunPod LLM·이미지 워커 동시 cold). 첫 요청/유휴 후 첫 요청에서 `FAILED` 위험 → 워커 웜 유지 또는 timeout 상향 검토 필요. --- diff --git a/tests/test_characters.py b/tests/test_characters.py index 4389510..3e39396 100644 --- a/tests/test_characters.py +++ b/tests/test_characters.py @@ -378,6 +378,11 @@ def test_character_register_success( def test_character_register_saves_appearance_as_visual( auth_client: APIClient, user: User ) -> None: + appearance_payload = { + "character_type": "bear", + "main_colors": ["yellow"], + "accessories": [], + } job = CharacterGenerationJob.objects.create( user=user, personality_keywords=["활발한"], @@ -385,6 +390,7 @@ def test_character_register_saves_appearance_as_visual( gen_img_url="https://example.com/gen.png", persona="밝은 캐릭터", appearance="둥근 얼굴에 노란 털", + appearance_payload=appearance_payload, ) # 요청에 persona 를 보내도 무시되고, job 의 AI 정제본이 저장돼야 한다. response = auth_client.post( @@ -399,6 +405,7 @@ def test_character_register_saves_appearance_as_visual( assert response.status_code == 201 character = Character.objects.get(character_id=response.json()["character_id"]) assert character.visual == "둥근 얼굴에 노란 털" + assert character.appearance_payload == appearance_payload assert character.persona == "밝은 캐릭터" # 개인화 프로필(JSON)에 캐릭터 정보가 기록돼야 한다. @@ -871,10 +878,19 @@ def test_process_job_submits_polls_and_saves_appearance(user: User, settings) -> submit = _mock_response( {"status": "pending", "result": {"job_id": "ai-1"}, "error": None} ) + appearance_payload = { + "character_type": "bear", + "main_colors": ["brown"], + "accessories": [], + } poll = _mock_response( { "status": "done", - "result": {"image_url": "https://cdn/x.png", "appearance": "둥근 갈색 몸"}, + "result": { + "image_url": "https://cdn/x.png", + "appearance": "둥근 갈색 몸", + "appearance_payload": appearance_payload, + }, "error": None, } ) @@ -902,6 +918,7 @@ def test_process_job_submits_polls_and_saves_appearance(user: User, settings) -> assert job.status == "SUCCEEDED" assert job.gen_img_url == "https://cdn/x.png" assert job.appearance == "둥근 갈색 몸" + assert job.appearance_payload == appearance_payload # 생성 실패 시 제출 시점에 차감한 일일 생성 횟수(ImgGenLog)가 환불(삭제)되는지 확인 diff --git a/tests/test_posts.py b/tests/test_posts.py index 377d8a6..dd481c8 100644 --- a/tests/test_posts.py +++ b/tests/test_posts.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import timedelta +from unittest.mock import MagicMock, patch from django.utils import timezone import pytest @@ -16,6 +17,39 @@ from apps.users.models import TokenTransaction, User +@pytest.mark.django_db +def test_generate_feed_post_sends_appearance_payload( + character: Character, quest: Quest, settings +) -> None: + appearance_payload = { + "character_type": "bear", + "main_colors": ["brown"], + "accessories": ["red ribbon"], + } + character.appearance_payload = appearance_payload + character.save(update_fields=["appearance_payload", "updated_at"]) + settings.AI_SERVICE_URL = "http://ai.test" + settings.AI_SERVICE_TOKEN = "secret-key" # noqa: S105 + + response = MagicMock() + response.raise_for_status.return_value = None + response.json.return_value = { + "result": { + "image_url": "https://cdn.test/feed.png", + "caption": "오늘도 산책을 완료했어요.", + } + } + + with patch("apps.posts.tasks.httpx.post", return_value=response) as mock_post: + from apps.posts.tasks import generate_feed_post + + generate_feed_post.run(str(quest.quest_id)) + + payload = mock_post.call_args.kwargs["json"] + assert payload["character"]["appearance_payload"] == appearance_payload + assert Post.objects.filter(quest=quest).exists() + + def _make_other_user_post() -> Post: """다른 사용자 소유의 게시글을 하나 만든다(스코프 격리 검증용).""" other = User.objects.create_user(