콘텐츠 API 추가

This commit is contained in:
Klaus 2023-08-03 20:36:37 +09:00
parent 5d6eb5da4f
commit 1fe5309fdc
55 changed files with 2740 additions and 0 deletions

View File

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

View File

@ -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("잘못된 요청입니다.")
}

View File

@ -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<UseCanCalculate> = mutableListOf()
}

View File

@ -0,0 +1,12 @@
package kr.co.vividnext.sodalive.content
data class AddAllPlaybackTrackingRequest(
val timezone: String,
val trackingDataList: List<PlaybackTrackingData>
)
data class PlaybackTrackingData(
val contentId: Long,
val playDateTime: String,
val isPreview: Boolean
)

View File

@ -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<AudioContentHashTag> = mutableListOf()
@OneToMany(mappedBy = "child", cascade = [CascadeType.ALL])
var children: MutableList<BundleAudioContent> = mutableListOf()
}

View File

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

View File

@ -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<AudioContent, Long>, AudioContentQueryRepository
interface AudioContentQueryRepository {
fun findByIdAndActive(contentId: Long): AudioContent?
fun findByIdAndCreatorId(contentId: Long, creatorId: Long): AudioContent?
fun findBundleByContentId(contentId: Long): List<AudioContent>
fun findByCreatorId(
creatorId: Long,
isAdult: Boolean = false,
sortType: SortType = SortType.NEWEST,
offset: Long = 0,
limit: Long = 10
): List<AudioContent>
fun findTotalCountByCreatorId(creatorId: Long, isAdult: Boolean = false): Int
fun getCreatorOtherContentList(
cloudfrontHost: String,
contentId: Long,
creatorId: Long,
isAdult: Boolean
): List<OtherContentResponse>
fun getSameThemeOtherContentList(
cloudfrontHost: String,
contentId: Long,
themeId: Long,
isAdult: Boolean
): List<OtherContentResponse>
fun findByTheme(
cloudfrontHost: String,
theme: String = "",
isAdult: Boolean = false,
limit: Long = 20
): List<GetAudioContentMainItem>
fun getNewContentUploadCreatorList(
cloudfrontHost: String,
isAdult: Boolean = false
): List<GetNewContentUploadCreator>
fun getAudioContentMainBannerList(isAdult: Boolean): List<AudioContentBanner>
fun getAudioContentCurations(isAdult: Boolean): List<AudioContentCuration>
fun findAudioContentByCurationId(
curationId: Long,
cloudfrontHost: String,
isAdult: Boolean
): List<GetAudioContentMainItem>
}
@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<AudioContent> {
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<AudioContent> {
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<OtherContentResponse> {
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<OtherContentResponse> {
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<GetAudioContentMainItem> {
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<GetNewContentUploadCreator> {
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<AudioContentBanner> {
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<AudioContentCuration> {
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<GetAudioContentMainItem> {
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()
}
}

View File

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

View File

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

View File

@ -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<Long>? = null
)

View File

@ -0,0 +1,3 @@
package kr.co.vividnext.sodalive.content
data class CreateAudioContentResponse(val contentId: Long)

View File

@ -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<OtherContentResponse>,
val sameThemeOtherContentList: List<OtherContentResponse>,
val isCommentAvailable: Boolean,
val isLike: Boolean,
val likeCount: Int,
val commentList: List<GetAudioContentCommentListItem>,
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
)

View File

@ -0,0 +1,18 @@
package kr.co.vividnext.sodalive.content
data class GetAudioContentListResponse(
val totalCount: Int,
val items: List<GetAudioContentListItem>
)
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
)

View File

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

View File

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

View File

@ -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<PlaybackTracking, Long>

View File

@ -0,0 +1,3 @@
package kr.co.vividnext.sodalive.content
data class UploadCompleteRequest(val contentId: Long, val contentPath: String, val duration: String)

View File

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

View File

@ -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<GetAudioContentCommentListResponse> {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
return ApiResponse.ok(
service.getCommentReplyList(
commentId = commentId,
timezone = timezone,
pageable = pageable
)
)
}
}

View File

@ -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<AudioContentComment, Long>, AudioContentCommentQueryRepository
interface AudioContentCommentQueryRepository {
fun findByContentId(
cloudFrontHost: String,
contentId: Long,
timezone: String,
offset: Long,
limit: Int
): List<GetAudioContentCommentListItem>
fun totalCountCommentByContentId(contentId: Long): Int
fun commentReplyCountByAudioContentCommentId(commentId: Long): Int
fun getAudioContentCommentReplyList(
cloudFrontHost: String,
commentId: Long,
timezone: String,
offset: Long,
limit: Int
): List<GetAudioContentCommentListItem>
}
@Repository
class AudioContentCommentQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory
) : AudioContentCommentQueryRepository {
override fun findByContentId(
cloudFrontHost: String,
contentId: Long,
timezone: String,
offset: Long,
limit: Int
): List<GetAudioContentCommentListItem> {
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<GetAudioContentCommentListItem> {
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()
}
}

View File

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

View File

@ -0,0 +1,17 @@
package kr.co.vividnext.sodalive.content.comment
data class GetAudioContentCommentListResponse(
val totalCount: Int,
val items: List<GetAudioContentCommentListItem>
)
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
)

View File

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

View File

@ -0,0 +1,3 @@
package kr.co.vividnext.sodalive.content.comment
data class RegisterCommentRequest(val comment: String, val contentId: Long, val parentId: Long?)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<HashTag, Long>, 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()
}
}

