캐릭터 챗봇 #338
| @@ -0,0 +1,191 @@ | |||||||
|  | package kr.co.vividnext.sodalive.admin.chat | ||||||
|  |  | ||||||
|  | import com.amazonaws.services.s3.model.ObjectMetadata | ||||||
|  | import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService | ||||||
|  | import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerListPageResponse | ||||||
|  | import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerRegisterRequest | ||||||
|  | import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerResponse | ||||||
|  | import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerUpdateRequest | ||||||
|  | import kr.co.vividnext.sodalive.aws.s3.S3Uploader | ||||||
|  | import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService | ||||||
|  | import kr.co.vividnext.sodalive.common.ApiResponse | ||||||
|  | import kr.co.vividnext.sodalive.common.SodaException | ||||||
|  | import kr.co.vividnext.sodalive.utils.generateFileName | ||||||
|  | import org.springframework.beans.factory.annotation.Value | ||||||
|  | import org.springframework.security.access.prepost.PreAuthorize | ||||||
|  | import org.springframework.web.bind.annotation.DeleteMapping | ||||||
|  | import org.springframework.web.bind.annotation.GetMapping | ||||||
|  | import org.springframework.web.bind.annotation.PathVariable | ||||||
|  | import org.springframework.web.bind.annotation.PostMapping | ||||||
|  | import org.springframework.web.bind.annotation.PutMapping | ||||||
|  | import org.springframework.web.bind.annotation.RequestMapping | ||||||
|  | import org.springframework.web.bind.annotation.RequestParam | ||||||
|  | import org.springframework.web.bind.annotation.RequestPart | ||||||
|  | import org.springframework.web.bind.annotation.RestController | ||||||
|  | import org.springframework.web.multipart.MultipartFile | ||||||
|  |  | ||||||
|  | @RestController | ||||||
|  | @RequestMapping("/api/admin/chat/banner") | ||||||
|  | @PreAuthorize("hasRole('ADMIN')") | ||||||
|  | class AdminChatBannerController( | ||||||
|  |     private val bannerService: ChatCharacterBannerService, | ||||||
|  |     private val adminCharacterService: AdminChatCharacterService, | ||||||
|  |     private val s3Uploader: S3Uploader, | ||||||
|  |  | ||||||
|  |     @Value("\${cloud.aws.s3.bucket}") | ||||||
|  |     private val s3Bucket: String, | ||||||
|  |  | ||||||
|  |     @Value("\${cloud.aws.cloud-front.host}") | ||||||
|  |     private val imageHost: String | ||||||
|  | ) { | ||||||
|  |     /** | ||||||
|  |      * 활성화된 배너 목록 조회 API | ||||||
|  |      * | ||||||
|  |      * @param page 페이지 번호 (0부터 시작, 기본값 0) | ||||||
|  |      * @param size 페이지 크기 (기본값 20) | ||||||
|  |      * @return 페이징된 배너 목록 | ||||||
|  |      */ | ||||||
|  |     @GetMapping("/list") | ||||||
|  |     fun getBannerList( | ||||||
|  |         @RequestParam(defaultValue = "0") page: Int, | ||||||
|  |         @RequestParam(defaultValue = "20") size: Int | ||||||
|  |     ) = run { | ||||||
|  |         val pageable = adminCharacterService.createDefaultPageRequest(page, size) | ||||||
|  |         val banners = bannerService.getActiveBanners(pageable) | ||||||
|  |         val response = ChatCharacterBannerListPageResponse( | ||||||
|  |             totalCount = banners.totalElements, | ||||||
|  |             content = banners.content.map { ChatCharacterBannerResponse.from(it, imageHost) } | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         ApiResponse.ok(response) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * 배너 상세 조회 API | ||||||
|  |      * | ||||||
|  |      * @param bannerId 배너 ID | ||||||
|  |      * @return 배너 상세 정보 | ||||||
|  |      */ | ||||||
|  |     @GetMapping("/{bannerId}") | ||||||
|  |     fun getBannerDetail(@PathVariable bannerId: Long) = run { | ||||||
|  |         val banner = bannerService.getBannerById(bannerId) | ||||||
|  |         val response = ChatCharacterBannerResponse.from(banner, imageHost) | ||||||
|  |  | ||||||
|  |         ApiResponse.ok(response) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * 캐릭터 검색 API (배너 등록을 위한) | ||||||
|  |      * | ||||||
|  |      * @param searchTerm 검색어 (이름, 설명, MBTI, 태그) | ||||||
|  |      * @param page 페이지 번호 (0부터 시작, 기본값 0) | ||||||
|  |      * @param size 페이지 크기 (기본값 20) | ||||||
|  |      * @return 검색된 캐릭터 목록 | ||||||
|  |      */ | ||||||
|  |     @GetMapping("/search-character") | ||||||
|  |     fun searchCharacters( | ||||||
|  |         @RequestParam searchTerm: String, | ||||||
|  |         @RequestParam(defaultValue = "0") page: Int, | ||||||
|  |         @RequestParam(defaultValue = "20") size: Int | ||||||
|  |     ) = run { | ||||||
|  |         val pageable = adminCharacterService.createDefaultPageRequest(page, size) | ||||||
|  |         val response = adminCharacterService.searchCharacters(searchTerm, pageable, imageHost) | ||||||
|  |  | ||||||
|  |         ApiResponse.ok(response) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * 배너 등록 API | ||||||
|  |      * | ||||||
|  |      * @param image 배너 이미지 | ||||||
|  |      * @param request 배너 등록 요청 정보 | ||||||
|  |      * @return 등록된 배너 정보 | ||||||
|  |      */ | ||||||
|  |     @PostMapping("/register") | ||||||
|  |     fun registerBanner( | ||||||
|  |         @RequestPart("image") image: MultipartFile, | ||||||
|  |         @RequestPart("request") request: ChatCharacterBannerRegisterRequest | ||||||
|  |     ) = run { | ||||||
|  |         // 1. 먼저 빈 이미지 경로로 배너 등록 | ||||||
|  |         val banner = bannerService.registerBanner(request.characterId, "") | ||||||
|  |  | ||||||
|  |         // 2. 배너 ID를 사용하여 이미지 업로드 | ||||||
|  |         val imagePath = saveImage(banner.id!!, image) | ||||||
|  |  | ||||||
|  |         // 3. 이미지 경로로 배너 업데이트 | ||||||
|  |         val updatedBanner = bannerService.updateBanner(banner.id!!, imagePath) | ||||||
|  |  | ||||||
|  |         val response = ChatCharacterBannerResponse.from(updatedBanner, imageHost) | ||||||
|  |  | ||||||
|  |         ApiResponse.ok(response) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * 이미지를 S3에 업로드하고 경로를 반환 | ||||||
|  |      * | ||||||
|  |      * @param bannerId 배너 ID (이미지 경로에 사용) | ||||||
|  |      * @param image 업로드할 이미지 파일 | ||||||
|  |      * @return 업로드된 이미지 경로 | ||||||
|  |      */ | ||||||
|  |     private fun saveImage(bannerId: Long, image: MultipartFile): String { | ||||||
|  |         try { | ||||||
|  |             val metadata = ObjectMetadata() | ||||||
|  |             metadata.contentLength = image.size | ||||||
|  |  | ||||||
|  |             val fileName = generateFileName("character-banner") | ||||||
|  |  | ||||||
|  |             // S3에 이미지 업로드 (배너 ID를 경로에 사용) | ||||||
|  |             return s3Uploader.upload( | ||||||
|  |                 inputStream = image.inputStream, | ||||||
|  |                 bucket = s3Bucket, | ||||||
|  |                 filePath = "/characters/banners/$bannerId/$fileName", | ||||||
|  |                 metadata = metadata | ||||||
|  |             ) | ||||||
|  |         } catch (e: Exception) { | ||||||
|  |             throw SodaException("이미지 저장에 실패했습니다: ${e.message}") | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * 배너 수정 API | ||||||
|  |      * | ||||||
|  |      * @param image 배너 이미지 | ||||||
|  |      * @param request 배너 수정 요청 정보 (배너 ID와 선택적으로 캐릭터 ID 포함) | ||||||
|  |      * @return 수정된 배너 정보 | ||||||
|  |      */ | ||||||
|  |     @PutMapping("/update") | ||||||
|  |     fun updateBanner( | ||||||
|  |         @RequestPart("image") image: MultipartFile, | ||||||
|  |         @RequestPart("request") request: ChatCharacterBannerUpdateRequest | ||||||
|  |     ) = run { | ||||||
|  |         // 배너 정보 조회 | ||||||
|  |         bannerService.getBannerById(request.bannerId) | ||||||
|  |  | ||||||
|  |         // 배너 ID를 사용하여 이미지 업로드 | ||||||
|  |         val imagePath = saveImage(request.bannerId, image) | ||||||
|  |  | ||||||
|  |         // 배너 수정 (이미지와 캐릭터 모두 수정 가능) | ||||||
|  |         val updatedBanner = bannerService.updateBanner( | ||||||
|  |             bannerId = request.bannerId, | ||||||
|  |             imagePath = imagePath, | ||||||
|  |             characterId = request.characterId | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         val response = ChatCharacterBannerResponse.from(updatedBanner, imageHost) | ||||||
|  |  | ||||||
|  |         ApiResponse.ok(response) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * 배너 삭제 API (소프트 삭제) | ||||||
|  |      * | ||||||
|  |      * @param bannerId 배너 ID | ||||||
|  |      * @return 성공 여부 | ||||||
|  |      */ | ||||||
|  |     @DeleteMapping("/{bannerId}") | ||||||
|  |     fun deleteBanner(@PathVariable bannerId: Long) = run { | ||||||
|  |         bannerService.deleteBanner(bannerId) | ||||||
|  |  | ||||||
|  |         ApiResponse.ok("배너가 성공적으로 삭제되었습니다.") | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,35 @@ | |||||||
|  | package kr.co.vividnext.sodalive.admin.chat.character.dto | ||||||
|  |  | ||||||
|  | import kr.co.vividnext.sodalive.chat.character.ChatCharacter | ||||||
|  | import org.springframework.data.domain.Page | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * 캐릭터 검색 결과 응답 DTO | ||||||
|  |  */ | ||||||
|  | data class ChatCharacterSearchResponse( | ||||||
|  |     val id: Long, | ||||||
|  |     val name: String, | ||||||
|  |     val description: String, | ||||||
|  |     val mbti: String?, | ||||||
|  |     val imagePath: String?, | ||||||
|  |     val tags: List<String> | ||||||
|  | ) { | ||||||
|  |     companion object { | ||||||
|  |         fun from(character: ChatCharacter, imageHost: String): ChatCharacterSearchResponse { | ||||||
|  |             val tags = character.tagMappings.map { it.tag.tag } | ||||||
|  |  | ||||||
|  |             return ChatCharacterSearchResponse( | ||||||
|  |                 id = character.id!!, | ||||||
|  |                 name = character.name, | ||||||
|  |                 description = character.description, | ||||||
|  |                 mbti = character.mbti, | ||||||
|  |                 imagePath = character.imagePath?.let { "$imageHost$it" }, | ||||||
|  |                 tags = tags | ||||||
|  |             ) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         fun fromPage(characters: Page<ChatCharacter>, imageHost: String): Page<ChatCharacterSearchResponse> { | ||||||
|  |             return characters.map { from(it, imageHost) } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -3,8 +3,10 @@ package kr.co.vividnext.sodalive.admin.chat.character.service | |||||||
| import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterDetailResponse | import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterDetailResponse | ||||||
| import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListPageResponse | import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListPageResponse | ||||||
| import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListResponse | import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListResponse | ||||||
|  | import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterSearchResponse | ||||||
| import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository | import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository | ||||||
| import kr.co.vividnext.sodalive.common.SodaException | import kr.co.vividnext.sodalive.common.SodaException | ||||||
|  | import org.springframework.data.domain.Page | ||||||
| import org.springframework.data.domain.PageRequest | import org.springframework.data.domain.PageRequest | ||||||
| import org.springframework.data.domain.Pageable | import org.springframework.data.domain.Pageable | ||||||
| import org.springframework.data.domain.Sort | import org.springframework.data.domain.Sort | ||||||
| @@ -61,4 +63,22 @@ class AdminChatCharacterService( | |||||||
|  |  | ||||||
|         return ChatCharacterDetailResponse.from(chatCharacter, imageHost) |         return ChatCharacterDetailResponse.from(chatCharacter, imageHost) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * 캐릭터 검색 (이름, 설명, MBTI, 태그 기반) | ||||||
|  |      * | ||||||
|  |      * @param searchTerm 검색어 | ||||||
|  |      * @param pageable 페이징 정보 | ||||||
|  |      * @param imageHost 이미지 호스트 URL | ||||||
|  |      * @return 검색된 캐릭터 목록 (페이징) | ||||||
|  |      */ | ||||||
|  |     @Transactional(readOnly = true) | ||||||
|  |     fun searchCharacters( | ||||||
|  |         searchTerm: String, | ||||||
|  |         pageable: Pageable, | ||||||
|  |         imageHost: String = "" | ||||||
|  |     ): Page<ChatCharacterSearchResponse> { | ||||||
|  |         val characters = chatCharacterRepository.searchCharacters(searchTerm, pageable) | ||||||
|  |         return characters.map { ChatCharacterSearchResponse.from(it, imageHost) } | ||||||
|  |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -0,0 +1,20 @@ | |||||||
|  | package kr.co.vividnext.sodalive.admin.chat.dto | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * 캐릭터 배너 등록 요청 DTO | ||||||
|  |  */ | ||||||
|  | data class ChatCharacterBannerRegisterRequest( | ||||||
|  |     // 캐릭터 ID | ||||||
|  |     val characterId: Long | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * 캐릭터 배너 수정 요청 DTO | ||||||
|  |  */ | ||||||
|  | data class ChatCharacterBannerUpdateRequest( | ||||||
|  |     // 배너 ID | ||||||
|  |     val bannerId: Long, | ||||||
|  |  | ||||||
|  |     // 캐릭터 ID (변경할 캐릭터) | ||||||
|  |     val characterId: Long? = null | ||||||
|  | ) | ||||||
| @@ -0,0 +1,37 @@ | |||||||
|  | package kr.co.vividnext.sodalive.admin.chat.dto | ||||||
|  |  | ||||||
|  | import kr.co.vividnext.sodalive.chat.character.ChatCharacterBanner | ||||||
|  | import org.springframework.data.domain.Page | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * 캐릭터 배너 응답 DTO | ||||||
|  |  */ | ||||||
|  | data class ChatCharacterBannerResponse( | ||||||
|  |     val id: Long, | ||||||
|  |     val imagePath: String, | ||||||
|  |     val characterId: Long, | ||||||
|  |     val characterName: String | ||||||
|  | ) { | ||||||
|  |     companion object { | ||||||
|  |         fun from(banner: ChatCharacterBanner, imageHost: String): ChatCharacterBannerResponse { | ||||||
|  |             return ChatCharacterBannerResponse( | ||||||
|  |                 id = banner.id!!, | ||||||
|  |                 imagePath = "$imageHost${banner.imagePath}", | ||||||
|  |                 characterId = banner.chatCharacter.id!!, | ||||||
|  |                 characterName = banner.chatCharacter.name | ||||||
|  |             ) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         fun fromPage(banners: Page<ChatCharacterBanner>, imageHost: String): Page<ChatCharacterBannerResponse> { | ||||||
|  |             return banners.map { from(it, imageHost) } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * 캐릭터 배너 목록 페이지 응답 DTO | ||||||
|  |  */ | ||||||
|  | data class ChatCharacterBannerListPageResponse( | ||||||
|  |     val totalCount: Long, | ||||||
|  |     val content: List<ChatCharacterBannerResponse> | ||||||
|  | ) | ||||||
| @@ -0,0 +1,25 @@ | |||||||
|  | package kr.co.vividnext.sodalive.chat.character | ||||||
|  |  | ||||||
|  | import kr.co.vividnext.sodalive.common.BaseEntity | ||||||
|  | import javax.persistence.Entity | ||||||
|  | import javax.persistence.FetchType | ||||||
|  | import javax.persistence.JoinColumn | ||||||
|  | import javax.persistence.ManyToOne | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * 캐릭터 배너 엔티티 | ||||||
|  |  * 이미지와 캐릭터 ID를 가지며, 소프트 삭제(isActive = false)를 지원합니다. | ||||||
|  |  */ | ||||||
|  | @Entity | ||||||
|  | class ChatCharacterBanner( | ||||||
|  |     // 배너 이미지 경로 | ||||||
|  |     var imagePath: String? = null, | ||||||
|  |  | ||||||
|  |     // 연관된 캐릭터 | ||||||
|  |     @ManyToOne(fetch = FetchType.LAZY) | ||||||
|  |     @JoinColumn(name = "character_id") | ||||||
|  |     var chatCharacter: ChatCharacter, | ||||||
|  |  | ||||||
|  |     // 활성화 여부 (소프트 삭제용) | ||||||
|  |     var isActive: Boolean = true | ||||||
|  | ) : BaseEntity() | ||||||
| @@ -0,0 +1,43 @@ | |||||||
|  | package kr.co.vividnext.sodalive.chat.character.repository | ||||||
|  |  | ||||||
|  | import kr.co.vividnext.sodalive.chat.character.ChatCharacter | ||||||
|  | import kr.co.vividnext.sodalive.chat.character.ChatCharacterBanner | ||||||
|  | import org.springframework.data.domain.Page | ||||||
|  | import org.springframework.data.domain.Pageable | ||||||
|  | import org.springframework.data.jpa.repository.JpaRepository | ||||||
|  | import org.springframework.data.jpa.repository.Query | ||||||
|  | import org.springframework.data.repository.query.Param | ||||||
|  | import org.springframework.stereotype.Repository | ||||||
|  |  | ||||||
|  | @Repository | ||||||
|  | interface ChatCharacterBannerRepository : JpaRepository<ChatCharacterBanner, Long> { | ||||||
|  |     // 활성화된 배너 목록 조회 | ||||||
|  |     fun findByIsActiveTrue(pageable: Pageable): Page<ChatCharacterBanner> | ||||||
|  |  | ||||||
|  |     // 특정 캐릭터의 활성화된 배너 목록 조회 | ||||||
|  |     fun findByChatCharacterAndIsActiveTrue(chatCharacter: ChatCharacter): List<ChatCharacterBanner> | ||||||
|  |  | ||||||
|  |     // 특정 캐릭터 ID의 활성화된 배너 목록 조회 | ||||||
|  |     fun findByChatCharacter_IdAndIsActiveTrue(characterId: Long): List<ChatCharacterBanner> | ||||||
|  |  | ||||||
|  |     // 이름, 설명, MBTI, 태그로 캐릭터 검색 (배너 포함) | ||||||
|  |     @Query( | ||||||
|  |         """ | ||||||
|  |         SELECT DISTINCT b FROM ChatCharacterBanner b | ||||||
|  |         JOIN FETCH b.chatCharacter c | ||||||
|  |         LEFT JOIN c.tagMappings tm | ||||||
|  |         LEFT JOIN tm.tag t | ||||||
|  |         WHERE b.isActive = true AND c.isActive = true AND | ||||||
|  |         ( | ||||||
|  |             LOWER(c.name) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR | ||||||
|  |             LOWER(c.description) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR | ||||||
|  |             (c.mbti IS NOT NULL AND LOWER(c.mbti) LIKE LOWER(CONCAT('%', :searchTerm, '%'))) OR | ||||||
|  |             (t.tag IS NOT NULL AND LOWER(t.tag) LIKE LOWER(CONCAT('%', :searchTerm, '%'))) | ||||||
|  |         ) | ||||||
|  |         """ | ||||||
|  |     ) | ||||||
|  |     fun searchBannersByCharacterAttributes( | ||||||
|  |         @Param("searchTerm") searchTerm: String, | ||||||
|  |         pageable: Pageable | ||||||
|  |     ): Page<ChatCharacterBanner> | ||||||
|  | } | ||||||
| @@ -4,6 +4,8 @@ import kr.co.vividnext.sodalive.chat.character.ChatCharacter | |||||||
| import org.springframework.data.domain.Page | import org.springframework.data.domain.Page | ||||||
| import org.springframework.data.domain.Pageable | import org.springframework.data.domain.Pageable | ||||||
| import org.springframework.data.jpa.repository.JpaRepository | 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 | import org.springframework.stereotype.Repository | ||||||
|  |  | ||||||
| @Repository | @Repository | ||||||
| @@ -11,4 +13,26 @@ interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> { | |||||||
|     fun findByCharacterUUID(characterUUID: String): ChatCharacter? |     fun findByCharacterUUID(characterUUID: String): ChatCharacter? | ||||||
|     fun findByName(name: String): ChatCharacter? |     fun findByName(name: String): ChatCharacter? | ||||||
|     fun findByActiveTrue(pageable: Pageable): Page<ChatCharacter> |     fun findByActiveTrue(pageable: Pageable): Page<ChatCharacter> | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * 이름, 설명, MBTI, 태그로 캐릭터 검색 | ||||||
|  |      */ | ||||||
|  |     @Query( | ||||||
|  |         """ | ||||||
|  |         SELECT DISTINCT c FROM ChatCharacter c | ||||||
|  |         LEFT JOIN c.tagMappings tm | ||||||
|  |         LEFT JOIN tm.tag t | ||||||
|  |         WHERE c.isActive = true AND | ||||||
|  |         ( | ||||||
|  |             LOWER(c.name) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR | ||||||
|  |             LOWER(c.description) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR | ||||||
|  |             (c.mbti IS NOT NULL AND LOWER(c.mbti) LIKE LOWER(CONCAT('%', :searchTerm, '%'))) OR | ||||||
|  |             (t.tag IS NOT NULL AND LOWER(t.tag) LIKE LOWER(CONCAT('%', :searchTerm, '%'))) | ||||||
|  |         ) | ||||||
|  |         """ | ||||||
|  |     ) | ||||||
|  |     fun searchCharacters( | ||||||
|  |         @Param("searchTerm") searchTerm: String, | ||||||
|  |         pageable: Pageable | ||||||
|  |     ): Page<ChatCharacter> | ||||||
| } | } | ||||||
|   | |||||||
| @@ -0,0 +1,114 @@ | |||||||
|  | package kr.co.vividnext.sodalive.chat.character.service | ||||||
|  |  | ||||||
|  | import kr.co.vividnext.sodalive.chat.character.ChatCharacterBanner | ||||||
|  | import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterBannerRepository | ||||||
|  | import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository | ||||||
|  | import kr.co.vividnext.sodalive.common.SodaException | ||||||
|  | import org.springframework.data.domain.Page | ||||||
|  | import org.springframework.data.domain.Pageable | ||||||
|  | import org.springframework.stereotype.Service | ||||||
|  | import org.springframework.transaction.annotation.Transactional | ||||||
|  |  | ||||||
|  | @Service | ||||||
|  | class ChatCharacterBannerService( | ||||||
|  |     private val bannerRepository: ChatCharacterBannerRepository, | ||||||
|  |     private val characterRepository: ChatCharacterRepository | ||||||
|  | ) { | ||||||
|  |     /** | ||||||
|  |      * 활성화된 모든 배너 조회 | ||||||
|  |      */ | ||||||
|  |     fun getActiveBanners(pageable: Pageable): Page<ChatCharacterBanner> { | ||||||
|  |         return bannerRepository.findByIsActiveTrue(pageable) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * 배너 상세 조회 | ||||||
|  |      */ | ||||||
|  |     fun getBannerById(bannerId: Long): ChatCharacterBanner { | ||||||
|  |         return bannerRepository.findById(bannerId) | ||||||
|  |             .orElseThrow { SodaException("배너를 찾을 수 없습니다: $bannerId") } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * 특정 캐릭터의 활성화된 배너 목록 조회 | ||||||
|  |      */ | ||||||
|  |     fun getActiveBannersByCharacterId(characterId: Long): List<ChatCharacterBanner> { | ||||||
|  |         return bannerRepository.findByChatCharacter_IdAndIsActiveTrue(characterId) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * 이름, 설명, MBTI, 태그로 캐릭터 검색 (배너 포함) | ||||||
|  |      */ | ||||||
|  |     fun searchBannersByCharacterAttributes(searchTerm: String, pageable: Pageable): Page<ChatCharacterBanner> { | ||||||
|  |         return bannerRepository.searchBannersByCharacterAttributes(searchTerm, pageable) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * 배너 등록 | ||||||
|  |      */ | ||||||
|  |     @Transactional | ||||||
|  |     fun registerBanner(characterId: Long, imagePath: String): ChatCharacterBanner { | ||||||
|  |         val character = characterRepository.findById(characterId) | ||||||
|  |             .orElseThrow { SodaException("캐릭터를 찾을 수 없습니다: $characterId") } | ||||||
|  |  | ||||||
|  |         if (!character.isActive) { | ||||||
|  |             throw SodaException("비활성화된 캐릭터에는 배너를 등록할 수 없습니다: $characterId") | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         val banner = ChatCharacterBanner( | ||||||
|  |             imagePath = imagePath, | ||||||
|  |             chatCharacter = character | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         return bannerRepository.save(banner) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * 배너 수정 | ||||||
|  |      * | ||||||
|  |      * @param bannerId 배너 ID | ||||||
|  |      * @param imagePath 이미지 경로 (변경할 경우) | ||||||
|  |      * @param characterId 캐릭터 ID (변경할 경우) | ||||||
|  |      * @return 수정된 배너 | ||||||
|  |      */ | ||||||
|  |     @Transactional | ||||||
|  |     fun updateBanner(bannerId: Long, imagePath: String? = null, characterId: Long? = null): ChatCharacterBanner { | ||||||
|  |         val banner = bannerRepository.findById(bannerId) | ||||||
|  |             .orElseThrow { SodaException("배너를 찾을 수 없습니다: $bannerId") } | ||||||
|  |  | ||||||
|  |         if (!banner.isActive) { | ||||||
|  |             throw SodaException("비활성화된 배너는 수정할 수 없습니다: $bannerId") | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // 이미지 경로 변경 | ||||||
|  |         if (imagePath != null) { | ||||||
|  |             banner.imagePath = imagePath | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // 캐릭터 변경 | ||||||
|  |         if (characterId != null) { | ||||||
|  |             val character = characterRepository.findById(characterId) | ||||||
|  |                 .orElseThrow { SodaException("캐릭터를 찾을 수 없습니다: $characterId") } | ||||||
|  |  | ||||||
|  |             if (!character.isActive) { | ||||||
|  |                 throw SodaException("비활성화된 캐릭터로는 변경할 수 없습니다: $characterId") | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             banner.chatCharacter = character | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return bannerRepository.save(banner) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * 배너 삭제 (소프트 삭제) | ||||||
|  |      */ | ||||||
|  |     @Transactional | ||||||
|  |     fun deleteBanner(bannerId: Long) { | ||||||
|  |         val banner = bannerRepository.findById(bannerId) | ||||||
|  |             .orElseThrow { SodaException("배너를 찾을 수 없습니다: $bannerId") } | ||||||
|  |  | ||||||
|  |         banner.isActive = false | ||||||
|  |         bannerRepository.save(banner) | ||||||
|  |     } | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user