Compare commits
5 Commits
ca27903e45
...
13fd262c94
Author | SHA1 | Date | |
---|---|---|---|
13fd262c94 | |||
8451cdfb80 | |||
c8841856c0 | |||
2a30b28e43 | |||
dd6849b840 |
@@ -0,0 +1,169 @@
|
|||||||
|
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,
|
||||||
|
|
||||||
|
@Value("\${cloud.aws.s3.bucket}")
|
||||||
|
private val freeBucket: 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)
|
||||||
|
|
||||||
|
// 업로드 키 생성
|
||||||
|
val s3Key = buildS3Key(characterId = request.characterId)
|
||||||
|
|
||||||
|
// 원본 저장 (content-bucket)
|
||||||
|
val imagePath = saveImageToBucket(s3Key, image, s3Bucket)
|
||||||
|
|
||||||
|
// 블러 생성 및 저장 (무료 이미지 버킷)
|
||||||
|
val blurImagePath = saveBlurImageToBucket(s3Key, image, freeBucket)
|
||||||
|
|
||||||
|
imageService.registerImage(
|
||||||
|
characterId = request.characterId,
|
||||||
|
imagePath = imagePath,
|
||||||
|
blurImagePath = blurImagePath,
|
||||||
|
imagePriceCan = request.imagePriceCan,
|
||||||
|
messagePriceCan = request.messagePriceCan,
|
||||||
|
isAdult = request.isAdult,
|
||||||
|
triggers = request.triggers ?: emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
ApiResponse.ok(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{imageId}/triggers")
|
||||||
|
fun updateTriggers(
|
||||||
|
@PathVariable imageId: Long,
|
||||||
|
@RequestBody request: UpdateCharacterImageTriggersRequest
|
||||||
|
) = run {
|
||||||
|
if (!request.triggers.isNullOrEmpty()) {
|
||||||
|
imageService.updateTriggers(imageId, request.triggers)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 saveImageToBucket(filePath: String, image: MultipartFile, bucket: String): String {
|
||||||
|
try {
|
||||||
|
val metadata = ObjectMetadata()
|
||||||
|
metadata.contentLength = image.size
|
||||||
|
return s3Uploader.upload(
|
||||||
|
inputStream = image.inputStream,
|
||||||
|
bucket = bucket,
|
||||||
|
filePath = filePath,
|
||||||
|
metadata = metadata
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveBlurImageToBucket(filePath: String, image: MultipartFile, bucket: String): String {
|
||||||
|
try {
|
||||||
|
// 멀티파트를 BufferedImage로 읽기
|
||||||
|
val bytes = image.bytes
|
||||||
|
val bimg = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes))
|
||||||
|
?: throw SodaException("이미지 포맷을 인식할 수 없습니다.")
|
||||||
|
val blurred = kr.co.vividnext.sodalive.utils.ImageBlurUtil.blur(bimg, 12)
|
||||||
|
|
||||||
|
// PNG로 저장(알파 유지), JPEG 업로드가 필요하면 포맷 변경 가능
|
||||||
|
val baos = java.io.ByteArrayOutputStream()
|
||||||
|
val format = when (image.contentType?.lowercase()) {
|
||||||
|
"image/png" -> "png"
|
||||||
|
else -> "jpg"
|
||||||
|
}
|
||||||
|
javax.imageio.ImageIO.write(blurred, format, baos)
|
||||||
|
val inputStream = java.io.ByteArrayInputStream(baos.toByteArray())
|
||||||
|
|
||||||
|
val metadata = ObjectMetadata()
|
||||||
|
metadata.contentLength = baos.size().toLong()
|
||||||
|
metadata.contentType = image.contentType ?: if (format == "png") "image/png" else "image/jpeg"
|
||||||
|
|
||||||
|
return s3Uploader.upload(
|
||||||
|
inputStream = inputStream,
|
||||||
|
bucket = bucket,
|
||||||
|
filePath = filePath,
|
||||||
|
metadata = metadata
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw SodaException("블러 이미지 저장에 실패했습니다: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,57 @@
|
|||||||
|
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("imagePriceCan") val imagePriceCan: Long,
|
||||||
|
@JsonProperty("messagePriceCan") val messagePriceCan: 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 imagePriceCan: Long,
|
||||||
|
val messagePriceCan: 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!!,
|
||||||
|
imagePriceCan = entity.imagePriceCan,
|
||||||
|
messagePriceCan = entity.messagePriceCan,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
@@ -72,6 +72,8 @@ class CanService(private val repository: CanRepository) {
|
|||||||
CanUsage.ORDER_CONTENT -> "[콘텐츠 구매] ${it.audioContent!!.title}"
|
CanUsage.ORDER_CONTENT -> "[콘텐츠 구매] ${it.audioContent!!.title}"
|
||||||
CanUsage.PAID_COMMUNITY_POST -> "[게시글 보기] ${it.communityPost?.member?.nickname ?: ""}"
|
CanUsage.PAID_COMMUNITY_POST -> "[게시글 보기] ${it.communityPost?.member?.nickname ?: ""}"
|
||||||
CanUsage.AUDITION_VOTE -> "[오디션 투표] ${it.auditionApplicant?.role?.audition?.title ?: ""}"
|
CanUsage.AUDITION_VOTE -> "[오디션 투표] ${it.auditionApplicant?.role?.audition?.title ?: ""}"
|
||||||
|
CanUsage.CHAT_MESSAGE_PURCHASE -> "[메시지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}"
|
||||||
|
CanUsage.CHARACTER_IMAGE_PURCHASE -> "[캐릭터 이미지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}"
|
||||||
}
|
}
|
||||||
|
|
||||||
val createdAt = it.createdAt!!
|
val createdAt = it.createdAt!!
|
||||||
|
@@ -9,5 +9,7 @@ enum class CanUsage {
|
|||||||
SPIN_ROULETTE,
|
SPIN_ROULETTE,
|
||||||
PAID_COMMUNITY_POST,
|
PAID_COMMUNITY_POST,
|
||||||
ALARM_SLOT,
|
ALARM_SLOT,
|
||||||
AUDITION_VOTE
|
AUDITION_VOTE,
|
||||||
|
CHAT_MESSAGE_PURCHASE, // 메시지를 통한 구매(이미지 등 다양한 리소스에 공통 적용)
|
||||||
|
CHARACTER_IMAGE_PURCHASE // 캐릭터 이미지 단독 구매
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,8 @@
|
|||||||
package kr.co.vividnext.sodalive.can.use
|
package kr.co.vividnext.sodalive.can.use
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.audition.AuditionApplicant
|
import kr.co.vividnext.sodalive.audition.AuditionApplicant
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.image.CharacterImage
|
||||||
|
import kr.co.vividnext.sodalive.chat.room.ChatMessage
|
||||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||||
import kr.co.vividnext.sodalive.content.AudioContent
|
import kr.co.vividnext.sodalive.content.AudioContent
|
||||||
import kr.co.vividnext.sodalive.content.order.Order
|
import kr.co.vividnext.sodalive.content.order.Order
|
||||||
@@ -58,6 +60,16 @@ data class UseCan(
|
|||||||
@JoinColumn(name = "audition_applicant_id", nullable = true)
|
@JoinColumn(name = "audition_applicant_id", nullable = true)
|
||||||
var auditionApplicant: AuditionApplicant? = null
|
var auditionApplicant: AuditionApplicant? = null
|
||||||
|
|
||||||
|
// 메시지를 통한 구매 연관 (옵션)
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "chat_message_id", nullable = true)
|
||||||
|
var chatMessage: ChatMessage? = null
|
||||||
|
|
||||||
|
// 캐릭터 이미지 연관 (메시지 구매/단독 구매 공통 사용)
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "character_image_id", nullable = true)
|
||||||
|
var characterImage: CharacterImage? = null
|
||||||
|
|
||||||
@OneToMany(mappedBy = "useCan", cascade = [CascadeType.ALL])
|
@OneToMany(mappedBy = "useCan", cascade = [CascadeType.ALL])
|
||||||
val useCanCalculates: MutableList<UseCanCalculate> = mutableListOf()
|
val useCanCalculates: MutableList<UseCanCalculate> = mutableListOf()
|
||||||
}
|
}
|
||||||
|
@@ -6,10 +6,22 @@ import org.springframework.data.jpa.repository.JpaRepository
|
|||||||
import org.springframework.stereotype.Repository
|
import org.springframework.stereotype.Repository
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
interface UseCanRepository : JpaRepository<UseCan, Long>, UseCanQueryRepository
|
interface UseCanRepository : JpaRepository<UseCan, Long>, UseCanQueryRepository {
|
||||||
|
// 특정 멤버가 해당 이미지에 대해 구매 이력이 있는지(환불 제외)
|
||||||
|
fun existsByMember_IdAndIsRefundFalseAndCharacterImage_IdAndCanUsageIn(
|
||||||
|
memberId: Long,
|
||||||
|
imageId: Long,
|
||||||
|
usages: Collection<CanUsage>
|
||||||
|
): Boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface UseCanQueryRepository {
|
interface UseCanQueryRepository {
|
||||||
fun isExistCommunityPostOrdered(postId: Long, memberId: Long): Boolean
|
fun isExistCommunityPostOrdered(postId: Long, memberId: Long): Boolean
|
||||||
|
fun countPurchasedActiveImagesByCharacter(
|
||||||
|
memberId: Long,
|
||||||
|
characterId: Long,
|
||||||
|
usages: Collection<CanUsage>
|
||||||
|
): Long
|
||||||
}
|
}
|
||||||
|
|
||||||
class UseCanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : UseCanQueryRepository {
|
class UseCanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : UseCanQueryRepository {
|
||||||
@@ -26,4 +38,24 @@ class UseCanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : Use
|
|||||||
|
|
||||||
return useCanId != null && useCanId > 0
|
return useCanId != null && useCanId > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun countPurchasedActiveImagesByCharacter(
|
||||||
|
memberId: Long,
|
||||||
|
characterId: Long,
|
||||||
|
usages: Collection<CanUsage>
|
||||||
|
): Long {
|
||||||
|
val count = queryFactory
|
||||||
|
.selectDistinct(useCan.characterImage.id)
|
||||||
|
.from(useCan)
|
||||||
|
.where(
|
||||||
|
useCan.member.id.eq(memberId)
|
||||||
|
.and(useCan.isRefund.isFalse)
|
||||||
|
.and(useCan.characterImage.chatCharacter.id.eq(characterId))
|
||||||
|
.and(useCan.characterImage.isActive.isTrue)
|
||||||
|
.and(useCan.canUsage.`in`(usages))
|
||||||
|
)
|
||||||
|
.fetch()
|
||||||
|
.size
|
||||||
|
return count.toLong()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -0,0 +1,41 @@
|
|||||||
|
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 - content-bucket)
|
||||||
|
var imagePath: String,
|
||||||
|
|
||||||
|
// 블러 이미지 경로 (S3 key - free/public bucket)
|
||||||
|
var blurImagePath: String,
|
||||||
|
|
||||||
|
// 이미지 단독 구매 가격 (단위: can)
|
||||||
|
var imagePriceCan: Long = 0L,
|
||||||
|
|
||||||
|
// 메시지를 통한 가격 (단위: can)
|
||||||
|
var messagePriceCan: 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,71 @@
|
|||||||
|
package kr.co.vividnext.sodalive.chat.character.image
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.aws.cloudfront.ImageContentCloudFront
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.image.dto.CharacterImageListItemResponse
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.image.dto.CharacterImageListResponse
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.data.domain.PageRequest
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/chat/character/image")
|
||||||
|
class CharacterImageController(
|
||||||
|
private val imageService: CharacterImageService,
|
||||||
|
private val imageCloudFront: ImageContentCloudFront,
|
||||||
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
|
private val imageHost: String
|
||||||
|
) {
|
||||||
|
|
||||||
|
@GetMapping("/list")
|
||||||
|
fun list(
|
||||||
|
@RequestParam characterId: Long,
|
||||||
|
@RequestParam(required = false, defaultValue = "0") page: Int,
|
||||||
|
@RequestParam(required = false, defaultValue = "20") size: Int,
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
|
) = run {
|
||||||
|
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||||
|
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
||||||
|
|
||||||
|
val pageSize = if (size <= 0) 20 else minOf(size, 20)
|
||||||
|
val pageable = PageRequest.of(page, pageSize)
|
||||||
|
|
||||||
|
val pageResult = imageService.pageActiveByCharacter(characterId, pageable)
|
||||||
|
val totalCount = pageResult.totalElements
|
||||||
|
|
||||||
|
// 현재 요구사항 기준 '무료=보유'로 계산 (구매 이력은 추후 확장)
|
||||||
|
val ownedCount = imageService.countOwnedActiveByCharacterForMember(characterId, member.id!!)
|
||||||
|
|
||||||
|
val expiration = 5L * 60L * 1000L // 5분
|
||||||
|
val items = pageResult.content.map { img ->
|
||||||
|
val isOwned = (img.imagePriceCan == 0L) || imageService.isOwnedImageByMember(img.id!!, member.id!!)
|
||||||
|
val url = if (isOwned) {
|
||||||
|
imageCloudFront.generateSignedURL(img.imagePath, expiration)
|
||||||
|
} else {
|
||||||
|
"$imageHost/${img.blurImagePath}"
|
||||||
|
}
|
||||||
|
CharacterImageListItemResponse(
|
||||||
|
id = img.id!!,
|
||||||
|
imageUrl = url,
|
||||||
|
isOwned = isOwned,
|
||||||
|
imagePriceCan = img.imagePriceCan,
|
||||||
|
isAdult = img.isAdult,
|
||||||
|
sortOrder = img.sortOrder
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiResponse.ok(
|
||||||
|
CharacterImageListResponse(
|
||||||
|
totalCount = totalCount,
|
||||||
|
ownedCount = ownedCount,
|
||||||
|
items = items
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,25 @@
|
|||||||
|
package kr.co.vividnext.sodalive.chat.character.image
|
||||||
|
|
||||||
|
import org.springframework.data.domain.Page
|
||||||
|
import org.springframework.data.domain.Pageable
|
||||||
|
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>
|
||||||
|
|
||||||
|
fun findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(
|
||||||
|
characterId: Long,
|
||||||
|
pageable: Pageable
|
||||||
|
): Page<CharacterImage>
|
||||||
|
|
||||||
|
fun countByChatCharacterIdAndIsActiveTrueAndImagePriceCan(characterId: Long, imagePriceCan: Long): Long
|
||||||
|
|
||||||
|
@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,142 @@
|
|||||||
|
package kr.co.vividnext.sodalive.chat.character.image
|
||||||
|
|
||||||
|
// ktlint-disable standard:max-line-length
|
||||||
|
import kr.co.vividnext.sodalive.can.use.CanUsage
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import org.springframework.data.domain.Page
|
||||||
|
import org.springframework.data.domain.Pageable
|
||||||
|
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,
|
||||||
|
private val useCanRepository: kr.co.vividnext.sodalive.can.use.UseCanRepository
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun listActiveByCharacter(characterId: Long): List<CharacterImage> {
|
||||||
|
return imageRepository.findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(characterId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 페이징 조회(활성 이미지)
|
||||||
|
fun pageActiveByCharacter(characterId: Long, pageable: Pageable): Page<CharacterImage> {
|
||||||
|
return imageRepository.findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(characterId, pageable)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 구매 이력 + 무료로 계산된 보유 수
|
||||||
|
fun countOwnedActiveByCharacterForMember(characterId: Long, memberId: Long): Long {
|
||||||
|
val freeCount = imageRepository.countByChatCharacterIdAndIsActiveTrueAndImagePriceCan(characterId, 0L)
|
||||||
|
val purchasedCount = useCanRepository.countPurchasedActiveImagesByCharacter(
|
||||||
|
memberId,
|
||||||
|
characterId,
|
||||||
|
listOf(
|
||||||
|
CanUsage.CHAT_MESSAGE_PURCHASE,
|
||||||
|
CanUsage.CHARACTER_IMAGE_PURCHASE
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return freeCount + purchasedCount
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isOwnedImageByMember(imageId: Long, memberId: Long): Boolean {
|
||||||
|
// 무료이거나(컨트롤러에서 가격 확인) 구매 이력이 있으면 보유로 판단
|
||||||
|
val purchased = useCanRepository.existsByMember_IdAndIsRefundFalseAndCharacterImage_IdAndCanUsageIn(
|
||||||
|
memberId,
|
||||||
|
imageId,
|
||||||
|
listOf(
|
||||||
|
CanUsage.CHAT_MESSAGE_PURCHASE,
|
||||||
|
CanUsage.CHARACTER_IMAGE_PURCHASE
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return purchased
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getById(id: Long): CharacterImage =
|
||||||
|
imageRepository.findById(id).orElseThrow { SodaException("캐릭터 이미지를 찾을 수 없습니다: $id") }
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun registerImage(
|
||||||
|
characterId: Long,
|
||||||
|
imagePath: String,
|
||||||
|
blurImagePath: String,
|
||||||
|
imagePriceCan: Long,
|
||||||
|
messagePriceCan: Long,
|
||||||
|
isAdult: Boolean,
|
||||||
|
triggers: List<String>
|
||||||
|
): CharacterImage {
|
||||||
|
val character = characterRepository.findById(characterId)
|
||||||
|
.orElseThrow { SodaException("캐릭터를 찾을 수 없습니다: $characterId") }
|
||||||
|
|
||||||
|
if (imagePriceCan < 0 || messagePriceCan < 0) throw SodaException("가격은 0 can 이상이어야 합니다.")
|
||||||
|
|
||||||
|
if (!character.isActive) throw SodaException("비활성화된 캐릭터에는 이미지를 등록할 수 없습니다: $characterId")
|
||||||
|
|
||||||
|
val nextOrder = (imageRepository.findMaxSortOrderByCharacterId(characterId)) + 1
|
||||||
|
val entity = CharacterImage(
|
||||||
|
chatCharacter = character,
|
||||||
|
imagePath = imagePath,
|
||||||
|
blurImagePath = blurImagePath,
|
||||||
|
imagePriceCan = imagePriceCan,
|
||||||
|
messagePriceCan = messagePriceCan,
|
||||||
|
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?
|
||||||
|
}
|
@@ -0,0 +1,18 @@
|
|||||||
|
package kr.co.vividnext.sodalive.chat.character.image.dto
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
|
||||||
|
data class CharacterImageListItemResponse(
|
||||||
|
@JsonProperty("id") val id: Long,
|
||||||
|
@JsonProperty("imageUrl") val imageUrl: String,
|
||||||
|
@JsonProperty("isOwned") val isOwned: Boolean,
|
||||||
|
@JsonProperty("imagePriceCan") val imagePriceCan: Long,
|
||||||
|
@JsonProperty("isAdult") val isAdult: Boolean,
|
||||||
|
@JsonProperty("sortOrder") val sortOrder: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CharacterImageListResponse(
|
||||||
|
@JsonProperty("totalCount") val totalCount: Long,
|
||||||
|
@JsonProperty("ownedCount") val ownedCount: Long,
|
||||||
|
@JsonProperty("items") val items: List<CharacterImageListItemResponse>
|
||||||
|
)
|
102
src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt
Normal file
102
src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package kr.co.vividnext.sodalive.utils
|
||||||
|
|
||||||
|
import java.awt.image.BufferedImage
|
||||||
|
import kotlin.math.exp
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 가우시안 커널 기반 블러 유틸리티
|
||||||
|
* - 반경(radius)에 따라 커널 크기(2*radius+1) 생성
|
||||||
|
* - 시그마는 관례적으로 radius/3.0 적용
|
||||||
|
* - 수평/수직 분리 합성곱으로 품질과 성능 확보
|
||||||
|
*/
|
||||||
|
object ImageBlurUtil {
|
||||||
|
fun blur(src: BufferedImage, radius: Int = 10): BufferedImage {
|
||||||
|
require(radius > 0) { "radius must be > 0" }
|
||||||
|
val w = src.width
|
||||||
|
val h = src.height
|
||||||
|
val dst = BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB)
|
||||||
|
|
||||||
|
// 가우시안 1D 커널 생성 및 정규화
|
||||||
|
val sigma = radius / 3.0
|
||||||
|
val kernel = gaussianKernel(radius, sigma)
|
||||||
|
|
||||||
|
// 중간 버퍼
|
||||||
|
val temp = BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB)
|
||||||
|
|
||||||
|
// 수평 합성곱
|
||||||
|
for (y in 0 until h) {
|
||||||
|
for (x in 0 until w) {
|
||||||
|
var aAcc = 0.0
|
||||||
|
var rAcc = 0.0
|
||||||
|
var gAcc = 0.0
|
||||||
|
var bAcc = 0.0
|
||||||
|
for (k in -radius..radius) {
|
||||||
|
val xx = clamp(x + k, 0, w - 1)
|
||||||
|
val rgb = src.getRGB(xx, y)
|
||||||
|
val a = (rgb ushr 24) and 0xFF
|
||||||
|
val r = (rgb ushr 16) and 0xFF
|
||||||
|
val g = (rgb ushr 8) and 0xFF
|
||||||
|
val b = rgb and 0xFF
|
||||||
|
val wgt = kernel[k + radius]
|
||||||
|
aAcc += a * wgt
|
||||||
|
rAcc += r * wgt
|
||||||
|
gAcc += g * wgt
|
||||||
|
bAcc += b * wgt
|
||||||
|
}
|
||||||
|
val a = aAcc.toInt().coerceIn(0, 255)
|
||||||
|
val r = rAcc.toInt().coerceIn(0, 255)
|
||||||
|
val g = gAcc.toInt().coerceIn(0, 255)
|
||||||
|
val b = bAcc.toInt().coerceIn(0, 255)
|
||||||
|
temp.setRGB(x, y, (a shl 24) or (r shl 16) or (g shl 8) or b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 수직 합성곱
|
||||||
|
for (x in 0 until w) {
|
||||||
|
for (y in 0 until h) {
|
||||||
|
var aAcc = 0.0
|
||||||
|
var rAcc = 0.0
|
||||||
|
var gAcc = 0.0
|
||||||
|
var bAcc = 0.0
|
||||||
|
for (k in -radius..radius) {
|
||||||
|
val yy = clamp(y + k, 0, h - 1)
|
||||||
|
val rgb = temp.getRGB(x, yy)
|
||||||
|
val a = (rgb ushr 24) and 0xFF
|
||||||
|
val r = (rgb ushr 16) and 0xFF
|
||||||
|
val g = (rgb ushr 8) and 0xFF
|
||||||
|
val b = rgb and 0xFF
|
||||||
|
val wgt = kernel[k + radius]
|
||||||
|
aAcc += a * wgt
|
||||||
|
rAcc += r * wgt
|
||||||
|
gAcc += g * wgt
|
||||||
|
bAcc += b * wgt
|
||||||
|
}
|
||||||
|
val a = aAcc.toInt().coerceIn(0, 255)
|
||||||
|
val r = rAcc.toInt().coerceIn(0, 255)
|
||||||
|
val g = gAcc.toInt().coerceIn(0, 255)
|
||||||
|
val b = bAcc.toInt().coerceIn(0, 255)
|
||||||
|
dst.setRGB(x, y, (a shl 24) or (r shl 16) or (g shl 8) or b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun gaussianKernel(radius: Int, sigma: Double): DoubleArray {
|
||||||
|
val size = 2 * radius + 1
|
||||||
|
val kernel = DoubleArray(size)
|
||||||
|
val sigma2 = 2.0 * sigma * sigma
|
||||||
|
var sum = 0.0
|
||||||
|
for (i in -radius..radius) {
|
||||||
|
val v = exp(-(i * i) / sigma2)
|
||||||
|
kernel[i + radius] = v
|
||||||
|
sum += v
|
||||||
|
}
|
||||||
|
// 정규화
|
||||||
|
for (i in kernel.indices) kernel[i] /= sum
|
||||||
|
return kernel
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clamp(v: Int, lo: Int, hi: Int): Int = max(lo, min(hi, v))
|
||||||
|
}
|
Reference in New Issue
Block a user