Compare commits

..

21 Commits

Author SHA1 Message Date
klaus 60c4e0b528 Merge pull request 'test' (#316) from test into main
Reviewed-on: #316
2025-05-20 06:03:10 +00:00
Klaus d3ec13e6c0 fix: 유저 행동 데이터에 따른 포인트 지급
- 본인인증을 한 유저만 포인트 정책에 따라 포인트를 지급하도록 수정
2025-05-20 00:51:04 +09:00
Klaus a36d9f02d8 fix: 포인트 내역 리스트
- 유저의 포인트 보상내역, 사용내역 id 내림차순 정렬
2025-05-20 00:14:57 +09:00
Klaus d6db862c9d fix: 포인트 내역 리스트
- 유저의 포인트 보상내역, 사용내역 API 추가
2025-05-19 21:38:24 +09:00
Klaus 56542a7bf1 fix: 포인트 사용내역
- 포인트를 어디에 사용했는지 알기 위해 포인트 사용내역 저장시 orderId 추가
2025-05-19 20:49:16 +09:00
Klaus 36b8e8169e fix: 유저 행동 데이터에 따른 포인트 지급
- 유저가 지급 받을 포인트가 0 이상인 경우에만 포인트 지급 로그를 남기고 푸시 발송
2025-05-19 16:27:58 +09:00
Klaus b102241efd fix: 유저 행동 데이터
- commentId -> contentCommentId 로 변경
2025-05-19 15:25:17 +09:00
Klaus f36010fefa fix: 유저 행동 데이터
- commentId -> contentCommentId 로 변경
2025-05-19 15:17:44 +09:00
Klaus aa23d6d50f fix: 주문한 콘텐츠에 댓글 작성 이벤트
- 포인트 받은 현황을 조회할 때 주문 ID를 같이 조회하도록 만들어서 주문한 콘텐츠에 댓글 작성 이벤트의 경우 주문별로 참여할 수 있도록 수정
2025-05-19 15:08:21 +09:00
Klaus 6df043dfac fix: 콘텐츠 댓글 작성시 유저 행동 데이터에 댓글 ID를 같이 기록하도록 수정 2025-05-19 15:05:31 +09:00
Klaus fe84292483 fix: 포인트 지급 요소 계산시 정책 시작 날짜 이후의 유저 행동들만 반영하도록 수정 2025-05-19 14:43:50 +09:00
Klaus 0f48c71837 fix: transactionTemplate 을 적용하여 횟수가 잘못 판단되는 경우 최소화 2025-05-19 11:43:24 +09:00
Klaus 107e8fce55 fix: 유저의 행동 데이터 기록시 주문한 콘텐츠에 댓글을 쓰는 것을 판단하기 위해 주문 정보 조회시 id 내림차순으로 하여 가장 최근 주문정보를 가져오도록 수정 2025-05-19 10:49:16 +09:00
Klaus 3079998a5d fix: 구매한 콘텐츠 댓글 이벤트 추가
- 구매한 콘텐츠 댓글 쓰기시 구매한 캔을 포인트로 지급 해야 되는데 설정한 포인트로 지급되는 버그 수정
2025-05-17 18:44:04 +09:00
Klaus e2d0ae558a feat: 구매한 콘텐츠 댓글 이벤트 추가
- 구매한 콘텐츠 댓글 쓰기시 구매한 캔을 포인트로 지급
2025-05-17 18:13:11 +09:00
Klaus 1bca1b27ed feat: 구매한 콘텐츠 댓글 이벤트 추가 2025-05-17 18:07:02 +09:00
Klaus 6fc372c898 feat: 유저 행동 데이터 기록 Controller 추가 2025-05-16 21:24:12 +09:00
Klaus ddcd54d3b9 feat: 유저 행동 데이터 기록 추가 - 콘텐츠에 댓글 쓰기 2025-05-16 20:32:48 +09:00
Klaus eb8c8c14e8 fix: 유저 행동 데이터 기록시 포인트 지급과 로그 기록 순서 변경
- 기존: 포인트 지급 후 로그 기록
- 변경: 로그 기록 후 포인트 지급
2025-05-16 17:57:37 +09:00
Klaus affc0cc235 fix: 관리자 - 포인트 정책 리스트 값 추가
- 지급유형(매일, 전체) 추가
- 참여가능 횟수 추가
2025-05-16 17:31:28 +09:00
Klaus f23251f5bb fix: 유저 행동 데이터 기록시 포인트 지급 조건 수정
- 지급유형(매일, 전체) 추가
- 참여가능 횟수 추가
- 주문한 콘텐츠에 댓글을 쓰면 포인트 지급을 위해 포인트 지급 이력에 orderId 추가
2025-05-16 15:01:33 +09:00
29 changed files with 541 additions and 96 deletions

View File

@ -2,14 +2,17 @@ package kr.co.vividnext.sodalive.admin.point
import kr.co.vividnext.sodalive.point.PointRewardPolicy import kr.co.vividnext.sodalive.point.PointRewardPolicy
import kr.co.vividnext.sodalive.useraction.ActionType import kr.co.vividnext.sodalive.useraction.ActionType
import kr.co.vividnext.sodalive.useraction.PolicyType
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
data class CreatePointRewardPolicyRequest( data class CreatePointRewardPolicyRequest(
val title: String, val title: String,
val policyType: PolicyType,
val actionType: ActionType, val actionType: ActionType,
val threshold: Int, val threshold: Int,
val availableCount: Int,
val pointAmount: Int, val pointAmount: Int,
val startDate: String, val startDate: String,
val endDate: String val endDate: String
@ -19,8 +22,10 @@ data class CreatePointRewardPolicyRequest(
return PointRewardPolicy( return PointRewardPolicy(
title = title, title = title,
policyType = policyType,
actionType = actionType, actionType = actionType,
threshold = threshold, threshold = threshold,
availableCount = availableCount,
pointAmount = pointAmount, pointAmount = pointAmount,
startDate = LocalDateTime.parse(startDate, dateTimeFormatter) startDate = LocalDateTime.parse(startDate, dateTimeFormatter)
.atZone(ZoneId.of("Asia/Seoul")) .atZone(ZoneId.of("Asia/Seoul"))

View File

@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.admin.point
import com.querydsl.core.annotations.QueryProjection import com.querydsl.core.annotations.QueryProjection
import kr.co.vividnext.sodalive.useraction.ActionType import kr.co.vividnext.sodalive.useraction.ActionType
import kr.co.vividnext.sodalive.useraction.PolicyType
data class GetPointRewardPolicyListResponse( data class GetPointRewardPolicyListResponse(
val totalCount: Int, val totalCount: Int,
@ -11,8 +12,10 @@ data class GetPointRewardPolicyListResponse(
data class GetPointRewardPolicyListItem @QueryProjection constructor( data class GetPointRewardPolicyListItem @QueryProjection constructor(
val id: Long, val id: Long,
val title: String, val title: String,
val policyType: PolicyType,
val actionType: ActionType, val actionType: ActionType,
val threshold: Int, val threshold: Int,
val availableCount: Int,
val pointAmount: Int, val pointAmount: Int,
val startDate: String, val startDate: String,
val endDate: String, val endDate: String,

View File

@ -33,8 +33,10 @@ class PointPolicyQueryRepositoryImpl(
QGetPointRewardPolicyListItem( QGetPointRewardPolicyListItem(
pointRewardPolicy.id, pointRewardPolicy.id,
pointRewardPolicy.title, pointRewardPolicy.title,
pointRewardPolicy.policyType,
pointRewardPolicy.actionType, pointRewardPolicy.actionType,
pointRewardPolicy.threshold, pointRewardPolicy.threshold,
pointRewardPolicy.availableCount,
pointRewardPolicy.pointAmount, pointRewardPolicy.pointAmount,
getFormattedDate(pointRewardPolicy.startDate), getFormattedDate(pointRewardPolicy.startDate),
getFormattedDate(pointRewardPolicy.endDate), getFormattedDate(pointRewardPolicy.endDate),

View File

@ -3,6 +3,9 @@ package kr.co.vividnext.sodalive.content.comment
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member 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.data.domain.Pageable
import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping 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 import org.springframework.web.bind.annotation.RestController
@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") @PostMapping("/audio-content/comment")
fun registerComment( fun registerComment(
@RequestBody request: RegisterCommentRequest, @RequestBody request: RegisterCommentRequest,
@ -22,15 +29,38 @@ class AudioContentCommentController(private val service: AudioContentCommentServ
) = run { ) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.") if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
ApiResponse.ok( val commentId = service.registerComment(
service.registerComment( comment = request.comment,
comment = request.comment, audioContentId = request.contentId,
audioContentId = request.contentId, parentId = request.parentId,
parentId = request.parentId, isSecret = request.isSecret,
isSecret = request.isSecret, member = member
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") @PutMapping("/audio-content/comment")

View File

@ -33,7 +33,7 @@ class AudioContentCommentService(
audioContentId: Long, audioContentId: Long,
parentId: Long? = null, parentId: Long? = null,
isSecret: Boolean = false isSecret: Boolean = false
) { ): Long {
val audioContent = audioContentRepository.findByIdOrNull(id = audioContentId) val audioContent = audioContentRepository.findByIdOrNull(id = audioContentId)
?: throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.") ?: throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.")
@ -64,7 +64,7 @@ class AudioContentCommentService(
audioContentComment.parent = parent audioContentComment.parent = parent
} }
repository.save(audioContentComment) val savedContentComment = repository.save(audioContentComment)
applicationEventPublisher.publishEvent( applicationEventPublisher.publishEvent(
FcmEvent( FcmEvent(
@ -84,6 +84,8 @@ class AudioContentCommentService(
myMemberId = member.id myMemberId = member.id
) )
) )
return savedContentComment.id!!
} }
@Transactional @Transactional

View File

@ -44,6 +44,7 @@ interface OrderQueryRepository {
fun findOrderedContent(contentIdList: List<Long>, memberId: Long): List<Long> fun findOrderedContent(contentIdList: List<Long>, memberId: Long): List<Long>
fun findEndDateByContentId(contentIdList: List<Long>, memberId: Long): List<ContentIdAndEndDateData> fun findEndDateByContentId(contentIdList: List<Long>, memberId: Long): List<ContentIdAndEndDateData>
fun findBuyerListByContentId(contentId: Long): List<ContentBuyer> fun findBuyerListByContentId(contentId: Long): List<ContentBuyer>
fun findByMemberIdAndContentId(memberId: Long, contentId: Long, createdAt: LocalDateTime): Order?
} }
@Repository @Repository
@ -280,4 +281,17 @@ class OrderQueryRepositoryImpl(
) )
.fetch() .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()
}
} }

View File

@ -44,7 +44,7 @@ class OrderService(
} }
val usedPoint = if (order.type == OrderType.RENTAL && content.isPointAvailable) { 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 { } else {
0 0
} }

View File

@ -4,8 +4,6 @@ import com.google.firebase.messaging.AndroidConfig
import com.google.firebase.messaging.ApnsConfig import com.google.firebase.messaging.ApnsConfig
import com.google.firebase.messaging.Aps import com.google.firebase.messaging.Aps
import com.google.firebase.messaging.FirebaseMessaging 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.MessagingErrorCode
import com.google.firebase.messaging.MulticastMessage import com.google.firebase.messaging.MulticastMessage
import com.google.firebase.messaging.Notification import com.google.firebase.messaging.Notification
@ -38,7 +36,7 @@ class FcmService(private val pushTokenService: PushTokenService) {
while (attempt <= maxAttempts && targets.isNotEmpty()) { while (attempt <= maxAttempts && targets.isNotEmpty()) {
val multicastMessage = MulticastMessage.builder() val multicastMessage = MulticastMessage.builder()
.addAllTokens(tokens) .addAllTokens(targets)
multicastMessage.setAndroidConfig( multicastMessage.setAndroidConfig(
AndroidConfig.builder() 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( val data = mapOf(
"type" to "POINT_GRANTED", "type" to "POINT_GRANTED",
"point" to point.toString(), "point" to point.toString(),
"message" to "${point}포인트가 지급되었습니다!" "message" to "${point}포인트가 지급되었습니다!"
) )
var targets = tokens
var attempts = 0 var attempts = 0
val maxAttempts = 3 val maxAttempts = 3
while (attempts < maxAttempts) { while (attempts <= maxAttempts && targets.isNotEmpty()) {
try { val multicastMessage = MulticastMessage.builder()
val message = Message.builder() .addAllTokens(targets)
.setToken(token) .putAllData(data)
.putAllData(data)
multicastMessage.setAndroidConfig(
AndroidConfig.builder()
.setPriority(AndroidConfig.Priority.HIGH)
.build() .build()
)
val response = FirebaseMessaging.getInstance().send(message) multicastMessage.setApnsConfig(
logger.info("[FCM] ✅ 성공 (attempt ${attempts + 1}): messageId=$response") ApnsConfig.builder()
return // 성공 시 즉시 종료 .setAps(
} catch (e: FirebaseMessagingException) { Aps.builder()
attempts++ .setSound("default")
.build()
)
.build()
)
// "registration-token-not-registered" 예외 코드 확인 val response = FirebaseMessaging.getInstance().sendEachForMulticast(multicastMessage.build())
if (e.messagingErrorCode == MessagingErrorCode.UNREGISTERED) { val failedTokens = mutableListOf<String>()
logger.error("[FCM] ❌ 실패: 토큰이 등록되지 않음 (등록 해제됨) → 재시도 안함")
// DB에서 삭제
pushTokenService.unregisterInvalidToken(token)
return
}
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) { if (exception?.messagingErrorCode == MessagingErrorCode.UNREGISTERED) {
logger.error("[FCM] ❌ 최종 실패: 전송 불가") logger.error("[FCM] ❌ UNREGISTERED → $token")
} // DB에서 삭제
} catch (e: Exception) { pushTokenService.unregisterInvalidToken(token)
// Firebase 이외의 예외도 잡기 } else {
attempts++ logger.error("[FCM] ❌ 실패: $token / ${exception?.messagingErrorCode}")
logger.error("[FCM] ❌ 실패 (attempt $attempts): ${e.message}") failedTokens.add(token)
}
if (attempts >= maxAttempts) {
logger.error("[FCM] ❌ 최종 실패: 알 수 없는 오류")
} }
} }
if (failedTokens.isEmpty()) {
logger.info("[FCM] ✅ 전체 전송 성공")
return
}
targets = failedTokens
attempts++
}
if (targets.isNotEmpty()) {
logger.error("[FCM] ❌ 최종 실패 대상 ${targets.size}명 → $targets")
} }
} }
} }

View File

@ -65,8 +65,13 @@ class MemberController(
userActionService.recordAction( userActionService.recordAction(
memberId = response.memberId, memberId = response.memberId,
isAuth = false,
actionType = ActionType.SIGN_UP, actionType = ActionType.SIGN_UP,
pushToken = request.pushToken pushTokenList = if (request.pushToken != null) {
listOf(request.pushToken)
} else {
emptyList()
}
) )
return ApiResponse.ok(message = "회원가입을 축하드립니다.", data = response.loginResponse) return ApiResponse.ok(message = "회원가입을 축하드립니다.", data = response.loginResponse)
@ -354,8 +359,13 @@ class MemberController(
if (response.isNew) { if (response.isNew) {
userActionService.recordAction( userActionService.recordAction(
memberId = response.memberId, memberId = response.memberId,
isAuth = false,
actionType = ActionType.SIGN_UP, 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) { if (response.isNew) {
userActionService.recordAction( userActionService.recordAction(
memberId = response.memberId, memberId = response.memberId,
isAuth = false,
actionType = ActionType.SIGN_UP, actionType = ActionType.SIGN_UP,
pushToken = request.pushToken pushTokenList = if (request.pushToken != null) {
listOf(request.pushToken)
} else {
emptyList()
}
) )
} }

View File

@ -64,6 +64,8 @@ interface MemberQueryRepository {
fun existsByNickname(nickname: String): Boolean fun existsByNickname(nickname: String): Boolean
fun findNicknamesWithPrefix(prefix: String): List<String> fun findNicknamesWithPrefix(prefix: String): List<String>
fun getPushTokenList(memberId: Long): List<String>
} }
@Repository @Repository
@ -507,4 +509,17 @@ class MemberQueryRepositoryImpl(
) )
.fetch() .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()
}
} }

View File

@ -907,6 +907,12 @@ class MemberService(
return MemberResolveResult(member = member, isNew = true) return MemberResolveResult(member = member, isNew = true)
} }
fun getPushTokenList(recipient: Long): List<String> {
return repository.getPushTokenList(recipient)
.toSet()
.toList()
}
private fun checkEmail(email: String) { private fun checkEmail(email: String) {
val member = repository.findByEmail(email) val member = repository.findByEmail(email)

View File

@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.member.auth
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member 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.ActionType
import kr.co.vividnext.sodalive.useraction.UserActionService import kr.co.vividnext.sodalive.useraction.UserActionService
import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.annotation.AuthenticationPrincipal
@ -15,6 +16,7 @@ import org.springframework.web.bind.annotation.RestController
@RequestMapping("/auth") @RequestMapping("/auth")
class AuthController( class AuthController(
private val service: AuthService, private val service: AuthService,
private val memberService: MemberService,
private val userActionService: UserActionService private val userActionService: UserActionService
) { ) {
@PostMapping @PostMapping
@ -33,11 +35,17 @@ class AuthController(
val authResponse = service.authenticate(authenticateData, member.id!!) val authResponse = service.authenticate(authenticateData, member.id!!)
userActionService.recordAction( try {
memberId = member.id!!, val memberId = member.id!!
actionType = ActionType.USER_AUTHENTICATION, val pushTokenList = memberService.getPushTokenList(recipient = memberId)
pushToken = member.pushToken userActionService.recordAction(
) memberId = member.id!!,
isAuth = true,
actionType = ActionType.USER_AUTHENTICATION,
pushTokenList = pushTokenList
)
} catch (_: Exception) {
}
ApiResponse.ok(authResponse) ApiResponse.ok(authResponse)
} }

View File

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

View File

@ -0,0 +1,3 @@
package kr.co.vividnext.sodalive.point
data class GetPointStatusResponse(val point: Int)

View File

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

View File

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

View File

@ -12,5 +12,6 @@ data class PointGrantLog(
val point: Int, val point: Int,
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
val actionType: ActionType, val actionType: ActionType,
val policyId: Long? val policyId: Long?,
val orderId: Long?
) : BaseEntity() ) : BaseEntity()

View File

@ -1,27 +1,75 @@
package kr.co.vividnext.sodalive.point package kr.co.vividnext.sodalive.point
import com.querydsl.core.types.dsl.Expressions
import com.querydsl.jpa.impl.JPAQueryFactory import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.point.QPointGrantLog.pointGrantLog import kr.co.vividnext.sodalive.point.QPointGrantLog.pointGrantLog
import kr.co.vividnext.sodalive.point.QPointRewardPolicy.pointRewardPolicy
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import java.time.LocalDateTime
interface PointGrantLogRepository : JpaRepository<PointGrantLog, Long>, PointGrantLogQueryRepository interface PointGrantLogRepository : JpaRepository<PointGrantLog, Long>, PointGrantLogQueryRepository
interface 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( class PointGrantLogQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory private val queryFactory: JPAQueryFactory
) : PointGrantLogQueryRepository { ) : 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 return queryFactory
.select(pointGrantLog.id) .select(pointGrantLog.id)
.from(pointGrantLog) .from(pointGrantLog)
.where( .where(where)
pointGrantLog.memberId.eq(memberId), .fetch()
pointGrantLog.policyId.eq(policyId) .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() .fetch()
.isNotEmpty()
} }
} }

View File

@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.point
import kr.co.vividnext.sodalive.common.BaseEntity import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.useraction.ActionType import kr.co.vividnext.sodalive.useraction.ActionType
import kr.co.vividnext.sodalive.useraction.PolicyType
import java.time.LocalDateTime import java.time.LocalDateTime
import javax.persistence.Entity import javax.persistence.Entity
import javax.persistence.EnumType import javax.persistence.EnumType
@ -11,8 +12,11 @@ import javax.persistence.Enumerated
data class PointRewardPolicy( data class PointRewardPolicy(
var title: String, var title: String,
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
val policyType: PolicyType,
@Enumerated(EnumType.STRING)
val actionType: ActionType, val actionType: ActionType,
val threshold: Int, val threshold: Int,
val availableCount: Int,
val pointAmount: Int, val pointAmount: Int,
var startDate: LocalDateTime, var startDate: LocalDateTime,
var endDate: LocalDateTime? = null, var endDate: LocalDateTime? = null,

View File

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

View File

@ -8,7 +8,7 @@ class PointUsageService(
private val memberPointRepository: MemberPointRepository, private val memberPointRepository: MemberPointRepository,
private val usePointRepository: UsePointRepository 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 now = LocalDateTime.now()
val maxUsablePoint = contentPrice * 10 val maxUsablePoint = contentPrice * 10
@ -33,7 +33,7 @@ class PointUsageService(
if (used > 0) { if (used > 0) {
memberPointRepository.saveAll(points) memberPointRepository.saveAll(points)
usePointRepository.save(UsePoint(memberId = memberId, amount = used)) usePointRepository.save(UsePoint(memberId = memberId, amount = used, orderId = orderId))
} }
return used return used

View File

@ -6,5 +6,6 @@ import javax.persistence.Entity
@Entity @Entity
data class UsePoint( data class UsePoint(
val memberId: Long, val memberId: Long,
val amount: Int val amount: Int,
val orderId: Long? = null
) : BaseEntity() ) : BaseEntity()

View File

@ -1,5 +1,48 @@
package kr.co.vividnext.sodalive.point 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 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()
}
}

View File

@ -2,5 +2,8 @@ package kr.co.vividnext.sodalive.useraction
enum class ActionType(val displayName: String) { enum class ActionType(val displayName: String) {
SIGN_UP("회원가입"), SIGN_UP("회원가입"),
USER_AUTHENTICATION("본인인증") USER_AUTHENTICATION("본인인증"),
CONTENT_COMMENT("콘텐츠 댓글"),
ORDER_CONTENT_COMMENT("구매한 콘텐츠 댓글"),
LIVE_CONTINUOUS_LISTEN_30("라이브 연속 청취 30분")
} }

View File

@ -0,0 +1,6 @@
package kr.co.vividnext.sodalive.useraction
enum class PolicyType(val displayName: String) {
DAILY("기간 내 매일"),
TOTAL("기간 내 전체")
}

View File

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

View File

@ -9,5 +9,6 @@ import javax.persistence.Enumerated
data class UserActionLog( data class UserActionLog(
val memberId: Long, val memberId: Long,
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
val actionType: ActionType val actionType: ActionType,
val contentCommentId: Long? = null
) : BaseEntity() ) : BaseEntity()

View File

@ -0,0 +1,3 @@
package kr.co.vividnext.sodalive.useraction
data class UserActionRequest(val actionType: ActionType)

View File

@ -3,67 +3,150 @@ package kr.co.vividnext.sodalive.useraction
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kr.co.vividnext.sodalive.content.order.OrderRepository
import kr.co.vividnext.sodalive.fcm.FcmService import kr.co.vividnext.sodalive.fcm.FcmService
import kr.co.vividnext.sodalive.point.MemberPoint import kr.co.vividnext.sodalive.point.MemberPoint
import kr.co.vividnext.sodalive.point.MemberPointRepository import kr.co.vividnext.sodalive.point.MemberPointRepository
import kr.co.vividnext.sodalive.point.PointGrantLog import kr.co.vividnext.sodalive.point.PointGrantLog
import kr.co.vividnext.sodalive.point.PointGrantLogRepository import kr.co.vividnext.sodalive.point.PointGrantLogRepository
import kr.co.vividnext.sodalive.point.PointRewardPolicyRepository import kr.co.vividnext.sodalive.point.PointRewardPolicyRepository
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.support.TransactionTemplate
import java.time.LocalDateTime import java.time.LocalDateTime
@Service @Service
class UserActionService( class UserActionService(
private val repository: UserActionLogRepository, private val repository: UserActionLogRepository,
private val orderRepository: OrderRepository,
private val policyRepository: PointRewardPolicyRepository, private val policyRepository: PointRewardPolicyRepository,
private val grantLogRepository: PointGrantLogRepository, private val grantLogRepository: PointGrantLogRepository,
private val memberPointRepository: MemberPointRepository, private val memberPointRepository: MemberPointRepository,
private val transactionTemplate: TransactionTemplate,
private val fcmService: FcmService private val fcmService: FcmService
) { ) {
private val coroutineScope = CoroutineScope(Dispatchers.IO) 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 { coroutineScope.launch {
val now = LocalDateTime.now() val now = LocalDateTime.now()
repository.save(UserActionLog(memberId, actionType)) transactionTemplate.execute {
repository.save(
val policy = policyRepository.findByActionTypeAndIsActiveTrue(actionType, now) UserActionLog(
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, memberId = memberId,
point = policy.pointAmount,
actionType = actionType, actionType = actionType,
expiresAt = now.plusDays(3) contentCommentId = contentCommentId
) )
) )
repository.flush()
}
grantLogRepository.save( if (isAuth) {
PointGrantLog( try {
memberId = memberId, transactionTemplate.execute {
point = policy.pointAmount, val policy = policyRepository.findByActionTypeAndIsActiveTrue(actionType, now)
actionType = actionType, if (policy != null) {
policyId = policy.id!! 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) { val isValidPolicyTypeDailyAndDailyStartDateAfterPolicyStartDate =
fcmService.sendPointGranted(pushToken, policy.pointAmount) 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)
}
} }