Compare commits

..

2 Commits

8 changed files with 343 additions and 4 deletions

View File

@@ -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}")
}
}
}

View File

@@ -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
}
}

View File

@@ -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>
)

View File

@@ -20,6 +20,7 @@ class ContentSeriesController(private val service: ContentSeriesService) {
fun getSeriesList( fun getSeriesList(
@RequestParam(required = false) creatorId: Long?, @RequestParam(required = false) creatorId: Long?,
@RequestParam(name = "isOriginal", required = false) isOriginal: Boolean? = null, @RequestParam(name = "isOriginal", required = false) isOriginal: Boolean? = null,
@RequestParam(name = "isCompleted", required = false) isCompleted: Boolean? = null,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null, @RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@@ -31,6 +32,7 @@ class ContentSeriesController(private val service: ContentSeriesService) {
service.getSeriesList( service.getSeriesList(
creatorId = creatorId, creatorId = creatorId,
isOriginal = isOriginal ?: false, isOriginal = isOriginal ?: false,
isCompleted = isCompleted ?: false,
isAdultContentVisible = isAdultContentVisible ?: true, isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL, contentType = contentType ?: ContentType.ALL,
member = member, member = member,

View File

@@ -14,6 +14,7 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
import kr.co.vividnext.sodalive.creator.admin.content.series.Series import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState
import kr.co.vividnext.sodalive.creator.admin.content.series.keyword.QSeriesKeyword.seriesKeyword import kr.co.vividnext.sodalive.creator.admin.content.series.keyword.QSeriesKeyword.seriesKeyword
import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.QMember.member
@@ -23,13 +24,21 @@ import org.springframework.data.jpa.repository.JpaRepository
interface ContentSeriesRepository : JpaRepository<Series, Long>, ContentSeriesQueryRepository interface ContentSeriesRepository : JpaRepository<Series, Long>, ContentSeriesQueryRepository
interface ContentSeriesQueryRepository { interface ContentSeriesQueryRepository {
fun getSeriesTotalCount(creatorId: Long?, isAuth: Boolean, contentType: ContentType, isOriginal: Boolean): Int fun getSeriesTotalCount(
creatorId: Long?,
isAuth: Boolean,
contentType: ContentType,
isOriginal: Boolean,
isCompleted: Boolean
): Int
fun getSeriesList( fun getSeriesList(
imageHost: String, imageHost: String,
creatorId: Long?, creatorId: Long?,
isAuth: Boolean, isAuth: Boolean,
contentType: ContentType, contentType: ContentType,
isOriginal: Boolean, isOriginal: Boolean,
isCompleted: Boolean,
offset: Long, offset: Long,
limit: Long limit: Long
): List<Series> ): List<Series>
@@ -60,7 +69,13 @@ interface ContentSeriesQueryRepository {
class ContentSeriesQueryRepositoryImpl( class ContentSeriesQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory private val queryFactory: JPAQueryFactory
) : ContentSeriesQueryRepository { ) : ContentSeriesQueryRepository {
override fun getSeriesTotalCount(creatorId: Long?, isAuth: Boolean, contentType: ContentType, isOriginal: Boolean): Int { override fun getSeriesTotalCount(
creatorId: Long?,
isAuth: Boolean,
contentType: ContentType,
isOriginal: Boolean,
isCompleted: Boolean
): Int {
var where = series.isActive.isTrue var where = series.isActive.isTrue
if (creatorId != null) { if (creatorId != null) {
@@ -71,6 +86,10 @@ class ContentSeriesQueryRepositoryImpl(
where = where.and(series.isOriginal.isTrue) where = where.and(series.isOriginal.isTrue)
} }
if (isCompleted) {
where = where.and(series.state.eq(SeriesState.COMPLETE))
}
if (!isAuth) { if (!isAuth) {
where = where.and(series.isAdult.isFalse) where = where.and(series.isAdult.isFalse)
} else { } else {
@@ -104,6 +123,7 @@ class ContentSeriesQueryRepositoryImpl(
isAuth: Boolean, isAuth: Boolean,
contentType: ContentType, contentType: ContentType,
isOriginal: Boolean, isOriginal: Boolean,
isCompleted: Boolean,
offset: Long, offset: Long,
limit: Long limit: Long
): List<Series> { ): List<Series> {
@@ -116,6 +136,10 @@ class ContentSeriesQueryRepositoryImpl(
where = where.and(series.isOriginal.isTrue) where = where.and(series.isOriginal.isTrue)
} }
if (isCompleted) {
where = where.and(series.state.eq(SeriesState.COMPLETE))
}
if (!isAuth) { if (!isAuth) {
where = where.and(series.isAdult.isFalse) where = where.and(series.isAdult.isFalse)
} else { } else {

View File

@@ -51,11 +51,12 @@ class ContentSeriesService(
fun getSeriesList( fun getSeriesList(
creatorId: Long?, creatorId: Long?,
isOriginal: Boolean = false, isOriginal: Boolean = false,
isCompleted: Boolean = false,
isAdultContentVisible: Boolean, isAdultContentVisible: Boolean,
contentType: ContentType, contentType: ContentType,
member: Member, member: Member,
offset: Long = 0, offset: Long = 0,
limit: Long = 10 limit: Long = 20
): GetSeriesListResponse { ): GetSeriesListResponse {
val isAuth = member.auth != null && isAdultContentVisible val isAuth = member.auth != null && isAdultContentVisible
@@ -63,7 +64,8 @@ class ContentSeriesService(
creatorId = creatorId, creatorId = creatorId,
isAuth = isAuth, isAuth = isAuth,
contentType = contentType, contentType = contentType,
isOriginal = isOriginal isOriginal = isOriginal,
isCompleted = isCompleted
) )
val rawItems = repository.getSeriesList( val rawItems = repository.getSeriesList(
@@ -72,6 +74,7 @@ class ContentSeriesService(
isAuth = isAuth, isAuth = isAuth,
contentType = contentType, contentType = contentType,
isOriginal = isOriginal, isOriginal = isOriginal,
isCompleted = isCompleted,
offset = offset, offset = offset,
limit = limit limit = limit
).filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!) } ).filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!) }

View File

@@ -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()

View File

@@ -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?
}