Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package gg.agit.konect.domain.notification.service;

import java.util.List;
import java.util.Map;

import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.retry.annotation.Recover;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Component;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@Component
public class ExpoPushClient {

private static final String EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send";
private static final String DEFAULT_NOTIFICATION_CHANNEL_ID = "default_notifications";

private final RestTemplate expoRestTemplate;

public ExpoPushClient(@Qualifier("expoRestTemplate") RestTemplate expoRestTemplate) {
this.expoRestTemplate = expoRestTemplate;
}

@Retryable(maxAttempts = 2)
public void sendNotification(Integer receiverId, List<String> tokens, String title, String body,
Map<String, Object> data) {
List<ExpoPushMessage> messages = tokens.stream()
.map(token -> new ExpoPushMessage(token, title, body, data, DEFAULT_NOTIFICATION_CHANNEL_ID))
.toList();

HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setAccept(List.of(MediaType.APPLICATION_JSON));

HttpEntity<List<ExpoPushMessage>> entity = new HttpEntity<>(messages, headers);
ResponseEntity<ExpoPushResponse> response = expoRestTemplate.exchange(
EXPO_PUSH_URL,
HttpMethod.POST,
entity,
ExpoPushResponse.class
);

if (!response.getStatusCode().is2xxSuccessful()) {
throw new IllegalStateException(
"Expo push response not successful: receiverId=%d, status=%s"
.formatted(receiverId, response.getStatusCode())
);
}

ExpoPushResponse responseBody = response.getBody();
if (responseBody == null || responseBody.data() == null) {
throw new IllegalStateException(
"Expo push response body missing: receiverId=%d".formatted(receiverId)
);
}

for (int i = 0; i < responseBody.data().size(); i += 1) {
ExpoPushTicket ticket = responseBody.data().get(i);
if (ticket == null || "ok".equalsIgnoreCase(ticket.status())) {
continue;
}
String token = i < tokens.size() ? tokens.get(i) : "unknown";
log.error(
"Expo ํ‘ธ์‹œ ๋ฐœ์†ก ์‹คํŒจ: receiverId={}, token={}, status={}, message={}, details={}",
receiverId,
token,
ticket.status(),
ticket.message(),
ticket.details()
);
}

log.debug("์•Œ๋ฆผ ๋ฐœ์†ก ์™„๋ฃŒ: receiverId={}, tokenCount={}", receiverId, tokens.size());
}

@Recover
public void sendNotificationRecover(HttpStatusCodeException e, Integer receiverId, List<String> tokens,
String title,
String body,
Map<String, Object> data) {
log.error(
"์•Œ๋ฆผ ์žฌ์‹œ๋„ ํ›„์—๋„ HTTP ์˜ค๋ฅ˜๋กœ ๋ฐœ์†ก์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: receiverId={}, tokenCount={}, statusCode={}, responseBody={}",
receiverId,
tokens.size(),
e.getStatusCode(),
e.getResponseBodyAsString(),
e
);
}

@Recover
public void sendNotificationRecover(ResourceAccessException e, Integer receiverId, List<String> tokens,
String title,
String body,
Map<String, Object> data) {
Throwable rootCause = e.getMostSpecificCause();
log.error(
"์•Œ๋ฆผ ์žฌ์‹œ๋„ ํ›„์—๋„ ์—ฐ๊ฒฐ ๋ฌธ์ œ๋กœ ๋ฐœ์†ก์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: receiverId={}, tokenCount={}, rootCauseType={}, rootCauseMessage={}",
receiverId,
tokens.size(),
rootCause.getClass().getSimpleName(),
rootCause.getMessage(),
e
);
}

@Recover
public void sendNotificationRecover(IllegalStateException e, Integer receiverId, List<String> tokens, String title,
String body,
Map<String, Object> data) {
log.error(
"์•Œ๋ฆผ ์žฌ์‹œ๋„ ํ›„์—๋„ Expo ์‘๋‹ต์ด ๋น„์ •์ƒ์ด๋ผ ๋ฐœ์†ก์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: receiverId={}, tokenCount={}, message={}",
receiverId,
tokens.size(),
e.getMessage(),
e
);
}

@Recover
public void sendNotificationRecover(RestClientException e, Integer receiverId, List<String> tokens, String title,
String body,
Map<String, Object> data) {
log.error(
"์•Œ๋ฆผ ์žฌ์‹œ๋„ ํ›„์—๋„ Rest ํด๋ผ์ด์–ธํŠธ ์˜ค๋ฅ˜๋กœ ๋ฐœ์†ก์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: receiverId={}, tokenCount={}, exceptionType={}, message={}",
receiverId,
tokens.size(),
e.getClass().getSimpleName(),
e.getMessage(),
e
);
}

@Recover
public void sendNotificationRecover(Exception e, Integer receiverId, List<String> tokens, String title, String body,
Map<String, Object> data) {
log.error(
"์•Œ๋ฆผ ์žฌ์‹œ๋„ ํ›„์—๋„ ์˜ˆ๊ธฐ์น˜ ๋ชปํ•œ ์˜ค๋ฅ˜๋กœ ๋ฐœ์†ก์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: receiverId={}, tokenCount={}, exceptionType={}, message={}",
receiverId,
tokens.size(),
e.getClass().getSimpleName(),
e.getMessage(),
e
);
}

private record ExpoPushMessage(String to, String title, String body, Map<String, Object> data, String channelId) {
}

private record ExpoPushResponse(List<ExpoPushTicket> data) {
}

private record ExpoPushTicket(String status, String message, Map<String, Object> details) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@
import gg.agit.konect.domain.user.model.User;
import gg.agit.konect.domain.user.repository.UserRepository;
import gg.agit.konect.global.exception.CustomException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Service
@Slf4j
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class NotificationService {

Expand All @@ -47,20 +49,7 @@ public class NotificationService {
private final NotificationMuteSettingRepository notificationMuteSettingRepository;
private final RestTemplate restTemplate;
private final ChatPresenceService chatPresenceService;

public NotificationService(
UserRepository userRepository,
NotificationDeviceTokenRepository notificationDeviceTokenRepository,
NotificationMuteSettingRepository notificationMuteSettingRepository,
RestTemplate restTemplate,
ChatPresenceService chatPresenceService
) {
this.userRepository = userRepository;
this.notificationDeviceTokenRepository = notificationDeviceTokenRepository;
this.notificationMuteSettingRepository = notificationMuteSettingRepository;
this.restTemplate = restTemplate;
this.chatPresenceService = chatPresenceService;
}
private final ExpoPushClient expoPushClient;

public NotificationTokenResponse getMyToken(Integer userId) {
NotificationDeviceToken token = notificationDeviceTokenRepository.getByUserId(userId);
Expand Down Expand Up @@ -337,66 +326,14 @@ public void sendClubApplicationRejectedNotification(Integer receiverId, Integer
}

private void sendNotification(Integer receiverId, String title, String body, String path) {
try {
List<String> tokens = notificationDeviceTokenRepository.findTokensByUserId(receiverId);
if (tokens.isEmpty()) {
log.debug("No device tokens found for user: receiverId={}", receiverId);
return;
}

Map<String, Object> data = buildData(null, path);

List<ExpoPushMessage> messages = tokens.stream()
.map(token -> new ExpoPushMessage(token, title, body, data, DEFAULT_NOTIFICATION_CHANNEL_ID))
.toList();

HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setAccept(List.of(MediaType.APPLICATION_JSON));

HttpEntity<List<ExpoPushMessage>> entity = new HttpEntity<>(messages, headers);
ResponseEntity<ExpoPushResponse> response = restTemplate.exchange(
EXPO_PUSH_URL,
HttpMethod.POST,
entity,
ExpoPushResponse.class
);

if (!response.getStatusCode().is2xxSuccessful()) {
log.error(
"Expo push response not successful: receiverId={}, status={}",
receiverId,
response.getStatusCode()
);
return;
}

ExpoPushResponse responseBody = response.getBody();
if (responseBody == null || responseBody.data == null) {
log.error("Expo push response body missing: receiverId={}", receiverId);
return;
}

for (int i = 0; i < responseBody.data.size(); i += 1) {
ExpoPushTicket ticket = responseBody.data.get(i);
if (ticket == null || "ok".equalsIgnoreCase(ticket.status())) {
continue;
}
String token = i < tokens.size() ? tokens.get(i) : "unknown";
log.error(
"Expo push failed: receiverId={}, token={}, status={}, message={}, details={}",
receiverId,
token,
ticket.status(),
ticket.message(),
ticket.details()
);
}

log.debug("Notification sent: receiverId={}, tokenCount={}", receiverId, tokens.size());
} catch (Exception e) {
log.error("Failed to send notification: receiverId={}", receiverId, e);
List<String> tokens = notificationDeviceTokenRepository.findTokensByUserId(receiverId);
if (tokens.isEmpty()) {
log.debug("No device tokens found for user: receiverId={}", receiverId);
return;
}

Map<String, Object> data = buildData(null, path);
expoPushClient.sendNotification(receiverId, tokens, title, body, data);
}

private Map<String, Object> buildData(Map<String, String> data, String path) {
Expand Down
15 changes: 15 additions & 0 deletions src/main/java/gg/agit/konect/global/config/RestTemplateConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ public class RestTemplateConfig {

private static final Integer CONNECT_TIMEOUT = 5000;
private static final Integer READ_TIMEOUT = 5000;
private static final Integer EXPO_CONNECT_TIMEOUT = 10000;
private static final Integer EXPO_READ_TIMEOUT = 10000;

@Bean
public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) {
Expand All @@ -28,4 +30,17 @@ public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) {
.additionalMessageConverters(new StringHttpMessageConverter(UTF_8))
.build();
}

@Bean("expoRestTemplate")
public RestTemplate expoRestTemplate(RestTemplateBuilder restTemplateBuilder) {
return restTemplateBuilder
.requestFactory(() -> {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(EXPO_CONNECT_TIMEOUT);
factory.setReadTimeout(EXPO_READ_TIMEOUT);
return new BufferingClientHttpRequestFactory(factory);
})
.additionalMessageConverters(new StringHttpMessageConverter(UTF_8))
.build();
}
}
Loading