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.main.GetAudioContentRanking 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.extensions.convertLocalDateTime import kr.co.vividnext.sodalive.fcm.FcmEvent import kr.co.vividnext.sodalive.fcm.FcmEventType 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.cache.annotation.Cacheable import org.springframework.context.ApplicationEventPublisher 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.text.SimpleDateFormat import java.time.LocalDateTime import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Locale @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, private val applicationEventPublisher: ApplicationEventPublisher, @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!!) val audioContent = repository.findByIdAndActive(request.contentId) audioContentLike.audioContent = audioContent 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 audioContent.releaseDate = null } @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) // 미리듣기 시간 체크 validatePreviewTime(request.previewStartTime, request.previewEndTime) val releaseDate = if (request.releaseDate != null) { request.releaseDate.convertLocalDateTime("yyyy-MM-dd HH:mm") .atZone(ZoneId.of(request.timezone)) .withZoneSameInstant(ZoneId.of("UTC")) .toLocalDateTime() } else { null } // 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..4) throw SodaException("콘텐츠의 최소금액은 5캔 입니다.") // DB에 값 추가 val audioContent = AudioContent( title = request.title, detail = request.detail, type = request.type, price = if (request.price > 0) { request.price } else { 0 }, releaseDate = releaseDate, isAdult = request.isAdult, isGeneratePreview = if (request.type == AudioContentType.INDIVIDUAL) { request.isGeneratePreview } else { false }, isOnlyRental = request.isOnlyRental, 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") if (request.previewStartTime != null && request.previewEndTime != null) { metadata.addUserMetadata("preview_start_time", request.previewStartTime) metadata.addUserMetadata("preview_end_time", request.previewEndTime) } 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!!) } private fun validatePreviewTime(previewStartTime: String?, previewEndTime: String?) { if (previewStartTime != null && previewEndTime != null) { val startTimeArray = previewStartTime.split(":") if (startTimeArray.size != 3) { throw SodaException("미리 듣기 시간 형식은 00:30:00 과 같아야 합니다") } for (time in startTimeArray) { if (time.length != 2) { throw SodaException("미리 듣기 시간 형식은 00:30:00 과 같아야 합니다") } } val endTimeArray = previewEndTime.split(":") if (endTimeArray.size != 3) { throw SodaException("미리 듣기 시간 형식은 00:30:00 과 같아야 합니다") } for (time in endTimeArray) { if (time.length != 2) { throw SodaException("미리 듣기 시간 형식은 00:30:00 과 같아야 합니다") } } val timeDifference = timeDifference(previewStartTime, previewEndTime) if (timeDifference < 30000) { throw SodaException("미리 듣기의 최소 시간은 30초 입니다.") } } else { if (previewStartTime != null || previewEndTime != null) { throw SodaException("미리 듣기 시작 시간과 종료 시간 둘 다 입력을 하거나 둘 다 입력 하지 않아야 합니다.") } } } private fun timeDifference(startTime: String, endTime: String): Long { try { // Define a date format for parsing the times val dateFormat = SimpleDateFormat("HH:mm:ss", Locale.KOREAN) // Parse the input times into Date objects val date1 = dateFormat.parse(startTime) val date2 = dateFormat.parse(endTime) // Check if either date is null if (date1 == null || date2 == null) { return 0 } // Check if the time difference is greater than 30 seconds (30000 milliseconds) return date2.time - date1.time } catch (e: Exception) { // Handle invalid time formats or parsing errors return 0 } } @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.content = content audioContent.duration = duration applicationEventPublisher.publishEvent( FcmEvent( type = FcmEventType.INDIVIDUAL, title = "콘텐츠 등록완료", message = audioContent.title, recipients = listOf(audioContent.member!!.id!!), isAuth = null, contentId = contentId ) ) if (audioContent.releaseDate == null) { audioContent.isActive = true applicationEventPublisher.publishEvent( FcmEvent( type = FcmEventType.UPLOAD_CONTENT, title = audioContent.member!!.nickname, message = "콘텐츠를 업로드 하였습니다. - ${audioContent.title}", isAuth = audioContent.isAdult, contentId = contentId, creatorId = audioContent.member!!.id, container = "ios" ) ) applicationEventPublisher.publishEvent( FcmEvent( type = FcmEventType.UPLOAD_CONTENT, title = audioContent.member!!.nickname, message = "콘텐츠를 업로드 하였습니다. - ${audioContent.title}", isAuth = audioContent.isAdult, contentId = contentId, creatorId = audioContent.member!!.id, container = "aos" ) ) } } @Transactional fun releaseContent() { val contentIdList = repository.getNotReleaseContentId() for (contentId in contentIdList) { val audioContent = repository.findByIdOrNull(contentId) ?: throw SodaException("잘못된 요청입니다.") audioContent.isActive = true applicationEventPublisher.publishEvent( FcmEvent( type = FcmEventType.UPLOAD_CONTENT, title = audioContent.member!!.nickname, message = "콘텐츠를 업로드 하였습니다. - ${audioContent.title}", isAuth = audioContent.isAdult, contentId = contentId, creatorId = audioContent.member!!.id, container = "ios" ) ) applicationEventPublisher.publishEvent( FcmEvent( type = FcmEventType.UPLOAD_CONTENT, title = audioContent.member!!.nickname, message = "콘텐츠를 업로드 하였습니다. - ${audioContent.title}", isAuth = audioContent.isAdult, contentId = contentId, creatorId = audioContent.member!!.id, container = "aos" ) ) } } 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.getMember(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 && audioContent.releaseDate == null ) { 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 releaseDate = if ( audioContent.releaseDate != null && audioContent.releaseDate!! >= LocalDateTime.now() ) { audioContent.releaseDate!! .atZone(ZoneId.of("UTC")) .withZoneSameInstant(ZoneId.of("Asia/Seoul")) .toLocalDateTime() .format(DateTimeFormatter.ofPattern("yyyy년 MM월 dd일 HH시 mm분 오픈예정")) } else { null } val audioContentUrl = if ( audioContent.releaseDate == null || audioContent.releaseDate!! <= LocalDateTime.now() || creatorId == member.id!! ) { 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) ) } else { "" } 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 ?: "", releaseDate = releaseDate, isAdult = audioContent.isAdult, isMosaic = audioContent.isAdult && member.auth == null, isOnlyRental = audioContent.isOnlyRental, 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, isScheduledToOpen = it.releaseDate != null && it.releaseDate!! > LocalDateTime.now() ) } 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 ) ) } } @Transactional(readOnly = true) @Cacheable( cacheNames = ["cache_ttl_3_days"], key = "'contentRanking:' + ':' +" + "#isAdult + ':' + #startDate + ':' + #endDate + ':' + #sortType + ':' + #offset + ':' + #limit" ) fun getAudioContentRanking( isAdult: Boolean, startDate: LocalDateTime, endDate: LocalDateTime, offset: Long, limit: Long, sortType: String = "매출" ): GetAudioContentRanking { val startDateFormatter = DateTimeFormatter.ofPattern("yyyy년 MM월 dd일") val endDateFormatter = DateTimeFormatter.ofPattern("MM월 dd일") val contentRankingItemList = repository .getAudioContentRanking( cloudfrontHost = coverImageHost, startDate = startDate.minusDays(1), endDate = endDate.minusDays(1), isAdult = isAdult, offset = offset, limit = limit, sortType = sortType ) return GetAudioContentRanking( startDate = startDate.format(startDateFormatter), endDate = endDate.minusDays(1).format(endDateFormatter), items = contentRankingItemList ) } fun getContentRankingSortTypeList(): List { return listOf("매출", "댓글", "좋아요") } }