diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepository.kt index 47c243b..e7b6e54 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepository.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.admin.calculate +import com.querydsl.core.types.dsl.CaseBuilder import com.querydsl.core.types.dsl.DateTimePath import com.querydsl.core.types.dsl.Expressions import com.querydsl.core.types.dsl.StringTemplate @@ -51,6 +52,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { fun getCalculateContentList(startDate: LocalDateTime, endDate: LocalDateTime): List { val orderFormattedDate = getFormattedDate(order.createdAt) + val pointGroup = CaseBuilder() + .`when`(order.point.loe(0)).then(0) + .otherwise(1) + return queryFactory .select( QGetCalculateContentQueryData( @@ -62,6 +67,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { order.can, order.id.count(), order.can.sum(), + order.point.sum(), creatorSettlementRatio.contentSettlementRatio ) ) @@ -80,6 +86,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { order.type, orderFormattedDate, order.can, + pointGroup, creatorSettlementRatio.contentSettlementRatio ) .orderBy(member.id.desc(), orderFormattedDate.desc(), audioContent.id.asc()) @@ -113,6 +120,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { } fun getCumulativeSalesByContent(offset: Long, limit: Long): List { + val pointGroup = CaseBuilder() + .`when`(order.point.loe(0)).then(0) + .otherwise(1) + return queryFactory .select( QGetCumulativeSalesByContentQueryData( @@ -123,6 +134,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { order.can, order.id.count(), order.can.sum(), + order.point.sum(), creatorSettlementRatio.contentSettlementRatio ) ) @@ -132,7 +144,14 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { .leftJoin(creatorSettlementRatio) .on(member.id.eq(creatorSettlementRatio.member.id)) .where(order.isActive.isTrue) - .groupBy(member.id, audioContent.id, order.type, order.can) + .groupBy( + member.id, + audioContent.id, + order.type, + order.can, + pointGroup, + creatorSettlementRatio.contentSettlementRatio + ) .offset(offset) .limit(limit) .orderBy(member.id.desc(), audioContent.id.desc()) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateContentQueryData.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateContentQueryData.kt index cf0a0c6..b3a60a4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateContentQueryData.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateContentQueryData.kt @@ -22,11 +22,15 @@ data class GetCalculateContentQueryData @QueryProjection constructor( val numberOfPeople: Long, // 합계 val totalCan: Int, + // 포인트 + val totalPoint: Int, // 정산비율 val settlementRatio: Int? ) { fun toGetCalculateContentResponse(): GetCalculateContentResponse { - val orderTypeStr = if (orderType == OrderType.RENTAL) { + val orderTypeStr = if (totalPoint > 0) { + "포인트" + } else if (orderType == OrderType.RENTAL) { "대여" } else { "소장" diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCumulativeSalesByContentResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCumulativeSalesByContentResponse.kt index 5c49858..a003a78 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCumulativeSalesByContentResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCumulativeSalesByContentResponse.kt @@ -21,11 +21,15 @@ data class GetCumulativeSalesByContentQueryData @QueryProjection constructor( val numberOfPeople: Long, // 합계 val totalCan: Int, + // 포인트 + val totalPoint: Int, // 정산비율 val settlementRatio: Int? ) { fun toCumulativeSalesByContentItem(): CumulativeSalesByContentItem { - val orderTypeStr = if (orderType == OrderType.RENTAL) { + val orderTypeStr = if (totalPoint > 0) { + "포인트" + } else if (orderType == OrderType.RENTAL) { "대여" } else { "소장" diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/point/CreatePointRewardPolicyRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/point/CreatePointRewardPolicyRequest.kt new file mode 100644 index 0000000..4c2cf73 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/point/CreatePointRewardPolicyRequest.kt @@ -0,0 +1,40 @@ +package kr.co.vividnext.sodalive.admin.point + +import kr.co.vividnext.sodalive.point.PointRewardPolicy +import kr.co.vividnext.sodalive.useraction.ActionType +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +data class CreatePointRewardPolicyRequest( + val title: String, + val actionType: ActionType, + val threshold: Int, + val pointAmount: Int, + val startDate: String, + val endDate: String +) { + fun toEntity(): PointRewardPolicy { + val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") + + return PointRewardPolicy( + title = title, + actionType = actionType, + threshold = threshold, + pointAmount = pointAmount, + startDate = LocalDateTime.parse(startDate, dateTimeFormatter) + .atZone(ZoneId.of("Asia/Seoul")) + .withZoneSameInstant(ZoneId.of("UTC")) + .toLocalDateTime(), + endDate = if (endDate.isNotBlank()) { + LocalDateTime.parse(endDate, dateTimeFormatter).withSecond(59) + .atZone(ZoneId.of("Asia/Seoul")) + .withZoneSameInstant(ZoneId.of("UTC")) + .toLocalDateTime() + } else { + null + }, + isActive = true + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/point/GetPointRewardPolicyListResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/point/GetPointRewardPolicyListResponse.kt new file mode 100644 index 0000000..33dd64e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/point/GetPointRewardPolicyListResponse.kt @@ -0,0 +1,20 @@ +package kr.co.vividnext.sodalive.admin.point + +import com.querydsl.core.annotations.QueryProjection +import kr.co.vividnext.sodalive.useraction.ActionType + +data class GetPointRewardPolicyListResponse( + val totalCount: Int, + val items: List +) + +data class GetPointRewardPolicyListItem @QueryProjection constructor( + val id: Long, + val title: String, + val actionType: ActionType, + val threshold: Int, + val pointAmount: Int, + val startDate: String, + val endDate: String, + val isActive: Boolean +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/point/ModifyPointRewardPolicyRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/point/ModifyPointRewardPolicyRequest.kt new file mode 100644 index 0000000..3a4bc11 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/point/ModifyPointRewardPolicyRequest.kt @@ -0,0 +1,8 @@ +package kr.co.vividnext.sodalive.admin.point + +data class ModifyPointRewardPolicyRequest( + val title: String?, + val startDate: String?, + val endDate: String?, + val isActive: Boolean? +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/point/PointPolicyController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/point/PointPolicyController.kt new file mode 100644 index 0000000..12d4dc9 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/point/PointPolicyController.kt @@ -0,0 +1,36 @@ +package kr.co.vividnext.sodalive.admin.point + +import kr.co.vividnext.sodalive.common.ApiResponse +import org.springframework.data.domain.Pageable +import org.springframework.security.access.prepost.PreAuthorize +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.RestController + +@RestController +@RequestMapping("/admin/point-policies") +@PreAuthorize("hasRole('ADMIN')") +class PointPolicyController(private val service: PointPolicyService) { + @GetMapping + fun getAll(pageable: Pageable) = ApiResponse.ok( + service.getAll( + offset = pageable.offset, + limit = pageable.pageSize.toLong() + ) + ) + + @PostMapping + fun create( + @RequestBody request: CreatePointRewardPolicyRequest + ) = ApiResponse.ok(service.create(request)) + + @PutMapping("/{id}") + fun update( + @PathVariable id: Long, + @RequestBody request: ModifyPointRewardPolicyRequest + ) = ApiResponse.ok(service.update(id, request)) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/point/PointPolicyRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/point/PointPolicyRepository.kt new file mode 100644 index 0000000..2cd63db --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/point/PointPolicyRepository.kt @@ -0,0 +1,57 @@ +package kr.co.vividnext.sodalive.admin.point + +import com.querydsl.core.types.dsl.DateTimePath +import com.querydsl.core.types.dsl.Expressions +import com.querydsl.core.types.dsl.StringTemplate +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.point.PointRewardPolicy +import kr.co.vividnext.sodalive.point.QPointRewardPolicy.pointRewardPolicy +import org.springframework.data.jpa.repository.JpaRepository +import java.time.LocalDateTime + +interface PointPolicyRepository : JpaRepository, PointPolicyQueryRepository + +interface PointPolicyQueryRepository { + fun getTotalCount(): Int + fun getAll(offset: Long, limit: Long): List +} + +class PointPolicyQueryRepositoryImpl( + private val queryFactory: JPAQueryFactory +) : PointPolicyQueryRepository { + override fun getTotalCount(): Int { + return queryFactory + .select(pointRewardPolicy.id) + .from(pointRewardPolicy) + .fetch() + .size + } + + override fun getAll(offset: Long, limit: Long): List { + return queryFactory + .select( + QGetPointRewardPolicyListItem( + pointRewardPolicy.id, + pointRewardPolicy.title, + pointRewardPolicy.actionType, + pointRewardPolicy.threshold, + pointRewardPolicy.pointAmount, + getFormattedDate(pointRewardPolicy.startDate), + getFormattedDate(pointRewardPolicy.endDate), + pointRewardPolicy.isActive + ) + ) + .from(pointRewardPolicy) + .orderBy(pointRewardPolicy.isActive.desc(), pointRewardPolicy.startDate.desc()) + .offset(offset) + .limit(limit) + .fetch() + } + + private fun getFormattedDate(dateTimePath: DateTimePath): StringTemplate { + return Expressions.stringTemplate( + "COALESCE(DATE_FORMAT(CONVERT_TZ({0}, 'UTC', 'Asia/Seoul'), '%Y-%m-%d %H:%i'), '')", + dateTimePath + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/point/PointPolicyService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/point/PointPolicyService.kt new file mode 100644 index 0000000..c179114 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/point/PointPolicyService.kt @@ -0,0 +1,55 @@ +package kr.co.vividnext.sodalive.admin.point + +import kr.co.vividnext.sodalive.common.SodaException +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +@Service +class PointPolicyService(private val repository: PointPolicyRepository) { + fun getAll(offset: Long, limit: Long): GetPointRewardPolicyListResponse { + val totalCount = repository.getTotalCount() + val items = repository.getAll(offset, limit) + + return GetPointRewardPolicyListResponse(totalCount, items) + } + + @Transactional + fun create(request: CreatePointRewardPolicyRequest) { + val pointPolicy = request.toEntity() + repository.save(pointPolicy) + } + + @Transactional + fun update(id: Long, request: ModifyPointRewardPolicyRequest) { + val pointPolicy = repository.findByIdOrNull(id) + ?: throw SodaException("잘못된 접근입니다.") + + val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") + + if (request.title != null) { + pointPolicy.title = request.title + } + + if (request.startDate != null) { + pointPolicy.startDate = LocalDateTime.parse(request.startDate, dateTimeFormatter) + .atZone(ZoneId.of("Asia/Seoul")) + .withZoneSameInstant(ZoneId.of("UTC")) + .toLocalDateTime() + } + + if (request.endDate != null) { + pointPolicy.endDate = LocalDateTime.parse(request.endDate, dateTimeFormatter).withSecond(59) + .atZone(ZoneId.of("Asia/Seoul")) + .withZoneSameInstant(ZoneId.of("UTC")) + .toLocalDateTime() + } + + if (request.isActive != null) { + pointPolicy.isActive = request.isActive + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/audition/applicant/AuditionApplicantRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/audition/applicant/AuditionApplicantRepository.kt index 92af3f3..7d56c8a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/audition/applicant/AuditionApplicantRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/audition/applicant/AuditionApplicantRepository.kt @@ -49,10 +49,12 @@ class AuditionApplicantQueryRepositoryImpl( return queryFactory .select(auditionApplicant.id) .from(auditionApplicant) + .innerJoin(auditionApplicant.member, member) .innerJoin(auditionApplicant.role, auditionRole) .where( auditionRole.id.eq(auditionRoleId), - auditionApplicant.isActive.isTrue + auditionApplicant.isActive.isTrue, + member.isActive.isTrue ) .fetch() .size @@ -87,7 +89,8 @@ class AuditionApplicantQueryRepositoryImpl( .leftJoin(auditionVote).on(auditionApplicant.id.eq(auditionVote.applicant.id)) .where( auditionRole.id.eq(auditionRoleId), - auditionApplicant.isActive.isTrue + auditionApplicant.isActive.isTrue, + member.isActive.isTrue ) .groupBy(auditionApplicant.id) .orderBy(orderBy) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt index 00d3eb3..f65ece2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt @@ -42,6 +42,7 @@ data class AudioContent( val isGeneratePreview: Boolean = true, var isOnlyRental: Boolean = false, var isAdult: Boolean = false, + var isPointAvailable: Boolean = false, var isCommentAvailable: Boolean = true, var isFullDetailVisible: Boolean = true ) : BaseEntity() { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt index 7717c1e..a8b25f4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt @@ -214,6 +214,7 @@ class AudioContentService( purchaseOption = purchaseOption, isGeneratePreview = request.isGeneratePreview, isOnlyRental = isOnlyRental, + isPointAvailable = request.isPointAvailable, isCommentAvailable = request.isCommentAvailable, isFullDetailVisible = isFullDetailVisible ) @@ -707,7 +708,8 @@ class AudioContentService( ), previousContent = previousContent, nextContent = nextContent, - buyerList = buyerList + buyerList = buyerList, + isAvailableUsePoint = audioContent.isPointAvailable ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/CreateAudioContentRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/CreateAudioContentRequest.kt index 08ae03c..2eacb00 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/CreateAudioContentRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/CreateAudioContentRequest.kt @@ -13,6 +13,7 @@ data class CreateAudioContentRequest( val isAdult: Boolean = false, val isGeneratePreview: Boolean = false, val isOnlyRental: Boolean = false, + val isPointAvailable: Boolean = false, val isCommentAvailable: Boolean = false, val isFullDetailVisible: Boolean = true, val previewStartTime: String? = null, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/GetAudioContentDetailResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/GetAudioContentDetailResponse.kt index 4eba473..9149ce2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/GetAudioContentDetailResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/GetAudioContentDetailResponse.kt @@ -38,7 +38,8 @@ data class GetAudioContentDetailResponse( val creator: AudioContentCreator, val previousContent: OtherContentResponse?, val nextContent: OtherContentResponse?, - val buyerList: List + val buyerList: List, + val isAvailableUsePoint: Boolean ) data class OtherContentResponse @QueryProjection constructor( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/order/Order.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/Order.kt index eb59754..391908b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/order/Order.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/Order.kt @@ -25,6 +25,7 @@ data class Order( var isActive: Boolean = true ) : BaseEntity() { var can: Int = 0 + var point: Int = 0 val startDate: LocalDateTime = LocalDateTime.now() var endDate: LocalDateTime? = null diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderService.kt index 630b22a..255da15 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderService.kt @@ -10,6 +10,7 @@ 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 kr.co.vividnext.sodalive.point.PointUsageService import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -19,6 +20,7 @@ import java.time.LocalDateTime @Transactional(readOnly = true) class OrderService( private val repository: OrderRepository, + private val pointUsageService: PointUsageService, private val canPaymentService: CanPaymentService, private val audioContentRepository: AudioContentRepository, private val audioContentCommentQueryRepository: AudioContentCommentRepository, @@ -41,13 +43,27 @@ class OrderService( orderContent(orderType, content, member) } - canPaymentService.spendCan( - memberId = member.id!!, - needCan = order.can, - canUsage = CanUsage.ORDER_CONTENT, - order = order, - container = container - ) + val usedPoint = if (order.type == OrderType.RENTAL && content.isPointAvailable) { + pointUsageService.usePoint(member.id!!, order.can) + } else { + 0 + } + order.point = usedPoint + + val remainingCan = order.can - (usedPoint / 10) + if (order.type == OrderType.RENTAL && content.isPointAvailable && usedPoint > 0) { + order.can = remainingCan + } + + if (remainingCan > 0) { + canPaymentService.spendCan( + memberId = member.id!!, + needCan = remainingCan, + canUsage = CanUsage.ORDER_CONTENT, + order = order, + container = container + ) + } } private fun orderContent(orderType: OrderType, content: AudioContent, member: Member): Order { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/CreatorAdminCalculateQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/CreatorAdminCalculateQueryRepository.kt index bae92bc..f9b4289 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/CreatorAdminCalculateQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/CreatorAdminCalculateQueryRepository.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.creator.admin.calculate +import com.querydsl.core.types.dsl.CaseBuilder import com.querydsl.core.types.dsl.DateTimePath import com.querydsl.core.types.dsl.Expressions import com.querydsl.core.types.dsl.StringTemplate @@ -95,6 +96,10 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac limit: Long ): List { val orderFormattedDate = getFormattedDate(order.createdAt) + val pointGroup = CaseBuilder() + .`when`(order.point.loe(0)).then(0) + .otherwise(1) + return queryFactory .select( QGetCalculateContentQueryData( @@ -106,6 +111,7 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac order.can, order.id.count(), order.can.sum(), + order.point.sum(), creatorSettlementRatio.contentSettlementRatio ) ) @@ -125,6 +131,7 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac order.type, orderFormattedDate, order.can, + pointGroup, creatorSettlementRatio.contentSettlementRatio ) .offset(offset) @@ -167,6 +174,10 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac offset: Long, limit: Long ): List { + val pointGroup = CaseBuilder() + .`when`(order.point.loe(0)).then(0) + .otherwise(1) + return queryFactory .select( QGetCumulativeSalesByContentQueryData( @@ -177,6 +188,7 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac order.can, order.id.count(), order.can.sum(), + order.point.sum(), creatorSettlementRatio.contentSettlementRatio ) ) @@ -189,7 +201,14 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac audioContent.member.id.eq(memberId) .and(order.isActive.isTrue) ) - .groupBy(member.id, audioContent.id, order.type, order.can) + .groupBy( + member.id, + audioContent.id, + order.type, + order.can, + pointGroup, + creatorSettlementRatio.contentSettlementRatio + ) .offset(offset) .limit(limit) .orderBy(member.id.desc(), audioContent.id.desc()) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentRepository.kt index 4a0a3da..519a969 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentRepository.kt @@ -84,6 +84,7 @@ class CreatorAdminAudioContentQueryRepositoryImpl( audioContent.limited, audioContent.remaining, audioContent.isAdult, + audioContent.isPointAvailable, audioContent.isCommentAvailable, audioContent.duration, audioContent.content, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentService.kt index a99972c..c5091c8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentService.kt @@ -131,6 +131,10 @@ class CreatorAdminContentService( audioContent.isAdult = request.isAdult } + if (request.isPointAvailable != null) { + audioContent.isPointAvailable = request.isPointAvailable + } + if (request.isCommentAvailable != null) { audioContent.isCommentAvailable = request.isCommentAvailable } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/GetCreatorAdminContentListResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/GetCreatorAdminContentListResponse.kt index 5176950..1975a38 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/GetCreatorAdminContentListResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/GetCreatorAdminContentListResponse.kt @@ -18,6 +18,7 @@ data class GetCreatorAdminContentListItem @QueryProjection constructor( val totalContentCount: Int?, val remainingContentCount: Int?, val isAdult: Boolean, + val isPointAvailable: Boolean, val isCommentAvailable: Boolean, val remainingTime: String, var contentUrl: String, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/UpdateCreatorAdminContentRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/UpdateCreatorAdminContentRequest.kt index 3360562..8eeda93 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/UpdateCreatorAdminContentRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/UpdateCreatorAdminContentRequest.kt @@ -7,5 +7,6 @@ data class UpdateCreatorAdminContentRequest( val price: Int?, val isAdult: Boolean?, val isActive: Boolean?, + val isPointAvailable: Boolean?, val isCommentAvailable: Boolean? ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt index 3b0a9ab..7a51f63 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt @@ -4,6 +4,9 @@ import com.google.firebase.messaging.AndroidConfig import com.google.firebase.messaging.ApnsConfig import com.google.firebase.messaging.Aps import com.google.firebase.messaging.FirebaseMessaging +import com.google.firebase.messaging.FirebaseMessagingException +import com.google.firebase.messaging.Message +import com.google.firebase.messaging.MessagingErrorCode import com.google.firebase.messaging.MulticastMessage import com.google.firebase.messaging.Notification import org.slf4j.LoggerFactory @@ -26,8 +29,14 @@ class FcmService { creatorId: Long? = null, auditionId: Long? = null ) { - if (tokens.isNotEmpty()) { - logger.info("os: $container") + if (tokens.isEmpty()) return + logger.info("os: $container") + + var targets = tokens + val maxAttempts = 3 + var attempt = 1 + + while (attempt <= maxAttempts && targets.isNotEmpty()) { val multicastMessage = MulticastMessage.builder() .addAllTokens(tokens) @@ -82,8 +91,80 @@ class FcmService { } val response = FirebaseMessaging.getInstance().sendEachForMulticast(multicastMessage.build()) - logger.info("보내기 성공: ${response.successCount}") - logger.info("보내기 실패: ${response.failureCount}") + val failedTokens = mutableListOf() + + response.responses.forEachIndexed { index, res -> + if (!res.isSuccessful) { + val exception = res.exception + val token = targets[index] + + if (exception?.messagingErrorCode == MessagingErrorCode.UNREGISTERED) { + logger.error("[FCM] ❌ UNREGISTERED → $token") + // 필요 시 DB에서 삭제 + } else { + logger.error("[FCM] ❌ 실패: $token / ${exception?.messagingErrorCode}") + failedTokens.add(token) + } + } + } + + if (failedTokens.isEmpty()) { + logger.info("[FCM] ✅ 전체 전송 성공") + return + } + + targets = failedTokens + attempt++ + } + + if (targets.isNotEmpty()) { + logger.error("[FCM] ❌ 최종 실패 대상 ${targets.size}명 → $targets") + } + } + + fun sendPointGranted(token: String, point: Int) { + val data = mapOf( + "type" to "POINT_GRANTED", + "point" to point.toString(), + "message" to "${point}포인트가 지급되었습니다!" + ) + + var attempts = 0 + val maxAttempts = 3 + + while (attempts < maxAttempts) { + try { + val message = Message.builder() + .setToken(token) + .putAllData(data) + .build() + + val response = FirebaseMessaging.getInstance().send(message) + logger.info("[FCM] ✅ 성공 (attempt ${attempts + 1}): messageId=$response") + return // 성공 시 즉시 종료 + } catch (e: FirebaseMessagingException) { + attempts++ + + // "registration-token-not-registered" 예외 코드 확인 + if (e.messagingErrorCode == MessagingErrorCode.UNREGISTERED) { + logger.error("[FCM] ❌ 실패: 토큰이 등록되지 않음 (등록 해제됨) → 재시도 안함") + return + } + + logger.error("[FCM] ❌ 실패 (attempt $attempts): ${e.errorCode} - ${e.message}") + + if (attempts >= maxAttempts) { + logger.error("[FCM] ❌ 최종 실패: 전송 불가") + } + } catch (e: Exception) { + // Firebase 이외의 예외도 잡기 + attempts++ + logger.error("[FCM] ❌ 실패 (attempt $attempts): ${e.message}") + + if (attempts >= maxAttempts) { + logger.error("[FCM] ❌ 최종 실패: 알 수 없는 오류") + } + } } } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt index 0c55544..0da7a69 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt @@ -13,6 +13,8 @@ import kr.co.vividnext.sodalive.member.notification.UpdateNotificationSettingReq import kr.co.vividnext.sodalive.member.signUp.SignUpRequestV2 import kr.co.vividnext.sodalive.member.social.google.GoogleAuthService import kr.co.vividnext.sodalive.member.social.kakao.KakaoAuthService +import kr.co.vividnext.sodalive.useraction.ActionType +import kr.co.vividnext.sodalive.useraction.UserActionService import org.springframework.data.domain.Pageable import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.userdetails.User @@ -34,7 +36,8 @@ class MemberController( private val service: MemberService, private val kakaoAuthService: KakaoAuthService, private val googleAuthService: GoogleAuthService, - private val trackingService: AdTrackingService + private val trackingService: AdTrackingService, + private val userActionService: UserActionService ) { @GetMapping("/check/email") fun checkEmail(@RequestParam email: String) = service.duplicateCheckEmail(email) @@ -60,6 +63,12 @@ class MemberController( ) } + userActionService.recordAction( + memberId = response.memberId, + actionType = ActionType.SIGN_UP, + pushToken = request.pushToken + ) + return ApiResponse.ok(message = "회원가입을 축하드립니다.", data = response.loginResponse) } @@ -332,7 +341,7 @@ class MemberController( } val token = authHeader.substring(7) - val response = googleAuthService.authenticate(token, request.container, request.marketingPid) + val response = googleAuthService.authenticate(token, request.container, request.marketingPid, request.pushToken) if (!response.marketingPid.isNullOrBlank()) { trackingService.saveTrackingHistory( @@ -342,6 +351,12 @@ class MemberController( ) } + userActionService.recordAction( + memberId = response.memberId, + actionType = ActionType.SIGN_UP, + pushToken = request.pushToken + ) + return ApiResponse.ok(message = "회원가입을 축하드립니다.", data = response.loginResponse) } @@ -355,7 +370,7 @@ class MemberController( } val token = authHeader.substring(7) - val response = kakaoAuthService.authenticate(token, request.container, request.marketingPid) + val response = kakaoAuthService.authenticate(token, request.container, request.marketingPid, request.pushToken) if (!response.marketingPid.isNullOrBlank()) { trackingService.saveTrackingHistory( @@ -365,6 +380,12 @@ class MemberController( ) } + userActionService.recordAction( + memberId = response.memberId, + actionType = ActionType.SIGN_UP, + pushToken = request.pushToken + ) + return ApiResponse.ok(message = "회원가입을 축하드립니다.", data = response.loginResponse) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt index 4308bc6..5a4f0e3 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt @@ -42,6 +42,7 @@ import kr.co.vividnext.sodalive.member.stipulation.StipulationRepository import kr.co.vividnext.sodalive.member.tag.MemberCreatorTag import kr.co.vividnext.sodalive.member.tag.MemberTagRepository import kr.co.vividnext.sodalive.member.token.MemberTokenRepository +import kr.co.vividnext.sodalive.point.MemberPointRepository import kr.co.vividnext.sodalive.utils.generateFileName import kr.co.vividnext.sodalive.utils.generatePassword import org.springframework.beans.factory.annotation.Value @@ -77,6 +78,7 @@ class MemberService( private val memberTagRepository: MemberTagRepository, private val liveReservationRepository: LiveReservationRepository, private val chargeRepository: ChargeRepository, + private val memberPointRepository: MemberPointRepository, private val orderService: OrderService, private val emailService: SendEmailService, @@ -125,6 +127,7 @@ class MemberService( gender = Gender.NONE, container = request.container ) + member.pushToken = request.pushToken if (!request.marketingPid.isNullOrBlank()) { member.activePid = request.marketingPid @@ -261,6 +264,11 @@ class MemberService( limit = 4 ) + val totalPoint = memberPointRepository.findByMemberIdAndExpiresAtAfterOrderByExpiresAtAsc( + memberId = member.id!!, + expiresAt = LocalDateTime.now() + ).sumOf { it.point } + return MyPageResponse( nickname = member.nickname, profileUrl = if (member.profileImage != null) { @@ -270,6 +278,7 @@ class MemberService( }, chargeCan = member.getChargeCan(container = container), rewardCan = member.getRewardCan(container = container), + point = totalPoint, youtubeUrl = member.youtubeUrl, instagramUrl = member.instagramUrl, websiteUrl = member.websiteUrl, @@ -780,7 +789,12 @@ class MemberService( } @Transactional - fun findOrRegister(googleUserInfo: GoogleUserInfo, container: String, marketingPid: String?): Member { + fun findOrRegister( + googleUserInfo: GoogleUserInfo, + container: String, + marketingPid: String?, + pushToken: String? + ): Member { val findMember = repository.findByGoogleId(googleUserInfo.sub) if (findMember != null) { if (findMember.isActive) { @@ -810,6 +824,7 @@ class MemberService( provider = MemberProvider.GOOGLE, container = container ) + member.pushToken = pushToken if (!marketingPid.isNullOrBlank()) { member.activePid = marketingPid @@ -823,7 +838,12 @@ class MemberService( } @Transactional - fun findOrRegister(kakaoUserInfo: KakaoUserInfo, container: String, marketingPid: String?): Member { + fun findOrRegister( + kakaoUserInfo: KakaoUserInfo, + container: String, + marketingPid: String?, + pushToken: String? + ): Member { val findMember = repository.findByKakaoId(kakaoUserInfo.id) if (findMember != null) { if (findMember.isActive) { @@ -853,6 +873,7 @@ class MemberService( provider = MemberProvider.KAKAO, container = container ) + member.pushToken = pushToken if (!marketingPid.isNullOrBlank()) { member.activePid = marketingPid diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthController.kt index ac823f2..173a186 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthController.kt @@ -3,6 +3,8 @@ package kr.co.vividnext.sodalive.member.auth import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.useraction.ActionType +import kr.co.vividnext.sodalive.useraction.UserActionService import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody @@ -11,7 +13,10 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/auth") -class AuthController(private val service: AuthService) { +class AuthController( + private val service: AuthService, + private val userActionService: UserActionService +) { @PostMapping fun authVerify( @RequestBody request: AuthVerifyRequest, @@ -26,6 +31,12 @@ class AuthController(private val service: AuthService) { throw SodaException("운영정책을 위반하여 이용을 제한합니다.") } + userActionService.recordAction( + memberId = member.id!!, + actionType = ActionType.USER_AUTHENTICATION, + pushToken = member.pushToken + ) + ApiResponse.ok(service.authenticate(authenticateData, member.id!!)) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/login/LoginRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/login/LoginRequest.kt index da16dc4..0895b91 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/login/LoginRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/login/LoginRequest.kt @@ -7,4 +7,8 @@ data class LoginRequest( val isCreator: Boolean = false ) -data class SocialLoginRequest(val container: String, val marketingPid: String? = null) +data class SocialLoginRequest( + val container: String, + val pushToken: String? = null, + val marketingPid: String? = null +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/myPage/MyPageResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/myPage/MyPageResponse.kt index 54d7a33..5bf0481 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/myPage/MyPageResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/myPage/MyPageResponse.kt @@ -7,6 +7,7 @@ data class MyPageResponse( val profileUrl: String, val chargeCan: Int, val rewardCan: Int, + val point: Int, val youtubeUrl: String?, val instagramUrl: String?, val websiteUrl: String? = null, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/signUp/SignUpRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/signUp/SignUpRequest.kt index de4a6fa..03ccc0f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/signUp/SignUpRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/signUp/SignUpRequest.kt @@ -16,6 +16,7 @@ data class SignUpRequest( data class SignUpRequestV2( val email: String, val password: String, + val pushToken: String? = null, val marketingPid: String? = null, val isAgreeTermsOfService: Boolean, val isAgreePrivacyPolicy: Boolean, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleAuthService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleAuthService.kt index 495e269..0d39c74 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleAuthService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleAuthService.kt @@ -19,10 +19,15 @@ class GoogleAuthService( @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String ) { - fun authenticate(idToken: String, container: String, marketingPid: String?): SocialLoginResponse { + fun authenticate( + idToken: String, + container: String, + marketingPid: String?, + pushToken: String? + ): SocialLoginResponse { val googleUserInfo = googleService.getUserInfo(idToken) ?: throw SodaException("구글 로그인을 하지 못했습니다. 다시 시도해 주세요") - val member = memberService.findOrRegister(googleUserInfo, container, marketingPid) + val member = memberService.findOrRegister(googleUserInfo, container, marketingPid, pushToken) val principal = MemberAdapter(member) val authToken = GoogleAuthenticationToken(idToken, principal.authorities) authToken.setPrincipal(principal) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoAuthService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoAuthService.kt index 49525bf..026aa79 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoAuthService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoAuthService.kt @@ -19,10 +19,15 @@ class KakaoAuthService( @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String ) { - fun authenticate(accessToken: String, container: String, marketingPid: String?): SocialLoginResponse { + fun authenticate( + accessToken: String, + container: String, + marketingPid: String?, + pushToken: String? + ): SocialLoginResponse { val kakaoUserInfo = kakaoService.getUserInfo(accessToken) ?: throw SodaException("카카오 로그인을 하지 못했습니다. 다시 시도해 주세요") - val member = memberService.findOrRegister(kakaoUserInfo, container, marketingPid) + val member = memberService.findOrRegister(kakaoUserInfo, container, marketingPid, pushToken) val principal = MemberAdapter(member) val authToken = KakaoAuthenticationToken(accessToken, principal.authorities) authToken.setPrincipal(principal) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/point/MemberPoint.kt b/src/main/kotlin/kr/co/vividnext/sodalive/point/MemberPoint.kt new file mode 100644 index 0000000..912f45c --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/point/MemberPoint.kt @@ -0,0 +1,17 @@ +package kr.co.vividnext.sodalive.point + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.useraction.ActionType +import java.time.LocalDateTime +import javax.persistence.Entity +import javax.persistence.EnumType +import javax.persistence.Enumerated + +@Entity +data class MemberPoint( + val memberId: Long, + var point: Int, + @Enumerated(EnumType.STRING) + val actionType: ActionType, + val expiresAt: LocalDateTime +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/point/MemberPointRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/point/MemberPointRepository.kt new file mode 100644 index 0000000..d8aec5d --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/point/MemberPointRepository.kt @@ -0,0 +1,29 @@ +package kr.co.vividnext.sodalive.point + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.point.QMemberPoint.memberPoint +import org.springframework.data.jpa.repository.JpaRepository +import java.time.LocalDateTime + +interface MemberPointRepository : JpaRepository, MemberPointQueryRepository + +interface MemberPointQueryRepository { + fun findByMemberIdAndExpiresAtAfterOrderByExpiresAtAsc(memberId: Long, expiresAt: LocalDateTime): List +} + +class MemberPointQueryRepositoryImpl( + private val queryFactory: JPAQueryFactory +) : MemberPointQueryRepository { + override fun findByMemberIdAndExpiresAtAfterOrderByExpiresAtAsc( + memberId: Long, + expiresAt: LocalDateTime + ): List { + return queryFactory + .selectFrom(memberPoint) + .where( + memberPoint.memberId.eq(memberId), + memberPoint.expiresAt.goe(expiresAt) + ) + .fetch() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/point/PointGrantLog.kt b/src/main/kotlin/kr/co/vividnext/sodalive/point/PointGrantLog.kt new file mode 100644 index 0000000..780eccc --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/point/PointGrantLog.kt @@ -0,0 +1,16 @@ +package kr.co.vividnext.sodalive.point + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.useraction.ActionType +import javax.persistence.Entity +import javax.persistence.EnumType +import javax.persistence.Enumerated + +@Entity +data class PointGrantLog( + val memberId: Long, + val point: Int, + @Enumerated(EnumType.STRING) + val actionType: ActionType, + val policyId: Long? +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/point/PointGrantLogRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/point/PointGrantLogRepository.kt new file mode 100644 index 0000000..361a873 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/point/PointGrantLogRepository.kt @@ -0,0 +1,27 @@ +package kr.co.vividnext.sodalive.point + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.point.QPointGrantLog.pointGrantLog +import org.springframework.data.jpa.repository.JpaRepository + +interface PointGrantLogRepository : JpaRepository, PointGrantLogQueryRepository + +interface PointGrantLogQueryRepository { + fun existsByMemberIdAndPolicyId(memberId: Long, policyId: Long): Boolean +} + +class PointGrantLogQueryRepositoryImpl( + private val queryFactory: JPAQueryFactory +) : PointGrantLogQueryRepository { + override fun existsByMemberIdAndPolicyId(memberId: Long, policyId: Long): Boolean { + return queryFactory + .select(pointGrantLog.id) + .from(pointGrantLog) + .where( + pointGrantLog.memberId.eq(memberId), + pointGrantLog.policyId.eq(policyId) + ) + .fetch() + .isNotEmpty() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/point/PointRewardPolicy.kt b/src/main/kotlin/kr/co/vividnext/sodalive/point/PointRewardPolicy.kt new file mode 100644 index 0000000..a898e1c --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/point/PointRewardPolicy.kt @@ -0,0 +1,20 @@ +package kr.co.vividnext.sodalive.point + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.useraction.ActionType +import java.time.LocalDateTime +import javax.persistence.Entity +import javax.persistence.EnumType +import javax.persistence.Enumerated + +@Entity +data class PointRewardPolicy( + var title: String, + @Enumerated(EnumType.STRING) + val actionType: ActionType, + val threshold: Int, + val pointAmount: Int, + var startDate: LocalDateTime, + var endDate: LocalDateTime? = null, + var isActive: Boolean = true +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/point/PointRewardPolicyRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/point/PointRewardPolicyRepository.kt new file mode 100644 index 0000000..c9e8fcb --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/point/PointRewardPolicyRepository.kt @@ -0,0 +1,36 @@ +package kr.co.vividnext.sodalive.point + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.point.QPointRewardPolicy.pointRewardPolicy +import kr.co.vividnext.sodalive.useraction.ActionType +import org.springframework.data.jpa.repository.JpaRepository +import java.time.LocalDateTime + +interface PointRewardPolicyRepository : JpaRepository, PointRewardPolicyQueryRepository + +interface PointRewardPolicyQueryRepository { + fun findByActionTypeAndIsActiveTrue(actionType: ActionType, nowDateTime: LocalDateTime): PointRewardPolicy? +} + +class PointRewardPolicyQueryRepositoryImpl( + private val queryFactory: JPAQueryFactory +) : PointRewardPolicyQueryRepository { + override fun findByActionTypeAndIsActiveTrue( + actionType: ActionType, + nowDateTime: LocalDateTime + ): PointRewardPolicy? { + return queryFactory + .selectFrom(pointRewardPolicy) + .where( + pointRewardPolicy.isActive.isTrue + .and(pointRewardPolicy.actionType.eq(actionType)) + .and(pointRewardPolicy.startDate.loe(nowDateTime)) + .and( + pointRewardPolicy.endDate.goe(nowDateTime) + .or(pointRewardPolicy.endDate.isNull) + ) + ) + .orderBy(pointRewardPolicy.endDate.asc()) + .fetchFirst() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/point/PointUsageService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/point/PointUsageService.kt new file mode 100644 index 0000000..ed02ca1 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/point/PointUsageService.kt @@ -0,0 +1,43 @@ +package kr.co.vividnext.sodalive.point + +import org.springframework.stereotype.Service +import java.time.LocalDateTime + +@Service +class PointUsageService( + private val memberPointRepository: MemberPointRepository, + private val usePointRepository: UsePointRepository +) { + fun usePoint(memberId: Long, contentPrice: Int): Int { + val now = LocalDateTime.now() + val maxUsablePoint = contentPrice * 10 + + val points = memberPointRepository.findByMemberIdAndExpiresAtAfterOrderByExpiresAtAsc( + memberId = memberId, + expiresAt = now + ) + + val totalAvailable = points.sumOf { it.point } + val usablePoint = minOf(totalAvailable, maxUsablePoint).floorToNearest10() + + var remaining = usablePoint + var used = 0 + + for (p in points) { + if (remaining <= 0) break + val usable = minOf(p.point, remaining) + p.point -= usable + remaining -= usable + used += usable + } + + if (used > 0) { + memberPointRepository.saveAll(points) + usePointRepository.save(UsePoint(memberId = memberId, amount = used)) + } + + return used + } + + private fun Int.floorToNearest10(): Int = (this / 10) * 10 +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/point/UsePoint.kt b/src/main/kotlin/kr/co/vividnext/sodalive/point/UsePoint.kt new file mode 100644 index 0000000..afbeb7e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/point/UsePoint.kt @@ -0,0 +1,10 @@ +package kr.co.vividnext.sodalive.point + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Entity + +@Entity +data class UsePoint( + val memberId: Long, + val amount: Int +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/point/UsePointRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/point/UsePointRepository.kt new file mode 100644 index 0000000..49a5fcc --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/point/UsePointRepository.kt @@ -0,0 +1,5 @@ +package kr.co.vividnext.sodalive.point + +import org.springframework.data.jpa.repository.JpaRepository + +interface UsePointRepository : JpaRepository diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/useraction/ActionType.kt b/src/main/kotlin/kr/co/vividnext/sodalive/useraction/ActionType.kt new file mode 100644 index 0000000..a9365c4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/useraction/ActionType.kt @@ -0,0 +1,6 @@ +package kr.co.vividnext.sodalive.useraction + +enum class ActionType(val displayName: String) { + SIGN_UP("회원가입"), + USER_AUTHENTICATION("본인인증") +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/useraction/UserActionLog.kt b/src/main/kotlin/kr/co/vividnext/sodalive/useraction/UserActionLog.kt new file mode 100644 index 0000000..fc92561 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/useraction/UserActionLog.kt @@ -0,0 +1,13 @@ +package kr.co.vividnext.sodalive.useraction + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Entity +import javax.persistence.EnumType +import javax.persistence.Enumerated + +@Entity +data class UserActionLog( + val memberId: Long, + @Enumerated(EnumType.STRING) + val actionType: ActionType +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/useraction/UserActionLogRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/useraction/UserActionLogRepository.kt new file mode 100644 index 0000000..0a0f5ec --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/useraction/UserActionLogRepository.kt @@ -0,0 +1,39 @@ +package kr.co.vividnext.sodalive.useraction + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.useraction.QUserActionLog.userActionLog +import org.springframework.data.jpa.repository.JpaRepository +import java.time.LocalDateTime + +interface UserActionLogRepository : JpaRepository, UserActionLogQueryRepository + +interface UserActionLogQueryRepository { + fun countByMemberIdAndActionTypeAndCreatedAtBetween( + memberId: Long, + actionType: ActionType, + startDate: LocalDateTime, + endDate: LocalDateTime + ): Int +} + +class UserActionLogQueryRepositoryImpl( + private val queryFactory: JPAQueryFactory +) : UserActionLogQueryRepository { + override fun countByMemberIdAndActionTypeAndCreatedAtBetween( + memberId: Long, + actionType: ActionType, + startDate: LocalDateTime, + endDate: LocalDateTime + ): Int { + return queryFactory + .select(userActionLog.id) + .from(userActionLog) + .where( + userActionLog.memberId.eq(memberId) + .and(userActionLog.actionType.eq(actionType)) + .and(userActionLog.createdAt.between(startDate, endDate)) + ) + .fetch() + .size + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/useraction/UserActionService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/useraction/UserActionService.kt new file mode 100644 index 0000000..b3f0236 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/useraction/UserActionService.kt @@ -0,0 +1,69 @@ +package kr.co.vividnext.sodalive.useraction + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kr.co.vividnext.sodalive.fcm.FcmService +import kr.co.vividnext.sodalive.point.MemberPoint +import kr.co.vividnext.sodalive.point.MemberPointRepository +import kr.co.vividnext.sodalive.point.PointGrantLog +import kr.co.vividnext.sodalive.point.PointGrantLogRepository +import kr.co.vividnext.sodalive.point.PointRewardPolicyRepository +import org.springframework.stereotype.Service +import java.time.LocalDateTime + +@Service +class UserActionService( + private val repository: UserActionLogRepository, + private val policyRepository: PointRewardPolicyRepository, + private val grantLogRepository: PointGrantLogRepository, + private val memberPointRepository: MemberPointRepository, + + private val fcmService: FcmService +) { + + private val coroutineScope = CoroutineScope(Dispatchers.IO) + + fun recordAction(memberId: Long, actionType: ActionType, pushToken: String?) { + coroutineScope.launch { + val now = LocalDateTime.now() + repository.save(UserActionLog(memberId, actionType)) + + val policy = policyRepository.findByActionTypeAndIsActiveTrue(actionType, now) + if (policy != null) { + val actionCount = repository.countByMemberIdAndActionTypeAndCreatedAtBetween( + memberId = memberId, + actionType = actionType, + startDate = policy.startDate, + endDate = policy.endDate ?: now + ) + if (actionCount < policy.threshold) return@launch + + val alreadyGranted = grantLogRepository.existsByMemberIdAndPolicyId(memberId, policy.id!!) + if (alreadyGranted) return@launch + + memberPointRepository.save( + MemberPoint( + memberId = memberId, + point = policy.pointAmount, + actionType = actionType, + expiresAt = now.plusDays(3) + ) + ) + + grantLogRepository.save( + PointGrantLog( + memberId = memberId, + point = policy.pointAmount, + actionType = actionType, + policyId = policy.id!! + ) + ) + + if (pushToken != null) { + fcmService.sendPointGranted(pushToken, policy.pointAmount) + } + } + } + } +}