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.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,27 @@ class AudioContentCommentController(private val service: AudioContentCommentServ
) = run { ) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.") if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
ApiResponse.ok( 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!!,
actionType = ActionType.CONTENT_COMMENT,
pushTokenList = pushTokenList
)
} catch (_: Exception) {
}
ApiResponse.ok(Unit, "")
} }
@PutMapping("/audio-content/comment") @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.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

@ -66,7 +66,11 @@ class MemberController(
userActionService.recordAction( userActionService.recordAction(
memberId = response.memberId, memberId = response.memberId,
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)
@ -355,7 +359,11 @@ class MemberController(
userActionService.recordAction( userActionService.recordAction(
memberId = response.memberId, memberId = response.memberId,
actionType = ActionType.SIGN_UP, 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( userActionService.recordAction(
memberId = response.memberId, memberId = response.memberId,
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,16 @@ 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!!,
actionType = ActionType.USER_AUTHENTICATION,
pushTokenList = pushTokenList
)
} catch (_: Exception) {
}
ApiResponse.ok(authResponse) ApiResponse.ok(authResponse)
} }

View File

@ -24,7 +24,12 @@ class UserActionService(
private val coroutineScope = CoroutineScope(Dispatchers.IO) 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 { coroutineScope.launch {
val now = LocalDateTime.now() val now = LocalDateTime.now()
repository.save(UserActionLog(memberId, actionType)) repository.save(UserActionLog(memberId, actionType))
@ -73,8 +78,8 @@ class UserActionService(
) )
) )
if (pushToken != null) { if (pushTokenList.isNotEmpty()) {
fcmService.sendPointGranted(pushToken, policy.pointAmount) fcmService.sendPointGranted(pushTokenList, policy.pointAmount)
} }
} }
} }