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

This commit is contained in:
2026-07-26 05:01:34 +09:00
parent d3564f8c0d
commit 0d2742756f
19 changed files with 4108 additions and 16 deletions

View File

@@ -142,6 +142,15 @@ interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
)
fun findByIdInWithTagMappings(@Param("ids") ids: List<Long>): List<ChatCharacter>
@Query(
"""
SELECT c FROM ChatCharacter c
LEFT JOIN FETCH c.creatorMember
WHERE c.id = :id
"""
)
fun findByIdWithCreatorMember(@Param("id") id: Long): ChatCharacter?
fun findByCreatorMemberId(creatorMemberId: Long): ChatCharacter?
fun existsByCreatorMemberId(creatorMemberId: Long): Boolean
}

View File

@@ -1,12 +1,22 @@
package kr.co.vividnext.sodalive.common
import com.fasterxml.jackson.databind.ObjectMapper
import io.jsonwebtoken.JwtException
import org.springframework.security.authentication.BadCredentialsException
import org.springframework.security.web.AuthenticationEntryPoint
import org.springframework.security.web.util.matcher.RequestMatcher
import org.springframework.web.filter.OncePerRequestFilter
import org.springframework.web.servlet.HandlerExceptionResolver
import javax.servlet.FilterChain
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
class ExceptionHandlerFilter(private val objectMapper: ObjectMapper) : OncePerRequestFilter() {
class ExceptionHandlerFilter(
private val objectMapper: ObjectMapper,
private val apiRequestMatcher: RequestMatcher,
private val apiAuthenticationEntryPoint: AuthenticationEntryPoint,
private val apiExceptionResolver: HandlerExceptionResolver
) : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
@@ -15,6 +25,18 @@ class ExceptionHandlerFilter(private val objectMapper: ObjectMapper) : OncePerRe
try {
filterChain.doFilter(request, response)
} catch (e: Exception) {
if (apiRequestMatcher.matches(request)) {
if (isAuthenticationFailure(e)) {
val authenticationException = e as? BadCredentialsException
?: BadCredentialsException("Authentication failed", e)
apiAuthenticationEntryPoint.commence(request, response, authenticationException)
} else {
val resolved = apiExceptionResolver.resolveException(request, response, null, e)
if (resolved == null) throw e
}
return
}
response.status = 401
response.contentType = "application/json"
response.characterEncoding = "UTF-8"
@@ -23,4 +45,10 @@ class ExceptionHandlerFilter(private val objectMapper: ObjectMapper) : OncePerRe
response.writer.write(json)
}
}
private fun isAuthenticationFailure(exception: Exception): Boolean {
return exception is JwtException ||
exception is BadCredentialsException ||
(exception is SodaException && exception.messageKey == "common.error.bad_credentials")
}
}

View File

