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.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"))

View File

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

View File

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

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.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(
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")

View File

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

View File

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

View File

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

View File

@ -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)
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] ❌ 실패: 토큰이 등록되지 않음 (등록 해제됨) → 재시도 안함")
val response = FirebaseMessaging.getInstance().sendEachForMulticast(multicastMessage.build())
val failedTokens = mutableListOf<String>()
response.responses.forEachIndexed { index, res ->
if (!res.isSuccessful) {
val exception = res.exception
val token = targets[index]
if (exception?.messagingErrorCode == MessagingErrorCode.UNREGISTERED) {
logger.error("[FCM] ❌ UNREGISTERED → $token")
// DB에서 삭제
pushTokenService.unregisterInvalidToken(token)
} else {
logger.error("[FCM] ❌ 실패: $token / ${exception?.messagingErrorCode}")
failedTokens.add(token)
}
}
}
if (failedTokens.isEmpty()) {
logger.info("[FCM] ✅ 전체 전송 성공")
return
}
logger.error("[FCM] ❌ 실패 (attempt $attempts): ${e.errorCode} - ${e.message}")
if (attempts >= maxAttempts) {
logger.error("[FCM] ❌ 최종 실패: 전송 불가")
}
} catch (e: Exception) {
// Firebase 이외의 예외도 잡기
targets = failedTokens
attempts++
logger.error("[FCM] ❌ 실패 (attempt $attempts): ${e.message}")
}
if (attempts >= maxAttempts) {
logger.error("[FCM] ❌ 최종 실패: 알 수 없는 오류")
}
}
if (targets.isNotEmpty()) {
logger.error("[FCM] ❌ 최종 실패 대상 ${targets.size}명 → $targets")
}
}
}

View File

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

View File

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

View File

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

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.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!!)
try {
val memberId = member.id!!
val pushTokenList = memberService.getPushTokenList(recipient = memberId)
userActionService.recordAction(
memberId = member.id!!,
isAuth = true,
actionType = ActionType.USER_AUTHENTICATION,
pushToken = member.pushToken
pushTokenList = pushTokenList
)
} catch (_: Exception) {
}
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,
@Enumerated(EnumType.STRING)
val actionType: ActionType,
val policyId: Long?
val policyId: Long?,
val orderId: Long?
) : BaseEntity()

View File

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

View File

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

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

View File

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

View File

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

View File

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

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(
val memberId: Long,
@Enumerated(EnumType.STRING)
val actionType: ActionType
val actionType: ActionType,
val contentCommentId: Long? = null
) : 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.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))
transactionTemplate.execute {
repository.save(
UserActionLog(
memberId = memberId,
actionType = actionType,
contentCommentId = contentCommentId
)
)
repository.flush()
}
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
}
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 = policy.startDate,
startDate = if (isValidPolicyTypeDailyAndDailyStartDateAfterPolicyStartDate) {
policyTypeDailyStartDate
} else {
policy.startDate
},
endDate = policy.endDate ?: now
)
if (actionCount < policy.threshold) return@launch
if (actionCount < policy.threshold) return@execute
val alreadyGranted = grantLogRepository.existsByMemberIdAndPolicyId(memberId, policy.id!!)
if (alreadyGranted) return@launch
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 = policy.pointAmount,
point = point,
actionType = actionType,
expiresAt = now.plusDays(3)
)
)
grantLogRepository.save(
PointGrantLog(
memberId = memberId,
point = policy.pointAmount,
actionType = actionType,
policyId = policy.id!!
)
if (pushTokenList.isNotEmpty()) {
fcmService.sendPointGranted(
pushTokenList,
point
)
}
}
}
}
} catch (e: Exception) {
logger.warn("포인트 지급 또는 알림 실패: ${e.message}")
}
}
}
}
if (pushToken != null) {
fcmService.sendPointGranted(pushToken, policy.pointAmount)
}
}
}
companion object {
private val logger = LoggerFactory.getLogger(UserActionService::class.java)
}
}