diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt new file mode 100644 index 0000000..1a8f3ea --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt @@ -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("배너가 성공적으로 삭제되었습니다.") + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterSearchResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterSearchResponse.kt new file mode 100644 index 0000000..e4e06ad --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterSearchResponse.kt @@ -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 +) { + 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, imageHost: String): Page { + return characters.map { from(it, imageHost) } + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/service/AdminChatCharacterService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/service/AdminChatCharacterService.kt index b48c66e..faa96e3 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/service/AdminChatCharacterService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/service/AdminChatCharacterService.kt @@ -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 { + val characters = chatCharacterRepository.searchCharacters(searchTerm, pageable) + return characters.map { ChatCharacterSearchResponse.from(it, imageHost) } + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerRequest.kt new file mode 100644 index 0000000..551150c --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerRequest.kt @@ -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 +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerResponse.kt new file mode 100644 index 0000000..05012b7 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerResponse.kt @@ -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, imageHost: String): Page { + return banners.map { from(it, imageHost) } + } + } +} + +/** + * 캐릭터 배너 목록 페이지 응답 DTO + */ +data class ChatCharacterBannerListPageResponse( + val totalCount: Long, + val content: List +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterBanner.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterBanner.kt new file mode 100644 index 0000000..643597b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterBanner.kt @@ -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() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterBannerRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterBannerRepository.kt new file mode 100644 index 0000000..b849c7e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterBannerRepository.kt @@ -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 { + // 활성화된 배너 목록 조회 + fun findByIsActiveTrue(pageable: Pageable): Page + + // 특정 캐릭터의 활성화된 배너 목록 조회 + fun findByChatCharacterAndIsActiveTrue(chatCharacter: ChatCharacter): List + + // 특정 캐릭터 ID의 활성화된 배너 목록 조회 + fun findByChatCharacter_IdAndIsActiveTrue(characterId: Long): List + + // 이름, 설명, 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 +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt index f5ab3dc..30ceb7c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt @@ -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 { fun findByCharacterUUID(characterUUID: String): ChatCharacter? fun findByName(name: String): ChatCharacter? fun findByActiveTrue(pageable: Pageable): Page + + /** + * 이름, 설명, 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 } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerService.kt new file mode 100644 index 0000000..b9175bc --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerService.kt @@ -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 { + return bannerRepository.findByIsActiveTrue(pageable) + } + + /** + * 배너 상세 조회 + */ + fun getBannerById(bannerId: Long): ChatCharacterBanner { + return bannerRepository.findById(bannerId) + .orElseThrow { SodaException("배너를 찾을 수 없습니다: $bannerId") } + } + + /** + * 특정 캐릭터의 활성화된 배너 목록 조회 + */ + fun getActiveBannersByCharacterId(characterId: Long): List { + return bannerRepository.findByChatCharacter_IdAndIsActiveTrue(characterId) + } + + /** + * 이름, 설명, MBTI, 태그로 캐릭터 검색 (배너 포함) + */ + fun searchBannersByCharacterAttributes(searchTerm: String, pageable: Pageable): Page { + 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) + } +}