sodalive-backend-spring-boot/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt

617 lines
24 KiB
Kotlin

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.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.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.DayOfWeek
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.temporal.TemporalAdjusters
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!!,
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)
// 미리듣기 시간 체크
validatePreviewTime(request.previewStartTime, request.previewEndTime)
// 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
},
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.isActive = true
audioContent.content = content
audioContent.duration = duration
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.INDIVIDUAL,
title = "콘텐츠 등록완료",
message = audioContent.title,
recipients = listOf(audioContent.member!!.id!!),
isAuth = audioContent.isAdult,
contentId = contentId
)
)
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) {
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,
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
)
}
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
)
)
}
}
fun getAudioContentRanking(
member: Member,
offset: Long,
limit: Long
): GetAudioContentRanking {
val currentDateTime = LocalDateTime.now()
val startDate = currentDateTime
.withHour(15)
.withMinute(0)
.withSecond(0)
.minusWeeks(1)
.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
val endDate = startDate
.plusDays(7)
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 = member.auth != null,
offset = offset,
limit = limit
)
return GetAudioContentRanking(
startDate = startDate.format(startDateFormatter),
endDate = endDate.minusDays(1).format(endDateFormatter),
items = contentRankingItemList
)
}
}