Compare commits

..

No commits in common. "60c4e0b528e14258935976772f346cbfe1100060" and "84f33d1bc2b205de4d0281273ae34ba865b694bb" have entirely different histories.

29 changed files with 95 additions and 540 deletions

View File

@ -2,17 +2,14 @@ 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
@ -22,10 +19,8 @@ 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,7 +2,6 @@ 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,
@ -12,10 +11,8 @@ 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,10 +33,8 @@ 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,9 +3,6 @@ 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
@ -17,11 +14,7 @@ import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
class AudioContentCommentController(
private val service: AudioContentCommentService,
private val memberService: MemberService,
private val userActionService: UserActionService
) {
class AudioContentCommentController(private val service: AudioContentCommentService) {
@PostMapping("/audio-content/comment")
fun registerComment(
@RequestBody request: RegisterCommentRequest,
@ -29,38 +22,15 @@ class AudioContentCommentController(
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val commentId = service.registerComment(
comment = request.comment,
audioContentId = request.contentId,
parentId = request.parentId,
isSecret = request.isSecret,
member = member
ApiResponse.ok(
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
}
val savedContentComment = repository.save(audioContentComment)
repository.save(audioContentComment)
applicationEventPublisher.publishEvent(
FcmEvent(
@ -84,8 +84,6 @@ class AudioContentCommentService(
myMemberId = member.id
)
)
return savedContentComment.id!!
}
@Transactional

View File

@ -44,7 +44,6 @@ 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
@ -281,17 +280,4 @@ 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, orderId = order.id)
pointUsageService.usePoint(member.id!!, order.can)
} else {
0
}

View File

@ -4,6 +4,8 @@ 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
@ -36,7 +38,7 @@ class FcmService(private val pushTokenService: PushTokenService) {
while (attempt <= maxAttempts && targets.isNotEmpty()) {
val multicastMessage = MulticastMessage.builder()
.addAllTokens(targets)
.addAllTokens(tokens)
multicastMessage.setAndroidConfig(
AndroidConfig.builder()
@ -115,69 +117,51 @@ class FcmService(private val pushTokenService: PushTokenService) {
}
}
fun sendPointGranted(tokens: List<String>, point: Int) {
if (tokens.isEmpty()) return
fun sendPointGranted(token: String, point: Int) {
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 && targets.isNotEmpty()) {
val multicastMessage = MulticastMessage.builder()
.addAllTokens(targets)
.putAllData(data)
multicastMessage.setAndroidConfig(
AndroidConfig.builder()
.setPriority(AndroidConfig.Priority.HIGH)
while (attempts < maxAttempts) {
try {
val message = Message.builder()
.setToken(token)
.putAllData(data)
.build()
)
multicastMessage.setApnsConfig(
ApnsConfig.builder()
.setAps(
Aps.builder()
.setSound("default")
.build()
)
.build()
)
val response = FirebaseMessaging.getInstance().send(message)
logger.info("[FCM] ✅ 성공 (attempt ${attempts + 1}): messageId=$response")
return // 성공 시 즉시 종료
} catch (e: FirebaseMessagingException) {
attempts++
val response = FirebaseMessaging.getInstance().sendEachForMulticast(multicastMessage.build())
val failedTokens = mutableListOf<String>()
// "registration-token-not-registered" 예외 코드 확인
if (e.messagingErrorCode == MessagingErrorCode.UNREGISTERED) {
logger.error("[FCM] ❌ 실패: 토큰이 등록되지 않음 (등록 해제됨) → 재시도 안함")
// DB에서 삭제
pushTokenService.unregisterInvalidToken(token)
return
}
response.responses.forEachIndexed { index, res ->
if (!res.isSuccessful) {
val exception = res.exception
val token = targets[index]
logger.error("[FCM] ❌ 실패 (attempt $attempts): ${e.errorCode} - ${e.message}")
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 (attempts >= maxAttempts) {
logger.error("[FCM] ❌ 최종 실패: 전송 불가")
}
} catch (e: Exception) {
// Firebase 이외의 예외도 잡기
attempts++
logger.error("[FCM] ❌ 실패 (attempt $attempts): ${e.message}")
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,13 +65,8 @@ class MemberController(
userActionService.recordAction(
memberId = response.memberId,
isAuth = false,
actionType = ActionType.SIGN_UP,
pushTokenList = if (request.pushToken != null) {
listOf(request.pushToken)
} else {
emptyList()
}
pushToken = request.pushToken
)
return ApiResponse.ok(message = "회원가입을 축하드립니다.", data = response.loginResponse)
@ -359,13 +354,8 @@ class MemberController(
if (response.isNew) {
userActionService.recordAction(
memberId = response.memberId,
isAuth = false,
actionType = ActionType.SIGN_UP,
pushTokenList = if (request.pushToken != null) {
listOf(request.pushToken)
} else {
emptyList()
}
pushToken = request.pushToken
)
}
@ -395,13 +385,8 @@ class MemberController(
if (response.isNew) {
userActionService.recordAction(
memberId = response.memberId,
isAuth = false,
actionType = ActionType.SIGN_UP,
pushTokenList = if (request.pushToken != null) {
listOf(request.pushToken)
} else {
emptyList()
}
pushToken = request.pushToken
)
}

View File

@ -64,8 +64,6 @@ interface MemberQueryRepository {
fun existsByNickname(nickname: String): Boolean
fun findNicknamesWithPrefix(prefix: String): List<String>
fun getPushTokenList(memberId: Long): List<String>
}
@Repository
@ -509,17 +507,4 @@ 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,12 +907,6 @@ 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,7 +3,6 @@ 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
@ -16,7 +15,6 @@ import org.springframework.web.bind.annotation.RestController
@RequestMapping("/auth")
class AuthController(
private val service: AuthService,
private val memberService: MemberService,
private val userActionService: UserActionService
) {
@PostMapping
@ -35,17 +33,11 @@ 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,
pushTokenList = pushTokenList
)
} catch (_: Exception) {
}
userActionService.recordAction(
memberId = member.id!!,
actionType = ActionType.USER_AUTHENTICATION,
pushToken = member.pushToken
)
ApiResponse.ok(authResponse)
}

View File

@ -1,9 +0,0 @@
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

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

View File

@ -1,9 +0,0 @@
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

@ -1,49 +0,0 @@
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,6 +12,5 @@ data class PointGrantLog(
val point: Int,
@Enumerated(EnumType.STRING)
val actionType: ActionType,
val policyId: Long?,
val orderId: Long?
val policyId: Long?
) : BaseEntity()

View File

@ -1,75 +1,27 @@
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 countByMemberIdAndPolicyIdAndStartDate(
memberId: Long,
policyId: Long,
startDate: LocalDateTime,
orderId: Long? = null
): Int
fun getPointRewardStatusByMemberId(memberId: Long, timezone: String): List<GetPointRewardStatusResponse>
fun existsByMemberIdAndPolicyId(memberId: Long, policyId: Long): Boolean
}
class PointGrantLogQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory
) : PointGrantLogQueryRepository {
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))
}
override fun existsByMemberIdAndPolicyId(memberId: Long, policyId: Long): Boolean {
return queryFactory
.select(pointGrantLog.id)
.from(pointGrantLog)
.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
)
.where(
pointGrantLog.memberId.eq(memberId),
pointGrantLog.policyId.eq(policyId)
)
.from(pointGrantLog)
.innerJoin(pointRewardPolicy).on(pointGrantLog.policyId.eq(pointRewardPolicy.id))
.where(pointGrantLog.memberId.eq(memberId))
.orderBy(pointGrantLog.id.desc())
.fetch()
.isNotEmpty()
}
}

View File

@ -2,7 +2,6 @@ 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
@ -12,11 +11,8 @@ 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

@ -1,29 +0,0 @@
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, orderId: Long?): Int {
fun usePoint(memberId: Long, contentPrice: Int): 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, orderId = orderId))
usePointRepository.save(UsePoint(memberId = memberId, amount = used))
}
return used

View File

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

View File

@ -1,48 +1,5 @@
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>, 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()
}
}
interface UsePointRepository : JpaRepository<UsePoint, Long>

View File

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

View File

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

View File

@ -1,37 +0,0 @@
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,6 +9,5 @@ import javax.persistence.Enumerated
data class UserActionLog(
val memberId: Long,
@Enumerated(EnumType.STRING)
val actionType: ActionType,
val contentCommentId: Long? = null
val actionType: ActionType
) : BaseEntity()

View File

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

View File

@ -3,150 +3,67 @@ 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,
isAuth: Boolean,
actionType: ActionType,
contentId: Long? = null,
contentCommentId: Long? = null,
pushTokenList: List<String> = emptyList()
) {
fun recordAction(memberId: Long, actionType: ActionType, pushToken: String?) {
coroutineScope.launch {
val now = LocalDateTime.now()
transactionTemplate.execute {
repository.save(
UserActionLog(
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(
memberId = memberId,
point = policy.pointAmount,
actionType = actionType,
contentCommentId = contentCommentId
expiresAt = now.plusDays(3)
)
)
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
}
grantLogRepository.save(
PointGrantLog(
memberId = memberId,
point = policy.pointAmount,
actionType = actionType,
policyId = policy.id!!
)
)
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}")
if (pushToken != null) {
fcmService.sendPointGranted(pushToken, policy.pointAmount)
}
}
}
}
companion object {
private val logger = LoggerFactory.getLogger(UserActionService::class.java)
}
}