@@ -2,13 +2,19 @@ package kr.co.vividnext.sodalive.configs
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.common.ExceptionHandlerFilter
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
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.api.admin.aicharacter.error.AiCharacterAdminErrorResponseWriter
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminExceptionHandler
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.security.AiCharacterAdminSecurityErrorHandler
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.Ordered
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.builders.WebSecurity
@@ -17,8 +23,16 @@ import org.springframework.security.config.annotation.web.configuration.WebSecur
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.web.AuthenticationEntryPoint
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.access.AccessDeniedHandler
import org.springframework.security.web.access.RequestMatcherDelegatingAccessDeniedHandler
import org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
import org.springframework.security.web.util.matcher.AntPathRequestMatcher
import org.springframework.security.web.util.matcher.RequestMatcher
import org.springframework.web.HttpRequestHandler
import org.springframework.web.servlet.handler.SimpleUrlHandlerMapping
@Configuration
@EnableWebSecurity
@@ -35,25 +49,94 @@ class SecurityConfig(
}
@Bean
fun webSecurityCustomizer(): WebSecurityCustomizer {
fun webSecurityCustomizer(
aiCharacterAdminSecurityErrorHandler: AiCharacterAdminSecurityErrorHandler
): WebSecurityCustomizer {
return WebSecurityCustomizer { web: WebSecurity ->
web
.requestRejectedHandler(aiCharacterAdminSecurityErrorHandler)
.ignoring()
.antMatchers("/h2-console/**", "/favicon.ico", "/error")
}
}
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
fun aiCharacterAdminErrorResponseWriter(messageSource: SodaMessageSource): AiCharacterAdminErrorResponseWriter {
return AiCharacterAdminErrorResponseWriter(objectMapper, messageSource)
}
@Bean
fun aiCharacterAdminSecurityErrorHandler(
responseWriter: AiCharacterAdminErrorResponseWriter
): AiCharacterAdminSecurityErrorHandler {
return AiCharacterAdminSecurityErrorHandler(
responseWriter,
AntPathRequestMatcher(AI_CHARACTER_ADMIN_PATH),
WebConfig.createAiCharacterAdminCorsConfiguration()
)
}
@Bean
fun aiCharacterAdminExceptionHandler(
responseWriter: AiCharacterAdminErrorResponseWriter
): AiCharacterAdminExceptionHandler {
return AiCharacterAdminExceptionHandler(
responseWriter,
AntPathRequestMatcher(AI_CHARACTER_ADMIN_PATH)
)
}
@Bean
fun aiCharacterAdminFallbackHandlerMapping(
responseWriter: AiCharacterAdminErrorResponseWriter
): SimpleUrlHandlerMapping {
val notFoundHandler = HttpRequestHandler { request, response ->
responseWriter.write(
request,
response,
HttpStatus.NOT_FOUND,
"common.error.invalid_request"
)
}
return SimpleUrlHandlerMapping(
mapOf(AI_CHARACTER_ADMIN_PATH to notFoundHandler),
Ordered.LOWEST_PRECEDENCE - 2
).apply {
setCorsConfigurations(
mapOf(AI_CHARACTER_ADMIN_PATH to WebConfig.createAiCharacterAdminCorsConfiguration())
)
}
}
@Bean
fun filterChain(
http: HttpSecurity,
aiCharacterAdminSecurityErrorHandler: AiCharacterAdminSecurityErrorHandler,
aiCharacterAdminExceptionHandler: AiCharacterAdminExceptionHandler
): SecurityFilterChain {
val jwtFilter = JwtFilter(tokenProvider)
val aiCharacterAdminRequestMatcher = AntPathRequestMatcher(AI_CHARACTER_ADMIN_PATH)
val authenticationEntryPoints = linkedMapOf<RequestMatcher, AuthenticationEntryPoint>(
aiCharacterAdminRequestMatcher to aiCharacterAdminSecurityErrorHandler
)
val delegatingAuthenticationEntryPoint = DelegatingAuthenticationEntryPoint(authenticationEntryPoints).apply {
setDefaultEntryPoint(authenticationEntryPoint)
}
val accessDeniedHandlers = linkedMapOf<RequestMatcher, AccessDeniedHandler>(
aiCharacterAdminRequestMatcher to aiCharacterAdminSecurityErrorHandler
)
val delegatingAccessDeniedHandler = RequestMatcherDelegatingAccessDeniedHandler(
accessDeniedHandlers,
accessDeniedHandler
)
return http
.cors()
.and()
.csrf().disable()
.exceptionHandling()
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler)
.authenticationEntryPoint(delegatingAuthenticationEntryPoint)
.accessDeniedHandler(delegatingAccessDeniedHandler)
.and()
.headers()
.frameOptions()
@@ -63,7 +146,15 @@ class SecurityConfig(
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter::class.java)
.addFilterBefore(ExceptionHandlerFilter(objectMapper), JwtFilter::class.java)
.addFilterBefore(
ExceptionHandlerFilter(
objectMapper,
aiCharacterAdminRequestMatcher,
aiCharacterAdminSecurityErrorHandler,
aiCharacterAdminExceptionHandler
),
JwtFilter::class.java
)
.authorizeRequests()
.antMatchers("/member/check/email").permitAll()
.antMatchers("/member/check/nickname").permitAll()
@@ -108,10 +199,20 @@ class SecurityConfig(
.antMatchers(HttpMethod.GET, "/api/v2/home/rankings/creators").permitAll()
.antMatchers(HttpMethod.GET, "/api/v2/home/following").permitAll()
.antMatchers(HttpMethod.GET, "/api/v2/home/on-air-lives").authenticated()
.antMatchers(AI_CHARACTER_ADMIN_PATH)
.access(
"hasRole('ADMIN') and " +
"principal instanceof T(kr.co.vividnext.sodalive.member.MemberAdapter) and " +
"principal.member.role == T(kr.co.vividnext.sodalive.member.MemberRole).ADMIN"
)
// 페이지네이션 하위 경로(/lives, /debut-creators 등)는 인증 필수
.antMatchers(HttpMethod.GET, "/api/v2/home/recommendations/**").authenticated()
.anyRequest().authenticated()
.and()
.build()
}
companion object {
private const val AI_CHARACTER_ADMIN_PATH = "/api/v2/admin/ai-characters/**"
}
}

