Skip to content

Commit 9cd7bf4

Browse files
authored
Merge pull request #12 from Nexters/feat/TASK-159-notifications
[TASK-159] 문제 생성 완료 알림 발송
2 parents e848f17 + b91b497 commit 9cd7bf4

20 files changed

Lines changed: 505 additions & 1 deletion

‎build.gradle.kts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ dependencies {
4545
// JWT
4646
implementation("com.nimbusds:nimbus-jose-jwt:${properties["nimbusVersion"]}")
4747

48+
// Push
49+
implementation("com.google.firebase:firebase-admin:${properties["firebaseAdminVersion"]}")
50+
4851
// Test
4952
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
5053
testImplementation("org.springframework.boot:spring-boot-starter-data-mongodb-test")
@@ -108,4 +111,10 @@ tasks.register<Test>("networkTest") {
108111
includeTags("network")
109112
}
110113
outputs.upToDateWhen { false }
114+
115+
// 실패 원인이 외부에 있어 스택 트레이스만으로는 모자란다. 어댑터가 삼킨 실패도 로그로는 남으므로 그대로 흘려보낸다.
116+
testLogging {
117+
showStandardStreams = true
118+
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
119+
}
111120
}

‎gradle.properties‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ kotlinLoggingVersion=8.0.4
22
springdocVersion=3.0.3
33
ktlintVersion=1.8.0
44
nimbusVersion=10.9.1
5+
firebaseAdminVersion=9.9.0
56
kotestVersion=6.2.3
67
mockitoKotlinVersion=5.4.0
78
springAiVersion=2.0.0
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package com.nexters.gitit.application
2+
3+
import com.nexters.gitit.domain.member.MemberRepository
4+
import com.nexters.gitit.domain.notification.NotificationSender
5+
import com.nexters.gitit.domain.notification.QuizResultNotification
6+
import com.nexters.gitit.domain.project.ProjectRepository
7+
import com.nexters.gitit.domain.quizrepo.QuizRepoRepository
8+
import org.springframework.stereotype.Service
9+
10+
/**
11+
* 문제 생성이 끝났음을 그 저장소를 학습 중인 회원들에게 알립니다.
12+
*
13+
* 저장소에서 회원으로 가는 길은 `Project`뿐입니다 — `QuizRepo`는 누가 자기를 학습하는지 모릅니다.
14+
* 그래서 저장소 하나에 회원이 여럿이고, 알림에 실을 프로젝트 id는 회원마다 다릅니다.
15+
*/
16+
@Service
17+
class NotifyQuizResult(
18+
private val quizRepoRepository: QuizRepoRepository,
19+
private val projectRepository: ProjectRepository,
20+
private val memberRepository: MemberRepository,
21+
private val notificationSender: NotificationSender,
22+
) {
23+
/**
24+
* 푸시 콜이 프로젝트 수만큼 나갑니다. [NotificationSender.send]는 토큰 여러 개에 메시지 하나라,
25+
* `projectId`가 회원마다 다른 이상 한 번으로 묶을 수 없습니다. 한 저장소의 학습자가 수백 명이 되면
26+
* 그때 포트에 "토큰별 data"를 넣습니다 — 지금은 루프가 더 쌉니다.
27+
*/
28+
operator fun invoke(command: Command) {
29+
// 생성이 끝난 뒤 삭제된 저장소라면 알릴 것이 없다.
30+
val quizRepo = quizRepoRepository.findById(command.quizRepoId) ?: return
31+
val projects = projectRepository.findAllByQuizRepoId(quizRepo.id)
32+
if (projects.isEmpty()) return
33+
34+
val notification = QuizResultNotification.from(quizRepo.status)
35+
val tokens =
36+
memberRepository
37+
.findAllByIds(projects.map { it.memberId })
38+
.mapNotNull { member -> member.deviceInfo?.deviceToken?.let { member.id to it } }
39+
.toMap()
40+
41+
projects.forEach { project ->
42+
// 푸시 권한을 거부한 회원은 토큰이 없다. 알림이 유일한 결과 통지가 아니라(화면에서 상태를 읽는다) 그냥 건너뛴다.
43+
val token = tokens[project.memberId] ?: return@forEach
44+
45+
notificationSender.send(listOf(token), notification.message(project.id))
46+
}
47+
}
48+
49+
/** 저장소 상태와 알릴 대상은 전부 도큐먼트에서 다시 읽으므로 id 하나만 받습니다. */
50+
data class Command(
51+
val quizRepoId: String,
52+
)
53+
}

