diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt index 979ceb8..2ab8471 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt @@ -17,10 +17,13 @@ import kr.co.vividnext.sodalive.content.main.curation.AudioContentCuration import kr.co.vividnext.sodalive.content.main.curation.QAudioContentCuration.audioContentCuration import kr.co.vividnext.sodalive.content.order.QOrder.order import kr.co.vividnext.sodalive.content.pin.QPinContent.pinContent +import kr.co.vividnext.sodalive.content.playlist.AudioContentPlaylistContent +import kr.co.vividnext.sodalive.content.playlist.QAudioContentPlaylistContent import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme import kr.co.vividnext.sodalive.event.QEvent.event import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.QMember.member +import org.springframework.beans.factory.annotation.Value import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository import java.time.LocalDateTime @@ -120,10 +123,18 @@ interface AudioContentQueryRepository { fun getNotReleaseContentId(): List fun isContentCreator(contentId: Long, memberId: Long): Boolean + + fun fetchContentForPlaylist(contentIdList: List): List + fun getCoverImageById(id: Long): String? } @Repository -class AudioContentQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AudioContentQueryRepository { +class AudioContentQueryRepositoryImpl( + private val queryFactory: JPAQueryFactory, + + @Value("\${cloud.aws.cloud-front.host}") + private val imageHost: String +) : AudioContentQueryRepository { override fun findByIdAndActive(contentId: Long): AudioContent? { return queryFactory .selectFrom(audioContent) @@ -778,4 +789,27 @@ class AudioContentQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) return foundedContentId != null && foundedContentId == contentId } + + override fun fetchContentForPlaylist(contentIdList: List): List { + return queryFactory + .select( + QAudioContentPlaylistContent( + audioContent.id, + audioContent.title, + audioContentTheme.theme, + audioContent.coverImage.prepend("/").prepend(imageHost), + audioContent.duration + ) + ) + .from(audioContent) + .innerJoin(audioContent.theme, audioContentTheme) + .fetch() + } + + override fun getCoverImageById(id: Long): String? { + return queryFactory + .select(audioContent.coverImage.prepend("/").prepend(imageHost)) + .from(audioContent) + .fetchFirst() + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderRepository.kt index 42ede81..fb6022a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderRepository.kt @@ -35,6 +35,8 @@ interface OrderQueryRepository { offset: Long = 0, limit: Long = 20 ): List + + fun findOrderedContent(contentIdList: List, memberId: Long): List } @Repository @@ -218,4 +220,19 @@ class OrderQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : Orde .orderBy(order.createdAt.desc()) .fetch() } + + override fun findOrderedContent(contentIdList: List, memberId: Long): List { + return queryFactory + .select(audioContent.id) + .from(order) + .innerJoin(order.member, member) + .innerJoin(order.audioContent, audioContent) + .where( + order.isActive.isTrue, + order.endDate.isNull, + member.id.eq(memberId), + audioContent.id.`in`(contentIdList) + ) + .fetch() + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/playlist/AudioContentPlaylist.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/playlist/AudioContentPlaylist.kt new file mode 100644 index 0000000..62badd8 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/playlist/AudioContentPlaylist.kt @@ -0,0 +1,33 @@ +package kr.co.vividnext.sodalive.content.playlist + +import com.querydsl.core.annotations.QueryProjection +import org.springframework.data.annotation.Id +import org.springframework.data.redis.core.RedisHash +import org.springframework.data.redis.core.index.Indexed +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +@RedisHash("AudioContentPlaylist") +data class AudioContentPlaylist( + @Id + val id: Long, + @Indexed + val memberId: Long, + var title: String, + var desc: String? = null, + var contentIdList: MutableList = mutableListOf(), + + // ISO 8601 형식의 String + private val _createdAt: String = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) +) { + val createdAt: LocalDateTime + get() = LocalDateTime.parse(_createdAt, DateTimeFormatter.ISO_LOCAL_DATE_TIME) +} + +data class AudioContentPlaylistContent @QueryProjection constructor( + val id: Long, + val title: String, + val category: String, + val coverUrl: String, + val duration: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/playlist/AudioContentPlaylistController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/playlist/AudioContentPlaylistController.kt new file mode 100644 index 0000000..a5d5865 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/playlist/AudioContentPlaylistController.kt @@ -0,0 +1,48 @@ +package kr.co.vividnext.sodalive.content.playlist + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import org.springframework.security.core.annotation.AuthenticationPrincipal +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.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/audio-content/playlist") +class AudioContentPlaylistController(private val service: AudioContentPlaylistService) { + @PostMapping + fun createPlaylist( + @RequestBody request: CreatePlaylistRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok( + service.createPlaylist(request, member) + ) + } + + @DeleteMapping("/{id}") + fun deletePlaylist( + @PathVariable id: Long, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.deletePlaylist(playlistId = id, member)) + } + + @GetMapping + fun getPlaylists( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.getPlaylists(member)) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/playlist/AudioContentPlaylistRedisRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/playlist/AudioContentPlaylistRedisRepository.kt new file mode 100644 index 0000000..6dedba0 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/playlist/AudioContentPlaylistRedisRepository.kt @@ -0,0 +1,9 @@ +package kr.co.vividnext.sodalive.content.playlist + +import org.springframework.data.repository.CrudRepository +import org.springframework.stereotype.Repository + +@Repository +interface AudioContentPlaylistRedisRepository : CrudRepository { + fun findByMemberId(memberId: Long): List +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/playlist/AudioContentPlaylistService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/playlist/AudioContentPlaylistService.kt new file mode 100644 index 0000000..37802c8 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/playlist/AudioContentPlaylistService.kt @@ -0,0 +1,93 @@ +package kr.co.vividnext.sodalive.content.playlist + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.content.AudioContentRepository +import kr.co.vividnext.sodalive.content.order.OrderRepository +import kr.co.vividnext.sodalive.live.roulette.RedisIdGenerator +import kr.co.vividnext.sodalive.member.Member +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service + +@Service +class AudioContentPlaylistService( + private val idGenerator: RedisIdGenerator, + private val orderRepository: OrderRepository, + private val audioContentRepository: AudioContentRepository, + private val redisRepository: AudioContentPlaylistRedisRepository +) { + fun createPlaylist(request: CreatePlaylistRequest, member: Member) { + if (request.contentIdList.size >= 30) { + throw SodaException("플레이 리스트에는 최대 30개의 콘텐츠를 저장할 수 있습니다.") + } + + val playlistCount = redisRepository.findByMemberId(member.id!!).size + if (playlistCount >= 10) { + throw SodaException("플레이 리스트는 최대 10개까지 생성할 수 있습니다.") + } + + // 콘텐츠 유효성 검사 (소장으로 구매한 콘텐츠 인가?) + checkOrderedContent( + contentIdList = request.contentIdList, + memberId = member.id!! + ) + + val playlist = AudioContentPlaylist( + id = idGenerator.generateId(SEQUENCE_NAME), + memberId = member.id!!, + title = request.title, + desc = request.desc + ) + playlist.contentIdList.addAll(request.contentIdList) + + redisRepository.save(playlist) + } + + private fun checkOrderedContent(contentIdList: List, memberId: Long) { + val orderedContentIdList = orderRepository.findOrderedContent(contentIdList, memberId).toSet() + val orderedContentMap = contentIdList.associateWith { it in orderedContentIdList } + val notOrderedContentList = orderedContentMap.filterValues { !it }.keys + + if (notOrderedContentList.isNotEmpty()) { + throw SodaException("소장하지 않은 콘텐츠는 재생목록에 추가할 수 없습니다.") + } + } + + fun getPlaylists(member: Member): GetPlaylistsResponse { + val playlists = redisRepository.findByMemberId(memberId = member.id!!) + + return GetPlaylistsResponse( + totalCount = playlists.size, + items = playlists.map { + val contentCount = it.contentIdList.size + val coverImageUrl = if (contentCount > 0) { + audioContentRepository.getCoverImageById(id = it.contentIdList[0]) + ?: "" + } else { + "" + } + GetPlaylistsItem( + id = it.id, + title = it.title, + desc = it.desc ?: "", + contentCount = contentCount, + coverImageUrl = coverImageUrl + ) + } + ) + } + + fun deletePlaylist(playlistId: Long, member: Member) { + val playlist = redisRepository.findByIdOrNull(id = playlistId) + ?: throw SodaException("잘못된 요청입니다.") + + if (playlist.memberId != member.id) { + throw SodaException("잘못된 요청입니다.") + } + + redisRepository.delete(playlist) + } + + companion object { + const val SEQUENCE_NAME = "AudioContentPlaylist:sequence" + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/playlist/CreatePlaylistRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/playlist/CreatePlaylistRequest.kt new file mode 100644 index 0000000..914cb90 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/playlist/CreatePlaylistRequest.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.content.playlist + +data class CreatePlaylistRequest( + val title: String, + val desc: String? = null, + val contentIdList: List = emptyList() +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/playlist/GetPlaylistsResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/playlist/GetPlaylistsResponse.kt new file mode 100644 index 0000000..b292c01 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/playlist/GetPlaylistsResponse.kt @@ -0,0 +1,14 @@ +package kr.co.vividnext.sodalive.content.playlist + +data class GetPlaylistsResponse( + val totalCount: Int, + val items: List +) + +data class GetPlaylistsItem( + val id: Long, + val title: String, + val desc: String, + val contentCount: Int, + val coverImageUrl: String +)