View File

@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.configs
import kr.co.vividnext.sodalive.common.CountryInterceptor
import kr.co.vividnext.sodalive.i18n.LangInterceptor
import org.springframework.context.annotation.Configuration
import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.servlet.config.annotation.CorsRegistry
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
@@ -18,15 +19,49 @@ class WebConfig(
}
override fun addCorsMappings(registry: CorsRegistry) {
listOf("/admin/member/login", "/member/logout").forEach { path ->
registry.addMapping(path)
.allowedOrigins(*AI_CHARACTER_ADMIN_SHARED_AUTH_ALLOWED_ORIGINS.toTypedArray())
.allowedMethods("*")
.allowCredentials(true)
}
registry.addMapping("/api/v2/admin/ai-characters/**")
.allowedOrigins(*AI_CHARACTER_ADMIN_ALLOWED_ORIGINS.toTypedArray())
.allowedMethods("*")
.allowCredentials(true)
registry.addMapping("/**")
.allowedOrigins(
"http://localhost:8888",
"https://creator.sodalive.net",
"https://test-creator.sodalive.net",
"https://test-admin.sodalive.net",
"https://admin.sodalive.net"
)
.allowedOrigins(*ALLOWED_ORIGINS.toTypedArray())
.allowedMethods("*")
.allowCredentials(true)
}
companion object {
private val ALLOWED_ORIGINS = listOf(
"http://localhost:8888",
"https://creator.sodalive.net",
"https://test-creator.sodalive.net",
"https://test-admin.sodalive.net",
"https://admin.sodalive.net"
)
private val AI_CHARACTER_ADMIN_ALLOWED_ORIGINS = listOf(
"http://localhost:8888",
"https://test-character-admin.sodalive.net",
"https://character-admin.sodalive.net"
)
private val AI_CHARACTER_ADMIN_SHARED_AUTH_ALLOWED_ORIGINS =
(ALLOWED_ORIGINS + AI_CHARACTER_ADMIN_ALLOWED_ORIGINS).distinct()
internal fun createAiCharacterAdminCorsConfiguration(): CorsConfiguration {
return CorsConfiguration().apply {
applyPermitDefaultValues()
allowedOrigins = AI_CHARACTER_ADMIN_ALLOWED_ORIGINS
allowedMethods = listOf(CorsConfiguration.ALL)
allowCredentials = true
}
}
}
}

View File

