feat: 유저 행동 데이터 기록 추가 - 콘텐츠에 댓글 쓰기

This commit is contained in:
Klaus 2025-05-16 20:32:48 +09:00
parent eb8c8c14e8
commit ddcd54d3b9
7 changed files with 132 additions and 52 deletions

View File

@ -3,6 +3,9 @@ package kr.co.vividnext.sodalive.content.comment
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberService
import kr.co.vividnext.sodalive.useraction.ActionType
import kr.co.vividnext.sodalive.useraction.UserActionService
import org.springframework.data.domain.Pageable
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
@ -14,7 +17,11 @@ import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
class AudioContentCommentController(private val service: AudioContentCommentService) {
class AudioContentCommentController(
private val service: AudioContentCommentService,
private val memberService: MemberService,
private val userActionService: UserActionService
) {
@PostMapping("/audio-content/comment")
fun registerComment(
@RequestBody request: RegisterCommentRequest,
@ -22,7 +29,6 @@ class AudioContentCommentController(private val service: AudioContentCommentServ
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
ApiResponse.ok(
service.registerComment(
comment = request.comment,
audioContentId = request.contentId,
@ -30,7 +36,20 @@ class AudioContentCommentController(private val service: AudioContentCommentServ
isSecret = request.isSecret,
member = member
)
try {
val memberId = member.id!!
val pushTokenList = memberService.getPushTokenList(recipient = memberId)
userActionService.recordAction(
memberId = member.id!!,
actionType = ActionType.CONTENT_COMMENT,
pushTokenList = pushTokenList
)
} catch (_: Exception) {
}
ApiResponse.ok(Unit, "")
}
@PutMapping("/audio-content/comment")

View File

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

View File

@ -66,7 +66,11 @@ class MemberController(
userActionService.recordAction(
memberId = response.memberId,
actionType = ActionType.SIGN_UP,
pushToken = request.pushToken
pushTokenList = if (request.pushToken != null) {
listOf(request.pushToken)
} else {
emptyList()
}
)
return ApiResponse.ok(message = "회원가입을 축하드립니다.", data = response.loginResponse)
@ -355,7 +359,11 @@ class MemberController(
userActionService.recordAction(
memberId = response.memberId,
actionType = ActionType.SIGN_UP,
pushToken = request.pushToken
pushTokenList = if (request.pushToken != null) {
listOf(request.pushToken)
} else {
emptyList()
}
)
}
@ -386,7 +394,11 @@ class MemberController(
userActionService.recordAction(
memberId = response.memberId,
actionType = ActionType.SIGN_UP,
pushToken = request.pushToken
pushTokenList = if (request.pushToken != null) {
listOf(request.pushToken)
} else {
emptyList()
}
)
}

View File

@ -64,6 +64,8 @@ interface MemberQueryRepository {
fun existsByNickname(nickname: String): Boolean
fun findNicknamesWithPrefix(prefix: String): List<String>
fun getPushTokenList(memberId: Long): List<String>
}
@Repository
@ -507,4 +509,17 @@ class MemberQueryRepositoryImpl(
)
.fetch()
}
override fun getPushTokenList(memberId: Long): List<String> {
val where = member.isActive.isTrue
.and(member.email.notIn("admin@sodalive.net"))
.and(member.id.eq(memberId))
return queryFactory
.select(pushToken.token)
.from(member)
.innerJoin(pushToken).on(member.id.eq(pushToken.member.id))
.where(where)
.fetch()
}
}

View File

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

View File

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

View File

@ -24,7 +24,12 @@ class UserActionService(
private val coroutineScope = CoroutineScope(Dispatchers.IO)
fun recordAction(memberId: Long, actionType: ActionType, orderId: Long? = null, pushToken: String? = null) {
fun recordAction(
memberId: Long,
actionType: ActionType,
orderId: Long? = null,
pushTokenList: List<String> = emptyList()
) {
coroutineScope.launch {
val now = LocalDateTime.now()
repository.save(UserActionLog(memberId, actionType))
@ -73,8 +78,8 @@ class UserActionService(
)
)
if (pushToken != null) {
fcmService.sendPointGranted(pushToken, policy.pointAmount)
if (pushTokenList.isNotEmpty()) {
fcmService.sendPointGranted(pushTokenList, policy.pointAmount)
}
}
}