Skip to content

Commit

Permalink
[OING-177] feat: 매일 사용자 휴대폰 FCM 노티 발송 기능 추가 (#145)
Browse files Browse the repository at this point in the history
* feat: add fcm services

* feat: add notification feature

* feat: only not quit members

* feat: add slack alert feature
  • Loading branch information
CChuYong committed Feb 11, 2024
1 parent b52b60f commit b689eeb
Show file tree
Hide file tree
Showing 18 changed files with 336 additions and 3 deletions.
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ subprojects {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'com.github.f4b6a3:ulid-creator:5.2.2'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'com.google.firebase:firebase-admin:9.2.0'
implementation 'com.google.api-client:google-api-client:1.32.1'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.12.3'
Expand Down
15 changes: 15 additions & 0 deletions common/src/main/java/com/oing/service/FCMNotificationService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.oing.service;

import com.google.firebase.messaging.Message;
import com.google.firebase.messaging.MulticastMessage;

/**
* no5ing-server
* User: CChuYong
* Date: 2/2/24
* Time: 4:09 AM
*/
public interface FCMNotificationService {
void sendMessage(Message message);
void sendMulticastMessage(MulticastMessage message);
}
41 changes: 41 additions & 0 deletions gateway/src/main/java/com/oing/component/ApplicationListener.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.oing.component;

import com.oing.domain.BulkNotificationCompletedEvent;
import com.oing.domain.ErrorReportDTO;
import lombok.RequiredArgsConstructor;
import org.springframework.context.event.EventListener;
Expand Down Expand Up @@ -27,6 +28,46 @@ private boolean isProductionInstance() {
return activeProfiles != null && (Arrays.asList(activeProfiles).contains("prod") || Arrays.asList(activeProfiles).contains("dev"));
}

@Async
@EventListener
public void onNotificationSent(BulkNotificationCompletedEvent event) {
if (!isProductionInstance()) return;
SlackGateway.SlackBotDto dto = SlackGateway.SlackBotDto.builder()
.attachments(List.of(
SlackGateway.SlackBotAttachmentDto.builder()
.authorName("no5ing-server")
.title("알림 발송 리포트")
.color("#abcdef")
.text("백엔드 서버에서 FCM 벌크 알림이 발송되었습니다")
.fields(List.of(
SlackGateway.SlackBotFieldDto.builder()
.title("알림 종류")
.value(event.reason())
.shortField(false)
.build(),
SlackGateway.SlackBotFieldDto.builder()
.title("알람 발송 대상 기기 수")
.value(String.valueOf(event.totalTargets()))
.shortField(true)
.build(),
SlackGateway.SlackBotFieldDto.builder()
.title("알람 발송 대상 사용자 수")
.value(String.valueOf(event.totalMembers()))
.shortField(true)
.build(),
SlackGateway.SlackBotFieldDto.builder()
.title("소요시간 (밀리초)")
.value(String.valueOf(event.elapsedMillis()))
.shortField(true)
.build()
))
.build()
))
.build();

slackGateway.sendSlackBotMessage(dto);
}

@Async
@EventListener
public void onErrorReport(ErrorReportDTO errorReportDTO) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.oing.component;

import com.google.firebase.messaging.FirebaseMessaging;
import com.google.firebase.messaging.Message;
import com.google.firebase.messaging.MulticastMessage;
import com.oing.service.FCMNotificationService;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.stereotype.Component;

/**
* no5ing-server
* User: CChuYong
* Date: 2/2/24
* Time: 4:10 AM
*/

@RequiredArgsConstructor
@Component
@ConditionalOnBean(FirebaseMessaging.class)
public class FCMNotificationServiceImpl implements FCMNotificationService {
private final FirebaseMessaging firebaseMessaging;

@Override
public void sendMessage(Message message) {
firebaseMessaging.sendAsync(message);
}

@Override
public void sendMulticastMessage(MulticastMessage message) {
firebaseMessaging.sendEachForMulticastAsync(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.oing.component;

import com.google.firebase.messaging.FirebaseMessaging;
import com.google.firebase.messaging.Message;
import com.google.firebase.messaging.MulticastMessage;
import com.oing.service.FCMNotificationService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.stereotype.Component;

/**
* no5ing-server
* User: CChuYong
* Date: 2/2/24
* Time: 4:10 AM
*/

@Slf4j
@RequiredArgsConstructor
@Component
@ConditionalOnMissingBean(FirebaseMessaging.class)
public class MockFCMNotificationServiceImpl implements FCMNotificationService {

@Override
public void sendMessage(Message message) {
log.info("MockFCMNotificationServiceImpl.sendMessage: {}", message);
}

@Override
public void sendMulticastMessage(MulticastMessage message) {
log.info("MockFCMNotificationServiceImpl.sendMulticastMessage: {}", message);
}
}
2 changes: 1 addition & 1 deletion gateway/src/main/java/com/oing/component/SlackGateway.java
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public static class SlackBotAttachmentDto {
@JsonProperty("mrkdwn_in")
final String mrkdwn_in = "[\"text\"]";
@JsonProperty("color")
final String color = "#ff2400";
String color = "#ff2400";
@JsonProperty("author_name")
final String authorName;
@JsonProperty("title")
Expand Down
36 changes: 36 additions & 0 deletions gateway/src/main/java/com/oing/config/FirebaseConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.oing.config;

import com.google.auth.oauth2.GoogleCredentials;
import com.google.firebase.FirebaseApp;
import com.google.firebase.FirebaseOptions;
import com.google.firebase.messaging.FirebaseMessaging;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.util.Base64;

/**
* no5ing-server
* User: CChuYong
* Date: 2/2/24
* Time: 3:59 AM
*/
@Profile({"prod", "dev"})
@Configuration
public class FirebaseConfig {
@Value("${cloud.firebase}")
private String firebaseSecret;

@Bean
public FirebaseMessaging firebaseMessaging() throws IOException {
ByteArrayInputStream credentials = new ByteArrayInputStream(Base64.getDecoder().decode(firebaseSecret));
FirebaseOptions options = FirebaseOptions.builder()
.setCredentials(GoogleCredentials.fromStream(credentials)).build();
FirebaseApp firebaseApp = FirebaseApp.initializeApp(options);
return FirebaseMessaging.getInstance(firebaseApp);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.oing.domain;

/**
* no5ing-server
* User: CChuYong
* Date: 2/2/24
* Time: 4:49 AM
*/
public record BulkNotificationCompletedEvent(
String reason,
int totalTargets,
int totalMembers,
long elapsedMillis
) {
}
122 changes: 122 additions & 0 deletions gateway/src/main/java/com/oing/job/DailyNotificationJob.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package com.oing.job;

import com.google.common.collect.Lists;
import com.google.firebase.messaging.ApnsConfig;
import com.google.firebase.messaging.Aps;
import com.google.firebase.messaging.MulticastMessage;
import com.google.firebase.messaging.Notification;
import com.oing.domain.BulkNotificationCompletedEvent;
import com.oing.domain.Member;
import com.oing.service.FCMNotificationService;
import com.oing.service.MemberDeviceService;
import com.oing.service.MemberPostService;
import com.oing.service.MemberService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.time.LocalDate;
import java.util.HashSet;
import java.util.List;

/**
* no5ing-server
* User: CChuYong
* Date: 2/2/24
* Time: 4:16 AM
*/
@Slf4j
@RequiredArgsConstructor
@Component
public class DailyNotificationJob {
private final ApplicationEventPublisher eventPublisher;
private final FCMNotificationService fcmNotificationService;

private final MemberService memberService;
private final MemberDeviceService memberDeviceService;
private final MemberPostService memberPostService;

@Scheduled(cron = "0 0 12 * * *", zone = "Asia/Seoul") // 12:00 PM
public void sendDailyUploadNotification() {
long start = System.currentTimeMillis();
log.info("[DailyNotificationJob] 오늘 업로드 알림 전송 시작");
HashSet<String> targetFcmTokens = new HashSet<>();
List<Member> members = memberService.findAllMember();
for (Member member : members) {
targetFcmTokens.addAll(memberDeviceService.getFcmTokensByMemberId(member.getId()));
}

Lists.partition(targetFcmTokens.stream().toList(), 500).forEach(partitionedList -> {
MulticastMessage multicastMessage = MulticastMessage.builder()
.setNotification(
buildNotification("삐삐", "지금 바로 가족에게 일상 공유를 해볼까요?")
)
.addAllTokens(partitionedList)
.setApnsConfig(buildApnsConfig())
.build();
fcmNotificationService.sendMulticastMessage(multicastMessage);
});
log.info("[DailyNotificationJob] 오늘 업로드 알림 전송 완료. (총 {}명, {}토큰) 소요시간 : {}ms",
members.size(),
targetFcmTokens.size(),
System.currentTimeMillis() - start);

eventPublisher.publishEvent(
new BulkNotificationCompletedEvent(
"오늘 업로드 알림 전송 완료", targetFcmTokens.size(), members.size(),
System.currentTimeMillis() - start
)
);
}

@Scheduled(cron = "0 30 23 * * *", zone = "Asia/Seoul") // 11:30 PM
public void sendDailyRemainingNotification() {
long start = System.currentTimeMillis();
log.info("[DailyNotificationJob] 오늘 미 업로드 사용자 대상 알림 전송 시작");
LocalDate today = LocalDate.now();
List<Member> allMembers = memberService.findAllMember();
HashSet<String> targetFcmTokens = new HashSet<>();
HashSet<String> postedMemberIds = new HashSet<>(memberPostService.getMemberIdsPostedToday(today));
allMembers.stream()
.filter(member -> !postedMemberIds.contains(member.getId())) //오늘 업로드한 사람이 아닌 사람들은
.forEach(member -> targetFcmTokens.addAll(memberDeviceService.getFcmTokensByMemberId(member.getId())));

Lists.partition(targetFcmTokens.stream().toList(), 500).forEach(partitionedList -> {
MulticastMessage multicastMessage = MulticastMessage.builder()
.setNotification(
buildNotification("삐삐", "사진을 공유할 수 있는 시간이 얼마 남지 않았어요.")
)
.addAllTokens(partitionedList)
.setApnsConfig(buildApnsConfig())
.build();
fcmNotificationService.sendMulticastMessage(multicastMessage);
});
log.info("[DailyNotificationJob] 오늘 미 업로드 사용자 대상 알림 전송 완료. (총 {}명, {}토큰) 소요시간 : {}ms",
allMembers.size() - postedMemberIds.size(),
targetFcmTokens.size(),
System.currentTimeMillis() - start);

eventPublisher.publishEvent(
new BulkNotificationCompletedEvent(
"오늘 미업로드자 업로드 알림 전송 완료", targetFcmTokens.size(),
allMembers.size() - postedMemberIds.size(),
System.currentTimeMillis() - start
)
);
}

private Notification buildNotification(String title, String body){
return Notification.builder()
.setTitle(title)
.setBody(body)
.build();
}

private ApnsConfig buildApnsConfig(){
return ApnsConfig.builder()
.setAps(Aps.builder().setSound("default").build())
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ public class MemberPostRepositoryCustomImpl implements MemberPostRepositoryCusto

private final JPAQueryFactory queryFactory;

@Override
public List<String> getMemberIdsPostedToday(LocalDate date) {
return queryFactory
.select(memberPost.memberId)
.from(memberPost)
.where(memberPost.createdAt.between(date.atStartOfDay(), date.atTime(23, 59, 59)))
.fetch();
}

@Override
public List<MemberPost> findLatestPostOfEveryday(List<String> memberIds, LocalDateTime startDate, LocalDateTime endDate) {
return queryFactory
Expand Down
2 changes: 1 addition & 1 deletion gateway/src/main/resources/application-prod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,4 @@ management:
endpoints:
web:
exposure:
include: health,info,prometheus
include: health,info,prometheus
3 changes: 2 additions & 1 deletion gateway/src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ logging:
com.oing: DEBUG

cloud:
firebase: ${FIREBASE_SECRET}
ncp:
region: ${OBJECT_STORAGE_REGION}
end-point: ${OBJECT_STORAGE_END_POINT}
Expand All @@ -111,4 +112,4 @@ management:
endpoints:
web:
exposure:
include: health,info,prometheus
include: health,info,prometheus
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,8 @@
import com.oing.domain.key.MemberDeviceKey;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface MemberDeviceRepository extends JpaRepository<MemberDevice, MemberDeviceKey> {
List<MemberDevice> findAllByMemberId(String memberId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,6 @@ public interface MemberRepository extends JpaRepository<Member, String> {
Page<Member> findAllByFamilyIdAndDeletedAtIsNull(String familyId, PageRequest pageRequest);

long countByFamilyIdAndFamilyJoinAtBefore(String familyId, LocalDateTime dateTime);

List<Member> findAllByDeletedAtIsNull();
}
Loading

0 comments on commit b689eeb

Please sign in to comment.