| @@ -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")) | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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), | ||||
|   | ||||
| @@ -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") | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -44,6 +44,7 @@ interface OrderQueryRepository { | ||||
|     fun findOrderedContent(contentIdList: List<Long>, memberId: Long): List<Long> | ||||
|     fun findEndDateByContentId(contentIdList: List<Long>, memberId: Long): List<ContentIdAndEndDateData> | ||||
|     fun findBuyerListByContentId(contentId: Long): List<ContentBuyer> | ||||
|     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() | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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 | ||||
|         } | ||||
|   | ||||
| @@ -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<String>, 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<String>() | ||||
|  | ||||
|                 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") | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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() | ||||
|                 } | ||||
|             ) | ||||
|         } | ||||
|  | ||||
|   | ||||
| @@ -64,6 +64,8 @@ interface MemberQueryRepository { | ||||
|  | ||||
|     fun existsByNickname(nickname: String): Boolean | ||||
|     fun findNicknamesWithPrefix(prefix: String): List<String> | ||||
|  | ||||
|     fun getPushTokenList(memberId: Long): List<String> | ||||
| } | ||||
|  | ||||
| @Repository | ||||
| @@ -507,4 +509,17 @@ class MemberQueryRepositoryImpl( | ||||
|             ) | ||||
|             .fetch() | ||||
|     } | ||||
|  | ||||
|     override fun getPushTokenList(memberId: Long): List<String> { | ||||
|         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() | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -907,6 +907,12 @@ class MemberService( | ||||
|         return MemberResolveResult(member = member, isNew = true) | ||||
|     } | ||||
|  | ||||
|     fun getPushTokenList(recipient: Long): List<String> { | ||||
|         return repository.getPushTokenList(recipient) | ||||
|             .toSet() | ||||
|             .toList() | ||||
|     } | ||||
|  | ||||
|     private fun checkEmail(email: String) { | ||||
|         val member = repository.findByEmail(email) | ||||
|  | ||||
|   | ||||
| @@ -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) | ||||
|     } | ||||
|   | ||||
| @@ -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 | ||||
| ) | ||||
| @@ -0,0 +1,3 @@ | ||||
| package kr.co.vividnext.sodalive.point | ||||
|  | ||||
| data class GetPointStatusResponse(val point: Int) | ||||
| @@ -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 | ||||
| ) | ||||
| @@ -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)) | ||||
|     } | ||||
| } | ||||
| @@ -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() | ||||
|   | ||||
| @@ -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<PointGrantLog, Long>, 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<GetPointRewardStatusResponse> | ||||
| } | ||||
|  | ||||
| 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<GetPointRewardStatusResponse> { | ||||
|         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() | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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<GetPointUseStatusResponse> { | ||||
|         return usePointRepository.getPointUseStatusByMemberId(member.id!!, timezone) | ||||
|     } | ||||
|  | ||||
|     fun getPointRewardStatus(member: Member, timezone: String): List<GetPointRewardStatusResponse> { | ||||
|         return pointGrantLogRepository.getPointRewardStatusByMemberId(member.id!!, timezone) | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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() | ||||
|   | ||||
| @@ -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<UsePoint, Long> | ||||
| interface UsePointRepository : JpaRepository<UsePoint, Long>, UsePointQueryRepository | ||||
|  | ||||
| interface UsePointQueryRepository { | ||||
|     fun getPointUseStatusByMemberId(memberId: Long, timezone: String): List<GetPointUseStatusResponse> | ||||
| } | ||||
|  | ||||
| class UsePointQueryRepositoryImpl( | ||||
|     private val queryFactory: JPAQueryFactory | ||||
| ) : UsePointQueryRepository { | ||||
|     override fun getPointUseStatusByMemberId(memberId: Long, timezone: String): List<GetPointUseStatusResponse> { | ||||
|         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() | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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분") | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,6 @@ | ||||
| package kr.co.vividnext.sodalive.useraction | ||||
|  | ||||
| enum class PolicyType(val displayName: String) { | ||||
|     DAILY("기간 내 매일"), | ||||
|     TOTAL("기간 내 전체") | ||||
| } | ||||
| @@ -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, "") | ||||
|     } | ||||
| } | ||||
| @@ -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() | ||||
|   | ||||
| @@ -0,0 +1,3 @@ | ||||
| package kr.co.vividnext.sodalive.useraction | ||||
|  | ||||
| data class UserActionRequest(val actionType: ActionType) | ||||
| @@ -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<String> = 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) | ||||
|     } | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user