Skip to content

Commit bfb4a23

Browse files
committed
[#57] ✨ Feature: add alarm ringing notifications
- add route to pre-alarm push data - send periodic alarm ringing push notifications with route and alarm id - query ringing alarms and handle invalid tokens - cover repository with ringing notification target tests - 해결: #57
1 parent 9e3a755 commit bfb4a23

File tree

8 files changed

+221
-6
lines changed

8 files changed

+221
-6
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package akuma.whiplash.domains.alarm.application.dto.etc;
2+
3+
import lombok.Builder;
4+
5+
@Builder
6+
public record RingingPushInfo(
7+
Long alarmId,
8+
Long memberId
9+
) {
10+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package akuma.whiplash.domains.alarm.application.dto.etc;
2+
3+
import lombok.Builder;
4+
5+
@Builder
6+
public record RingingPushTargetDto(
7+
String token,
8+
Long alarmId,
9+
Long memberId
10+
) {
11+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package akuma.whiplash.domains.alarm.application.scheduler;
2+
3+
import akuma.whiplash.domains.alarm.application.dto.etc.RingingPushInfo;
4+
import akuma.whiplash.domains.alarm.application.dto.etc.RingingPushTargetDto;
5+
import akuma.whiplash.domains.alarm.domain.service.AlarmQueryService;
6+
import akuma.whiplash.infrastructure.firebase.FcmService;
7+
import akuma.whiplash.infrastructure.redis.RedisService;
8+
import java.util.List;
9+
import lombok.RequiredArgsConstructor;
10+
import lombok.extern.slf4j.Slf4j;
11+
import org.springframework.stereotype.Service;
12+
13+
@Slf4j
14+
@Service
15+
@RequiredArgsConstructor
16+
public class AlarmRingingNotificationScheduler {
17+
18+
private final AlarmQueryService alarmQueryService;
19+
private final RedisService redisService;
20+
private final FcmService fcmService;
21+
22+
// @Scheduled(fixedRate = 10000) // 10초 간격
23+
public void sendRingingAlarmNotifications() {
24+
log.info("[AlarmRingingNotificationScheduler.sendRingingAlarmNotifications] 알람 울림 푸시 알림 전송 스케줄러 시작");
25+
try {
26+
List<RingingPushInfo> infos = alarmQueryService.getRingingNotificationTargets();
27+
if (infos.isEmpty()) {
28+
return;
29+
}
30+
31+
List<RingingPushTargetDto> targets = infos.stream()
32+
.flatMap(info -> redisService.getFcmTokens(info.memberId()).stream()
33+
.map(token -> RingingPushTargetDto.builder()
34+
.token(token)
35+
.alarmId(info.alarmId())
36+
.memberId(info.memberId())
37+
.build()))
38+
.toList();
39+
40+
if (targets.isEmpty()) {
41+
return;
42+
}
43+
44+
fcmService.sendRingingNotifications(targets);
45+
} finally {
46+
log.info("[AlarmRingingNotificationScheduler.sendRingingAlarmNotifications] 알람 울림 푸시 알림 전송 스케줄러 종료");
47+
}
48+
}
49+
}

src/main/java/akuma/whiplash/domains/alarm/domain/service/AlarmQueryService.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package akuma.whiplash.domains.alarm.domain.service;
22

33
import akuma.whiplash.domains.alarm.application.dto.etc.OccurrencePushInfo;
4+
import akuma.whiplash.domains.alarm.application.dto.etc.RingingPushInfo;
45
import akuma.whiplash.domains.alarm.application.dto.response.AlarmInfoPreviewResponse;
56
import akuma.whiplash.domains.alarm.application.dto.response.AlarmRemainingOffCountResponse;
67
import java.time.LocalDateTime;
@@ -10,4 +11,5 @@ public interface AlarmQueryService {
1011
List<AlarmInfoPreviewResponse> getAlarms(Long memberId);
1112
List<OccurrencePushInfo> getPreNotificationTargets(LocalDateTime startInclusive, LocalDateTime endInclusive);
1213
AlarmRemainingOffCountResponse getWeeklyRemainingOffCount(Long memberId);
14+
List<RingingPushInfo> getRingingNotificationTargets();
1315
}

src/main/java/akuma/whiplash/domains/alarm/domain/service/AlarmQueryServiceImpl.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package akuma.whiplash.domains.alarm.domain.service;
22

33
import akuma.whiplash.domains.alarm.application.dto.etc.OccurrencePushInfo;
4+
import akuma.whiplash.domains.alarm.application.dto.etc.RingingPushInfo;
45
import akuma.whiplash.domains.alarm.application.dto.response.AlarmInfoPreviewResponse;
56
import akuma.whiplash.domains.alarm.application.dto.response.AlarmRemainingOffCountResponse;
67
import akuma.whiplash.domains.alarm.domain.constant.DeactivateType;
@@ -88,6 +89,11 @@ public List<OccurrencePushInfo> getPreNotificationTargets(LocalDateTime startInc
8889
));
8990
}
9091

92+
@Override
93+
public List<RingingPushInfo> getRingingNotificationTargets() {
94+
return alarmOccurrenceRepository.findRingingNotificationTargets(DeactivateType.NONE);
95+
}
96+
9197
@Override
9298
public AlarmRemainingOffCountResponse getWeeklyRemainingOffCount(Long memberId) {
9399
memberRepository

src/main/java/akuma/whiplash/domains/alarm/persistence/repository/AlarmOccurrenceRepository.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package akuma.whiplash.domains.alarm.persistence.repository;
22

33
import akuma.whiplash.domains.alarm.application.dto.etc.OccurrencePushInfo;
4+
import akuma.whiplash.domains.alarm.application.dto.etc.RingingPushInfo;
45
import akuma.whiplash.domains.alarm.domain.constant.DeactivateType;
56
import akuma.whiplash.domains.alarm.persistence.entity.AlarmOccurrenceEntity;
67
import java.time.LocalDate;
@@ -122,4 +123,14 @@ List<OccurrencePushInfo> findPreNotificationTargetsUntilTime(
122123
WHERE o.id IN :ids AND o.reminderSent = false
123124
""")
124125
void markReminderSentIn(@Param("ids") Set<Long> ids);
126+
127+
@Query("""
128+
SELECT new akuma.whiplash.domains.alarm.application.dto.etc.RingingPushInfo(a.id, m.id)
129+
FROM AlarmOccurrenceEntity o
130+
JOIN o.alarm a
131+
JOIN a.member m
132+
WHERE o.alarmRinging = true
133+
AND o.deactivateType = :status
134+
""")
135+
List<RingingPushInfo> findRingingNotificationTargets(@Param("status") DeactivateType status);
125136
}

src/main/java/akuma/whiplash/infrastructure/firebase/FcmService.java

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package akuma.whiplash.infrastructure.firebase;
22

33
import akuma.whiplash.domains.alarm.application.dto.etc.PushTargetDto;
4+
import akuma.whiplash.domains.alarm.application.dto.etc.RingingPushTargetDto;
45
import akuma.whiplash.infrastructure.firebase.dto.FcmSendResult;
56
import akuma.whiplash.infrastructure.redis.RedisService;
67
import com.google.firebase.messaging.AndroidConfig;
@@ -33,6 +34,7 @@ public class FcmService {
3334

3435
private static final int FCM_MULTICAST_LIMIT = 500;
3536
private static final String DEFAULT_TITLE = "눈 떠";
37+
private static final String RINGING_BODY = "알람이 울리고 있어요! 앱으로 접속해서 알람을 꺼주세요!";
3638

3739
private final RedisService redisService;
3840

@@ -71,11 +73,12 @@ public FcmSendResult sendBulkNotification(List<PushTargetDto> targets) {
7173

7274
for (Map.Entry<String, List<PushTargetDto>> entry : groupedByBody.entrySet()) {
7375
String body = entry.getKey();
74-
List<PushTargetDto> group = dedupByToken(entry.getValue());
76+
List<PushTargetDto> group = dedupByToken(entry.getValue(), PushTargetDto::token);
7577

7678
Map<String, String> data = Map.of(
7779
"title", DEFAULT_TITLE,
78-
"body", body
80+
"body", body,
81+
"route", "MAIN_VIEW"
7982
);
8083

8184
for (List<PushTargetDto> batch : partition(group, FCM_MULTICAST_LIMIT)) {
@@ -181,10 +184,10 @@ private boolean isTokenInvalid(FirebaseMessagingException e) {
181184
}
182185

183186
// 같은 FCM 토큰 중복 제거
184-
private List<PushTargetDto> dedupByToken(List<PushTargetDto> src) {
185-
Map<String, PushTargetDto> map = new LinkedHashMap<>();
186-
for (PushTargetDto d : src) {
187-
map.putIfAbsent(d.token(), d);
187+
private <T> List<T> dedupByToken(List<T> src, java.util.function.Function<T, String> tokenFn) {
188+
Map<String, T> map = new LinkedHashMap<>();
189+
for (T d : src) {
190+
map.putIfAbsent(tokenFn.apply(d), d);
188191
}
189192
return new ArrayList<>(map.values());
190193
}
@@ -196,4 +199,61 @@ private <T> List<List<T>> partition(List<T> list, int size) {
196199
}
197200
return result;
198201
}
202+
203+
public void sendRingingNotifications(List<RingingPushTargetDto> targets) {
204+
if (targets == null || targets.isEmpty()) {
205+
return;
206+
}
207+
208+
Map<Long, List<RingingPushTargetDto>> groupedByAlarm = targets.stream()
209+
.collect(Collectors.groupingBy(RingingPushTargetDto::alarmId));
210+
211+
for (Map.Entry<Long, List<RingingPushTargetDto>> entry : groupedByAlarm.entrySet()) {
212+
Long alarmId = entry.getKey();
213+
List<RingingPushTargetDto> group = dedupByToken(entry.getValue(), RingingPushTargetDto::token);
214+
215+
Map<String, String> data = Map.of(
216+
"title", DEFAULT_TITLE,
217+
"body", RINGING_BODY,
218+
"route", "ALARM_LINGING_VIEW",
219+
"alarm_id", alarmId.toString()
220+
);
221+
222+
for (List<RingingPushTargetDto> batch : partition(group, FCM_MULTICAST_LIMIT)) {
223+
MulticastMessage message = MulticastMessage.builder()
224+
.addAllTokens(batch.stream().map(RingingPushTargetDto::token).toList())
225+
.putAllData(data)
226+
.setAndroidConfig(buildAndroidConfig(Duration.ofSeconds(30), Priority.HIGH))
227+
.setApnsConfig(buildApnsConfigAlert(DEFAULT_TITLE, RINGING_BODY, Duration.ofSeconds(30)))
228+
.build();
229+
230+
try {
231+
BatchResponse response = FirebaseMessaging.getInstance().sendEachForMulticast(message);
232+
handleRingingSendResult(response.getResponses(), batch);
233+
} catch (FirebaseMessagingException e) {
234+
log.error("FCM 전송 실패(알람 울림)", e);
235+
}
236+
}
237+
}
238+
}
239+
240+
private void handleRingingSendResult(
241+
List<SendResponse> responses,
242+
List<RingingPushTargetDto> batch
243+
) {
244+
for (int i = 0; i < responses.size(); i++) {
245+
SendResponse res = responses.get(i);
246+
RingingPushTargetDto dto = batch.get(i);
247+
248+
if (!res.isSuccessful()) {
249+
Exception ex = res.getException();
250+
FirebaseMessagingException fme = (ex instanceof FirebaseMessagingException) ? (FirebaseMessagingException) ex : null;
251+
if (fme != null && isTokenInvalid(fme)) {
252+
redisService.removeInvalidToken(dto.memberId(), dto.token());
253+
} else {
254+
log.warn("FCM 실패(알람 울림): token={}, ex={}", dto.token(), ex != null ? ex.getClass().getSimpleName() : "null");
255+
}
256+
}
257+
}
258+
}
199259
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package akuma.whiplash.domains.alarm.persistence.repository;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import akuma.whiplash.common.config.PersistenceTest;
6+
import akuma.whiplash.common.fixture.AlarmFixture;
7+
import akuma.whiplash.common.fixture.MemberFixture;
8+
import akuma.whiplash.domains.alarm.application.dto.etc.RingingPushInfo;
9+
import akuma.whiplash.domains.alarm.domain.constant.DeactivateType;
10+
import akuma.whiplash.domains.alarm.persistence.entity.AlarmEntity;
11+
import akuma.whiplash.domains.alarm.persistence.entity.AlarmOccurrenceEntity;
12+
import akuma.whiplash.domains.alarm.persistence.repository.AlarmRepository;
13+
import akuma.whiplash.domains.member.persistence.entity.MemberEntity;
14+
import akuma.whiplash.domains.member.persistence.repository.MemberRepository;
15+
import java.time.LocalDate;
16+
import java.util.List;
17+
import org.junit.jupiter.api.DisplayName;
18+
import org.junit.jupiter.api.Test;
19+
import org.springframework.beans.factory.annotation.Autowired;
20+
21+
@PersistenceTest
22+
class AlarmOccurrenceRepositoryTest {
23+
24+
@Autowired
25+
private AlarmOccurrenceRepository alarmOccurrenceRepository;
26+
@Autowired
27+
private AlarmRepository alarmRepository;
28+
@Autowired
29+
private MemberRepository memberRepository;
30+
31+
@DisplayName("알람이 울리고 있을 때 알람 울림 푸시 알림 대상을 조회하면 회원과 알람 정보가 반환된다")
32+
@Test
33+
void findRingingNotificationTargets_returnsInfo_whenAlarmRinging() {
34+
// given
35+
MemberEntity member = memberRepository.save(MemberFixture.MEMBER_7.toEntity());
36+
AlarmEntity alarm = alarmRepository.save(AlarmFixture.ALARM_07.toEntity(member));
37+
alarmOccurrenceRepository.save(AlarmOccurrenceEntity.builder()
38+
.alarm(alarm)
39+
.date(LocalDate.now())
40+
.time(alarm.getTime())
41+
.deactivateType(DeactivateType.NONE)
42+
.alarmRinging(true)
43+
.ringingCount(1)
44+
.reminderSent(false)
45+
.build());
46+
47+
// when
48+
List<RingingPushInfo> infos = alarmOccurrenceRepository.findRingingNotificationTargets(DeactivateType.NONE);
49+
50+
// then
51+
assertThat(infos).hasSize(1);
52+
RingingPushInfo info = infos.get(0);
53+
assertThat(info.alarmId()).isEqualTo(alarm.getId());
54+
assertThat(info.memberId()).isEqualTo(member.getId());
55+
}
56+
57+
@DisplayName("알람이 울리고 있지 않으면 알람 울림 푸시 알림 대상이 조회되지 않는다")
58+
@Test
59+
void findRingingNotificationTargets_returnsEmpty_whenNoRinging() {
60+
// when
61+
List<RingingPushInfo> infos = alarmOccurrenceRepository.findRingingNotificationTargets(DeactivateType.NONE);
62+
63+
// then
64+
assertThat(infos).isEmpty();
65+
}
66+
}

0 commit comments

Comments
 (0)