feat(chat-character-image): 캐릭터 이미지
- 등록, 리스트, 상세, 트리거 단어 업데이트, 삭제 기능 추가
This commit is contained in:
parent
ca27903e45
commit
dd6849b840
|
@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<String>? = null
|
||||
)
|
||||
|
||||
data class UpdateCharacterImageTriggersRequest(
|
||||
@JsonProperty("triggers") val triggers: List<String>? = null
|
||||
)
|
||||
|
||||
data class UpdateCharacterImageOrdersRequest(
|
||||
@JsonProperty("characterId") val characterId: Long?,
|
||||
@JsonProperty("ids") val ids: List<Long>
|
||||
)
|
||||
|
||||
// 응답 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<String>
|
||||
) {
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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<CharacterImageTriggerMapping> = mutableListOf()
|
||||
}
|
|
@ -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<CharacterImage, Long> {
|
||||
fun findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(characterId: Long): List<CharacterImage>
|
||||
|
||||
@Query(
|
||||
"SELECT COALESCE(MAX(ci.sortOrder), 0) FROM CharacterImage ci " +
|
||||
"WHERE ci.chatCharacter.id = :characterId AND ci.isActive = true"
|
||||
)
|
||||
fun findMaxSortOrderByCharacterId(characterId: Long): Int
|
||||
}
|
|
@ -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<CharacterImage> {
|
||||
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<String>
|
||||
): 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<String>): CharacterImage {
|
||||
val image = getById(imageId)
|
||||
if (!image.isActive) throw SodaException("비활성화된 이미지는 수정할 수 없습니다: $imageId")
|
||||
applyTriggers(image, triggers)
|
||||
return image
|
||||
}
|
||||
|
||||
private fun applyTriggers(image: CharacterImage, triggers: List<String>) {
|
||||
// 입력 트리거 정규화
|
||||
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<Long>): List<CharacterImage> {
|
||||
// 동일 캐릭터 소속 검증 및 순서 재지정
|
||||
val updated = mutableListOf<CharacterImage>()
|
||||
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
|
||||
}
|
||||
}
|
|
@ -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<CharacterImageTriggerMapping> = mutableListOf()
|
||||
}
|
|
@ -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()
|
|
@ -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<CharacterImageTrigger, Long> {
|
||||
fun findByWord(word: String): CharacterImageTrigger?
|
||||
}
|
Loading…
Reference in New Issue