From 68dde6d76920f0be10afaee5ef6cbf27b7f39465 Mon Sep 17 00:00:00 2001 From: letsgilit Date: Thu, 8 May 2025 19:32:50 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20slack=20=EC=95=8C=EB=A6=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EC=9E=AC=EC=8B=9C=EB=8F=84=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -webhook알림 추가 -slackbot을 사용한 사용자 DM추가 -재처리 로직추가 --- .../notification/dto/NotificationEvent.java | 2 +- notification-service/build.gradle | 6 +- .../application/client/SlackClient.java | 13 +++++ .../application/dto/NotificationDto.java | 2 +- .../application/dto/SlackBotMessage.java | 20 +++++++ .../application/dto/SlackUserResponse.java | 23 ++++++++ .../application/dto/SlackWebHookMessage.java | 18 ++++++ .../application/event/NotificationEvent.java | 41 ------------- .../service/NotificationService.java | 29 +++++----- .../domain/entity/Notification.java | 2 + .../domain/entity/NotificationEventType.java | 15 ----- .../domain/entity/NotificationType.java | 36 ------------ .../kafka/NotificationConsumer.java | 18 +++++- .../infrastructure/slack/SlackBotClient.java | 23 ++++++++ .../slack/SlackFeignClientImpl.java | 58 +++++++++++++++++++ .../slack/SlackWebHookClient.java | 14 +++++ 16 files changed, 208 insertions(+), 112 deletions(-) create mode 100644 notification-service/src/main/java/com/timebank/notification_service/application/client/SlackClient.java create mode 100644 notification-service/src/main/java/com/timebank/notification_service/application/dto/SlackBotMessage.java create mode 100644 notification-service/src/main/java/com/timebank/notification_service/application/dto/SlackUserResponse.java create mode 100644 notification-service/src/main/java/com/timebank/notification_service/application/dto/SlackWebHookMessage.java delete mode 100644 notification-service/src/main/java/com/timebank/notification_service/application/event/NotificationEvent.java delete mode 100644 notification-service/src/main/java/com/timebank/notification_service/domain/entity/NotificationEventType.java delete mode 100644 notification-service/src/main/java/com/timebank/notification_service/domain/entity/NotificationType.java create mode 100644 notification-service/src/main/java/com/timebank/notification_service/infrastructure/slack/SlackBotClient.java create mode 100644 notification-service/src/main/java/com/timebank/notification_service/infrastructure/slack/SlackFeignClientImpl.java create mode 100644 notification-service/src/main/java/com/timebank/notification_service/infrastructure/slack/SlackWebHookClient.java diff --git a/common/src/main/java/com/timebank/common/infrastructure/external/notification/dto/NotificationEvent.java b/common/src/main/java/com/timebank/common/infrastructure/external/notification/dto/NotificationEvent.java index 2b02d65..f21cd54 100644 --- a/common/src/main/java/com/timebank/common/infrastructure/external/notification/dto/NotificationEvent.java +++ b/common/src/main/java/com/timebank/common/infrastructure/external/notification/dto/NotificationEvent.java @@ -23,5 +23,5 @@ public class NotificationEvent { private LocalDateTime sentAt; private NotificationEventType eventType; // 이벤트 구분 (CREATED, UPDATED, DELETED) private Map payload; // (추가 정보 전달용) - + private String slackUserEmail; } diff --git a/notification-service/build.gradle b/notification-service/build.gradle index 68de770..5fa78a7 100644 --- a/notification-service/build.gradle +++ b/notification-service/build.gradle @@ -38,12 +38,14 @@ dependencies { // ✅ 공통 모듈 implementation 'com.timebank:common:0.0.1-SNAPSHOT' implementation 'org.springframework.kafka:spring-kafka' - + implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'io.micrometer:micrometer-registry-prometheus' - + implementation 'io.github.resilience4j:resilience4j-spring-boot3' + implementation 'io.github.resilience4j:resilience4j-retry' + runtimeOnly 'com.mysql:mysql-connector-j' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' diff --git a/notification-service/src/main/java/com/timebank/notification_service/application/client/SlackClient.java b/notification-service/src/main/java/com/timebank/notification_service/application/client/SlackClient.java new file mode 100644 index 0000000..2b641af --- /dev/null +++ b/notification-service/src/main/java/com/timebank/notification_service/application/client/SlackClient.java @@ -0,0 +1,13 @@ +package com.timebank.notification_service.application.client; + +import com.timebank.notification_service.application.dto.SlackBotMessage; +import com.timebank.notification_service.application.dto.SlackWebHookMessage; + +public interface SlackClient { + void sendMessage(SlackWebHookMessage message); + + void sendMessage(SlackBotMessage request); + + String getUserIdByEmail(String email); + +} diff --git a/notification-service/src/main/java/com/timebank/notification_service/application/dto/NotificationDto.java b/notification-service/src/main/java/com/timebank/notification_service/application/dto/NotificationDto.java index 955bdde..d8e57cc 100644 --- a/notification-service/src/main/java/com/timebank/notification_service/application/dto/NotificationDto.java +++ b/notification-service/src/main/java/com/timebank/notification_service/application/dto/NotificationDto.java @@ -1,7 +1,7 @@ package com.timebank.notification_service.application.dto; +import com.timebank.common.infrastructure.external.notification.dto.NotificationType; import com.timebank.notification_service.domain.entity.Notification; -import com.timebank.notification_service.domain.entity.NotificationType; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; diff --git a/notification-service/src/main/java/com/timebank/notification_service/application/dto/SlackBotMessage.java b/notification-service/src/main/java/com/timebank/notification_service/application/dto/SlackBotMessage.java new file mode 100644 index 0000000..67eabed --- /dev/null +++ b/notification-service/src/main/java/com/timebank/notification_service/application/dto/SlackBotMessage.java @@ -0,0 +1,20 @@ +package com.timebank.notification_service.application.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Builder +public class SlackBotMessage { + private String channel; + private String text; + + public static SlackBotMessage of(String channel, String text) { + return SlackBotMessage.builder() + .channel(channel) + .text(text) + .build(); + } +} \ No newline at end of file diff --git a/notification-service/src/main/java/com/timebank/notification_service/application/dto/SlackUserResponse.java b/notification-service/src/main/java/com/timebank/notification_service/application/dto/SlackUserResponse.java new file mode 100644 index 0000000..327acf2 --- /dev/null +++ b/notification-service/src/main/java/com/timebank/notification_service/application/dto/SlackUserResponse.java @@ -0,0 +1,23 @@ +package com.timebank.notification_service.application.dto; + +import lombok.Getter; + +@Getter +public class SlackUserResponse { + private boolean ok; + private SlackUser user; + + @Getter + public static class SlackUser { + private String id; + private String teamId; + private String name; + private String realName; + private SlackProfile profile; + + @Getter + public static class SlackProfile { + private String email; + } + } +} diff --git a/notification-service/src/main/java/com/timebank/notification_service/application/dto/SlackWebHookMessage.java b/notification-service/src/main/java/com/timebank/notification_service/application/dto/SlackWebHookMessage.java new file mode 100644 index 0000000..f34480e --- /dev/null +++ b/notification-service/src/main/java/com/timebank/notification_service/application/dto/SlackWebHookMessage.java @@ -0,0 +1,18 @@ +package com.timebank.notification_service.application.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Builder +public class SlackWebHookMessage { + private String text; + + public static SlackWebHookMessage from(String text) { + return SlackWebHookMessage.builder() + .text(text) + .build(); + } +} diff --git a/notification-service/src/main/java/com/timebank/notification_service/application/event/NotificationEvent.java b/notification-service/src/main/java/com/timebank/notification_service/application/event/NotificationEvent.java deleted file mode 100644 index 04ae942..0000000 --- a/notification-service/src/main/java/com/timebank/notification_service/application/event/NotificationEvent.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.timebank.notification_service.application.event; - -import java.time.LocalDateTime; -import java.util.Map; - -import com.timebank.notification_service.domain.entity.Notification; -import com.timebank.notification_service.domain.entity.NotificationEventType; -import com.timebank.notification_service.domain.entity.NotificationType; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class NotificationEvent { - private Long notificationId; - private Long recipientId; - private NotificationType type; // 알림의 타입 (예: USER_LOGIN, POINT_HOLD 등) - private String message; - private Boolean isRead; - private LocalDateTime sentAt; - private NotificationEventType eventType; // 이벤트 구분 (CREATED, UPDATED, DELETED) - private Map payload; // (추가 정보 전달용) - - // 생성자: Notification 엔티티와 이벤트 타입 enum을 받아서 생성 - public NotificationEvent(Notification notification, NotificationEventType eventType) { - this.notificationId = notification.getNotificationId(); - this.recipientId = notification.getRecipientId(); - this.type = notification.getNotificationType(); - this.message = notification.getMessage(); - this.isRead = notification.getIsRead(); - this.eventType = eventType; - this.sentAt = LocalDateTime.now(); - } -} diff --git a/notification-service/src/main/java/com/timebank/notification_service/application/service/NotificationService.java b/notification-service/src/main/java/com/timebank/notification_service/application/service/NotificationService.java index ee38b8a..cd5197c 100644 --- a/notification-service/src/main/java/com/timebank/notification_service/application/service/NotificationService.java +++ b/notification-service/src/main/java/com/timebank/notification_service/application/service/NotificationService.java @@ -11,10 +11,12 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.timebank.common.infrastructure.external.notification.dto.NotificationEventType; +import com.timebank.notification_service.application.client.SlackClient; import com.timebank.notification_service.application.dto.NotificationDto; -import com.timebank.notification_service.application.event.NotificationEvent; +import com.timebank.notification_service.application.dto.SlackBotMessage; +import com.timebank.notification_service.application.dto.SlackWebHookMessage; import com.timebank.notification_service.domain.entity.Notification; -import com.timebank.notification_service.domain.entity.NotificationEventType; import com.timebank.notification_service.domain.repository.NotificationRepository; import jakarta.persistence.EntityNotFoundException; @@ -29,6 +31,7 @@ public class NotificationService { private final NotificationRepository notificationRepository; private final KafkaTemplate kafkaTemplate; + private final SlackClient slackClient; /** * 알림 생성 @@ -46,10 +49,6 @@ public NotificationDto createNotification(NotificationDto notificationDto) { .build(); notification = notificationRepository.save(notification); - // Kafka 이벤트 발행 (CREATED) - NotificationEvent event = new NotificationEvent(notification, NotificationEventType.CREATED); - kafkaTemplate.send(NotificationEventType.CREATED.getTopic(), event); - return NotificationDto.fromEntity(notification); } @@ -96,10 +95,6 @@ public NotificationDto markAsRead(Long notificationId) { notification.setIsRead(true); notification = notificationRepository.save(notification); - // Kafka 이벤트 발행 (UPDATED) - NotificationEvent event = new NotificationEvent(notification, NotificationEventType.UPDATED); - kafkaTemplate.send(NotificationEventType.UPDATED.getTopic(), event); - return NotificationDto.fromEntity(notification); } @@ -113,9 +108,9 @@ public void deleteNotification(Long notificationId) { notificationRepository.delete(notification); - // Kafka 이벤트 발행 (DELETED) - NotificationEvent event = new NotificationEvent(notification, NotificationEventType.DELETED); - kafkaTemplate.send(NotificationEventType.DELETED.getTopic(), event); + // // Kafka 이벤트 발행 (DELETED) + // NotificationEvent event = new NotificationEvent(notification, NotificationEventType.DELETED); + // kafkaTemplate.send(NotificationEventType.DELETED.getTopic(), event); } /** @@ -131,4 +126,12 @@ public List getNotificationsByUser(Long userId) { .map(NotificationDto::fromEntity) .collect(Collectors.toList()); } + + public void sendMessage(SlackWebHookMessage message, String email) { + //채널 알림 + slackClient.sendMessage(message); + + //사용자 DM + slackClient.sendMessage(SlackBotMessage.of(slackClient.getUserIdByEmail(email), message.getText())); + } } diff --git a/notification-service/src/main/java/com/timebank/notification_service/domain/entity/Notification.java b/notification-service/src/main/java/com/timebank/notification_service/domain/entity/Notification.java index 160db2f..3ca6037 100644 --- a/notification-service/src/main/java/com/timebank/notification_service/domain/entity/Notification.java +++ b/notification-service/src/main/java/com/timebank/notification_service/domain/entity/Notification.java @@ -1,6 +1,8 @@ package com.timebank.notification_service.domain.entity; import com.timebank.common.domain.Timestamped; +import com.timebank.common.infrastructure.external.notification.dto.NotificationEventType; +import com.timebank.common.infrastructure.external.notification.dto.NotificationType; import jakarta.persistence.Column; import jakarta.persistence.Entity; diff --git a/notification-service/src/main/java/com/timebank/notification_service/domain/entity/NotificationEventType.java b/notification-service/src/main/java/com/timebank/notification_service/domain/entity/NotificationEventType.java deleted file mode 100644 index 92dc371..0000000 --- a/notification-service/src/main/java/com/timebank/notification_service/domain/entity/NotificationEventType.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.timebank.notification_service.domain.entity; - -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -@Getter -@RequiredArgsConstructor -public enum NotificationEventType { - CREATED("notification.events.CREATED"), - UPDATED("notification.events.UPDATED"), - DELETED("notification.events.DELETED"); - - private final String topic; - -} diff --git a/notification-service/src/main/java/com/timebank/notification_service/domain/entity/NotificationType.java b/notification-service/src/main/java/com/timebank/notification_service/domain/entity/NotificationType.java deleted file mode 100644 index a127fcf..0000000 --- a/notification-service/src/main/java/com/timebank/notification_service/domain/entity/NotificationType.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.timebank.notification_service.domain.entity; - -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor -public enum NotificationType { - - // 유저 관련 알림 - USER_LOGIN("USER_LOGIN"), - USER_INFO_UPDATE("USER_INFO_UPDATE"), - USER_PROFILE_CREATE("USER_PROFILE_CREATE"), - USER_PROFILE_UPDATE("USER_PROFILE_UPDATE"), - - // 포인트 관련 알림 - POINT_HOLD("POINT_HOLD"), // 글 작성 시 포인트 보류 알림 - POINT_TRANSFER("POINT_TRANSFER"), // 거래 확정 시 포인트 송금 알림 - POINT_RECOVERY("POINT_RECOVERY"), // 거래 취소 시 보류 포인트 복구 알림 - - // 게시글 관련 알림 - POST_CREATED("POST_CREATED"), // 게시글 작성 알림 - POST_RECRUITMENT_COMPLETED("POST_RECRUITMENT_COMPLETED"), // 모집 완료 알림 - - // 지원자 관련 알림 - APPLICANT_SUBMITTED("APPLICANT_SUBMITTED"), // 지원 완료 알림 - APPLICANT_SELECTED("APPLICANT_SELECTED"), // 선별 완료 알림 - - // 거래내역 관련 알림 - TRANSACTION_START_REQUEST("TRANSACTION_START_REQUEST"), // 거래 시작 요청 - TRANSACTION_START_COMPLETED("TRANSACTION_START_COMPLETED"), // 거래 시작 완료 - TRANSACTION_END_REQUEST("TRANSACTION_END_REQUEST"), // 거래 종료 요청 - TRANSACTION_END_COMPLETED("TRANSACTION_END_COMPLETED"); // 거래 종료 완료 - - private final String string; -} diff --git a/notification-service/src/main/java/com/timebank/notification_service/infrastructure/kafka/NotificationConsumer.java b/notification-service/src/main/java/com/timebank/notification_service/infrastructure/kafka/NotificationConsumer.java index ac2d02c..082a803 100644 --- a/notification-service/src/main/java/com/timebank/notification_service/infrastructure/kafka/NotificationConsumer.java +++ b/notification-service/src/main/java/com/timebank/notification_service/infrastructure/kafka/NotificationConsumer.java @@ -3,7 +3,9 @@ import org.springframework.kafka.annotation.KafkaListener; import org.springframework.stereotype.Component; -import com.timebank.notification_service.application.event.NotificationEvent; +import com.timebank.common.infrastructure.external.notification.dto.NotificationEvent; +import com.timebank.notification_service.application.dto.SlackWebHookMessage; +import com.timebank.notification_service.application.service.NotificationService; import com.timebank.notification_service.domain.entity.Notification; import com.timebank.notification_service.domain.repository.NotificationRepository; @@ -16,20 +18,26 @@ public class NotificationConsumer { private final NotificationRepository notificationRepository; + private final NotificationService notificationService; // CREATED 토픽 소비 메서드 @KafkaListener(topics = "notification.events.CREATED", groupId = "notification-group") public void consumeCreated(NotificationEvent event) { log.info("Consumed CREATED event: {}", event); saveNotification(event); - // 필요시 추가 로직 처리 (예: 로깅) + + notificationService.sendMessage( + SlackWebHookMessage.from(event.getMessage()), event.getSlackUserEmail()); } // UPDATED 토픽 소비 메서드 @KafkaListener(topics = "notification.events.UPDATED", groupId = "notification-group") public void consumeUpdated(NotificationEvent event) { log.info("Consumed UPDATED event: {}", event); - // 업데이트 이벤트의 경우 추가 처리 로직이 있다면 구현합니다. + + notificationService.sendMessage( + SlackWebHookMessage.from(event.getMessage()), event.getSlackUserEmail()); + saveNotification(event); } @@ -38,7 +46,11 @@ public void consumeUpdated(NotificationEvent event) { public void consumeDeleted(NotificationEvent event) { log.info("Consumed DELETED event: {}", event); // 삭제 이벤트는 삭제 후 로깅 혹은 관련 작업 수행 + notificationService.sendMessage( + SlackWebHookMessage.from(event.getMessage()), event.getSlackUserEmail()); + saveNotification(event); + } // 공통적으로 Notification DB 저장 처리 diff --git a/notification-service/src/main/java/com/timebank/notification_service/infrastructure/slack/SlackBotClient.java b/notification-service/src/main/java/com/timebank/notification_service/infrastructure/slack/SlackBotClient.java new file mode 100644 index 0000000..1e0140f --- /dev/null +++ b/notification-service/src/main/java/com/timebank/notification_service/infrastructure/slack/SlackBotClient.java @@ -0,0 +1,23 @@ +package com.timebank.notification_service.infrastructure.slack; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; + +import com.timebank.notification_service.application.dto.SlackBotMessage; +import com.timebank.notification_service.application.dto.SlackUserResponse; + +@FeignClient(name = "slackBotClient", url = "https://slack.com/api") +public interface SlackBotClient { + + @PostMapping("/chat.postMessage") + void sendMessage(@RequestHeader("Authorization") String authorization, + @RequestBody SlackBotMessage body); + + @GetMapping("/users.lookupByEmail") + SlackUserResponse lookupByEmail(@RequestHeader("Authorization") String authorization, + @RequestParam("email") String email); +} diff --git a/notification-service/src/main/java/com/timebank/notification_service/infrastructure/slack/SlackFeignClientImpl.java b/notification-service/src/main/java/com/timebank/notification_service/infrastructure/slack/SlackFeignClientImpl.java new file mode 100644 index 0000000..ced0371 --- /dev/null +++ b/notification-service/src/main/java/com/timebank/notification_service/infrastructure/slack/SlackFeignClientImpl.java @@ -0,0 +1,58 @@ +package com.timebank.notification_service.infrastructure.slack; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import com.timebank.notification_service.application.client.SlackClient; +import com.timebank.notification_service.application.dto.SlackBotMessage; +import com.timebank.notification_service.application.dto.SlackUserResponse; +import com.timebank.notification_service.application.dto.SlackWebHookMessage; + +import io.github.resilience4j.retry.annotation.Retry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +@RequiredArgsConstructor +public class SlackFeignClientImpl implements SlackClient { + private final SlackWebHookClient slackWebHookClient; + private final SlackBotClient slackBotClient; + private final String bearerPrefix = "Bearer "; + + @Value("${slack.bot.token}") + private String slackBotToken; + + @Override + @Retry(name = "slackMessageRetry", fallbackMethod = "sendMessageFallback") + public void sendMessage(SlackWebHookMessage message) { + slackWebHookClient.sendMessage(message); + } + + @Override + @Retry(name = "slackMessageRetry", fallbackMethod = "sendMessageFallback") + public void sendMessage(SlackBotMessage body) { + slackBotClient.sendMessage(bearerPrefix + slackBotToken, body); + } + + @Override + @Retry(name = "slackMessageRetry", fallbackMethod = "lookupByEmailFallback") + public String getUserIdByEmail(String email) { + SlackUserResponse response = slackBotClient.lookupByEmail(bearerPrefix + slackBotToken, email); + return response.getUser().getId(); + } + + private void sendMessageFallback(SlackWebHookMessage message, Throwable t) { + log.error("SlackWebHook 메시지 전송 실패. fallback 실행: {}", t.getMessage()); + } + + private void sendMessageFallback(SlackBotMessage message, Throwable t) { + log.error("SlackBot 메시지 전송 실패. fallback 실행: {}", t.getMessage()); + } + + private String lookupByEmailFallback(String email, Throwable t) { + log.error("lookupByEmail 실패. fallback 실행: {}", t.getMessage()); + return null; + } + +} diff --git a/notification-service/src/main/java/com/timebank/notification_service/infrastructure/slack/SlackWebHookClient.java b/notification-service/src/main/java/com/timebank/notification_service/infrastructure/slack/SlackWebHookClient.java new file mode 100644 index 0000000..52fb64b --- /dev/null +++ b/notification-service/src/main/java/com/timebank/notification_service/infrastructure/slack/SlackWebHookClient.java @@ -0,0 +1,14 @@ +package com.timebank.notification_service.infrastructure.slack; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +import com.timebank.notification_service.application.dto.SlackWebHookMessage; + +@FeignClient(name = "slackWebhookClient", url = "${slack.webhook.url}") +public interface SlackWebHookClient { + + @PostMapping("${slack.webhook.path}") + void sendMessage(@RequestBody SlackWebHookMessage message); +}