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/controller/InternalAiAvatarGenerationController.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/controller/InternalAiAvatarGenerationController.kt new file mode 100644 index 0000000..3144fa1 --- /dev/null +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/controller/InternalAiAvatarGenerationController.kt @@ -0,0 +1,34 @@ +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 +import java.security.MessageDigest + +@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 (!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/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/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/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/entity/AvatarFeedback.kt b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/entity/AvatarFeedback.kt new file mode 100644 index 0000000..efaaddb --- /dev/null +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/entity/AvatarFeedback.kt @@ -0,0 +1,62 @@ +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: MutableList, + @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.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/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/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/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..2dede06 --- /dev/null +++ b/src/main/kotlin/team/darkmoderap/aikon/domain/avatar/service/CreateFeedbackServiceImpl.kt @@ -0,0 +1,92 @@ +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 +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.toMutableList(), + 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, + ) + + 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/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..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 @@ -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.trimEnd('/')}/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..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 @@ -42,6 +42,44 @@ 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 key = + if (s3Uri.startsWith("s3://")) { + s3Uri.substringAfter("s3://").substringAfter("/") + } else { + s3Uri + } + 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 +99,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..ade6a35 --- /dev/null +++ b/src/main/kotlin/team/darkmoderap/aikon/global/config/RestClientConfig.kt @@ -0,0 +1,26 @@ +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 +class RestClientConfig { + @Bean + fun fastApiRestClient( + @Value("\${fastapi.base-url}") baseUrl: String, + ): RestClient { + val requestFactory = + SimpleClientHttpRequestFactory().apply { + setConnectTimeout(5000) + setReadTimeout(10000) + } + return RestClient + .builder() + .requestFactory(requestFactory) + .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: 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..82915d7 --- /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 = mutableListOf(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 = mutableListOf(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 = mutableListOf(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() + } + } +}