캐릭터 챗봇 #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.ChatCharacterListPageResponse | ||||
| 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.common.SodaException | ||||
| import org.springframework.data.domain.Page | ||||
| import org.springframework.data.domain.PageRequest | ||||
| import org.springframework.data.domain.Pageable | ||||
| import org.springframework.data.domain.Sort | ||||
| @@ -61,4 +63,22 @@ class AdminChatCharacterService( | ||||
|  | ||||
|         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.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 | ||||
| @@ -11,4 +13,26 @@ interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> { | ||||
|     fun findByCharacterUUID(characterUUID: String): ChatCharacter? | ||||
|     fun findByName(name: String): 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