diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt new file mode 100644 index 0000000..2c181c3 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt @@ -0,0 +1,125 @@ +package kr.co.vividnext.sodalive.admin.chat.character.image + +import com.amazonaws.services.s3.model.ObjectMetadata +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.admin.chat.character.image.dto.AdminCharacterImageResponse +import kr.co.vividnext.sodalive.admin.chat.character.image.dto.RegisterCharacterImageRequest +import kr.co.vividnext.sodalive.admin.chat.character.image.dto.UpdateCharacterImageOrdersRequest +import kr.co.vividnext.sodalive.admin.chat.character.image.dto.UpdateCharacterImageTriggersRequest +import kr.co.vividnext.sodalive.aws.cloudfront.ImageContentCloudFront +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +import kr.co.vividnext.sodalive.chat.character.image.CharacterImageService +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.utils.generateFileName +import org.springframework.beans.factory.annotation.Value +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RequestPart +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile + +@RestController +@RequestMapping("/admin/chat/character/image") +@PreAuthorize("hasRole('ADMIN')") +class AdminCharacterImageController( + private val imageService: CharacterImageService, + private val s3Uploader: S3Uploader, + private val imageCloudFront: ImageContentCloudFront, + + @Value("\${cloud.aws.s3.content-bucket}") + private val s3Bucket: String +) { + + @GetMapping("/list") + fun list(@RequestParam characterId: Long) = run { + val expiration = 5L * 60L * 1000L // 5분 + val list = imageService.listActiveByCharacter(characterId) + .map { img -> + val signedUrl = imageCloudFront.generateSignedURL(img.imagePath, expiration) + AdminCharacterImageResponse.fromWithUrl(img, signedUrl) + } + ApiResponse.ok(list) + } + + @GetMapping("/{imageId}") + fun detail(@PathVariable imageId: Long) = run { + val img = imageService.getById(imageId) + val expiration = 5L * 60L * 1000L // 5분 + val signedUrl = imageCloudFront.generateSignedURL(img.imagePath, expiration) + ApiResponse.ok(AdminCharacterImageResponse.fromWithUrl(img, signedUrl)) + } + + @PostMapping("/register") + fun register( + @RequestPart("image") image: MultipartFile, + @RequestPart("request") requestString: String + ) = run { + val objectMapper = ObjectMapper() + val request = objectMapper.readValue(requestString, RegisterCharacterImageRequest::class.java) + + // 1) 임시 경로로 엔티티 생성하기 전에 파일 업로드 경로 계산 + val tempKey = buildS3Key(characterId = request.characterId) + val imagePath = saveImage(tempKey, image) + + imageService.registerImage( + characterId = request.characterId, + imagePath = imagePath, + price = request.price, + isAdult = request.isAdult, + triggers = request.triggers ?: emptyList() + ) + + ApiResponse.ok(null) + } + + @PutMapping("/{imageId}/triggers") + fun updateTriggers( + @PathVariable imageId: Long, + @RequestBody request: UpdateCharacterImageTriggersRequest + ) = run { + imageService.updateTriggers(imageId, request.triggers ?: emptyList()) + + ApiResponse.ok(null) + } + + @DeleteMapping("/{imageId}") + fun delete(@PathVariable imageId: Long) = run { + imageService.deleteImage(imageId) + ApiResponse.ok(null, "이미지가 삭제되었습니다.") + } + + @PutMapping("/orders") + fun updateOrders(@RequestBody request: UpdateCharacterImageOrdersRequest) = run { + if (request.characterId == null) throw SodaException("characterId는 필수입니다") + imageService.updateOrders(request.characterId, request.ids) + ApiResponse.ok(null, "정렬 순서가 변경되었습니다.") + } + + private fun buildS3Key(characterId: Long): String { + val fileName = generateFileName("character-image") + return "characters/$characterId/images/$fileName" + } + + private fun saveImage(filePath: String, image: MultipartFile): String { + try { + val metadata = ObjectMetadata() + metadata.contentLength = image.size + return s3Uploader.upload( + inputStream = image.inputStream, + bucket = s3Bucket, + filePath = filePath, + metadata = metadata + ) + } catch (e: Exception) { + throw SodaException("이미지 저장에 실패했습니다: ${e.message}") + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/dto/CharacterImageDtos.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/dto/CharacterImageDtos.kt new file mode 100644 index 0000000..f007a02 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/dto/CharacterImageDtos.kt @@ -0,0 +1,54 @@ +package kr.co.vividnext.sodalive.admin.chat.character.image.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import kr.co.vividnext.sodalive.chat.character.image.CharacterImage + +// 요청 DTOs + +data class RegisterCharacterImageRequest( + @JsonProperty("characterId") val characterId: Long, + @JsonProperty("price") val price: Long, + @JsonProperty("isAdult") val isAdult: Boolean = false, + @JsonProperty("triggers") val triggers: List? = null +) + +data class UpdateCharacterImageTriggersRequest( + @JsonProperty("triggers") val triggers: List? = null +) + +data class UpdateCharacterImageOrdersRequest( + @JsonProperty("characterId") val characterId: Long?, + @JsonProperty("ids") val ids: List +) + +// 응답 DTOs + +data class AdminCharacterImageResponse( + val id: Long, + val characterId: Long, + val price: Long, + val isAdult: Boolean, + val sortOrder: Int, + val active: Boolean, + val imageUrl: String, + val triggers: List +) { + companion object { + fun fromWithUrl(entity: CharacterImage, signedUrl: String): AdminCharacterImageResponse { + return base(entity, signedUrl) + } + + private fun base(entity: CharacterImage, url: String): AdminCharacterImageResponse { + return AdminCharacterImageResponse( + id = entity.id!!, + characterId = entity.chatCharacter.id!!, + price = entity.price, + isAdult = entity.isAdult, + sortOrder = entity.sortOrder, + active = entity.isActive, + imageUrl = url, + triggers = entity.triggerMappings.map { it.tag.word } + ) + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/aws/cloudfront/ImageContentCloudFront.kt b/src/main/kotlin/kr/co/vividnext/sodalive/aws/cloudfront/ImageContentCloudFront.kt new file mode 100644 index 0000000..c8858df --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/aws/cloudfront/ImageContentCloudFront.kt @@ -0,0 +1,48 @@ +package kr.co.vividnext.sodalive.aws.cloudfront + +import com.amazonaws.services.cloudfront.CloudFrontUrlSigner +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.nio.file.Files +import java.nio.file.Paths +import java.security.KeyFactory +import java.security.PrivateKey +import java.security.spec.PKCS8EncodedKeySpec +import java.util.Date + +/** + * 이미지(CloudFront) 서명 URL 생성기 + * - cloud.aws.cloud-front.* 설정을 사용 + */ +@Component +class ImageContentCloudFront( + @Value("\${cloud.aws.cloud-front.host}") + private val cloudfrontDomain: String, + + @Value("\${cloud.aws.cloud-front.private-key-file-path}") + private val privateKeyFilePath: String, + + @Value("\${cloud.aws.cloud-front.key-pair-id}") + private val keyPairId: String +) { + fun generateSignedURL( + resourcePath: String, + expirationTimeMillis: Long + ): String { + val privateKey = loadPrivateKey(privateKeyFilePath) + return CloudFrontUrlSigner.getSignedURLWithCannedPolicy( + "$cloudfrontDomain/$resourcePath", + keyPairId, + privateKey, + Date(System.currentTimeMillis() + expirationTimeMillis) + ) + } + + private fun loadPrivateKey(resourceName: String): PrivateKey { + val path = Paths.get(resourceName) + val bytes = Files.readAllBytes(path) + val keySpec = PKCS8EncodedKeySpec(bytes) + val keyFactory = KeyFactory.getInstance("RSA") + return keyFactory.generatePrivate(keySpec) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImage.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImage.kt new file mode 100644 index 0000000..350c7a2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImage.kt @@ -0,0 +1,35 @@ +package kr.co.vividnext.sodalive.chat.character.image + +import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.CascadeType +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne +import javax.persistence.OneToMany + +@Entity +class CharacterImage( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "character_id") + var chatCharacter: ChatCharacter, + + // 이미지 경로 (S3 key) + var imagePath: String, + + // 가격 (메시지/이미지 통합 단일가 - 요구사항 범위) + var price: Long = 0L, + + // 성인 이미지 여부 (본인인증 필요) + var isAdult: Boolean = false, + + // 갤러리/관리자 노출 순서 (낮을수록 먼저) + var sortOrder: Int = 0, + + // 활성화 여부 (소프트 삭제) + var isActive: Boolean = true +) : BaseEntity() { + @OneToMany(mappedBy = "characterImage", cascade = [CascadeType.ALL], orphanRemoval = true) + var triggerMappings: MutableList = mutableListOf() +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageRepository.kt new file mode 100644 index 0000000..3c6de00 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageRepository.kt @@ -0,0 +1,16 @@ +package kr.co.vividnext.sodalive.chat.character.image + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.stereotype.Repository + +@Repository +interface CharacterImageRepository : JpaRepository { + fun findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(characterId: Long): List + + @Query( + "SELECT COALESCE(MAX(ci.sortOrder), 0) FROM CharacterImage ci " + + "WHERE ci.chatCharacter.id = :characterId AND ci.isActive = true" + ) + fun findMaxSortOrderByCharacterId(characterId: Long): Int +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt new file mode 100644 index 0000000..6cf8768 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt @@ -0,0 +1,99 @@ +package kr.co.vividnext.sodalive.chat.character.image + +import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository +import kr.co.vividnext.sodalive.common.SodaException +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class CharacterImageService( + private val characterRepository: ChatCharacterRepository, + private val imageRepository: CharacterImageRepository, + private val triggerTagRepository: CharacterImageTriggerRepository +) { + + fun listActiveByCharacter(characterId: Long): List { + return imageRepository.findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(characterId) + } + + fun getById(id: Long): CharacterImage = + imageRepository.findById(id).orElseThrow { SodaException("캐릭터 이미지를 찾을 수 없습니다: $id") } + + @Transactional + fun registerImage( + characterId: Long, + imagePath: String, + price: Long, + isAdult: Boolean, + triggers: List + ): CharacterImage { + val character = characterRepository.findById(characterId) + .orElseThrow { SodaException("캐릭터를 찾을 수 없습니다: $characterId") } + + if (!character.isActive) throw SodaException("비활성화된 캐릭터에는 이미지를 등록할 수 없습니다: $characterId") + + val nextOrder = (imageRepository.findMaxSortOrderByCharacterId(characterId)) + 1 + val entity = CharacterImage( + chatCharacter = character, + imagePath = imagePath, + price = price, + isAdult = isAdult, + sortOrder = nextOrder, + isActive = true + ) + val saved = imageRepository.save(entity) + applyTriggers(saved, triggers) + return saved + } + + /** + * 수정은 트리거만 가능 + */ + @Transactional + fun updateTriggers(imageId: Long, triggers: List): CharacterImage { + val image = getById(imageId) + if (!image.isActive) throw SodaException("비활성화된 이미지는 수정할 수 없습니다: $imageId") + applyTriggers(image, triggers) + return image + } + + private fun applyTriggers(image: CharacterImage, triggers: List) { + // 입력 트리거 정규화 + val newWords = triggers.mapNotNull { it.trim().lowercase().takeIf { s -> s.isNotBlank() } }.distinct().toSet() + + // 현재 매핑 단어 집합 + val currentMappings = image.triggerMappings + val currentWords = currentMappings.map { it.tag.word }.toSet() + + // 제거되어야 할 매핑(현재는 있지만 새 입력에는 없는 단어) + val toRemove = currentMappings.filter { it.tag.word !in newWords } + currentMappings.removeAll(toRemove) + + // 추가되어야 할 단어(새 입력에는 있지만 현재는 없는 단어) + val toAdd = newWords.minus(currentWords) + toAdd.forEach { w -> + val tag = triggerTagRepository.findByWord(w) ?: triggerTagRepository.save(CharacterImageTrigger(word = w)) + currentMappings.add(CharacterImageTriggerMapping(characterImage = image, tag = tag)) + } + } + + @Transactional + fun deleteImage(imageId: Long) { + val image = getById(imageId) + image.isActive = false + } + + @Transactional + fun updateOrders(characterId: Long, ids: List): List { + // 동일 캐릭터 소속 검증 및 순서 재지정 + val updated = mutableListOf() + ids.forEachIndexed { idx, id -> + val img = getById(id) + if (img.chatCharacter.id != characterId) throw SodaException("다른 캐릭터의 이미지가 포함되어 있습니다: $id") + if (!img.isActive) throw SodaException("비활성화된 이미지는 순서를 변경할 수 없습니다: $id") + img.sortOrder = idx + 1 + updated.add(img) + } + return updated + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageTrigger.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageTrigger.kt new file mode 100644 index 0000000..4335e57 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageTrigger.kt @@ -0,0 +1,24 @@ +package kr.co.vividnext.sodalive.chat.character.image + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.OneToMany +import javax.persistence.Table +import javax.persistence.UniqueConstraint + +/** + * 캐릭터 이미지 트리거 "태그" 엔티티 + * - word를 전역 고유로 관리하여 중복 단어 저장을 방지한다. + * - 이미지와의 연결은 CharacterImageTriggerMapping을 사용한다. + */ +@Entity +@Table(uniqueConstraints = [UniqueConstraint(columnNames = ["word"])]) +class CharacterImageTrigger( + @Column(nullable = false) + var word: String +) : BaseEntity() { + @OneToMany(mappedBy = "tag", fetch = FetchType.LAZY) + var mappings: MutableList = mutableListOf() +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageTriggerMapping.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageTriggerMapping.kt new file mode 100644 index 0000000..746cbef --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageTriggerMapping.kt @@ -0,0 +1,21 @@ +package kr.co.vividnext.sodalive.chat.character.image + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne + +/** + * CharacterImage 와 CharacterImageTrigger(태그) 사이의 매핑 엔티티 + */ +@Entity +class CharacterImageTriggerMapping( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "character_image_id") + var characterImage: CharacterImage, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "tag_id") + var tag: CharacterImageTrigger +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageTriggerRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageTriggerRepository.kt new file mode 100644 index 0000000..537f308 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageTriggerRepository.kt @@ -0,0 +1,9 @@ +package kr.co.vividnext.sodalive.chat.character.image + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface CharacterImageTriggerRepository : JpaRepository { + fun findByWord(word: String): CharacterImageTrigger? +}