From 1fe5309fdc2e8192ec55617044b83efb70acf923 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 3 Aug 2023 20:36:37 +0900 Subject: [PATCH] =?UTF-8?q?=EC=BD=98=ED=85=90=EC=B8=A0=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../aws/cloudfront/AudioContentCloudFront.kt | 48 ++ .../sodalive/can/payment/CanPaymentService.kt | 12 + .../co/vividnext/sodalive/can/use/UseCan.kt | 11 + .../content/AddAllPlaybackTrackingRequest.kt | 12 + .../sodalive/content/AudioContent.kt | 64 +++ .../content/AudioContentController.kt | 151 ++++++ .../content/AudioContentRepository.kt | 341 +++++++++++++ .../sodalive/content/AudioContentService.kt | 469 ++++++++++++++++++ .../sodalive/content/BundleAudioContent.kt | 22 + .../content/CreateAudioContentRequest.kt | 14 + .../content/CreateAudioContentResponse.kt | 3 + .../content/GetAudioContentDetailResponse.kt | 43 ++ .../content/GetAudioContentListResponse.kt | 18 + .../content/ModifyAudioContentRequest.kt | 9 + .../sodalive/content/PlaybackTracking.kt | 26 + .../content/PlaybackTrackingRepository.kt | 7 + .../sodalive/content/UploadCompleteRequest.kt | 3 + .../content/comment/AudioContentComment.kt | 41 ++ .../comment/AudioContentCommentController.kt | 80 +++ .../comment/AudioContentCommentRepository.kt | 141 ++++++ .../comment/AudioContentCommentService.kt | 89 ++++ .../GetAudioContentCommentListResponse.kt | 17 + .../content/comment/ModifyCommentRequest.kt | 3 + .../content/comment/RegisterCommentRequest.kt | 3 + .../AudioContentDonationController.kt | 25 + .../donation/AudioContentDonationRequest.kt | 8 + .../donation/AudioContentDonationService.kt | 43 ++ .../content/hashtag/AudioContentHashTag.kt | 36 ++ .../sodalive/content/hashtag/HashTag.kt | 23 + .../content/hashtag/HashTagRepository.kt | 23 + .../sodalive/content/like/AudioContentLike.kt | 37 ++ .../like/AudioContentLikeRepository.kt | 39 ++ .../like/PutAudioContentLikeRequest.kt | 3 + .../like/PutAudioContentLikeResponse.kt | 3 + .../main/AudioContentMainController.kt | 34 ++ .../content/main/AudioContentMainService.kt | 147 ++++++ .../content/main/GetAudioContentMainItem.kt | 13 + .../main/GetAudioContentMainResponse.kt | 13 + .../main/GetNewContentUploadCreator.kt | 9 + .../content/main/banner/AudioContentBanner.kt | 43 ++ .../banner/GetAudioContentBannerResponse.kt | 11 + .../main/curation/AudioContentCuration.kt | 26 + .../GetAudioContentCurationResponse.kt | 9 + .../order/GetAudioContentOrderListResponse.kt | 20 + .../vividnext/sodalive/content/order/Order.kt | 58 +++ .../sodalive/content/order/OrderController.kt | 49 ++ .../sodalive/content/order/OrderRepository.kt | 215 ++++++++ .../sodalive/content/order/OrderRequest.kt | 3 + .../sodalive/content/order/OrderService.kt | 99 ++++ .../content/theme/AudioContentTheme.kt | 19 + .../theme/AudioContentThemeController.kt | 24 + .../theme/AudioContentThemeQueryRepository.kt | 57 +++ .../content/theme/AudioContentThemeService.kt | 10 + .../theme/GetAudioContentThemeResponse.kt | 9 + src/main/resources/application.yml | 5 + 55 files changed, 2740 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/aws/cloudfront/AudioContentCloudFront.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/AddAllPlaybackTrackingRequest.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/BundleAudioContent.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/CreateAudioContentRequest.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/CreateAudioContentResponse.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/GetAudioContentDetailResponse.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/GetAudioContentListResponse.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/ModifyAudioContentRequest.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/PlaybackTracking.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/PlaybackTrackingRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/UploadCompleteRequest.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentComment.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/comment/GetAudioContentCommentListResponse.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/comment/ModifyCommentRequest.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/comment/RegisterCommentRequest.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationRequest.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/hashtag/AudioContentHashTag.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/hashtag/HashTag.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/hashtag/HashTagRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/like/AudioContentLike.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/like/AudioContentLikeRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/like/PutAudioContentLikeRequest.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/like/PutAudioContentLikeResponse.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/main/GetAudioContentMainItem.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/main/GetAudioContentMainResponse.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/main/GetNewContentUploadCreator.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBanner.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/GetAudioContentBannerResponse.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/main/curation/AudioContentCuration.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/main/curation/GetAudioContentCurationResponse.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/order/GetAudioContentOrderListResponse.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/order/Order.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderRequest.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentTheme.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeQueryRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/theme/GetAudioContentThemeResponse.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/aws/cloudfront/AudioContentCloudFront.kt b/src/main/kotlin/kr/co/vividnext/sodalive/aws/cloudfront/AudioContentCloudFront.kt new file mode 100644 index 0000000..a43f938 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/aws/cloudfront/AudioContentCloudFront.kt @@ -0,0 +1,48 @@ +package kr.co.vividnext.sodalive.aws.cloudfront + +import com.amazonaws.services.cloudfront.CloudFrontUrlSigner +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.nio.file.Files +import java.nio.file.Paths +import java.security.KeyFactory +import java.security.PrivateKey +import java.security.spec.PKCS8EncodedKeySpec +import java.util.Date + +@Component +class AudioContentCloudFront( + @Value("\${cloud.aws.content-cloud-front.host}") + private val cloudfrontDomain: String, + + @Value("\${cloud.aws.content-cloud-front.private-key-file-path}") + private val privateKeyFilePath: String, + + @Value("\${cloud.aws.content-cloud-front.key-pair-id}") + private val keyPairId: String +) { + fun generateSignedURL( + resourcePath: String, + expirationTime: Long + ): String { + // Load private key from file + val privateKey = loadPrivateKey(privateKeyFilePath) + + // Generate signed URL for resource with custom policy and expiration time + + return CloudFrontUrlSigner.getSignedURLWithCannedPolicy( + "https://$cloudfrontDomain/$resourcePath", // Resource URL + keyPairId, // CloudFront key pair ID + privateKey, // CloudFront private key + Date(System.currentTimeMillis() + expirationTime) // Expiration date + ) + } + + private fun loadPrivateKey(resourceName: String): PrivateKey { + val path = Paths.get(resourceName) + val bytes = Files.readAllBytes(path) + val keySpec = PKCS8EncodedKeySpec(bytes) + val keyFactory = KeyFactory.getInstance("RSA") + return keyFactory.generatePrivate(keySpec) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt index e181567..d9dbbe0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt @@ -13,6 +13,8 @@ import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus import kr.co.vividnext.sodalive.can.use.UseCanRepository import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.order.Order import kr.co.vividnext.sodalive.live.room.LiveRoom import kr.co.vividnext.sodalive.member.MemberRepository import org.springframework.data.repository.findByIdOrNull @@ -33,6 +35,8 @@ class CanPaymentService( needCan: Int, canUsage: CanUsage, liveRoom: LiveRoom? = null, + order: Order? = null, + audioContent: AudioContent? = null, container: String ) { val member = memberRepository.findByIdOrNull(id = memberId) @@ -72,6 +76,14 @@ class CanPaymentService( recipientId = liveRoom.member!!.id!! useCan.room = liveRoom useCan.member = member + } else if (canUsage == CanUsage.ORDER_CONTENT && order != null) { + recipientId = order.creator!!.id!! + useCan.order = order + useCan.member = member + } else if (canUsage == CanUsage.DONATION && audioContent != null) { + recipientId = audioContent.member!!.id!! + useCan.audioContent = audioContent + useCan.member = member } else { throw SodaException("잘못된 요청입니다.") } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCan.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCan.kt index 9232846..683492a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCan.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCan.kt @@ -1,6 +1,8 @@ package kr.co.vividnext.sodalive.can.use import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.order.Order import kr.co.vividnext.sodalive.live.room.LiveRoom import kr.co.vividnext.sodalive.member.Member import javax.persistence.CascadeType @@ -11,6 +13,7 @@ import javax.persistence.FetchType import javax.persistence.JoinColumn import javax.persistence.ManyToOne import javax.persistence.OneToMany +import javax.persistence.OneToOne @Entity data class UseCan( @@ -35,6 +38,14 @@ data class UseCan( field = value } + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "order_id", nullable = true) + var order: Order? = null + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id", nullable = true) + var audioContent: AudioContent? = null + @OneToMany(mappedBy = "useCan", cascade = [CascadeType.ALL]) val useCanCalculates: MutableList = mutableListOf() } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AddAllPlaybackTrackingRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AddAllPlaybackTrackingRequest.kt new file mode 100644 index 0000000..b31f736 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AddAllPlaybackTrackingRequest.kt @@ -0,0 +1,12 @@ +package kr.co.vividnext.sodalive.content + +data class AddAllPlaybackTrackingRequest( + val timezone: String, + val trackingDataList: List +) + +data class PlaybackTrackingData( + val contentId: Long, + val playDateTime: String, + val isPreview: Boolean +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt new file mode 100644 index 0000000..116d8b4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt @@ -0,0 +1,64 @@ +package kr.co.vividnext.sodalive.content + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.content.hashtag.AudioContentHashTag +import kr.co.vividnext.sodalive.content.main.curation.AudioContentCuration +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.member.Member +import javax.persistence.CascadeType +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.EnumType +import javax.persistence.Enumerated +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne +import javax.persistence.OneToMany +import javax.persistence.OneToOne +import javax.persistence.Table + +enum class AudioContentType { + INDIVIDUAL, BUNDLE +} + +enum class SortType { + NEWEST, PRICE_HIGH, PRICE_LOW +} + +@Entity +@Table(name = "content") +data class AudioContent( + var title: String, + @Column(columnDefinition = "TEXT", nullable = false) + var detail: String, + val price: Int = 0, + @Enumerated(value = EnumType.STRING) + val type: AudioContentType = AudioContentType.INDIVIDUAL, + val isGeneratePreview: Boolean = true, + var isAdult: Boolean = false, + var isCommentAvailable: Boolean = true +) : BaseEntity() { + var isActive: Boolean = false + var content: String? = null + var coverImage: String? = null + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "theme_id", nullable = false) + var theme: AudioContentTheme? = null + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "curation_id", nullable = true) + var curation: AudioContentCuration? = null + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + var member: Member? = null + + var duration: String? = null + + @OneToMany(mappedBy = "audioContent", cascade = [CascadeType.ALL]) + val audioContentHashTags: MutableList = mutableListOf() + + @OneToMany(mappedBy = "child", cascade = [CascadeType.ALL]) + var children: MutableList = mutableListOf() +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt new file mode 100644 index 0000000..0d32d5e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt @@ -0,0 +1,151 @@ +package kr.co.vividnext.sodalive.content + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.content.like.PutAudioContentLikeRequest +import kr.co.vividnext.sodalive.member.Member +import org.springframework.data.domain.Pageable +import org.springframework.lang.Nullable +import org.springframework.security.access.prepost.PreAuthorize +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.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("/audio-content") +class AudioContentController(private val service: AudioContentService) { + @PostMapping + @PreAuthorize("hasRole('CREATOR')") + fun createAudioContent( + @Nullable + @RequestPart("contentFile") + contentFile: MultipartFile?, + @RequestPart("coverImage") coverImage: MultipartFile?, + @RequestPart("request") requestString: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok( + service.createAudioContent( + contentFile = contentFile, + coverImage = coverImage, + requestString = requestString, + member = member + ) + ) + } + + @PutMapping + @PreAuthorize("hasRole('CREATOR')") + fun modifyAudioContent( + @Nullable + @RequestPart("coverImage") + coverImage: MultipartFile?, + @RequestPart("request") requestString: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok( + service.modifyAudioContent( + coverImage = coverImage, + requestString = requestString, + member = member + ) + ) + } + + @PutMapping("/upload-complete") + @PreAuthorize("hasRole('ADMIN')") + fun uploadComplete( + @RequestBody request: UploadCompleteRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok( + service.uploadComplete( + contentId = request.contentId, + content = request.contentPath, + duration = request.duration + ) + ) + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasRole('CREATOR')") + fun deleteAudioContent( + @PathVariable id: Long, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok( + service.deleteAudioContent( + audioContentId = id, + member = member + ) + ) + } + + @GetMapping + fun getAudioContentList( + @RequestParam("creator-id") creatorId: Long, + @RequestParam("sort-type", required = false) sortType: SortType = SortType.NEWEST, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + pageable: Pageable + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok( + service.getAudioContentList( + creatorId = creatorId, + sortType = sortType, + member = member, + offset = pageable.offset, + limit = pageable.pageSize.toLong() + ) + ) + } + + @GetMapping("/{id}") + fun getDetail( + @PathVariable id: Long, + @RequestParam timezone: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.getDetail(id = id, member = member, timezone = timezone)) + } + + @PostMapping("/playback-tracking") + fun addAllPlaybackTracking( + @RequestBody request: AddAllPlaybackTrackingRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.addAllPlaybackTracking(request, member)) + } + + @PutMapping("/like") + fun audioContentLike( + @RequestBody request: PutAudioContentLikeRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.audioContentLike(request, member)) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt new file mode 100644 index 0000000..72ac833 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt @@ -0,0 +1,341 @@ +package kr.co.vividnext.sodalive.content + +import com.querydsl.core.types.dsl.Expressions +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.content.QAudioContent.audioContent +import kr.co.vividnext.sodalive.content.QBundleAudioContent.bundleAudioContent +import kr.co.vividnext.sodalive.content.main.GetAudioContentMainItem +import kr.co.vividnext.sodalive.content.main.GetNewContentUploadCreator +import kr.co.vividnext.sodalive.content.main.QGetAudioContentMainItem +import kr.co.vividnext.sodalive.content.main.QGetNewContentUploadCreator +import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner +import kr.co.vividnext.sodalive.content.main.banner.QAudioContentBanner.audioContentBanner +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.theme.QAudioContentTheme.audioContentTheme +import kr.co.vividnext.sodalive.event.QEvent.event +import kr.co.vividnext.sodalive.member.QMember.member +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import java.time.LocalDateTime + +@Repository +interface AudioContentRepository : JpaRepository, AudioContentQueryRepository + +interface AudioContentQueryRepository { + fun findByIdAndActive(contentId: Long): AudioContent? + fun findByIdAndCreatorId(contentId: Long, creatorId: Long): AudioContent? + fun findBundleByContentId(contentId: Long): List + fun findByCreatorId( + creatorId: Long, + isAdult: Boolean = false, + sortType: SortType = SortType.NEWEST, + offset: Long = 0, + limit: Long = 10 + ): List + + fun findTotalCountByCreatorId(creatorId: Long, isAdult: Boolean = false): Int + fun getCreatorOtherContentList( + cloudfrontHost: String, + contentId: Long, + creatorId: Long, + isAdult: Boolean + ): List + + fun getSameThemeOtherContentList( + cloudfrontHost: String, + contentId: Long, + themeId: Long, + isAdult: Boolean + ): List + + fun findByTheme( + cloudfrontHost: String, + theme: String = "", + isAdult: Boolean = false, + limit: Long = 20 + ): List + + fun getNewContentUploadCreatorList( + cloudfrontHost: String, + isAdult: Boolean = false + ): List + + fun getAudioContentMainBannerList(isAdult: Boolean): List + fun getAudioContentCurations(isAdult: Boolean): List + fun findAudioContentByCurationId( + curationId: Long, + cloudfrontHost: String, + isAdult: Boolean + ): List +} + +@Repository +class AudioContentQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AudioContentQueryRepository { + override fun findByIdAndActive(contentId: Long): AudioContent? { + return queryFactory + .selectFrom(audioContent) + .where( + audioContent.isActive.isTrue + .and(audioContent.id.eq(contentId)) + ) + .fetchOne() + } + + override fun findByIdAndCreatorId(contentId: Long, creatorId: Long): AudioContent? { + return queryFactory + .selectFrom(audioContent) + .where( + audioContent.isActive.isTrue + .and(audioContent.id.eq(contentId)) + .and(audioContent.member.id.eq(creatorId)) + ) + .fetchOne() + } + + // 해당 컨텐츠가 속한 묶음(번들) 상품 리스트 검색 + override fun findBundleByContentId(contentId: Long): List { + return queryFactory + .select(bundleAudioContent.parent) + .from(bundleAudioContent) + .where( + bundleAudioContent.child.id.eq(contentId) + .and(bundleAudioContent.child.isActive.isTrue) + ) + .fetch() + } + + override fun findByCreatorId( + creatorId: Long, + isAdult: Boolean, + sortType: SortType, + offset: Long, + limit: Long + ): List { + val orderBy = when (sortType) { + SortType.NEWEST -> audioContent.createdAt.desc() + SortType.PRICE_HIGH -> audioContent.price.desc() + SortType.PRICE_LOW -> audioContent.price.asc() + } + + var where = audioContent.isActive.isTrue + .and(audioContent.member.id.eq(creatorId)) + + if (!isAdult) { + where = where.and(audioContent.isAdult.isFalse) + } + + return queryFactory + .selectFrom(audioContent) + .where(where) + .offset(offset) + .limit(limit) + .orderBy(orderBy) + .fetch() + } + + override fun findTotalCountByCreatorId( + creatorId: Long, + isAdult: Boolean + ): Int { + var where = audioContent.isActive.isTrue + .and(audioContent.member.id.eq(creatorId)) + + if (!isAdult) { + where = where.and(audioContent.isAdult.isFalse) + } + + return queryFactory + .selectFrom(audioContent) + .where(where) + .fetch() + .size + } + + override fun getCreatorOtherContentList( + cloudfrontHost: String, + contentId: Long, + creatorId: Long, + isAdult: Boolean + ): List { + var where = audioContent.id.ne(contentId) + .and(audioContent.member.id.eq(creatorId)) + .and(audioContent.isActive.isTrue) + + if (!isAdult) { + where = where.and(audioContent.isAdult.isFalse) + } + + return queryFactory + .select( + QOtherContentResponse( + audioContent.id, + audioContent.title, + audioContent.coverImage.prepend("$cloudfrontHost/") + ) + ) + .from(audioContent) + .where(where) + .offset(0) + .limit(10) + .orderBy(Expressions.numberTemplate(Double::class.java, "function('rand')").asc()) + .fetch() + } + + override fun getSameThemeOtherContentList( + cloudfrontHost: String, + contentId: Long, + themeId: Long, + isAdult: Boolean + ): List { + var where = audioContent.id.ne(contentId) + .and(audioContent.theme.id.eq(themeId)) + .and(audioContent.isActive.isTrue) + + if (!isAdult) { + where = where.and(audioContent.isAdult.isFalse) + } + + return queryFactory + .select( + QOtherContentResponse( + audioContent.id, + audioContent.title, + audioContent.coverImage.prepend("$cloudfrontHost/") + ) + ) + .from(audioContent) + .where(where) + .offset(0) + .limit(10) + .orderBy(Expressions.numberTemplate(Double::class.java, "function('rand')").asc()) + .fetch() + } + + override fun findByTheme( + cloudfrontHost: String, + theme: String, + isAdult: Boolean, + limit: Long + ): List { + var where = audioContent.isActive.isTrue + + if (!isAdult) { + where = where.and(audioContent.isAdult.isFalse) + } + + if (theme.isNotBlank()) { + where = where.and(audioContentTheme.theme.eq(theme)) + } + + return queryFactory + .select( + QGetAudioContentMainItem( + audioContent.id, + audioContent.coverImage.prepend("/").prepend(cloudfrontHost), + audioContent.title, + audioContent.isAdult, + member.id, + member.profileImage.prepend("/").prepend(cloudfrontHost), + member.nickname + ) + ) + .from(audioContent) + .innerJoin(audioContent.member, member) + .innerJoin(audioContent.theme, audioContentTheme) + .where(where) + .limit(limit) + .orderBy(audioContent.createdAt.desc()) + .fetch() + } + + override fun getNewContentUploadCreatorList( + cloudfrontHost: String, + isAdult: Boolean + ): List { + var where = audioContent.createdAt.after(LocalDateTime.now().minusWeeks(2)) + .and(audioContent.isActive.isTrue) + + if (!isAdult) { + where = where.and(audioContent.isAdult.isFalse) + } + + return queryFactory + .select( + QGetNewContentUploadCreator( + member.id, + member.nickname, + member.profileImage.nullif("profile/default-profile.png").prepend("$cloudfrontHost/") + ) + ) + .from(audioContent) + .innerJoin(audioContent.member, member) + .where(where) + .groupBy(member.id) + .orderBy(Expressions.numberTemplate(Double::class.java, "function('rand')").asc()) + .limit(20) + .fetch() + } + + override fun getAudioContentMainBannerList(isAdult: Boolean): List { + var where = audioContentBanner.isActive.isTrue + + if (!isAdult) { + where = where.and(audioContentBanner.isAdult.isFalse) + } + + return queryFactory + .selectFrom(audioContentBanner) + .leftJoin(audioContentBanner.event, event) + .leftJoin(audioContentBanner.creator, member) + .where(where) + .orderBy(audioContentBanner.orders.asc()) + .fetch() + } + + override fun getAudioContentCurations(isAdult: Boolean): List { + var where = audioContentCuration.isActive.isTrue + + if (!isAdult) { + where = where.and(audioContentCuration.isAdult.isFalse) + } + + return queryFactory + .selectFrom(audioContentCuration) + .where(where) + .orderBy(audioContentCuration.orders.asc()) + .fetch() + } + + override fun findAudioContentByCurationId( + curationId: Long, + cloudfrontHost: String, + isAdult: Boolean + ): List { + var where = audioContent.isActive.isTrue + .and(audioContent.curation.id.eq(curationId)) + + if (!isAdult) { + where = where.and(audioContent.isAdult.isFalse) + } + + return queryFactory + .select( + QGetAudioContentMainItem( + audioContent.id, + audioContent.coverImage.prepend("/").prepend(cloudfrontHost), + audioContent.title, + audioContent.isAdult, + member.id, + member.profileImage.nullif("profile/default-profile.png") + .prepend("/") + .prepend(cloudfrontHost), + member.nickname + ) + ) + .from(audioContent) + .where(where) + .orderBy(audioContent.id.desc()) + .fetch() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt new file mode 100644 index 0000000..1bceec0 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt @@ -0,0 +1,469 @@ +package kr.co.vividnext.sodalive.content + +import com.amazonaws.services.s3.model.ObjectMetadata +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository +import kr.co.vividnext.sodalive.content.hashtag.AudioContentHashTag +import kr.co.vividnext.sodalive.content.hashtag.HashTag +import kr.co.vividnext.sodalive.content.hashtag.HashTagRepository +import kr.co.vividnext.sodalive.content.like.AudioContentLike +import kr.co.vividnext.sodalive.content.like.AudioContentLikeRepository +import kr.co.vividnext.sodalive.content.like.PutAudioContentLikeRequest +import kr.co.vividnext.sodalive.content.like.PutAudioContentLikeResponse +import kr.co.vividnext.sodalive.content.order.OrderRepository +import kr.co.vividnext.sodalive.content.order.OrderType +import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository +import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.block.BlockMemberRepository +import kr.co.vividnext.sodalive.utils.generateFileName +import org.springframework.beans.factory.annotation.Value +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.multipart.MultipartFile +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +@Service +@Transactional(readOnly = true) +class AudioContentService( + private val repository: AudioContentRepository, + private val explorerQueryRepository: ExplorerQueryRepository, + private val blockMemberRepository: BlockMemberRepository, + private val hashTagRepository: HashTagRepository, + private val orderRepository: OrderRepository, + private val themeQueryRepository: AudioContentThemeQueryRepository, + private val playbackTrackingRepository: PlaybackTrackingRepository, + private val commentRepository: AudioContentCommentRepository, + private val audioContentLikeRepository: AudioContentLikeRepository, + + private val s3Uploader: S3Uploader, + private val objectMapper: ObjectMapper, + private val audioContentCloudFront: AudioContentCloudFront, + + @Value("\${cloud.aws.s3.content-bucket}") + private val audioContentBucket: String, + + @Value("\${cloud.aws.s3.bucket}") + private val coverImageBucket: String, + + @Value("\${cloud.aws.cloud-front.host}") + private val coverImageHost: String +) { + @Transactional + fun audioContentLike(request: PutAudioContentLikeRequest, member: Member): PutAudioContentLikeResponse { + var audioContentLike = audioContentLikeRepository.findByMemberIdAndContentId( + memberId = member.id!!, + contentId = request.contentId + ) + + if (audioContentLike == null) { + audioContentLike = AudioContentLike( + memberId = member.id!!, + contentId = request.contentId + ) + + audioContentLikeRepository.save(audioContentLike) + } else { + audioContentLike.isActive = !audioContentLike.isActive + } + + return PutAudioContentLikeResponse(like = audioContentLike.isActive) + } + + @Transactional + fun modifyAudioContent( + coverImage: MultipartFile?, + requestString: String, + member: Member + ) { + // request 내용 파싱 + val request = objectMapper.readValue(requestString, ModifyAudioContentRequest::class.java) + + val audioContent = repository.findByIdAndCreatorId(request.contentId, member.id!!) + ?: throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.") + + if (request.title != null) audioContent.title = request.title + if (request.detail != null) audioContent.detail = request.detail + audioContent.isCommentAvailable = request.isCommentAvailable + audioContent.isAdult = request.isAdult + + if (coverImage != null) { + val metadata = ObjectMetadata() + metadata.contentLength = coverImage.size + + // 커버 이미지 파일명 생성 + val coverImageFileName = generateFileName(prefix = "${audioContent.id}-cover") + + // 커버 이미지 업로드 + val coverImagePath = s3Uploader.upload( + inputStream = coverImage.inputStream, + bucket = coverImageBucket, + filePath = "audio_content_cover/${audioContent.id}/$coverImageFileName", + metadata = metadata + ) + + audioContent.coverImage = coverImagePath + } + } + + @Transactional + fun deleteAudioContent(audioContentId: Long, member: Member) { + val audioContent = repository.findByIdAndCreatorId(audioContentId, member.id!!) + ?: throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.") + + audioContent.isActive = false + } + + @Transactional + fun createAudioContent( + contentFile: MultipartFile?, + coverImage: MultipartFile?, + requestString: String, + member: Member + ): CreateAudioContentResponse { + // coverImage 체크 + if (coverImage == null) throw SodaException("커버이미지를 선택해 주세요.") + + // request 내용 파싱 + val request = objectMapper.readValue(requestString, CreateAudioContentRequest::class.java) + + // contentFile 체크 + if (contentFile == null && request.type == AudioContentType.INDIVIDUAL) { + throw SodaException("콘텐츠를 선택해 주세요.") + } + + if (request.type == AudioContentType.BUNDLE && request.childIds == null) { + throw SodaException("묶음상품의 하위상품을 선택해 주세요.") + } + + // 테마 체크 + val theme = themeQueryRepository.findThemeByIdAndActive(id = request.themeId) + ?: throw SodaException("잘못된 테마입니다. 다시 선택해 주세요.") + + if (request.price in 1..9) throw SodaException("콘텐츠의 최소금액은 10코인 입니다.") + + // DB에 값 추가 + val audioContent = AudioContent( + title = request.title, + detail = request.detail, + type = request.type, + price = if (request.price < 0) { + 0 + } else { + request.price + }, + isAdult = request.isAdult, + isGeneratePreview = if (request.type == AudioContentType.INDIVIDUAL) { + request.isGeneratePreview + } else { + false + }, + isCommentAvailable = request.isCommentAvailable + ) + audioContent.theme = theme + audioContent.member = member + audioContent.isActive = request.type == AudioContentType.BUNDLE + + repository.save(audioContent) + + // 태그 분리, #추가, 등록 + if (request.tags.isNotBlank()) { + val tags = request.tags + .split(" ") + .asSequence() + .map { it.trim() } + .filter { it.isNotEmpty() } + .map { + val tag = if (!it.startsWith("#")) { + "#$it" + } else { + it + } + + val hashTag = hashTagRepository.findByTag(tag) + ?: hashTagRepository.save(HashTag(tag)) + + val audioContentHashTag = AudioContentHashTag() + audioContentHashTag.audioContent = audioContent + audioContentHashTag.hashTag = hashTag + + audioContentHashTag + }.toList() + + audioContent.audioContentHashTags.addAll(tags) + } + + var metadata = ObjectMetadata() + metadata.contentLength = coverImage.size + + // 커버 이미지 파일명 생성 + val coverImageFileName = generateFileName(prefix = "${audioContent.id}-cover") + + // 커버 이미지 업로드 + val coverImagePath = s3Uploader.upload( + inputStream = coverImage.inputStream, + bucket = coverImageBucket, + filePath = "audio_content_cover/${audioContent.id}/$coverImageFileName", + metadata = metadata + ) + + audioContent.coverImage = coverImagePath + + if (contentFile != null && request.type == AudioContentType.INDIVIDUAL) { + // 콘텐츠 파일명 생성 + val contentFileName = generateFileName(prefix = "${audioContent.id}-content") + + // 콘텐츠 파일 업로드 + metadata = ObjectMetadata() + metadata.contentLength = contentFile.size + metadata.addUserMetadata("generate_preview", "true") + + val contentPath = s3Uploader.upload( + inputStream = contentFile.inputStream, + bucket = audioContentBucket, + filePath = "input/${audioContent.id}/$contentFileName", + metadata = metadata + ) + + audioContent.content = contentPath + } + + if (request.childIds != null && request.type == AudioContentType.BUNDLE) { + for (childId in request.childIds) { + val childContent = repository.findByIdAndActive(childId) + ?: continue + + val bundleAudioContent = BundleAudioContent() + bundleAudioContent.parent = audioContent + bundleAudioContent.child = childContent + audioContent.children.add(bundleAudioContent) + } + } + + return CreateAudioContentResponse(contentId = audioContent.id!!) + } + + @Transactional + fun uploadComplete(contentId: Long, content: String, duration: String) { + val keyFileName = content.split("/").last() + if (!keyFileName.startsWith(contentId.toString())) throw SodaException("잘못된 요청입니다.") + + val audioContent = repository.findByIdOrNull(contentId) + ?: throw SodaException("잘못된 요청입니다.") + + audioContent.isActive = true + audioContent.content = content + audioContent.duration = duration + } + + fun getDetail(id: Long, member: Member, timezone: String): GetAudioContentDetailResponse { + // 묶음 콘텐츠 조회 + val bundleAudioContentList = repository.findBundleByContentId(contentId = id) + + // 오디오 콘텐츠 조회 (content_id, 제목, 내용, 테마, 태그, 19여부, 이미지, 콘텐츠 PATH) + val audioContent = repository.findByIdOrNull(id) + ?: throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.") + + // 크리에이터(유저) 정보 + val creatorId = audioContent.member!!.id!! + val creator = explorerQueryRepository.getAccount(creatorId) + ?: throw SodaException("없는 사용자 입니다.") + + val notificationUserIds = explorerQueryRepository.getNotificationUserIds(creatorId) + val isFollowing = notificationUserIds.contains(member.id) + + // 차단된 사용자 체크 + val isBlocked = blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = creatorId) + if (isBlocked) throw SodaException("${creator.nickname}님의 요청으로 콘텐츠 접근이 제한됩니다.") + + // 구매 여부 확인 + val isExistsBundleAudioContent = bundleAudioContentList + .map { orderRepository.isExistOrdered(memberId = member.id!!, contentId = it.id!!) } + .contains(true) + + val (isExistsAudioContent, orderType) = orderRepository.isExistOrderedAndOrderType( + memberId = member.id!!, + contentId = audioContent.id!! + ) + + if (!isExistsAudioContent && !isExistsBundleAudioContent && !audioContent.isActive) { + throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.") + } + + // 댓글 + val commentList = if (audioContent.isCommentAvailable) { + commentRepository.findByContentId( + cloudFrontHost = coverImageHost, + contentId = audioContent.id!!, + timezone = timezone, + offset = 0, + limit = 1 + ) + } else { + listOf() + } + + // 댓글 수 + val commentCount = if (audioContent.isCommentAvailable) { + commentRepository.totalCountCommentByContentId(contentId = audioContent.id!!) + } else { + 0 + } + + val audioContentUrl = audioContentCloudFront.generateSignedURL( + resourcePath = if ( + isExistsAudioContent || + isExistsBundleAudioContent || + audioContent.member!!.id!! == member.id!! || + audioContent.price <= 0 + ) { + audioContent.content!! + } else { + audioContent.content!!.replace("output/", "preview/") + }, + expirationTime = 1000 * 60 * 60 * (audioContent.duration!!.split(":")[0].toLong() + 2) + ) + + val tag = audioContent.audioContentHashTags + .map { it.hashTag!!.tag } + .joinToString(" ") { it } + + val creatorOtherContentList = repository.getCreatorOtherContentList( + cloudfrontHost = coverImageHost, + contentId = audioContent.id!!, + creatorId = creatorId, + isAdult = member.auth != null + ) + + val sameThemeOtherContentList = repository.getSameThemeOtherContentList( + cloudfrontHost = coverImageHost, + contentId = audioContent.id!!, + themeId = audioContent.theme!!.id!!, + isAdult = member.auth != null + ) + + val likeCount = audioContentLikeRepository.totalCountAudioContentLike(contentId = id) + val isLike = audioContentLikeRepository.findByMemberIdAndContentId( + memberId = member.id!!, + contentId = id + )?.isActive ?: false + + val remainingTime = if (orderType == OrderType.RENTAL) { + orderRepository.getAudioContentRemainingTime( + memberId = member.id!!, + contentId = audioContent.id!!, + timezone = timezone + ) + } else { + null + } + + return GetAudioContentDetailResponse( + contentId = audioContent.id!!, + title = audioContent.title, + detail = audioContent.detail, + coverImageUrl = "$coverImageHost/${audioContent.coverImage!!}", + contentUrl = audioContentUrl, + themeStr = audioContent.theme!!.theme, + tag = tag, + price = audioContent.price, + duration = audioContent.duration ?: "", + isAdult = audioContent.isAdult, + isMosaic = audioContent.isAdult && member.auth == null, + existOrdered = isExistsBundleAudioContent || isExistsAudioContent, + orderType = orderType, + remainingTime = remainingTime, + creatorOtherContentList = creatorOtherContentList, + sameThemeOtherContentList = sameThemeOtherContentList, + isCommentAvailable = audioContent.isCommentAvailable, + isLike = isLike, + likeCount = likeCount, + commentList = commentList, + commentCount = commentCount, + creator = AudioContentCreator( + creatorId = creatorId, + nickname = creator.nickname, + profileImageUrl = if (creator.profileImage != null) { + "$coverImageHost/${creator.profileImage}" + } else { + "$coverImageHost/profile/default-profile.png" + }, + isFollowing = isFollowing + ) + ) + } + + fun getAudioContentList( + creatorId: Long, + sortType: SortType, + member: Member, + offset: Long, + limit: Long + ): GetAudioContentListResponse { + val totalCount = repository.findTotalCountByCreatorId( + creatorId = creatorId, + isAdult = member.auth != null + ) + + val audioContentList = repository.findByCreatorId( + creatorId = creatorId, + isAdult = member.auth != null, + sortType = sortType, + offset = offset, + limit = limit + ) + + val items = audioContentList + .map { + val commentCount = commentRepository + .totalCountCommentByContentId(it.id!!) + + val likeCount = audioContentLikeRepository + .totalCountAudioContentLike(it.id!!) + + GetAudioContentListItem( + contentId = it.id!!, + coverImageUrl = "$coverImageHost/${it.coverImage!!}", + title = it.title, + price = it.price, + themeStr = it.theme!!.theme, + duration = it.duration, + likeCount = likeCount, + commentCount = commentCount, + isAdult = it.isAdult + ) + } + + return GetAudioContentListResponse( + totalCount = totalCount, + items = items + ) + } + + @Transactional + fun addAllPlaybackTracking(request: AddAllPlaybackTrackingRequest, member: Member) { + val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + + for (trackingData in request.trackingDataList) { + val playDate = LocalDateTime.parse(trackingData.playDateTime, dateTimeFormatter) + .atZone(ZoneId.of(request.timezone)) + .withZoneSameInstant(ZoneId.of("UTC")) + .toLocalDateTime() + + playbackTrackingRepository.save( + PlaybackTracking( + memberId = member.id!!, + contentId = trackingData.contentId, + playDate = playDate, + isPreview = trackingData.isPreview + ) + ) + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/BundleAudioContent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/BundleAudioContent.kt new file mode 100644 index 0000000..375e819 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/BundleAudioContent.kt @@ -0,0 +1,22 @@ +package kr.co.vividnext.sodalive.content + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne +import javax.persistence.Table + +@Entity +@Table(name = "bundle_content") +data class BundleAudioContent( + var isActive: Boolean = true +) : BaseEntity() { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_content_id", nullable = false) + var parent: AudioContent? = null + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "child_content_id", nullable = false) + var child: AudioContent? = null +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/CreateAudioContentRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/CreateAudioContentRequest.kt new file mode 100644 index 0000000..8952c84 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/CreateAudioContentRequest.kt @@ -0,0 +1,14 @@ +package kr.co.vividnext.sodalive.content + +data class CreateAudioContentRequest( + val title: String, + val detail: String, + val tags: String, + val price: Int, + val themeId: Long = 0, + val isAdult: Boolean = false, + val isGeneratePreview: Boolean = true, + val isCommentAvailable: Boolean = false, + val type: AudioContentType = AudioContentType.INDIVIDUAL, + val childIds: List? = null +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/CreateAudioContentResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/CreateAudioContentResponse.kt new file mode 100644 index 0000000..3bffecf --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/CreateAudioContentResponse.kt @@ -0,0 +1,3 @@ +package kr.co.vividnext.sodalive.content + +data class CreateAudioContentResponse(val contentId: Long) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/GetAudioContentDetailResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/GetAudioContentDetailResponse.kt new file mode 100644 index 0000000..0c5f3e9 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/GetAudioContentDetailResponse.kt @@ -0,0 +1,43 @@ +package kr.co.vividnext.sodalive.content + +import com.querydsl.core.annotations.QueryProjection +import kr.co.vividnext.sodalive.content.comment.GetAudioContentCommentListItem +import kr.co.vividnext.sodalive.content.order.OrderType + +data class GetAudioContentDetailResponse( + val contentId: Long, + val title: String, + val detail: String, + val coverImageUrl: String, + val contentUrl: String, + val themeStr: String, + val tag: String, + val price: Int, + val duration: String, + val isAdult: Boolean, + val isMosaic: Boolean, + val existOrdered: Boolean, + val orderType: OrderType?, + val remainingTime: String?, + val creatorOtherContentList: List, + val sameThemeOtherContentList: List, + val isCommentAvailable: Boolean, + val isLike: Boolean, + val likeCount: Int, + val commentList: List, + val commentCount: Int, + val creator: AudioContentCreator +) + +data class OtherContentResponse @QueryProjection constructor( + val contentId: Long, + val title: String, + val coverUrl: String +) + +data class AudioContentCreator( + val creatorId: Long, + val nickname: String, + val profileImageUrl: String, + val isFollowing: Boolean +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/GetAudioContentListResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/GetAudioContentListResponse.kt new file mode 100644 index 0000000..bfef003 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/GetAudioContentListResponse.kt @@ -0,0 +1,18 @@ +package kr.co.vividnext.sodalive.content + +data class GetAudioContentListResponse( + val totalCount: Int, + val items: List +) + +data class GetAudioContentListItem( + val contentId: Long, + val coverImageUrl: String, + val title: String, + val price: Int, + val themeStr: String, + val duration: String?, + val likeCount: Int, + val commentCount: Int, + val isAdult: Boolean +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/ModifyAudioContentRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/ModifyAudioContentRequest.kt new file mode 100644 index 0000000..fc104f7 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/ModifyAudioContentRequest.kt @@ -0,0 +1,9 @@ +package kr.co.vividnext.sodalive.content + +data class ModifyAudioContentRequest( + val contentId: Long, + val title: String?, + val detail: String?, + val isAdult: Boolean, + val isCommentAvailable: Boolean +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/PlaybackTracking.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/PlaybackTracking.kt new file mode 100644 index 0000000..2639f37 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/PlaybackTracking.kt @@ -0,0 +1,26 @@ +package kr.co.vividnext.sodalive.content + +import java.time.LocalDateTime +import javax.persistence.Entity +import javax.persistence.GeneratedValue +import javax.persistence.GenerationType +import javax.persistence.Id +import javax.persistence.PrePersist + +@Entity +data class PlaybackTracking( + val memberId: Long, + val contentId: Long, + val isPreview: Boolean, + val playDate: LocalDateTime +) { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null + var createdAt: LocalDateTime? = null + + @PrePersist + fun prePersist() { + createdAt = LocalDateTime.now() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/PlaybackTrackingRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/PlaybackTrackingRepository.kt new file mode 100644 index 0000000..f768729 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/PlaybackTrackingRepository.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.content + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface PlaybackTrackingRepository : JpaRepository diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/UploadCompleteRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/UploadCompleteRequest.kt new file mode 100644 index 0000000..75a4898 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/UploadCompleteRequest.kt @@ -0,0 +1,3 @@ +package kr.co.vividnext.sodalive.content + +data class UploadCompleteRequest(val contentId: Long, val contentPath: String, val duration: String) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentComment.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentComment.kt new file mode 100644 index 0000000..0f345e3 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentComment.kt @@ -0,0 +1,41 @@ +package kr.co.vividnext.sodalive.content.comment + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.member.Member +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne +import javax.persistence.OneToMany +import javax.persistence.Table + +@Entity +@Table(name = "content_comment") +data class AudioContentComment( + @Column(columnDefinition = "TEXT", nullable = false) + var comment: String, + @Column(nullable = true) + var donationCan: Int? = null, + var isActive: Boolean = true +) : BaseEntity() { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_id", nullable = true) + var parent: AudioContentComment? = null + set(value) { + value?.children?.add(this) + field = value + } + + @OneToMany(mappedBy = "parent") + var children: MutableList = mutableListOf() + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + var member: Member? = null + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id", nullable = false) + var audioContent: AudioContent? = null +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentController.kt new file mode 100644 index 0000000..dc153e2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentController.kt @@ -0,0 +1,80 @@ +package kr.co.vividnext.sodalive.content.comment + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import org.springframework.data.domain.Pageable +import org.springframework.security.core.annotation.AuthenticationPrincipal +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.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +class AudioContentCommentController(private val service: AudioContentCommentService) { + @PostMapping("/audio-content/comment") + fun registerComment( + @RequestBody request: RegisterCommentRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok( + service.registerComment( + comment = request.comment, + audioContentId = request.contentId, + parentId = request.parentId, + member = member + ) + ) + } + + @PutMapping("/audio-content/comment") + fun modifyComment( + @RequestBody request: ModifyCommentRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.modifyComment(request = request, member = member)) + } + + @GetMapping("/audio-content/{id}/comment") + fun getCommentList( + @PathVariable("id") audioContentId: Long, + @RequestParam timezone: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + pageable: Pageable + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok( + service.getCommentList( + audioContentId = audioContentId, + timezone = timezone, + pageable = pageable + ) + ) + } + + @GetMapping("/audio-content/comment/{id}") + fun getCommentReplyList( + @PathVariable("id") commentId: Long, + @RequestParam timezone: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + pageable: Pageable + ): ApiResponse { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + return ApiResponse.ok( + service.getCommentReplyList( + commentId = commentId, + timezone = timezone, + pageable = pageable + ) + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentRepository.kt new file mode 100644 index 0000000..d09397c --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentRepository.kt @@ -0,0 +1,141 @@ +package kr.co.vividnext.sodalive.content.comment + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.content.comment.QAudioContentComment.audioContentComment +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +@Repository +interface AudioContentCommentRepository : JpaRepository, AudioContentCommentQueryRepository + +interface AudioContentCommentQueryRepository { + fun findByContentId( + cloudFrontHost: String, + contentId: Long, + timezone: String, + offset: Long, + limit: Int + ): List + + fun totalCountCommentByContentId(contentId: Long): Int + fun commentReplyCountByAudioContentCommentId(commentId: Long): Int + fun getAudioContentCommentReplyList( + cloudFrontHost: String, + commentId: Long, + timezone: String, + offset: Long, + limit: Int + ): List +} + +@Repository +class AudioContentCommentQueryRepositoryImpl( + private val queryFactory: JPAQueryFactory +) : AudioContentCommentQueryRepository { + override fun findByContentId( + cloudFrontHost: String, + contentId: Long, + timezone: String, + offset: Long, + limit: Int + ): List { + return queryFactory.selectFrom(audioContentComment) + .where( + audioContentComment.audioContent.id.eq(contentId) + .and(audioContentComment.isActive.isTrue) + .and(audioContentComment.parent.isNull) + ) + .offset(offset) + .limit(limit.toLong()) + .orderBy(audioContentComment.createdAt.desc()) + .fetch() + .asSequence() + .map { + val date = it.createdAt!! + .atZone(ZoneId.of("UTC")) + .withZoneSameInstant(ZoneId.of(timezone)) + + GetAudioContentCommentListItem( + id = it.id!!, + writerId = it.member!!.id!!, + nickname = it.member!!.nickname, + profileUrl = if (it.member!!.profileImage != null) { + "$cloudFrontHost/${it.member!!.profileImage}" + } else { + "$cloudFrontHost/profile/default-profile.png" + }, + comment = it.comment, + donationCoin = it.donationCan ?: 0, + date = date.format(DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a")), + replyCount = commentReplyCountByAudioContentCommentId(it.id!!) + ) + } + .toList() + } + + override fun totalCountCommentByContentId(contentId: Long): Int { + return queryFactory.select(audioContentComment.id) + .from(audioContentComment) + .where( + audioContentComment.audioContent.id.eq(contentId) + .and(audioContentComment.isActive.isTrue) + ) + .fetch() + .size + } + + override fun commentReplyCountByAudioContentCommentId(commentId: Long): Int { + return queryFactory.select(audioContentComment.id) + .from(audioContentComment) + .where( + audioContentComment.parent.isNotNull + .and(audioContentComment.parent.id.eq(commentId)) + .and(audioContentComment.isActive.isTrue) + ) + .fetch() + .size + } + + override fun getAudioContentCommentReplyList( + cloudFrontHost: String, + commentId: Long, + timezone: String, + offset: Long, + limit: Int + ): List { + return queryFactory.selectFrom(audioContentComment) + .where( + audioContentComment.parent.isNotNull + .and(audioContentComment.parent.id.eq(commentId)) + .and(audioContentComment.isActive.isTrue) + ) + .offset(offset) + .limit(limit.toLong()) + .orderBy(audioContentComment.createdAt.desc()) + .fetch() + .asSequence() + .map { + val date = it.createdAt!! + .atZone(ZoneId.of("UTC")) + .withZoneSameInstant(ZoneId.of(timezone)) + + GetAudioContentCommentListItem( + id = it.id!!, + writerId = it.member!!.id!!, + nickname = it.member!!.nickname, + profileUrl = if (it.member!!.profileImage != null) { + "$cloudFrontHost/${it.member!!.profileImage}" + } else { + "$cloudFrontHost/profile/default-profile.png" + }, + comment = it.comment, + donationCoin = it.donationCan ?: 0, + date = date.format(DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a")), + replyCount = 0 + ) + } + .toList() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt new file mode 100644 index 0000000..dd91014 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt @@ -0,0 +1,89 @@ +package kr.co.vividnext.sodalive.content.comment + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.content.AudioContentRepository +import kr.co.vividnext.sodalive.member.Member +import org.springframework.beans.factory.annotation.Value +import org.springframework.data.domain.Pageable +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class AudioContentCommentService( + private val repository: AudioContentCommentRepository, + private val audioContentRepository: AudioContentRepository, + + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) { + @Transactional + fun registerComment(member: Member, comment: String, audioContentId: Long, parentId: Long? = null) { + val audioContent = audioContentRepository.findByIdOrNull(id = audioContentId) + ?: throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.") + + val audioContentComment = AudioContentComment(comment = comment) + audioContentComment.audioContent = audioContent + audioContentComment.member = member + + val parent = if (parentId != null) { + repository.findByIdOrNull(id = parentId) + } else { + null + } + + if (parent != null) { + audioContentComment.parent = parent + } + + repository.save(audioContentComment) + } + + @Transactional + fun modifyComment(request: ModifyCommentRequest, member: Member) { + val audioContentComment = repository.findByIdOrNull(request.commentId) + ?: throw SodaException("잘못된 접근 입니다.\n확인 후 다시 시도해 주세요.") + + if (audioContentComment.audioContent!!.member!!.id!! != member.id!!) { + if (audioContentComment.member == null || audioContentComment.member!!.id!! != member.id!!) { + throw SodaException("잘못된 접근 입니다.\n확인 후 다시 시도해 주세요.") + } + + if (request.comment != null) { + audioContentComment.comment = request.comment + } + } + + if (request.isActive != null) { + audioContentComment.isActive = request.isActive + } + } + + fun getCommentList(audioContentId: Long, timezone: String, pageable: Pageable): GetAudioContentCommentListResponse { + val commentList = + repository.findByContentId( + cloudFrontHost = cloudFrontHost, + contentId = audioContentId, + timezone = timezone, + offset = pageable.offset, + limit = pageable.pageSize + ) + val totalCount = repository.totalCountCommentByContentId(audioContentId) + + return GetAudioContentCommentListResponse(totalCount, commentList) + } + + fun getCommentReplyList(commentId: Long, timezone: String, pageable: Pageable): GetAudioContentCommentListResponse { + val commentList = repository.getAudioContentCommentReplyList( + cloudFrontHost = cloudFrontHost, + commentId = commentId, + timezone = timezone, + offset = pageable.offset, + limit = pageable.pageSize + ) + val totalCount = repository.commentReplyCountByAudioContentCommentId(commentId) + + return GetAudioContentCommentListResponse(totalCount, commentList) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/GetAudioContentCommentListResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/GetAudioContentCommentListResponse.kt new file mode 100644 index 0000000..08be9e4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/GetAudioContentCommentListResponse.kt @@ -0,0 +1,17 @@ +package kr.co.vividnext.sodalive.content.comment + +data class GetAudioContentCommentListResponse( + val totalCount: Int, + val items: List +) + +data class GetAudioContentCommentListItem( + val id: Long, + val writerId: Long, + val nickname: String, + val profileUrl: String, + val comment: String, + val donationCoin: Int, + val date: String, + val replyCount: Int +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/ModifyCommentRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/ModifyCommentRequest.kt new file mode 100644 index 0000000..574baee --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/ModifyCommentRequest.kt @@ -0,0 +1,3 @@ +package kr.co.vividnext.sodalive.content.comment + +data class ModifyCommentRequest(val commentId: Long, val comment: String? = null, val isActive: Boolean? = null) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/RegisterCommentRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/RegisterCommentRequest.kt new file mode 100644 index 0000000..df29747 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/RegisterCommentRequest.kt @@ -0,0 +1,3 @@ +package kr.co.vividnext.sodalive.content.comment + +data class RegisterCommentRequest(val comment: String, val contentId: Long, val parentId: Long?) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationController.kt new file mode 100644 index 0000000..ebd097a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationController.kt @@ -0,0 +1,25 @@ +package kr.co.vividnext.sodalive.content.donation + +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.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/donation") +class AudioContentDonationController(private val service: AudioContentDonationService) { + + @PostMapping + fun donation( + @RequestBody request: AudioContentDonationRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.donation(request = request, member = member)) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationRequest.kt new file mode 100644 index 0000000..a8ed8a9 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationRequest.kt @@ -0,0 +1,8 @@ +package kr.co.vividnext.sodalive.content.donation + +data class AudioContentDonationRequest( + val contentId: Long, + val donationCan: Int, + val comment: String, + val container: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationService.kt new file mode 100644 index 0000000..043e6ca --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationService.kt @@ -0,0 +1,43 @@ +package kr.co.vividnext.sodalive.content.donation + +import kr.co.vividnext.sodalive.can.payment.CanPaymentService +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.content.AudioContentRepository +import kr.co.vividnext.sodalive.content.comment.AudioContentComment +import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository +import kr.co.vividnext.sodalive.member.Member +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class AudioContentDonationService( + private val coinPaymentService: CanPaymentService, + private val queryRepository: AudioContentRepository, + private val commentRepository: AudioContentCommentRepository +) { + @Transactional + fun donation(request: AudioContentDonationRequest, member: Member) { + if (request.donationCan < 1) throw SodaException("1캔 이상 후원하실 수 있습니다.") + if (request.comment.isBlank()) throw SodaException("함께 보낼 메시지를 입력하세요.") + + val audioContent = queryRepository.findByIdAndActive(request.contentId) + ?: throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.") + + coinPaymentService.spendCan( + memberId = member.id!!, + needCan = request.donationCan, + canUsage = CanUsage.DONATION, + audioContent = audioContent, + container = request.container + ) + + val audioContentComment = AudioContentComment( + comment = request.comment, + donationCan = request.donationCan + ) + audioContentComment.audioContent = audioContent + audioContentComment.member = member + commentRepository.save(audioContentComment) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/hashtag/AudioContentHashTag.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/hashtag/AudioContentHashTag.kt new file mode 100644 index 0000000..0ff2b6d --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/hashtag/AudioContentHashTag.kt @@ -0,0 +1,36 @@ +package kr.co.vividnext.sodalive.content.hashtag + +import kr.co.vividnext.sodalive.content.AudioContent +import java.time.LocalDateTime +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.GeneratedValue +import javax.persistence.GenerationType +import javax.persistence.Id +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne +import javax.persistence.PrePersist +import javax.persistence.Table + +@Entity +@Table(name = "content_hash_tag") +class AudioContentHashTag { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null + + var createdAt: LocalDateTime? = null + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id", nullable = false) + var audioContent: AudioContent? = null + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "hash_tag_id", nullable = false) + var hashTag: HashTag? = null + + @PrePersist + fun prePersist() { + createdAt = LocalDateTime.now() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/hashtag/HashTag.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/hashtag/HashTag.kt new file mode 100644 index 0000000..118e081 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/hashtag/HashTag.kt @@ -0,0 +1,23 @@ +package kr.co.vividnext.sodalive.content.hashtag + +import java.time.LocalDateTime +import javax.persistence.Entity +import javax.persistence.GeneratedValue +import javax.persistence.GenerationType +import javax.persistence.Id +import javax.persistence.PrePersist + +@Entity +class HashTag( + val tag: String +) { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Int? = null + var createdAt: LocalDateTime? = null + + @PrePersist + fun prePersist() { + createdAt = LocalDateTime.now() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/hashtag/HashTagRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/hashtag/HashTagRepository.kt new file mode 100644 index 0000000..93edd35 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/hashtag/HashTagRepository.kt @@ -0,0 +1,23 @@ +package kr.co.vividnext.sodalive.content.hashtag + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.content.hashtag.QHashTag.hashTag +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface HashTagRepository : JpaRepository, HashTagQueryRepository + +interface HashTagQueryRepository { + fun findByTag(tag: String): HashTag? +} + +@Repository +class HashTagQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : HashTagQueryRepository { + override fun findByTag(tag: String): HashTag? { + return queryFactory + .selectFrom(hashTag) + .where(hashTag.tag.eq(tag)) + .fetchOne() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/like/AudioContentLike.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/like/AudioContentLike.kt new file mode 100644 index 0000000..5b9b16a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/like/AudioContentLike.kt @@ -0,0 +1,37 @@ +package kr.co.vividnext.sodalive.content.like + +import java.time.LocalDateTime +import javax.persistence.Entity +import javax.persistence.GeneratedValue +import javax.persistence.GenerationType +import javax.persistence.Id +import javax.persistence.PrePersist +import javax.persistence.PreUpdate +import javax.persistence.Table + +@Entity +@Table(name = "content_like") +data class AudioContentLike( + val memberId: Long, + val contentId: Long +) { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null + + var createdAt: LocalDateTime? = null + var updatedAt: LocalDateTime? = null + + @PrePersist + fun prePersist() { + createdAt = LocalDateTime.now() + updatedAt = LocalDateTime.now() + } + + @PreUpdate + fun preUpdate() { + updatedAt = LocalDateTime.now() + } + + var isActive = true +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/like/AudioContentLikeRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/like/AudioContentLikeRepository.kt new file mode 100644 index 0000000..db8b1ad --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/like/AudioContentLikeRepository.kt @@ -0,0 +1,39 @@ +package kr.co.vividnext.sodalive.content.like + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.content.like.QAudioContentLike.audioContentLike +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface AudioContentLikeRepository : JpaRepository, AudioContentLikeQueryRepository + +interface AudioContentLikeQueryRepository { + fun findByMemberIdAndContentId(memberId: Long, contentId: Long): AudioContentLike? + fun totalCountAudioContentLike(contentId: Long): Int +} + +@Repository +class AudioContentLikeQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AudioContentLikeQueryRepository { + override fun findByMemberIdAndContentId(memberId: Long, contentId: Long): AudioContentLike? { + return queryFactory + .selectFrom(audioContentLike) + .where( + audioContentLike.memberId.eq(memberId) + .and(audioContentLike.contentId.eq(contentId)) + ) + .fetchFirst() + } + + override fun totalCountAudioContentLike(contentId: Long): Int { + return queryFactory + .select(audioContentLike.id) + .from(audioContentLike) + .where( + audioContentLike.contentId.eq(contentId) + .and(audioContentLike.isActive.isTrue) + ) + .fetch() + .size + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/like/PutAudioContentLikeRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/like/PutAudioContentLikeRequest.kt new file mode 100644 index 0000000..5bc9df2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/like/PutAudioContentLikeRequest.kt @@ -0,0 +1,3 @@ +package kr.co.vividnext.sodalive.content.like + +data class PutAudioContentLikeRequest(val contentId: Long) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/like/PutAudioContentLikeResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/like/PutAudioContentLikeResponse.kt new file mode 100644 index 0000000..55698e2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/like/PutAudioContentLikeResponse.kt @@ -0,0 +1,3 @@ +package kr.co.vividnext.sodalive.content.like + +data class PutAudioContentLikeResponse(val like: Boolean) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainController.kt new file mode 100644 index 0000000..d021553 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainController.kt @@ -0,0 +1,34 @@ +package kr.co.vividnext.sodalive.content.main + +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.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/audio-content/main") +class AudioContentMainController(private val service: AudioContentMainService) { + + @GetMapping + fun getMain( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.getMain(member = member)) + } + + @GetMapping("/new") + fun getNewContentByTheme( + @RequestParam("theme") theme: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.getNewContentByTheme(theme, member)) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainService.kt new file mode 100644 index 0000000..8c01c2a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainService.kt @@ -0,0 +1,147 @@ +package kr.co.vividnext.sodalive.content.main + +import kr.co.vividnext.sodalive.content.AudioContentRepository +import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType +import kr.co.vividnext.sodalive.content.main.banner.GetAudioContentBannerResponse +import kr.co.vividnext.sodalive.content.main.curation.GetAudioContentCurationResponse +import kr.co.vividnext.sodalive.content.order.OrderService +import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository +import kr.co.vividnext.sodalive.event.EventItem +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.block.BlockMemberRepository +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service + +@Service +class AudioContentMainService( + private val repository: AudioContentRepository, + private val blockMemberRepository: BlockMemberRepository, + private val orderService: OrderService, + private val audioContentThemeRepository: AudioContentThemeQueryRepository, + + @Value("\${cloud.aws.cloud-front.host}") + private val imageHost: String +) { + fun getMain(member: Member): GetAudioContentMainResponse { + val isAdult = member.auth != null + + // 2주일 이내에 콘텐츠를 올린 크리에이터 20명 조회 + val newContentUploadCreatorList = repository.getNewContentUploadCreatorList( + cloudfrontHost = imageHost, + isAdult = isAdult + ) + .asSequence() + .filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.creatorId) } + .toList() + + val bannerList = repository.getAudioContentMainBannerList(isAdult = isAdult) + .asSequence() + .filter { + if (it.type == AudioContentBannerType.CREATOR && it.creator != null) { + !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.creator!!.id!!) + } else { + true + } + } + .map { + GetAudioContentBannerResponse( + type = it.type, + thumbnailImageUrl = "$imageHost/${it.thumbnailImage}", + eventItem = if (it.type == AudioContentBannerType.EVENT && it.event != null) { + EventItem( + id = it.event!!.id!!, + thumbnailImageUrl = if (!it.event!!.thumbnailImage.startsWith("https://")) { + "$imageHost/${it.event!!.thumbnailImage}" + } else { + it.event!!.thumbnailImage + }, + detailImageUrl = if ( + it.event!!.detailImage != null && + !it.event!!.detailImage!!.startsWith("https://") + ) { + "$imageHost/${it.event!!.detailImage}" + } else { + it.event!!.detailImage + }, + popupImageUrl = null, + link = it.event!!.link, + title = it.event!!.title, + isPopup = false + ) + } else { + null + }, + creatorId = if (it.type == AudioContentBannerType.CREATOR && it.creator != null) { + it.creator!!.id + } else { + null + }, + link = it.link + ) + } + .toList() + + // 구매목록 20개 + val orderList = orderService.getAudioContentMainOrderList( + member = member, + limit = 20 + ) + + // 콘텐츠 테마 + val themeList = audioContentThemeRepository.getActiveThemeOfContent(isAdult = isAdult) + + // 새 콘텐츠 20개 - 시간 내림차순 정렬 + val newContentList = repository.findByTheme( + cloudfrontHost = imageHost, + isAdult = isAdult + ) + .asSequence() + .filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.creatorId) } + .toList() + + val curationList = repository + .getAudioContentCurations(isAdult = isAdult) + .asSequence() + .map { + GetAudioContentCurationResponse( + title = it.title, + description = it.description, + contents = repository.findAudioContentByCurationId( + curationId = it.id!!, + cloudfrontHost = imageHost, + isAdult = isAdult + ) + .asSequence() + .filter { content -> + !blockMemberRepository.isBlocked( + blockedMemberId = member.id!!, + memberId = content.creatorId + ) + } + .toList() + ) + } + .filter { it.contents.isNotEmpty() } + .toList() + + return GetAudioContentMainResponse( + newContentUploadCreatorList = newContentUploadCreatorList, + bannerList = bannerList, + orderList = orderList, + themeList = themeList, + newContentList = newContentList, + curationList = curationList + ) + } + + fun getNewContentByTheme(theme: String, member: Member): List { + return repository.findByTheme( + cloudfrontHost = imageHost, + theme = theme, + isAdult = member.auth != null + ) + .asSequence() + .filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.creatorId) } + .toList() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/GetAudioContentMainItem.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/GetAudioContentMainItem.kt new file mode 100644 index 0000000..1ab49eb --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/GetAudioContentMainItem.kt @@ -0,0 +1,13 @@ +package kr.co.vividnext.sodalive.content.main + +import com.querydsl.core.annotations.QueryProjection + +data class GetAudioContentMainItem @QueryProjection constructor( + val contentId: Long, + val coverImageUrl: String, + val title: String, + val isAdult: Boolean, + val creatorId: Long, + val creatorProfileImageUrl: String, + val creatorNickname: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/GetAudioContentMainResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/GetAudioContentMainResponse.kt new file mode 100644 index 0000000..60b708a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/GetAudioContentMainResponse.kt @@ -0,0 +1,13 @@ +package kr.co.vividnext.sodalive.content.main + +import kr.co.vividnext.sodalive.content.main.banner.GetAudioContentBannerResponse +import kr.co.vividnext.sodalive.content.main.curation.GetAudioContentCurationResponse + +data class GetAudioContentMainResponse( + val newContentUploadCreatorList: List, + val bannerList: List, + val orderList: List, + val themeList: List, + val newContentList: List, + val curationList: List +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/GetNewContentUploadCreator.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/GetNewContentUploadCreator.kt new file mode 100644 index 0000000..55a50a7 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/GetNewContentUploadCreator.kt @@ -0,0 +1,9 @@ +package kr.co.vividnext.sodalive.content.main + +import com.querydsl.core.annotations.QueryProjection + +data class GetNewContentUploadCreator @QueryProjection constructor( + val creatorId: Long, + val creatorNickname: String, + val creatorProfileImageUrl: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBanner.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBanner.kt new file mode 100644 index 0000000..dd71d3b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBanner.kt @@ -0,0 +1,43 @@ +package kr.co.vividnext.sodalive.content.main.banner + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.event.Event +import kr.co.vividnext.sodalive.member.Member +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.EnumType +import javax.persistence.Enumerated +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.OneToOne +import javax.persistence.Table + +@Entity +@Table(name = "content_banner") +data class AudioContentBanner( + @Column(nullable = false) + var thumbnailImage: String = "", + @Enumerated(value = EnumType.STRING) + var type: AudioContentBannerType, + @Column(nullable = false) + var isAdult: Boolean = false, + @Column(nullable = false) + var isActive: Boolean = true, + @Column(nullable = false) + var orders: Int = 1 +) : BaseEntity() { + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "event_id", nullable = true) + var event: Event? = null + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "creator_id", nullable = true) + var creator: Member? = null + + @Column(nullable = true) + var link: String? = null +} + +enum class AudioContentBannerType { + EVENT, CREATOR, LINK +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/GetAudioContentBannerResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/GetAudioContentBannerResponse.kt new file mode 100644 index 0000000..31cc797 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/GetAudioContentBannerResponse.kt @@ -0,0 +1,11 @@ +package kr.co.vividnext.sodalive.content.main.banner + +import kr.co.vividnext.sodalive.event.EventItem + +data class GetAudioContentBannerResponse( + val type: AudioContentBannerType, + val thumbnailImageUrl: String, + val eventItem: EventItem?, + val creatorId: Long?, + val link: String? +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/curation/AudioContentCuration.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/curation/AudioContentCuration.kt new file mode 100644 index 0000000..83ec820 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/curation/AudioContentCuration.kt @@ -0,0 +1,26 @@ +package kr.co.vividnext.sodalive.content.main.curation + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.content.AudioContent +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.OneToMany +import javax.persistence.Table + +@Entity +@Table(name = "content_curation") +data class AudioContentCuration( + @Column(nullable = false) + var title: String, + @Column(nullable = false) + var description: String, + @Column(nullable = false) + var isAdult: Boolean = false, + @Column(nullable = false) + var isActive: Boolean = true, + @Column(nullable = false) + var orders: Int = 1 +) : BaseEntity() { + @OneToMany(mappedBy = "curation") + val audioContents: MutableList = mutableListOf() +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/curation/GetAudioContentCurationResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/curation/GetAudioContentCurationResponse.kt new file mode 100644 index 0000000..802e9e4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/curation/GetAudioContentCurationResponse.kt @@ -0,0 +1,9 @@ +package kr.co.vividnext.sodalive.content.main.curation + +import kr.co.vividnext.sodalive.content.main.GetAudioContentMainItem + +data class GetAudioContentCurationResponse( + val title: String, + val description: String, + val contents: List +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/order/GetAudioContentOrderListResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/GetAudioContentOrderListResponse.kt new file mode 100644 index 0000000..c762fae --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/GetAudioContentOrderListResponse.kt @@ -0,0 +1,20 @@ +package kr.co.vividnext.sodalive.content.order + +import com.querydsl.core.annotations.QueryProjection + +data class GetAudioContentOrderListResponse( + val totalCount: Int, + val items: List +) + +data class GetAudioContentOrderListItem @QueryProjection constructor( + val contentId: Long, + val coverImageUrl: String, + val title: String, + val themeStr: String, + val duration: String?, + val isAdult: Boolean, + val orderType: OrderType, + var likeCount: Int = 0, + var commentCount: Int = 0 +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/order/Order.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/Order.kt new file mode 100644 index 0000000..7b778cd --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/Order.kt @@ -0,0 +1,58 @@ +package kr.co.vividnext.sodalive.content.order + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.member.Member +import java.time.LocalDateTime +import javax.persistence.Entity +import javax.persistence.EnumType +import javax.persistence.Enumerated +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne +import javax.persistence.Table +import kotlin.math.ceil + +enum class OrderType { + RENTAL, KEEP +} + +@Entity +@Table(name = "orders") +data class Order( + @Enumerated(value = EnumType.STRING) + val type: OrderType, + var isActive: Boolean = true +) : BaseEntity() { + var can: Int = 0 + + val startDate: LocalDateTime = LocalDateTime.now() + var endDate: LocalDateTime? = null + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "creator_id", nullable = false) + var creator: Member? = null + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + var member: Member? = null + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id", nullable = false) + var audioContent: AudioContent? = null + set(value) { + can = if (type == OrderType.RENTAL) { + ceil(value?.price!! * 0.7).toInt() + } else { + value?.price!! + } + field = value + } + + override fun prePersist() { + super.prePersist() + if (type == OrderType.RENTAL) { + endDate = startDate.plusDays(7) + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderController.kt new file mode 100644 index 0000000..89c1e08 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderController.kt @@ -0,0 +1,49 @@ +package kr.co.vividnext.sodalive.content.order + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import org.springframework.data.domain.Pageable +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +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("/order") +class OrderController(private val service: OrderService) { + @PostMapping("/audio-content") + fun order( + @RequestBody request: OrderRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok( + service.order( + request.contentId, + request.orderType, + request.container, + member + ) + ) + } + + @GetMapping("/audio-content") + fun getAudioContentOrderList( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + pageable: Pageable + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok( + service.getAudioContentOrderList( + member = member, + offset = pageable.offset, + limit = pageable.pageSize.toLong() + ) + ) + } +} 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 new file mode 100644 index 0000000..b76ef6f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderRepository.kt @@ -0,0 +1,215 @@ +package kr.co.vividnext.sodalive.content.order + +import com.querydsl.core.types.dsl.Expressions +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.content.QAudioContent.audioContent +import kr.co.vividnext.sodalive.content.main.GetAudioContentMainItem +import kr.co.vividnext.sodalive.content.main.QGetAudioContentMainItem +import kr.co.vividnext.sodalive.content.order.QOrder.order +import kr.co.vividnext.sodalive.member.QMember.member +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import java.time.Duration +import java.time.LocalDateTime + +@Repository +interface OrderRepository : JpaRepository, OrderQueryRepository + +interface OrderQueryRepository { + fun isExistOrdered(memberId: Long, contentId: Long): Boolean + fun isExistOrderedAndOrderType(memberId: Long, contentId: Long): Pair + fun getAudioContentRemainingTime(memberId: Long, contentId: Long, timezone: String): String + fun getAudioContentOrderList( + dateTime: LocalDateTime, + coverImageHost: String, + memberId: Long, + offset: Long = 0, + limit: Long = 10 + ): List + + fun totalAudioContentOrderListCount(memberId: Long, dateTime: LocalDateTime): Int + fun getAudioContentMainOrderList( + dateTime: LocalDateTime, + coverImageHost: String, + memberId: Long, + offset: Long = 0, + limit: Long = 20 + ): List +} + +@Repository +class OrderQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : OrderQueryRepository { + override fun isExistOrdered(memberId: Long, contentId: Long): Boolean { + return queryFactory + .select(order.id) + .from(order) + .where( + order.member.id.eq(memberId) + .and(order.audioContent.id.eq(contentId)) + .and(order.isActive.isTrue) + .and( + order.type.eq(OrderType.KEEP) + .or( + order.type.eq(OrderType.RENTAL) + .and(order.endDate.after(LocalDateTime.now())) + ) + ) + ) + .fetch() + .isNotEmpty() + } + + override fun isExistOrderedAndOrderType(memberId: Long, contentId: Long): Pair { + val result = queryFactory + .select(order.type) + .from(order) + .where( + order.member.id.eq(memberId) + .and(order.audioContent.id.eq(contentId)) + .and(order.isActive.isTrue) + .and( + order.type.eq(OrderType.KEEP) + .or( + order.type.eq(OrderType.RENTAL) + .and(order.endDate.after(LocalDateTime.now())) + ) + ) + ) + .fetch() + + val isExist = result.isNotEmpty() + + return if (!isExist) { + Pair(false, null) + } else { + if (result.contains(OrderType.KEEP)) { + Pair(true, OrderType.KEEP) + } else { + Pair(true, OrderType.RENTAL) + } + } + } + + override fun getAudioContentRemainingTime(memberId: Long, contentId: Long, timezone: String): String { + val result = queryFactory + .select(order.endDate) + .from(order) + .where( + order.member.id.eq(memberId) + .and(order.audioContent.id.eq(contentId)) + .and(order.isActive.isTrue) + .and( + order.type.eq(OrderType.KEEP) + .or( + order.type.eq(OrderType.RENTAL) + .and(order.endDate.after(LocalDateTime.now())) + ) + ) + ) + .fetchFirst() ?: return "" + + val duration = Duration.between(LocalDateTime.now(), result) + + return duration.toHours().toString().padStart(2, '0') + "시간 " + + duration.toMinutesPart().toString().padStart(2, '0') + "분" + } + + override fun getAudioContentOrderList( + dateTime: LocalDateTime, + coverImageHost: String, + memberId: Long, + offset: Long, + limit: Long + ): List { + return queryFactory + .select( + QGetAudioContentOrderListItem( + audioContent.id, + audioContent.coverImage.prepend("/").prepend(coverImageHost), + audioContent.title, + audioContent.theme.theme, + audioContent.duration, + audioContent.isAdult, + order.type, + Expressions.ZERO, + Expressions.ZERO + ) + ) + .from(order) + .innerJoin(order.audioContent, audioContent) + .where( + order.member.id.eq(memberId) + .and( + order.type.eq(OrderType.KEEP) + .or( + order.type.eq(OrderType.RENTAL) + .and(order.startDate.before(dateTime)) + .and(order.endDate.after(dateTime)) + ) + ) + ) + .offset(offset) + .limit(limit) + .orderBy(order.createdAt.desc()) + .fetch() + } + + override fun totalAudioContentOrderListCount(memberId: Long, dateTime: LocalDateTime): Int { + return queryFactory.select(order.id) + .from(order) + .where( + order.member.id.eq(memberId) + .and( + order.type.eq(OrderType.KEEP) + .or( + order.type.eq(OrderType.RENTAL) + .and(order.startDate.before(dateTime)) + .and(order.endDate.after(dateTime)) + ) + ) + ) + .fetch() + .size + } + + override fun getAudioContentMainOrderList( + dateTime: LocalDateTime, + coverImageHost: String, + memberId: Long, + offset: Long, + limit: Long + ): List { + return queryFactory + .select( + QGetAudioContentMainItem( + audioContent.id, + audioContent.coverImage.prepend("/").prepend(coverImageHost), + audioContent.title, + audioContent.isAdult, + member.id, + member.profileImage.nullif("profile/default-profile.png") + .prepend("/") + .prepend(coverImageHost), + member.nickname + ) + ) + .from(order) + .innerJoin(order.audioContent, audioContent) + .innerJoin(audioContent.member, member) + .where( + order.member.id.eq(memberId) + .and( + order.type.eq(OrderType.KEEP) + .or( + order.type.eq(OrderType.RENTAL) + .and(order.startDate.before(dateTime)) + .and(order.endDate.after(dateTime)) + ) + ) + ) + .offset(offset) + .limit(limit) + .orderBy(order.createdAt.desc()) + .fetch() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderRequest.kt new file mode 100644 index 0000000..9fbc616 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderRequest.kt @@ -0,0 +1,3 @@ +package kr.co.vividnext.sodalive.content.order + +data class OrderRequest(val contentId: Long, val orderType: OrderType, val container: String) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderService.kt new file mode 100644 index 0000000..1112fcd --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderService.kt @@ -0,0 +1,99 @@ +package kr.co.vividnext.sodalive.content.order + +import kr.co.vividnext.sodalive.can.payment.CanPaymentService +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.content.AudioContentRepository +import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository +import kr.co.vividnext.sodalive.content.like.AudioContentLikeRepository +import kr.co.vividnext.sodalive.content.main.GetAudioContentMainItem +import kr.co.vividnext.sodalive.member.Member +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +@Transactional(readOnly = true) +class OrderService( + private val repository: OrderRepository, + private val coinPaymentService: CanPaymentService, + private val audioContentRepository: AudioContentRepository, + private val audioContentCommentQueryRepository: AudioContentCommentRepository, + private val audioContentLikeQueryRepository: AudioContentLikeRepository, + + @Value("\${cloud.aws.cloud-front.host}") + private val audioContentCoverImageHost: String +) { + @Transactional + fun order(contentId: Long, orderType: OrderType, container: String, member: Member) { + val order = Order(type = orderType) + val content = audioContentRepository.findByIdAndActive(contentId) + ?: throw SodaException("잘못된 콘텐츠 입니다\n다시 시도해 주세요.") + + if (member.id!! == content.member!!.id!!) throw SodaException("자신이 올린 콘텐츠는 구매할 수 없습니다.") + if (repository.isExistOrdered(memberId = member.id!!, contentId = contentId)) { + throw SodaException("이미 구매한 콘텐츠 입니다.") + } + + order.member = member + order.creator = content.member + order.audioContent = content + + repository.save(order) + + coinPaymentService.spendCan( + memberId = member.id!!, + needCan = order.can, + canUsage = CanUsage.ORDER_CONTENT, + order = order, + container = container + ) + } + + fun getAudioContentOrderList( + member: Member, + offset: Long, + limit: Long + ): GetAudioContentOrderListResponse { + val totalCount = repository.totalAudioContentOrderListCount( + memberId = member.id!!, + dateTime = LocalDateTime.now() + ) + val orderItems = repository.getAudioContentOrderList( + dateTime = LocalDateTime.now(), + coverImageHost = audioContentCoverImageHost, + memberId = member.id!!, + offset = offset, + limit = limit + ) + .asSequence() + .map { + val commentCount = audioContentCommentQueryRepository + .totalCountCommentByContentId(it.contentId) + + val likeCount = audioContentLikeQueryRepository + .totalCountAudioContentLike(it.contentId) + + it.commentCount = commentCount + it.likeCount = likeCount + it + } + .toList() + + return GetAudioContentOrderListResponse( + totalCount = totalCount, + items = orderItems + ) + } + + fun getAudioContentMainOrderList(member: Member, limit: Int): List { + return repository.getAudioContentMainOrderList( + dateTime = LocalDateTime.now(), + coverImageHost = audioContentCoverImageHost, + memberId = member.id!!, + offset = 0, + limit = 20 + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentTheme.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentTheme.kt new file mode 100644 index 0000000..621b444 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentTheme.kt @@ -0,0 +1,19 @@ +package kr.co.vividnext.sodalive.content.theme + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.Table + +@Entity +@Table(name = "content_theme") +data class AudioContentTheme( + @Column(nullable = false) + var theme: String, + @Column(nullable = false) + var image: String, + @Column(nullable = false) + var isActive: Boolean = true, + @Column(nullable = false) + var orders: Int = 1 +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeController.kt new file mode 100644 index 0000000..7b13530 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeController.kt @@ -0,0 +1,24 @@ +package kr.co.vividnext.sodalive.content.theme + +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.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/audio-content/theme") +@PreAuthorize("hasRole('CREATOR')") +class AudioContentThemeController(private val service: AudioContentThemeService) { + @GetMapping + fun getThemes( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.getThemes()) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeQueryRepository.kt new file mode 100644 index 0000000..e5cba07 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeQueryRepository.kt @@ -0,0 +1,57 @@ +package kr.co.vividnext.sodalive.content.theme + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.content.QAudioContent.audioContent +import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Repository + +@Repository +class AudioContentThemeQueryRepository( + private val queryFactory: JPAQueryFactory, + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) { + fun getActiveThemes(): List { + return queryFactory + .select( + QGetAudioContentThemeResponse( + audioContentTheme.id, + audioContentTheme.theme, + audioContentTheme.image.prepend("/").prepend(cloudFrontHost) + ) + ) + .from(audioContentTheme) + .where(audioContentTheme.isActive.isTrue) + .orderBy(audioContentTheme.orders.asc()) + .fetch() + } + + fun getActiveThemeOfContent(isAdult: Boolean = false): List { + var where = audioContent.isActive.isTrue + .and(audioContentTheme.isActive.isTrue) + + if (!isAdult) { + where = where.and(audioContent.isAdult.isFalse) + } + + return queryFactory + .select(audioContentTheme.theme) + .from(audioContent) + .innerJoin(audioContent.theme, audioContentTheme) + .where(where) + .groupBy(audioContentTheme.id) + .orderBy(audioContentTheme.orders.asc()) + .fetch() + } + + fun findThemeByIdAndActive(id: Long): AudioContentTheme? { + return queryFactory + .selectFrom(audioContentTheme) + .where( + audioContentTheme.id.eq(id) + .and(audioContentTheme.isActive.isTrue) + ) + .fetchFirst() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt new file mode 100644 index 0000000..f8b2fb0 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt @@ -0,0 +1,10 @@ +package kr.co.vividnext.sodalive.content.theme + +import org.springframework.stereotype.Service + +@Service +class AudioContentThemeService(private val queryRepository: AudioContentThemeQueryRepository) { + fun getThemes(): List { + return queryRepository.getActiveThemes() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/GetAudioContentThemeResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/GetAudioContentThemeResponse.kt new file mode 100644 index 0000000..e9e07c5 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/GetAudioContentThemeResponse.kt @@ -0,0 +1,9 @@ +package kr.co.vividnext.sodalive.content.theme + +import com.querydsl.core.annotations.QueryProjection + +data class GetAudioContentThemeResponse @QueryProjection constructor( + val id: Long, + val theme: String, + val image: String +) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4dfbeac..c33f8dc 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -26,7 +26,12 @@ cloud: accessKey: ${APP_AWS_ACCESS_KEY} secretKey: ${APP_AWS_SECRET_KEY} s3: + contentBucket: ${S3_CONTENT_BUCKET} bucket: ${S3_BUCKET} + contentCloudFront: + host: ${CONTENT_CLOUD_FRONT_HOST} + privateKeyFilePath: ${CONTENT_CLOUD_FRONT_PRIVATE_KEY_FILE_PATH} + keyPairId: ${CONTENT_CLOUD_FRONT_KEY_PAIR_ID} cloudFront: host: ${CLOUD_FRONT_HOST} region: