Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3f40505
add :: ENHANCED 아바타 스타일 추가
cfcromn Jun 9, 2026
8665cb1
fix :: 아바타 스타일 리뷰 반영
cfcromn Jun 9, 2026
3a52e54
Merge pull request #40 from DarkMode-Lap/feat/add-enhanced-avatar-style
cfcromn Jun 9, 2026
5bac5c7
add :: FastAPI 아바타 생성 연동 구현
cfcromn Jun 21, 2026
9643352
add :: 피드백 도메인 구현
cfcromn Jun 21, 2026
8762980
test :: FastAPI 연동 및 피드백 테스트 추가
cfcromn Jun 21, 2026
da1371c
fix :: 리뷰 반영 - ElementCollection MutableList 수정, 트랜잭션 커밋 후 피드백 전송, Re…
cfcromn Jun 21, 2026
e561a01
fix :: 리뷰 반영 - 시크릿 상수 시간 비교, callbackUrl 슬래시 정규화, S3 URI 파싱 개선
cfcromn Jun 21, 2026
aee8242
Merge pull request #44 from DarkMode-Lap/feat/fastapi-integration-fee…
cfcromn Jun 21, 2026
20eed2c
add :: 로컬 개발 인프라에 Qdrant 추가
cfcromn Jun 24, 2026
3799d01
fix :: 트랜잭션 이벤트 리스너 초기화 오류 수정
cfcromn Jun 24, 2026
470c081
fix :: SSE 조회 트랜잭션 범위 최적화
cfcromn Jun 24, 2026
487192c
Merge pull request #46 from DarkMode-Lap/feat/45-add-qdrant
cfcromn Jun 24, 2026
ac9a49e
update :: STUDIO 스타일 제거하고 전 스타일 프롬프트에 보정 강화
cfcromn Jul 15, 2026
dff079f
test :: STUDIO 스타일 참조 테스트 픽스처 수정
cfcromn Jul 15, 2026
f5f2b7f
Merge pull request #48 from DarkMode-Lap/feat/47-remove-studio-style-…
cfcromn Jul 15, 2026
e4204b7
아바타 이미지 조회 URL을 S3 Presigned URL로 발급 (#49)
jyx-07 Jul 23, 2026
3e9a337
add :: MinIO 파일 교환 기능 추가
cfcromn Jul 31, 2026
17ecc00
Merge pull request #51 from Team-OTL/feat/add-minio-file-exchange
cfcromn Jul 31, 2026
8304f4a
update :: SSE 아바타 목록 조회 시 전체 엔티티 대신 프로젝션 사용 (#52)
jyx-07 Aug 4, 2026
aed718f
CI/CD 파이프라인 구성 추가
chaeeun-09 Aug 5, 2026
6506dbc
update :: Exchange 파일 경로 명칭 변경
cfcromn Aug 5, 2026
c3dc6f1
Merge pull request #54 from Team-OTL/update/exchange-avatar-report-paths
cfcromn Aug 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@ 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
S3_BUCKET=String
S3_ENDPOINT=String
S3_PUBLIC_BASE_URL=String
S3_PATH_STYLE_ACCESS_ENABLED=String
S3_PRESIGNED_URL_EXPIRATION_SECONDS=Number
10 changes: 10 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
) {
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 = Style.SCHEMA_DESCRIPTION)
val style: Style,
@Schema(description = "성별 (MALE, FEMALE)")
val gender: Gender,
Expand Down
Original file line number Diff line number Diff line change
@@ -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?,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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 = Style.SCHEMA_DESCRIPTION)
@field:NotNull
val style: Style?,
@Schema(description = "연령대 (AGE_0_7, AGE_8_13, AGE_14_19, AGE_20_PLUS)")
Expand Down
Original file line number Diff line number Diff line change
@@ -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<FeedbackReason>?,
val comment: String?,
@field:NotNull
val trainingConsent: Boolean?,
@field:NotNull
val feedbackUseConsent: Boolean?,
)
Original file line number Diff line number Diff line change
@@ -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<String>,
val comment: String?,
val trainingConsent: Boolean,
val feedbackUseConsent: Boolean,
val style: String,
val gender: String,
val ageRange: String,
val promptVersion: String?,
val modelName: String?,
)
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package team.darkmoderap.aikon.domain.avatar.dto

data class FastApiGenerationResDto(
val jobId: String,
val status: String,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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 = Style.SCHEMA_DESCRIPTION)
val style: Style,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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,
Expand All @@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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<FeedbackReason>,
@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<FeedbackReason>,
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
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package team.darkmoderap.aikon.domain.avatar.entity.enum

enum class FeedbackRating {
LIKE,
DISLIKE,
}
Original file line number Diff line number Diff line change
@@ -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,
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
package team.darkmoderap.aikon.domain.avatar.entity.enum

enum class Style {
STUDIO,
ENHANCED,
ZOOTOPIA,
TRADITIONAL_HANBOK,
DISNEY_PIXAR,
GHIBLI,
LIGHT_ART,
;

companion object {
const val SCHEMA_DESCRIPTION =
"스타일 (ENHANCED, ZOOTOPIA, TRADITIONAL_HANBOK, DISNEY_PIXAR, GHIBLI, LIGHT_ART)"
}
}
Original file line number Diff line number Diff line change
@@ -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<AvatarFeedback, Long> {
fun findByAvatarId(avatarId: Long): AvatarFeedback?
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import org.springframework.data.jpa.repository.Query
import team.darkmoderap.aikon.domain.avatar.entity.AvatarEntity

interface AvatarRepository : JpaRepository<AvatarEntity, Long> {
fun findAllByOrderByIdAsc(): List<AvatarEntity>
fun findAllByOrderByIdAsc(): List<AvatarSummaryProjection>

fun findByPassUrl(passUrl: String): AvatarEntity?

Expand Down
Loading
Loading