|
1 | 1 | package com.fund.stockProject.notification.service; |
2 | 2 |
|
| 3 | +import com.fasterxml.jackson.core.type.TypeReference; |
| 4 | +import com.fasterxml.jackson.databind.ObjectMapper; |
3 | 5 | import com.fund.stockProject.notification.domain.NotificationType; |
4 | 6 | import com.fund.stockProject.notification.entity.Notification; |
5 | 7 | import com.fund.stockProject.notification.entity.OutboxEvent; |
|
9 | 11 | import com.fund.stockProject.preference.entity.Preference; |
10 | 12 | import com.fund.stockProject.preference.repository.PreferenceRepository; |
11 | 13 | import lombok.RequiredArgsConstructor; |
| 14 | +import lombok.extern.slf4j.Slf4j; |
12 | 15 | import org.springframework.stereotype.Service; |
13 | 16 | import org.springframework.transaction.annotation.Transactional; |
14 | 17 |
|
15 | 18 | import java.time.Instant; |
16 | 19 | import java.time.LocalDate; |
17 | 20 | import java.time.LocalTime; |
18 | 21 | import java.time.ZoneId; |
| 22 | +import java.util.ArrayList; |
| 23 | +import java.util.HashMap; |
19 | 24 | import java.util.List; |
20 | 25 | import java.util.Map; |
21 | 26 |
|
22 | 27 | @Service |
23 | 28 | @RequiredArgsConstructor |
| 29 | +@Slf4j |
24 | 30 | public class StockScoreAlertService { |
25 | 31 | private final NotificationRepository notificationRepo; |
26 | 32 | private final OutboxRepository outboxRepo; |
27 | 33 | private final NotificationService notificationService; |
28 | 34 | private final PreferenceRepository preferenceRepo; |
| 35 | + private final ObjectMapper objectMapper; |
29 | 36 |
|
30 | 37 | private static final int THRESHOLD_ABS = 15; |
31 | 38 |
|
@@ -89,10 +96,102 @@ public void sendDailyScoreAlerts() { |
89 | 96 | // PENDING 상태이고 scheduledAt이 현재 시간 이전인 알림들을 발송 |
90 | 97 | List<OutboxEvent> pendingEvents = outboxRepo.findByStatusAndScheduledAtBefore( |
91 | 98 | "PENDING", Instant.now()); |
92 | | - |
| 99 | + |
| 100 | + if (pendingEvents.isEmpty()) { |
| 101 | + return; |
| 102 | + } |
| 103 | + |
| 104 | + Map<Integer, List<OutboxEvent>> scoreSpikeEventsByUser = new HashMap<>(); |
| 105 | + int readyCount = 0; |
| 106 | + int suppressedCount = 0; |
| 107 | + |
93 | 108 | for (OutboxEvent event : pendingEvents) { |
94 | | - // OutboxDispatcher에서 처리하도록 상태 업데이트 |
| 109 | + Map<String, Object> payload = parsePayload(event.getPayload()); |
| 110 | + if (payload == null) { |
| 111 | + event.setStatus("READY_TO_SEND"); |
| 112 | + readyCount++; |
| 113 | + continue; |
| 114 | + } |
| 115 | + |
| 116 | + String type = String.valueOf(payload.get("type")); |
| 117 | + Integer userId = toInteger(payload.get("userId")); |
| 118 | + |
| 119 | + if (NotificationType.SCORE_SPIKE.name().equals(type) && userId != null) { |
| 120 | + scoreSpikeEventsByUser.computeIfAbsent(userId, key -> new ArrayList<>()).add(event); |
| 121 | + continue; |
| 122 | + } |
| 123 | + |
95 | 124 | event.setStatus("READY_TO_SEND"); |
| 125 | + readyCount++; |
| 126 | + } |
| 127 | + |
| 128 | + for (Map.Entry<Integer, List<OutboxEvent>> entry : scoreSpikeEventsByUser.entrySet()) { |
| 129 | + List<OutboxEvent> userEvents = entry.getValue(); |
| 130 | + if (userEvents.isEmpty()) { |
| 131 | + continue; |
| 132 | + } |
| 133 | + |
| 134 | + OutboxEvent representative = userEvents.get(0); |
| 135 | + representative.setStatus("READY_TO_SEND"); |
| 136 | + readyCount++; |
| 137 | + |
| 138 | + if (userEvents.size() > 1) { |
| 139 | + updateRepresentativeMessage(representative, userEvents.size()); |
| 140 | + for (int i = 1; i < userEvents.size(); i++) { |
| 141 | + userEvents.get(i).setStatus("PROCESSED"); |
| 142 | + suppressedCount++; |
| 143 | + } |
| 144 | + } |
| 145 | + } |
| 146 | + |
| 147 | + log.info("Daily score alerts prepared: totalPending={}, readyToSend={}, suppressed={}", |
| 148 | + pendingEvents.size(), readyCount, suppressedCount); |
| 149 | + } |
| 150 | + |
| 151 | + private void updateRepresentativeMessage(OutboxEvent representative, int totalCount) { |
| 152 | + Map<String, Object> payload = parsePayload(representative.getPayload()); |
| 153 | + if (payload == null) { |
| 154 | + return; |
| 155 | + } |
| 156 | + |
| 157 | + Integer notificationId = toInteger(payload.get("notificationId")); |
| 158 | + if (notificationId == null) { |
| 159 | + return; |
| 160 | + } |
| 161 | + |
| 162 | + notificationRepo.findById(notificationId).ifPresent(notification -> { |
| 163 | + notification.setTitle("북마크 종목 점수 급변 알림"); |
| 164 | + notification.setBody("북마크한 종목 " + totalCount + "개의 점수가 크게 변했습니다."); |
| 165 | + notificationRepo.save(notification); |
| 166 | + }); |
| 167 | + } |
| 168 | + |
| 169 | + private Map<String, Object> parsePayload(String payload) { |
| 170 | + if (payload == null || payload.isBlank()) { |
| 171 | + return null; |
| 172 | + } |
| 173 | + |
| 174 | + try { |
| 175 | + return objectMapper.readValue(payload, new TypeReference<>() {}); |
| 176 | + } catch (Exception e) { |
| 177 | + log.warn("Failed to parse outbox payload: {}", e.getMessage()); |
| 178 | + return null; |
| 179 | + } |
| 180 | + } |
| 181 | + |
| 182 | + private Integer toInteger(Object value) { |
| 183 | + if (value == null) { |
| 184 | + return null; |
| 185 | + } |
| 186 | + |
| 187 | + if (value instanceof Number number) { |
| 188 | + return number.intValue(); |
| 189 | + } |
| 190 | + |
| 191 | + try { |
| 192 | + return Integer.valueOf(value.toString()); |
| 193 | + } catch (NumberFormatException e) { |
| 194 | + return null; |
96 | 195 | } |
97 | 196 | } |
98 | 197 | } |
0 commit comments