revert(ai-character): 기존 관리자 기능 기반을 제거한다
This commit is contained in:
@@ -1,12 +1,9 @@
|
||||
package kr.co.vividnext.sodalive.common
|
||||
|
||||
import org.springframework.http.HttpStatus
|
||||
|
||||
class SodaException(
|
||||
message: String? = null,
|
||||
val errorProperty: String? = null,
|
||||
val messageKey: String? = null,
|
||||
val httpStatus: HttpStatus? = null
|
||||
val messageKey: String? = null
|
||||
) : RuntimeException(message)
|
||||
|
||||
class AdsChargeException(
|
||||
|
||||
@@ -6,7 +6,6 @@ import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.dao.DataIntegrityViolationException
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.security.access.AccessDeniedException
|
||||
import org.springframework.security.authentication.BadCredentialsException
|
||||
import org.springframework.security.authentication.InternalAuthenticationServiceException
|
||||
@@ -14,9 +13,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler
|
||||
import org.springframework.web.bind.annotation.ResponseStatus
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice
|
||||
import org.springframework.web.multipart.MaxUploadSizeExceededException
|
||||
import org.springframework.web.multipart.MultipartException
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
import javax.servlet.http.HttpServletRequest
|
||||
|
||||
@RestControllerAdvice
|
||||
class SodaExceptionHandler(
|
||||
@@ -41,25 +38,12 @@ class SodaExceptionHandler(
|
||||
)
|
||||
}
|
||||
|
||||
@ExceptionHandler(MaxUploadSizeExceededException::class, MultipartException::class)
|
||||
fun handleMultipartException(e: MultipartException, request: HttpServletRequest) = run {
|
||||
val isAiCharacterAdminPath = isAiCharacterAdminPath(request.requestURI)
|
||||
val messageKey = if (e is MaxUploadSizeExceededException) {
|
||||
"common.error.max_upload_size"
|
||||
} else if (isAiCharacterAdminPath) {
|
||||
"common.error.invalid_request"
|
||||
} else {
|
||||
"common.error.unknown"
|
||||
}
|
||||
val logMessage = messageSource.getMessage(messageKey, logLang)
|
||||
@ExceptionHandler(MaxUploadSizeExceededException::class)
|
||||
fun handleMaxUploadSizeExceededException(e: MaxUploadSizeExceededException) = run {
|
||||
val logMessage = messageSource.getMessage("common.error.max_upload_size", logLang)
|
||||
logger.error("API error: {}", logMessage, e)
|
||||
val message = messageSource.getMessage(messageKey, langContext.lang)
|
||||
val body = ApiResponse.error(message = message)
|
||||
if (isAiCharacterAdminPath) {
|
||||
ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body)
|
||||
} else {
|
||||
body
|
||||
}
|
||||
val message = messageSource.getMessage("common.error.max_upload_size", langContext.lang)
|
||||
ApiResponse.error(message = message)
|
||||
}
|
||||
|
||||
@ExceptionHandler(AccessDeniedException::class)
|
||||
@@ -115,12 +99,4 @@ class SodaExceptionHandler(
|
||||
val message = messageSource.getMessage("common.error.unknown", langContext.lang)
|
||||
ApiResponse.error(message)
|
||||
}
|
||||
|
||||
private fun isAiCharacterAdminPath(requestUri: String): Boolean {
|
||||
return requestUri == AI_CHARACTER_ADMIN_PATH_PREFIX || requestUri.startsWith("$AI_CHARACTER_ADMIN_PATH_PREFIX/")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val AI_CHARACTER_ADMIN_PATH_PREFIX = "/admin/ai-characters"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,6 @@ import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler
|
||||
import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint
|
||||
import kr.co.vividnext.sodalive.jwt.JwtFilter
|
||||
import kr.co.vividnext.sodalive.jwt.TokenProvider
|
||||
import kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.security.AiCharacterAdminAccessDeniedHandler
|
||||
import kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.security.AiCharacterAdminAuthenticationEntryPoint
|
||||
import org.springframework.beans.factory.ObjectProvider
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.http.HttpMethod
|
||||
@@ -30,9 +27,7 @@ class SecurityConfig(
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val tokenProvider: TokenProvider,
|
||||
private val accessDeniedHandler: JwtAccessDeniedHandler,
|
||||
private val authenticationEntryPoint: JwtAuthenticationEntryPoint,
|
||||
private val aiCharacterAdminAuthenticationEntryPoint: ObjectProvider<AiCharacterAdminAuthenticationEntryPoint>,
|
||||
private val aiCharacterAdminAccessDeniedHandler: ObjectProvider<AiCharacterAdminAccessDeniedHandler>
|
||||
private val authenticationEntryPoint: JwtAuthenticationEntryPoint
|
||||
) {
|
||||
@Bean
|
||||
fun passwordEncoder(): PasswordEncoder {
|
||||
@@ -57,22 +52,8 @@ class SecurityConfig(
|
||||
.and()
|
||||
.csrf().disable()
|
||||
.exceptionHandling()
|
||||
.authenticationEntryPoint { request, response, authException ->
|
||||
val adminEntryPoint = aiCharacterAdminAuthenticationEntryPoint.getIfAvailable()
|
||||
if (isAiCharacterAdminPath(request.requestURI) && adminEntryPoint != null) {
|
||||
adminEntryPoint.commence(request, response, authException)
|
||||
} else {
|
||||
authenticationEntryPoint.commence(request, response, authException)
|
||||
}
|
||||
}
|
||||
.accessDeniedHandler { request, response, accessDeniedException ->
|
||||
val adminAccessDeniedHandler = aiCharacterAdminAccessDeniedHandler.getIfAvailable()
|
||||
if (isAiCharacterAdminPath(request.requestURI) && adminAccessDeniedHandler != null) {
|
||||
adminAccessDeniedHandler.handle(request, response, accessDeniedException)
|
||||
} else {
|
||||
accessDeniedHandler.handle(request, response, accessDeniedException)
|
||||
}
|
||||
}
|
||||
.authenticationEntryPoint(authenticationEntryPoint)
|
||||
.accessDeniedHandler(accessDeniedHandler)
|
||||
.and()
|
||||
.headers()
|
||||
.frameOptions()
|
||||
@@ -119,7 +100,6 @@ class SecurityConfig(
|
||||
.antMatchers(HttpMethod.GET, "/api/chat/character/main").permitAll()
|
||||
.antMatchers(HttpMethod.GET, "/api/chat/room/list").permitAll()
|
||||
.antMatchers(HttpMethod.GET, "/api/chat/original/list").permitAll()
|
||||
.antMatchers(HttpMethod.PUT, "/audio-content/upload-complete").hasAnyRole("ADMIN", "BOT")
|
||||
.antMatchers(HttpMethod.POST, "/charge/payverse/webhook").permitAll()
|
||||
.antMatchers(HttpMethod.GET, "/api/v2/home/recommendations").permitAll()
|
||||
.antMatchers(HttpMethod.GET, "/api/v2/audio/recommendations").permitAll()
|
||||
@@ -130,17 +110,8 @@ class SecurityConfig(
|
||||
.antMatchers(HttpMethod.GET, "/api/v2/home/on-air-lives").authenticated()
|
||||
// 페이지네이션 하위 경로(/lives, /debut-creators 등)는 인증 필수
|
||||
.antMatchers(HttpMethod.GET, "/api/v2/home/recommendations/**").authenticated()
|
||||
.antMatchers(AI_CHARACTER_ADMIN_PATH_PREFIX, "$AI_CHARACTER_ADMIN_PATH_PREFIX/**").hasRole("ADMIN")
|
||||
.anyRequest().authenticated()
|
||||
.and()
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun isAiCharacterAdminPath(requestUri: String): Boolean {
|
||||
return requestUri == AI_CHARACTER_ADMIN_PATH_PREFIX || requestUri.startsWith("$AI_CHARACTER_ADMIN_PATH_PREFIX/")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val AI_CHARACTER_ADMIN_PATH_PREFIX = "/admin/ai-characters"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.security
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.security.access.AccessDeniedException
|
||||
import org.springframework.security.web.access.AccessDeniedHandler
|
||||
import org.springframework.stereotype.Component
|
||||
import javax.servlet.http.HttpServletRequest
|
||||
import javax.servlet.http.HttpServletResponse
|
||||
|
||||
@Component
|
||||
class AiCharacterAdminAccessDeniedHandler(
|
||||
private val objectMapper: ObjectMapper
|
||||
) : AccessDeniedHandler {
|
||||
override fun handle(
|
||||
request: HttpServletRequest,
|
||||
response: HttpServletResponse,
|
||||
accessDeniedException: AccessDeniedException
|
||||
) {
|
||||
response.status = HttpStatus.FORBIDDEN.value()
|
||||
response.contentType = MediaType.APPLICATION_JSON_VALUE
|
||||
response.characterEncoding = Charsets.UTF_8.name()
|
||||
response.writer.write(objectMapper.writeValueAsString(ApiResponse.error("권한이 없습니다.")))
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.security
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.security.core.AuthenticationException
|
||||
import org.springframework.security.web.AuthenticationEntryPoint
|
||||
import org.springframework.stereotype.Component
|
||||
import javax.servlet.http.HttpServletRequest
|
||||
import javax.servlet.http.HttpServletResponse
|
||||
|
||||
@Component
|
||||
class AiCharacterAdminAuthenticationEntryPoint(
|
||||
private val objectMapper: ObjectMapper
|
||||
) : AuthenticationEntryPoint {
|
||||
override fun commence(
|
||||
request: HttpServletRequest,
|
||||
response: HttpServletResponse,
|
||||
authException: AuthenticationException
|
||||
) {
|
||||
response.status = HttpStatus.UNAUTHORIZED.value()
|
||||
response.contentType = MediaType.APPLICATION_JSON_VALUE
|
||||
response.characterEncoding = Charsets.UTF_8.name()
|
||||
response.writer.write(objectMapper.writeValueAsString(ApiResponse.error("로그인 정보를 확인해주세요.")))
|
||||
}
|
||||
}
|
||||
@@ -1,395 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.web
|
||||
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
import java.awt.Rectangle
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import javax.imageio.ImageIO
|
||||
|
||||
@Component
|
||||
class AdminImagePartValidator {
|
||||
fun validate(image: MultipartFile, allowGif: Boolean): AdminValidatedImage {
|
||||
if (image.isEmpty) {
|
||||
throw invalidImage(image.name)
|
||||
}
|
||||
if (image.size > MAX_IMAGE_BYTES) {
|
||||
throw invalidImage(image.name)
|
||||
}
|
||||
|
||||
val bytes = image.bytes
|
||||
val signatureFormat = detectSignatureFormat(bytes) ?: throw invalidImage(image.name)
|
||||
if (signatureFormat == "gif" && !allowGif) {
|
||||
throw invalidImage(image.name)
|
||||
}
|
||||
if (!hasBoundedContainer(bytes = bytes, format = signatureFormat)) {
|
||||
throw invalidImage(image.name)
|
||||
}
|
||||
val format = detectFormat(bytes) ?: throw invalidImage(image.name)
|
||||
|
||||
val contentType = when (format) {
|
||||
"jpg" -> "image/jpeg"
|
||||
else -> "image/$format"
|
||||
}
|
||||
|
||||
return AdminValidatedImage(
|
||||
bytes = bytes,
|
||||
contentType = contentType,
|
||||
extension = format
|
||||
)
|
||||
}
|
||||
|
||||
private fun detectSignatureFormat(bytes: ByteArray): String? {
|
||||
return when {
|
||||
bytes.size >= PNG_SIGNATURE.size && bytes.take(PNG_SIGNATURE.size) == PNG_SIGNATURE.toList() -> "png"
|
||||
bytes.size >= 3 && bytes[0] == 0xFF.toByte() && bytes[1] == 0xD8.toByte() && bytes[2] == 0xFF.toByte() -> "jpg"
|
||||
bytes.size >= 6 && bytes.copyOfRange(0, 6).decodeToString() in setOf("GIF87a", "GIF89a") -> "gif"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun detectFormat(bytes: ByteArray): String? {
|
||||
ImageIO.createImageInputStream(ByteArrayInputStream(bytes)).use { stream ->
|
||||
if (stream == null) return null
|
||||
return try {
|
||||
val readers = ImageIO.getImageReaders(stream)
|
||||
if (!readers.hasNext()) return null
|
||||
val reader = readers.next()
|
||||
try {
|
||||
reader.setInput(stream, false, true)
|
||||
val format = normalizeFormat(reader.formatName) ?: return null
|
||||
val frameCount = validatedFrameCount(reader = reader, format = format, bytes = bytes) ?: return null
|
||||
|
||||
repeat(frameCount) { index ->
|
||||
val readParam = reader.defaultReadParam
|
||||
readParam.sourceRegion = Rectangle(0, 0, 1, 1)
|
||||
reader.read(index, readParam) ?: return null
|
||||
}
|
||||
format
|
||||
} finally {
|
||||
reader.dispose()
|
||||
}
|
||||
} catch (e: java.io.IOException) {
|
||||
null
|
||||
} catch (e: IndexOutOfBoundsException) {
|
||||
null
|
||||
} catch (e: IllegalArgumentException) {
|
||||
null
|
||||
} catch (e: RuntimeException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasBoundedContainer(bytes: ByteArray, format: String): Boolean {
|
||||
return when (format) {
|
||||
"png" -> hasBoundedPngChunks(bytes)
|
||||
"gif" -> hasBoundedGifBlocks(bytes)
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasBoundedPngChunks(bytes: ByteArray): Boolean {
|
||||
var offset = PNG_SIGNATURE.size
|
||||
var chunkCount = 0
|
||||
var ancillaryBytes = 0L
|
||||
|
||||
while (offset + PNG_CHUNK_HEADER_BYTES + PNG_CHUNK_CRC_BYTES <= bytes.size) {
|
||||
if (++chunkCount > MAX_PNG_CHUNKS) return false
|
||||
val length = readBigEndianInt(bytes = bytes, offset = offset)
|
||||
if (length < 0) return false
|
||||
val typeOffset = offset + 4
|
||||
val dataOffset = typeOffset + 4
|
||||
val nextOffset = dataOffset.toLong() + length.toLong() + PNG_CHUNK_CRC_BYTES.toLong()
|
||||
if (nextOffset > bytes.size) return false
|
||||
|
||||
val isAncillary = (bytes[typeOffset].toInt() and 0x20) != 0
|
||||
if (isAncillary) {
|
||||
ancillaryBytes += length.toLong()
|
||||
if (length > MAX_IMAGE_METADATA_BYTES || ancillaryBytes > MAX_IMAGE_METADATA_BYTES) return false
|
||||
}
|
||||
if (bytes.copyOfRange(typeOffset, typeOffset + 4).decodeToString() == "IEND") return nextOffset == bytes.size.toLong()
|
||||
offset = nextOffset.toInt()
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private fun hasBoundedGifBlocks(bytes: ByteArray): Boolean {
|
||||
if (bytes.size < GIF_LOGICAL_SCREEN_END_OFFSET + 3) return false
|
||||
var offset = GIF_LOGICAL_SCREEN_END_OFFSET
|
||||
val packed = bytes[offset].toInt() and 0xFF
|
||||
offset += 3
|
||||
if ((packed and 0x80) != 0) {
|
||||
offset += 3 * (1 shl ((packed and 0x07) + 1))
|
||||
if (offset > bytes.size) return false
|
||||
}
|
||||
|
||||
var frameCount = 0
|
||||
var extensionCount = 0
|
||||
var extensionBytes = 0L
|
||||
var framePixels = 0L
|
||||
while (offset < bytes.size) {
|
||||
when (bytes[offset++].toInt() and 0xFF) {
|
||||
0x2C -> {
|
||||
if (++frameCount > MAX_GIF_FRAMES || offset + GIF_IMAGE_DESCRIPTOR_BYTES > bytes.size) return false
|
||||
val width = readLittleEndianUnsignedShort(bytes = bytes, offset = offset + 4)
|
||||
val height = readLittleEndianUnsignedShort(bytes = bytes, offset = offset + 6)
|
||||
if (!hasValidDimensions(width = width, height = height)) return false
|
||||
framePixels += width.toLong() * height
|
||||
if (framePixels > MAX_IMAGE_PIXELS) return false
|
||||
|
||||
val imagePacked = bytes[offset + 8].toInt() and 0xFF
|
||||
offset += GIF_IMAGE_DESCRIPTOR_BYTES
|
||||
if ((imagePacked and 0x80) != 0) {
|
||||
offset += 3 * (1 shl ((imagePacked and 0x07) + 1))
|
||||
if (offset > bytes.size) return false
|
||||
}
|
||||
if (offset >= bytes.size) return false
|
||||
val minimumCodeSize = bytes[offset++].toInt() and 0xFF
|
||||
val scanned = scanGifSubBlocks(bytes = bytes, offset = offset, maxBlocks = null, collectPayload = true)
|
||||
?: return false
|
||||
val payload = scanned.payload ?: return false
|
||||
if (!hasExactGifLzwPixelCount(payload, minimumCodeSize, width.toLong() * height)) return false
|
||||
offset = scanned.nextOffset
|
||||
}
|
||||
0x21 -> {
|
||||
if (++extensionCount > MAX_GIF_EXTENSIONS || offset >= bytes.size) return false
|
||||
val label = bytes[offset++].toInt() and 0xFF
|
||||
val scanned = when (label) {
|
||||
0xF9 -> scanGifGraphicControlExtension(bytes, offset)
|
||||
0x01 -> scanGifFixedHeaderExtension(bytes, offset, fixedHeaderSize = 12)
|
||||
0xFF -> scanGifFixedHeaderExtension(bytes, offset, fixedHeaderSize = 11)
|
||||
else -> scanGifSubBlocks(
|
||||
bytes = bytes,
|
||||
offset = offset,
|
||||
maxBlocks = MAX_GIF_EXTENSION_SUB_BLOCKS,
|
||||
collectPayload = false
|
||||
)
|
||||
} ?: return false
|
||||
extensionBytes += scanned.payloadBytes
|
||||
if (extensionBytes > MAX_IMAGE_METADATA_BYTES) return false
|
||||
offset = scanned.nextOffset
|
||||
}
|
||||
0x3B -> return offset == bytes.size && frameCount > 0
|
||||
else -> return false
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private fun scanGifSubBlocks(
|
||||
bytes: ByteArray,
|
||||
offset: Int,
|
||||
maxBlocks: Int?,
|
||||
collectPayload: Boolean
|
||||
): GifSubBlockScan? {
|
||||
var currentOffset = offset
|
||||
var payloadBytes = 0L
|
||||
var blockCount = 0
|
||||
val payload = if (collectPayload) ByteArrayOutputStream() else null
|
||||
while (currentOffset < bytes.size) {
|
||||
val size = bytes[currentOffset++].toInt() and 0xFF
|
||||
if (size == 0) {
|
||||
return GifSubBlockScan(
|
||||
nextOffset = currentOffset,
|
||||
payloadBytes = payloadBytes,
|
||||
payload = payload?.toByteArray()
|
||||
)
|
||||
}
|
||||
if (++blockCount > (maxBlocks ?: Int.MAX_VALUE)) return null
|
||||
if (currentOffset + size > bytes.size) return null
|
||||
payloadBytes += size.toLong()
|
||||
payload?.write(bytes, currentOffset, size)
|
||||
currentOffset += size
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun scanGifGraphicControlExtension(bytes: ByteArray, offset: Int): GifSubBlockScan? {
|
||||
if (offset + 5 >= bytes.size || (bytes[offset].toInt() and 0xFF) != 4) return null
|
||||
if (bytes[offset + 5].toInt() != 0) return null
|
||||
return GifSubBlockScan(nextOffset = offset + 6, payloadBytes = 4, payload = null)
|
||||
}
|
||||
|
||||
private fun scanGifFixedHeaderExtension(bytes: ByteArray, offset: Int, fixedHeaderSize: Int): GifSubBlockScan? {
|
||||
if (offset >= bytes.size || (bytes[offset].toInt() and 0xFF) != fixedHeaderSize) return null
|
||||
val scanned = scanGifSubBlocks(
|
||||
bytes = bytes,
|
||||
offset = offset + fixedHeaderSize + 1,
|
||||
maxBlocks = MAX_GIF_EXTENSION_SUB_BLOCKS,
|
||||
collectPayload = false
|
||||
) ?: return null
|
||||
return scanned.copy(payloadBytes = scanned.payloadBytes + fixedHeaderSize)
|
||||
}
|
||||
|
||||
private fun hasExactGifLzwPixelCount(data: ByteArray, minimumCodeSize: Int, expectedPixels: Long): Boolean {
|
||||
if (minimumCodeSize !in MIN_GIF_LZW_CODE_SIZE..MAX_GIF_LZW_CODE_SIZE) return false
|
||||
|
||||
val clearCode = 1 shl minimumCodeSize
|
||||
val endOfInformationCode = clearCode + 1
|
||||
val codeLengths = IntArray(MAX_GIF_LZW_TABLE_SIZE)
|
||||
repeat(clearCode) { codeLengths[it] = 1 }
|
||||
val bitReader = GifLzwBitReader(data)
|
||||
val maxCodes = expectedPixels * 2L + 2L
|
||||
var codeCount = 0L
|
||||
var codeSize = minimumCodeSize + 1
|
||||
var nextCode = endOfInformationCode + 1
|
||||
var previousCode = -1
|
||||
var decodedPixels = 0L
|
||||
|
||||
while (++codeCount <= maxCodes) {
|
||||
val code = bitReader.read(codeSize) ?: return false
|
||||
when {
|
||||
code == clearCode -> {
|
||||
if (previousCode < 0 && codeCount > 1) return false
|
||||
codeSize = minimumCodeSize + 1
|
||||
nextCode = endOfInformationCode + 1
|
||||
previousCode = -1
|
||||
}
|
||||
code == endOfInformationCode -> {
|
||||
return decodedPixels == expectedPixels && bitReader.hasOnlyPaddingBits()
|
||||
}
|
||||
previousCode < 0 -> {
|
||||
if (code >= clearCode) return false
|
||||
decodedPixels++
|
||||
if (decodedPixels > expectedPixels) return false
|
||||
previousCode = code
|
||||
}
|
||||
else -> {
|
||||
val decodedLength = when {
|
||||
code < clearCode -> 1
|
||||
code < nextCode && codeLengths[code] > 0 -> codeLengths[code]
|
||||
code == nextCode && nextCode < MAX_GIF_LZW_TABLE_SIZE -> codeLengths[previousCode] + 1
|
||||
else -> return false
|
||||
}
|
||||
decodedPixels += decodedLength
|
||||
if (decodedPixels > expectedPixels) return false
|
||||
|
||||
if (nextCode < MAX_GIF_LZW_TABLE_SIZE) {
|
||||
codeLengths[nextCode++] = codeLengths[previousCode] + 1
|
||||
if (nextCode == (1 shl codeSize) && codeSize < MAX_GIF_LZW_BITS) codeSize++
|
||||
}
|
||||
previousCode = code
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private fun validatedFrameCount(reader: javax.imageio.ImageReader, format: String, bytes: ByteArray): Int? {
|
||||
if (format != "gif") {
|
||||
return 1.takeIf { hasValidDimensions(width = reader.getWidth(0), height = reader.getHeight(0)) }
|
||||
}
|
||||
|
||||
if (bytes.size < GIF_LOGICAL_SCREEN_END_OFFSET) return null
|
||||
val logicalWidth = readLittleEndianUnsignedShort(bytes = bytes, offset = GIF_LOGICAL_SCREEN_WIDTH_OFFSET)
|
||||
val logicalHeight = readLittleEndianUnsignedShort(bytes = bytes, offset = GIF_LOGICAL_SCREEN_HEIGHT_OFFSET)
|
||||
if (!hasValidDimensions(width = logicalWidth, height = logicalHeight)) return null
|
||||
|
||||
val frameCount = reader.getNumImages(true)
|
||||
if (frameCount < 1) return null
|
||||
val hasInvalidFrame = (0 until frameCount).any { index ->
|
||||
!hasValidDimensions(width = reader.getWidth(index), height = reader.getHeight(index))
|
||||
}
|
||||
if (hasInvalidFrame) return null
|
||||
return frameCount
|
||||
}
|
||||
|
||||
private fun hasValidDimensions(width: Int, height: Int): Boolean {
|
||||
return width in 1..MAX_IMAGE_DIMENSION &&
|
||||
height in 1..MAX_IMAGE_DIMENSION &&
|
||||
width.toLong() * height <= MAX_IMAGE_PIXELS
|
||||
}
|
||||
|
||||
private fun readLittleEndianUnsignedShort(bytes: ByteArray, offset: Int): Int {
|
||||
return (bytes[offset].toInt() and 0xFF) or ((bytes[offset + 1].toInt() and 0xFF) shl 8)
|
||||
}
|
||||
|
||||
private fun readBigEndianInt(bytes: ByteArray, offset: Int): Int {
|
||||
return ((bytes[offset].toInt() and 0xFF) shl 24) or
|
||||
((bytes[offset + 1].toInt() and 0xFF) shl 16) or
|
||||
((bytes[offset + 2].toInt() and 0xFF) shl 8) or
|
||||
(bytes[offset + 3].toInt() and 0xFF)
|
||||
}
|
||||
|
||||
private fun normalizeFormat(format: String): String? {
|
||||
return when (format.lowercase()) {
|
||||
"jpeg" -> "jpg"
|
||||
"png", "jpg", "gif" -> format.lowercase()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun invalidImage(errorProperty: String): SodaException {
|
||||
return SodaException(
|
||||
messageKey = "admin.chat.character.image_format_invalid",
|
||||
errorProperty = errorProperty.ifBlank { "image" },
|
||||
httpStatus = HttpStatus.BAD_REQUEST
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MAX_IMAGE_BYTES = 10L * 1024L * 1024L
|
||||
private const val MAX_IMAGE_METADATA_BYTES = 1024 * 1024
|
||||
private const val MAX_IMAGE_DIMENSION = 20_000
|
||||
private const val MAX_IMAGE_PIXELS = 40_000_000L
|
||||
private const val MAX_PNG_CHUNKS = 4096
|
||||
private const val MAX_GIF_FRAMES = 500
|
||||
private const val MAX_GIF_EXTENSIONS = 1024
|
||||
private const val MAX_GIF_EXTENSION_SUB_BLOCKS = 64
|
||||
private const val MIN_GIF_LZW_CODE_SIZE = 2
|
||||
private const val MAX_GIF_LZW_CODE_SIZE = 8
|
||||
private const val MAX_GIF_LZW_BITS = 12
|
||||
private const val MAX_GIF_LZW_TABLE_SIZE = 1 shl MAX_GIF_LZW_BITS
|
||||
private const val PNG_CHUNK_HEADER_BYTES = 8
|
||||
private const val PNG_CHUNK_CRC_BYTES = 4
|
||||
private const val GIF_LOGICAL_SCREEN_WIDTH_OFFSET = 6
|
||||
private const val GIF_LOGICAL_SCREEN_HEIGHT_OFFSET = 8
|
||||
private const val GIF_LOGICAL_SCREEN_END_OFFSET = 10
|
||||
private const val GIF_IMAGE_DESCRIPTOR_BYTES = 9
|
||||
private val PNG_SIGNATURE = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A)
|
||||
}
|
||||
}
|
||||
|
||||
private data class GifSubBlockScan(
|
||||
val nextOffset: Int,
|
||||
val payloadBytes: Long,
|
||||
val payload: ByteArray?
|
||||
)
|
||||
|
||||
private class GifLzwBitReader(
|
||||
private val data: ByteArray
|
||||
) {
|
||||
private var offset = 0
|
||||
private var bitBuffer = 0L
|
||||
private var bufferedBits = 0
|
||||
|
||||
fun read(bitCount: Int): Int? {
|
||||
while (bufferedBits < bitCount) {
|
||||
if (offset >= data.size) return null
|
||||
bitBuffer = bitBuffer or ((data[offset++].toInt() and 0xFF).toLong() shl bufferedBits)
|
||||
bufferedBits += 8
|
||||
}
|
||||
|
||||
val code = (bitBuffer and ((1L shl bitCount) - 1L)).toInt()
|
||||
bitBuffer = bitBuffer ushr bitCount
|
||||
bufferedBits -= bitCount
|
||||
return code
|
||||
}
|
||||
|
||||
fun hasOnlyPaddingBits(): Boolean {
|
||||
return offset == data.size && bufferedBits < 8
|
||||
}
|
||||
}
|
||||
|
||||
data class AdminValidatedImage(
|
||||
val bytes: ByteArray,
|
||||
val contentType: String,
|
||||
val extension: String
|
||||
)
|
||||
@@ -1,49 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.web
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
@Component
|
||||
class AdminJsonRequestParser(private val objectMapper: ObjectMapper) {
|
||||
fun parseRequiredObject(rawJson: String, requiredKeys: Set<String>): ObjectNode {
|
||||
val node: JsonNode = try {
|
||||
objectMapper.reader()
|
||||
.with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
|
||||
.readTree(rawJson)
|
||||
} catch (e: Exception) {
|
||||
throw SodaException(
|
||||
messageKey = "common.error.invalid_request",
|
||||
errorProperty = "request",
|
||||
httpStatus = HttpStatus.BAD_REQUEST
|
||||
)
|
||||
}
|
||||
|
||||
return parseRequiredObject(node = node, requiredKeys = requiredKeys)
|
||||
}
|
||||
|
||||
fun parseRequiredObject(node: JsonNode, requiredKeys: Set<String>): ObjectNode {
|
||||
if (!node.isObject) {
|
||||
throw SodaException(
|
||||
messageKey = "common.error.invalid_request",
|
||||
errorProperty = "request",
|
||||
httpStatus = HttpStatus.BAD_REQUEST
|
||||
)
|
||||
}
|
||||
|
||||
val objectNode = node as ObjectNode
|
||||
requiredKeys.firstOrNull { key -> !objectNode.has(key) }?.let { key ->
|
||||
throw SodaException(
|
||||
messageKey = "common.error.invalid_request",
|
||||
errorProperty = key,
|
||||
httpStatus = HttpStatus.BAD_REQUEST
|
||||
)
|
||||
}
|
||||
|
||||
return objectNode
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.web
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonMappingException
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.i18n.Lang
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.core.Ordered
|
||||
import org.springframework.core.annotation.Order
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException
|
||||
import org.springframework.security.access.AccessDeniedException
|
||||
import org.springframework.web.bind.MissingServletRequestParameterException
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException
|
||||
import org.springframework.web.multipart.MultipartException
|
||||
import org.springframework.web.multipart.support.MissingServletRequestPartException
|
||||
|
||||
@RestControllerAdvice(basePackages = ["kr.co.vividnext.sodalive.v2.admin.aicharacter"])
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
class AiCharacterAdminExceptionHandler(
|
||||
private val langContext: LangContext,
|
||||
private val messageSource: SodaMessageSource
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
@ExceptionHandler(SodaException::class)
|
||||
fun handleSodaException(e: SodaException): ResponseEntity<ApiResponse<Any>> {
|
||||
val message = resolveMessage(e, langContext.lang)
|
||||
logger.warn(
|
||||
"AI character admin API error status={} errorProperty={} error={}",
|
||||
e.httpStatus ?: HttpStatus.BAD_REQUEST,
|
||||
e.errorProperty,
|
||||
e.javaClass.simpleName
|
||||
)
|
||||
return ResponseEntity
|
||||
.status(e.httpStatus ?: HttpStatus.BAD_REQUEST)
|
||||
.body(ApiResponse.error(message = message, errorProperty = e.errorProperty))
|
||||
}
|
||||
|
||||
@ExceptionHandler(AccessDeniedException::class)
|
||||
fun handleAccessDeniedException(e: AccessDeniedException): ResponseEntity<ApiResponse<Any>> {
|
||||
val message = messageSource.getMessage("common.error.access_denied", langContext.lang) ?: "You do not have permission."
|
||||
logger.warn("AI character admin API access denied error={}", e.javaClass.simpleName)
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiResponse.error(message = message))
|
||||
}
|
||||
|
||||
@ExceptionHandler(
|
||||
HttpMessageNotReadableException::class,
|
||||
MethodArgumentTypeMismatchException::class,
|
||||
MissingServletRequestParameterException::class,
|
||||
MissingServletRequestPartException::class,
|
||||
MultipartException::class
|
||||
)
|
||||
fun handleBadRequestException(e: Exception): ResponseEntity<ApiResponse<Any>> {
|
||||
val message = messageSource.getMessage("common.error.invalid_request", langContext.lang) ?: "Invalid request."
|
||||
val errorProperty = resolveBadRequestErrorProperty(e)
|
||||
logger.warn("AI character admin bad request error={} errorProperty={}", e.javaClass.simpleName, errorProperty)
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.body(ApiResponse.error(message = message, errorProperty = errorProperty))
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception::class)
|
||||
fun handleException(e: Exception): ResponseEntity<ApiResponse<Any>> {
|
||||
val message = messageSource.getMessage("common.error.unknown", langContext.lang) ?: DEFAULT_UNKNOWN_MESSAGE
|
||||
logger.error("AI character admin API error error={}", e.javaClass.simpleName)
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ApiResponse.error(message = message))
|
||||
}
|
||||
|
||||
private fun resolveBadRequestErrorProperty(e: Exception): String? {
|
||||
return when (e) {
|
||||
is MethodArgumentTypeMismatchException -> e.name
|
||||
is MissingServletRequestParameterException -> e.parameterName
|
||||
is MissingServletRequestPartException -> e.requestPartName
|
||||
is HttpMessageNotReadableException -> resolveUnreadableMessageErrorProperty(e)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveUnreadableMessageErrorProperty(e: HttpMessageNotReadableException): String {
|
||||
val mappingException = generateSequence(e.cause) { it.cause }
|
||||
.filterIsInstance<JsonMappingException>()
|
||||
.firstOrNull()
|
||||
return mappingException?.path
|
||||
?.asSequence()
|
||||
?.mapNotNull { it.fieldName?.takeIf(String::isNotBlank) }
|
||||
?.firstOrNull()
|
||||
?: "request"
|
||||
}
|
||||
|
||||
private fun resolveMessage(e: SodaException, lang: Lang): String {
|
||||
return e.messageKey?.takeIf { it.isNotBlank() }?.let { messageSource.getMessage(it, lang) }
|
||||
?: e.message?.takeIf { it.isNotBlank() }.orEmpty().ifBlank { null }
|
||||
?: messageSource.getMessage("common.error.unknown", lang)
|
||||
?: DEFAULT_UNKNOWN_MESSAGE
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DEFAULT_UNKNOWN_MESSAGE = "An unknown error occurred."
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.admin.aicharacter.application
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.admin.aicharacter.dto.AdminPageRequest
|
||||
|
||||
object AdminPagePolicy {
|
||||
fun normalize(page: Int?, size: Int?): AdminPageRequest {
|
||||
val normalizedPage = page?.coerceAtLeast(0) ?: 0
|
||||
val normalizedSize = when {
|
||||
size == null -> DEFAULT_SIZE
|
||||
size < MIN_SIZE -> DEFAULT_SIZE
|
||||
size > MAX_SIZE -> MAX_SIZE
|
||||
else -> size
|
||||
}
|
||||
|
||||
return AdminPageRequest(
|
||||
page = normalizedPage,
|
||||
size = normalizedSize,
|
||||
offset = normalizedPage.toLong() * normalizedSize,
|
||||
limit = normalizedSize.toLong()
|
||||
)
|
||||
}
|
||||
|
||||
private const val DEFAULT_SIZE = 20
|
||||
private const val MIN_SIZE = 1
|
||||
private const val MAX_SIZE = 50
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.admin.aicharacter.application
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
@Component
|
||||
class AiCharacterAdminAuditLogger {
|
||||
private val logger = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
fun logSuccess(context: AiCharacterAdminAuditContext) {
|
||||
logger.info(
|
||||
"aiCharacterAdminAudit result={} adminMemberId={} characterId={} " +
|
||||
"creatorMemberId={} action={} resourceType={} resourceId={}",
|
||||
AiCharacterAdminAuditResult.SUCCESS,
|
||||
context.adminMemberId,
|
||||
context.characterId,
|
||||
context.creatorMemberId,
|
||||
context.action,
|
||||
context.resourceType,
|
||||
context.resourceId
|
||||
)
|
||||
}
|
||||
|
||||
fun logFailure(context: AiCharacterAdminAuditContext, exception: Exception) {
|
||||
logger.warn(
|
||||
"aiCharacterAdminAudit result={} adminMemberId={} characterId={} " +
|
||||
"creatorMemberId={} action={} resourceType={} resourceId={} error={}",
|
||||
AiCharacterAdminAuditResult.FAILURE,
|
||||
context.adminMemberId,
|
||||
context.characterId,
|
||||
context.creatorMemberId,
|
||||
context.action,
|
||||
context.resourceType,
|
||||
context.resourceId,
|
||||
exception.javaClass.simpleName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class AiCharacterAdminAuditContext private constructor(
|
||||
val adminMemberId: Long,
|
||||
val characterId: Long?,
|
||||
val creatorMemberId: Long?,
|
||||
val action: AiCharacterAdminAuditAction,
|
||||
val resourceType: AiCharacterAdminAuditResourceType,
|
||||
val resourceId: Long?
|
||||
) {
|
||||
companion object {
|
||||
fun globalOriginalWork(
|
||||
adminMemberId: Long,
|
||||
action: AiCharacterAdminAuditAction,
|
||||
resourceType: AiCharacterAdminAuditResourceType,
|
||||
resourceId: Long?
|
||||
): AiCharacterAdminAuditContext {
|
||||
require(resourceType == AiCharacterAdminAuditResourceType.ORIGINAL_WORK)
|
||||
require(
|
||||
action == AiCharacterAdminAuditAction.CREATE ||
|
||||
action == AiCharacterAdminAuditAction.UPDATE ||
|
||||
action == AiCharacterAdminAuditAction.DELETE
|
||||
)
|
||||
return AiCharacterAdminAuditContext(
|
||||
adminMemberId = adminMemberId,
|
||||
characterId = null,
|
||||
creatorMemberId = null,
|
||||
action = action,
|
||||
resourceType = resourceType,
|
||||
resourceId = resourceId
|
||||
)
|
||||
}
|
||||
|
||||
fun characterScoped(
|
||||
adminMemberId: Long,
|
||||
characterId: Long,
|
||||
creatorMemberId: Long?,
|
||||
action: AiCharacterAdminAuditAction,
|
||||
resourceType: AiCharacterAdminAuditResourceType,
|
||||
resourceId: Long?
|
||||
): AiCharacterAdminAuditContext {
|
||||
val isAssignmentAction =
|
||||
action == AiCharacterAdminAuditAction.ASSIGN || action == AiCharacterAdminAuditAction.UNASSIGN
|
||||
val isOriginalWorkCharacter = resourceType == AiCharacterAdminAuditResourceType.ORIGINAL_WORK_CHARACTER
|
||||
require(resourceType != AiCharacterAdminAuditResourceType.ORIGINAL_WORK)
|
||||
require(isAssignmentAction == isOriginalWorkCharacter)
|
||||
require(action == AiCharacterAdminAuditAction.UNASSIGN || creatorMemberId != null)
|
||||
return AiCharacterAdminAuditContext(
|
||||
adminMemberId = adminMemberId,
|
||||
characterId = characterId,
|
||||
creatorMemberId = creatorMemberId,
|
||||
action = action,
|
||||
resourceType = resourceType,
|
||||
resourceId = resourceId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class AiCharacterAdminAuditAction {
|
||||
CREATE,
|
||||
UPDATE,
|
||||
DELETE,
|
||||
ASSIGN,
|
||||
UNASSIGN,
|
||||
PIN,
|
||||
READ
|
||||
}
|
||||
|
||||
enum class AiCharacterAdminAuditResourceType {
|
||||
CHARACTER,
|
||||
ORIGINAL_WORK,
|
||||
ORIGINAL_WORK_CHARACTER,
|
||||
CONTENT,
|
||||
CONTENT_COMMENT,
|
||||
CONTENT_CATEGORY,
|
||||
SERIES,
|
||||
COMMUNITY_POST,
|
||||
COMMUNITY_COMMENT,
|
||||
FAN_TALK,
|
||||
FAN_TALK_REPLY,
|
||||
NOTICE,
|
||||
CHANNEL_NOTICE,
|
||||
CREATOR_TAG,
|
||||
CHANNEL_PROFILE
|
||||
}
|
||||
|
||||
enum class AiCharacterAdminAuditResult {
|
||||
SUCCESS,
|
||||
FAILURE
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.admin.aicharacter.dto
|
||||
|
||||
data class AdminPageRequest(
|
||||
val page: Int,
|
||||
val size: Int,
|
||||
val offset: Long,
|
||||
val limit: Long
|
||||
)
|
||||
|
||||
data class AdminPageResponse<T>(
|
||||
val totalCount: Long,
|
||||
val items: List<T>,
|
||||
val page: Int,
|
||||
val size: Int,
|
||||
val hasNext: Boolean
|
||||
) {
|
||||
companion object {
|
||||
fun <T> of(totalCount: Long, content: List<T>, page: Int, size: Int): AdminPageResponse<T> {
|
||||
return AdminPageResponse(
|
||||
totalCount = totalCount,
|
||||
items = content,
|
||||
page = page,
|
||||
size = size,
|
||||
hasNext = ((page.toLong() + 1L) * size.toLong()) < totalCount
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class AdminMutationResponse(
|
||||
val id: Long,
|
||||
val isActive: Boolean
|
||||
)
|
||||
|
||||
data class AdminCommentUpdateRequest(
|
||||
val content: String
|
||||
)
|
||||
@@ -1,51 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.aicharacter.adapter.out.persistence
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||
import kr.co.vividnext.sodalive.member.MemberKind
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.v2.aicharacter.domain.AiCharacterAdminTarget
|
||||
import kr.co.vividnext.sodalive.v2.aicharacter.port.out.AiCharacterPersistencePort
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import javax.persistence.EntityManager
|
||||
import javax.persistence.NoResultException
|
||||
|
||||
@Component
|
||||
class DefaultAiCharacterPersistenceAdapter(
|
||||
private val entityManager: EntityManager
|
||||
) : AiCharacterPersistencePort {
|
||||
@Transactional(readOnly = true)
|
||||
override fun findAdminTarget(characterId: Long): AiCharacterAdminTarget? {
|
||||
val character = findCharacter(characterId) ?: return null
|
||||
val creatorMember = character.creatorMember ?: return null
|
||||
if (creatorMember.role != MemberRole.CREATOR || creatorMember.memberKind != MemberKind.AI_CHARACTER) {
|
||||
return null
|
||||
}
|
||||
|
||||
return AiCharacterAdminTarget(
|
||||
characterId = character.id!!,
|
||||
creatorMemberId = creatorMember.id!!,
|
||||
characterIsActive = character.isActive,
|
||||
creatorMemberIsActive = creatorMember.isActive,
|
||||
creatorRole = creatorMember.role,
|
||||
memberKind = creatorMember.memberKind
|
||||
)
|
||||
}
|
||||
|
||||
private fun findCharacter(characterId: Long): ChatCharacter? {
|
||||
return try {
|
||||
entityManager.createQuery(
|
||||
"""
|
||||
select c
|
||||
from ChatCharacter c
|
||||
left join fetch c.creatorMember
|
||||
where c.id = :characterId
|
||||
""".trimIndent(),
|
||||
ChatCharacter::class.java
|
||||
).setParameter("characterId", characterId)
|
||||
.singleResult
|
||||
} catch (e: NoResultException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.aicharacter.application
|
||||
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.member.MemberKind
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.v2.aicharacter.domain.AiCharacterAdminTarget
|
||||
import kr.co.vividnext.sodalive.v2.aicharacter.port.out.AiCharacterPersistencePort
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
@Service
|
||||
class AiCharacterAdminTargetResolver(
|
||||
private val persistencePort: AiCharacterPersistencePort
|
||||
) {
|
||||
fun resolveActiveTarget(characterId: Long): AiCharacterAdminTarget {
|
||||
val target = resolveExistingTarget(characterId)
|
||||
if (!target.characterIsActive || !target.creatorMemberIsActive) {
|
||||
throw SodaException(
|
||||
messageKey = "common.error.invalid_request",
|
||||
errorProperty = "characterId",
|
||||
httpStatus = HttpStatus.CONFLICT
|
||||
)
|
||||
}
|
||||
|
||||
return target
|
||||
}
|
||||
|
||||
fun resolveExistingTarget(characterId: Long): AiCharacterAdminTarget {
|
||||
val target = persistencePort.findAdminTarget(characterId) ?: throwNotFound()
|
||||
if (target.creatorRole != MemberRole.CREATOR || target.memberKind != MemberKind.AI_CHARACTER) {
|
||||
throwNotFound()
|
||||
}
|
||||
|
||||
return target
|
||||
}
|
||||
|
||||
private fun throwNotFound(): Nothing {
|
||||
throw SodaException(
|
||||
messageKey = "admin.chat.character.not_found",
|
||||
errorProperty = "characterId",
|
||||
httpStatus = HttpStatus.NOT_FOUND
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.aicharacter.domain
|
||||
|
||||
import kr.co.vividnext.sodalive.member.MemberKind
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
|
||||
data class AiCharacterAdminTarget(
|
||||
val characterId: Long,
|
||||
val creatorMemberId: Long,
|
||||
val characterIsActive: Boolean,
|
||||
val creatorMemberIsActive: Boolean,
|
||||
val creatorRole: MemberRole,
|
||||
val memberKind: MemberKind
|
||||
)
|
||||
@@ -1,7 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.aicharacter.port.out
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.aicharacter.domain.AiCharacterAdminTarget
|
||||
|
||||
interface AiCharacterPersistencePort {
|
||||
fun findAdminTarget(characterId: Long): AiCharacterAdminTarget?
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.common.application
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.transaction.support.TransactionSynchronization
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||
|
||||
@Component
|
||||
class AfterCommitExecutor {
|
||||
private val logger = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
fun executeAfterCommit(callback: () -> Unit) {
|
||||
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
executeCallback(callback)
|
||||
return
|
||||
}
|
||||
|
||||
TransactionSynchronizationManager.registerSynchronization(
|
||||
object : TransactionSynchronization {
|
||||
override fun afterCommit() {
|
||||
executeCallback(callback)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun executeCallback(callback: () -> Unit) {
|
||||
try {
|
||||
callback()
|
||||
} catch (e: Exception) {
|
||||
logger.warn("afterCommit callback failed error={}", e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user