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 index 4c2cf73..0f80f8a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/point/CreatePointRewardPolicyRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/point/CreatePointRewardPolicyRequest.kt @@ -2,14 +2,17 @@ package kr.co.vividnext.sodalive.admin.point import kr.co.vividnext.sodalive.point.PointRewardPolicy import kr.co.vividnext.sodalive.useraction.ActionType +import kr.co.vividnext.sodalive.useraction.PolicyType import java.time.LocalDateTime import java.time.ZoneId import java.time.format.DateTimeFormatter data class CreatePointRewardPolicyRequest( val title: String, + val policyType: PolicyType, val actionType: ActionType, val threshold: Int, + val availableCount: Int, val pointAmount: Int, val startDate: String, val endDate: String @@ -19,8 +22,10 @@ data class CreatePointRewardPolicyRequest( return PointRewardPolicy( title = title, + policyType = policyType, actionType = actionType, threshold = threshold, + availableCount = availableCount, pointAmount = pointAmount, startDate = LocalDateTime.parse(startDate, dateTimeFormatter) .atZone(ZoneId.of("Asia/Seoul")) 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 index 33dd64e..8654c19 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/point/GetPointRewardPolicyListResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/point/GetPointRewardPolicyListResponse.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.admin.point import com.querydsl.core.annotations.QueryProjection import kr.co.vividnext.sodalive.useraction.ActionType +import kr.co.vividnext.sodalive.useraction.PolicyType data class GetPointRewardPolicyListResponse( val totalCount: Int, @@ -11,8 +12,10 @@ data class GetPointRewardPolicyListResponse( data class GetPointRewardPolicyListItem @QueryProjection constructor( val id: Long, val title: String, + val policyType: PolicyType, val actionType: ActionType, val threshold: Int, + val availableCount: Int, val pointAmount: Int, val startDate: String, val endDate: String, 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 index 2cd63db..0c66df7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/point/PointPolicyRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/point/PointPolicyRepository.kt @@ -33,8 +33,10 @@ class PointPolicyQueryRepositoryImpl( QGetPointRewardPolicyListItem( pointRewardPolicy.id, pointRewardPolicy.title, + pointRewardPolicy.policyType, pointRewardPolicy.actionType, pointRewardPolicy.threshold, + pointRewardPolicy.availableCount, pointRewardPolicy.pointAmount, getFormattedDate(pointRewardPolicy.startDate), getFormattedDate(pointRewardPolicy.endDate), diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentController.kt index a68faa2..593758f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentController.kt @@ -3,6 +3,9 @@ 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 kr.co.vividnext.sodalive.member.MemberService +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.web.bind.annotation.GetMapping @@ -14,7 +17,11 @@ import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController @RestController -class AudioContentCommentController(private val service: AudioContentCommentService) { +class AudioContentCommentController( + private val service: AudioContentCommentService, + private val memberService: MemberService, + private val userActionService: UserActionService +) { @PostMapping("/audio-content/comment") fun registerComment( @RequestBody request: RegisterCommentRequest, @@ -22,15 +29,38 @@ class AudioContentCommentController(private val service: AudioContentCommentServ ) = run { if (member == null) throw SodaException("로그인 정보를 확인해주세요.") - ApiResponse.ok( - service.registerComment( - comment = request.comment, - audioContentId = request.contentId, - parentId = request.parentId, - isSecret = request.isSecret, - member = member - ) + val commentId = service.registerComment( + comment = request.comment, + audioContentId = request.contentId, + parentId = request.parentId, + isSecret = request.isSecret, + member = member ) + + try { + val memberId = member.id!! + val pushTokenList = memberService.getPushTokenList(recipient = memberId) + + userActionService.recordAction( + memberId = member.id!!, + isAuth = member.auth != null, + actionType = ActionType.CONTENT_COMMENT, + contentCommentId = commentId, + pushTokenList = pushTokenList + ) + + userActionService.recordAction( + memberId = member.id!!, + isAuth = member.auth != null, + actionType = ActionType.ORDER_CONTENT_COMMENT, + contentId = request.contentId, + contentCommentId = commentId, + pushTokenList = pushTokenList + ) + } catch (_: Exception) { + } + + ApiResponse.ok(Unit, "") } @PutMapping("/audio-content/comment") diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt index 9d885c1..90b9be7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt @@ -33,7 +33,7 @@ class AudioContentCommentService( audioContentId: Long, parentId: Long? = null, isSecret: Boolean = false - ) { + ): Long { val audioContent = audioContentRepository.findByIdOrNull(id = audioContentId) ?: throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.") @@ -64,7 +64,7 @@ class AudioContentCommentService( audioContentComment.parent = parent } - repository.save(audioContentComment) + val savedContentComment = repository.save(audioContentComment) applicationEventPublisher.publishEvent( FcmEvent( @@ -84,6 +84,8 @@ class AudioContentCommentService( myMemberId = member.id ) ) + + return savedContentComment.id!! } @Transactional diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderRepository.kt index 566cc27..d460d19 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderRepository.kt @@ -44,6 +44,7 @@ interface OrderQueryRepository { fun findOrderedContent(contentIdList: List, memberId: Long): List fun findEndDateByContentId(contentIdList: List, memberId: Long): List fun findBuyerListByContentId(contentId: Long): List + fun findByMemberIdAndContentId(memberId: Long, contentId: Long, createdAt: LocalDateTime): Order? } @Repository @@ -280,4 +281,17 @@ class OrderQueryRepositoryImpl( ) .fetch() } + + override fun findByMemberIdAndContentId(memberId: Long, contentId: Long, createdAt: LocalDateTime): Order? { + return queryFactory + .selectFrom(order) + .where( + order.isActive.isTrue, + order.member.id.eq(memberId), + order.audioContent.id.eq(contentId), + order.createdAt.after(createdAt) + ) + .orderBy(order.id.desc()) + .fetchFirst() + } } 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 255da15..68de04a 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 @@ -44,7 +44,7 @@ class OrderService( } val usedPoint = if (order.type == OrderType.RENTAL && content.isPointAvailable) { - pointUsageService.usePoint(member.id!!, order.can) + pointUsageService.usePoint(member.id!!, order.can, orderId = order.id) } else { 0 } 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 997b2f5..68e0570 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt @@ -4,8 +4,6 @@ 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 @@ -38,7 +36,7 @@ class FcmService(private val pushTokenService: PushTokenService) { while (attempt <= maxAttempts && targets.isNotEmpty()) { val multicastMessage = MulticastMessage.builder() - .addAllTokens(tokens) + .addAllTokens(targets) multicastMessage.setAndroidConfig( AndroidConfig.builder() @@ -117,51 +115,69 @@ class FcmService(private val pushTokenService: PushTokenService) { } } - fun sendPointGranted(token: String, point: Int) { + fun sendPointGranted(tokens: List, point: Int) { + if (tokens.isEmpty()) return val data = mapOf( "type" to "POINT_GRANTED", "point" to point.toString(), "message" to "${point}포인트가 지급되었습니다!" ) + var targets = tokens var attempts = 0 val maxAttempts = 3 - while (attempts < maxAttempts) { - try { - val message = Message.builder() - .setToken(token) - .putAllData(data) + while (attempts <= maxAttempts && targets.isNotEmpty()) { + val multicastMessage = MulticastMessage.builder() + .addAllTokens(targets) + .putAllData(data) + + multicastMessage.setAndroidConfig( + AndroidConfig.builder() + .setPriority(AndroidConfig.Priority.HIGH) .build() + ) - val response = FirebaseMessaging.getInstance().send(message) - logger.info("[FCM] ✅ 성공 (attempt ${attempts + 1}): messageId=$response") - return // 성공 시 즉시 종료 - } catch (e: FirebaseMessagingException) { - attempts++ + multicastMessage.setApnsConfig( + ApnsConfig.builder() + .setAps( + Aps.builder() + .setSound("default") + .build() + ) + .build() + ) - // "registration-token-not-registered" 예외 코드 확인 - if (e.messagingErrorCode == MessagingErrorCode.UNREGISTERED) { - logger.error("[FCM] ❌ 실패: 토큰이 등록되지 않음 (등록 해제됨) → 재시도 안함") - // DB에서 삭제 - pushTokenService.unregisterInvalidToken(token) - return - } + val response = FirebaseMessaging.getInstance().sendEachForMulticast(multicastMessage.build()) + val failedTokens = mutableListOf() - logger.error("[FCM] ❌ 실패 (attempt $attempts): ${e.errorCode} - ${e.message}") + response.responses.forEachIndexed { index, res -> + if (!res.isSuccessful) { + val exception = res.exception + val token = targets[index] - if (attempts >= maxAttempts) { - logger.error("[FCM] ❌ 최종 실패: 전송 불가") - } - } catch (e: Exception) { - // Firebase 이외의 예외도 잡기 - attempts++ - logger.error("[FCM] ❌ 실패 (attempt $attempts): ${e.message}") - - if (attempts >= maxAttempts) { - logger.error("[FCM] ❌ 최종 실패: 알 수 없는 오류") + if (exception?.messagingErrorCode == MessagingErrorCode.UNREGISTERED) { + logger.error("[FCM] ❌ UNREGISTERED → $token") + // DB에서 삭제 + pushTokenService.unregisterInvalidToken(token) + } else { + logger.error("[FCM] ❌ 실패: $token / ${exception?.messagingErrorCode}") + failedTokens.add(token) + } } } + + if (failedTokens.isEmpty()) { + logger.info("[FCM] ✅ 전체 전송 성공") + return + } + + targets = failedTokens + attempts++ + } + + if (targets.isNotEmpty()) { + logger.error("[FCM] ❌ 최종 실패 대상 ${targets.size}명 → $targets") } } } 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 1a1b2e4..e7cd05b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt @@ -65,8 +65,13 @@ class MemberController( userActionService.recordAction( memberId = response.memberId, + isAuth = false, actionType = ActionType.SIGN_UP, - pushToken = request.pushToken + pushTokenList = if (request.pushToken != null) { + listOf(request.pushToken) + } else { + emptyList() + } ) return ApiResponse.ok(message = "회원가입을 축하드립니다.", data = response.loginResponse) @@ -354,8 +359,13 @@ class MemberController( if (response.isNew) { userActionService.recordAction( memberId = response.memberId, + isAuth = false, actionType = ActionType.SIGN_UP, - pushToken = request.pushToken + pushTokenList = if (request.pushToken != null) { + listOf(request.pushToken) + } else { + emptyList() + } ) } @@ -385,8 +395,13 @@ class MemberController( if (response.isNew) { userActionService.recordAction( memberId = response.memberId, + isAuth = false, actionType = ActionType.SIGN_UP, - pushToken = request.pushToken + pushTokenList = if (request.pushToken != null) { + listOf(request.pushToken) + } else { + emptyList() + } ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt index 2f14fea..2eb20ef 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt @@ -64,6 +64,8 @@ interface MemberQueryRepository { fun existsByNickname(nickname: String): Boolean fun findNicknamesWithPrefix(prefix: String): List + + fun getPushTokenList(memberId: Long): List } @Repository @@ -507,4 +509,17 @@ class MemberQueryRepositoryImpl( ) .fetch() } + + override fun getPushTokenList(memberId: Long): List { + val where = member.isActive.isTrue + .and(member.email.notIn("admin@sodalive.net")) + .and(member.id.eq(memberId)) + + return queryFactory + .select(pushToken.token) + .from(member) + .innerJoin(pushToken).on(member.id.eq(pushToken.member.id)) + .where(where) + .fetch() + } } 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 49f3362..ad71bec 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt @@ -907,6 +907,12 @@ class MemberService( return MemberResolveResult(member = member, isNew = true) } + fun getPushTokenList(recipient: Long): List { + return repository.getPushTokenList(recipient) + .toSet() + .toList() + } + private fun checkEmail(email: String) { val member = repository.findByEmail(email) 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 f645797..50f8cbb 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,7 @@ 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.member.MemberService import kr.co.vividnext.sodalive.useraction.ActionType import kr.co.vividnext.sodalive.useraction.UserActionService import org.springframework.security.core.annotation.AuthenticationPrincipal @@ -15,6 +16,7 @@ import org.springframework.web.bind.annotation.RestController @RequestMapping("/auth") class AuthController( private val service: AuthService, + private val memberService: MemberService, private val userActionService: UserActionService ) { @PostMapping @@ -33,11 +35,17 @@ class AuthController( val authResponse = service.authenticate(authenticateData, member.id!!) - userActionService.recordAction( - memberId = member.id!!, - actionType = ActionType.USER_AUTHENTICATION, - pushToken = member.pushToken - ) + try { + val memberId = member.id!! + val pushTokenList = memberService.getPushTokenList(recipient = memberId) + userActionService.recordAction( + memberId = member.id!!, + isAuth = true, + actionType = ActionType.USER_AUTHENTICATION, + pushTokenList = pushTokenList + ) + } catch (_: Exception) { + } ApiResponse.ok(authResponse) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/point/GetPointRewardStatusResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/point/GetPointRewardStatusResponse.kt new file mode 100644 index 0000000..050f7a7 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/point/GetPointRewardStatusResponse.kt @@ -0,0 +1,9 @@ +package kr.co.vividnext.sodalive.point + +import com.querydsl.core.annotations.QueryProjection + +data class GetPointRewardStatusResponse @QueryProjection constructor( + val rewardPoint: String, + val date: String, + val method: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/point/GetPointStatusResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/point/GetPointStatusResponse.kt new file mode 100644 index 0000000..3f60f3c --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/point/GetPointStatusResponse.kt @@ -0,0 +1,3 @@ +package kr.co.vividnext.sodalive.point + +data class GetPointStatusResponse(val point: Int) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/point/GetPointUseStatusResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/point/GetPointUseStatusResponse.kt new file mode 100644 index 0000000..bffec5f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/point/GetPointUseStatusResponse.kt @@ -0,0 +1,9 @@ +package kr.co.vividnext.sodalive.point + +import com.querydsl.core.annotations.QueryProjection + +data class GetPointUseStatusResponse @QueryProjection constructor( + val title: String, + val date: String, + val point: Int +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/point/PointController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/point/PointController.kt new file mode 100644 index 0000000..04d20c0 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/point/PointController.kt @@ -0,0 +1,49 @@ +package kr.co.vividnext.sodalive.point + +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("/point") +class PointController(private val service: PointService) { + @GetMapping("/status") + fun getPointStatus( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) { + throw SodaException("로그인 정보를 확인해주세요.") + } + + ApiResponse.ok(service.getPointStatus(member)) + } + + @GetMapping("/status/use") + fun getPointUseStatus( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @RequestParam("timezone") timezone: String + ) = run { + if (member == null) { + throw SodaException("로그인 정보를 확인해주세요.") + } + + ApiResponse.ok(service.getPointUseStatus(member, timezone)) + } + + @GetMapping("/status/reward") + fun getPointRewardStatus( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @RequestParam("timezone") timezone: String + ) = run { + if (member == null) { + throw SodaException("로그인 정보를 확인해주세요.") + } + + ApiResponse.ok(service.getPointRewardStatus(member, timezone)) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/point/PointGrantLog.kt b/src/main/kotlin/kr/co/vividnext/sodalive/point/PointGrantLog.kt index 780eccc..83666c6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/point/PointGrantLog.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/point/PointGrantLog.kt @@ -12,5 +12,6 @@ data class PointGrantLog( val point: Int, @Enumerated(EnumType.STRING) val actionType: ActionType, - val policyId: Long? + val policyId: Long?, + val orderId: 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 index 361a873..6edc73a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/point/PointGrantLogRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/point/PointGrantLogRepository.kt @@ -1,27 +1,75 @@ package kr.co.vividnext.sodalive.point +import com.querydsl.core.types.dsl.Expressions import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.point.QPointGrantLog.pointGrantLog +import kr.co.vividnext.sodalive.point.QPointRewardPolicy.pointRewardPolicy import org.springframework.data.jpa.repository.JpaRepository +import java.time.LocalDateTime interface PointGrantLogRepository : JpaRepository, PointGrantLogQueryRepository interface PointGrantLogQueryRepository { - fun existsByMemberIdAndPolicyId(memberId: Long, policyId: Long): Boolean + fun countByMemberIdAndPolicyIdAndStartDate( + memberId: Long, + policyId: Long, + startDate: LocalDateTime, + orderId: Long? = null + ): Int + + fun getPointRewardStatusByMemberId(memberId: Long, timezone: String): List } class PointGrantLogQueryRepositoryImpl( private val queryFactory: JPAQueryFactory ) : PointGrantLogQueryRepository { - override fun existsByMemberIdAndPolicyId(memberId: Long, policyId: Long): Boolean { + override fun countByMemberIdAndPolicyIdAndStartDate( + memberId: Long, + policyId: Long, + startDate: LocalDateTime, + orderId: Long? + ): Int { + var where = pointGrantLog.memberId.eq(memberId) + .and(pointGrantLog.policyId.eq(policyId)) + .and(pointGrantLog.createdAt.goe(startDate)) + + if (orderId != null) { + where = where.and(pointGrantLog.orderId.eq(orderId)) + } + return queryFactory .select(pointGrantLog.id) .from(pointGrantLog) - .where( - pointGrantLog.memberId.eq(memberId), - pointGrantLog.policyId.eq(policyId) - ) + .where(where) + .fetch() + .size + } + + override fun getPointRewardStatusByMemberId(memberId: Long, timezone: String): List { + val formattedDate = Expressions.stringTemplate( + "DATE_FORMAT({0}, {1})", + Expressions.dateTimeTemplate( + LocalDateTime::class.java, + "CONVERT_TZ({0},{1},{2})", + pointGrantLog.createdAt, + "UTC", + timezone + ), + "%Y.%m.%d | %H:%i:%s" + ) + + return queryFactory + .select( + QGetPointRewardStatusResponse( + pointGrantLog.point.stringValue().concat(" 포인트"), + formattedDate, + pointRewardPolicy.title + ) + ) + .from(pointGrantLog) + .innerJoin(pointRewardPolicy).on(pointGrantLog.policyId.eq(pointRewardPolicy.id)) + .where(pointGrantLog.memberId.eq(memberId)) + .orderBy(pointGrantLog.id.desc()) .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 index a898e1c..2fd9bd9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/point/PointRewardPolicy.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/point/PointRewardPolicy.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.point import kr.co.vividnext.sodalive.common.BaseEntity import kr.co.vividnext.sodalive.useraction.ActionType +import kr.co.vividnext.sodalive.useraction.PolicyType import java.time.LocalDateTime import javax.persistence.Entity import javax.persistence.EnumType @@ -11,8 +12,11 @@ import javax.persistence.Enumerated data class PointRewardPolicy( var title: String, @Enumerated(EnumType.STRING) + val policyType: PolicyType, + @Enumerated(EnumType.STRING) val actionType: ActionType, val threshold: Int, + val availableCount: Int, val pointAmount: Int, var startDate: LocalDateTime, var endDate: LocalDateTime? = null, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/point/PointService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/point/PointService.kt new file mode 100644 index 0000000..92ec123 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/point/PointService.kt @@ -0,0 +1,29 @@ +package kr.co.vividnext.sodalive.point + +import kr.co.vividnext.sodalive.member.Member +import org.springframework.stereotype.Service +import java.time.LocalDateTime + +@Service +class PointService( + private val pointGrantLogRepository: PointGrantLogRepository, + private val memberPointRepository: MemberPointRepository, + private val usePointRepository: UsePointRepository +) { + fun getPointStatus(member: Member): GetPointStatusResponse { + return GetPointStatusResponse( + point = memberPointRepository.findByMemberIdAndExpiresAtAfterOrderByExpiresAtAsc( + memberId = member.id!!, + expiresAt = LocalDateTime.now() + ).sumOf { it.point } + ) + } + + fun getPointUseStatus(member: Member, timezone: String): List { + return usePointRepository.getPointUseStatusByMemberId(member.id!!, timezone) + } + + fun getPointRewardStatus(member: Member, timezone: String): List { + return pointGrantLogRepository.getPointRewardStatusByMemberId(member.id!!, timezone) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/point/PointUsageService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/point/PointUsageService.kt index ed02ca1..4aadd65 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/point/PointUsageService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/point/PointUsageService.kt @@ -8,7 +8,7 @@ class PointUsageService( private val memberPointRepository: MemberPointRepository, private val usePointRepository: UsePointRepository ) { - fun usePoint(memberId: Long, contentPrice: Int): Int { + fun usePoint(memberId: Long, contentPrice: Int, orderId: Long?): Int { val now = LocalDateTime.now() val maxUsablePoint = contentPrice * 10 @@ -33,7 +33,7 @@ class PointUsageService( if (used > 0) { memberPointRepository.saveAll(points) - usePointRepository.save(UsePoint(memberId = memberId, amount = used)) + usePointRepository.save(UsePoint(memberId = memberId, amount = used, orderId = orderId)) } return used diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/point/UsePoint.kt b/src/main/kotlin/kr/co/vividnext/sodalive/point/UsePoint.kt index afbeb7e..a2ba533 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/point/UsePoint.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/point/UsePoint.kt @@ -6,5 +6,6 @@ import javax.persistence.Entity @Entity data class UsePoint( val memberId: Long, - val amount: Int + val amount: Int, + val orderId: Long? = null ) : 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 index 49a5fcc..054d175 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/point/UsePointRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/point/UsePointRepository.kt @@ -1,5 +1,48 @@ package kr.co.vividnext.sodalive.point +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.order.QOrder.order +import kr.co.vividnext.sodalive.point.QUsePoint.usePoint import org.springframework.data.jpa.repository.JpaRepository +import java.time.LocalDateTime -interface UsePointRepository : JpaRepository +interface UsePointRepository : JpaRepository, UsePointQueryRepository + +interface UsePointQueryRepository { + fun getPointUseStatusByMemberId(memberId: Long, timezone: String): List +} + +class UsePointQueryRepositoryImpl( + private val queryFactory: JPAQueryFactory +) : UsePointQueryRepository { + override fun getPointUseStatusByMemberId(memberId: Long, timezone: String): List { + val formattedDate = Expressions.stringTemplate( + "DATE_FORMAT({0}, {1})", + Expressions.dateTimeTemplate( + LocalDateTime::class.java, + "CONVERT_TZ({0},{1},{2})", + usePoint.createdAt, + "UTC", + timezone + ), + "%Y.%m.%d | %H:%i:%s" + ) + + return queryFactory + .select( + QGetPointUseStatusResponse( + audioContent.title.prepend("[콘텐츠 대여] "), + formattedDate, + usePoint.amount + ) + ) + .from(usePoint) + .innerJoin(order).on(usePoint.orderId.eq(order.id)) + .innerJoin(audioContent).on(order.audioContent.id.eq(audioContent.id)) + .where(usePoint.memberId.eq(memberId)) + .orderBy(usePoint.id.desc()) + .fetch() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/useraction/ActionType.kt b/src/main/kotlin/kr/co/vividnext/sodalive/useraction/ActionType.kt index a9365c4..5933be9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/useraction/ActionType.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/useraction/ActionType.kt @@ -2,5 +2,8 @@ package kr.co.vividnext.sodalive.useraction enum class ActionType(val displayName: String) { SIGN_UP("회원가입"), - USER_AUTHENTICATION("본인인증") + USER_AUTHENTICATION("본인인증"), + CONTENT_COMMENT("콘텐츠 댓글"), + ORDER_CONTENT_COMMENT("구매한 콘텐츠 댓글"), + LIVE_CONTINUOUS_LISTEN_30("라이브 연속 청취 30분") } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/useraction/PolicyType.kt b/src/main/kotlin/kr/co/vividnext/sodalive/useraction/PolicyType.kt new file mode 100644 index 0000000..81f4982 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/useraction/PolicyType.kt @@ -0,0 +1,6 @@ +package kr.co.vividnext.sodalive.useraction + +enum class PolicyType(val displayName: String) { + DAILY("기간 내 매일"), + TOTAL("기간 내 전체") +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/useraction/UserActionController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/useraction/UserActionController.kt new file mode 100644 index 0000000..1a87e08 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/useraction/UserActionController.kt @@ -0,0 +1,37 @@ +package kr.co.vividnext.sodalive.useraction + +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.member.MemberService +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("/user-action") +class UserActionController( + private val service: UserActionService, + private val memberService: MemberService +) { + @PostMapping + fun recordAction( + @RequestBody request: UserActionRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("") + + val memberId = member.id!! + val pushTokenList = memberService.getPushTokenList(recipient = memberId) + service.recordAction( + memberId = memberId, + isAuth = member.auth != null, + actionType = request.actionType, + pushTokenList = pushTokenList + ) + + ApiResponse.ok(Unit, "") + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/useraction/UserActionLog.kt b/src/main/kotlin/kr/co/vividnext/sodalive/useraction/UserActionLog.kt index fc92561..2261cc6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/useraction/UserActionLog.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/useraction/UserActionLog.kt @@ -9,5 +9,6 @@ import javax.persistence.Enumerated data class UserActionLog( val memberId: Long, @Enumerated(EnumType.STRING) - val actionType: ActionType + val actionType: ActionType, + val contentCommentId: Long? = null ) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/useraction/UserActionRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/useraction/UserActionRequest.kt new file mode 100644 index 0000000..4d92eb7 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/useraction/UserActionRequest.kt @@ -0,0 +1,3 @@ +package kr.co.vividnext.sodalive.useraction + +data class UserActionRequest(val actionType: ActionType) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/useraction/UserActionService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/useraction/UserActionService.kt index b3f0236..1025369 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/useraction/UserActionService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/useraction/UserActionService.kt @@ -3,67 +3,150 @@ package kr.co.vividnext.sodalive.useraction import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kr.co.vividnext.sodalive.content.order.OrderRepository 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.slf4j.LoggerFactory import org.springframework.stereotype.Service +import org.springframework.transaction.support.TransactionTemplate import java.time.LocalDateTime @Service class UserActionService( private val repository: UserActionLogRepository, + private val orderRepository: OrderRepository, private val policyRepository: PointRewardPolicyRepository, private val grantLogRepository: PointGrantLogRepository, private val memberPointRepository: MemberPointRepository, + private val transactionTemplate: TransactionTemplate, private val fcmService: FcmService ) { private val coroutineScope = CoroutineScope(Dispatchers.IO) - fun recordAction(memberId: Long, actionType: ActionType, pushToken: String?) { + fun recordAction( + memberId: Long, + isAuth: Boolean, + actionType: ActionType, + contentId: Long? = null, + contentCommentId: Long? = null, + pushTokenList: List = emptyList() + ) { 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( + transactionTemplate.execute { + repository.save( + UserActionLog( memberId = memberId, - point = policy.pointAmount, actionType = actionType, - expiresAt = now.plusDays(3) + contentCommentId = contentCommentId ) ) + repository.flush() + } - grantLogRepository.save( - PointGrantLog( - memberId = memberId, - point = policy.pointAmount, - actionType = actionType, - policyId = policy.id!! - ) - ) + if (isAuth) { + try { + transactionTemplate.execute { + val policy = policyRepository.findByActionTypeAndIsActiveTrue(actionType, now) + if (policy != null) { + val policyType = policy.policyType + val todayAt15 = now.toLocalDate().atTime(15, 0) + val policyTypeDailyStartDate = if (now.toLocalTime().isBefore(todayAt15.toLocalTime())) { + now.toLocalDate().minusDays(1).atTime(15, 0) + } else { + todayAt15 + } - if (pushToken != null) { - fcmService.sendPointGranted(pushToken, policy.pointAmount) + val isValidPolicyTypeDailyAndDailyStartDateAfterPolicyStartDate = + policyType == PolicyType.DAILY && policyTypeDailyStartDate >= policy.startDate + val order = if (contentId != null) { + orderRepository.findByMemberIdAndContentId( + memberId = memberId, + contentId = contentId, + createdAt = if (isValidPolicyTypeDailyAndDailyStartDateAfterPolicyStartDate) { + policyTypeDailyStartDate + } else { + policy.startDate + } + ) + } else { + null + } + if (actionType == ActionType.ORDER_CONTENT_COMMENT && order == null) return@execute + + val actionCount = repository.countByMemberIdAndActionTypeAndCreatedAtBetween( + memberId = memberId, + actionType = actionType, + startDate = if (isValidPolicyTypeDailyAndDailyStartDateAfterPolicyStartDate) { + policyTypeDailyStartDate + } else { + policy.startDate + }, + endDate = policy.endDate ?: now + ) + if (actionCount < policy.threshold) return@execute + + val grantedCount = grantLogRepository.countByMemberIdAndPolicyIdAndStartDate( + memberId, + policy.id!!, + startDate = if (isValidPolicyTypeDailyAndDailyStartDateAfterPolicyStartDate) { + policyTypeDailyStartDate + } else { + policy.startDate + }, + orderId = order?.id + ) + if (grantedCount >= policy.availableCount) return@execute + + val point = if (actionType == ActionType.ORDER_CONTENT_COMMENT && order != null) { + order.can + } else { + policy.pointAmount + } + + if (point > 0) { + grantLogRepository.save( + PointGrantLog( + memberId = memberId, + point = point, + actionType = actionType, + policyId = policy.id!!, + orderId = order?.id + ) + ) + + memberPointRepository.save( + MemberPoint( + memberId = memberId, + point = point, + actionType = actionType, + expiresAt = now.plusDays(3) + ) + ) + + if (pushTokenList.isNotEmpty()) { + fcmService.sendPointGranted( + pushTokenList, + point + ) + } + } + } + } + } catch (e: Exception) { + logger.warn("포인트 지급 또는 알림 실패: ${e.message}") } } } } + + companion object { + private val logger = LoggerFactory.getLogger(UserActionService::class.java) + } }