Skip to content

Commit 6cebe03

Browse files
committed
feat : 알림 기능 추가
1 parent 154852f commit 6cebe03

28 files changed

Lines changed: 1129 additions & 4 deletions

.claude/settings.local.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(mkdir:*)"
5+
]
6+
}
7+
}

src/main/java/novaminds/gradproj/GradprojApplication.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration;
77
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
88
import org.springframework.retry.annotation.EnableRetry;
9+
import org.springframework.scheduling.annotation.EnableScheduling;
910

1011
@SpringBootApplication(
1112
exclude = {
@@ -15,6 +16,7 @@
1516
)
1617
@EnableJpaAuditing
1718
@EnableRetry
19+
@EnableScheduling
1820
public class GradprojApplication {
1921

2022
public static void main(String[] args) {
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package novaminds.gradproj.config;
2+
3+
import org.springframework.context.annotation.Bean;
4+
import org.springframework.context.annotation.Configuration;
5+
import org.springframework.http.client.SimpleClientHttpRequestFactory;
6+
import org.springframework.web.client.RestTemplate;
7+
8+
@Configuration
9+
public class RestTemplateConfig {
10+
11+
@Bean
12+
public RestTemplate restTemplate() {
13+
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
14+
factory.setConnectTimeout(5000); // 5초
15+
factory.setReadTimeout(5000); // 5초
16+
return new RestTemplate(factory);
17+
}
18+
}

src/main/java/novaminds/gradproj/domain/member/service/command/FollowCommandService.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ public class FollowCommandService {
2020
private final CacheManager cacheManager;
2121
private final MemberRepository memberRepository;
2222
private final FollowRepository followRepository;
23+
private final novaminds.gradproj.domain.notification.service.command.NotificationCommandService notificationCommandService;
2324

2425
public void following(Member follower, String followingNickName) {
2526

@@ -45,6 +46,19 @@ public void following(Member follower, String followingNickName) {
4546

4647
evictMemberCache(follower.getLoginId());
4748
evictMemberCache(following.getLoginId());
49+
50+
// 알림 발송
51+
try {
52+
notificationCommandService.createAndSendNotification(
53+
following,
54+
"새 팔로워",
55+
follower.getNickname() + "님이 회원님을 팔로우하기 시작했습니다.",
56+
"/member/" + follower.getNickname() + "/refrigerator",
57+
novaminds.gradproj.domain.notification.entity.NotificationType.FOLLOW
58+
);
59+
} catch (Exception e) {
60+
// 알림 발송 실패해도 팔로우는 정상 처리
61+
}
4862
}
4963

5064
public void unfollowing(String followerId, String followingNickname) {
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,19 @@
11
package novaminds.gradproj.domain.notification.converter;
22

3+
import novaminds.gradproj.domain.notification.entity.Notification;
4+
import novaminds.gradproj.domain.notification.web.dto.NotificationResponseDTO;
5+
36
public class NotificationConverter {
7+
8+
public static NotificationResponseDTO.NotificationInfo toNotificationInfo(Notification notification) {
9+
return NotificationResponseDTO.NotificationInfo.builder()
10+
.id(notification.getId())
11+
.title(notification.getTitle())
12+
.body(notification.getBody())
13+
.deepLink(notification.getDeepLink())
14+
.type(notification.getType())
15+
.isRead(notification.isRead())
16+
.createdAt(notification.getCreatedAt())
17+
.build();
18+
}
419
}

src/main/java/novaminds/gradproj/domain/notification/entity/NotificationSettings.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,5 +59,20 @@ public class NotificationSettings extends BaseEntity {
5959
@Builder.Default
6060
private boolean enableLikeNotification = true;
6161

62+
//냉장고 초대 알림
63+
@Column(nullable = false)
64+
@Builder.Default
65+
private boolean enableRefrigeratorInvitation = true;
66+
67+
//팔로우 알림
68+
@Column(nullable = false)
69+
@Builder.Default
70+
private boolean enableFollow = true;
71+
72+
//냉장고 재료 추가 알림
73+
@Column(nullable = false)
74+
@Builder.Default
75+
private boolean enableRefrigeratorItemAdded = true;
76+
6277
//TODO : 랭킹 알림은 일단 좀 더 고민해봐야 할 듯
6378
}

src/main/java/novaminds/gradproj/domain/notification/entity/NotificationType.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ public enum NotificationType {
1111
// RECIPE_RECOMMENDATION("레시피 추천"),
1212
RECIPE_LIKE("레시피 좋아요"),
1313
RECIPE_COMMENT("레시피 댓글"),
14-
RECIPE_COMMENT_REPLY("댓글 답글");
14+
RECIPE_COMMENT_REPLY("댓글 답글"),
15+
REFRIGERATOR_INVITATION("냉장고 초대"),
16+
FOLLOW("팔로우 알림"),
17+
REFRIGERATOR_ITEM_ADDED("냉장고 재료 추가");
1518

1619
private final String description;
1720
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,19 @@
11
package novaminds.gradproj.domain.notification.repository;
22

3+
import novaminds.gradproj.domain.member.entity.Member;
4+
import org.springframework.data.domain.Page;
5+
import org.springframework.data.domain.Pageable;
36
import org.springframework.data.jpa.repository.JpaRepository;
47

58
import novaminds.gradproj.domain.notification.entity.Notification;
69

10+
import java.util.List;
11+
712
public interface NotificationRepository extends JpaRepository<Notification, Long> {
13+
14+
Page<Notification> findByMemberOrderByCreatedAtDesc(Member member, Pageable pageable);
15+
16+
List<Notification> findByMemberAndIsReadFalse(Member member);
17+
18+
long countByMemberAndIsReadFalse(Member member);
819
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
package novaminds.gradproj.domain.notification.repository;
22

3+
import novaminds.gradproj.domain.member.entity.Member;
34
import org.springframework.data.jpa.repository.JpaRepository;
45

56
import novaminds.gradproj.domain.notification.entity.NotificationSettings;
67

8+
import java.util.Optional;
9+
710
public interface NotificationSettingsRepository extends JpaRepository<NotificationSettings, Long> {
11+
12+
Optional<NotificationSettings> findByMember(Member member);
813
}
Lines changed: 161 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,172 @@
11
package novaminds.gradproj.domain.notification.service.command;
22

3+
import lombok.RequiredArgsConstructor;
4+
import lombok.extern.slf4j.Slf4j;
5+
import novaminds.gradproj.domain.member.entity.Member;
6+
import novaminds.gradproj.domain.notification.entity.Notification;
7+
import novaminds.gradproj.domain.notification.entity.NotificationSettings;
8+
import novaminds.gradproj.domain.notification.entity.NotificationType;
9+
import novaminds.gradproj.domain.notification.repository.NotificationRepository;
10+
import novaminds.gradproj.domain.notification.repository.NotificationSettingsRepository;
11+
import novaminds.gradproj.domain.userdevice.entity.UserDevice;
12+
import novaminds.gradproj.domain.userdevice.repository.UserDeviceRepository;
13+
import novaminds.gradproj.global.push.service.ExpoPushNotificationService;
314
import org.springframework.stereotype.Service;
415
import org.springframework.transaction.annotation.Transactional;
516

6-
import lombok.RequiredArgsConstructor;
17+
import java.util.HashMap;
18+
import java.util.List;
19+
import java.util.Map;
720

21+
@Slf4j
822
@Service
923
@RequiredArgsConstructor
1024
@Transactional
1125
public class NotificationCommandService {
26+
27+
private final NotificationRepository notificationRepository;
28+
private final NotificationSettingsRepository notificationSettingsRepository;
29+
private final UserDeviceRepository userDeviceRepository;
30+
private final ExpoPushNotificationService expoPushNotificationService;
31+
32+
/**
33+
* 알림 생성 및 발송
34+
* @param member 알림을 받을 사용자
35+
* @param title 알림 제목
36+
* @param body 알림 내용
37+
* @param deepLink 딥링크 URL
38+
* @param type 알림 타입
39+
*/
40+
public Notification createAndSendNotification(
41+
Member member,
42+
String title,
43+
String body,
44+
String deepLink,
45+
NotificationType type
46+
) {
47+
// 1. 사용자 알림 설정 확인
48+
NotificationSettings settings = getNotificationSettings(member);
49+
50+
// 2. 알림 타입별 설정 체크
51+
if (!shouldSendNotification(settings, type)) {
52+
log.info("User {} has disabled {} notifications", member.getLoginId(), type);
53+
return null;
54+
}
55+
56+
// 3. DB에 알림 저장
57+
Notification notification = Notification.builder()
58+
.member(member)
59+
.title(title)
60+
.body(body)
61+
.deepLink(deepLink)
62+
.type(type)
63+
.isRead(false)
64+
.build();
65+
notificationRepository.save(notification);
66+
67+
// 4. 푸시 알림 발송 (설정이 켜져있으면)
68+
if (settings.isEnablePush()) {
69+
sendPushToUserDevices(member, title, body, deepLink, type);
70+
}
71+
72+
return notification;
73+
}
74+
75+
/**
76+
* 알림 타입에 따라 발송 여부 결정
77+
*/
78+
private boolean shouldSendNotification(NotificationSettings settings, NotificationType type) {
79+
return switch (type) {
80+
case EXPIRATION_ALERT -> settings.isEnableExpirationAlert();
81+
case RECIPE_LIKE -> settings.isEnableLikeNotification();
82+
case RECIPE_COMMENT, RECIPE_COMMENT_REPLY -> settings.isEnableCommentNotification();
83+
case REFRIGERATOR_INVITATION -> settings.isEnableRefrigeratorInvitation();
84+
case FOLLOW -> settings.isEnableFollow();
85+
case REFRIGERATOR_ITEM_ADDED -> settings.isEnableRefrigeratorItemAdded();
86+
};
87+
}
88+
89+
/**
90+
* 사용자의 모든 활성 디바이스에 푸시 발송
91+
*/
92+
private void sendPushToUserDevices(Member member, String title, String body, String deepLink, NotificationType type) {
93+
List<UserDevice> devices = userDeviceRepository.findByMemberAndIsActiveTrue(member);
94+
95+
if (devices.isEmpty()) {
96+
log.warn("No active devices found for user: {}", member.getLoginId());
97+
return;
98+
}
99+
100+
Map<String, Object> data = new HashMap<>();
101+
data.put("deepLink", deepLink);
102+
data.put("type", type.name());
103+
data.put("notificationType", type.name());
104+
105+
for (UserDevice device : devices) {
106+
try {
107+
boolean success = expoPushNotificationService.sendPushNotification(
108+
device.getFcmToken(),
109+
title,
110+
body,
111+
data
112+
);
113+
if (success) {
114+
log.info("Push sent to device: {} for user: {}", device.getDeviceId(), member.getLoginId());
115+
}
116+
} catch (Exception e) {
117+
log.error("Failed to send push to device: {}", device.getDeviceId(), e);
118+
}
119+
}
120+
}
121+
122+
/**
123+
* 사용자 알림 설정 조회 (없으면 기본값으로 생성)
124+
*/
125+
private NotificationSettings getNotificationSettings(Member member) {
126+
return notificationSettingsRepository.findByMember(member)
127+
.orElseGet(() -> {
128+
NotificationSettings defaultSettings = NotificationSettings.builder()
129+
.member(member)
130+
.build();
131+
return notificationSettingsRepository.save(defaultSettings);
132+
});
133+
}
134+
135+
/**
136+
* 알림 읽음 처리
137+
*/
138+
public void markAsRead(Long notificationId) {
139+
Notification notification = notificationRepository.findById(notificationId)
140+
.orElseThrow(() -> new IllegalArgumentException("알림을 찾을 수 없습니다."));
141+
142+
Notification updated = Notification.builder()
143+
.id(notification.getId())
144+
.member(notification.getMember())
145+
.title(notification.getTitle())
146+
.body(notification.getBody())
147+
.deepLink(notification.getDeepLink())
148+
.type(notification.getType())
149+
.isRead(true)
150+
.build();
151+
notificationRepository.save(updated);
152+
}
153+
154+
/**
155+
* 모든 알림 읽음 처리
156+
*/
157+
public void markAllAsRead(Member member) {
158+
List<Notification> unreadNotifications = notificationRepository.findByMemberAndIsReadFalse(member);
159+
unreadNotifications.forEach(notification -> {
160+
Notification updated = Notification.builder()
161+
.id(notification.getId())
162+
.member(notification.getMember())
163+
.title(notification.getTitle())
164+
.body(notification.getBody())
165+
.deepLink(notification.getDeepLink())
166+
.type(notification.getType())
167+
.isRead(true)
168+
.build();
169+
notificationRepository.save(updated);
170+
});
171+
}
12172
}

0 commit comments

Comments
 (0)