feat(series-banner): 시리즈 배너의 등록, 수정, 삭제, 조회 및 정렬 순서 일괄 변경 기능이 추가
This commit is contained in:
@@ -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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SeriesBanner> {
|
||||||
|
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<Long>): List<SeriesBanner> {
|
||||||
|
val updated = mutableListOf<SeriesBanner>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SeriesBannerResponse>
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
@@ -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<SeriesBanner, Long> {
|
||||||
|
fun findByIsActiveTrueOrderBySortOrderAsc(pageable: Pageable): Page<SeriesBanner>
|
||||||
|
|
||||||
|
@Query("SELECT MAX(b.sortOrder) FROM SeriesBanner b WHERE b.isActive = true")
|
||||||
|
fun findMaxSortOrder(): Int?
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user