@@ -83,15 +83,21 @@ class TokenProvider(
.parseClaimsJws(token)
.body
val authorities = claims[AUTHORITIES_KEY].toString().split(",").map { SimpleGrantedAuthority(it) }
val memberToken = tokenRepository.findByIdOrNull(id = claims.subject.toLong())
val memberId = claims.subject?.toLongOrNull()
?: throw SodaException(messageKey = "common.error.bad_credentials")
val authorityNames = (claims[AUTHORITIES_KEY] as? String)
?.split(",")
?.takeIf { names -> names.all { it.isNotBlank() } }
?: throw SodaException(messageKey = "common.error.bad_credentials")
val authorities = authorityNames.map { SimpleGrantedAuthority(it) }
val memberToken = tokenRepository.findByIdOrNull(id = memberId)
?: throw SodaException(messageKey = "common.error.bad_credentials")
if (!memberToken.tokenSet.contains(token)) {
throw SodaException(messageKey = "common.error.bad_credentials")
}
val member = repository.findByIdOrNull(id = claims.subject.toLong())
val member = repository.findByIdOrNull(id = memberId)
?: throw SodaException(messageKey = "common.error.bad_credentials")
val principal = MemberAdapter(member)

View File

@@ -0,0 +1,43 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberKind
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class AiCharacterAdminTargetResolver(
private val chatCharacterRepository: ChatCharacterRepository
) {
@Transactional(readOnly = true)
fun resolve(characterId: Long): AiCharacterAdminTarget {
val chatCharacter = chatCharacterRepository.findByIdWithCreatorMember(characterId)
?: throw invalidTarget()
val creatorMember = chatCharacter.creatorMember
if (creatorMember?.role != MemberRole.CREATOR || creatorMember.memberKind != MemberKind.AI_CHARACTER) {
throw invalidTarget()
}
return AiCharacterAdminTarget(
characterId = characterId,
chatCharacter = chatCharacter,
creatorMember = creatorMember
)
}
private fun invalidTarget(): AiCharacterAdminApiException {
return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request")
}
}
data class AiCharacterAdminTarget(
val characterId: Long,
val chatCharacter: ChatCharacter,
val creatorMember: Member
)

View File

@@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error
import org.springframework.http.HttpStatus
class AiCharacterAdminApiException(
val status: HttpStatus,
val messageKey: String
) : RuntimeException(messageKey)

View File

@@ -0,0 +1,35 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
class AiCharacterAdminErrorResponseWriter(
private val objectMapper: ObjectMapper,
private val messageSource: SodaMessageSource
) {
fun createResponse(request: HttpServletRequest, messageKey: String): ApiResponse<Any> {
val lang = Lang.fromAcceptLanguage(request.getHeader(HttpHeaders.ACCEPT_LANGUAGE))
val message = messageSource.getMessage(messageKey, lang)
?: messageSource.getMessage("common.error.unknown", lang)
return ApiResponse.error(message = message)
}
fun write(
request: HttpServletRequest,
response: HttpServletResponse,
status: HttpStatus,
messageKey: String
) {
response.status = status.value()
response.contentType = MediaType.APPLICATION_JSON_VALUE
response.characterEncoding = Charsets.UTF_8.name()
response.writer.write(objectMapper.writeValueAsString(createResponse(request, messageKey)))
}
}

View File

@@ -0,0 +1,102 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error
import org.slf4j.LoggerFactory
import org.springframework.core.Ordered
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.converter.HttpMessageNotReadableException
import org.springframework.security.access.AccessDeniedException
import org.springframework.security.web.util.matcher.RequestMatcher
import org.springframework.validation.BindException
import org.springframework.web.HttpMediaTypeNotAcceptableException
import org.springframework.web.HttpMediaTypeNotSupportedException
import org.springframework.web.HttpRequestMethodNotSupportedException
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.MissingPathVariableException
import org.springframework.web.bind.ServletRequestBindingException
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException
import org.springframework.web.multipart.MultipartException
import org.springframework.web.multipart.support.MissingServletRequestPartException
import org.springframework.web.servlet.HandlerExceptionResolver
import org.springframework.web.servlet.ModelAndView
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
class AiCharacterAdminExceptionHandler(
private val responseWriter: AiCharacterAdminErrorResponseWriter,
private val requestMatcher: RequestMatcher
) : HandlerExceptionResolver, Ordered {
private val logger = LoggerFactory.getLogger(this::class.java)
override fun getOrder(): Int = Ordered.HIGHEST_PRECEDENCE
override fun resolveException(
request: HttpServletRequest,
response: HttpServletResponse,
handler: Any?,
exception: Exception
): ModelAndView? {
if (!requestMatcher.matches(request)) return null
val error = resolveError(exception)
if (error.status.is5xxServerError) {
logger.error("Unexpected AI character admin API error", exception)
} else {
logger.debug("AI character admin API request error", exception)
}
writeStandardHeaders(request, response, exception)
responseWriter.write(request, response, error.status, error.messageKey)
return ModelAndView()
}
private fun resolveError(exception: Exception): ResolvedError {
return when (exception) {
is AiCharacterAdminApiException -> ResolvedError(exception.status, exception.messageKey)
is AccessDeniedException -> ResolvedError(HttpStatus.FORBIDDEN, "common.error.access_denied")
is HttpRequestMethodNotSupportedException -> invalidRequest(HttpStatus.METHOD_NOT_ALLOWED)
is HttpMediaTypeNotSupportedException -> invalidRequest(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
is HttpMediaTypeNotAcceptableException -> invalidRequest(HttpStatus.NOT_ACCEPTABLE)
is MissingPathVariableException -> ResolvedError(HttpStatus.INTERNAL_SERVER_ERROR, "common.error.unknown")
is BindException,
is HttpMessageNotReadableException,
is MethodArgumentNotValidException,
is MethodArgumentTypeMismatchException,
is ServletRequestBindingException,
is MultipartException,
is MissingServletRequestPartException -> invalidRequest(HttpStatus.BAD_REQUEST)
else -> ResolvedError(HttpStatus.INTERNAL_SERVER_ERROR, "common.error.unknown")
}
}
private fun writeStandardHeaders(
request: HttpServletRequest,
response: HttpServletResponse,
exception: Exception
) {
when (exception) {
is HttpRequestMethodNotSupportedException -> {
exception.supportedMethods?.let { response.setHeader(HttpHeaders.ALLOW, it.joinToString(", ")) }
}
is HttpMediaTypeNotSupportedException -> {
if (exception.supportedMediaTypes.isNotEmpty()) {
val supportedMediaTypes = MediaType.toString(exception.supportedMediaTypes)
response.setHeader(HttpHeaders.ACCEPT, supportedMediaTypes)
if (request.method == HttpMethod.PATCH.name) {
response.setHeader(HttpHeaders.ACCEPT_PATCH, supportedMediaTypes)
}
}
}
}
}
private fun invalidRequest(status: HttpStatus): ResolvedError {
return ResolvedError(status, "common.error.invalid_request")
}
private data class ResolvedError(
val status: HttpStatus,
val messageKey: String
)
}

View File

@@ -0,0 +1,83 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.security
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminErrorResponseWriter
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.security.access.AccessDeniedException
import org.springframework.security.core.AuthenticationException
import org.springframework.security.web.AuthenticationEntryPoint
import org.springframework.security.web.access.AccessDeniedHandler
import org.springframework.security.web.firewall.DefaultRequestRejectedHandler
import org.springframework.security.web.firewall.RequestRejectedException
import org.springframework.security.web.firewall.RequestRejectedHandler
import org.springframework.security.web.util.matcher.RequestMatcher
import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.DefaultCorsProcessor
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletRequestWrapper
import javax.servlet.http.HttpServletResponse
class AiCharacterAdminSecurityErrorHandler(
private val responseWriter: AiCharacterAdminErrorResponseWriter,
private val aiCharacterAdminRequestMatcher: RequestMatcher,
private val aiCharacterAdminCorsConfiguration: CorsConfiguration
) : AuthenticationEntryPoint, AccessDeniedHandler, RequestRejectedHandler {
private val defaultRequestRejectedHandler = DefaultRequestRejectedHandler()
private val corsProcessor = DefaultCorsProcessor()
override fun commence(
request: HttpServletRequest,
response: HttpServletResponse,
authException: AuthenticationException
) {
responseWriter.write(
request = request,
response = response,
status = HttpStatus.UNAUTHORIZED,
messageKey = "common.error.bad_credentials"
)
}
override fun handle(
request: HttpServletRequest,
response: HttpServletResponse,
accessDeniedException: AccessDeniedException
) {
responseWriter.write(
request = request,
response = response,
status = HttpStatus.FORBIDDEN,
messageKey = "common.error.access_denied"
)
}
override fun handle(
request: HttpServletRequest,
response: HttpServletResponse,
requestRejectedException: RequestRejectedException
) {
if (!aiCharacterAdminRequestMatcher.matches(request)) {
defaultRequestRejectedHandler.handle(request, response, requestRejectedException)
return
}
// Spring 5.3 CORS processor only recognizes HttpMethod enums, so use GET for nonstandard method checks.
val corsRequest = if (HttpMethod.resolve(request.method) == null) {
object : HttpServletRequestWrapper(request) {
override fun getMethod(): String = HttpMethod.GET.name
}
} else {
request
}
if (!corsProcessor.processRequest(aiCharacterAdminCorsConfiguration, corsRequest, response)) {
return
}
responseWriter.write(
request = request,
response = response,
status = HttpStatus.BAD_REQUEST,
messageKey = "common.error.invalid_request"
)
}
}