Skip to content

Commit efa5fd2

Browse files
authored
Merge pull request #68 from Moongeul/fix/#62
[FIX] ์•Œ๋ฆผ ์„œ๋น„์Šค FCM -> Expo ๋ณ€๊ฒฝ & ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ๊ตฌํ˜„
2 parents af483bb + 4037c0e commit efa5fd2

12 files changed

Lines changed: 210 additions & 93 deletions

File tree

โ€Žbuild.gradleโ€Ž

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,6 @@ dependencies {
6060

6161
testImplementation 'org.springframework.boot:spring-boot-starter-test'
6262
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
63-
64-
// Firebase Admin SDK: ์„œ๋ฒ„ ์ธก์—์„œ FCM, ์ธ์ฆ ๋“ฑ์„ ์ œ์–ดํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•ด์ฃผ๋Š” ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ
65-
implementation 'com.google.firebase:firebase-admin:9.2.0'
6663
}
6764

6865
tasks.named('test') {

โ€Žsrc/main/java/com/moongeul/backend/api/member/service/FollowService.javaโ€Ž

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import com.moongeul.backend.api.member.entity.PrivacyLevel;
99
import com.moongeul.backend.api.member.repository.FollowRepository;
1010
import com.moongeul.backend.api.member.repository.MemberRepository;
11+
import com.moongeul.backend.api.notification.service.NotificationTriggerService;
1112
import com.moongeul.backend.common.exception.BadRequestException;
1213
import com.moongeul.backend.common.exception.NotFoundException;
1314
import com.moongeul.backend.common.response.ErrorStatus;
@@ -28,6 +29,8 @@ public class FollowService {
2829
private final FollowRepository followRepository;
2930
private final MemberRepository memberRepository;
3031

32+
private final NotificationTriggerService notificationTriggerService;
33+
3134
/* ํŒ”๋กœ์šฐ API */
3235
@Transactional
3336
public void follow(Long following_id, String email){
@@ -59,6 +62,10 @@ public void follow(Long following_id, String email){
5962
.build();
6063

6164
followRepository.save(newFollow);
65+
66+
log.info("ํŒ”๋กœ์šฐ ์™„๋ฃŒ - ํŒ”๋กœ์šฐ ๋Œ€์ƒ ID: {}, ์ž‘์„ฑ์ž ID: {}", following.getNickname(), follower.getNickname());
67+
68+
notificationTriggerService.followNotification(following, follower); // ํŒ”๋กœ์šฐ ์•Œ๋ฆผ ๋ฐœ์ƒ
6269
}
6370

6471
/* ์–ธํŒ”๋กœ์šฐ API - ์ด ๊ฒฝ์šฐ, ์Šน์ธ ๋Œ€๊ธฐ์ค‘ ์ƒํƒœ๋„ ๊ฐ™์ด ์‚ญ์ œ */
@@ -71,6 +78,8 @@ public void unfollow(Long followingId, String email) {
7178
.orElseThrow(() -> new BadRequestException(ErrorStatus.NO_FOLLOW_RELATIONSHIP.getMessage()));
7279

7380
followRepository.delete(follow);
81+
82+
log.info("์–ธํŒ”๋กœ์šฐ ์™„๋ฃŒ - ์–ธํŒ”๋กœ์šฐ ๋Œ€์ƒ ID: {}, ์ž‘์„ฑ์ž ID: {}", follow.getFollowing().getNickname(), follower.getNickname());
7483
}
7584

7685
// ํŒ”๋กœ์ž‰ ์‚ฌ์šฉ์ž ๋ชฉ๋ก ์กฐํšŒ

โ€Žsrc/main/java/com/moongeul/backend/api/notification/dto/DeviceTokenRequestDTO.javaโ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,5 @@
1212
public class DeviceTokenRequestDTO {
1313

1414
private String token;
15-
private String platform; // 'ANDROID' or 'IOS' or 'WEB'
15+
private String platform; // AND, IOS, WEB
1616
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.moongeul.backend.api.notification.dto;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Builder;
5+
import lombok.Getter;
6+
import lombok.NoArgsConstructor;
7+
8+
import java.util.List;
9+
import java.util.Map;
10+
11+
@Getter
12+
@Builder
13+
@NoArgsConstructor
14+
@AllArgsConstructor
15+
public class ExpoPushRequestDTO {
16+
17+
private List<String> to; // ์ˆ˜์‹ ์ž ํ† ํฐ ๋ฆฌ์ŠคํŠธ
18+
private String title; // ์•Œ๋ฆผ ์ œ๋ชฉ
19+
private String body; // ์•Œ๋ฆผ ๋ณธ๋ฌธ
20+
private String sound; // "default"
21+
private Map<String, Object> pushData; // ์ถ”๊ฐ€ ๋ฐ์ดํ„ฐ
22+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.moongeul.backend.api.notification.event;
2+
3+
import com.moongeul.backend.api.member.entity.Member;
4+
import com.moongeul.backend.api.question.entity.Question;
5+
6+
public record AnswerNotificationEvent(
7+
8+
Member receiver, // ์•Œ๋ฆผ์„ ๋ฐ›์„ ์‚ฌ๋žŒ (์งˆ๋ฌธ ์ž‘์„ฑ์ž)
9+
Member actor, // ๋Œ“๊ธ€์„ ์ž‘์„ฑํ•œ ์‚ฌ๋žŒ
10+
Question question // ์–ด๋–ค ์งˆ๋ฌธ์ธ์ง€
11+
) {}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.moongeul.backend.api.notification.event;
2+
3+
import com.moongeul.backend.api.member.entity.Member;
4+
5+
public record FollowNotificationEvent(
6+
7+
Member receiver, // ์•Œ๋ฆผ์„ ๋ฐ›์„ ์‚ฌ๋žŒ
8+
Member actor // ํŒ”๋กœ์šฐ๋ฅผ ๊ฑด ์‚ฌ๋žŒ
9+
) {}

โ€Žsrc/main/java/com/moongeul/backend/api/notification/listener/NotificationEventListener.javaโ€Ž

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package com.moongeul.backend.api.notification.listener;
22

3+
import com.moongeul.backend.api.member.entity.PrivacyLevel;
34
import com.moongeul.backend.api.notification.entity.NotificationType;
5+
import com.moongeul.backend.api.notification.event.AnswerNotificationEvent;
6+
import com.moongeul.backend.api.notification.event.FollowNotificationEvent;
47
import com.moongeul.backend.api.notification.event.LikeNotificationEvent;
58
import com.moongeul.backend.api.notification.service.NotificationService;
69
import lombok.RequiredArgsConstructor;
@@ -14,12 +17,13 @@ public class NotificationEventListener { // ๋ฐœํ–‰๋œ ์ด๋ฒคํŠธ๋ฅผ ๋ฐ›์•„์„œ ๋ณ„
1417

1518
private final NotificationService notificationService;
1619

20+
/* ๊ณต๊ฐ ์•Œ๋ฆผ */
1721
@Async // ๋ณ„๋„์˜ ์Šค๋ ˆ๋“œ์—์„œ ์‹คํ–‰๋˜๋„๋ก ์„ค์ •
1822
@EventListener // LikeNotificationEvent๊ฐ€ ๋ฐœํ–‰๋˜๋ฉด ํ˜ธ์ถœ๋จ
1923
public void handleLikeNotification(LikeNotificationEvent event) {
2024
String message = event.actor().getNickname() + "๋‹˜์ด ํšŒ์›๋‹˜์˜ ๊ธฐ๋ก์— ๊ณต๊ฐํ–ˆ์Šต๋‹ˆ๋‹ค.";
2125

22-
// ์‹ค์ œ DB ์ €์žฅ ๋ฐ FCM ๋ฐœ์†ก ๋กœ์ง ์‹คํ–‰
26+
// ์‹ค์ œ DB ์ €์žฅ ๋ฐ Expo ํ‘ธ์‹œ ์•Œ๋ฆผ ๋ฐœ์†ก ๋กœ์ง ์‹คํ–‰
2327
notificationService.send(
2428
event.receiver(),
2529
event.actor(),
@@ -28,4 +32,43 @@ public void handleLikeNotification(LikeNotificationEvent event) {
2832
event.post().getId()
2933
);
3034
}
35+
36+
/* ๋Œ“๊ธ€ ์•Œ๋ฆผ */
37+
@Async
38+
@EventListener
39+
public void handleCommentNotification(AnswerNotificationEvent event) {
40+
String message = event.actor().getNickname() + "๋‹˜์ด ํšŒ์›๋‹˜์˜ ์งˆ๋ฌธ์— ๋Œ“๊ธ€์„ ๋‹ฌ์•˜์Šต๋‹ˆ๋‹ค.";
41+
42+
// ์‹ค์ œ DB ์ €์žฅ ๋ฐ Expo ํ‘ธ์‹œ ์•Œ๋ฆผ ๋ฐœ์†ก ๋กœ์ง ์‹คํ–‰
43+
notificationService.send(
44+
event.receiver(),
45+
event.actor(),
46+
NotificationType.LIKE,
47+
message,
48+
event.question().getId()
49+
);
50+
}
51+
52+
/* ํŒ”๋กœ์šฐ ์•Œ๋ฆผ */
53+
@Async
54+
@EventListener
55+
public void handleFollowNotification(FollowNotificationEvent event) {
56+
57+
String message = event.actor().getNickname() + "๋‹˜์ด ํšŒ์›๋‹˜์—๊ฒŒ ํŒ”๋กœ์šฐ๋ฅผ ์š”์ฒญํ–ˆ์Šต๋‹ˆ๋‹ค.";
58+
NotificationType notificationType = NotificationType.FOLLOW_PRIVATE;
59+
60+
if(event.receiver().getPrivacyLevel() == PrivacyLevel.PUBLIC){ // ๊ณต๊ฐœ ๊ณ„์ •์ผ ๊ฒฝ์šฐ (์š”์ฒญx)
61+
message = event.actor().getNickname() + "๋‹˜์ด ํšŒ์›๋‹˜์„ ํŒ”๋กœ์šฐ ํ–ˆ์Šต๋‹ˆ๋‹ค.";
62+
notificationType = NotificationType.FOLLOW_OPEN;
63+
}
64+
65+
// ์‹ค์ œ DB ์ €์žฅ ๋ฐ Expo ํ‘ธ์‹œ ์•Œ๋ฆผ ๋ฐœ์†ก ๋กœ์ง ์‹คํ–‰
66+
notificationService.send(
67+
event.receiver(),
68+
event.actor(),
69+
notificationType,
70+
message,
71+
event.actor().getId()
72+
);
73+
}
3174
}
Lines changed: 67 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
package com.moongeul.backend.api.notification.service;
22

3-
import com.google.firebase.messaging.*;
43
import com.moongeul.backend.api.notification.dto.DeviceTokenRequestDTO;
4+
import com.moongeul.backend.api.notification.dto.ExpoPushRequestDTO;
55
import com.moongeul.backend.api.notification.entity.DeviceToken;
66
import com.moongeul.backend.api.notification.entity.NotificationType;
77
import com.moongeul.backend.api.notification.entity.Notifications;
@@ -15,8 +15,12 @@
1515
import lombok.extern.slf4j.Slf4j;
1616
import org.springframework.stereotype.Service;
1717
import org.springframework.transaction.annotation.Transactional;
18+
import org.springframework.web.reactive.function.client.WebClient;
1819

20+
import java.util.HashMap;
1921
import java.util.List;
22+
import java.util.Map;
23+
import java.util.stream.Collectors;
2024

2125
@Service
2226
@Slf4j
@@ -27,25 +31,38 @@ public class NotificationService {
2731
private final MemberRepository memberRepository;
2832
private final NotificationRepository notificationRepository;
2933

34+
private final WebClient webClient;
35+
private static final String EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send";
36+
3037
/* ํ† ํฐ ๋“ฑ๋ก/์ˆ˜์ • */
3138
@Transactional
32-
public void registerOrUpdateToken(String email, DeviceTokenRequestDTO requestDTO) {
39+
public void registerOrUpdateToken(String email, DeviceTokenRequestDTO deviceTokenRequestDTO) {
3340
Member member = memberRepository.findByEmail(email)
3441
.orElseThrow(() -> new NotFoundException(ErrorStatus.USER_NOTFOUND_EXCEPTION.getMessage()));
3542

36-
deviceTokenRepository.findByToken(requestDTO.getToken())
43+
// 1. ๊ธฐ์กด์— ํ•ด๋‹น ํ† ํฐ์ด ์žˆ๋Š”์ง€ ํ™•์ธ
44+
deviceTokenRepository.findByToken(deviceTokenRequestDTO.getToken())
3745
.ifPresentOrElse(
38-
token -> token.updateMember(member),
39-
() -> deviceTokenRepository.save(DeviceToken.builder()
40-
.member(member)
41-
.token(requestDTO.getToken())
42-
.platform(requestDTO.getPlatform())
43-
.build())
46+
// 2. ์ด๋ฏธ ์žˆ๋‹ค๋ฉด: ํ† ํฐ์˜ ์ฃผ์ธ์ด ๋ฐ”๋€Œ์—ˆ์„ ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ ์—…๋ฐ์ดํŠธ
47+
existingToken -> {
48+
existingToken.updateMember(member);
49+
},
50+
// 3. ์—†๋‹ค๋ฉด: ์ƒˆ๋กœ์šด DeviceToken ์ƒ์„ฑ ๋ฐ ์ €์žฅ
51+
() -> {
52+
DeviceToken newToken = DeviceToken.builder()
53+
.member(member)
54+
.token(deviceTokenRequestDTO.getToken())
55+
.platform(deviceTokenRequestDTO.getPlatform())
56+
.build();
57+
deviceTokenRepository.save(newToken);
58+
}
4459
);
4560
}
4661

62+
/* ์•Œ๋ฆผ ์ „์†ก */
4763
@Transactional
4864
public void send(Member receiver, Member actor, NotificationType notificationType, String message, Long relatedId) {
65+
// 1. ์•Œ๋ฆผ ๋‚ด์—ญ DB ์ €์žฅ
4966
Notifications notifications = Notifications.builder()
5067
.receiver(receiver)
5168
.actor(actor)
@@ -56,37 +73,52 @@ public void send(Member receiver, Member actor, NotificationType notificationTyp
5673
.build();
5774
notificationRepository.save(notifications);
5875

59-
List<DeviceToken> tokens = deviceTokenRepository.findAllByMemberId(receiver.getId());
76+
// 2. ์ˆ˜์‹ ์ž์˜ ๋ชจ๋“  ๋””๋ฐ”์ด์Šค ํ† ํฐ ์กฐํšŒ
77+
List<String> tokenStrings = deviceTokenRepository.findAllByMemberId(receiver.getId())
78+
.stream()
79+
.map(DeviceToken::getToken)
80+
.collect(Collectors.toList());
81+
82+
// Expo ์ „์†ก์šฉ ์ถ”๊ฐ€ ๋ฐ์ดํ„ฐ ๊ตฌ์„ฑ
83+
Map<String, Object> pushData = new HashMap<>();
84+
pushData.put("type", notificationType);
85+
pushData.put("id", relatedId);
6086

61-
if (!tokens.isEmpty()) {
62-
for (DeviceToken deviceToken : tokens) {
63-
try {
64-
sendMessage(deviceToken.getToken(), notificationType.getKey(), message);
65-
} catch (FirebaseMessagingException e) {
66-
if (e.getMessagingErrorCode() == MessagingErrorCode.UNREGISTERED) {
67-
log.warn("์œ ํšจํ•˜์ง€ ์•Š์€ ํ† ํฐ ์‚ญ์ œ: {}", deviceToken.getToken());
68-
deviceTokenRepository.delete(deviceToken);
69-
} else {
70-
log.error("FCM ์—๋Ÿฌ ๋ฐœ์ƒ: {}", e.getMessage());
71-
}
72-
} catch (Exception e) {
73-
log.error("์ผ๋ฐ˜ ์ „์†ก ์—๋Ÿฌ: {}", e.getMessage());
74-
}
75-
}
87+
if (!tokenStrings.isEmpty()) {
88+
// 3. WebClient๋กœ ๋น„๋™๊ธฐ ์ „์†ก
89+
sendToExpo(tokenStrings, notificationType.getKey(), message, pushData);
7690
}
7791
}
7892

79-
/* ํ† ํฐ์„ ๊ฐ€์ง„ ๊ธฐ๊ธฐ์— ํ‘ธ์‹œ ์•Œ๋ฆผ ์ „์†ก */
80-
public void sendMessage(String targetToken, String title, String body) throws FirebaseMessagingException {
81-
Message message = Message.builder()
82-
.setToken(targetToken)
83-
.setNotification(Notification.builder()
84-
.setTitle(title) // ์•Œ๋ฆผ ํƒ€์ž…
85-
.setBody(body) // ์•Œ๋ฆผ ๋‚ด์šฉ
86-
.build())
93+
/* Expo Push API ํ˜ธ์ถœ */
94+
private void sendToExpo(List<String> targetTokens, String title, String body, Map<String, Object> pushData) {
95+
// ํŽ˜์ด๋กœ๋“œ ๊ตฌ์„ฑ
96+
ExpoPushRequestDTO requestPayload = ExpoPushRequestDTO.builder()
97+
.to(targetTokens)
98+
.title(title)
99+
.body(body)
100+
.sound("default")
101+
.pushData(pushData)
87102
.build();
88103

89-
String response = FirebaseMessaging.getInstance().send(message); // ์—ฌ๊ธฐ์„œ ๋ฐœ์ƒํ•˜๋Š” ์—๋Ÿฌ๊ฐ€ ์œ„์ชฝ(send ๋ฉ”์„œ๋“œ)์œผ๋กœ ์ „๋‹ฌ
90-
log.info("FCM ์ „์†ก ์„ฑ๊ณต: " + response);
104+
webClient.post()
105+
.uri(EXPO_PUSH_URL)
106+
.bodyValue(requestPayload)
107+
.retrieve()
108+
.bodyToMono(Map.class) // ์‘๋‹ต์„ Map ํ˜•ํƒœ๋กœ ๋ฐ›์Œ
109+
.subscribe(
110+
response -> log.info("Expo ํ‘ธ์‹œ ์ „์†ก ์„ฑ๊ณต: {}", response),
111+
error -> log.error("Expo ํ‘ธ์‹œ ์ „์†ก ์‹คํŒจ: {}", error.getMessage())
112+
);
113+
}
114+
115+
/* ๋กœ๊ทธ์•„์›ƒ ์‹œ, ํ† ํฐ ์‚ญ์ œ */
116+
@Transactional
117+
public void removeDeviceToken(String token) {
118+
// ํ† ํฐ์ด ์กด์žฌํ•  ๊ฒฝ์šฐ์—๋งŒ ์‚ญ์ œ ์ง„ํ–‰
119+
deviceTokenRepository.findByToken(token).ifPresent(deviceToken -> {
120+
deviceTokenRepository.delete(deviceToken);
121+
log.info("๋กœ๊ทธ์•„์›ƒ์œผ๋กœ ์ธํ•œ ๋””๋ฐ”์ด์Šค ํ† ํฐ ์‚ญ์ œ ์™„๋ฃŒ: {}", token);
122+
});
91123
}
92124
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package com.moongeul.backend.api.notification.service;
2+
3+
import com.moongeul.backend.api.member.entity.Member;
4+
import com.moongeul.backend.api.notification.event.AnswerNotificationEvent;
5+
import com.moongeul.backend.api.notification.event.FollowNotificationEvent;
6+
import com.moongeul.backend.api.notification.event.LikeNotificationEvent;
7+
import com.moongeul.backend.api.post.entity.Post;
8+
import com.moongeul.backend.api.question.entity.Question;
9+
import lombok.RequiredArgsConstructor;
10+
import lombok.extern.slf4j.Slf4j;
11+
import org.springframework.context.ApplicationEventPublisher;
12+
import org.springframework.stereotype.Service;
13+
14+
@Service
15+
@Slf4j
16+
@RequiredArgsConstructor
17+
public class NotificationTriggerService {
18+
19+
private final ApplicationEventPublisher eventPublisher; // Spring Event ๋ฐœํ–‰ ๊ฐ์ฒด
20+
21+
/* ๊ณต๊ฐ ์•Œ๋ฆผ */
22+
public void likeNotification(Member receiver, Member actor, Post post){
23+
if (!receiver.getId().equals(actor.getId())) { // ์ž์‹ ์˜ ๊ฒŒ์‹œ๊ธ€์ผ ๊ฒฝ์šฐ ์•Œ๋ฆผ ๋ฐœ์ƒ x
24+
eventPublisher.publishEvent(new LikeNotificationEvent(receiver, actor, post));
25+
}
26+
}
27+
28+
/* ๋Œ“๊ธ€ ์•Œ๋ฆผ */
29+
public void answerNotification(Member receiver, Member actor, Question question){
30+
eventPublisher.publishEvent(new AnswerNotificationEvent(receiver, actor, question));
31+
}
32+
33+
/* ํŒ”๋กœ์šฐ ์•Œ๋ฆผ */
34+
public void followNotification(Member receiver, Member actor){
35+
eventPublisher.publishEvent(new FollowNotificationEvent(receiver, actor));
36+
}
37+
}

0 commit comments

Comments
ย (0)