‎src/main/kotlin/com/nexters/gitit/domain/member/MemberRepository.kt‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,8 @@ interface MemberRepository {
55

66
fun findById(id: String): Member?
77

8+
/** 없는 id는 결과에서 빠집니다. 개수가 맞는지 확인하려면 부르는 쪽이 셉니다. */
9+
fun findAllByIds(ids: List<String>): List<Member>
10+
811
fun findBySocialIdentity(socialIdentity: SocialIdentity): Member?
912
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.nexters.gitit.domain.notification
2+
3+
/**
4+
* 알림 한 건의 내용. 도메인이 FCM 타입을 모르게 하려고 우리 타입을 둡니다.
5+
*
6+
* [data]는 화면에 안 보이고 앱이 읽는 값(눌렀을 때 갈 곳 등)입니다. 전송 규격상 숫자도 문자열로 넣어야 합니다.
7+
*/
8+
data class NotificationMessage(
9+
val title: String,
10+
val body: String,
11+
val data: Map<String, String> = emptyMap(),
12+
)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.nexters.gitit.domain.notification
2+
3+
/**
4+
* 여러 기기에 같은 푸시 알림을 보냅니다.
5+
*
6+
* 전송 실패를 예외로 올리지 않는 것이 이 포트의 계약입니다 — 알림을 못 보냈다고 부르는 쪽 작업까지
7+
* 되돌릴 이유가 없습니다.
8+
*/
9+
interface NotificationSender {
10+
fun send(
11+
deviceTokens: List<String>,
12+
message: NotificationMessage,
13+
)
14+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package com.nexters.gitit.domain.notification
2+
3+
import com.nexters.gitit.domain.quizrepo.QuizRepoStatus
4+
5+
/**
6+
* 문제 생성 결과를 알리는 문구 한 벌.
7+
*
8+
* 셋으로 가르는 기준은 **받은 사람이 할 수 있는 일**입니다. [REJECTED]는 이 저장소로는 문제를 못 만든다는
9+
* 판정이라 다시 돌려도 결과가 같아 재시도를 권하면 안 되고, [FAILED]는 사고라 다시 하면 성공할 수 있습니다.
10+
*
11+
* 거절 사유(`QuizRepo.rejectedReason`)는 싣지 않습니다 — 내부 에러 코드이고, 화면에서 다시 읽습니다.
12+
*/
13+
enum class QuizResultNotification(
14+
private val type: String,
15+
private val title: String,
16+
private val body: String,
17+
) {
18+
READY("QUIZ_READY", "프로젝트 준비 완료", "새 문제가 도착했어요"),
19+
REJECTED("QUIZ_REJECTED", "문제를 만들 수 없는 저장소예요", "다른 저장소로 등록해 주세요"),
20+
FAILED("QUIZ_FAILED", "문제를 만들지 못했어요", "잠시 후 다시 시도해 주세요"),
21+
;
22+
23+
/**
24+
* [type]을 enum 이름에서 뽑지 않는 이유는 그 문자열이 앱과의 약속이어서입니다 — 여기서 이름을 바꾸면
25+
* 클라이언트가 조용히 못 알아듣습니다.
26+
*
27+
* 문구는 모두에게 같지만 [projectId]는 회원마다 다릅니다. 눌렀을 때 열 화면이 회원별 프로젝트입니다.
28+
*/
29+
fun message(projectId: String): NotificationMessage =
30+
NotificationMessage(
31+
title = title,
32+
body = body,
33+
data = mapOf("type" to type, "projectId" to projectId),
34+
)
35+
36+
companion object {
37+
/** 끝났다는 이벤트를 받고도 상태가 완료·거절이 아니면 사고로 끝난 것입니다. */
38+
fun from(status: QuizRepoStatus): QuizResultNotification =
39+
when (status) {
40+
QuizRepoStatus.COMPLETED -> READY
41+
QuizRepoStatus.REJECTED -> REJECTED
42+
else -> FAILED
43+
}
44+
}
45+
}

‎src/main/kotlin/com/nexters/gitit/domain/project/ProjectRepository.kt‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,7 @@ interface ProjectRepository {
77
* 이미 있을 때 난이도를 덮어쓰지 않는 것이 이 메서드의 계약입니다. 난이도 변경은 등록과 구분되는 별도 행위입니다.
88
*/
99
fun saveIfAbsent(project: Project): Project
10+
11+
/** 저장소 하나를 학습 중인 프로젝트 전부. 문제 생성이 끝났을 때 알릴 대상을 찾는 방향입니다. */
12+
fun findAllByQuizRepoId(quizRepoId: String): List<Project>
1013
}

‎src/main/kotlin/com/nexters/gitit/event/QuizEventHandler.kt‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package com.nexters.gitit.event
22

33
import com.nexters.gitit.application.GenerateQuiz
4+
import com.nexters.gitit.application.NotifyQuizResult
5+
import com.nexters.gitit.domain.quizrepo.QuizGenerationFinished
46
import com.nexters.gitit.domain.quizrepo.QuizGenerationRequested
57
import org.springframework.context.event.EventListener
68
import org.springframework.scheduling.annotation.Async
@@ -15,6 +17,7 @@ import org.springframework.stereotype.Component
1517
@Component
1618
class QuizEventHandler(
1719
private val generateQuiz: GenerateQuiz,
20+
private val notifyQuizResult: NotifyQuizResult,
1821
) {
1922
/**
2023
* 발행자를 붙잡아 두지 않으려고 비동기입니다 — 문제 생성은 한 번에 몇 분이 걸립니다.
@@ -27,4 +30,14 @@ class QuizEventHandler(
2730
fun handle(event: QuizGenerationRequested) {
2831
generateQuiz(GenerateQuiz.Command(event.quizRepoId))
2932
}
33+
34+
/**
35+
* 이 이벤트는 [GenerateQuiz]의 finally에서, 예외를 다시 던지는 경로에서도 나옵니다.
36+
* 동기로 받으면 알림 발송이 그 스택 위에서 돌아 문제 생성 스레드를 더 붙잡습니다.
37+
*/
38+
@Async
39+
@EventListener
40+
fun handle(event: QuizGenerationFinished) {
41+
notifyQuizResult(NotifyQuizResult.Command(event.quizRepoId))
42+
}
3043
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package com.nexters.gitit.infrastructure.firebase
2+
3+
import com.google.firebase.messaging.FirebaseMessaging
4+
import com.google.firebase.messaging.FirebaseMessagingException
5+
import com.google.firebase.messaging.MulticastMessage
6+
import com.google.firebase.messaging.Notification
7+
import com.nexters.gitit.domain.notification.NotificationMessage
8+
import com.nexters.gitit.domain.notification.NotificationSender
9+
import com.nexters.gitit.infrastructure.firebase.FcmNotificationSender.Companion.MULTICAST_TOKEN_LIMIT
10+
import io.github.oshai.kotlinlogging.KotlinLogging
11+
12+
private val logger = KotlinLogging.logger {}
13+
14+
/**
15+
* iOS도 FCM이 APNs로 중계해 기기 종류로 갈라지지 않습니다.
16+
*
17+
* 실패는 로그로만 남깁니다 — 지운 앱의 토큰이 조용히 죽어 있어 실패가 정상 범위입니다.
18+
*/
19+
class FcmNotificationSender(
20+
private val firebaseMessaging: FirebaseMessaging,
21+
) : NotificationSender {
22+
/** 상한을 넘겨 보내면 그 묶음이 통째로 거절되므로 [MULTICAST_TOKEN_LIMIT]개씩 나눠 보냅니다. */
23+
override fun send(
24+
deviceTokens: List<String>,
25+
message: NotificationMessage,
26+
) {
27+
val notification =
28+
Notification
29+
.builder()
30+
.setTitle(message.title)
31+
.setBody(message.body)
32+
.build()
33+
34+
// 빈 목록이면 반복이 안 돌아 따로 거르지 않는다.
35+
deviceTokens.chunked(MULTICAST_TOKEN_LIMIT).forEach { tokens ->
36+
sendChunk(
37+
MulticastMessage
38+
.builder()
39+
.setNotification(notification)
40+
.putAllData(message.data)
41+
.addAllTokens(tokens)
42+
.build(),
43+
)
44+
}
45+
}
46+
47+
private fun sendChunk(message: MulticastMessage) {
48+
try {
49+
val response = firebaseMessaging.sendEachForMulticast(message)
50+
if (response.failureCount > 0) {
51+
// 실패 건수만 남기면 토큰이 죽은 건지 설정이 틀린 건지 구분이 안 돼, 사유 코드를 종류별로 센다.
52+
val reasons =
53+
response.responses
54+
.mapNotNull { it.exception?.messagingErrorCode }
55+
.groupingBy { it }
56+
.eachCount()
57+
logger.warn { "Push delivery failed for ${response.failureCount}/${response.responses.size} devices: $reasons" }
58+
}
59+
} catch (e: FirebaseMessagingException) {
60+
logger.warn(e) { "Push send call failed: ${e.messagingErrorCode}" }
61+
}
62+
}
63+
64+
companion object {
65+
// FCM이 정한 상한. 줄이는 건 되지만 늘리면 거절된다.
66+
private const val MULTICAST_TOKEN_LIMIT = 500
67+
}
68+
}

0 commit comments

Comments
 (0)