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