diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerController.kt new file mode 100644 index 0000000..9dd67aa --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerController.kt @@ -0,0 +1,144 @@ +package kr.co.vividnext.sodalive.admin.content.series.banner + +import com.amazonaws.services.s3.model.ObjectMetadata +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.admin.content.banner.UpdateBannerOrdersRequest +import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerListPageResponse +import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerRegisterRequest +import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerResponse +import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerUpdateRequest +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +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.data.domain.PageRequest +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.RequestParam +import org.springframework.web.bind.annotation.RequestPart +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile + +@RestController +@RequestMapping("/admin/audio-content/series/banner") +@PreAuthorize("hasRole('ADMIN')") +class AdminContentSeriesBannerController( + private val bannerService: AdminContentSeriesBannerService, + private val s3Uploader: S3Uploader, + + @Value("\${cloud.aws.s3.bucket}") + private val s3Bucket: String, + + @Value("\${cloud.aws.cloud-front.host}") + private val imageHost: String +) { + /** + * 활성화된 배너 목록 조회 API + */ + @GetMapping("/list") + fun getBannerList( + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "20") size: Int + ) = run { + val pageable = PageRequest.of(page, size) + val banners = bannerService.getActiveBanners(pageable) + val response = SeriesBannerListPageResponse( + totalCount = banners.totalElements, + content = banners.content.map { SeriesBannerResponse.from(it, imageHost) } + ) + ApiResponse.ok(response) + } + + /** + * 배너 상세 조회 API + */ + @GetMapping("/{bannerId}") + fun getBannerDetail(@PathVariable bannerId: Long) = run { + val banner = bannerService.getBannerById(bannerId) + val response = SeriesBannerResponse.from(banner, imageHost) + ApiResponse.ok(response) + } + + /** + * 배너 등록 API + */ + @PostMapping("/register") + fun registerBanner( + @RequestPart("image") image: MultipartFile, + @RequestPart("request") requestString: String + ) = run { + val objectMapper = ObjectMapper() + val request = objectMapper.readValue(requestString, SeriesBannerRegisterRequest::class.java) + + val banner = bannerService.registerBanner(seriesId = request.seriesId, imagePath = "") + val imagePath = saveImage(banner.id!!, image) + val updatedBanner = bannerService.updateBanner(banner.id!!, imagePath) + val response = SeriesBannerResponse.from(updatedBanner, imageHost) + ApiResponse.ok(response) + } + + /** + * 배너 수정 API + */ + @PutMapping("/update") + fun updateBanner( + @RequestPart("image") image: MultipartFile, + @RequestPart("request") requestString: String + ) = run { + val objectMapper = ObjectMapper() + val request = objectMapper.readValue(requestString, SeriesBannerUpdateRequest::class.java) + // 배너 존재 확인 + bannerService.getBannerById(request.bannerId) + val imagePath = saveImage(request.bannerId, image) + val updated = bannerService.updateBanner( + bannerId = request.bannerId, + imagePath = imagePath, + seriesId = request.seriesId + ) + val response = SeriesBannerResponse.from(updated, imageHost) + ApiResponse.ok(response) + } + + /** + * 배너 삭제 API (소프트 삭제) + */ + @DeleteMapping("/{bannerId}") + fun deleteBanner(@PathVariable bannerId: Long) = run { + bannerService.deleteBanner(bannerId) + ApiResponse.ok("배너가 성공적으로 삭제되었습니다.") + } + + /** + * 배너 정렬 순서 일괄 변경 API + */ + @PutMapping("/orders") + fun updateBannerOrders( + @RequestBody request: UpdateBannerOrdersRequest + ) = run { + bannerService.updateBannerOrders(request.ids) + ApiResponse.ok(null, "배너 정렬 순서가 성공적으로 변경되었습니다.") + } + + private fun saveImage(bannerId: Long, image: MultipartFile): String { + try { + val metadata = ObjectMetadata() + metadata.contentLength = image.size + val fileName = generateFileName("series-banner") + return s3Uploader.upload( + inputStream = image.inputStream, + bucket = s3Bucket, + filePath = "series_banner/$bannerId/$fileName", + metadata = metadata + ) + } catch (e: Exception) { + throw SodaException("이미지 저장에 실패했습니다: ${e.message}") + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerService.kt new file mode 100644 index 0000000..598dd95 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerService.kt @@ -0,0 +1,81 @@ +package kr.co.vividnext.sodalive.admin.content.series.banner + +import kr.co.vividnext.sodalive.admin.content.series.AdminContentSeriesRepository +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.content.series.main.banner.SeriesBanner +import kr.co.vividnext.sodalive.content.series.main.banner.SeriesBannerRepository +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Service + +@Service +class AdminContentSeriesBannerService( + private val bannerRepository: SeriesBannerRepository, + private val seriesRepository: AdminContentSeriesRepository +) { + fun getActiveBanners(pageable: Pageable): Page { + return bannerRepository.findByIsActiveTrueOrderBySortOrderAsc(pageable) + } + + fun getBannerById(bannerId: Long): SeriesBanner { + return bannerRepository.findById(bannerId) + .orElseThrow { SodaException("배너를 찾을 수 없습니다: $bannerId") } + } + + @org.springframework.transaction.annotation.Transactional + fun registerBanner(seriesId: Long, imagePath: String): SeriesBanner { + val series = seriesRepository.findByIdAndActiveTrue(seriesId) + ?: throw SodaException("시리즈를 찾을 수 없습니다: $seriesId") + + val finalSortOrder = (bannerRepository.findMaxSortOrder() ?: 0) + 1 + + val banner = SeriesBanner( + imagePath = imagePath, + series = series, + sortOrder = finalSortOrder + ) + return bannerRepository.save(banner) + } + + @org.springframework.transaction.annotation.Transactional + fun updateBanner( + bannerId: Long, + imagePath: String? = null, + seriesId: Long? = null + ): SeriesBanner { + val banner = bannerRepository.findById(bannerId) + .orElseThrow { SodaException("배너를 찾을 수 없습니다: $bannerId") } + if (!banner.isActive) throw SodaException("비활성화된 배너는 수정할 수 없습니다: $bannerId") + + if (imagePath != null) banner.imagePath = imagePath + + if (seriesId != null) { + val series = seriesRepository.findByIdAndActiveTrue(seriesId) + ?: throw SodaException("시리즈를 찾을 수 없습니다: $seriesId") + banner.series = series + } + + return bannerRepository.save(banner) + } + + @org.springframework.transaction.annotation.Transactional + fun deleteBanner(bannerId: Long) { + val banner = bannerRepository.findById(bannerId) + .orElseThrow { SodaException("배너를 찾을 수 없습니다: $bannerId") } + banner.isActive = false + bannerRepository.save(banner) + } + + @org.springframework.transaction.annotation.Transactional + fun updateBannerOrders(ids: List): List { + val updated = mutableListOf() + for (index in ids.indices) { + val banner = bannerRepository.findById(ids[index]) + .orElseThrow { SodaException("배너를 찾을 수 없습니다: ${ids[index]}") } + if (!banner.isActive) throw SodaException("비활성화된 배너는 수정할 수 없습니다: ${ids[index]}") + banner.sortOrder = index + 1 + updated.add(bannerRepository.save(banner)) + } + return updated + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/dto/SeriesBannerDtos.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/dto/SeriesBannerDtos.kt new file mode 100644 index 0000000..d054ca9 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/dto/SeriesBannerDtos.kt @@ -0,0 +1,40 @@ +package kr.co.vividnext.sodalive.admin.content.series.banner.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import kr.co.vividnext.sodalive.content.series.main.banner.SeriesBanner + +// 시리즈 배너 등록 요청 DTO +data class SeriesBannerRegisterRequest( + @JsonProperty("seriesId") val seriesId: Long +) + +// 시리즈 배너 수정 요청 DTO +data class SeriesBannerUpdateRequest( + @JsonProperty("bannerId") val bannerId: Long, + @JsonProperty("seriesId") val seriesId: Long? = null +) + +// 시리즈 배너 응답 DTO +data class SeriesBannerResponse( + val id: Long, + val imagePath: String, + val seriesId: Long, + val seriesTitle: String +) { + companion object { + fun from(banner: SeriesBanner, imageHost: String): SeriesBannerResponse { + return SeriesBannerResponse( + id = banner.id!!, + imagePath = "$imageHost/${banner.imagePath}", + seriesId = banner.series.id!!, + seriesTitle = banner.series.title + ) + } + } +} + +// 시리즈 배너 목록 페이지 응답 DTO +data class SeriesBannerListPageResponse( + val totalCount: Long, + val content: List +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/SeriesBanner.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/SeriesBanner.kt new file mode 100644 index 0000000..2b4cc81 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/SeriesBanner.kt @@ -0,0 +1,30 @@ +package kr.co.vividnext.sodalive.content.series.main.banner + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.creator.admin.content.series.Series +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne + +/** + * 시리즈 배너 엔티티 + * 이미지와 시리즈 ID를 가지며, 소프트 삭제(isActive = false)를 지원합니다. + * 정렬 순서(sortOrder)를 통해 배너의 표시 순서를 결정합니다. + */ +@Entity +class SeriesBanner( + // 배너 이미지 경로 + var imagePath: String? = null, + + // 연관된 캐릭터 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "series_id") + var series: Series, + + // 정렬 순서 (낮을수록 먼저 표시) + var sortOrder: Int = 0, + + // 활성화 여부 (소프트 삭제용) + var isActive: Boolean = true +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/SeriesBannerRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/SeriesBannerRepository.kt new file mode 100644 index 0000000..c2c2052 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/SeriesBannerRepository.kt @@ -0,0 +1,15 @@ +package kr.co.vividnext.sodalive.content.series.main.banner + +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.stereotype.Repository + +@Repository +interface SeriesBannerRepository : JpaRepository { + fun findByIsActiveTrueOrderBySortOrderAsc(pageable: Pageable): Page + + @Query("SELECT MAX(b.sortOrder) FROM SeriesBanner b WHERE b.isActive = true") + fun findMaxSortOrder(): Int? +}