feat(character-curation): 캐릭터 큐레이션 도메인/관리 API 추가 및 메인 화면 통합
- CharacterCuration/CharacterCurationMapping 엔티티 추가 - 리포지토리/서비스(조회·관리) 구현 - 관리자 컨트롤러에 등록/수정/삭제/정렬/캐릭터 추가·삭제·정렬 API 추가 - 앱 메인 API에 큐레이션 섹션 노출 - 정렬/소프트 삭제/활성 캐릭터 필터링 규칙 적용
This commit is contained in:
		| @@ -0,0 +1,76 @@ | |||||||
|  | package kr.co.vividnext.sodalive.admin.chat.character.curation | ||||||
|  |  | ||||||
|  | import kr.co.vividnext.sodalive.common.ApiResponse | ||||||
|  | import org.springframework.beans.factory.annotation.Value | ||||||
|  | import org.springframework.security.access.prepost.PreAuthorize | ||||||
|  | import org.springframework.web.bind.annotation.DeleteMapping | ||||||
|  | import org.springframework.web.bind.annotation.GetMapping | ||||||
|  | import org.springframework.web.bind.annotation.PathVariable | ||||||
|  | import org.springframework.web.bind.annotation.PostMapping | ||||||
|  | import org.springframework.web.bind.annotation.PutMapping | ||||||
|  | import org.springframework.web.bind.annotation.RequestBody | ||||||
|  | import org.springframework.web.bind.annotation.RequestMapping | ||||||
|  | import org.springframework.web.bind.annotation.RestController | ||||||
|  |  | ||||||
|  | @RestController | ||||||
|  | @RequestMapping("/admin/chat/character/curation") | ||||||
|  | @PreAuthorize("hasRole('ADMIN')") | ||||||
|  | class CharacterCurationAdminController( | ||||||
|  |     private val service: CharacterCurationAdminService, | ||||||
|  |     @Value("\${cloud.aws.cloud-front.host}") | ||||||
|  |     private val imageHost: String | ||||||
|  | ) { | ||||||
|  |     @GetMapping("/list") | ||||||
|  |     fun listAll(): ApiResponse<List<CharacterCurationListItemResponse>> = | ||||||
|  |         ApiResponse.ok(service.listAll()) | ||||||
|  |  | ||||||
|  |     @GetMapping("/{curationId}/characters") | ||||||
|  |     fun listCharacters( | ||||||
|  |         @PathVariable curationId: Long | ||||||
|  |     ): ApiResponse<List<CharacterCurationCharacterItemResponse>> { | ||||||
|  |         val characters = service.listCharacters(curationId) | ||||||
|  |         val items = characters.map { | ||||||
|  |             CharacterCurationCharacterItemResponse( | ||||||
|  |                 id = it.id!!, | ||||||
|  |                 name = it.name, | ||||||
|  |                 description = it.description, | ||||||
|  |                 imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}" | ||||||
|  |             ) | ||||||
|  |         } | ||||||
|  |         return ApiResponse.ok(items) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     @PostMapping("/register") | ||||||
|  |     fun register(@RequestBody request: CharacterCurationRegisterRequest) = | ||||||
|  |         ApiResponse.ok(service.register(request).id) | ||||||
|  |  | ||||||
|  |     @PutMapping("/update") | ||||||
|  |     fun update(@RequestBody request: CharacterCurationUpdateRequest) = | ||||||
|  |         ApiResponse.ok(service.update(request).id) | ||||||
|  |  | ||||||
|  |     @DeleteMapping("/{curationId}") | ||||||
|  |     fun delete(@PathVariable curationId: Long) = | ||||||
|  |         ApiResponse.ok(service.softDelete(curationId)) | ||||||
|  |  | ||||||
|  |     @PutMapping("/reorder") | ||||||
|  |     fun reorder(@RequestBody request: CharacterCurationOrderUpdateRequest) = | ||||||
|  |         ApiResponse.ok(service.reorder(request.ids)) | ||||||
|  |  | ||||||
|  |     @PostMapping("/{curationId}/characters") | ||||||
|  |     fun addCharacter( | ||||||
|  |         @PathVariable curationId: Long, | ||||||
|  |         @RequestBody request: CharacterCurationAddCharacterRequest | ||||||
|  |     ) = ApiResponse.ok(service.addCharacter(curationId, request.characterId)) | ||||||
|  |  | ||||||
|  |     @DeleteMapping("/{curationId}/characters/{characterId}") | ||||||
|  |     fun removeCharacter( | ||||||
|  |         @PathVariable curationId: Long, | ||||||
|  |         @PathVariable characterId: Long | ||||||
|  |     ) = ApiResponse.ok(service.removeCharacter(curationId, characterId)) | ||||||
|  |  | ||||||
|  |     @PutMapping("/{curationId}/characters/reorder") | ||||||
|  |     fun reorderCharacters( | ||||||
|  |         @PathVariable curationId: Long, | ||||||
|  |         @RequestBody request: CharacterCurationReorderCharactersRequest | ||||||
|  |     ) = ApiResponse.ok(service.reorderCharacters(curationId, request.characterIds)) | ||||||
|  | } | ||||||
| @@ -0,0 +1,44 @@ | |||||||
|  | package kr.co.vividnext.sodalive.admin.chat.character.curation | ||||||
|  |  | ||||||
|  | data class CharacterCurationRegisterRequest( | ||||||
|  |     val title: String, | ||||||
|  |     val isAdult: Boolean = false, | ||||||
|  |     val isActive: Boolean = true | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | data class CharacterCurationUpdateRequest( | ||||||
|  |     val id: Long, | ||||||
|  |     val title: String? = null, | ||||||
|  |     val isAdult: Boolean? = null, | ||||||
|  |     val isActive: Boolean? = null | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | data class CharacterCurationOrderUpdateRequest( | ||||||
|  |     val ids: List<Long> | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | data class CharacterCurationAddCharacterRequest( | ||||||
|  |     val characterId: Long | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | data class CharacterCurationReorderCharactersRequest( | ||||||
|  |     val characterIds: List<Long> | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | data class CharacterCurationListItemResponse( | ||||||
|  |     val id: Long, | ||||||
|  |     val title: String, | ||||||
|  |     val isAdult: Boolean, | ||||||
|  |     val isActive: Boolean | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | // 관리자 큐레이션 상세 - 캐릭터 리스트 항목 응답 DTO | ||||||
|  | // id, name, description, 이미지 URL | ||||||
|  | // 이미지 URL은 컨트롤러에서 cloud-front host + imagePath로 구성 | ||||||
|  |  | ||||||
|  | data class CharacterCurationCharacterItemResponse( | ||||||
|  |     val id: Long, | ||||||
|  |     val name: String, | ||||||
|  |     val description: String, | ||||||
|  |     val imageUrl: String | ||||||
|  | ) | ||||||
| @@ -0,0 +1,123 @@ | |||||||
|  | package kr.co.vividnext.sodalive.admin.chat.character.curation | ||||||
|  |  | ||||||
|  | import kr.co.vividnext.sodalive.chat.character.ChatCharacter | ||||||
|  | import kr.co.vividnext.sodalive.chat.character.curation.CharacterCuration | ||||||
|  | import kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationMapping | ||||||
|  | import kr.co.vividnext.sodalive.chat.character.curation.repository.CharacterCurationMappingRepository | ||||||
|  | import kr.co.vividnext.sodalive.chat.character.curation.repository.CharacterCurationRepository | ||||||
|  | import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository | ||||||
|  | import kr.co.vividnext.sodalive.common.SodaException | ||||||
|  | import org.springframework.stereotype.Service | ||||||
|  | import org.springframework.transaction.annotation.Transactional | ||||||
|  |  | ||||||
|  | @Service | ||||||
|  | class CharacterCurationAdminService( | ||||||
|  |     private val curationRepository: CharacterCurationRepository, | ||||||
|  |     private val mappingRepository: CharacterCurationMappingRepository, | ||||||
|  |     private val characterRepository: ChatCharacterRepository | ||||||
|  | ) { | ||||||
|  |  | ||||||
|  |     @Transactional | ||||||
|  |     fun register(request: CharacterCurationRegisterRequest): CharacterCuration { | ||||||
|  |         val sortOrder = (curationRepository.findMaxSortOrder() ?: 0) + 1 | ||||||
|  |         val curation = CharacterCuration( | ||||||
|  |             title = request.title, | ||||||
|  |             isAdult = request.isAdult, | ||||||
|  |             isActive = request.isActive, | ||||||
|  |             sortOrder = sortOrder | ||||||
|  |         ) | ||||||
|  |         return curationRepository.save(curation) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     @Transactional | ||||||
|  |     fun update(request: CharacterCurationUpdateRequest): CharacterCuration { | ||||||
|  |         val curation = curationRepository.findById(request.id) | ||||||
|  |             .orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: ${request.id}") } | ||||||
|  |  | ||||||
|  |         request.title?.let { curation.title = it } | ||||||
|  |         request.isAdult?.let { curation.isAdult = it } | ||||||
|  |         request.isActive?.let { curation.isActive = it } | ||||||
|  |  | ||||||
|  |         return curationRepository.save(curation) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     @Transactional | ||||||
|  |     fun softDelete(curationId: Long) { | ||||||
|  |         val curation = curationRepository.findById(curationId) | ||||||
|  |             .orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") } | ||||||
|  |         curation.isActive = false | ||||||
|  |         curationRepository.save(curation) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     @Transactional | ||||||
|  |     fun reorder(ids: List<Long>) { | ||||||
|  |         ids.forEachIndexed { index, id -> | ||||||
|  |             val curation = curationRepository.findById(id) | ||||||
|  |                 .orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $id") } | ||||||
|  |             curation.sortOrder = index + 1 | ||||||
|  |             curationRepository.save(curation) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     @Transactional | ||||||
|  |     fun addCharacter(curationId: Long, characterId: Long) { | ||||||
|  |         val curation = curationRepository.findById(curationId) | ||||||
|  |             .orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") } | ||||||
|  |         if (!curation.isActive) throw SodaException("비활성화된 큐레이션입니다: $curationId") | ||||||
|  |  | ||||||
|  |         val character = characterRepository.findById(characterId) | ||||||
|  |             .orElseThrow { SodaException("캐릭터를 찾을 수 없습니다: $characterId") } | ||||||
|  |         if (!character.isActive) throw SodaException("비활성화된 캐릭터는 추가할 수 없습니다: $characterId") | ||||||
|  |  | ||||||
|  |         val existing = mappingRepository.findByCuration(curation) | ||||||
|  |             .firstOrNull { it.chatCharacter.id == characterId } | ||||||
|  |         if (existing != null) return // 이미 존재하면 무시 | ||||||
|  |  | ||||||
|  |         val nextOrder = (mappingRepository.findByCuration(curation).maxOfOrNull { it.sortOrder } ?: 0) + 1 | ||||||
|  |         val mapping = CharacterCurationMapping( | ||||||
|  |             curation = curation, | ||||||
|  |             chatCharacter = character, | ||||||
|  |             sortOrder = nextOrder | ||||||
|  |         ) | ||||||
|  |         mappingRepository.save(mapping) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     @Transactional | ||||||
|  |     fun removeCharacter(curationId: Long, characterId: Long) { | ||||||
|  |         val curation = curationRepository.findById(curationId) | ||||||
|  |             .orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") } | ||||||
|  |         val mappings = mappingRepository.findByCuration(curation) | ||||||
|  |         val target = mappings.firstOrNull { it.chatCharacter.id == characterId } | ||||||
|  |             ?: throw SodaException("매핑을 찾을 수 없습니다: curation=$curationId, character=$characterId") | ||||||
|  |         mappingRepository.delete(target) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     @Transactional | ||||||
|  |     fun reorderCharacters(curationId: Long, characterIds: List<Long>) { | ||||||
|  |         val curation = curationRepository.findById(curationId) | ||||||
|  |             .orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") } | ||||||
|  |         val mappings = mappingRepository.findByCuration(curation) | ||||||
|  |         val mappingByCharacterId = mappings.associateBy { it.chatCharacter.id } | ||||||
|  |  | ||||||
|  |         characterIds.forEachIndexed { index, cid -> | ||||||
|  |             val mapping = mappingByCharacterId[cid] | ||||||
|  |                 ?: throw SodaException("큐레이션에 포함되지 않은 캐릭터입니다: $cid") | ||||||
|  |             mapping.sortOrder = index + 1 | ||||||
|  |             mappingRepository.save(mapping) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     @Transactional(readOnly = true) | ||||||
|  |     fun listAll(): List<CharacterCurationListItemResponse> { | ||||||
|  |         return curationRepository.findAllByOrderBySortOrderAsc() | ||||||
|  |             .map { CharacterCurationListItemResponse(it.id!!, it.title, it.isAdult, it.isActive) } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     @Transactional(readOnly = true) | ||||||
|  |     fun listCharacters(curationId: Long): List<ChatCharacter> { | ||||||
|  |         val curation = curationRepository.findById(curationId) | ||||||
|  |             .orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") } | ||||||
|  |         val mappings = mappingRepository.findByCurationWithCharacterOrderBySortOrderAsc(curation) | ||||||
|  |         return mappings.map { it.chatCharacter } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -31,6 +31,7 @@ class ChatCharacterController( | |||||||
|     private val bannerService: ChatCharacterBannerService, |     private val bannerService: ChatCharacterBannerService, | ||||||
|     private val chatRoomService: ChatRoomService, |     private val chatRoomService: ChatRoomService, | ||||||
|     private val characterCommentService: CharacterCommentService, |     private val characterCommentService: CharacterCommentService, | ||||||
|  |     private val curationQueryService: kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationQueryService, | ||||||
|  |  | ||||||
|     @Value("\${cloud.aws.cloud-front.host}") |     @Value("\${cloud.aws.cloud-front.host}") | ||||||
|     private val imageHost: String |     private val imageHost: String | ||||||
| @@ -85,8 +86,22 @@ class ChatCharacterController( | |||||||
|                 ) |                 ) | ||||||
|             } |             } | ||||||
|  |  | ||||||
|         // 큐레이션 섹션 (현재는 빈 리스트) |         // 큐레이션 섹션 (활성화된 큐레이션 + 캐릭터) | ||||||
|         val curationSections = emptyList<CurationSection>() |         val curationSections = curationQueryService.getActiveCurationsWithCharacters() | ||||||
|  |             .map { agg -> | ||||||
|  |                 CurationSection( | ||||||
|  |                     characterCurationId = agg.curation.id!!, | ||||||
|  |                     title = agg.curation.title, | ||||||
|  |                     characters = agg.characters.map { | ||||||
|  |                         Character( | ||||||
|  |                             characterId = it.id!!, | ||||||
|  |                             name = it.name, | ||||||
|  |                             description = it.description, | ||||||
|  |                             imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}" | ||||||
|  |                         ) | ||||||
|  |                     } | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |  | ||||||
|         // 응답 생성 |         // 응답 생성 | ||||||
|         ApiResponse.ok( |         ApiResponse.ok( | ||||||
|   | |||||||
| @@ -0,0 +1,47 @@ | |||||||
|  | package kr.co.vividnext.sodalive.chat.character.curation | ||||||
|  |  | ||||||
|  | import kr.co.vividnext.sodalive.chat.character.ChatCharacter | ||||||
|  | import kr.co.vividnext.sodalive.common.BaseEntity | ||||||
|  | import javax.persistence.CascadeType | ||||||
|  | import javax.persistence.Column | ||||||
|  | import javax.persistence.Entity | ||||||
|  | import javax.persistence.FetchType | ||||||
|  | import javax.persistence.JoinColumn | ||||||
|  | import javax.persistence.ManyToOne | ||||||
|  | import javax.persistence.OneToMany | ||||||
|  |  | ||||||
|  | @Entity | ||||||
|  | class CharacterCuration( | ||||||
|  |     @Column(nullable = false) | ||||||
|  |     var title: String, | ||||||
|  |  | ||||||
|  |     // 19금 여부 | ||||||
|  |     @Column(nullable = false) | ||||||
|  |     var isAdult: Boolean = false, | ||||||
|  |  | ||||||
|  |     // 활성화 여부 (소프트 삭제) | ||||||
|  |     @Column(nullable = false) | ||||||
|  |     var isActive: Boolean = true, | ||||||
|  |  | ||||||
|  |     // 정렬 순서 (낮을수록 먼저) | ||||||
|  |     @Column(nullable = false) | ||||||
|  |     var sortOrder: Int = 0 | ||||||
|  | ) : BaseEntity() { | ||||||
|  |     @OneToMany(mappedBy = "curation", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true) | ||||||
|  |     var characterMappings: MutableList<CharacterCurationMapping> = mutableListOf() | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @Entity | ||||||
|  | class CharacterCurationMapping( | ||||||
|  |     @ManyToOne(fetch = FetchType.LAZY) | ||||||
|  |     @JoinColumn(name = "curation_id") | ||||||
|  |     var curation: CharacterCuration, | ||||||
|  |  | ||||||
|  |     @ManyToOne(fetch = FetchType.LAZY) | ||||||
|  |     @JoinColumn(name = "character_id") | ||||||
|  |     var chatCharacter: ChatCharacter, | ||||||
|  |  | ||||||
|  |     // 정렬 순서 (낮을수록 먼저) | ||||||
|  |     @Column(nullable = false) | ||||||
|  |     var sortOrder: Int = 0 | ||||||
|  | ) : BaseEntity() | ||||||
| @@ -0,0 +1,37 @@ | |||||||
|  | package kr.co.vividnext.sodalive.chat.character.curation | ||||||
|  |  | ||||||
|  | import kr.co.vividnext.sodalive.chat.character.ChatCharacter | ||||||
|  | import kr.co.vividnext.sodalive.chat.character.curation.repository.CharacterCurationMappingRepository | ||||||
|  | import kr.co.vividnext.sodalive.chat.character.curation.repository.CharacterCurationRepository | ||||||
|  | import org.springframework.stereotype.Service | ||||||
|  | import org.springframework.transaction.annotation.Transactional | ||||||
|  |  | ||||||
|  | @Service | ||||||
|  | class CharacterCurationQueryService( | ||||||
|  |     private val curationRepository: CharacterCurationRepository, | ||||||
|  |     private val mappingRepository: CharacterCurationMappingRepository | ||||||
|  | ) { | ||||||
|  |     data class CurationAgg( | ||||||
|  |         val curation: CharacterCuration, | ||||||
|  |         val characters: List<ChatCharacter> | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     @Transactional(readOnly = true) | ||||||
|  |     fun getActiveCurationsWithCharacters(): List<CurationAgg> { | ||||||
|  |         val curations = curationRepository.findByIsActiveTrueOrderBySortOrderAsc() | ||||||
|  |         if (curations.isEmpty()) return emptyList() | ||||||
|  |  | ||||||
|  |         // 매핑 + 캐릭터를 한 번에 조회(ch.isActive = true 필터 적용)하여 N+1 해소 | ||||||
|  |         val mappings = mappingRepository | ||||||
|  |             .findByCurationInWithActiveCharacterOrderByCurationIdAscAndSortOrderAsc(curations) | ||||||
|  |  | ||||||
|  |         val charactersByCurationId: Map<Long, List<ChatCharacter>> = mappings | ||||||
|  |             .groupBy { it.curation.id!! } | ||||||
|  |             .mapValues { (_, list) -> list.map { it.chatCharacter } } | ||||||
|  |  | ||||||
|  |         return curations.map { curation -> | ||||||
|  |             val characters = charactersByCurationId[curation.id!!] ?: emptyList() | ||||||
|  |             CurationAgg(curation, characters) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,33 @@ | |||||||
|  | package kr.co.vividnext.sodalive.chat.character.curation.repository | ||||||
|  |  | ||||||
|  | import kr.co.vividnext.sodalive.chat.character.curation.CharacterCuration | ||||||
|  | import kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationMapping | ||||||
|  | import org.springframework.data.jpa.repository.JpaRepository | ||||||
|  | import org.springframework.data.jpa.repository.Query | ||||||
|  | import org.springframework.data.repository.query.Param | ||||||
|  | import org.springframework.stereotype.Repository | ||||||
|  |  | ||||||
|  | @Repository | ||||||
|  | interface CharacterCurationMappingRepository : JpaRepository<CharacterCurationMapping, Long> { | ||||||
|  |     fun findByCuration(curation: CharacterCuration): List<CharacterCurationMapping> | ||||||
|  |  | ||||||
|  |     @Query( | ||||||
|  |         "select m from CharacterCurationMapping m " + | ||||||
|  |             "join fetch m.chatCharacter ch " + | ||||||
|  |             "where m.curation in :curations and ch.isActive = true " + | ||||||
|  |             "order by m.curation.id asc, m.sortOrder asc" | ||||||
|  |     ) | ||||||
|  |     fun findByCurationInWithActiveCharacterOrderByCurationIdAscAndSortOrderAsc( | ||||||
|  |         @Param("curations") curations: List<CharacterCuration> | ||||||
|  |     ): List<CharacterCurationMapping> | ||||||
|  |  | ||||||
|  |     @Query( | ||||||
|  |         "select m from CharacterCurationMapping m " + | ||||||
|  |             "join fetch m.chatCharacter ch " + | ||||||
|  |             "where m.curation = :curation " + | ||||||
|  |             "order by m.sortOrder asc" | ||||||
|  |     ) | ||||||
|  |     fun findByCurationWithCharacterOrderBySortOrderAsc( | ||||||
|  |         @Param("curation") curation: CharacterCuration | ||||||
|  |     ): List<CharacterCurationMapping> | ||||||
|  | } | ||||||
| @@ -0,0 +1,15 @@ | |||||||
|  | package kr.co.vividnext.sodalive.chat.character.curation.repository | ||||||
|  |  | ||||||
|  | import kr.co.vividnext.sodalive.chat.character.curation.CharacterCuration | ||||||
|  | import org.springframework.data.jpa.repository.JpaRepository | ||||||
|  | import org.springframework.data.jpa.repository.Query | ||||||
|  | import org.springframework.stereotype.Repository | ||||||
|  |  | ||||||
|  | @Repository | ||||||
|  | interface CharacterCurationRepository : JpaRepository<CharacterCuration, Long> { | ||||||
|  |     fun findByIsActiveTrueOrderBySortOrderAsc(): List<CharacterCuration> | ||||||
|  |     fun findAllByOrderBySortOrderAsc(): List<CharacterCuration> | ||||||
|  |  | ||||||
|  |     @Query("SELECT MAX(c.sortOrder) FROM CharacterCuration c WHERE c.isActive = true") | ||||||
|  |     fun findMaxSortOrder(): Int? | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user