From 3f405057147cd58bc294527ff7586e7746249a5e Mon Sep 17 00:00:00 2001 From: hamin Date: Tue, 9 Jun 2026 09:31:45 +0900 Subject: [PATCH 01/16] =?UTF-8?q?add=20::=20ENHANCED=20=EC=95=84=EB=B0=94?= =?UTF-8?q?=ED=83=80=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/avatar/dto/AvatarChangeResDto.kt | 2 +- .../domain/avatar/dto/CreateAvatarReqDto.kt | 2 +- .../avatar/dto/UpdateDefaultStyleReqDto.kt | 2 +- .../aikon/domain/avatar/entity/enum/Style.kt | 1 + .../service/AvatarImagePromptProvider.kt | 58 +++++++++++++++---- .../service/AvatarImagePromptProviderTest.kt | 55 ++++++++++++++++++ 6 files changed, 106 insertions(+), 14 deletions(-) create mode 100644 src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/AvatarImagePromptProviderTest.kt diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/AvatarChangeResDto.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/AvatarChangeResDto.kt index 790f8da..2fc508b 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/AvatarChangeResDto.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/AvatarChangeResDto.kt @@ -13,7 +13,7 @@ data class AvatarChangeResDto( val id: Long, @Schema(description = "닉네임", example = "홍길동") val nickname: String, - @Schema(description = "스타일 (STUDIO, ZOOTOPIA, TRADITIONAL_HANBOK, DISNEY_PIXAR, GHIBLI, LIGHT_ART)") + @Schema(description = "스타일 (STUDIO, ENHANCED, ZOOTOPIA, TRADITIONAL_HANBOK, DISNEY_PIXAR, GHIBLI, LIGHT_ART)") val style: Style, @Schema(description = "성별 (MALE, FEMALE)") val gender: Gender, diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/CreateAvatarReqDto.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/CreateAvatarReqDto.kt index 8d09e81..ef3f6d6 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/CreateAvatarReqDto.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/CreateAvatarReqDto.kt @@ -17,7 +17,7 @@ data class CreateAvatarReqDto( @Schema(description = "성별 (MALE, FEMALE)") @field:NotNull val gender: Gender?, - @Schema(description = "스타일 (STUDIO, ZOOTOPIA, TRADITIONAL_HANBOK, DISNEY_PIXAR, GHIBLI, LIGHT_ART)") + @Schema(description = "스타일 (STUDIO, ENHANCED, ZOOTOPIA, TRADITIONAL_HANBOK, DISNEY_PIXAR, GHIBLI, LIGHT_ART)") @field:NotNull val style: Style?, @Schema(description = "연령대 (AGE_0_7, AGE_8_13, AGE_14_19, AGE_20_PLUS)") diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/UpdateDefaultStyleReqDto.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/UpdateDefaultStyleReqDto.kt index ce0f4d4..5dec0f7 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/UpdateDefaultStyleReqDto.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/UpdateDefaultStyleReqDto.kt @@ -5,6 +5,6 @@ import team.darkmoderap.aikon.domain.avatar.entity.enum.Style @Schema(description = "기본 스타일 변경 요청") data class UpdateDefaultStyleReqDto( - @Schema(description = "스타일 (STUDIO, ZOOTOPIA, TRADITIONAL_HANBOK, DISNEY_PIXAR, GHIBLI, LIGHT_ART)") + @Schema(description = "스타일 (STUDIO, ENHANCED, ZOOTOPIA, TRADITIONAL_HANBOK, DISNEY_PIXAR, GHIBLI, LIGHT_ART)") val style: Style, ) diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/entity/enum/Style.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/entity/enum/Style.kt index 9cdc102..9314da0 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/entity/enum/Style.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/entity/enum/Style.kt @@ -2,6 +2,7 @@ package team.darkmoderap.aikon.domain.avatar.entity.enum enum class Style { STUDIO, + ENHANCED, ZOOTOPIA, TRADITIONAL_HANBOK, DISNEY_PIXAR, diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/AvatarImagePromptProvider.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/AvatarImagePromptProvider.kt index 4045088..03cd412 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/AvatarImagePromptProvider.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/AvatarImagePromptProvider.kt @@ -17,6 +17,7 @@ class AvatarImagePromptProvider { return when (style) { Style.STUDIO -> buildStudioPrompt(subject, ageHint) + Style.ENHANCED -> buildEnhancedPrompt(subject, ageHint) Style.ZOOTOPIA -> buildZootopiaPrompt(subject, ageHint) Style.TRADITIONAL_HANBOK -> buildHanbokPrompt(subject, gender, ageRange) Style.DISNEY_PIXAR -> buildPixarPrompt(subject, ageHint) @@ -83,9 +84,29 @@ class AvatarImagePromptProvider { 왼쪽 45도 각도에서 대형 소프트박스 조명을 비춰 얼굴에 부드럽고 입체적인 음영을 만들어줘. 85mm 인물 사진 렌즈 느낌으로 배경은 살짝 흐리게(얕은 심도) 처리하고, 눈동자에는 자연스러운 캐치라이트가 반사되도록 해줘. - 피부는 자연스러운 질감을 유지하면서 깔끔하게 보정된 느낌으로, + 구도는 얼굴과 상반신이 안정적으로 보이는 정면 또는 약간 사선의 프로필 구도로 잡아줘. + 피부는 자연스러운 질감을 유지하면서 깔끔하게 보정하되 과한 미백이나 인위적인 보정은 피하고, + 옷차림은 단정하고 고급스러운 모던 포트레이트 의상으로 정리해줘. 고급 패션 매거진이나 브랜드 화보 수준의 상업 사진 퀄리티로 표현해줘. - 인물의 얼굴형, 눈코입, 헤어스타일, 전체적인 생김새는 원본 사진과 최대한 동일하게 유지해줘. + 인물의 얼굴형, 눈코입, 헤어스타일, 표정, 전체적인 생김새는 원본 사진과 최대한 동일하게 유지해줘. + 손가락, 귀, 눈동자, 치아, 안경 같은 디테일이 어색하게 변형되지 않도록 자연스럽게 마무리해줘. + """.trimIndent() + + private fun buildEnhancedPrompt( + subject: String, + ageHint: String, + ): String = + """ + 첨부된 사진 속 $subject 특정 화풍이나 캐릭터로 바꾸지 말고, 원본 사진을 더 멋있고 예쁜 프리미엄 프로필 사진처럼 보정해줘. + $ageHint + 인물의 얼굴형, 눈코입, 헤어스타일, 표정, 나이대, 전체적인 생김새는 원본 사진과 최대한 동일하게 유지해줘. + 조명은 화려하지만 과하지 않은 시네마틱 소프트 조명으로 정리하고, + 피부톤은 자연스럽고 건강하게 보이도록 보정하되 실제 피부 질감은 유지해줘. + 색감은 선명하고 고급스럽게, 대비와 채도는 SNS 프로필이나 브랜드 화보처럼 세련되게 조정해줘. + 배경은 인물을 돋보이게 하는 감각적인 스튜디오 또는 도시적인 무드의 흐림 배경으로 구성해줘. + 눈빛과 표정은 더 또렷하고 자신감 있게 보이도록 살리고, + 전체 결과물은 원본 인물의 정체성을 유지한 화려한 고급 프로필/화보 사진 퀄리티로 완성해줘. + 애니메이션, 수채화, 동물 캐릭터, 전통 의상 등 별도의 스타일 변환은 하지 마. """.trimIndent() private fun buildZootopiaPrompt( @@ -97,11 +118,14 @@ class AvatarImagePromptProvider { $ageHint 인물의 전체적인 분위기, 표정, 헤어스타일을 참고해서 가장 잘 어울리는 동물 종을 선택하고, 쥬토피아 세계관에 맞는 세련되고 현대적인 의상을 입혀줘. + 원본 인물의 눈매, 얼굴 비율, 헤어 실루엣, 액세서리 특징은 동물 캐릭터 디자인 안에 자연스럽게 반영해줘. 배경은 다양한 동물들이 공존하는 활기차고 화려한 동물 도시(Zootopia 시내)로 설정하고, - 네온사인, 마천루, 분주한 거리 풍경을 담아줘. + 네온사인, 마천루, 분주한 거리 풍경을 담되 인물이 가장 먼저 보이도록 구도를 잡아줘. 색감은 밝고 채도가 높으며 생동감 넘치는 디즈니 애니메이션 특유의 스타일로, - 캐릭터의 표정과 눈빛은 원본 인물의 분위기와 감정을 그대로 반영해줘. + 털, 귀, 꼬리, 의상 소재는 부드럽고 입체감 있게 렌더링해줘. + 캐릭터의 표정과 눈빛은 원본 인물의 분위기와 감정을 그대로 반영하고, 전체 퀄리티는 디즈니 공식 영화 장면 수준으로 렌더링해줘. + 얼굴이 과도하게 바뀌거나 전혀 다른 캐릭터처럼 보이지 않게 원본과의 유사성을 유지해줘. """.trimIndent() private fun buildHanbokPrompt( @@ -115,8 +139,11 @@ class AvatarImagePromptProvider { ${buildHanbokHair(gender, ageRange)} 배경은 조선시대 궁궐 정원으로, 만개한 벚꽃나무, 석등, 기와 처마, 연못을 배치해줘. 조명은 따뜻한 황금빛 노을로 설정하고, + 한복의 비단 질감, 자수, 옷고름, 주름, 장신구를 섬세하고 고급스럽게 묘사해줘. + 구도는 인물이 중심이 되는 우아한 전신 또는 반신 초상화로 잡고, 전체적으로 고풍스럽고 우아한 한국 전통 회화 일러스트 스타일로 표현해줘. - 인물의 얼굴형과 생김새, 눈코입의 특징은 원본과 최대한 동일하게 유지해줘. + 인물의 얼굴형과 생김새, 눈코입, 헤어라인, 표정의 특징은 원본과 최대한 동일하게 유지해줘. + 장식 요소가 얼굴을 가리거나 원본 인물의 인상을 바꾸지 않도록 자연스럽게 배치해줘. """.trimIndent() private fun buildHanbokCostume( @@ -194,9 +221,13 @@ class AvatarImagePromptProvider { 전체적인 신체 비율은 픽사 특유의 살짝 과장된 귀여운 비율로 조정해줘. 피부에는 픽사 특유의 SSS(피부 투광) 효과를 적용해 생동감 있게 표현하고, 조명은 따뜻하고 부드러운 시네마틱 3점 조명으로 설정해줘. - 배경은 픽사 영화에 나올 법한 화려하고 디테일한 환경으로 구성하고, + 머리카락의 형태, 눈매, 미소, 얼굴 윤곽은 원본 인물을 알아볼 수 있게 캐릭터 디자인에 반영해줘. + 의상은 원본의 분위기를 참고해 밝고 세련된 3D 애니메이션 의상으로 재해석해줘. + 배경은 픽사 영화에 나올 법한 화려하고 디테일한 환경으로 구성하되 인물이 또렷하게 돋보이게 하고, + 눈동자 하이라이트, 피부 질감, 머리카락, 옷감의 재질감을 정교하게 렌더링해줘. 전체 퀄리티는 픽사 영화 공식 포스터 수준으로 렌더링해줘. - 인물의 헤어 색상, 얼굴 특징, 개성과 표정은 최대한 원본과 유사하게 유지해줘. + 인물의 헤어 색상, 얼굴 특징, 개성과 표정은 최대한 원본과 유사하게 유지하고, + 손, 치아, 눈동자 같은 디테일이 어색하게 뭉개지지 않도록 완성도를 높여줘. """.trimIndent() private fun buildGhibliPrompt( @@ -210,8 +241,10 @@ class AvatarImagePromptProvider { 배경은 지브리 영화에서 자주 등장하는 드넓은 초록 언덕, 고즈넉한 시골 마을, 또는 울창한 숲속으로 설정하고, 바람에 살랑이는 풀잎, 흐드러진 꽃밭, 멀리 보이는 구름과 하늘을 세밀하게 묘사해줘. 조명은 따뜻한 오후 햇살이 부드럽게 스며드는 느낌으로, - 전체적으로 평화롭고 따뜻하며 향수를 자극하는 지브리 특유의 감성을 담아줘. - 인물의 얼굴 생김새, 헤어스타일, 표정은 원본 사진을 기반으로 최대한 유사하게 유지해줘. + 인물 주변에는 자연스러운 바람감과 은은한 생활감이 느껴지는 작은 디테일을 더해줘. + 얼굴 윤곽, 눈매, 코와 입의 위치, 헤어스타일, 표정은 원본 사진을 기반으로 최대한 유사하게 유지해줘. + 전체적으로 평화롭고 따뜻하며 향수를 자극하는 지브리 특유의 감성을 담고, + 배경이 과하게 복잡해져 인물의 얼굴 인상이 흐려지지 않게 균형을 잡아줘. """.trimIndent() private fun buildLightArtPrompt( @@ -226,7 +259,10 @@ class AvatarImagePromptProvider { 윤곽선은 얇고 섬세한 잉크 선으로 처리하되, 일부는 의도적으로 흐릿하게 남겨줘. 밝은 부분은 종이 흰색을 살려 투명하게, 어두운 부분은 색층을 겹쳐 깊이감을 표현해줘. 배경은 꽃잎이나 빛 번짐 등 몽환적이고 감성적인 요소로 채워주고, - 전체적으로 갤러리에 걸릴 법한 섬세하고 아름다운 수채화 작품 느낌으로 완성해줘. - 인물의 얼굴 특징과 포즈는 원본 사진을 기반으로 유지해줘. + 종이 질감, 물 번짐, 가장자리의 부드러운 그라데이션이 자연스럽게 드러나게 해줘. + 인물의 눈매, 얼굴형, 헤어스타일, 표정, 포즈는 원본 사진을 기반으로 유지하고, + 얼굴의 핵심 특징은 흐릿하게 사라지지 않도록 선명한 초점으로 살려줘. + 전체적으로 갤러리에 걸릴 법한 섬세하고 아름다운 수채화 작품 느낌으로 완성하되, + 추상적인 장식이 과해져 인물 식별성이 낮아지지 않게 균형을 맞춰줘. """.trimIndent() } diff --git a/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/AvatarImagePromptProviderTest.kt b/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/AvatarImagePromptProviderTest.kt new file mode 100644 index 0000000..3d24ed2 --- /dev/null +++ b/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/AvatarImagePromptProviderTest.kt @@ -0,0 +1,55 @@ +package team.darkmoderap.aikon.domain.avatar.service + +import org.junit.jupiter.api.Assertions.assertAll +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import team.darkmoderap.aikon.domain.avatar.entity.enum.AgeRange +import team.darkmoderap.aikon.domain.avatar.entity.enum.Gender +import team.darkmoderap.aikon.domain.avatar.entity.enum.Style + +class AvatarImagePromptProviderTest { + private val avatarImagePromptProvider = AvatarImagePromptProvider() + + @Nested + @DisplayName("getPrompt 메서드는") + inner class GetPrompt { + @Test + @DisplayName("ENHANCED 스타일이면 원본 인물성을 유지한 화려한 프로필 보정 프롬프트를 반환한다") + fun `returns enhanced prompt`() { + // Given + val style = Style.ENHANCED + val gender = Gender.FEMALE + val ageRange = AgeRange.AGE_20_PLUS + + // When + val result = avatarImagePromptProvider.getPrompt(style, gender, ageRange) + + // Then + assertAll( + { assertTrue(result.contains("원본 사진을 더 멋있고 예쁜 프리미엄 프로필 사진처럼 보정해줘")) }, + { assertTrue(result.contains("전체적인 생김새는 원본 사진과 최대한 동일하게 유지해줘")) }, + { assertTrue(result.contains("SNS 프로필이나 브랜드 화보처럼 세련되게 조정해줘")) }, + { assertTrue(result.contains("화려한 고급 프로필/화보 사진 퀄리티")) }, + { assertTrue(result.contains("별도의 스타일 변환은 하지 마")) }, + ) + } + + @Test + @DisplayName("모든 스타일의 프롬프트를 반환한다") + fun `returns prompt for every style`() { + // Given + val gender = Gender.FEMALE + val ageRange = AgeRange.AGE_20_PLUS + + // When & Then + Style.entries.forEach { style -> + val result = avatarImagePromptProvider.getPrompt(style, gender, ageRange) + + assertFalse(result.isBlank()) + } + } + } +} From 8665cb1a7774dedcac41553c952330f1cea46e85 Mon Sep 17 00:00:00 2001 From: hamin Date: Tue, 9 Jun 2026 09:42:57 +0900 Subject: [PATCH 02/16] =?UTF-8?q?fix=20::=20=EC=95=84=EB=B0=94=ED=83=80=20?= =?UTF-8?q?=EC=8A=A4=ED=83=80=EC=9D=BC=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../aikon/domain/avatar/dto/AvatarChangeResDto.kt | 2 +- .../aikon/domain/avatar/dto/CreateAvatarReqDto.kt | 2 +- .../domain/avatar/dto/UpdateDefaultStyleReqDto.kt | 2 +- .../aikon/domain/avatar/entity/enum/Style.kt | 6 ++++++ .../avatar/service/AvatarImagePromptProviderTest.kt | 10 +++++----- 5 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/AvatarChangeResDto.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/AvatarChangeResDto.kt index 2fc508b..d301ae9 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/AvatarChangeResDto.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/AvatarChangeResDto.kt @@ -13,7 +13,7 @@ data class AvatarChangeResDto( val id: Long, @Schema(description = "닉네임", example = "홍길동") val nickname: String, - @Schema(description = "스타일 (STUDIO, ENHANCED, ZOOTOPIA, TRADITIONAL_HANBOK, DISNEY_PIXAR, GHIBLI, LIGHT_ART)") + @Schema(description = Style.SCHEMA_DESCRIPTION) val style: Style, @Schema(description = "성별 (MALE, FEMALE)") val gender: Gender, diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/CreateAvatarReqDto.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/CreateAvatarReqDto.kt index ef3f6d6..899e32c 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/CreateAvatarReqDto.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/CreateAvatarReqDto.kt @@ -17,7 +17,7 @@ data class CreateAvatarReqDto( @Schema(description = "성별 (MALE, FEMALE)") @field:NotNull val gender: Gender?, - @Schema(description = "스타일 (STUDIO, ENHANCED, ZOOTOPIA, TRADITIONAL_HANBOK, DISNEY_PIXAR, GHIBLI, LIGHT_ART)") + @Schema(description = Style.SCHEMA_DESCRIPTION) @field:NotNull val style: Style?, @Schema(description = "연령대 (AGE_0_7, AGE_8_13, AGE_14_19, AGE_20_PLUS)") diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/UpdateDefaultStyleReqDto.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/UpdateDefaultStyleReqDto.kt index 5dec0f7..264be9f 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/UpdateDefaultStyleReqDto.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/UpdateDefaultStyleReqDto.kt @@ -5,6 +5,6 @@ import team.darkmoderap.aikon.domain.avatar.entity.enum.Style @Schema(description = "기본 스타일 변경 요청") data class UpdateDefaultStyleReqDto( - @Schema(description = "스타일 (STUDIO, ENHANCED, ZOOTOPIA, TRADITIONAL_HANBOK, DISNEY_PIXAR, GHIBLI, LIGHT_ART)") + @Schema(description = Style.SCHEMA_DESCRIPTION) val style: Style, ) diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/entity/enum/Style.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/entity/enum/Style.kt index 9314da0..011c543 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/entity/enum/Style.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/entity/enum/Style.kt @@ -8,4 +8,10 @@ enum class Style { DISNEY_PIXAR, GHIBLI, LIGHT_ART, + ; + + companion object { + const val SCHEMA_DESCRIPTION = + "스타일 (STUDIO, ENHANCED, ZOOTOPIA, TRADITIONAL_HANBOK, DISNEY_PIXAR, GHIBLI, LIGHT_ART)" + } } diff --git a/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/AvatarImagePromptProviderTest.kt b/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/AvatarImagePromptProviderTest.kt index 3d24ed2..a8cb7de 100644 --- a/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/AvatarImagePromptProviderTest.kt +++ b/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/AvatarImagePromptProviderTest.kt @@ -29,11 +29,11 @@ class AvatarImagePromptProviderTest { // Then assertAll( - { assertTrue(result.contains("원본 사진을 더 멋있고 예쁜 프리미엄 프로필 사진처럼 보정해줘")) }, - { assertTrue(result.contains("전체적인 생김새는 원본 사진과 최대한 동일하게 유지해줘")) }, - { assertTrue(result.contains("SNS 프로필이나 브랜드 화보처럼 세련되게 조정해줘")) }, - { assertTrue(result.contains("화려한 고급 프로필/화보 사진 퀄리티")) }, - { assertTrue(result.contains("별도의 스타일 변환은 하지 마")) }, + { assertTrue(result.contains("프리미엄 프로필")) }, + { assertTrue(result.contains("동일하게 유지")) }, + { assertTrue(result.contains("세련되게 조정")) }, + { assertTrue(result.contains("고급 프로필/화보")) }, + { assertTrue(result.contains("스타일 변환은 하지 마")) }, ) } From 5bac5c78a41a28f80b5ae52faa30d10b6cde01a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=95=98=EB=AF=BC?= Date: Sun, 21 Jun 2026 14:55:45 +0900 Subject: [PATCH 03/16] =?UTF-8?q?add=20::=20FastAPI=20=EC=95=84=EB=B0=94?= =?UTF-8?q?=ED=83=80=20=EC=83=9D=EC=84=B1=20=EC=97=B0=EB=8F=99=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../InternalAiAvatarGenerationController.kt | 33 +++++++++ .../dto/AvatarGenerationCallbackReqDto.kt | 14 ++++ .../avatar/dto/FastApiGenerationReqDto.kt | 10 +++ .../avatar/dto/FastApiGenerationResDto.kt | 6 ++ .../domain/avatar/entity/AvatarEntity.kt | 38 +++++++++- .../avatar/service/AvatarImageStorage.kt | 8 +++ .../avatar/service/CreateAvatarServiceImpl.kt | 5 ++ .../avatar/service/DeleteAvatarServiceImpl.kt | 1 + .../service/GenerateAvatarImageServiceImpl.kt | 71 ++++++++++++------- .../HandleAvatarGenerationCallbackService.kt | 7 ++ ...ndleAvatarGenerationCallbackServiceImpl.kt | 51 +++++++++++++ .../avatar/service/S3AvatarImageStorage.kt | 39 ++++++++++ .../aikon/global/client/FastApiClient.kt | 49 +++++++++++++ .../aikon/global/common/error/ErrorCode.kt | 5 ++ .../error/handler/GlobalExceptionHandler.kt | 8 +++ .../aikon/global/config/RestClientConfig.kt | 18 +++++ src/main/resources/application.yml | 7 ++ 17 files changed, 344 insertions(+), 26 deletions(-) create mode 100644 src/main/kotlin/team/darkmoderap/aikon/domain/avatar/controller/InternalAiAvatarGenerationController.kt create mode 100644 src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/AvatarGenerationCallbackReqDto.kt create mode 100644 src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/FastApiGenerationReqDto.kt create mode 100644 src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/FastApiGenerationResDto.kt create mode 100644 src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/HandleAvatarGenerationCallbackService.kt create mode 100644 src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/HandleAvatarGenerationCallbackServiceImpl.kt create mode 100644 src/main/kotlin/team/darkmoderap/aikon/global/client/FastApiClient.kt create mode 100644 src/main/kotlin/team/darkmoderap/aikon/global/config/RestClientConfig.kt diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/controller/InternalAiAvatarGenerationController.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/controller/InternalAiAvatarGenerationController.kt new file mode 100644 index 0000000..67c98cb --- /dev/null +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/controller/InternalAiAvatarGenerationController.kt @@ -0,0 +1,33 @@ +package team.darkmoderap.aikon.domain.avatar.controller + +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import team.darkmoderap.aikon.domain.avatar.dto.AvatarGenerationCallbackReqDto +import team.darkmoderap.aikon.domain.avatar.service.HandleAvatarGenerationCallbackService +import team.darkmoderap.aikon.global.common.error.AikonException +import team.darkmoderap.aikon.global.common.error.ErrorCode + +@RestController +@RequestMapping("/internal/ai/avatar-generations") +class InternalAiAvatarGenerationController( + private val handleAvatarGenerationCallbackService: HandleAvatarGenerationCallbackService, + @Value("\${internal.callback-secret}") private val callbackSecret: String, +) { + @PostMapping("/callback") + @ResponseStatus(HttpStatus.OK) + fun callback( + @RequestHeader("X-Internal-Secret") secret: String, + @RequestBody reqDto: AvatarGenerationCallbackReqDto, + ) { + if (secret != callbackSecret) { + throw AikonException(ErrorCode.INVALID_INTERNAL_SECRET) + } + handleAvatarGenerationCallbackService.execute(reqDto) + } +} diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/AvatarGenerationCallbackReqDto.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/AvatarGenerationCallbackReqDto.kt new file mode 100644 index 0000000..5ed827b --- /dev/null +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/AvatarGenerationCallbackReqDto.kt @@ -0,0 +1,14 @@ +package team.darkmoderap.aikon.domain.avatar.dto + +data class AvatarGenerationCallbackReqDto( + val avatarId: Long, + val jobId: String, + val status: String, + val generatedImageUri: String?, + val modelName: String?, + val promptVersion: String?, + val promptText: String?, + val durationMs: Long?, + val errorCode: String?, + val errorMessage: String?, +) diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/FastApiGenerationReqDto.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/FastApiGenerationReqDto.kt new file mode 100644 index 0000000..11ac911 --- /dev/null +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/FastApiGenerationReqDto.kt @@ -0,0 +1,10 @@ +package team.darkmoderap.aikon.domain.avatar.dto + +data class FastApiGenerationReqDto( + val avatarId: Long, + val sourceImageUri: String, + val style: String, + val gender: String, + val ageRange: String, + val callbackUrl: String, +) diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/FastApiGenerationResDto.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/FastApiGenerationResDto.kt new file mode 100644 index 0000000..09b703b --- /dev/null +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/FastApiGenerationResDto.kt @@ -0,0 +1,6 @@ +package team.darkmoderap.aikon.domain.avatar.dto + +data class FastApiGenerationResDto( + val jobId: String, + val status: String, +) diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/entity/AvatarEntity.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/entity/AvatarEntity.kt index 91e1f6b..80145b4 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/entity/AvatarEntity.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/entity/AvatarEntity.kt @@ -13,6 +13,7 @@ import team.darkmoderap.aikon.domain.avatar.entity.enum.Gender import team.darkmoderap.aikon.domain.avatar.entity.enum.GenerationStatus import team.darkmoderap.aikon.domain.avatar.entity.enum.Style import team.darkmoderap.aikon.global.common.entity.BaseEntity +import java.time.Instant @Entity @Table(name = "avatars") @@ -35,6 +36,22 @@ class AvatarEntity( var imageUrl: String? = null, @Column(name = "pass_url", unique = true) var passUrl: String? = null, + @Column(name = "ai_job_id", length = 36) + var aiJobId: String? = null, + @Column(name = "source_image_uri", length = 500) + var sourceImageUri: String? = null, + @Column(name = "model_name", length = 100) + var modelName: String? = null, + @Column(name = "prompt_version", length = 20) + var promptVersion: String? = null, + @Column(name = "prompt_text", columnDefinition = "TEXT") + var promptText: String? = null, + @Column(name = "duration_ms") + var durationMs: Long? = null, + @Column(name = "error_code", length = 50) + var errorCode: String? = null, + @Column(name = "completed_at") + var completedAt: Instant? = null, @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long = 0, @@ -49,12 +66,29 @@ class AvatarEntity( this.ageRange = ageRange } - fun completeGeneration(imageUrl: String) { + fun completeGeneration( + imageUrl: String, + modelName: String?, + promptVersion: String?, + promptText: String?, + durationMs: Long?, + completedAt: Instant?, + ) { this.imageUrl = imageUrl this.generationStatus = GenerationStatus.COMPLETED + this.modelName = modelName + this.promptVersion = promptVersion + this.promptText = promptText + this.durationMs = durationMs + this.completedAt = completedAt } - fun failGeneration() { + fun failGeneration( + errorCode: String?, + completedAt: Instant?, + ) { this.generationStatus = GenerationStatus.FAILED + this.errorCode = errorCode + this.completedAt = completedAt } } diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/AvatarImageStorage.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/AvatarImageStorage.kt index bddbbbd..4d91986 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/AvatarImageStorage.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/AvatarImageStorage.kt @@ -6,5 +6,13 @@ interface AvatarImageStorage { image: GeneratedAvatarImage, ): String + fun uploadSourceImage( + avatarId: Long, + bytes: ByteArray, + mimeType: String, + ): String + + fun toPublicUrl(s3Uri: String): String + fun delete(imageUrl: String) } diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/CreateAvatarServiceImpl.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/CreateAvatarServiceImpl.kt index 99b546c..0bfa462 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/CreateAvatarServiceImpl.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/CreateAvatarServiceImpl.kt @@ -9,6 +9,7 @@ import team.darkmoderap.aikon.domain.avatar.dto.CreateAvatarReqDto import team.darkmoderap.aikon.domain.avatar.dto.CreateAvatarResDto import team.darkmoderap.aikon.domain.avatar.entity.AvatarEntity import team.darkmoderap.aikon.domain.avatar.entity.enum.GenerationStatus +import team.darkmoderap.aikon.domain.avatar.entity.enum.Style import team.darkmoderap.aikon.domain.avatar.event.AvatarCreatedEvent import team.darkmoderap.aikon.domain.avatar.event.AvatarListChangedEvent import team.darkmoderap.aikon.domain.avatar.repository.AvatarRepository @@ -31,6 +32,10 @@ class CreateAvatarServiceImpl( val gender = reqDto.gender ?: throw AikonException(ErrorCode.INVALID_INPUT_VALUE) val style = reqDto.style ?: throw AikonException(ErrorCode.INVALID_INPUT_VALUE) val ageRange = reqDto.ageRange ?: throw AikonException(ErrorCode.INVALID_INPUT_VALUE) + + if (style == Style.ENHANCED) { + throw AikonException(ErrorCode.AVATAR_STYLE_NOT_SUPPORTED_BY_AI) + } val avatar = try { avatarRepository.saveAndFlush( diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/DeleteAvatarServiceImpl.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/DeleteAvatarServiceImpl.kt index f736911..5b95c82 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/DeleteAvatarServiceImpl.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/DeleteAvatarServiceImpl.kt @@ -31,6 +31,7 @@ class DeleteAvatarServiceImpl( } deleteImageIfExists(avatar.imageUrl) + deleteImageIfExists(avatar.sourceImageUri) avatarRepository.delete(avatar) eventPublisher.publishEvent(AvatarListChangedEvent()) } diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/GenerateAvatarImageServiceImpl.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/GenerateAvatarImageServiceImpl.kt index e25a6c0..4bb9f94 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/GenerateAvatarImageServiceImpl.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/GenerateAvatarImageServiceImpl.kt @@ -1,26 +1,39 @@ package team.darkmoderap.aikon.domain.avatar.service +import jakarta.annotation.PostConstruct import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value import org.springframework.context.ApplicationEventPublisher import org.springframework.data.repository.findByIdOrNull import org.springframework.scheduling.annotation.Async import org.springframework.stereotype.Service import org.springframework.transaction.support.TransactionTemplate +import team.darkmoderap.aikon.domain.avatar.dto.FastApiGenerationReqDto import team.darkmoderap.aikon.domain.avatar.event.AvatarListChangedEvent import team.darkmoderap.aikon.domain.avatar.repository.AvatarRepository +import team.darkmoderap.aikon.global.client.FastApiClient import team.darkmoderap.aikon.global.common.error.AikonException import team.darkmoderap.aikon.global.common.error.ErrorCode +import java.time.Instant @Service class GenerateAvatarImageServiceImpl( private val avatarRepository: AvatarRepository, - private val avatarImageGenerator: AvatarImageGenerator, private val avatarImageStorage: AvatarImageStorage, + private val fastApiClient: FastApiClient, private val eventPublisher: ApplicationEventPublisher, private val transactionTemplate: TransactionTemplate, + @Value("\${aikon.api-base-url}") private val apiBaseUrl: String, ) : GenerateAvatarImageService { private val logger = LoggerFactory.getLogger(javaClass) + @PostConstruct + fun validateApiBaseUrl() { + require(apiBaseUrl.isNotBlank() && (apiBaseUrl.startsWith("http://") || apiBaseUrl.startsWith("https://"))) { + "aikon.api-base-url must be an absolute HTTP/HTTPS URL" + } + } + @Async override fun execute( avatarId: Long, @@ -31,35 +44,45 @@ class GenerateAvatarImageServiceImpl( avatarRepository.findByIdOrNull(avatarId) } ?: throw AikonException(ErrorCode.AVATAR_NOT_FOUND) - val result = + val sourceImageUri = runCatching { - val generatedImage = - avatarImageGenerator.generate( - AvatarImageGenerationCommand( - style = avatar.style, - gender = avatar.gender, - ageRange = avatar.ageRange, - sourceImage = sourceImage.bytes, - sourceMimeType = sourceImage.mimeType, - ), - ) - avatarImageStorage.upload(avatarId, generatedImage) + avatarImageStorage.uploadSourceImage(avatarId, sourceImage.bytes, sourceImage.mimeType) + }.getOrElse { exception -> + logger.error("Failed to upload source image for avatar {}", avatarId, exception) + transactionTemplate.executeWithoutResult { + avatarRepository.findByIdOrNull(avatarId)?.failGeneration(null, Instant.now()) + } + eventPublisher.publishEvent(AvatarListChangedEvent()) + return } transactionTemplate.executeWithoutResult { - val avatar = - avatarRepository.findByIdOrNull(avatarId) - ?: throw AikonException(ErrorCode.AVATAR_NOT_FOUND) + avatarRepository.findByIdOrNull(avatarId)?.let { it.sourceImageUri = sourceImageUri } + } - result.fold( - onSuccess = { imageUrl -> avatar.completeGeneration(imageUrl) }, - onFailure = { exception -> - logger.error("Failed to generate avatar image for avatar {}", avatarId, exception) - avatar.failGeneration() - }, + val callbackUrl = "$apiBaseUrl/internal/ai/avatar-generations/callback" + + val reqDto = + FastApiGenerationReqDto( + avatarId = avatarId, + sourceImageUri = sourceImageUri, + style = avatar.style.name, + gender = avatar.gender.name, + ageRange = avatar.ageRange.name, + callbackUrl = callbackUrl, ) - } - eventPublisher.publishEvent(AvatarListChangedEvent()) + runCatching { + val res = fastApiClient.requestAvatarGeneration(reqDto) + transactionTemplate.executeWithoutResult { + avatarRepository.findByIdOrNull(avatarId)?.let { it.aiJobId = res.jobId } + } + }.onFailure { exception -> + logger.error("Failed to request avatar generation to FastAPI for avatar {}", avatarId, exception) + transactionTemplate.executeWithoutResult { + avatarRepository.findByIdOrNull(avatarId)?.failGeneration(null, Instant.now()) + } + eventPublisher.publishEvent(AvatarListChangedEvent()) + } } } diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/HandleAvatarGenerationCallbackService.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/HandleAvatarGenerationCallbackService.kt new file mode 100644 index 0000000..053a8b4 --- /dev/null +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/HandleAvatarGenerationCallbackService.kt @@ -0,0 +1,7 @@ +package team.darkmoderap.aikon.domain.avatar.service + +import team.darkmoderap.aikon.domain.avatar.dto.AvatarGenerationCallbackReqDto + +interface HandleAvatarGenerationCallbackService { + fun execute(reqDto: AvatarGenerationCallbackReqDto) +} diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/HandleAvatarGenerationCallbackServiceImpl.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/HandleAvatarGenerationCallbackServiceImpl.kt new file mode 100644 index 0000000..1cbbcb5 --- /dev/null +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/HandleAvatarGenerationCallbackServiceImpl.kt @@ -0,0 +1,51 @@ +package team.darkmoderap.aikon.domain.avatar.service + +import org.springframework.context.ApplicationEventPublisher +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import team.darkmoderap.aikon.domain.avatar.dto.AvatarGenerationCallbackReqDto +import team.darkmoderap.aikon.domain.avatar.event.AvatarListChangedEvent +import team.darkmoderap.aikon.domain.avatar.repository.AvatarRepository +import team.darkmoderap.aikon.global.common.error.AikonException +import team.darkmoderap.aikon.global.common.error.ErrorCode +import java.time.Instant + +@Service +class HandleAvatarGenerationCallbackServiceImpl( + private val avatarRepository: AvatarRepository, + private val avatarImageStorage: AvatarImageStorage, + private val eventPublisher: ApplicationEventPublisher, +) : HandleAvatarGenerationCallbackService { + @Transactional + override fun execute(reqDto: AvatarGenerationCallbackReqDto) { + val avatar = + avatarRepository.findByIdOrNull(reqDto.avatarId) + ?: throw AikonException(ErrorCode.AVATAR_NOT_FOUND) + + if (reqDto.jobId != avatar.aiJobId) { + throw AikonException(ErrorCode.INVALID_AVATAR_JOB_ID) + } + + val completedAt = Instant.now() + + if (reqDto.status == "COMPLETED" && reqDto.generatedImageUri != null) { + val imageUrl = avatarImageStorage.toPublicUrl(reqDto.generatedImageUri) + avatar.completeGeneration( + imageUrl = imageUrl, + modelName = reqDto.modelName, + promptVersion = reqDto.promptVersion, + promptText = reqDto.promptText, + durationMs = reqDto.durationMs, + completedAt = completedAt, + ) + } else { + avatar.failGeneration( + errorCode = reqDto.errorCode, + completedAt = completedAt, + ) + } + + eventPublisher.publishEvent(AvatarListChangedEvent()) + } +} diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/S3AvatarImageStorage.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/S3AvatarImageStorage.kt index b0b658e..6ebfd1d 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/S3AvatarImageStorage.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/S3AvatarImageStorage.kt @@ -42,6 +42,40 @@ class S3AvatarImageStorage( } } + override fun uploadSourceImage( + avatarId: Long, + bytes: ByteArray, + mimeType: String, + ): String { + if (bucket.isBlank()) { + throw AikonException(ErrorCode.AVATAR_IMAGE_GENERATION_FAILED) + } + + val ext = mimeType.toExtension() + val key = "sources/$avatarId.$ext" + val request = + PutObjectRequest + .builder() + .bucket(bucket) + .key(key) + .contentType(mimeType) + .build() + + s3Client.putObject(request, RequestBody.fromBytes(bytes)) + + return "s3://$bucket/$key" + } + + override fun toPublicUrl(s3Uri: String): String { + val s3Prefix = "s3://$bucket/" + val key = s3Uri.removePrefix(s3Prefix) + return if (publicBaseUrl.isBlank()) { + "https://$bucket.s3.$region.amazonaws.com/$key" + } else { + "${publicBaseUrl.trimEnd('/')}/$key" + } + } + override fun delete(imageUrl: String) { if (bucket.isBlank()) { throw AikonException(ErrorCode.AVATAR_IMAGE_DELETE_FAILED) @@ -61,6 +95,11 @@ class S3AvatarImageStorage( } private fun extractKey(imageUrl: String): String? { + val s3Prefix = "s3://$bucket/" + if (imageUrl.startsWith(s3Prefix)) { + return imageUrl.removePrefix(s3Prefix) + } + val normalizedPublicBaseUrl = publicBaseUrl.trimEnd('/') if (normalizedPublicBaseUrl.isNotBlank() && imageUrl.startsWith("$normalizedPublicBaseUrl/")) { return imageUrl.removePrefix("$normalizedPublicBaseUrl/") diff --git a/src/main/kotlin/team/darkmoderap/aikon/global/client/FastApiClient.kt b/src/main/kotlin/team/darkmoderap/aikon/global/client/FastApiClient.kt new file mode 100644 index 0000000..2b62db0 --- /dev/null +++ b/src/main/kotlin/team/darkmoderap/aikon/global/client/FastApiClient.kt @@ -0,0 +1,49 @@ +package team.darkmoderap.aikon.global.client + +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import org.springframework.web.client.RestClient +import team.darkmoderap.aikon.domain.avatar.dto.FastApiFeedbackReqDto +import team.darkmoderap.aikon.domain.avatar.dto.FastApiGenerationReqDto +import team.darkmoderap.aikon.domain.avatar.dto.FastApiGenerationResDto +import team.darkmoderap.aikon.global.common.error.AikonException +import team.darkmoderap.aikon.global.common.error.ErrorCode +import java.util.concurrent.CompletableFuture + +@Component +class FastApiClient( + private val fastApiRestClient: RestClient, +) { + private val logger = LoggerFactory.getLogger(javaClass) + + fun requestAvatarGeneration(reqDto: FastApiGenerationReqDto): FastApiGenerationResDto { + try { + return fastApiRestClient + .post() + .uri("/ai/avatar-generations") + .body(reqDto) + .retrieve() + .body(FastApiGenerationResDto::class.java) + ?: throw AikonException(ErrorCode.FASTAPI_REQUEST_FAILED) + } catch (e: AikonException) { + throw e + } catch (e: Exception) { + throw AikonException(ErrorCode.FASTAPI_REQUEST_FAILED, cause = e) + } + } + + fun sendFeedback(reqDto: FastApiFeedbackReqDto) { + CompletableFuture.runAsync { + try { + fastApiRestClient + .post() + .uri("/ai/feedbacks") + .body(reqDto) + .retrieve() + .toBodilessEntity() + } catch (e: Exception) { + logger.warn("Failed to send feedback to FastAPI for avatar {}", reqDto.avatarId, e) + } + } + } +} diff --git a/src/main/kotlin/team/darkmoderap/aikon/global/common/error/ErrorCode.kt b/src/main/kotlin/team/darkmoderap/aikon/global/common/error/ErrorCode.kt index 9be73a4..8b8384a 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/global/common/error/ErrorCode.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/global/common/error/ErrorCode.kt @@ -13,5 +13,10 @@ enum class ErrorCode( AVATAR_PASS_CODE_ASSIGNMENT_FAILED("아바타 패스 코드 배정에 실패하였습니다.", 409), AVATAR_IMAGE_GENERATION_FAILED("아바타 이미지 생성에 실패하였습니다.", 502), AVATAR_IMAGE_DELETE_FAILED("아바타 이미지 삭제에 실패하였습니다.", 502), + AVATAR_STYLE_NOT_SUPPORTED_BY_AI("해당 스타일은 현재 AI 서버에서 지원되지 않습니다.", 422), + FASTAPI_REQUEST_FAILED("AI 서버 요청에 실패하였습니다.", 502), + INVALID_INTERNAL_SECRET("내부 인증에 실패하였습니다.", 401), + INVALID_AVATAR_JOB_ID("잘못된 AI 작업 식별자입니다.", 400), + AVATAR_NOT_COMPLETED("아바타 생성이 완료되지 않은 상태입니다.", 409), SSE_MAX_CONNECTIONS_EXCEEDED("SSE 최대 연결 수를 초과하였습니다.", 429), } diff --git a/src/main/kotlin/team/darkmoderap/aikon/global/common/error/handler/GlobalExceptionHandler.kt b/src/main/kotlin/team/darkmoderap/aikon/global/common/error/handler/GlobalExceptionHandler.kt index d49a5e6..bedb346 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/global/common/error/handler/GlobalExceptionHandler.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/global/common/error/handler/GlobalExceptionHandler.kt @@ -6,6 +6,7 @@ import org.springframework.http.converter.HttpMessageNotReadableException import org.springframework.validation.BindException import org.springframework.web.HttpRequestMethodNotSupportedException import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.MissingRequestHeaderException import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.RestControllerAdvice import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException @@ -87,6 +88,13 @@ class GlobalExceptionHandler { return createErrorResponse(ErrorCode.INVALID_INPUT_VALUE) } + @ExceptionHandler(MissingRequestHeaderException::class) + fun handleMissingRequestHeaderException(exception: MissingRequestHeaderException): ResponseEntity { + logger.warn("Handled missing request header exception {}", exception.message) + + return createErrorResponse(ErrorCode.INVALID_INPUT_VALUE) + } + @ExceptionHandler(HttpRequestMethodNotSupportedException::class) fun handleHttpRequestMethodNotSupportedException(exception: HttpRequestMethodNotSupportedException): ResponseEntity { logger.warn("Handled method not supported exception {}", exception.message) diff --git a/src/main/kotlin/team/darkmoderap/aikon/global/config/RestClientConfig.kt b/src/main/kotlin/team/darkmoderap/aikon/global/config/RestClientConfig.kt new file mode 100644 index 0000000..a4056e0 --- /dev/null +++ b/src/main/kotlin/team/darkmoderap/aikon/global/config/RestClientConfig.kt @@ -0,0 +1,18 @@ +package team.darkmoderap.aikon.global.config + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.client.RestClient + +@Configuration +class RestClientConfig { + @Bean + fun fastApiRestClient( + @Value("\${fastapi.base-url}") baseUrl: String, + ): RestClient = + RestClient + .builder() + .baseUrl(baseUrl) + .build() +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 259a4eb..97b7ebc 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -13,7 +13,14 @@ springdoc: swagger-ui: path: /swagger-ui.html +fastapi: + base-url: ${FASTAPI_BASE_URL:http://localhost:8000} + +internal: + callback-secret: ${INTERNAL_CALLBACK_SECRET:dev-secret} + aikon: + api-base-url: ${API_BASE_URL:} frontend: public-base-url: ${FRONTEND_PUBLIC_BASE_URL:} sse: From 964335281b4ce1cf6ec21e225c480b2d198a12d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=95=98=EB=AF=BC?= Date: Sun, 21 Jun 2026 14:55:53 +0900 Subject: [PATCH 04/16] =?UTF-8?q?add=20::=20=ED=94=BC=EB=93=9C=EB=B0=B1=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../avatar/controller/AvatarController.kt | 26 ++++++ .../domain/avatar/dto/CreateFeedbackReqDto.kt | 18 +++++ .../avatar/dto/FastApiFeedbackReqDto.kt | 16 ++++ .../domain/avatar/entity/AvatarFeedback.kt | 61 ++++++++++++++ .../avatar/entity/enum/FeedbackRating.kt | 6 ++ .../avatar/entity/enum/FeedbackReason.kt | 12 +++ .../repository/AvatarFeedbackRepository.kt | 8 ++ .../avatar/service/CreateFeedbackService.kt | 10 +++ .../service/CreateFeedbackServiceImpl.kt | 80 +++++++++++++++++++ 9 files changed, 237 insertions(+) create mode 100644 src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/CreateFeedbackReqDto.kt create mode 100644 src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/FastApiFeedbackReqDto.kt create mode 100644 src/main/kotlin/team/darkmoderap/aikon/domain/avatar/entity/AvatarFeedback.kt create mode 100644 src/main/kotlin/team/darkmoderap/aikon/domain/avatar/entity/enum/FeedbackRating.kt create mode 100644 src/main/kotlin/team/darkmoderap/aikon/domain/avatar/entity/enum/FeedbackReason.kt create mode 100644 src/main/kotlin/team/darkmoderap/aikon/domain/avatar/repository/AvatarFeedbackRepository.kt create mode 100644 src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/CreateFeedbackService.kt create mode 100644 src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/CreateFeedbackServiceImpl.kt diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/controller/AvatarController.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/controller/AvatarController.kt index 10c08b5..c5849fc 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/controller/AvatarController.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/controller/AvatarController.kt @@ -32,10 +32,12 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter import team.darkmoderap.aikon.domain.avatar.dto.AvatarChangeResDto import team.darkmoderap.aikon.domain.avatar.dto.CreateAvatarReqDto import team.darkmoderap.aikon.domain.avatar.dto.CreateAvatarResDto +import team.darkmoderap.aikon.domain.avatar.dto.CreateFeedbackReqDto import team.darkmoderap.aikon.domain.avatar.dto.GetAvatarResDto import team.darkmoderap.aikon.domain.avatar.dto.UpdateAvatarReqDto import team.darkmoderap.aikon.domain.avatar.dto.UpdateDefaultStyleReqDto import team.darkmoderap.aikon.domain.avatar.service.CreateAvatarService +import team.darkmoderap.aikon.domain.avatar.service.CreateFeedbackService import team.darkmoderap.aikon.domain.avatar.service.DeleteAllAvatarsService import team.darkmoderap.aikon.domain.avatar.service.DeleteAvatarService import team.darkmoderap.aikon.domain.avatar.service.GetAvatarByPassService @@ -59,6 +61,7 @@ class AvatarController( private val updateDefaultStyleService: UpdateDefaultStyleService, private val deleteAvatarService: DeleteAvatarService, private val deleteAllAvatarsService: DeleteAllAvatarsService, + private val createFeedbackService: CreateFeedbackService, private val validator: Validator, private val objectMapper: ObjectMapper, ) { @@ -209,6 +212,29 @@ class AvatarController( deleteAllAvatarsService.execute() } + @Operation(summary = "아바타 피드백 등록") + @ApiResponses( + ApiResponse(responseCode = "201", description = "피드백 등록 성공"), + ApiResponse( + responseCode = "400", + description = "잘못된 입력값", + content = [Content(schema = Schema(implementation = ErrorResponse::class))], + ), + ApiResponse( + responseCode = "404", + description = "아바타를 찾을 수 없음", + content = [Content(schema = Schema(implementation = ErrorResponse::class))], + ), + ) + @PostMapping("/{avatarId}/feedback") + @ResponseStatus(HttpStatus.CREATED) + fun createFeedback( + @PathVariable avatarId: Long, + @Valid @RequestBody reqDto: CreateFeedbackReqDto, + ) { + createFeedbackService.execute(avatarId, reqDto) + } + private fun parseCreateAvatarReqDto(rawReqDto: String): CreateAvatarReqDto { val reqDto = try { diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/CreateFeedbackReqDto.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/CreateFeedbackReqDto.kt new file mode 100644 index 0000000..c6108c6 --- /dev/null +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/CreateFeedbackReqDto.kt @@ -0,0 +1,18 @@ +package team.darkmoderap.aikon.domain.avatar.dto + +import jakarta.validation.constraints.NotEmpty +import jakarta.validation.constraints.NotNull +import team.darkmoderap.aikon.domain.avatar.entity.enum.FeedbackRating +import team.darkmoderap.aikon.domain.avatar.entity.enum.FeedbackReason + +data class CreateFeedbackReqDto( + @field:NotNull + val rating: FeedbackRating?, + @field:NotEmpty + val reasons: List?, + val comment: String?, + @field:NotNull + val trainingConsent: Boolean?, + @field:NotNull + val feedbackUseConsent: Boolean?, +) diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/FastApiFeedbackReqDto.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/FastApiFeedbackReqDto.kt new file mode 100644 index 0000000..bdd6ea1 --- /dev/null +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/dto/FastApiFeedbackReqDto.kt @@ -0,0 +1,16 @@ +package team.darkmoderap.aikon.domain.avatar.dto + +data class FastApiFeedbackReqDto( + val avatarId: Long, + val jobId: String?, + val rating: String, + val reasons: List, + val comment: String?, + val trainingConsent: Boolean, + val feedbackUseConsent: Boolean, + val style: String, + val gender: String, + val ageRange: String, + val promptVersion: String?, + val modelName: String?, +) diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/entity/AvatarFeedback.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/entity/AvatarFeedback.kt new file mode 100644 index 0000000..139c2b8 --- /dev/null +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/entity/AvatarFeedback.kt @@ -0,0 +1,61 @@ +package team.darkmoderap.aikon.domain.avatar.entity + +import jakarta.persistence.CollectionTable +import jakarta.persistence.Column +import jakarta.persistence.ElementCollection +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint +import team.darkmoderap.aikon.domain.avatar.entity.enum.FeedbackRating +import team.darkmoderap.aikon.domain.avatar.entity.enum.FeedbackReason +import team.darkmoderap.aikon.global.common.entity.BaseEntity + +@Entity +@Table( + name = "avatar_feedbacks", + uniqueConstraints = [UniqueConstraint(columnNames = ["avatar_id"])], +) +class AvatarFeedback( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "avatar_id", nullable = false) + val avatar: AvatarEntity, + @Enumerated(EnumType.STRING) + @Column(nullable = false) + var rating: FeedbackRating, + @ElementCollection + @CollectionTable(name = "avatar_feedback_reasons", joinColumns = [JoinColumn(name = "feedback_id")]) + @Enumerated(EnumType.STRING) + @Column(name = "reason", nullable = false) + var reasons: List, + @Column(length = 500) + var comment: String? = null, + @Column(name = "training_consent", nullable = false) + var trainingConsent: Boolean = false, + @Column(name = "feedback_use_consent", nullable = false) + var feedbackUseConsent: Boolean = false, + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0, +) : BaseEntity() { + fun update( + rating: FeedbackRating, + reasons: List, + comment: String?, + trainingConsent: Boolean, + feedbackUseConsent: Boolean, + ) { + this.rating = rating + this.reasons = reasons + this.comment = comment + this.trainingConsent = trainingConsent + this.feedbackUseConsent = feedbackUseConsent + } +} diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/entity/enum/FeedbackRating.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/entity/enum/FeedbackRating.kt new file mode 100644 index 0000000..afb019f --- /dev/null +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/entity/enum/FeedbackRating.kt @@ -0,0 +1,6 @@ +package team.darkmoderap.aikon.domain.avatar.entity.enum + +enum class FeedbackRating { + LIKE, + DISLIKE, +} diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/entity/enum/FeedbackReason.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/entity/enum/FeedbackReason.kt new file mode 100644 index 0000000..136e0a7 --- /dev/null +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/entity/enum/FeedbackReason.kt @@ -0,0 +1,12 @@ +package team.darkmoderap.aikon.domain.avatar.entity.enum + +enum class FeedbackReason { + FACE_SIMILARITY, + STYLE_MATCH, + BACKGROUND, + DETAIL_BROKEN, + AGE_MISMATCH, + TOO_DIFFERENT, + GOOD_QUALITY, + BAD_QUALITY, +} diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/repository/AvatarFeedbackRepository.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/repository/AvatarFeedbackRepository.kt new file mode 100644 index 0000000..d8da3a4 --- /dev/null +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/repository/AvatarFeedbackRepository.kt @@ -0,0 +1,8 @@ +package team.darkmoderap.aikon.domain.avatar.repository + +import org.springframework.data.jpa.repository.JpaRepository +import team.darkmoderap.aikon.domain.avatar.entity.AvatarFeedback + +interface AvatarFeedbackRepository : JpaRepository { + fun findByAvatarId(avatarId: Long): AvatarFeedback? +} diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/CreateFeedbackService.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/CreateFeedbackService.kt new file mode 100644 index 0000000..144ad07 --- /dev/null +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/CreateFeedbackService.kt @@ -0,0 +1,10 @@ +package team.darkmoderap.aikon.domain.avatar.service + +import team.darkmoderap.aikon.domain.avatar.dto.CreateFeedbackReqDto + +interface CreateFeedbackService { + fun execute( + avatarId: Long, + reqDto: CreateFeedbackReqDto, + ) +} diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/CreateFeedbackServiceImpl.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/CreateFeedbackServiceImpl.kt new file mode 100644 index 0000000..87808fa --- /dev/null +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/CreateFeedbackServiceImpl.kt @@ -0,0 +1,80 @@ +package team.darkmoderap.aikon.domain.avatar.service + +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import team.darkmoderap.aikon.domain.avatar.dto.CreateFeedbackReqDto +import team.darkmoderap.aikon.domain.avatar.dto.FastApiFeedbackReqDto +import team.darkmoderap.aikon.domain.avatar.entity.AvatarFeedback +import team.darkmoderap.aikon.domain.avatar.entity.enum.GenerationStatus +import team.darkmoderap.aikon.domain.avatar.repository.AvatarFeedbackRepository +import team.darkmoderap.aikon.domain.avatar.repository.AvatarRepository +import team.darkmoderap.aikon.global.client.FastApiClient +import team.darkmoderap.aikon.global.common.error.AikonException +import team.darkmoderap.aikon.global.common.error.ErrorCode + +@Service +class CreateFeedbackServiceImpl( + private val avatarRepository: AvatarRepository, + private val avatarFeedbackRepository: AvatarFeedbackRepository, + private val fastApiClient: FastApiClient, +) : CreateFeedbackService { + @Transactional + override fun execute( + avatarId: Long, + reqDto: CreateFeedbackReqDto, + ) { + val avatar = + avatarRepository.findByIdOrNull(avatarId) + ?: throw AikonException(ErrorCode.AVATAR_NOT_FOUND) + + if (avatar.generationStatus != GenerationStatus.COMPLETED || avatar.imageUrl == null) { + throw AikonException(ErrorCode.AVATAR_NOT_COMPLETED) + } + + val rating = reqDto.rating ?: throw AikonException(ErrorCode.INVALID_INPUT_VALUE) + val reasons = reqDto.reasons ?: throw AikonException(ErrorCode.INVALID_INPUT_VALUE) + val trainingConsent = reqDto.trainingConsent ?: throw AikonException(ErrorCode.INVALID_INPUT_VALUE) + val feedbackUseConsent = reqDto.feedbackUseConsent ?: throw AikonException(ErrorCode.INVALID_INPUT_VALUE) + + val existing = avatarFeedbackRepository.findByAvatarId(avatarId) + if (existing != null) { + existing.update( + rating = rating, + reasons = reasons, + comment = reqDto.comment, + trainingConsent = trainingConsent, + feedbackUseConsent = feedbackUseConsent, + ) + } else { + avatarFeedbackRepository.save( + AvatarFeedback( + avatar = avatar, + rating = rating, + reasons = reasons, + comment = reqDto.comment, + trainingConsent = trainingConsent, + feedbackUseConsent = feedbackUseConsent, + ), + ) + } + + val fastApiReqDto = + FastApiFeedbackReqDto( + avatarId = avatarId, + jobId = avatar.aiJobId, + rating = rating.name, + reasons = reasons.map { it.name }, + comment = reqDto.comment, + trainingConsent = trainingConsent, + feedbackUseConsent = feedbackUseConsent, + style = avatar.style.name, + gender = avatar.gender.name, + ageRange = avatar.ageRange.name, + promptVersion = avatar.promptVersion, + modelName = avatar.modelName, + ) + + fastApiClient.sendFeedback(fastApiReqDto) + } +} From 876298047e64881943ed733e21af78d99e662344 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=95=98=EB=AF=BC?= Date: Sun, 21 Jun 2026 14:56:00 +0900 Subject: [PATCH 05/16] =?UTF-8?q?test=20::=20FastAPI=20=EC=97=B0=EB=8F=99?= =?UTF-8?q?=20=EB=B0=8F=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../avatar/controller/AvatarControllerTest.kt | 16 +- ...nternalAiAvatarGenerationControllerTest.kt | 114 ++++++++++ .../service/CreateFeedbackServiceImplTest.kt | 198 ++++++++++++++++++ .../GenerateAvatarImageServiceImplTest.kt | 94 +++++---- ...AvatarGenerationCallbackServiceImplTest.kt | 165 +++++++++++++++ 5 files changed, 546 insertions(+), 41 deletions(-) create mode 100644 src/test/kotlin/team/darkmoderap/aikon/domain/avatar/controller/InternalAiAvatarGenerationControllerTest.kt create mode 100644 src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/CreateFeedbackServiceImplTest.kt create mode 100644 src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/HandleAvatarGenerationCallbackServiceImplTest.kt diff --git a/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/controller/AvatarControllerTest.kt b/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/controller/AvatarControllerTest.kt index 965f53b..f5b7537 100644 --- a/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/controller/AvatarControllerTest.kt +++ b/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/controller/AvatarControllerTest.kt @@ -33,6 +33,7 @@ import team.darkmoderap.aikon.domain.avatar.entity.enum.Gender import team.darkmoderap.aikon.domain.avatar.entity.enum.GenerationStatus import team.darkmoderap.aikon.domain.avatar.entity.enum.Style import team.darkmoderap.aikon.domain.avatar.service.CreateAvatarService +import team.darkmoderap.aikon.domain.avatar.service.CreateFeedbackService import team.darkmoderap.aikon.domain.avatar.service.DeleteAllAvatarsService import team.darkmoderap.aikon.domain.avatar.service.DeleteAvatarService import team.darkmoderap.aikon.domain.avatar.service.GetAvatarByPassService @@ -53,6 +54,7 @@ class AvatarControllerTest { private val updateDefaultStyleService = mock(UpdateDefaultStyleService::class.java) private val deleteAvatarService = mock(DeleteAvatarService::class.java) private val deleteAllAvatarsService = mock(DeleteAllAvatarsService::class.java) + private val createFeedbackService = mock(CreateFeedbackService::class.java) private val mockMvc: MockMvc = MockMvcBuilders @@ -66,6 +68,7 @@ class AvatarControllerTest { updateDefaultStyleService, deleteAvatarService, deleteAllAvatarsService, + createFeedbackService, Validation.buildDefaultValidatorFactory().validator, jacksonObjectMapper(), ), @@ -83,16 +86,19 @@ class AvatarControllerTest { .`when`(createAvatarService.execute(anyCreateReqDto(), anyImage())) .thenReturn(CreateAvatarResDto(id = AVATAR_ID, generationStatus = GenerationStatus.PROCESSING)) - // When & Then - mockMvc - .perform( + // When + val result = + mockMvc.perform( multipart("/avatars") .file(reqDtoPart()) .file(imagePart()), - ).andExpect(status().isCreated) + ) + + // Then + result + .andExpect(status().isCreated) .andExpect(jsonPath("$.id").value(AVATAR_ID)) .andExpect(jsonPath("$.generationStatus").value("PROCESSING")) - verify(createAvatarService).execute(anyCreateReqDto(), anyImage()) } diff --git a/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/controller/InternalAiAvatarGenerationControllerTest.kt b/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/controller/InternalAiAvatarGenerationControllerTest.kt new file mode 100644 index 0000000..2e62b0f --- /dev/null +++ b/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/controller/InternalAiAvatarGenerationControllerTest.kt @@ -0,0 +1,114 @@ +package team.darkmoderap.aikon.domain.avatar.controller + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import team.darkmoderap.aikon.domain.avatar.dto.AvatarGenerationCallbackReqDto +import team.darkmoderap.aikon.domain.avatar.service.HandleAvatarGenerationCallbackService +import team.darkmoderap.aikon.global.common.error.handler.GlobalExceptionHandler + +class InternalAiAvatarGenerationControllerTest { + private val handleAvatarGenerationCallbackService = mock(HandleAvatarGenerationCallbackService::class.java) + private val callbackSecret = "test-secret" + private val objectMapper = jacksonObjectMapper() + + private val mockMvc: MockMvc = + MockMvcBuilders + .standaloneSetup( + InternalAiAvatarGenerationController( + handleAvatarGenerationCallbackService, + callbackSecret, + ), + ).setControllerAdvice(GlobalExceptionHandler()) + .build() + + @Nested + @DisplayName("POST /internal/ai/avatar-generations/callback 는") + inner class Callback { + @Test + @DisplayName("올바른 시크릿이면 200을 반환하고 서비스를 호출한다") + fun `returns 200 and calls service when secret is valid`() { + // Given + val reqDto = callbackReqDto() + + // When + val result = + mockMvc.perform( + post("/internal/ai/avatar-generations/callback") + .header("X-Internal-Secret", callbackSecret) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(reqDto)), + ) + + // Then + result.andExpect(status().isOk) + verify(handleAvatarGenerationCallbackService).execute(reqDto) + } + + @Test + @DisplayName("시크릿이 일치하지 않으면 401을 반환하고 서비스를 호출하지 않는다") + fun `returns 401 and does not call service when secret is invalid`() { + // Given + val reqDto = callbackReqDto() + + // When + val result = + mockMvc.perform( + post("/internal/ai/avatar-generations/callback") + .header("X-Internal-Secret", "wrong-secret") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(reqDto)), + ) + + // Then + result.andExpect(status().isUnauthorized) + verify(handleAvatarGenerationCallbackService, never()).execute(reqDto) + } + + @Test + @DisplayName("X-Internal-Secret 헤더가 없으면 400을 반환한다") + fun `returns 400 when X-Internal-Secret header is missing`() { + // Given + val reqDto = callbackReqDto() + + // When + val result = + mockMvc.perform( + post("/internal/ai/avatar-generations/callback") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(reqDto)), + ) + + // Then + result.andExpect(status().isBadRequest) + } + } + + companion object { + private const val AVATAR_ID = 1L + private const val JOB_ID = "test-job-id" + + private fun callbackReqDto(): AvatarGenerationCallbackReqDto = + AvatarGenerationCallbackReqDto( + avatarId = AVATAR_ID, + jobId = JOB_ID, + status = "COMPLETED", + generatedImageUri = "s3://bucket/avatars/1.png", + modelName = "gemini-2.5-flash", + promptVersion = "v1", + promptText = "test prompt", + durationMs = 3000, + errorCode = null, + errorMessage = null, + ) + } +} diff --git a/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/CreateFeedbackServiceImplTest.kt b/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/CreateFeedbackServiceImplTest.kt new file mode 100644 index 0000000..bf166e8 --- /dev/null +++ b/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/CreateFeedbackServiceImplTest.kt @@ -0,0 +1,198 @@ +package team.darkmoderap.aikon.domain.avatar.service + +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.ArgumentMatchers.any +import org.mockito.BDDMockito.given +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.junit.jupiter.MockitoExtension +import team.darkmoderap.aikon.domain.avatar.dto.CreateFeedbackReqDto +import team.darkmoderap.aikon.domain.avatar.dto.FastApiFeedbackReqDto +import team.darkmoderap.aikon.domain.avatar.entity.AvatarEntity +import team.darkmoderap.aikon.domain.avatar.entity.AvatarFeedback +import team.darkmoderap.aikon.domain.avatar.entity.enum.AgeRange +import team.darkmoderap.aikon.domain.avatar.entity.enum.FeedbackRating +import team.darkmoderap.aikon.domain.avatar.entity.enum.FeedbackReason +import team.darkmoderap.aikon.domain.avatar.entity.enum.Gender +import team.darkmoderap.aikon.domain.avatar.entity.enum.GenerationStatus +import team.darkmoderap.aikon.domain.avatar.entity.enum.Style +import team.darkmoderap.aikon.domain.avatar.repository.AvatarFeedbackRepository +import team.darkmoderap.aikon.domain.avatar.repository.AvatarRepository +import team.darkmoderap.aikon.global.client.FastApiClient +import team.darkmoderap.aikon.global.common.error.AikonException +import team.darkmoderap.aikon.global.common.error.ErrorCode +import java.util.Optional + +@ExtendWith(MockitoExtension::class) +class CreateFeedbackServiceImplTest { + @Mock + private lateinit var avatarRepository: AvatarRepository + + @Mock + private lateinit var avatarFeedbackRepository: AvatarFeedbackRepository + + @Mock + private lateinit var fastApiClient: FastApiClient + + @InjectMocks + private lateinit var createFeedbackService: CreateFeedbackServiceImpl + + @Nested + @DisplayName("execute 메서드는") + inner class Execute { + @Test + @DisplayName("생성 완료된 아바타에 피드백을 저장하고 FastAPI로 전송한다") + fun `saves feedback and sends to fastapi when avatar is completed`() { + // Given + val avatar = completedAvatar() + given(avatarRepository.findById(AVATAR_ID)).willReturn(Optional.of(avatar)) + given(avatarFeedbackRepository.findByAvatarId(AVATAR_ID)).willReturn(null) + given(avatarFeedbackRepository.save(anyAvatarFeedback())).willReturn( + AvatarFeedback( + avatar = avatar, + rating = FeedbackRating.LIKE, + reasons = listOf(FeedbackReason.FACE_SIMILARITY), + ), + ) + + // When + createFeedbackService.execute(AVATAR_ID, reqDto()) + + // Then + verify(avatarFeedbackRepository).save(anyAvatarFeedback()) + verify(fastApiClient).sendFeedback(anyFastApiFeedbackReqDto()) + } + + @Test + @DisplayName("이미 피드백이 존재하면 업데이트한다") + fun `updates existing feedback when feedback already exists`() { + // Given + val avatar = completedAvatar() + val existing = existingFeedback(avatar) + given(avatarRepository.findById(AVATAR_ID)).willReturn(Optional.of(avatar)) + given(avatarFeedbackRepository.findByAvatarId(AVATAR_ID)).willReturn(existing) + + // When + createFeedbackService.execute(AVATAR_ID, reqDto()) + + // Then + verify(avatarFeedbackRepository, never()).save(anyAvatarFeedback()) + verify(fastApiClient).sendFeedback(anyFastApiFeedbackReqDto()) + } + + @Test + @DisplayName("아바타 생성이 완료되지 않으면 409 예외를 던진다") + fun `throws 409 when avatar generation is not completed`() { + // Given + val avatar = processingAvatar() + given(avatarRepository.findById(AVATAR_ID)).willReturn(Optional.of(avatar)) + + // When + val exception = + assertThrows { + createFeedbackService.execute(AVATAR_ID, reqDto()) + } + + // Then + assert(exception.errorCode == ErrorCode.AVATAR_NOT_COMPLETED) + verify(avatarFeedbackRepository, never()).save(any()) + verify(fastApiClient, never()).sendFeedback(anyFastApiFeedbackReqDto()) + } + + @Test + @DisplayName("아바타가 존재하지 않으면 404 예외를 던진다") + fun `throws 404 when avatar does not exist`() { + // Given + given(avatarRepository.findById(AVATAR_ID)).willReturn(Optional.empty()) + + // When + val exception = + assertThrows { + createFeedbackService.execute(AVATAR_ID, reqDto()) + } + + // Then + assert(exception.errorCode == ErrorCode.AVATAR_NOT_FOUND) + } + } + + companion object { + private const val AVATAR_ID = 1L + private const val JOB_ID = "test-job-id" + private const val IMAGE_URL = "https://cdn.example.com/avatars/1.png" + + private fun completedAvatar(): AvatarEntity = + AvatarEntity( + nickname = "새아바타", + gender = Gender.FEMALE, + style = Style.GHIBLI, + ageRange = AgeRange.AGE_20_PLUS, + generationStatus = GenerationStatus.COMPLETED, + passUrl = "Aikon500", + imageUrl = IMAGE_URL, + aiJobId = JOB_ID, + id = AVATAR_ID, + ) + + private fun processingAvatar(): AvatarEntity = + AvatarEntity( + nickname = "새아바타", + gender = Gender.FEMALE, + style = Style.GHIBLI, + ageRange = AgeRange.AGE_20_PLUS, + generationStatus = GenerationStatus.PROCESSING, + passUrl = "Aikon500", + id = AVATAR_ID, + ) + + private fun existingFeedback(avatar: AvatarEntity): AvatarFeedback = + AvatarFeedback( + avatar = avatar, + rating = FeedbackRating.DISLIKE, + reasons = listOf(FeedbackReason.AGE_MISMATCH), + id = 1L, + ) + + private fun reqDto(): CreateFeedbackReqDto = + CreateFeedbackReqDto( + rating = FeedbackRating.LIKE, + reasons = listOf(FeedbackReason.FACE_SIMILARITY), + comment = null, + trainingConsent = true, + feedbackUseConsent = true, + ) + + private fun anyAvatarFeedback(): AvatarFeedback { + any(AvatarFeedback::class.java) + return AvatarFeedback( + avatar = completedAvatar(), + rating = FeedbackRating.LIKE, + reasons = listOf(FeedbackReason.FACE_SIMILARITY), + ) + } + + private fun anyFastApiFeedbackReqDto(): FastApiFeedbackReqDto { + any(FastApiFeedbackReqDto::class.java) + return FastApiFeedbackReqDto( + avatarId = AVATAR_ID, + jobId = JOB_ID, + rating = "LIKE", + reasons = listOf("FACE_SIMILARITY"), + comment = null, + trainingConsent = true, + feedbackUseConsent = true, + style = "GHIBLI", + gender = "FEMALE", + ageRange = "AGE_20_PLUS", + promptVersion = null, + modelName = null, + ) + } + } +} diff --git a/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/GenerateAvatarImageServiceImplTest.kt b/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/GenerateAvatarImageServiceImplTest.kt index a80b005..a4ef20d 100644 --- a/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/GenerateAvatarImageServiceImplTest.kt +++ b/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/GenerateAvatarImageServiceImplTest.kt @@ -1,6 +1,5 @@ package team.darkmoderap.aikon.domain.avatar.service -import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Nested @@ -8,8 +7,11 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.extension.ExtendWith import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.ArgumentMatchers.anyString import org.mockito.BDDMockito.given import org.mockito.Mock +import org.mockito.Mockito.never import org.mockito.Mockito.verify import org.mockito.junit.jupiter.MockitoExtension import org.springframework.context.ApplicationEventPublisher @@ -18,6 +20,8 @@ import org.springframework.transaction.TransactionDefinition import org.springframework.transaction.TransactionStatus import org.springframework.transaction.support.SimpleTransactionStatus import org.springframework.transaction.support.TransactionTemplate +import team.darkmoderap.aikon.domain.avatar.dto.FastApiGenerationReqDto +import team.darkmoderap.aikon.domain.avatar.dto.FastApiGenerationResDto import team.darkmoderap.aikon.domain.avatar.entity.AvatarEntity import team.darkmoderap.aikon.domain.avatar.entity.enum.AgeRange import team.darkmoderap.aikon.domain.avatar.entity.enum.Gender @@ -25,6 +29,7 @@ import team.darkmoderap.aikon.domain.avatar.entity.enum.GenerationStatus import team.darkmoderap.aikon.domain.avatar.entity.enum.Style import team.darkmoderap.aikon.domain.avatar.event.AvatarListChangedEvent import team.darkmoderap.aikon.domain.avatar.repository.AvatarRepository +import team.darkmoderap.aikon.global.client.FastApiClient import team.darkmoderap.aikon.global.common.error.AikonException import team.darkmoderap.aikon.global.common.error.ErrorCode import java.util.Optional @@ -35,10 +40,10 @@ class GenerateAvatarImageServiceImplTest { private lateinit var avatarRepository: AvatarRepository @Mock - private lateinit var avatarImageGenerator: AvatarImageGenerator + private lateinit var avatarImageStorage: AvatarImageStorage @Mock - private lateinit var avatarImageStorage: AvatarImageStorage + private lateinit var fastApiClient: FastApiClient @Mock private lateinit var eventPublisher: ApplicationEventPublisher @@ -50,10 +55,11 @@ class GenerateAvatarImageServiceImplTest { generateAvatarImageService = GenerateAvatarImageServiceImpl( avatarRepository, - avatarImageGenerator, avatarImageStorage, + fastApiClient, eventPublisher, TransactionTemplate(NoOpTransactionManager()), + API_BASE_URL, ) } @@ -61,38 +67,56 @@ class GenerateAvatarImageServiceImplTest { @DisplayName("execute 메서드는") inner class Execute { @Test - @DisplayName("이미지 생성에 성공하면 이미지 URL을 저장하고 완료 상태로 변경한다") - fun `completes avatar when image generation succeeds`() { + @DisplayName("소스 이미지 업로드 및 FastAPI 요청에 성공하면 jobId를 저장한다") + fun `saves jobId when source upload and fastapi request succeed`() { // Given val avatar = avatar() - given(avatarRepository.findById(AVATAR_ID)).willReturn(Optional.of(avatar), Optional.of(avatar)) - given(avatarImageGenerator.generate(anyImageGenerationCommand())) - .willReturn(GeneratedAvatarImage(byteArrayOf(4, 5, 6), "image/png")) - given(avatarImageStorage.upload(anyLong(), anyGeneratedImage())).willReturn(IMAGE_URL) + given(avatarRepository.findById(AVATAR_ID)).willReturn(Optional.of(avatar)) + given(avatarImageStorage.uploadSourceImage(anyLong(), anyByteArray(), anyString())) + .willReturn(SOURCE_URI) + given(fastApiClient.requestAvatarGeneration(anyFastApiGenerationReqDto())) + .willReturn(FastApiGenerationResDto(jobId = JOB_ID, status = "PENDING")) + + // When + generateAvatarImageService.execute(AVATAR_ID, sourceImage()) + + // Then + verify(fastApiClient).requestAvatarGeneration(anyFastApiGenerationReqDto()) + verify(eventPublisher, never()).publishEvent(anyEvent()) + } + + @Test + @DisplayName("소스 이미지 업로드에 실패하면 실패 상태로 변경하고 SSE 이벤트를 발행한다") + fun `fails avatar when source image upload fails`() { + // Given + val avatar = avatar() + given(avatarRepository.findById(AVATAR_ID)).willReturn(Optional.of(avatar)) + given(avatarImageStorage.uploadSourceImage(anyLong(), anyByteArray(), anyString())) + .willThrow(RuntimeException("S3 upload failed")) // When generateAvatarImageService.execute(AVATAR_ID, sourceImage()) // Then - assertEquals(GenerationStatus.COMPLETED, avatar.generationStatus) - assertEquals(IMAGE_URL, avatar.imageUrl) verify(eventPublisher).publishEvent(anyEvent()) + verify(fastApiClient, never()).requestAvatarGeneration(anyFastApiGenerationReqDto()) } @Test - @DisplayName("이미지 생성에 실패하면 실패 상태로 변경한다") - fun `fails avatar when image generation fails`() { + @DisplayName("FastAPI 요청에 실패하면 실패 상태로 변경하고 SSE 이벤트를 발행한다") + fun `fails avatar when fastapi request fails`() { // Given val avatar = avatar() - given(avatarRepository.findById(AVATAR_ID)).willReturn(Optional.of(avatar), Optional.of(avatar)) - given(avatarImageGenerator.generate(anyImageGenerationCommand())) - .willThrow(AikonException(ErrorCode.AVATAR_IMAGE_GENERATION_FAILED)) + given(avatarRepository.findById(AVATAR_ID)).willReturn(Optional.of(avatar)) + given(avatarImageStorage.uploadSourceImage(anyLong(), anyByteArray(), anyString())) + .willReturn(SOURCE_URI) + given(fastApiClient.requestAvatarGeneration(anyFastApiGenerationReqDto())) + .willThrow(AikonException(ErrorCode.FASTAPI_REQUEST_FAILED)) // When generateAvatarImageService.execute(AVATAR_ID, sourceImage()) // Then - assertEquals(GenerationStatus.FAILED, avatar.generationStatus) verify(eventPublisher).publishEvent(anyEvent()) } @@ -109,13 +133,15 @@ class GenerateAvatarImageServiceImplTest { } // Then - assertEquals(ErrorCode.AVATAR_NOT_FOUND, exception.errorCode) + assert(exception.errorCode == ErrorCode.AVATAR_NOT_FOUND) } } companion object { private const val AVATAR_ID = 1L - private const val IMAGE_URL = "https://cdn.example.com/avatars/1.png" + private const val JOB_ID = "test-job-id" + private const val SOURCE_URI = "s3://bucket/sources/1.png" + private const val API_BASE_URL = "https://api.aikon.example.com" private fun avatar(): AvatarEntity = AvatarEntity( @@ -134,25 +160,21 @@ class GenerateAvatarImageServiceImplTest { mimeType = "image/png", ) - private fun anyImageGenerationCommand(): AvatarImageGenerationCommand { - any(AvatarImageGenerationCommand::class.java) - return AvatarImageGenerationCommand( - style = Style.GHIBLI, - gender = Gender.FEMALE, - ageRange = AgeRange.AGE_20_PLUS, - sourceImage = byteArrayOf(1, 2, 3), - sourceMimeType = "image/png", + private fun anyFastApiGenerationReqDto(): FastApiGenerationReqDto { + any(FastApiGenerationReqDto::class.java) + return FastApiGenerationReqDto( + avatarId = AVATAR_ID, + sourceImageUri = SOURCE_URI, + style = "GHIBLI", + gender = "FEMALE", + ageRange = "AGE_20_PLUS", + callbackUrl = "$API_BASE_URL/internal/ai/avatar-generations/callback", ) } - private fun anyGeneratedImage(): GeneratedAvatarImage { - any(GeneratedAvatarImage::class.java) - return GeneratedAvatarImage(byteArrayOf(4, 5, 6), "image/png") - } - - private fun anyLong(): Long { - org.mockito.ArgumentMatchers.anyLong() - return 0L + private fun anyByteArray(): ByteArray { + any(ByteArray::class.java) + return byteArrayOf() } private fun anyEvent(): Any { diff --git a/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/HandleAvatarGenerationCallbackServiceImplTest.kt b/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/HandleAvatarGenerationCallbackServiceImplTest.kt new file mode 100644 index 0000000..a0cc867 --- /dev/null +++ b/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/HandleAvatarGenerationCallbackServiceImplTest.kt @@ -0,0 +1,165 @@ +package team.darkmoderap.aikon.domain.avatar.service + +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.ArgumentMatchers.any +import org.mockito.BDDMockito.given +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.junit.jupiter.MockitoExtension +import org.springframework.context.ApplicationEventPublisher +import team.darkmoderap.aikon.domain.avatar.dto.AvatarGenerationCallbackReqDto +import team.darkmoderap.aikon.domain.avatar.entity.AvatarEntity +import team.darkmoderap.aikon.domain.avatar.entity.enum.AgeRange +import team.darkmoderap.aikon.domain.avatar.entity.enum.Gender +import team.darkmoderap.aikon.domain.avatar.entity.enum.GenerationStatus +import team.darkmoderap.aikon.domain.avatar.entity.enum.Style +import team.darkmoderap.aikon.domain.avatar.event.AvatarListChangedEvent +import team.darkmoderap.aikon.domain.avatar.repository.AvatarRepository +import team.darkmoderap.aikon.global.common.error.AikonException +import team.darkmoderap.aikon.global.common.error.ErrorCode +import java.util.Optional + +@ExtendWith(MockitoExtension::class) +class HandleAvatarGenerationCallbackServiceImplTest { + @Mock + private lateinit var avatarRepository: AvatarRepository + + @Mock + private lateinit var avatarImageStorage: AvatarImageStorage + + @Mock + private lateinit var eventPublisher: ApplicationEventPublisher + + @InjectMocks + private lateinit var handleAvatarGenerationCallbackService: HandleAvatarGenerationCallbackServiceImpl + + @Nested + @DisplayName("execute 메서드는") + inner class Execute { + @Test + @DisplayName("COMPLETED 콜백이고 jobId가 일치하면 아바타를 완료 처리하고 SSE 이벤트를 발행한다") + fun `completes avatar and publishes event when completed callback with matching jobId`() { + // Given + val avatar = avatar() + given(avatarRepository.findById(AVATAR_ID)).willReturn(Optional.of(avatar)) + given(avatarImageStorage.toPublicUrl(S3_URI)).willReturn(PUBLIC_URL) + + // When + handleAvatarGenerationCallbackService.execute(completedCallbackReqDto()) + + // Then + verify(eventPublisher).publishEvent(anyEvent()) + assert(avatar.generationStatus == GenerationStatus.COMPLETED) + assert(avatar.imageUrl == PUBLIC_URL) + } + + @Test + @DisplayName("FAILED 콜백이고 jobId가 일치하면 아바타를 실패 처리하고 SSE 이벤트를 발행한다") + fun `fails avatar and publishes event when failed callback with matching jobId`() { + // Given + val avatar = avatar() + given(avatarRepository.findById(AVATAR_ID)).willReturn(Optional.of(avatar)) + + // When + handleAvatarGenerationCallbackService.execute(failedCallbackReqDto()) + + // Then + verify(eventPublisher).publishEvent(anyEvent()) + assert(avatar.generationStatus == GenerationStatus.FAILED) + } + + @Test + @DisplayName("jobId가 일치하지 않으면 400 예외를 던진다") + fun `throws 400 when jobId does not match`() { + // Given + val avatar = avatar() + given(avatarRepository.findById(AVATAR_ID)).willReturn(Optional.of(avatar)) + + // When + val exception = + assertThrows { + handleAvatarGenerationCallbackService.execute( + completedCallbackReqDto().copy(jobId = "wrong-job-id"), + ) + } + + // Then + assert(exception.errorCode == ErrorCode.INVALID_AVATAR_JOB_ID) + verify(eventPublisher, never()).publishEvent(any()) + } + + @Test + @DisplayName("아바타가 존재하지 않으면 404 예외를 던진다") + fun `throws 404 when avatar does not exist`() { + // Given + given(avatarRepository.findById(AVATAR_ID)).willReturn(Optional.empty()) + + // When + val exception = + assertThrows { + handleAvatarGenerationCallbackService.execute(completedCallbackReqDto()) + } + + // Then + assert(exception.errorCode == ErrorCode.AVATAR_NOT_FOUND) + } + } + + companion object { + private const val AVATAR_ID = 1L + private const val JOB_ID = "test-job-id" + private const val S3_URI = "s3://bucket/avatars/1.png" + private const val PUBLIC_URL = "https://cdn.example.com/avatars/1.png" + + private fun avatar(): AvatarEntity = + AvatarEntity( + nickname = "새아바타", + gender = Gender.FEMALE, + style = Style.GHIBLI, + ageRange = AgeRange.AGE_20_PLUS, + generationStatus = GenerationStatus.PROCESSING, + passUrl = "Aikon500", + aiJobId = JOB_ID, + id = AVATAR_ID, + ) + + private fun completedCallbackReqDto(): AvatarGenerationCallbackReqDto = + AvatarGenerationCallbackReqDto( + avatarId = AVATAR_ID, + jobId = JOB_ID, + status = "COMPLETED", + generatedImageUri = S3_URI, + modelName = "gemini-2.5-flash", + promptVersion = "v1", + promptText = "test prompt", + durationMs = 3000, + errorCode = null, + errorMessage = null, + ) + + private fun failedCallbackReqDto(): AvatarGenerationCallbackReqDto = + AvatarGenerationCallbackReqDto( + avatarId = AVATAR_ID, + jobId = JOB_ID, + status = "FAILED", + generatedImageUri = null, + modelName = null, + promptVersion = null, + promptText = null, + durationMs = null, + errorCode = "GENERATION_FAILED", + errorMessage = "generation error", + ) + + private fun anyEvent(): Any { + any(AvatarListChangedEvent::class.java) + return AvatarListChangedEvent() + } + } +} From da1371c25333f15f71a878c228a7bd8383d55e9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=95=98=EB=AF=BC?= Date: Sun, 21 Jun 2026 15:14:11 +0900 Subject: [PATCH 06/16] =?UTF-8?q?fix=20::=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20-=20ElementCollection=20MutableList=20=EC=88=98?= =?UTF-8?q?=EC=A0=95,=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B=20=ED=9B=84=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EC=A0=84?= =?UTF-8?q?=EC=86=A1,=20RestClient=20=ED=83=80=EC=9E=84=EC=95=84=EC=9B=83?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../aikon/domain/avatar/entity/AvatarFeedback.kt | 5 +++-- .../avatar/service/CreateFeedbackServiceImpl.kt | 16 ++++++++++++++-- .../aikon/global/config/RestClientConfig.kt | 12 ++++++++++-- .../service/CreateFeedbackServiceImplTest.kt | 6 +++--- 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/entity/AvatarFeedback.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/entity/AvatarFeedback.kt index 139c2b8..efaaddb 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/entity/AvatarFeedback.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/entity/AvatarFeedback.kt @@ -34,7 +34,7 @@ class AvatarFeedback( @CollectionTable(name = "avatar_feedback_reasons", joinColumns = [JoinColumn(name = "feedback_id")]) @Enumerated(EnumType.STRING) @Column(name = "reason", nullable = false) - var reasons: List, + var reasons: MutableList, @Column(length = 500) var comment: String? = null, @Column(name = "training_consent", nullable = false) @@ -53,7 +53,8 @@ class AvatarFeedback( feedbackUseConsent: Boolean, ) { this.rating = rating - this.reasons = reasons + this.reasons.clear() + this.reasons.addAll(reasons) this.comment = comment this.trainingConsent = trainingConsent this.feedbackUseConsent = feedbackUseConsent diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/CreateFeedbackServiceImpl.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/CreateFeedbackServiceImpl.kt index 87808fa..2dede06 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/CreateFeedbackServiceImpl.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/CreateFeedbackServiceImpl.kt @@ -3,6 +3,8 @@ package team.darkmoderap.aikon.domain.avatar.service import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionSynchronization +import org.springframework.transaction.support.TransactionSynchronizationManager import team.darkmoderap.aikon.domain.avatar.dto.CreateFeedbackReqDto import team.darkmoderap.aikon.domain.avatar.dto.FastApiFeedbackReqDto import team.darkmoderap.aikon.domain.avatar.entity.AvatarFeedback @@ -51,7 +53,7 @@ class CreateFeedbackServiceImpl( AvatarFeedback( avatar = avatar, rating = rating, - reasons = reasons, + reasons = reasons.toMutableList(), comment = reqDto.comment, trainingConsent = trainingConsent, feedbackUseConsent = feedbackUseConsent, @@ -75,6 +77,16 @@ class CreateFeedbackServiceImpl( modelName = avatar.modelName, ) - fastApiClient.sendFeedback(fastApiReqDto) + if (TransactionSynchronizationManager.isActualTransactionActive()) { + TransactionSynchronizationManager.registerSynchronization( + object : TransactionSynchronization { + override fun afterCommit() { + fastApiClient.sendFeedback(fastApiReqDto) + } + }, + ) + } else { + fastApiClient.sendFeedback(fastApiReqDto) + } } } diff --git a/src/main/kotlin/team/darkmoderap/aikon/global/config/RestClientConfig.kt b/src/main/kotlin/team/darkmoderap/aikon/global/config/RestClientConfig.kt index a4056e0..ade6a35 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/global/config/RestClientConfig.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/global/config/RestClientConfig.kt @@ -3,6 +3,7 @@ package team.darkmoderap.aikon.global.config import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.http.client.SimpleClientHttpRequestFactory import org.springframework.web.client.RestClient @Configuration @@ -10,9 +11,16 @@ class RestClientConfig { @Bean fun fastApiRestClient( @Value("\${fastapi.base-url}") baseUrl: String, - ): RestClient = - RestClient + ): RestClient { + val requestFactory = + SimpleClientHttpRequestFactory().apply { + setConnectTimeout(5000) + setReadTimeout(10000) + } + return RestClient .builder() + .requestFactory(requestFactory) .baseUrl(baseUrl) .build() + } } diff --git a/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/CreateFeedbackServiceImplTest.kt b/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/CreateFeedbackServiceImplTest.kt index bf166e8..82915d7 100644 --- a/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/CreateFeedbackServiceImplTest.kt +++ b/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/CreateFeedbackServiceImplTest.kt @@ -57,7 +57,7 @@ class CreateFeedbackServiceImplTest { AvatarFeedback( avatar = avatar, rating = FeedbackRating.LIKE, - reasons = listOf(FeedbackReason.FACE_SIMILARITY), + reasons = mutableListOf(FeedbackReason.FACE_SIMILARITY), ), ) @@ -155,7 +155,7 @@ class CreateFeedbackServiceImplTest { AvatarFeedback( avatar = avatar, rating = FeedbackRating.DISLIKE, - reasons = listOf(FeedbackReason.AGE_MISMATCH), + reasons = mutableListOf(FeedbackReason.AGE_MISMATCH), id = 1L, ) @@ -173,7 +173,7 @@ class CreateFeedbackServiceImplTest { return AvatarFeedback( avatar = completedAvatar(), rating = FeedbackRating.LIKE, - reasons = listOf(FeedbackReason.FACE_SIMILARITY), + reasons = mutableListOf(FeedbackReason.FACE_SIMILARITY), ) } From e561a01215f8ee6dfacbd1c671b6e381e5d21270 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=95=98=EB=AF=BC?= Date: Sun, 21 Jun 2026 21:00:10 +0900 Subject: [PATCH 07/16] =?UTF-8?q?fix=20::=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20-=20=EC=8B=9C=ED=81=AC=EB=A6=BF=20=EC=83=81?= =?UTF-8?q?=EC=88=98=20=EC=8B=9C=EA=B0=84=20=EB=B9=84=EA=B5=90,=20callback?= =?UTF-8?q?Url=20=EC=8A=AC=EB=9E=98=EC=8B=9C=20=EC=A0=95=EA=B7=9C=ED=99=94?= =?UTF-8?q?,=20S3=20URI=20=ED=8C=8C=EC=8B=B1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/InternalAiAvatarGenerationController.kt | 3 ++- .../avatar/service/GenerateAvatarImageServiceImpl.kt | 2 +- .../aikon/domain/avatar/service/S3AvatarImageStorage.kt | 8 ++++++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/controller/InternalAiAvatarGenerationController.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/controller/InternalAiAvatarGenerationController.kt index 67c98cb..3144fa1 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/controller/InternalAiAvatarGenerationController.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/controller/InternalAiAvatarGenerationController.kt @@ -12,6 +12,7 @@ import team.darkmoderap.aikon.domain.avatar.dto.AvatarGenerationCallbackReqDto import team.darkmoderap.aikon.domain.avatar.service.HandleAvatarGenerationCallbackService import team.darkmoderap.aikon.global.common.error.AikonException import team.darkmoderap.aikon.global.common.error.ErrorCode +import java.security.MessageDigest @RestController @RequestMapping("/internal/ai/avatar-generations") @@ -25,7 +26,7 @@ class InternalAiAvatarGenerationController( @RequestHeader("X-Internal-Secret") secret: String, @RequestBody reqDto: AvatarGenerationCallbackReqDto, ) { - if (secret != callbackSecret) { + if (!MessageDigest.isEqual(secret.toByteArray(), callbackSecret.toByteArray())) { throw AikonException(ErrorCode.INVALID_INTERNAL_SECRET) } handleAvatarGenerationCallbackService.execute(reqDto) diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/GenerateAvatarImageServiceImpl.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/GenerateAvatarImageServiceImpl.kt index 4bb9f94..041de05 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/GenerateAvatarImageServiceImpl.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/GenerateAvatarImageServiceImpl.kt @@ -60,7 +60,7 @@ class GenerateAvatarImageServiceImpl( avatarRepository.findByIdOrNull(avatarId)?.let { it.sourceImageUri = sourceImageUri } } - val callbackUrl = "$apiBaseUrl/internal/ai/avatar-generations/callback" + val callbackUrl = "${apiBaseUrl.trimEnd('/')}/internal/ai/avatar-generations/callback" val reqDto = FastApiGenerationReqDto( diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/S3AvatarImageStorage.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/S3AvatarImageStorage.kt index 6ebfd1d..edfb8c0 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/S3AvatarImageStorage.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/S3AvatarImageStorage.kt @@ -67,8 +67,12 @@ class S3AvatarImageStorage( } override fun toPublicUrl(s3Uri: String): String { - val s3Prefix = "s3://$bucket/" - val key = s3Uri.removePrefix(s3Prefix) + val key = + if (s3Uri.startsWith("s3://")) { + s3Uri.substringAfter("s3://").substringAfter("/") + } else { + s3Uri + } return if (publicBaseUrl.isBlank()) { "https://$bucket.s3.$region.amazonaws.com/$key" } else { From 20eed2c57af0c199374f15acca1569b6e26546a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=95=98=EB=AF=BC?= Date: Wed, 24 Jun 2026 18:16:28 +0900 Subject: [PATCH 08/16] =?UTF-8?q?add=20::=20=EB=A1=9C=EC=BB=AC=20=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C=20=EC=9D=B8=ED=94=84=EB=9D=BC=EC=97=90=20Qdrant=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 ++ docker-compose.yml | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/.env.example b/.env.example index 568791c..029c3fc 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,8 @@ MINIO_ROOT_PASSWORD=String MINIO_API_PORT=Number MINIO_CONSOLE_PORT=Number +QDRANT_PORT=6333 + AWS_ACCESS_KEY_ID=String AWS_SECRET_ACCESS_KEY=String AWS_REGION=String diff --git a/docker-compose.yml b/docker-compose.yml index af337c7..b9ff242 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,8 +43,18 @@ services: until mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD; do sleep 2; done; mc mb --ignore-existing local/$$S3_BUCKET; mc anonymous set download local/$$S3_BUCKET; + exec tail -f /dev/null; " + qdrant: + image: qdrant/qdrant:v1.18.2 + container_name: aikon-qdrant + ports: + - "${QDRANT_PORT:-6333}:6333" + volumes: + - qdrant-data:/qdrant/storage + volumes: postgres-data: minio-data: + qdrant-data: From 3799d013e9fe8bef8e49b2eaab86f3db43fd40ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=95=98=EB=AF=BC?= Date: Wed, 24 Jun 2026 18:16:48 +0900 Subject: [PATCH 09/16] =?UTF-8?q?fix=20::=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=EB=84=88=20=EC=B4=88=EA=B8=B0=ED=99=94=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/avatar/service/SubscribeAvatarChangesServiceImpl.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/SubscribeAvatarChangesServiceImpl.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/SubscribeAvatarChangesServiceImpl.kt index b0cfa24..0d0c191 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/SubscribeAvatarChangesServiceImpl.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/SubscribeAvatarChangesServiceImpl.kt @@ -7,6 +7,7 @@ import org.springframework.context.event.EventListener import org.springframework.scheduling.annotation.Async import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Propagation import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.event.TransactionPhase import org.springframework.transaction.event.TransactionalEventListener @@ -57,7 +58,7 @@ class SubscribeAvatarChangesServiceImpl( @Async @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) - @Transactional(readOnly = true) + @Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW) fun handleAvatarListChanged(event: AvatarListChangedEvent) { if (emitters.isEmpty()) return val avatarChanges = findAvatarChanges() From 470c081183123696a49eca1ba8c321434a1bf13a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=95=98=EB=AF=BC?= Date: Wed, 24 Jun 2026 19:41:56 +0900 Subject: [PATCH 10/16] =?UTF-8?q?fix=20::=20SSE=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=EB=B2=94=EC=9C=84=20?= =?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/SubscribeAvatarChangesServiceImpl.kt | 11 ++++++++--- .../service/SubscribeAvatarChangesServiceImplTest.kt | 11 +++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/SubscribeAvatarChangesServiceImpl.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/SubscribeAvatarChangesServiceImpl.kt index 0d0c191..5a679ea 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/SubscribeAvatarChangesServiceImpl.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/SubscribeAvatarChangesServiceImpl.kt @@ -7,10 +7,11 @@ import org.springframework.context.event.EventListener import org.springframework.scheduling.annotation.Async import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.PlatformTransactionManager import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.event.TransactionPhase import org.springframework.transaction.event.TransactionalEventListener +import org.springframework.transaction.support.TransactionTemplate import org.springframework.web.servlet.mvc.method.annotation.SseEmitter import team.darkmoderap.aikon.domain.avatar.dto.AvatarChangeResDto import team.darkmoderap.aikon.domain.avatar.entity.AvatarEntity @@ -25,11 +26,16 @@ import java.util.concurrent.CopyOnWriteArrayList class SubscribeAvatarChangesServiceImpl( private val avatarRepository: AvatarRepository, private val eventPublisher: ApplicationEventPublisher, + transactionManager: PlatformTransactionManager, @Value("\${aikon.sse.timeout-millis:1800000}") private val timeoutMillis: Long, @Value("\${aikon.sse.max-connections:100}") private val maxConnections: Int, ) : SubscribeAvatarChangesService { private val logger = LoggerFactory.getLogger(SubscribeAvatarChangesServiceImpl::class.java) private val emitters = CopyOnWriteArrayList() + private val readOnlyTransactionTemplate = + TransactionTemplate(transactionManager).apply { + isReadOnly = true + } override fun execute(): SseEmitter { val emitter = SseEmitter(timeoutMillis) @@ -58,10 +64,9 @@ class SubscribeAvatarChangesServiceImpl( @Async @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) - @Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW) fun handleAvatarListChanged(event: AvatarListChangedEvent) { if (emitters.isEmpty()) return - val avatarChanges = findAvatarChanges() + val avatarChanges = readOnlyTransactionTemplate.execute { findAvatarChanges() }.orEmpty() emitters.forEach { emitter -> send(emitter, avatarChanges) } } diff --git a/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/SubscribeAvatarChangesServiceImplTest.kt b/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/SubscribeAvatarChangesServiceImplTest.kt index c8f7a94..5259c55 100644 --- a/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/SubscribeAvatarChangesServiceImplTest.kt +++ b/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/SubscribeAvatarChangesServiceImplTest.kt @@ -12,6 +12,9 @@ import org.mockito.Mock import org.mockito.Mockito.verify import org.mockito.junit.jupiter.MockitoExtension import org.springframework.context.ApplicationEventPublisher +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.TransactionDefinition +import org.springframework.transaction.TransactionStatus import org.springframework.web.servlet.mvc.method.annotation.SseEmitter import team.darkmoderap.aikon.domain.avatar.entity.AvatarEntity import team.darkmoderap.aikon.domain.avatar.entity.enum.AgeRange @@ -31,6 +34,12 @@ class SubscribeAvatarChangesServiceImplTest { @Mock private lateinit var eventPublisher: ApplicationEventPublisher + @Mock + private lateinit var transactionManager: PlatformTransactionManager + + @Mock + private lateinit var transactionStatus: TransactionStatus + private lateinit var subscribeAvatarChangesService: SubscribeAvatarChangesServiceImpl @BeforeEach @@ -39,6 +48,7 @@ class SubscribeAvatarChangesServiceImplTest { SubscribeAvatarChangesServiceImpl( avatarRepository = avatarRepository, eventPublisher = eventPublisher, + transactionManager = transactionManager, timeoutMillis = 5000L, maxConnections = 2, ) @@ -79,6 +89,7 @@ class SubscribeAvatarChangesServiceImplTest { @DisplayName("목록 변경 이벤트를 받으면 최신 아바타 목록을 조회한다") fun `finds latest avatar list when event is received`() { // Given + given(transactionManager.getTransaction(any(TransactionDefinition::class.java))).willReturn(transactionStatus) given(avatarRepository.findAllByOrderByIdAsc()).willReturn(listOf(avatar())) subscribeAvatarChangesService.execute() From ac9a49e34b6794d5f039e445bc0342935682ec16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=95=98=EB=AF=BC?= Date: Wed, 15 Jul 2026 10:13:22 +0900 Subject: [PATCH 11/16] =?UTF-8?q?update=20::=20STUDIO=20=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EC=9D=BC=20=EC=A0=9C=EA=B1=B0=ED=95=98=EA=B3=A0=20=EC=A0=84=20?= =?UTF-8?q?=EC=8A=A4=ED=83=80=EC=9D=BC=20=ED=94=84=EB=A1=AC=ED=94=84?= =?UTF-8?q?=ED=8A=B8=EC=97=90=20=EB=B3=B4=EC=A0=95=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../aikon/domain/avatar/entity/enum/Style.kt | 3 +-- .../service/AvatarImagePromptProvider.kt | 26 +++++-------------- .../init/AvatarDefaultStyleInitializer.kt | 2 +- 3 files changed, 8 insertions(+), 23 deletions(-) diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/entity/enum/Style.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/entity/enum/Style.kt index 011c543..36d17ff 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/entity/enum/Style.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/entity/enum/Style.kt @@ -1,7 +1,6 @@ package team.darkmoderap.aikon.domain.avatar.entity.enum enum class Style { - STUDIO, ENHANCED, ZOOTOPIA, TRADITIONAL_HANBOK, @@ -12,6 +11,6 @@ enum class Style { companion object { const val SCHEMA_DESCRIPTION = - "스타일 (STUDIO, ENHANCED, ZOOTOPIA, TRADITIONAL_HANBOK, DISNEY_PIXAR, GHIBLI, LIGHT_ART)" + "스타일 (ENHANCED, ZOOTOPIA, TRADITIONAL_HANBOK, DISNEY_PIXAR, GHIBLI, LIGHT_ART)" } } diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/AvatarImagePromptProvider.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/AvatarImagePromptProvider.kt index 03cd412..5e9d74b 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/AvatarImagePromptProvider.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/AvatarImagePromptProvider.kt @@ -16,7 +16,6 @@ class AvatarImagePromptProvider { val ageHint = buildAgeHint(ageRange) return when (style) { - Style.STUDIO -> buildStudioPrompt(subject, ageHint) Style.ENHANCED -> buildEnhancedPrompt(subject, ageHint) Style.ZOOTOPIA -> buildZootopiaPrompt(subject, ageHint) Style.TRADITIONAL_HANBOK -> buildHanbokPrompt(subject, gender, ageRange) @@ -73,25 +72,6 @@ class AvatarImagePromptProvider { } } - private fun buildStudioPrompt( - subject: String, - ageHint: String, - ): String = - """ - 첨부된 사진 속 $subject 고급 전문 스튜디오 포트레이트 사진 스타일로 변환해줘. - $ageHint - 배경은 완전한 흰색 또는 밝은 회색 무배경(seamless backdrop)으로 처리하고, - 왼쪽 45도 각도에서 대형 소프트박스 조명을 비춰 얼굴에 부드럽고 입체적인 음영을 만들어줘. - 85mm 인물 사진 렌즈 느낌으로 배경은 살짝 흐리게(얕은 심도) 처리하고, - 눈동자에는 자연스러운 캐치라이트가 반사되도록 해줘. - 구도는 얼굴과 상반신이 안정적으로 보이는 정면 또는 약간 사선의 프로필 구도로 잡아줘. - 피부는 자연스러운 질감을 유지하면서 깔끔하게 보정하되 과한 미백이나 인위적인 보정은 피하고, - 옷차림은 단정하고 고급스러운 모던 포트레이트 의상으로 정리해줘. - 고급 패션 매거진이나 브랜드 화보 수준의 상업 사진 퀄리티로 표현해줘. - 인물의 얼굴형, 눈코입, 헤어스타일, 표정, 전체적인 생김새는 원본 사진과 최대한 동일하게 유지해줘. - 손가락, 귀, 눈동자, 치아, 안경 같은 디테일이 어색하게 변형되지 않도록 자연스럽게 마무리해줘. - """.trimIndent() - private fun buildEnhancedPrompt( subject: String, ageHint: String, @@ -105,6 +85,7 @@ class AvatarImagePromptProvider { 색감은 선명하고 고급스럽게, 대비와 채도는 SNS 프로필이나 브랜드 화보처럼 세련되게 조정해줘. 배경은 인물을 돋보이게 하는 감각적인 스튜디오 또는 도시적인 무드의 흐림 배경으로 구성해줘. 눈빛과 표정은 더 또렷하고 자신감 있게 보이도록 살리고, + 이목구비는 원본보다 더 또렷하고 매력적인 비율로 보정해서 확실히 더 잘생기고 예쁘게 표현해줘. 전체 결과물은 원본 인물의 정체성을 유지한 화려한 고급 프로필/화보 사진 퀄리티로 완성해줘. 애니메이션, 수채화, 동물 캐릭터, 전통 의상 등 별도의 스타일 변환은 하지 마. """.trimIndent() @@ -124,6 +105,7 @@ class AvatarImagePromptProvider { 색감은 밝고 채도가 높으며 생동감 넘치는 디즈니 애니메이션 특유의 스타일로, 털, 귀, 꼬리, 의상 소재는 부드럽고 입체감 있게 렌더링해줘. 캐릭터의 표정과 눈빛은 원본 인물의 분위기와 감정을 그대로 반영하고, + 이목구비는 원본보다 더 또렷하고 매력적인 비율로 보정해서 확실히 더 잘생기고 예쁜 캐릭터로 표현해줘. 전체 퀄리티는 디즈니 공식 영화 장면 수준으로 렌더링해줘. 얼굴이 과도하게 바뀌거나 전혀 다른 캐릭터처럼 보이지 않게 원본과의 유사성을 유지해줘. """.trimIndent() @@ -142,6 +124,7 @@ class AvatarImagePromptProvider { 한복의 비단 질감, 자수, 옷고름, 주름, 장신구를 섬세하고 고급스럽게 묘사해줘. 구도는 인물이 중심이 되는 우아한 전신 또는 반신 초상화로 잡고, 전체적으로 고풍스럽고 우아한 한국 전통 회화 일러스트 스타일로 표현해줘. + 피부는 깨끗하고 화사하게, 이목구비는 더 또렷하고 아름답게 보정해서 원본보다 확실히 매력적으로 표현해줘. 인물의 얼굴형과 생김새, 눈코입, 헤어라인, 표정의 특징은 원본과 최대한 동일하게 유지해줘. 장식 요소가 얼굴을 가리거나 원본 인물의 인상을 바꾸지 않도록 자연스럽게 배치해줘. """.trimIndent() @@ -226,6 +209,7 @@ class AvatarImagePromptProvider { 배경은 픽사 영화에 나올 법한 화려하고 디테일한 환경으로 구성하되 인물이 또렷하게 돋보이게 하고, 눈동자 하이라이트, 피부 질감, 머리카락, 옷감의 재질감을 정교하게 렌더링해줘. 전체 퀄리티는 픽사 영화 공식 포스터 수준으로 렌더링해줘. + 이목구비는 원본보다 더 또렷하고 매력적인 비율로 보정해서 확실히 더 잘생기고 예쁜 캐릭터로 표현해줘. 인물의 헤어 색상, 얼굴 특징, 개성과 표정은 최대한 원본과 유사하게 유지하고, 손, 치아, 눈동자 같은 디테일이 어색하게 뭉개지지 않도록 완성도를 높여줘. """.trimIndent() @@ -243,6 +227,7 @@ class AvatarImagePromptProvider { 조명은 따뜻한 오후 햇살이 부드럽게 스며드는 느낌으로, 인물 주변에는 자연스러운 바람감과 은은한 생활감이 느껴지는 작은 디테일을 더해줘. 얼굴 윤곽, 눈매, 코와 입의 위치, 헤어스타일, 표정은 원본 사진을 기반으로 최대한 유사하게 유지해줘. + 피부는 깨끗하고 화사하게, 이목구비는 더 또렷하고 사랑스럽게 보정해서 원본보다 확실히 예쁘게 표현해줘. 전체적으로 평화롭고 따뜻하며 향수를 자극하는 지브리 특유의 감성을 담고, 배경이 과하게 복잡해져 인물의 얼굴 인상이 흐려지지 않게 균형을 잡아줘. """.trimIndent() @@ -262,6 +247,7 @@ class AvatarImagePromptProvider { 종이 질감, 물 번짐, 가장자리의 부드러운 그라데이션이 자연스럽게 드러나게 해줘. 인물의 눈매, 얼굴형, 헤어스타일, 표정, 포즈는 원본 사진을 기반으로 유지하고, 얼굴의 핵심 특징은 흐릿하게 사라지지 않도록 선명한 초점으로 살려줘. + 피부는 맑고 화사하게, 이목구비는 더 또렷하고 아름답게 보정해서 원본보다 확실히 매력적으로 표현해줘. 전체적으로 갤러리에 걸릴 법한 섬세하고 아름다운 수채화 작품 느낌으로 완성하되, 추상적인 장식이 과해져 인물 식별성이 낮아지지 않게 균형을 맞춰줘. """.trimIndent() diff --git a/src/main/kotlin/team/darkmoderap/aikon/global/common/init/AvatarDefaultStyleInitializer.kt b/src/main/kotlin/team/darkmoderap/aikon/global/common/init/AvatarDefaultStyleInitializer.kt index 723d0d1..25c29aa 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/global/common/init/AvatarDefaultStyleInitializer.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/global/common/init/AvatarDefaultStyleInitializer.kt @@ -14,7 +14,7 @@ class AvatarDefaultStyleInitializer( @Transactional override fun run(vararg args: String) { if (!avatarDefaultStyleRepository.existsById(AvatarDefaultStyle.SINGLETON_ID)) { - avatarDefaultStyleRepository.save(AvatarDefaultStyle(defaultStyle = Style.STUDIO)) + avatarDefaultStyleRepository.save(AvatarDefaultStyle(defaultStyle = Style.ENHANCED)) } } } From dff079f996fe1630a06d48241ca063488104a537 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=95=98=EB=AF=BC?= Date: Wed, 15 Jul 2026 10:13:25 +0900 Subject: [PATCH 12/16] =?UTF-8?q?test=20::=20STUDIO=20=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EC=9D=BC=20=EC=B0=B8=EC=A1=B0=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=94=BD=EC=8A=A4=EC=B2=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../aikon/domain/avatar/controller/AvatarControllerTest.kt | 4 ++-- .../domain/avatar/service/DeleteAvatarServiceImplTest.kt | 2 +- .../domain/avatar/service/UpdateAvatarServiceImplTest.kt | 2 +- .../avatar/service/UpdateDefaultStyleServiceImplTest.kt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/controller/AvatarControllerTest.kt b/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/controller/AvatarControllerTest.kt index f5b7537..46aaf11 100644 --- a/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/controller/AvatarControllerTest.kt +++ b/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/controller/AvatarControllerTest.kt @@ -503,7 +503,7 @@ class AvatarControllerTest { return CreateAvatarReqDto( nickname = "dummy", gender = Gender.MALE, - style = Style.STUDIO, + style = Style.ZOOTOPIA, ageRange = AgeRange.AGE_0_7, ) } @@ -539,7 +539,7 @@ class AvatarControllerTest { private fun anyDefaultStyleReqDto(): UpdateDefaultStyleReqDto { Mockito.any(UpdateDefaultStyleReqDto::class.java) - return UpdateDefaultStyleReqDto(style = Style.STUDIO) + return UpdateDefaultStyleReqDto(style = Style.GHIBLI) } } } diff --git a/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/DeleteAvatarServiceImplTest.kt b/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/DeleteAvatarServiceImplTest.kt index 5b9bdc9..2768b66 100644 --- a/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/DeleteAvatarServiceImplTest.kt +++ b/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/DeleteAvatarServiceImplTest.kt @@ -138,7 +138,7 @@ class DeleteAvatarServiceImplTest { ) = AvatarEntity( nickname = "test", gender = Gender.MALE, - style = Style.STUDIO, + style = Style.GHIBLI, ageRange = AgeRange.AGE_20_PLUS, generationStatus = status, imageUrl = imageUrl, diff --git a/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/UpdateAvatarServiceImplTest.kt b/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/UpdateAvatarServiceImplTest.kt index 21aad58..b44c1cd 100644 --- a/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/UpdateAvatarServiceImplTest.kt +++ b/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/UpdateAvatarServiceImplTest.kt @@ -153,7 +153,7 @@ class UpdateAvatarServiceImplTest { AvatarEntity( nickname = "기존이름", gender = Gender.MALE, - style = Style.STUDIO, + style = Style.GHIBLI, ageRange = AgeRange.AGE_0_7, generationStatus = generationStatus, ) diff --git a/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/UpdateDefaultStyleServiceImplTest.kt b/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/UpdateDefaultStyleServiceImplTest.kt index b69860b..fb71f3d 100644 --- a/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/UpdateDefaultStyleServiceImplTest.kt +++ b/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/UpdateDefaultStyleServiceImplTest.kt @@ -36,7 +36,7 @@ class UpdateDefaultStyleServiceImplTest { @DisplayName("설정이 있으면 기본 스타일을 수정한다") fun `updates default style when setting exists`() { // Given - val setting = AvatarDefaultStyle(defaultStyle = Style.STUDIO) + val setting = AvatarDefaultStyle(defaultStyle = Style.ZOOTOPIA) given(avatarDefaultStyleRepository.findById(AvatarDefaultStyle.SINGLETON_ID)) .willReturn(Optional.of(setting)) val reqDto = UpdateDefaultStyleReqDto(Style.GHIBLI) From e4204b7b983766b212756595395b230344b7a963 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A2=85=EC=9C=A4?= Date: Thu, 23 Jul 2026 10:45:29 +0900 Subject: [PATCH 13/16] =?UTF-8?q?=EC=95=84=EB=B0=94=ED=83=80=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EC=A1=B0=ED=9A=8C=20URL=EC=9D=84=20S3=20P?= =?UTF-8?q?resigned=20URL=EB=A1=9C=20=EB=B0=9C=EA=B8=89=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add :: 아바타 이미지 조회 URL을 presigned URL로 발급하도록 변경 * test :: presigned URL 발급 로직에 대한 테스트 추가 및 기존 테스트 수정 * docs :: presigned URL 만료 시간 환경변수를 env.example에 추가 --- .env.example | 1 + .../avatar/service/AvatarImageStorage.kt | 2 + .../service/GetAvatarByPassServiceImpl.kt | 3 +- .../avatar/service/GetAvatarServiceImpl.kt | 3 +- .../avatar/service/S3AvatarImageStorage.kt | 26 +++++++++ .../SubscribeAvatarChangesServiceImpl.kt | 3 +- .../aikon/global/common/error/ErrorCode.kt | 1 + .../aikon/global/config/S3Config.kt | 27 +++++++++ src/main/resources/application.yml | 1 + .../service/GetAvatarByPassServiceImplTest.kt | 7 ++- .../service/GetAvatarServiceImplTest.kt | 7 ++- .../service/S3AvatarImageStorageTest.kt | 57 +++++++++++++++++++ .../SubscribeAvatarChangesServiceImplTest.kt | 4 ++ 13 files changed, 137 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 029c3fc..2ba6b69 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,4 @@ S3_BUCKET=String S3_ENDPOINT=String S3_PUBLIC_BASE_URL=String S3_PATH_STYLE_ACCESS_ENABLED=String +S3_PRESIGNED_URL_EXPIRATION_SECONDS=Number diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/AvatarImageStorage.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/AvatarImageStorage.kt index 4d91986..d45b8fc 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/AvatarImageStorage.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/AvatarImageStorage.kt @@ -14,5 +14,7 @@ interface AvatarImageStorage { fun toPublicUrl(s3Uri: String): String + fun generatePresignedUrl(imageUrl: String): String + fun delete(imageUrl: String) } diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/GetAvatarByPassServiceImpl.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/GetAvatarByPassServiceImpl.kt index 7718c11..9c3eb89 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/GetAvatarByPassServiceImpl.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/GetAvatarByPassServiceImpl.kt @@ -11,6 +11,7 @@ import team.darkmoderap.aikon.global.common.error.ErrorCode @Service class GetAvatarByPassServiceImpl( private val avatarRepository: AvatarRepository, + private val avatarImageStorage: AvatarImageStorage, private val avatarQrUrlProvider: AvatarQrUrlProvider, ) : GetAvatarByPassService { @Transactional(readOnly = true) @@ -20,7 +21,7 @@ class GetAvatarByPassServiceImpl( ?: throw AikonException(ErrorCode.AVATAR_NOT_FOUND) val isCompleted = avatar.generationStatus == GenerationStatus.COMPLETED - val imageUrl = if (isCompleted) avatar.imageUrl else null + val imageUrl = if (isCompleted) avatar.imageUrl?.let { avatarImageStorage.generatePresignedUrl(it) } else null val resolvedPassUrl = if (isCompleted) avatar.passUrl else null return GetAvatarResDto( diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/GetAvatarServiceImpl.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/GetAvatarServiceImpl.kt index b775e76..f59162d 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/GetAvatarServiceImpl.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/GetAvatarServiceImpl.kt @@ -12,6 +12,7 @@ import team.darkmoderap.aikon.global.common.error.ErrorCode @Service class GetAvatarServiceImpl( private val avatarRepository: AvatarRepository, + private val avatarImageStorage: AvatarImageStorage, private val avatarQrUrlProvider: AvatarQrUrlProvider, ) : GetAvatarService { @Transactional(readOnly = true) @@ -21,7 +22,7 @@ class GetAvatarServiceImpl( ?: throw AikonException(ErrorCode.AVATAR_NOT_FOUND) val isCompleted = avatar.generationStatus == GenerationStatus.COMPLETED - val imageUrl = if (isCompleted) avatar.imageUrl else null + val imageUrl = if (isCompleted) avatar.imageUrl?.let { avatarImageStorage.generatePresignedUrl(it) } else null val passUrl = if (isCompleted) avatar.passUrl else null return GetAvatarResDto( diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/S3AvatarImageStorage.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/S3AvatarImageStorage.kt index edfb8c0..038f1fb 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/S3AvatarImageStorage.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/S3AvatarImageStorage.kt @@ -5,16 +5,22 @@ import org.springframework.stereotype.Service import software.amazon.awssdk.core.sync.RequestBody import software.amazon.awssdk.services.s3.S3Client import software.amazon.awssdk.services.s3.model.DeleteObjectRequest +import software.amazon.awssdk.services.s3.model.GetObjectRequest import software.amazon.awssdk.services.s3.model.PutObjectRequest +import software.amazon.awssdk.services.s3.presigner.S3Presigner +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest import team.darkmoderap.aikon.global.common.error.AikonException import team.darkmoderap.aikon.global.common.error.ErrorCode +import java.time.Duration @Service class S3AvatarImageStorage( private val s3Client: S3Client, + private val s3Presigner: S3Presigner, @Value("\${aws.region}") private val region: String, @Value("\${aws.s3.bucket}") private val bucket: String, @Value("\${aws.s3.public-base-url}") private val publicBaseUrl: String, + @Value("\${aws.s3.presigned-url-expiration-seconds}") private val presignedUrlExpirationSeconds: Long, ) : AvatarImageStorage { override fun upload( avatarId: Long, @@ -80,6 +86,26 @@ class S3AvatarImageStorage( } } + override fun generatePresignedUrl(imageUrl: String): String { + val key = + extractKey(imageUrl) + ?: throw AikonException(ErrorCode.AVATAR_IMAGE_URL_GENERATION_FAILED) + + val presignRequest = + GetObjectPresignRequest + .builder() + .signatureDuration(Duration.ofSeconds(presignedUrlExpirationSeconds)) + .getObjectRequest( + GetObjectRequest + .builder() + .bucket(bucket) + .key(key) + .build(), + ).build() + + return s3Presigner.presignGetObject(presignRequest).url().toString() + } + override fun delete(imageUrl: String) { if (bucket.isBlank()) { throw AikonException(ErrorCode.AVATAR_IMAGE_DELETE_FAILED) diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/SubscribeAvatarChangesServiceImpl.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/SubscribeAvatarChangesServiceImpl.kt index 5a679ea..66bf2de 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/SubscribeAvatarChangesServiceImpl.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/SubscribeAvatarChangesServiceImpl.kt @@ -25,6 +25,7 @@ import java.util.concurrent.CopyOnWriteArrayList @Service class SubscribeAvatarChangesServiceImpl( private val avatarRepository: AvatarRepository, + private val avatarImageStorage: AvatarImageStorage, private val eventPublisher: ApplicationEventPublisher, transactionManager: PlatformTransactionManager, @Value("\${aikon.sse.timeout-millis:1800000}") private val timeoutMillis: Long, @@ -134,7 +135,7 @@ class SubscribeAvatarChangesServiceImpl( gender = gender, ageRange = ageRange, generationStatus = generationStatus, - imageUrl = imageUrl, + imageUrl = imageUrl?.let { avatarImageStorage.generatePresignedUrl(it) }, passUrl = passUrl, createdAt = createdAt, ) diff --git a/src/main/kotlin/team/darkmoderap/aikon/global/common/error/ErrorCode.kt b/src/main/kotlin/team/darkmoderap/aikon/global/common/error/ErrorCode.kt index 8b8384a..dbd978c 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/global/common/error/ErrorCode.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/global/common/error/ErrorCode.kt @@ -13,6 +13,7 @@ enum class ErrorCode( AVATAR_PASS_CODE_ASSIGNMENT_FAILED("아바타 패스 코드 배정에 실패하였습니다.", 409), AVATAR_IMAGE_GENERATION_FAILED("아바타 이미지 생성에 실패하였습니다.", 502), AVATAR_IMAGE_DELETE_FAILED("아바타 이미지 삭제에 실패하였습니다.", 502), + AVATAR_IMAGE_URL_GENERATION_FAILED("아바타 이미지 URL 생성에 실패하였습니다.", 502), AVATAR_STYLE_NOT_SUPPORTED_BY_AI("해당 스타일은 현재 AI 서버에서 지원되지 않습니다.", 422), FASTAPI_REQUEST_FAILED("AI 서버 요청에 실패하였습니다.", 502), INVALID_INTERNAL_SECRET("내부 인증에 실패하였습니다.", 401), diff --git a/src/main/kotlin/team/darkmoderap/aikon/global/config/S3Config.kt b/src/main/kotlin/team/darkmoderap/aikon/global/config/S3Config.kt index a90022f..67f48fb 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/global/config/S3Config.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/global/config/S3Config.kt @@ -7,6 +7,7 @@ import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider import software.amazon.awssdk.regions.Region import software.amazon.awssdk.services.s3.S3Client import software.amazon.awssdk.services.s3.S3Configuration +import software.amazon.awssdk.services.s3.presigner.S3Presigner import java.net.URI @Configuration @@ -36,4 +37,30 @@ class S3Config { return builder .build() } + + @Bean + fun s3Presigner( + @Value("\${aws.region}") region: String, + @Value("\${aws.s3.endpoint}") endpoint: String, + @Value("\${aws.s3.path-style-access-enabled}") pathStyleAccessEnabled: Boolean, + ): S3Presigner { + val builder = + S3Presigner + .builder() + .region(Region.of(region)) + .credentialsProvider(DefaultCredentialsProvider.builder().build()) + .serviceConfiguration( + S3Configuration + .builder() + .pathStyleAccessEnabled(pathStyleAccessEnabled) + .build(), + ) + + if (endpoint.isNotBlank()) { + builder.endpointOverride(URI.create(endpoint)) + } + + return builder + .build() + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 97b7ebc..0b02def 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -39,3 +39,4 @@ aws: public-base-url: ${S3_PUBLIC_BASE_URL:} endpoint: ${S3_ENDPOINT:} path-style-access-enabled: ${S3_PATH_STYLE_ACCESS_ENABLED:true} + presigned-url-expiration-seconds: ${S3_PRESIGNED_URL_EXPIRATION_SECONDS:3600} diff --git a/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/GetAvatarByPassServiceImplTest.kt b/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/GetAvatarByPassServiceImplTest.kt index 52fbd2f..0dfe46d 100644 --- a/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/GetAvatarByPassServiceImplTest.kt +++ b/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/GetAvatarByPassServiceImplTest.kt @@ -24,6 +24,9 @@ class GetAvatarByPassServiceImplTest { @Mock private lateinit var avatarRepository: AvatarRepository + @Mock + private lateinit var avatarImageStorage: AvatarImageStorage + @Mock private lateinit var avatarQrUrlProvider: AvatarQrUrlProvider @@ -38,6 +41,7 @@ class GetAvatarByPassServiceImplTest { fun `returns completed avatar with result urls when found`() { // Given given(avatarRepository.findByPassUrl(PASS_URL)).willReturn(avatar()) + given(avatarImageStorage.generatePresignedUrl("https://example.com/avatar.png")).willReturn(PRESIGNED_IMAGE_URL) given(avatarQrUrlProvider.create(PASS_URL)).willReturn(QR_URL) // When @@ -47,7 +51,7 @@ class GetAvatarByPassServiceImplTest { assertEquals(AVATAR_ID, result.id) assertEquals("새아바타", result.nickname) assertEquals(GenerationStatus.COMPLETED, result.generationStatus) - assertEquals("https://example.com/avatar.png", result.imageUrl) + assertEquals(PRESIGNED_IMAGE_URL, result.imageUrl) assertEquals(PASS_URL, result.passUrl) assertEquals(QR_URL, result.qrUrl) } @@ -91,6 +95,7 @@ class GetAvatarByPassServiceImplTest { private const val AVATAR_ID = 1L private const val PASS_URL = "Aikon500" private const val QR_URL = "https://aikon.example.com/pass/Aikon500" + private const val PRESIGNED_IMAGE_URL = "https://example.com/avatar.png?X-Amz-Signature=signed" private fun avatar(generationStatus: GenerationStatus = GenerationStatus.COMPLETED): AvatarEntity = AvatarEntity( diff --git a/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/GetAvatarServiceImplTest.kt b/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/GetAvatarServiceImplTest.kt index 96e937f..d4b76d6 100644 --- a/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/GetAvatarServiceImplTest.kt +++ b/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/GetAvatarServiceImplTest.kt @@ -25,6 +25,9 @@ class GetAvatarServiceImplTest { @Mock private lateinit var avatarRepository: AvatarRepository + @Mock + private lateinit var avatarImageStorage: AvatarImageStorage + @Mock private lateinit var avatarQrUrlProvider: AvatarQrUrlProvider @@ -39,6 +42,7 @@ class GetAvatarServiceImplTest { fun `returns completed avatar with result urls when found`() { // Given given(avatarRepository.findById(AVATAR_ID)).willReturn(Optional.of(avatar())) + given(avatarImageStorage.generatePresignedUrl("https://example.com/avatar.png")).willReturn(PRESIGNED_IMAGE_URL) given(avatarQrUrlProvider.create("Aikon500")).willReturn(QR_URL) // When @@ -48,7 +52,7 @@ class GetAvatarServiceImplTest { assertEquals(AVATAR_ID, result.id) assertEquals("새아바타", result.nickname) assertEquals(GenerationStatus.COMPLETED, result.generationStatus) - assertEquals("https://example.com/avatar.png", result.imageUrl) + assertEquals(PRESIGNED_IMAGE_URL, result.imageUrl) assertEquals("Aikon500", result.passUrl) assertEquals(QR_URL, result.qrUrl) } @@ -91,6 +95,7 @@ class GetAvatarServiceImplTest { companion object { private const val AVATAR_ID = 1L private const val QR_URL = "https://aikon.example.com/pass/Aikon500" + private const val PRESIGNED_IMAGE_URL = "https://example.com/avatar.png?X-Amz-Signature=signed" private fun avatar(generationStatus: GenerationStatus = GenerationStatus.COMPLETED): AvatarEntity = AvatarEntity( diff --git a/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/S3AvatarImageStorageTest.kt b/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/S3AvatarImageStorageTest.kt index 1800133..5e2c641 100644 --- a/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/S3AvatarImageStorageTest.kt +++ b/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/S3AvatarImageStorageTest.kt @@ -7,20 +7,28 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.extension.ExtendWith import org.mockito.ArgumentCaptor +import org.mockito.BDDMockito.given import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import software.amazon.awssdk.core.sync.RequestBody import software.amazon.awssdk.services.s3.S3Client import software.amazon.awssdk.services.s3.model.DeleteObjectRequest import software.amazon.awssdk.services.s3.model.PutObjectRequest +import software.amazon.awssdk.services.s3.presigner.S3Presigner +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest import team.darkmoderap.aikon.global.common.error.AikonException import team.darkmoderap.aikon.global.common.error.ErrorCode +import java.net.URI @ExtendWith(MockitoExtension::class) class S3AvatarImageStorageTest { @Mock private lateinit var s3Client: S3Client + @Mock + private lateinit var s3Presigner: S3Presigner + @Nested @DisplayName("upload 메서드는") inner class Upload { @@ -97,12 +105,59 @@ class S3AvatarImageStorageTest { } } + @Nested + @DisplayName("generatePresignedUrl 메서드는") + inner class GeneratePresignedUrl { + @Test + @DisplayName("publicBaseUrl 기반 URL에서 key를 추출해 presigned URL을 생성한다") + fun `returns presigned url extracted from public base url`() { + // Given + val storage = s3AvatarImageStorage(publicBaseUrl = PUBLIC_BASE_URL) + val imageUrl = "$PUBLIC_BASE_URL/avatars/$AVATAR_ID.png" + val presignedGetObjectRequest = org.mockito.Mockito.mock(PresignedGetObjectRequest::class.java) + given(presignedGetObjectRequest.url()).willReturn(URI.create(PRESIGNED_URL).toURL()) + given( + s3Presigner.presignGetObject(org.mockito.ArgumentMatchers.any(GetObjectPresignRequest::class.java)), + ).willReturn(presignedGetObjectRequest) + + // When + val result = storage.generatePresignedUrl(imageUrl) + + // Then + assertEquals(PRESIGNED_URL, result) + val requestCaptor = ArgumentCaptor.forClass(GetObjectPresignRequest::class.java) + org.mockito.Mockito + .verify(s3Presigner) + .presignGetObject(requestCaptor.capture()) + assertEquals(BUCKET, requestCaptor.value.getObjectRequest().bucket()) + assertEquals("avatars/$AVATAR_ID.png", requestCaptor.value.getObjectRequest().key()) + } + + @Test + @DisplayName("key를 추출할 수 없으면 502 예외를 던진다") + fun `throws bad gateway when key cannot be extracted`() { + // Given + val storage = s3AvatarImageStorage(publicBaseUrl = PUBLIC_BASE_URL) + + // When + val exception = + assertThrows { + storage.generatePresignedUrl("https://unknown.example.com/avatar.png") + } + + // Then + assertEquals(ErrorCode.AVATAR_IMAGE_URL_GENERATION_FAILED, exception.errorCode) + } + } + private fun s3AvatarImageStorage(publicBaseUrl: String): S3AvatarImageStorage = S3AvatarImageStorage( s3Client = s3Client, + s3Presigner = s3Presigner, region = REGION, bucket = BUCKET, publicBaseUrl = publicBaseUrl, + presignedUrlExpirationSeconds = PRESIGNED_URL_EXPIRATION_SECONDS, ) companion object { @@ -110,5 +165,7 @@ class S3AvatarImageStorageTest { private const val REGION = "ap-northeast-2" private const val BUCKET = "aikon" private const val PUBLIC_BASE_URL = "http://localhost:9000/aikon" + private const val PRESIGNED_URL_EXPIRATION_SECONDS = 3600L + private const val PRESIGNED_URL = "https://example.com/avatars/1.png?X-Amz-Signature=signed" } } diff --git a/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/SubscribeAvatarChangesServiceImplTest.kt b/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/SubscribeAvatarChangesServiceImplTest.kt index 5259c55..6d5c800 100644 --- a/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/SubscribeAvatarChangesServiceImplTest.kt +++ b/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/SubscribeAvatarChangesServiceImplTest.kt @@ -31,6 +31,9 @@ class SubscribeAvatarChangesServiceImplTest { @Mock private lateinit var avatarRepository: AvatarRepository + @Mock + private lateinit var avatarImageStorage: AvatarImageStorage + @Mock private lateinit var eventPublisher: ApplicationEventPublisher @@ -47,6 +50,7 @@ class SubscribeAvatarChangesServiceImplTest { subscribeAvatarChangesService = SubscribeAvatarChangesServiceImpl( avatarRepository = avatarRepository, + avatarImageStorage = avatarImageStorage, eventPublisher = eventPublisher, transactionManager = transactionManager, timeoutMillis = 5000L, From 3e9a3371d7574b0f96aada95df312505f57e7923 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=95=98=EB=AF=BC?= Date: Fri, 31 Jul 2026 18:49:10 +0900 Subject: [PATCH 14/16] =?UTF-8?q?add=20::=20MinIO=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EA=B5=90=ED=99=98=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exchange/controller/ExchangeController.kt | 29 +++++++++++++ .../exchange/service/ExchangeFileService.kt | 43 +++++++++++++++++++ .../global/config/ExchangeMinioConfig.kt | 27 ++++++++++++ src/main/resources/application.yml | 7 +++ 4 files changed, 106 insertions(+) create mode 100644 src/main/kotlin/team/darkmoderap/aikon/domain/exchange/controller/ExchangeController.kt create mode 100644 src/main/kotlin/team/darkmoderap/aikon/domain/exchange/service/ExchangeFileService.kt create mode 100644 src/main/kotlin/team/darkmoderap/aikon/global/config/ExchangeMinioConfig.kt diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/exchange/controller/ExchangeController.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/exchange/controller/ExchangeController.kt new file mode 100644 index 0000000..bc940d3 --- /dev/null +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/exchange/controller/ExchangeController.kt @@ -0,0 +1,29 @@ +package team.darkmoderap.aikon.domain.exchange.controller + +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestPart +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile +import team.darkmoderap.aikon.domain.exchange.service.ExchangeFileService + +@RestController +@RequestMapping("/exchange") +class ExchangeController( + private val exchangeFileService: ExchangeFileService, +) { + @PostMapping("/upload", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + @ResponseStatus(HttpStatus.NO_CONTENT) + fun uploadIncoming( + @RequestPart("file") file: MultipartFile, + ) { + exchangeFileService.uploadIncoming(file) + } + + @GetMapping("/outgoing", produces = [MediaType.IMAGE_PNG_VALUE]) + fun downloadOutgoing(): ByteArray = exchangeFileService.downloadOutgoing() +} diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/exchange/service/ExchangeFileService.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/exchange/service/ExchangeFileService.kt new file mode 100644 index 0000000..20b386b --- /dev/null +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/exchange/service/ExchangeFileService.kt @@ -0,0 +1,43 @@ +package team.darkmoderap.aikon.domain.exchange.service + +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.MediaType +import org.springframework.stereotype.Service +import org.springframework.web.multipart.MultipartFile +import software.amazon.awssdk.core.sync.RequestBody +import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.model.GetObjectRequest +import software.amazon.awssdk.services.s3.model.PutObjectRequest + +@Service +class ExchangeFileService( + @Qualifier("exchangeS3Client") private val s3Client: S3Client, + @Value("\${exchange.minio.bucket}") private val bucket: String, +) { + fun uploadIncoming( + file: MultipartFile, + fileName: String = "Aikon500.png", + ) { + val request = + PutObjectRequest + .builder() + .bucket(bucket) + .key("incoming/$fileName") + .contentType(file.contentType ?: MediaType.APPLICATION_OCTET_STREAM_VALUE) + .build() + + file.inputStream.use { s3Client.putObject(request, RequestBody.fromInputStream(it, file.size)) } + } + + fun downloadOutgoing(fileName: String = "report500.png"): ByteArray { + val request = + GetObjectRequest + .builder() + .bucket(bucket) + .key("outgoing/$fileName") + .build() + + return s3Client.getObjectAsBytes(request).asByteArray() + } +} diff --git a/src/main/kotlin/team/darkmoderap/aikon/global/config/ExchangeMinioConfig.kt b/src/main/kotlin/team/darkmoderap/aikon/global/config/ExchangeMinioConfig.kt new file mode 100644 index 0000000..239231f --- /dev/null +++ b/src/main/kotlin/team/darkmoderap/aikon/global/config/ExchangeMinioConfig.kt @@ -0,0 +1,27 @@ +package team.darkmoderap.aikon.global.config + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.s3.S3Client +import java.net.URI + +@Configuration +class ExchangeMinioConfig { + @Bean("exchangeS3Client") + fun exchangeS3Client( + @Value("\${exchange.minio.endpoint}") endpoint: String, + @Value("\${exchange.minio.access-key}") accessKey: String, + @Value("\${exchange.minio.secret-key}") secretKey: String, + ): S3Client = + S3Client + .builder() + .endpointOverride(URI.create(endpoint)) + .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey))) + .region(Region.US_EAST_1) + .forcePathStyle(true) + .build() +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0b02def..bef4a59 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -40,3 +40,10 @@ aws: endpoint: ${S3_ENDPOINT:} path-style-access-enabled: ${S3_PATH_STYLE_ACCESS_ENABLED:true} presigned-url-expiration-seconds: ${S3_PRESIGNED_URL_EXPIRATION_SECONDS:3600} + +exchange: + minio: + endpoint: ${EXCHANGE_MINIO_ENDPOINT:http://localhost:9000} + access-key: ${EXCHANGE_MINIO_ACCESS_KEY:exchange-partner} + secret-key: ${EXCHANGE_MINIO_SECRET_KEY:} + bucket: ${EXCHANGE_MINIO_BUCKET:exchange} From 8304f4a17b4ed6250466e998b0d46e5ed048515e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A2=85=EC=9C=A4?= Date: Tue, 4 Aug 2026 13:51:43 +0900 Subject: [PATCH 15/16] =?UTF-8?q?update=20::=20SSE=20=EC=95=84=EB=B0=94?= =?UTF-8?q?=ED=83=80=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C?= =?UTF-8?q?=20=EC=A0=84=EC=B2=B4=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EB=8C=80?= =?UTF-8?q?=EC=8B=A0=20=ED=94=84=EB=A1=9C=EC=A0=9D=EC=85=98=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=20(#52)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../avatar/repository/AvatarRepository.kt | 2 +- .../repository/AvatarSummaryProjection.kt | 19 ++++++++++++++ .../SubscribeAvatarChangesServiceImpl.kt | 4 +-- .../SubscribeAvatarChangesServiceImplTest.kt | 26 +++++++++++-------- 4 files changed, 37 insertions(+), 14 deletions(-) create mode 100644 src/main/kotlin/team/darkmoderap/aikon/domain/avatar/repository/AvatarSummaryProjection.kt diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/repository/AvatarRepository.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/repository/AvatarRepository.kt index 1794caf..8ba3ed7 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/repository/AvatarRepository.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/repository/AvatarRepository.kt @@ -5,7 +5,7 @@ import org.springframework.data.jpa.repository.Query import team.darkmoderap.aikon.domain.avatar.entity.AvatarEntity interface AvatarRepository : JpaRepository { - fun findAllByOrderByIdAsc(): List + fun findAllByOrderByIdAsc(): List fun findByPassUrl(passUrl: String): AvatarEntity? diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/repository/AvatarSummaryProjection.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/repository/AvatarSummaryProjection.kt new file mode 100644 index 0000000..53edb4d --- /dev/null +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/repository/AvatarSummaryProjection.kt @@ -0,0 +1,19 @@ +package team.darkmoderap.aikon.domain.avatar.repository + +import team.darkmoderap.aikon.domain.avatar.entity.enum.AgeRange +import team.darkmoderap.aikon.domain.avatar.entity.enum.Gender +import team.darkmoderap.aikon.domain.avatar.entity.enum.GenerationStatus +import team.darkmoderap.aikon.domain.avatar.entity.enum.Style +import java.time.Instant + +interface AvatarSummaryProjection { + val id: Long + val nickname: String + val style: Style + val gender: Gender + val ageRange: AgeRange + val generationStatus: GenerationStatus + val imageUrl: String? + val passUrl: String? + val createdAt: Instant +} diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/SubscribeAvatarChangesServiceImpl.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/SubscribeAvatarChangesServiceImpl.kt index 66bf2de..27510f2 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/SubscribeAvatarChangesServiceImpl.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/SubscribeAvatarChangesServiceImpl.kt @@ -14,10 +14,10 @@ import org.springframework.transaction.event.TransactionalEventListener import org.springframework.transaction.support.TransactionTemplate import org.springframework.web.servlet.mvc.method.annotation.SseEmitter import team.darkmoderap.aikon.domain.avatar.dto.AvatarChangeResDto -import team.darkmoderap.aikon.domain.avatar.entity.AvatarEntity import team.darkmoderap.aikon.domain.avatar.event.AvatarListChangedEvent import team.darkmoderap.aikon.domain.avatar.event.AvatarSseSubscribedEvent import team.darkmoderap.aikon.domain.avatar.repository.AvatarRepository +import team.darkmoderap.aikon.domain.avatar.repository.AvatarSummaryProjection import team.darkmoderap.aikon.global.common.error.AikonException import team.darkmoderap.aikon.global.common.error.ErrorCode import java.util.concurrent.CopyOnWriteArrayList @@ -127,7 +127,7 @@ class SubscribeAvatarChangesServiceImpl( } } - private fun AvatarEntity.toChangeResDto(): AvatarChangeResDto = + private fun AvatarSummaryProjection.toChangeResDto(): AvatarChangeResDto = AvatarChangeResDto( id = id, nickname = nickname, diff --git a/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/SubscribeAvatarChangesServiceImplTest.kt b/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/SubscribeAvatarChangesServiceImplTest.kt index 6d5c800..f16de6a 100644 --- a/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/SubscribeAvatarChangesServiceImplTest.kt +++ b/src/test/kotlin/team/darkmoderap/aikon/domain/avatar/service/SubscribeAvatarChangesServiceImplTest.kt @@ -16,7 +16,6 @@ import org.springframework.transaction.PlatformTransactionManager import org.springframework.transaction.TransactionDefinition import org.springframework.transaction.TransactionStatus import org.springframework.web.servlet.mvc.method.annotation.SseEmitter -import team.darkmoderap.aikon.domain.avatar.entity.AvatarEntity import team.darkmoderap.aikon.domain.avatar.entity.enum.AgeRange import team.darkmoderap.aikon.domain.avatar.entity.enum.Gender import team.darkmoderap.aikon.domain.avatar.entity.enum.GenerationStatus @@ -24,7 +23,9 @@ import team.darkmoderap.aikon.domain.avatar.entity.enum.Style import team.darkmoderap.aikon.domain.avatar.event.AvatarListChangedEvent import team.darkmoderap.aikon.domain.avatar.event.AvatarSseSubscribedEvent import team.darkmoderap.aikon.domain.avatar.repository.AvatarRepository +import team.darkmoderap.aikon.domain.avatar.repository.AvatarSummaryProjection import team.darkmoderap.aikon.global.common.error.AikonException +import java.time.Instant @ExtendWith(MockitoExtension::class) class SubscribeAvatarChangesServiceImplTest { @@ -94,7 +95,7 @@ class SubscribeAvatarChangesServiceImplTest { fun `finds latest avatar list when event is received`() { // Given given(transactionManager.getTransaction(any(TransactionDefinition::class.java))).willReturn(transactionStatus) - given(avatarRepository.findAllByOrderByIdAsc()).willReturn(listOf(avatar())) + given(avatarRepository.findAllByOrderByIdAsc()).willReturn(listOf(avatarSummaryProjection())) subscribeAvatarChangesService.execute() // When @@ -121,14 +122,17 @@ class SubscribeAvatarChangesServiceImplTest { } companion object { - private fun avatar(): AvatarEntity = - AvatarEntity( - nickname = "새아바타", - gender = Gender.FEMALE, - style = Style.GHIBLI, - ageRange = AgeRange.AGE_20_PLUS, - generationStatus = GenerationStatus.WAITING, - passUrl = "Aikon500", - ) + private fun avatarSummaryProjection(): AvatarSummaryProjection = + object : AvatarSummaryProjection { + override val id: Long = 1L + override val nickname: String = "새아바타" + override val style: Style = Style.GHIBLI + override val gender: Gender = Gender.FEMALE + override val ageRange: AgeRange = AgeRange.AGE_20_PLUS + override val generationStatus: GenerationStatus = GenerationStatus.WAITING + override val imageUrl: String? = null + override val passUrl: String? = "Aikon500" + override val createdAt: Instant = Instant.now() + } } } From 6506dbc2c462229aec2989fce608770dff47b32a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=95=98=EB=AF=BC?= Date: Thu, 6 Aug 2026 08:46:29 +0900 Subject: [PATCH 16/16] =?UTF-8?q?update=20::=20Exchange=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EA=B2=BD=EB=A1=9C=20=EB=AA=85=EC=B9=AD=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/exchange/controller/ExchangeController.kt | 10 +++++----- .../domain/exchange/service/ExchangeFileService.kt | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/exchange/controller/ExchangeController.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/exchange/controller/ExchangeController.kt index bc940d3..045c5fc 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/domain/exchange/controller/ExchangeController.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/exchange/controller/ExchangeController.kt @@ -16,14 +16,14 @@ import team.darkmoderap.aikon.domain.exchange.service.ExchangeFileService class ExchangeController( private val exchangeFileService: ExchangeFileService, ) { - @PostMapping("/upload", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + @PostMapping("/avatar", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) @ResponseStatus(HttpStatus.NO_CONTENT) - fun uploadIncoming( + fun uploadAvatar( @RequestPart("file") file: MultipartFile, ) { - exchangeFileService.uploadIncoming(file) + exchangeFileService.uploadAvatar(file) } - @GetMapping("/outgoing", produces = [MediaType.IMAGE_PNG_VALUE]) - fun downloadOutgoing(): ByteArray = exchangeFileService.downloadOutgoing() + @GetMapping("/report", produces = [MediaType.IMAGE_PNG_VALUE]) + fun downloadReport(): ByteArray = exchangeFileService.downloadReport() } diff --git a/src/main/kotlin/team/darkmoderap/aikon/domain/exchange/service/ExchangeFileService.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/exchange/service/ExchangeFileService.kt index 20b386b..bb09f23 100644 --- a/src/main/kotlin/team/darkmoderap/aikon/domain/exchange/service/ExchangeFileService.kt +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/exchange/service/ExchangeFileService.kt @@ -15,7 +15,7 @@ class ExchangeFileService( @Qualifier("exchangeS3Client") private val s3Client: S3Client, @Value("\${exchange.minio.bucket}") private val bucket: String, ) { - fun uploadIncoming( + fun uploadAvatar( file: MultipartFile, fileName: String = "Aikon500.png", ) { @@ -23,19 +23,19 @@ class ExchangeFileService( PutObjectRequest .builder() .bucket(bucket) - .key("incoming/$fileName") + .key("avatar/$fileName") .contentType(file.contentType ?: MediaType.APPLICATION_OCTET_STREAM_VALUE) .build() file.inputStream.use { s3Client.putObject(request, RequestBody.fromInputStream(it, file.size)) } } - fun downloadOutgoing(fileName: String = "report500.png"): ByteArray { + fun downloadReport(fileName: String = "report500.png"): ByteArray { val request = GetObjectRequest .builder() .bucket(bucket) - .key("outgoing/$fileName") + .key("report/$fileName") .build() return s3Client.getObjectAsBytes(request).asByteArray()