Skip to content

Commit b35c01a

Browse files
authored
Merge pull request #63 from 9ITHON/dev
Dev
2 parents b952458 + f2d0955 commit b35c01a

File tree

10 files changed

+346
-2
lines changed

10 files changed

+346
-2
lines changed

โ€Žbackend/src/main/java/com/together/backend/ToGetHerApplication.javaโ€Ž

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@
22

33
import org.springframework.boot.SpringApplication;
44
import org.springframework.boot.autoconfigure.SpringBootApplication;
5+
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
6+
import org.springframework.scheduling.annotation.EnableScheduling;
57

68
@SpringBootApplication
9+
@EnableJpaAuditing
10+
@EnableScheduling
711
public class ToGetHerApplication {
812

913
public static void main(String[] args) {

โ€Žbackend/src/main/java/com/together/backend/domain/calendar/service/CalendarService.javaโ€Ž

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
import com.together.backend.domain.calendar.repository.RelationRecordRepository;
1313
import com.together.backend.domain.couple.model.entity.Couple;
1414
import com.together.backend.domain.couple.repository.CoupleRepository;
15+
import com.together.backend.domain.notification.model.NotificationType;
16+
import com.together.backend.domain.notification.service.NotificationService;
1517
import com.together.backend.domain.pill.model.UserPill;
1618
import com.together.backend.domain.pill.repository.UserPillRepository;
1719
import com.together.backend.domain.user.model.entity.Gender;
@@ -38,6 +40,7 @@ public class CalendarService {
3840
private final UserRepository userRepository;
3941
private final CoupleRepository coupleRepository;
4042
private final UserPillRepository userPillRepository;
43+
private final NotificationService notificationService;
4144

4245
@Autowired
4346
public CalendarService(
@@ -46,14 +49,16 @@ public CalendarService(
4649
IntakeRecordRepository intakeRecordRepository,
4750
UserRepository userRepository,
4851
CoupleRepository coupleRepository,
49-
UserPillRepository userPillRepository
52+
UserPillRepository userPillRepository,
53+
NotificationService notificationService
5054
) {
5155
this.relationRecordRepository = relationRecordRepository;
5256
this.basicRecordRepository = basicRecordRepository;
5357
this.intakeRecordRepository = intakeRecordRepository;
5458
this.userRepository = userRepository;
5559
this.coupleRepository = coupleRepository;
5660
this.userPillRepository = userPillRepository;
61+
this.notificationService = notificationService;
5762
}
5863

5964
// ์บ˜๋ฆฐ๋” ๊ธฐ๋ก ๋“ฑ๋ก ๋กœ์ง
@@ -157,6 +162,18 @@ public void saveCalendarRecord(User user, CalendarRecordRequest request) {
157162
}
158163
basicRecordRepository.save(basicRecord);
159164

165+
// ์•Œ๋ฆผ ์„œ๋น„์Šค ํ˜ธ์ถœ ๋กœ์ง ์ถ”๊ฐ€
166+
if(request.getMoodEmoji() != null) {
167+
// ํŒŒํŠธ๋„ˆ์—๊ฒŒ ์•Œ๋ฆผ ์ „์†ก
168+
notificationService.sendNotification(
169+
partnerUser.getUserId(),
170+
user.getUserId(),
171+
NotificationType.EMOTION_UPDATE,
172+
"๊ฐ์ •์ด ๊ธฐ๋ก๋˜์—ˆ์–ด์š”.",
173+
user.getNickname() + " ๋‹˜์ด ์˜ค๋Š˜ ๊ฐ์ •์„ ๋“ฑ๋กํ–ˆ์–ด์š”! ํ•œ ๋ฒˆ ํ™•์ธํ•ด๋ณด์„ธ์š”."
174+
);
175+
}
176+
160177
} else if (isMale) {
161178
// [๋ถˆ๊ฐ€] ๊ฐ์ •, ๋ณต์šฉ ๊ธฐ๋ก
162179
if (request.getMoodEmoji() != null || request.getTakenPill() != null) {
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package com.together.backend.domain.notification.controller;
2+
3+
import com.together.backend.domain.notification.service.NotificationService;
4+
import com.together.backend.domain.notification.service.NotificationSseService;
5+
import com.together.backend.global.security.oauth2.dto.CustomOAuth2User;
6+
import lombok.RequiredArgsConstructor;
7+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
8+
import org.springframework.web.bind.annotation.GetMapping;
9+
import org.springframework.web.bind.annotation.RequestMapping;
10+
import org.springframework.web.bind.annotation.RequestParam;
11+
import org.springframework.web.bind.annotation.RestController;
12+
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
13+
14+
@RestController
15+
@RequestMapping("/api/notifications")
16+
@RequiredArgsConstructor
17+
public class NotificationSseController {
18+
19+
private final NotificationSseService notificationSseService;
20+
21+
@GetMapping("/subscribe")
22+
public SseEmitter subscribe(@AuthenticationPrincipal CustomOAuth2User oAuth2User) {
23+
if (oAuth2User == null) {
24+
// ์ธ์ฆ๋˜์ง€ ์•Š์€ ์š”์ฒญ
25+
throw new IllegalArgumentException("Unauthorized: ๋กœ๊ทธ์ธ ํ•„์š”");
26+
}
27+
String email = oAuth2User.getEmail();
28+
System.out.println("SSE subscribe ์š”์ฒญ: "+ email);
29+
return notificationSseService.subscribe(email);
30+
}
31+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package com.together.backend.domain.notification.model;
2+
3+
import com.together.backend.domain.user.model.entity.User;
4+
import jakarta.persistence.*;
5+
import lombok.*;
6+
import org.springframework.data.annotation.CreatedDate;
7+
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
8+
9+
import java.time.LocalDateTime;
10+
11+
@Getter @Setter
12+
@NoArgsConstructor
13+
@AllArgsConstructor
14+
@Builder
15+
@Entity
16+
@EntityListeners(AuditingEntityListener.class)
17+
@Table(name = "notification") // ์‹ค์ œ ์•Œ๋ฆฌ ์ด๋ ฅ ํ…Œ์ด๋ธ”
18+
public class Notification {
19+
@Id
20+
@GeneratedValue(strategy = GenerationType.IDENTITY)
21+
private Long id;
22+
23+
// ์•Œ๋ฆผ ๋ฐ›๋Š” ์‚ฌ๋žŒ
24+
@ManyToOne(fetch = FetchType.LAZY)
25+
@JoinColumn(name = "receiver_id", nullable = false)
26+
private User receiver;
27+
28+
// ์•Œ๋ฆผ ๋ณด๋‚ธ ์‚ฌ๋žŒ
29+
@ManyToOne(fetch = FetchType.LAZY)
30+
@JoinColumn(name = "sender_id")
31+
private User sender;
32+
33+
@Enumerated(EnumType.STRING)
34+
@Column(nullable = false)
35+
private NotificationType type;
36+
37+
@Column(nullable = false, length = 255)
38+
private String title;
39+
40+
@Column(nullable = false, columnDefinition = "TEXT")
41+
private String content;
42+
43+
@Column(nullable = false)
44+
private Boolean isRead = false;
45+
46+
@CreatedDate
47+
@Column(name = "created_at", nullable = false, updatable = false)
48+
private LocalDateTime createdAt = LocalDateTime.now();
49+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.together.backend.domain.notification.repository;
2+
3+
import com.together.backend.domain.notification.model.Notification;
4+
import org.springframework.data.jpa.repository.JpaRepository;
5+
6+
public interface NotificationRepository extends JpaRepository<Notification, Long> {
7+
}

โ€Žbackend/src/main/java/com/together/backend/domain/notification/repository/NotificationSettingsRepository.javaโ€Ž

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
import org.springframework.data.jpa.repository.JpaRepository;
77

88
import java.time.LocalDate;
9+
import java.util.List;
910
import java.util.Optional;
1011

1112
public interface NotificationSettingsRepository extends JpaRepository<NotificationSettings, Long> {
1213
Optional<NotificationSettings> findByUserAndType(User user, NotificationType type);
14+
List<NotificationSettings> findByTypeAndIsEnabled(NotificationType notificationType, boolean b);
1315
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package com.together.backend.domain.notification.service;
2+
3+
import com.together.backend.domain.calendar.model.entity.IntakeRecord;
4+
import com.together.backend.domain.calendar.repository.IntakeRecordRepository;
5+
import com.together.backend.domain.notification.model.Notification;
6+
import com.together.backend.domain.notification.model.NotificationSettings;
7+
import com.together.backend.domain.notification.model.NotificationType;
8+
import com.together.backend.domain.notification.repository.NotificationRepository;
9+
import com.together.backend.domain.notification.repository.NotificationSettingsRepository;
10+
import com.together.backend.domain.pill.model.UserPill;
11+
import com.together.backend.domain.pill.repository.UserPillRepository;
12+
import com.together.backend.domain.user.model.entity.User;
13+
import com.together.backend.domain.user.repository.UserRepository;
14+
import jakarta.transaction.Transactional;
15+
import lombok.RequiredArgsConstructor;
16+
import org.springframework.scheduling.annotation.Scheduled;
17+
import org.springframework.stereotype.Service;
18+
19+
import java.time.LocalDate;
20+
import java.time.LocalTime;
21+
import java.util.List;
22+
import java.util.Optional;
23+
24+
@Service
25+
@RequiredArgsConstructor
26+
public class NotificationService {
27+
28+
private final NotificationRepository notificationRepository;
29+
private final UserRepository userRepository;
30+
private final NotificationSettingsRepository notificationSettingsRepository;
31+
private final UserPillRepository userPillRepository;
32+
private final IntakeRecordRepository intakeRecordRepository;
33+
private final NotificationSseService notificationSseService;
34+
35+
@Transactional
36+
public Notification sendNotification(
37+
Long receiverId,
38+
Long senderId,
39+
NotificationType type,
40+
String title,
41+
String content
42+
) {
43+
User receiver = userRepository.findById(receiverId)
44+
.orElseThrow(() -> new IllegalArgumentException("์ˆ˜์‹ ์ž(user) ์ •๋ณด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค"));
45+
User sender = null;
46+
if(senderId != null) {
47+
sender = userRepository.findById(senderId)
48+
.orElseThrow(() -> new IllegalArgumentException("๋ฐœ์‹ ์ž(user) ์ •๋ณด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค."));
49+
}
50+
51+
Notification notification = Notification.builder()
52+
.receiver(receiver)
53+
.sender(sender)
54+
.type(type)
55+
.title(title)
56+
.content(content)
57+
.isRead(false)
58+
.build();
59+
60+
Notification saved = notificationRepository.save(notification);
61+
notificationSseService.notifyUser(saved);
62+
return saved;
63+
}
64+
65+
/**
66+
* ์•ฝ ๋ณต์šฉ ์•Œ๋ฆผ - ๋งค 1๋ถ„๋งˆ๋‹ค ํ˜„์žฌ ์‹œ๊ฐ์— ๋งž๋Š” ์œ ์ €์—๊ฒŒ ์•Œ๋ฆผ ์ƒ์„ฑ
67+
*/
68+
@Scheduled(cron= "0 * * * * *") // ๋งค๋ถ„ 0์ดˆ
69+
public void checkAndSendPillIntakeNotifications() {
70+
LocalTime now = LocalTime.now().withSecond(0).withNano(0); // ์ดˆ, ๋ฐ€๋ฆฌ์ดˆ ๋ฒ„๋ฆผ
71+
72+
// ์•Œ๋ฆผ ์„ค์ •์ด ์ผœ์ ธ ์žˆ๋Š” ๋ชจ๋“  ์œ ์ €์˜ PILL_INTAKE ์กฐํšŒ
73+
List<NotificationSettings> settingsList =
74+
notificationSettingsRepository.findByTypeAndIsEnabled(NotificationType.PILL_INTAKE, true);
75+
76+
for (NotificationSettings settings : settingsList) {
77+
// ์•Œ๋ฆผ ์‹œ๊ฐ„์ด null์ด ์•„๋‹ˆ๊ณ , ์ง€๊ธˆ ์‹œ๊ฐ„๊ณผ ๊ฐ™์œผ๋ฉด
78+
if (settings.getNotificationTime() != null &&
79+
settings.getNotificationTime().withSecond(0).equals(now)) {
80+
81+
// ์œ ์ €์˜ UserPill ๊ฐ€์ ธ์˜ค๊ธฐ
82+
Optional<UserPill> userPillOpt = userPillRepository.findByUser(settings.getUser());
83+
if(userPillOpt.isPresent()) {
84+
UserPill userPill = userPillOpt.get();
85+
86+
// ์˜ค๋Š˜ ๋ณต์šฉํ–ˆ๋Š”์ง€ IntakeRecord๋กœ ์ฒดํฌ
87+
Optional<IntakeRecord> recordOpt = intakeRecordRepository
88+
.findByUserPillAndIntakeDate(userPill, LocalDate.now());
89+
boolean alreadyTaken = recordOpt.map(r -> Boolean.TRUE.equals(r.getIsTaken())).orElse(false);
90+
91+
if(alreadyTaken) continue; // ์˜ค๋Š˜ ์ด๋ฏธ ๋ณต์šฉํ–ˆ๋‹ค๋ฉด ์•Œ๋ฆผ ๋ณด๋‚ด์ง€ ์•Š์Œ
92+
}
93+
94+
// ์•Œ๋ฆผ ์ €์žฅ (sender๋Š” system)
95+
sendNotification(
96+
settings.getUser().getUserId(),
97+
null, // sender == system
98+
NotificationType.PILL_INTAKE,
99+
"ํ”ผ์ž„์•ฝ์„ ๋ณต์šฉํ•  ์‹œ๊ฐ„์ด์—์š”.",
100+
"ํ”ผ์ž„์•ฝ์„ ๋ณต์šฉํ•  ์‹œ๊ฐ„์ด ๋˜์—ˆ์–ด์š”. ์‹์‚ฌ ํ›„ ์ถฉ๋ถ„ํ•œ ๋ฌผ๊ณผ ํ•จ๊ป˜ ์„ญ์ทจํ•ด์ฃผ์„ธ์š”."
101+
);
102+
}
103+
}
104+
}
105+
106+
/**
107+
* ์•ฝ ๊ตฌ๋งค ์•Œ๋ฆผ - ๋งค์ผ 10์‹œ์— ์‹คํ–‰, ๊ตฌ๋งค์•Œ๋ฆผ ์‹œ์ ์ด ์˜ค๋Š˜์ธ ๊ฒฝ์šฐ ์•Œ๋ฆผ ์ƒ์„ฑ
108+
*/
109+
@Scheduled(cron = "0 0 10 * * *") // ๋งค์ผ 22์‹œ 7๋ถ„
110+
public void checkAndSendPillPurchaseNotifications() {
111+
LocalDate today = LocalDate.now();
112+
System.out.println("[๊ตฌ๋งค์•Œ๋ฆผ] ์˜ค๋Š˜ ๋‚ ์งœ: " + today);
113+
114+
List<NotificationSettings> settingsList =
115+
notificationSettingsRepository.findByTypeAndIsEnabled(NotificationType.PILL_PURCHASE, true);
116+
117+
for (NotificationSettings settings : settingsList) {
118+
System.out.println("[๊ตฌ๋งค์•Œ๋ฆผ] ๊ฒ€์‚ฌ์ค‘ user: " + settings.getUser().getUserId());
119+
120+
Optional<UserPill> userPillOpt = userPillRepository.findByUser(settings.getUser());
121+
if(userPillOpt.isPresent()) {
122+
UserPill userPill = userPillOpt.get();
123+
124+
System.out.println("[๊ตฌ๋งค์•Œ๋ฆผ] nextPurchaseAlert: " + userPill.getNextPurchaseAlert());
125+
126+
// today == nextPurchaseAlert์ผ ๋•Œ๋งŒ ์•Œ๋ฆผ ์ „์†ก
127+
if(today.equals(userPill.getNextPurchaseAlert())) {
128+
System.out.println("[๊ตฌ๋งค์•Œ๋ฆผ] ์•Œ๋ฆผ ์ƒ์„ฑ! ์œ ์ €: " + settings.getUser().getUserId());
129+
sendNotification(
130+
settings.getUser().getUserId(),
131+
null,
132+
NotificationType.PILL_PURCHASE,
133+
"ํ”ผ์ž„์•ฝ์„ ๊ตฌ๋งคํ•ด์•ผ ๋ผ์š”",
134+
"๋‹˜์€ ํ”ผ์ž„์•ฝ์ด ์–ผ๋งˆ ๋‚จ์ง€ ์•Š์•˜์–ด์š”. ์•ฝ๊ตญ์— ๊ฐ€์„œ ๊ตฌ๋งค ํ›„ ๊ธฐ๋กํ•ด์ฃผ์„ธ์š”."
135+
);
136+
}
137+
}
138+
}
139+
}
140+
141+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package com.together.backend.domain.notification.service;
2+
3+
import com.together.backend.domain.notification.model.Notification;
4+
import lombok.RequiredArgsConstructor;
5+
import lombok.extern.slf4j.Slf4j;
6+
import org.springframework.stereotype.Service;
7+
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
8+
9+
import java.io.IOException;
10+
import java.util.Map;
11+
import java.util.concurrent.ConcurrentHashMap;
12+
13+
@Slf4j
14+
@Service
15+
@RequiredArgsConstructor
16+
public class NotificationSseService {
17+
18+
// email์„ key๋กœ SSE emitter ๊ด€๋ฆฌ
19+
private final Map<String, SseEmitter> emitters = new ConcurrentHashMap<>();
20+
21+
// 1. SSE ๊ตฌ๋… ์š”์ฒญ
22+
public SseEmitter subscribe(String email) {
23+
SseEmitter emitter = new SseEmitter(60 * 60 * 1000L); // 1์‹œ๊ฐ„ ํƒ€์ž„์•„์›ƒ
24+
emitters.put(email, emitter);
25+
log.info("[SSE] emitter ๋“ฑ๋ก: email={}, ํ˜„์žฌ ์—ฐ๊ฒฐ ์ˆ˜: {}", email, emitters.size());
26+
27+
emitter.onCompletion(() -> {
28+
emitters.remove(email);
29+
log.info("[SSE] emitter ์ •์ƒ์ข…๋ฃŒ: email={}, ์—ฐ๊ฒฐ ์ˆ˜: {}", email, emitters.size());
30+
});
31+
emitter.onTimeout(() -> {
32+
emitters.remove(email);
33+
log.info("[SSE] emitter ํƒ€์ž„์•„์›ƒ: email={}, ์—ฐ๊ฒฐ ์ˆ˜: {}", email, emitters.size());
34+
});
35+
emitter.onError(e -> {
36+
emitters.remove(email);
37+
log.warn("[SSE] emitter ์˜ค๋ฅ˜ ๋ฐœ์ƒ, ์ œ๊ฑฐ: email={}, ์—๋Ÿฌ: {}", email, e.toString());
38+
});
39+
40+
try {
41+
emitter.send(SseEmitter.event().name("connect").data("connected!"));
42+
log.info("[SSE] ์—ฐ๊ฒฐ ํ™•์ธ์šฉ ๋”๋ฏธ ์ด๋ฒคํŠธ ์ „์†ก: email={}", email);
43+
} catch (IOException e) {
44+
emitter.completeWithError(e);
45+
log.error("[SSE] ๋”๋ฏธ ์ด๋ฒคํŠธ ์ „์†ก ์‹คํŒจ: email={}", email, e);
46+
}
47+
48+
return emitter;
49+
}
50+
51+
// 2. ์•Œ๋ฆผ์ด ์ €์žฅ๋  ๋•Œ ํ˜ธ์ถœ (NotificationService์—์„œ ์‚ฌ์šฉ)
52+
public void notifyUser(Notification notification) {
53+
String email = notification.getReceiver().getEmail();
54+
SseEmitter emitter = emitters.get(email);
55+
if (emitter != null) {
56+
log.info("[SSE] ์•Œ๋ฆผ PUSH ์‹œ๋„: email={}, ์•Œ๋ฆผ id={}", email, notification.getId());
57+
try {
58+
emitter.send(SseEmitter.event()
59+
.name("notification")
60+
.data(notification));
61+
log.info("[SSE] ์•Œ๋ฆผ PUSH ์„ฑ๊ณต: email={}, ์•Œ๋ฆผ id={}", email, notification.getId());
62+
} catch (IOException e) {
63+
emitter.completeWithError(e);
64+
emitters.remove(email);
65+
log.warn("[SSE] ์•Œ๋ฆผ PUSH ์‹คํŒจ & emitter ์ œ๊ฑฐ: email={}, ์—๋Ÿฌ: {}", email, e.toString());
66+
}
67+
} else {
68+
log.info("[SSE] ์•Œ๋ฆผ PUSH ์‹คํŒจ: email={} โ†’ emitter ์—†์Œ(๋ฏธ์—ฐ๊ฒฐ ์ƒํƒœ)", email);
69+
}
70+
}
71+
}

0 commit comments

Comments
ย (0)