View File

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

View File

@ -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<AudioContentLike, Long>, 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
}
}

View File

@ -0,0 +1,3 @@
package kr.co.vividnext.sodalive.content.like
data class PutAudioContentLikeRequest(val contentId: Long)

View File

@ -0,0 +1,3 @@
package kr.co.vividnext.sodalive.content.like
data class PutAudioContentLikeResponse(val like: Boolean)

View File

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

View File

@ -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<GetAudioContentMainItem> {
return repository.findByTheme(
cloudfrontHost = imageHost,
theme = theme,
isAdult = member.auth != null
)
.asSequence()
.filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.creatorId) }
.toList()
}
}

View File

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

View File

@ -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<GetNewContentUploadCreator>,
val bannerList: List<GetAudioContentBannerResponse>,
val orderList: List<GetAudioContentMainItem>,
val themeList: List<String>,
val newContentList: List<GetAudioContentMainItem>,
val curationList: List<GetAudioContentCurationResponse>
)

View File

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

View File

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

View File

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

View File

@ -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<AudioContent> = mutableListOf()
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Order, Long>, OrderQueryRepository
interface OrderQueryRepository {
fun isExistOrdered(memberId: Long, contentId: Long): Boolean
fun isExistOrderedAndOrderType(memberId: Long, contentId: Long): Pair<Boolean, OrderType?>
fun getAudioContentRemainingTime(memberId: Long, contentId: Long, timezone: String): String
fun getAudioContentOrderList(
dateTime: LocalDateTime,
coverImageHost: String,
memberId: Long,
offset: Long = 0,
limit: Long = 10
): List<GetAudioContentOrderListItem>
fun totalAudioContentOrderListCount(memberId: Long, dateTime: LocalDateTime): Int
fun getAudioContentMainOrderList(
dateTime: LocalDateTime,
coverImageHost: String,
memberId: Long,
offset: Long = 0,
limit: Long = 20
): List<GetAudioContentMainItem>
}
@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<Boolean, OrderType?> {
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<GetAudioContentOrderListItem> {
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<GetAudioContentMainItem> {
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()
}
}

View File

@ -0,0 +1,3 @@
package kr.co.vividnext.sodalive.content.order
data class OrderRequest(val contentId: Long, val orderType: OrderType, val container: String)

View File

@ -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<GetAudioContentMainItem> {
return repository.getAudioContentMainOrderList(
dateTime = LocalDateTime.now(),
coverImageHost = audioContentCoverImageHost,
memberId = member.id!!,
offset = 0,
limit = 20
)
}
}

View File

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

View File

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

View File

@ -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<GetAudioContentThemeResponse> {
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<String> {
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()
}
}

View File

@ -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<GetAudioContentThemeResponse> {
return queryRepository.getActiveThemes()
}
}

View File

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

View File

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