diff --git a/moit-api/src/main/kotlin/com/mashup/moit/infra/event/MoitEvent.kt b/moit-api/src/main/kotlin/com/mashup/moit/infra/event/MoitEvent.kt index 0582bac9..36702beb 100644 --- a/moit-api/src/main/kotlin/com/mashup/moit/infra/event/MoitEvent.kt +++ b/moit-api/src/main/kotlin/com/mashup/moit/infra/event/MoitEvent.kt @@ -53,3 +53,9 @@ data class StudyAttendanceStartNotificationPushEvent(val studyIdWithMoitIds: Set return KafkaEventTopic.STUDY_ATTENDANCE_START_NOTIFICATION } } + +data class RemindFineNotificationPushEvent(val fineIds: Set, val flushAt: LocalDateTime) : MoitEvent { + override fun getTopic(): String { + return KafkaEventTopic.REMIND_FINE_NOTIFICATION + } +} diff --git a/moit-api/src/main/kotlin/com/mashup/moit/infra/event/MoitEventTopic.kt b/moit-api/src/main/kotlin/com/mashup/moit/infra/event/MoitEventTopic.kt index 0c82e195..3cbad200 100644 --- a/moit-api/src/main/kotlin/com/mashup/moit/infra/event/MoitEventTopic.kt +++ b/moit-api/src/main/kotlin/com/mashup/moit/infra/event/MoitEventTopic.kt @@ -9,6 +9,7 @@ object KafkaEventTopic { const val FINE_CREATE = "fine_create" const val FINE_CREATE_BULK = "fine_create_bulk" const val FINE_APPROVE = "fine_approve" + const val REMIND_FINE_NOTIFICATION = "remind_fine_notification" } object KafkaConsumerGroup { @@ -20,4 +21,5 @@ object KafkaConsumerGroup { const val FINE_CREATE_BANNER_UPDATE = "fine_create_banner_update" const val FINE_CREATE_BANNER_UPDATE_BULK = "fine_create_banner_update_bulk" const val FINE_APPROVE_BANNER_UPDATE = "fine_approve_banner_update" + const val REMIND_FINE_NOTIFICATION_CREATE = "remind_fine_notification_create" } diff --git a/moit-api/src/main/kotlin/com/mashup/moit/infra/event/kafka/KafkaConsumer.kt b/moit-api/src/main/kotlin/com/mashup/moit/infra/event/kafka/KafkaConsumer.kt index 53179eeb..ce8ce035 100644 --- a/moit-api/src/main/kotlin/com/mashup/moit/infra/event/kafka/KafkaConsumer.kt +++ b/moit-api/src/main/kotlin/com/mashup/moit/infra/event/kafka/KafkaConsumer.kt @@ -6,6 +6,7 @@ import com.mashup.moit.domain.banner.update.StudyAttendanceStartBannerUpdateRequ import com.mashup.moit.domain.fine.FineService import com.mashup.moit.domain.notification.AttendanceStartNotificationEvent import com.mashup.moit.domain.notification.NotificationService +import com.mashup.moit.domain.notification.RemindFineNotificationEvent import com.mashup.moit.domain.study.StudyService import com.mashup.moit.infra.event.EventProducer import com.mashup.moit.infra.event.FineApproveEvent @@ -14,6 +15,7 @@ import com.mashup.moit.infra.event.FineCreateEventBulk import com.mashup.moit.infra.event.KafkaConsumerGroup import com.mashup.moit.infra.event.KafkaEventTopic import com.mashup.moit.infra.event.MoitCreateEvent +import com.mashup.moit.infra.event.RemindFineNotificationPushEvent import com.mashup.moit.infra.event.StudyAttendanceEvent import com.mashup.moit.infra.event.StudyAttendanceEventBulk import com.mashup.moit.infra.event.StudyAttendanceStartNotificationPushEvent @@ -112,4 +114,13 @@ class KafkaConsumer( log.debug("consumeStudyAttendanceStartNotificationPushEvent called: {}", event) notificationService.save(AttendanceStartNotificationEvent(event.studyIdWithMoitIds, event.flushAt)) } + + @KafkaListener( + topics = [KafkaEventTopic.REMIND_FINE_NOTIFICATION], + groupId = KafkaConsumerGroup.REMIND_FINE_NOTIFICATION_CREATE, + ) + fun consumeRemindFineNotificationPushEvent(event: RemindFineNotificationPushEvent) { + log.debug("consumeRemindFineNotificationPushEvent called: {}", event) + notificationService.save(RemindFineNotificationEvent(event.fineIds, event.flushAt)) + } } diff --git a/moit-api/src/main/kotlin/com/mashup/moit/infra/fcm/FCMNotification.kt b/moit-api/src/main/kotlin/com/mashup/moit/infra/fcm/FCMNotification.kt index 87f318ce..612f48ba 100644 --- a/moit-api/src/main/kotlin/com/mashup/moit/infra/fcm/FCMNotification.kt +++ b/moit-api/src/main/kotlin/com/mashup/moit/infra/fcm/FCMNotification.kt @@ -2,8 +2,10 @@ package com.mashup.moit.infra.fcm import com.mashup.moit.domain.moit.Moit import com.mashup.moit.domain.moit.NotificationRemindOption +import com.mashup.moit.domain.notification.RemindFinePushNotificationGenerator import com.mashup.moit.domain.notification.StartAttendancePushNotificationGenerator import com.mashup.moit.domain.study.Study +import com.mashup.moit.domain.user.User data class SampleNotificationRequest( val studyId: Long, @@ -33,3 +35,23 @@ data class StudyAttendanceStartNotification( ) } } + +data class FineRemindNotification( + val userFcmToken: String, + val title: String, + val body: String +) { + companion object { + fun of( + user: User, + moit: Moit, + study: Study + ): FineRemindNotification? = user.fcmToken?.let { token -> + FineRemindNotification( + userFcmToken = token, + title = RemindFinePushNotificationGenerator.TITLE_TEMPLATE, + body = RemindFinePushNotificationGenerator.bodyTemplate(user.nickname, moit.name, study.order) + ) + } + } +} diff --git a/moit-api/src/main/kotlin/com/mashup/moit/infra/fcm/FCMNotificationService.kt b/moit-api/src/main/kotlin/com/mashup/moit/infra/fcm/FCMNotificationService.kt index 2ce69478..9f6a51e7 100644 --- a/moit-api/src/main/kotlin/com/mashup/moit/infra/fcm/FCMNotificationService.kt +++ b/moit-api/src/main/kotlin/com/mashup/moit/infra/fcm/FCMNotificationService.kt @@ -33,6 +33,27 @@ class FCMNotificationService( } } + fun pushRemindFineNotification(fineNotification: FineRemindNotification) { + val userFcmToken = fineNotification.userFcmToken + runCatching { + val notification = Notification.builder() + .setTitle(fineNotification.title) + .setBody(fineNotification.body) + .build() + + val message = Message.builder() + .setToken(userFcmToken) + .setNotification(notification) + .build() + + firebaseMessaging.send(message) + }.onSuccess { response -> + logger.info("success to send notification : {}", response) + }.onFailure { e -> + logger.error("Fail to send Remind Fine Noti. userFcmToken : [{}], error: [{}]", userFcmToken, e.toString()) + } + } + fun getMoitTopic(moitId: Long): String { return TOPIC_MOIT_PREFIX + moitId } diff --git a/moit-api/src/main/kotlin/com/mashup/moit/scheduler/FineRemindNotiPushScheduler.kt b/moit-api/src/main/kotlin/com/mashup/moit/scheduler/FineRemindNotiPushScheduler.kt new file mode 100644 index 00000000..c354bbf1 --- /dev/null +++ b/moit-api/src/main/kotlin/com/mashup/moit/scheduler/FineRemindNotiPushScheduler.kt @@ -0,0 +1,71 @@ +package com.mashup.moit.scheduler + +import com.mashup.moit.domain.fine.FineService +import com.mashup.moit.domain.moit.MoitService +import com.mashup.moit.domain.study.StudyService +import com.mashup.moit.domain.user.UserService +import com.mashup.moit.infra.event.EventProducer +import com.mashup.moit.infra.event.RemindFineNotificationPushEvent +import com.mashup.moit.infra.fcm.FCMNotificationService +import com.mashup.moit.infra.fcm.FineRemindNotification +import org.joda.time.LocalDateTime +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.scheduling.annotation.Async +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component + +@Component +class FineRemindNotiPushScheduler( + private val userService: UserService, + private val moitService: MoitService, + private val studyService: StudyService, + private val fineService: FineService, + private val fcmNotificationService: FCMNotificationService, + private val eventProducer: EventProducer +) { + + val logger: Logger = LoggerFactory.getLogger(FineRemindNotiPushScheduler::class.java) + + @Scheduled(cron = "0 30 10 * * *") + @Async("pushSchedulerExecutor") + fun pushRemindFineNotification() { + val scheduleContext = LocalDateTime.now() + + val unrequestedFines = fineService.getUnrequestedFines() + logger.info("Remind {} fines! Start Push Notification at {}.", unrequestedFines.size, scheduleContext) + + val unrequestedFineUserMap = userService + .findUsersById( + unrequestedFines.map { fine -> fine.userId }.distinct() + ) + .associateBy { user -> user.id } + val unrequestedFineMoitMap = moitService + .getMoitsByIds( + unrequestedFines.map { fine -> fine.moitId }.toSet() + ) + .associateBy { moit -> moit.id } + val unrequestedFineStudyMap = studyService + .findByStudyIds( + unrequestedFines.map { fine -> fine.studyId }.distinct() + ) + .associateBy { study -> study.id } + + for (fine in unrequestedFines) { + val user = unrequestedFineUserMap[fine.userId] ?: continue + val moit = unrequestedFineMoitMap[fine.moitId] ?: continue + val study = unrequestedFineStudyMap[fine.studyId] ?: continue + FineRemindNotification.of( + user = user, + moit = moit, + study = study + )?.let { notification -> fcmNotificationService.pushRemindFineNotification(notification) } + } + eventProducer.produce( + RemindFineNotificationPushEvent( + fineIds = unrequestedFines.map { fine -> fine.id }.toSet(), + flushAt = java.time.LocalDateTime.now()) + ) + logger.info("Done Push notification for {} fines, at {}", unrequestedFines.size, LocalDateTime.now()) + } +} diff --git a/moit-domain/src/main/kotlin/com/mashup/moit/domain/fine/FineRepository.kt b/moit-domain/src/main/kotlin/com/mashup/moit/domain/fine/FineRepository.kt index 4644a3b8..75b3cfc0 100644 --- a/moit-domain/src/main/kotlin/com/mashup/moit/domain/fine/FineRepository.kt +++ b/moit-domain/src/main/kotlin/com/mashup/moit/domain/fine/FineRepository.kt @@ -5,4 +5,5 @@ import org.springframework.data.jpa.repository.JpaRepository interface FineRepository : JpaRepository { fun findByMoitId(moitId: Long): List fun findAllByMoitIdAndUserIdAndApproveStatusIn(moitId: Long, userId: Long, approveStatuses: Collection): List + fun findAllByApproveStatusIn(approveStatuses: Collection): List } diff --git a/moit-domain/src/main/kotlin/com/mashup/moit/domain/fine/FineService.kt b/moit-domain/src/main/kotlin/com/mashup/moit/domain/fine/FineService.kt index d3d2d7f3..dd641604 100644 --- a/moit-domain/src/main/kotlin/com/mashup/moit/domain/fine/FineService.kt +++ b/moit-domain/src/main/kotlin/com/mashup/moit/domain/fine/FineService.kt @@ -54,6 +54,12 @@ class FineService( .map { it.toDomain() } } + fun getUnrequestedFines(): List { + return fineRepository.findAllByApproveStatusIn( + listOf(FineApproveStatus.NEW, FineApproveStatus.REJECTED) + ).map { it.toDomain() } + } + @Transactional fun updateFineApproveStatus(fineId: Long, confirmFine: Boolean) { val approveStatus = if (confirmFine) FineApproveStatus.APPROVED else FineApproveStatus.REJECTED diff --git a/moit-domain/src/main/kotlin/com/mashup/moit/domain/moit/MoitService.kt b/moit-domain/src/main/kotlin/com/mashup/moit/domain/moit/MoitService.kt index f9678f4d..ff5459ed 100644 --- a/moit-domain/src/main/kotlin/com/mashup/moit/domain/moit/MoitService.kt +++ b/moit-domain/src/main/kotlin/com/mashup/moit/domain/moit/MoitService.kt @@ -10,7 +10,7 @@ import org.springframework.transaction.annotation.Transactional import java.time.DayOfWeek import java.time.LocalDate import java.time.LocalTime -import java.util.Locale +import java.util.* @Service @Transactional(readOnly = true) @@ -84,6 +84,11 @@ class MoitService( .toDomain() } + fun getMoitsByIds(moitIds: Set): List { + return moitRepository.findAllById(moitIds) + .map { it.toDomain() } + } + fun getMoitsByUserId(userId: Long): List { val moitIds = userMoitRepository.findAllByUserId(userId).map { it.moitId } return moitRepository.findAllById(moitIds).map { it.toDomain() } diff --git a/moit-domain/src/main/kotlin/com/mashup/moit/domain/notification/NotificationEvent.kt b/moit-domain/src/main/kotlin/com/mashup/moit/domain/notification/NotificationEvent.kt index 123fcf95..cb76864d 100644 --- a/moit-domain/src/main/kotlin/com/mashup/moit/domain/notification/NotificationEvent.kt +++ b/moit-domain/src/main/kotlin/com/mashup/moit/domain/notification/NotificationEvent.kt @@ -8,3 +8,8 @@ data class AttendanceStartNotificationEvent( val studyIdWithMoitIds: Set>, val flushAt: LocalDateTime ) : NotificationEvent + +data class RemindFineNotificationEvent( + val fineIds: Set, + val flushAt: LocalDateTime +) : NotificationEvent diff --git a/moit-domain/src/main/kotlin/com/mashup/moit/domain/notification/NotificationGenerator.kt b/moit-domain/src/main/kotlin/com/mashup/moit/domain/notification/NotificationGenerator.kt index 3385bd1f..57dfe738 100644 --- a/moit-domain/src/main/kotlin/com/mashup/moit/domain/notification/NotificationGenerator.kt +++ b/moit-domain/src/main/kotlin/com/mashup/moit/domain/notification/NotificationGenerator.kt @@ -2,8 +2,10 @@ package com.mashup.moit.domain.notification import com.mashup.moit.common.exception.MoitException import com.mashup.moit.common.exception.MoitExceptionType +import com.mashup.moit.domain.fine.FineService import com.mashup.moit.domain.moit.MoitService import com.mashup.moit.domain.study.StudyService +import com.mashup.moit.domain.user.UserService import com.mashup.moit.domain.usermoit.UserMoitService import org.springframework.stereotype.Component @@ -57,3 +59,51 @@ class StartAttendancePushNotificationGenerator( } } } + +@Component +class RemindFinePushNotificationGenerator( + private val userService: UserService, + private val moitService: MoitService, + private val studyService: StudyService, + private val fineService: FineService, + private val urlSchemeProperties: UrlSchemeProperties, +) : NotificationGenerator { + companion object { + const val TITLE_TEMPLATE = "밀린 벌금이 있어요!" + fun bodyTemplate(userName: String, moitName: String, studyOrder: Int): String { + return "$moitName ${userName}님, 아직 ${studyOrder + 1}회차 스터디의 벌금을 납부하지 않았습니다.\n얼른 납부하고 인증받으세요!" + } + } + + override fun support(event: NotificationEvent): Boolean { + return event is RemindFineNotificationEvent + } + + override fun generate(event: NotificationEvent): List { + if (event !is RemindFineNotificationEvent) { + throw MoitException.of(MoitExceptionType.SYSTEM_FAIL) + } + + return event.fineIds + .map { fineId -> + val fine = fineService.getFine(fineId) + val user = userService.findUserById(fine.userId) + val (userId, userName) = user.id to user.nickname + val moit = moitService.getMoitById(fine.moitId) + val (moitId, moitName) = moit.id to moit.name + val studyOrder = studyService.findById(fine.studyId).order + + NotificationEntity( + type = NotificationType.CHECK_FINE_NOTIFICATION, + userId = userId, + title = TITLE_TEMPLATE, + body = bodyTemplate(userName, moitName, studyOrder), + urlScheme = UrlSchemeUtils.generate( + urlSchemeProperties.checkFine, + UrlSchemeParameter("moitId", moitId.toString()), + UrlSchemeParameter("fineId", fineId.toString()) + ) + ) + } + } +} diff --git a/moit-domain/src/main/kotlin/com/mashup/moit/domain/user/User.kt b/moit-domain/src/main/kotlin/com/mashup/moit/domain/user/User.kt index 3afdfa27..e6285184 100644 --- a/moit-domain/src/main/kotlin/com/mashup/moit/domain/user/User.kt +++ b/moit-domain/src/main/kotlin/com/mashup/moit/domain/user/User.kt @@ -12,4 +12,5 @@ data class User( val profileImage: Int, val email: String, val roles: Set, + val fcmToken: String? ) diff --git a/moit-domain/src/main/kotlin/com/mashup/moit/domain/user/UserEntity.kt b/moit-domain/src/main/kotlin/com/mashup/moit/domain/user/UserEntity.kt index c72ab8f0..7e7ff647 100644 --- a/moit-domain/src/main/kotlin/com/mashup/moit/domain/user/UserEntity.kt +++ b/moit-domain/src/main/kotlin/com/mashup/moit/domain/user/UserEntity.kt @@ -31,7 +31,7 @@ class UserEntity( val roles: Set, @Column(name = "fcm_token", nullable = true) - val fcmToken: String? + var fcmToken: String? ) : BaseEntity() { fun toDomain(): User { return User( @@ -44,6 +44,7 @@ class UserEntity( profileImage = profileImage, email = email, roles = roles, + fcmToken = fcmToken ) } diff --git a/moit-domain/src/main/kotlin/com/mashup/moit/domain/user/UserService.kt b/moit-domain/src/main/kotlin/com/mashup/moit/domain/user/UserService.kt index 123be063..f88acd43 100644 --- a/moit-domain/src/main/kotlin/com/mashup/moit/domain/user/UserService.kt +++ b/moit-domain/src/main/kotlin/com/mashup/moit/domain/user/UserService.kt @@ -26,6 +26,11 @@ class UserService( .toDomain() } + @Transactional + fun updateFcmToken(userId: Long, fcmToken: String?) { + userRepository.findByIdOrNull(userId)?.fcmToken = fcmToken + } + @Transactional fun deleteUser(userId: Long) { userRepository.deleteById(userId); diff --git a/moit-domain/src/main/resources/application-domain.yml b/moit-domain/src/main/resources/application-domain.yml index 582bb672..ea880785 100644 --- a/moit-domain/src/main/resources/application-domain.yml +++ b/moit-domain/src/main/resources/application-domain.yml @@ -13,4 +13,4 @@ spring: url-scheme: study-scheduled: "moit://details?moitId={moitId}" # 모잇 상세 페이지 attendance-start: "moit://attendance?moitId={moitId}" # 출석 시작 -> 모잇 상세 페이지 - check-fine: "moit://fine?moitId={moitId}&findId={findId}" # 벌금 상세 + check-fine: "moit://fine?moitId={moitId}&fineId={fineId}" # 벌금 상세