Merge pull request 'test' (#316) from test into main

Reviewed-on: #316
This commit is contained in:
klaus 2025-05-20 06:03:10 +00:00
commit 60c4e0b528
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)
}
} }