feat(ai-character): 관리자 기능 기반을 추가한다

This commit is contained in:
2026-07-22 01:43:08 +09:00
parent 5b700892c3
commit 3f4d7b237f
39 changed files with 5657 additions and 114 deletions

View File

@@ -1,9 +1,12 @@
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 messageKey: String? = null,
val httpStatus: HttpStatus? = null
) : RuntimeException(message)
class AdsChargeException(

View File

@@ -6,6 +6,7 @@ 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
@@ -13,7 +14,9 @@ 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(
@@ -38,12 +41,25 @@ class SodaExceptionHandler(
)
}
@ExceptionHandler(MaxUploadSizeExceededException::class)
fun handleMaxUploadSizeExceededException(e: MaxUploadSizeExceededException) = run {
val logMessage = messageSource.getMessage("common.error.max_upload_size", logLang)
@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)
logger.error("API error: {}", logMessage, e)
val message = messageSource.getMessage("common.error.max_upload_size", langContext.lang)
ApiResponse.error(message = message)
val message = messageSource.getMessage(messageKey, langContext.lang)
val body = ApiResponse.error(message = message)
if (isAiCharacterAdminPath) {
ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body)
} else {
body
}
}
@ExceptionHandler(AccessDeniedException::class)
@@ -99,4 +115,12 @@ 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"
}
}

View File

@@ -6,6 +6,9 @@ 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
@@ -27,7 +30,9 @@ class SecurityConfig(
private val objectMapper: ObjectMapper,
private val tokenProvider: TokenProvider,
private val accessDeniedHandler: JwtAccessDeniedHandler,
private val authenticationEntryPoint: JwtAuthenticationEntryPoint
private val authenticationEntryPoint: JwtAuthenticationEntryPoint,
private val aiCharacterAdminAuthenticationEntryPoint: ObjectProvider<AiCharacterAdminAuthenticationEntryPoint>,
private val aiCharacterAdminAccessDeniedHandler: ObjectProvider<AiCharacterAdminAccessDeniedHandler>
) {
@Bean
fun passwordEncoder(): PasswordEncoder {
@@ -52,8 +57,22 @@ class SecurityConfig(
.and()
.csrf().disable()
.exceptionHandling()
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler)
.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)
}
}
.and()
.headers()
.frameOptions()
@@ -100,6 +119,7 @@ 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()
@@ -110,8 +130,17 @@ 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"
}
}

View File

@@ -0,0 +1,27 @@
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("권한이 없습니다.")))
}
}

View File

@@ -0,0 +1,27 @@
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("로그인 정보를 확인해주세요.")))
}
}

View File

@@ -0,0 +1,395 @@
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
)

View File

@@ -0,0 +1,49 @@
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
}
}

View File

@@ -0,0 +1,106 @@
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."
}
}

View File

@@ -0,0 +1,26 @@
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
}

View File

@@ -0,0 +1,128 @@
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
}

View File

@@ -0,0 +1,37 @@
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
)

View File

@@ -0,0 +1,51 @@
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
}
}
}

View File

@@ -0,0 +1,44 @@
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
)
}
}

View File

@@ -0,0 +1,13 @@
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
)

View File

@@ -0,0 +1,7 @@
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?
}

View File

@@ -0,0 +1,34 @@
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)
}
}
}