From acb9b2f3bac4e74e00bedb6bbc161d1373fa3071 Mon Sep 17 00:00:00 2001 From: JiWoo Date: Mon, 22 Jan 2024 21:14:02 +0900 Subject: [PATCH] [Feat] fcm alaram #26 (#29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * setting: 서브모듈 갱신과 jvmTarget 17로 명시 * feat(Events): Events 구현 * feat(ReminderService): 물주기 알림을 주는 ReminderService 구현 * feat(FirebaseConfig): FCM scope 추가 * refactor(FCMChannel): 페키지 이동 --- build.gradle.kts | 8 +++ .../common/config/EventsConfiguration.kt | 19 ++++++ .../plantory/common/config/FirebaseConfig.kt | 5 +- src/main/kotlin/gdsc/plantory/event/Events.kt | 20 ++++++ .../kotlin/gdsc/plantory/event/FCMChannel.kt | 5 ++ .../event/notification/WaterCycleEvent.kt | 7 ++ .../notification/WaterCycleEventListener.kt | 66 +++++++++++++++++++ .../plant/domain/CompanionPlantRepository.kt | 14 ++++ .../dto/CompanionPlantWaterCycleDto.kt | 6 ++ .../plantory/plant/service/ReminderService.kt | 35 ++++++++++ src/main/resources/application.yml | 1 + src/main/resources/config | 2 +- .../domain/CompanionPlantRepositoryTest.kt | 64 ++++++++++++++++++ src/test/resources/application.yml | 5 ++ 14 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 src/main/kotlin/gdsc/plantory/common/config/EventsConfiguration.kt create mode 100644 src/main/kotlin/gdsc/plantory/event/Events.kt create mode 100644 src/main/kotlin/gdsc/plantory/event/FCMChannel.kt create mode 100644 src/main/kotlin/gdsc/plantory/event/notification/WaterCycleEvent.kt create mode 100644 src/main/kotlin/gdsc/plantory/event/notification/WaterCycleEventListener.kt create mode 100644 src/main/kotlin/gdsc/plantory/plant/presentation/dto/CompanionPlantWaterCycleDto.kt create mode 100644 src/main/kotlin/gdsc/plantory/plant/service/ReminderService.kt create mode 100644 src/test/kotlin/gdsc/plantory/plant/domain/CompanionPlantRepositoryTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index bc6f91b..7afc595 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -72,3 +72,11 @@ tasks.withType { tasks.withType { useJUnitPlatform() } +val compileKotlin: KotlinCompile by tasks +compileKotlin.kotlinOptions { + jvmTarget = "17" +} +val compileTestKotlin: KotlinCompile by tasks +compileTestKotlin.kotlinOptions { + jvmTarget = "17" +} diff --git a/src/main/kotlin/gdsc/plantory/common/config/EventsConfiguration.kt b/src/main/kotlin/gdsc/plantory/common/config/EventsConfiguration.kt new file mode 100644 index 0000000..8b3f125 --- /dev/null +++ b/src/main/kotlin/gdsc/plantory/common/config/EventsConfiguration.kt @@ -0,0 +1,19 @@ +package gdsc.plantory.common.config + +import gdsc.plantory.event.Events +import org.springframework.beans.factory.InitializingBean +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.ApplicationContext +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class EventsConfiguration( + @Autowired val applicationContext: ApplicationContext, +) { + + @Bean + fun eventsInitializer(): InitializingBean { + return InitializingBean { Events.setPublisher(applicationContext) } + } +} diff --git a/src/main/kotlin/gdsc/plantory/common/config/FirebaseConfig.kt b/src/main/kotlin/gdsc/plantory/common/config/FirebaseConfig.kt index 2578533..961e67f 100644 --- a/src/main/kotlin/gdsc/plantory/common/config/FirebaseConfig.kt +++ b/src/main/kotlin/gdsc/plantory/common/config/FirebaseConfig.kt @@ -13,7 +13,9 @@ import java.util.Optional @Configuration class FirebaseConfig( @Value("\${fcm.key.path}") - private val fcmPrivateKeyPath: String + private val fcmPrivateKeyPath: String, + @Value("\${fcm.key.scope}") + private val fcmScope: String ) { @Bean @@ -48,5 +50,6 @@ class FirebaseConfig( private fun createGoogleCredentials(): GoogleCredentials { return GoogleCredentials .fromStream(ClassPathResource(fcmPrivateKeyPath).inputStream) + .createScoped(fcmScope) } } diff --git a/src/main/kotlin/gdsc/plantory/event/Events.kt b/src/main/kotlin/gdsc/plantory/event/Events.kt new file mode 100644 index 0000000..4f3a1ab --- /dev/null +++ b/src/main/kotlin/gdsc/plantory/event/Events.kt @@ -0,0 +1,20 @@ +package gdsc.plantory.event + +import org.springframework.context.ApplicationEventPublisher +import java.util.Objects + +class Events { + companion object { + private lateinit var publisher: ApplicationEventPublisher + + fun setPublisher(publisher: ApplicationEventPublisher) { + Events.publisher = publisher + } + + fun raise(event: Any) { + if (Objects.nonNull(publisher)) { + publisher.publishEvent(event) + } + } + } +} diff --git a/src/main/kotlin/gdsc/plantory/event/FCMChannel.kt b/src/main/kotlin/gdsc/plantory/event/FCMChannel.kt new file mode 100644 index 0000000..2151249 --- /dev/null +++ b/src/main/kotlin/gdsc/plantory/event/FCMChannel.kt @@ -0,0 +1,5 @@ +package gdsc.plantory.event + +enum class FCMChannel { + WATER_ALERT, +} diff --git a/src/main/kotlin/gdsc/plantory/event/notification/WaterCycleEvent.kt b/src/main/kotlin/gdsc/plantory/event/notification/WaterCycleEvent.kt new file mode 100644 index 0000000..917df71 --- /dev/null +++ b/src/main/kotlin/gdsc/plantory/event/notification/WaterCycleEvent.kt @@ -0,0 +1,7 @@ +package gdsc.plantory.event.notification + +data class WaterCycleEvent( + val deviceToken: String, + val title: String = "물을 줄 시간이에요!", + val body: String = "반려 식물에게 물을 줄 시간이에요!", +) diff --git a/src/main/kotlin/gdsc/plantory/event/notification/WaterCycleEventListener.kt b/src/main/kotlin/gdsc/plantory/event/notification/WaterCycleEventListener.kt new file mode 100644 index 0000000..5d7d617 --- /dev/null +++ b/src/main/kotlin/gdsc/plantory/event/notification/WaterCycleEventListener.kt @@ -0,0 +1,66 @@ +package gdsc.plantory.event.notification + +import com.google.firebase.messaging.AndroidConfig +import com.google.firebase.messaging.AndroidNotification +import com.google.firebase.messaging.FirebaseMessaging +import com.google.firebase.messaging.FirebaseMessagingException +import com.google.firebase.messaging.Message +import com.google.firebase.messaging.Notification +import gdsc.plantory.event.FCMChannel +import org.slf4j.LoggerFactory +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Component +import org.springframework.transaction.event.TransactionPhase +import org.springframework.transaction.event.TransactionalEventListener + +@Component +class WaterCycleEventListener( + val firebaseMessaging: FirebaseMessaging, +) { + + companion object { + private val log = LoggerFactory.getLogger(WaterCycleEventListener::class.java) + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + fun sendFcmNotification(events: List) { + val messages: List = createMessages(events, FCMChannel.WATER_ALERT.name) + + try { + firebaseMessaging.sendEach(messages) + } catch (e: FirebaseMessagingException) { + log.warn("fail send FCM message", e) + } + } + + private fun createMessages(events: List, channelId: String): List { + return events.map { event -> createMessage(event, channelId) }.toList() + } + + private fun createMessage(event: WaterCycleEvent, channelId: String): Message { + return Message.builder() + .setNotification( + Notification + .builder() + .setTitle(event.title) + .setBody(event.body) + .build() + ) + .setAndroidConfig(createAndroidConfig(channelId)) + .setToken(event.deviceToken) + .build() + } + + private fun createAndroidConfig(channelId: String): AndroidConfig? { + return AndroidConfig.builder() + .setNotification(createAndroidNotification(channelId)) + .build() + } + + private fun createAndroidNotification(channelId: String): AndroidNotification? { + return AndroidNotification.builder() + .setChannelId(channelId) + .build() + } +} diff --git a/src/main/kotlin/gdsc/plantory/plant/domain/CompanionPlantRepository.kt b/src/main/kotlin/gdsc/plantory/plant/domain/CompanionPlantRepository.kt index b73f697..583ec4b 100644 --- a/src/main/kotlin/gdsc/plantory/plant/domain/CompanionPlantRepository.kt +++ b/src/main/kotlin/gdsc/plantory/plant/domain/CompanionPlantRepository.kt @@ -1,6 +1,7 @@ package gdsc.plantory.plant.domain import NotFoundException +import gdsc.plantory.plant.presentation.dto.CompanionPlantWaterCycleDto import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query import java.time.LocalDate @@ -19,6 +20,19 @@ interface CompanionPlantRepository : JpaRepository { fun findByIdAndMemberId(id: Long, memberId: Long): CompanionPlant? fun removeByIdAndMemberId(id: Long, memberId: Long) + @Query( + """ + SELECT new gdsc.plantory.plant.presentation.dto.CompanionPlantWaterCycleDto( + member.deviceToken, + plant.nickname._value + ) + FROM Member member JOIN CompanionPlant plant + ON member.id = plant.memberId + WHERE plant.nextWaterDate = :date + """ + ) + fun findAllByNextWaterDate(date: LocalDate): List + @Query( """ SELECT history FROM PlantHistory history diff --git a/src/main/kotlin/gdsc/plantory/plant/presentation/dto/CompanionPlantWaterCycleDto.kt b/src/main/kotlin/gdsc/plantory/plant/presentation/dto/CompanionPlantWaterCycleDto.kt new file mode 100644 index 0000000..8b9ec3d --- /dev/null +++ b/src/main/kotlin/gdsc/plantory/plant/presentation/dto/CompanionPlantWaterCycleDto.kt @@ -0,0 +1,6 @@ +package gdsc.plantory.plant.presentation.dto + +class CompanionPlantWaterCycleDto( + val deviceToken: String, + val nickName: String, +) diff --git a/src/main/kotlin/gdsc/plantory/plant/service/ReminderService.kt b/src/main/kotlin/gdsc/plantory/plant/service/ReminderService.kt new file mode 100644 index 0000000..0117e4c --- /dev/null +++ b/src/main/kotlin/gdsc/plantory/plant/service/ReminderService.kt @@ -0,0 +1,35 @@ +package gdsc.plantory.plant.service + +import gdsc.plantory.event.Events +import gdsc.plantory.event.notification.WaterCycleEvent +import gdsc.plantory.plant.domain.CompanionPlantRepository +import gdsc.plantory.plant.presentation.dto.CompanionPlantWaterCycleDto +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate + +@Service +@Transactional +class ReminderService( + private val companionPlantRepository: CompanionPlantRepository, +) { + + @Scheduled(cron = "0 0 8 * * *") + fun sendWaterNotification() { + val companionPlants = companionPlantRepository.findAllByNextWaterDate(LocalDate.now()) + val events: List = buildWaterCycleEvents(companionPlants) + Events.raise(events) + } + + private fun buildWaterCycleEvents(companionPlants: List) = + companionPlants + .map { + WaterCycleEvent( + it.deviceToken, + "물을 줄 시간이에요!", + "${it.nickName}에게 물을 줄 시간이에요!" + ) + } + .toList() +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index cd8babe..7e29226 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -24,3 +24,4 @@ companionPlant: fcm: key: path: src/main/resources/config/google-services.json + scope: prod diff --git a/src/main/resources/config b/src/main/resources/config index 7897edf..dff9592 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit 7897edf6aa6e3a328799b060bb61f744967da5fa +Subproject commit dff95924a7a7500eb56c05ee9959434d6cde9726 diff --git a/src/test/kotlin/gdsc/plantory/plant/domain/CompanionPlantRepositoryTest.kt b/src/test/kotlin/gdsc/plantory/plant/domain/CompanionPlantRepositoryTest.kt new file mode 100644 index 0000000..bad6b4c --- /dev/null +++ b/src/test/kotlin/gdsc/plantory/plant/domain/CompanionPlantRepositoryTest.kt @@ -0,0 +1,64 @@ +package gdsc.plantory.plant.domain + +import gdsc.plantory.member.domain.Member +import gdsc.plantory.member.domain.MemberRepository +import gdsc.plantory.util.AcceptanceTest +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.groups.Tuple +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import java.time.LocalDate + +@DisplayName("리포지토리 : CompanionPlant") +class CompanionPlantRepositoryTest( + @Autowired val companionPlantRepository: CompanionPlantRepository, + @Autowired val memberRepository: MemberRepository, +) : AcceptanceTest() { + + @Test + fun `물주는 날짜가 된 반려식물의 별칭과 해당 유저의 deviceToken을 조회한다`() { + // given + val member = Member("shine") + val savedMember = memberRepository.save(member) + val memberId = savedMember.getId + + val nextWaterDate = LocalDate.of(2024, 1, 7) + + val companionPlant1 = createCompanionPlantByLastWaterDate(nextWaterDate, memberId) + val companionPlant2 = createCompanionPlantByLastWaterDate(nextWaterDate.plusDays(1), memberId) + val companionPlant3 = createCompanionPlantByLastWaterDate(nextWaterDate.minusDays(1), memberId) + val companionPlant4 = createCompanionPlantByLastWaterDate(nextWaterDate, memberId) + companionPlantRepository.saveAll( + listOf( + companionPlant1, + companionPlant2, + companionPlant3, + companionPlant4 + ) + ) + + // when + val results = companionPlantRepository.findAllByNextWaterDate(nextWaterDate) + + // then + assertThat(results).hasSize(2) + .extracting("deviceToken", "nickName") + .containsExactlyInAnyOrder( + Tuple.tuple("shine", "2024-01-07"), + Tuple.tuple("shine", "2024-01-07") + ) + } + + private fun createCompanionPlantByLastWaterDate(nextWaterDate: LocalDate, memberId: Long) = CompanionPlant( + _imageUrl = "https://nongsaro.go.kr/cms_contents/301/13336_MF_ATTACH_05.jpg", + _shortDescription = "덕구리난은 덕구리난과!", + _nickname = nextWaterDate.toString(), + birthDate = LocalDate.of(2024, 1, 1), + nextWaterDate = nextWaterDate, + lastWaterDate = LocalDate.of(2024, 1, 4), + waterCycle = 3, + plantInformationId = 1L, + memberId = memberId, + ) +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index c23a8d6..b851355 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -22,3 +22,8 @@ local: companionPlant: image: directory: photos/companionPlant + +fcm: + key: + path: config/google-services.json + scope: test