diff --git a/src/main/java/com/kkinikong/be/feedback/event/FeedbackEventListener.java b/src/main/java/com/kkinikong/be/feedback/event/FeedbackEventListener.java new file mode 100644 index 0000000..a4f0cdc --- /dev/null +++ b/src/main/java/com/kkinikong/be/feedback/event/FeedbackEventListener.java @@ -0,0 +1,27 @@ +package com.kkinikong.be.feedback.event; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import lombok.RequiredArgsConstructor; + +import com.kkinikong.be.feedback.event.payload.FeedbackCreatedEvent; +import com.kkinikong.be.feedback.util.discord.DiscordClient; +import com.kkinikong.be.feedback.util.notion.NotionClient; + +@Component +@RequiredArgsConstructor +public class FeedbackEventListener { + + private final DiscordClient discordClient; + private final NotionClient notionClient; + + @Async("externalApiExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleFeedbackCreatedEvent(FeedbackCreatedEvent feedbackCreatedEvent) { + discordClient.sendFeedbackAlert(feedbackCreatedEvent); + notionClient.sendFeedback(feedbackCreatedEvent); + } +} diff --git a/src/main/java/com/kkinikong/be/feedback/event/payload/FeedbackCreatedEvent.java b/src/main/java/com/kkinikong/be/feedback/event/payload/FeedbackCreatedEvent.java new file mode 100644 index 0000000..cb3fdd4 --- /dev/null +++ b/src/main/java/com/kkinikong/be/feedback/event/payload/FeedbackCreatedEvent.java @@ -0,0 +1,10 @@ +package com.kkinikong.be.feedback.event.payload; + +import com.kkinikong.be.feedback.domain.Feedback; + +public record FeedbackCreatedEvent(int rating, String content, String type) { + public static FeedbackCreatedEvent from(Feedback feedback) { + return new FeedbackCreatedEvent( + feedback.getRating(), feedback.getContent(), feedback.getType()); + } +} diff --git a/src/main/java/com/kkinikong/be/feedback/service/FeedbackService.java b/src/main/java/com/kkinikong/be/feedback/service/FeedbackService.java index f9f8296..44b5de4 100644 --- a/src/main/java/com/kkinikong/be/feedback/service/FeedbackService.java +++ b/src/main/java/com/kkinikong/be/feedback/service/FeedbackService.java @@ -2,6 +2,7 @@ import java.util.stream.Collectors; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -9,15 +10,15 @@ import com.kkinikong.be.feedback.domain.Feedback; import com.kkinikong.be.feedback.dto.request.FeedbackRequest; +import com.kkinikong.be.feedback.event.payload.FeedbackCreatedEvent; import com.kkinikong.be.feedback.repository.FeedbackRepository; -import com.kkinikong.be.user.repository.UserRepository; @Service @RequiredArgsConstructor public class FeedbackService { private final FeedbackRepository feedbackRepository; - private final UserRepository userRepository; + private final ApplicationEventPublisher eventPublisher; @Transactional public void addFeedback(FeedbackRequest request) { @@ -27,5 +28,7 @@ public void addFeedback(FeedbackRequest request) { Feedback.builder().rating(request.rating()).content(request.content()).type(type).build(); feedbackRepository.save(feedback); + + eventPublisher.publishEvent(FeedbackCreatedEvent.from(feedback)); } } diff --git a/src/main/java/com/kkinikong/be/feedback/util/discord/DiscordClient.java b/src/main/java/com/kkinikong/be/feedback/util/discord/DiscordClient.java new file mode 100644 index 0000000..56c41fd --- /dev/null +++ b/src/main/java/com/kkinikong/be/feedback/util/discord/DiscordClient.java @@ -0,0 +1,71 @@ +package com.kkinikong.be.feedback.util.discord; + +import java.util.List; +import java.util.Map; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import com.kkinikong.be.feedback.event.payload.FeedbackCreatedEvent; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DiscordClient { + + private final WebClient webClient; + + @Value("${external-api.discord.feedback-url}") + private String feedbackUrl; + + public void sendFeedbackAlert(FeedbackCreatedEvent feedbackCreatedEvent) { + // 디스코드 규격에 맞는 JSON 바디 생성 + Map body = + Map.of( + "embeds", + List.of( + Map.of( + "title", + "\uD83D\uDCCC 유저의 피드백 도착", + "color", + 5814783, + "fields", + List.of( + Map.of( + "name", + "⭐ 별점", + "value", + feedbackCreatedEvent.rating() + "점", + "inline", + false), + Map.of( + "name", + "🏷️ 유형", + "value", + feedbackCreatedEvent.type(), + "inline", + false), + Map.of( + "name", + "📝 내용", + "value", + feedbackCreatedEvent.content(), + "inline", + false))))); + + // WebClient를 이용한 비동기 전송 + webClient + .post() + .uri(feedbackUrl) + .bodyValue(body) + .retrieve() + .bodyToMono(String.class) + .doOnSuccess(response -> log.info("디스코드 알림 전송 성공")) + .doOnError(error -> log.error("디스코드 알림 전송 실패: {}", error.getMessage())) + .subscribe(); + } +} diff --git a/src/main/java/com/kkinikong/be/feedback/util/notion/NotionClient.java b/src/main/java/com/kkinikong/be/feedback/util/notion/NotionClient.java new file mode 100644 index 0000000..3182a16 --- /dev/null +++ b/src/main/java/com/kkinikong/be/feedback/util/notion/NotionClient.java @@ -0,0 +1,77 @@ +package com.kkinikong.be.feedback.util.notion; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import com.kkinikong.be.feedback.domain.type.FeedbackType; +import com.kkinikong.be.feedback.event.payload.FeedbackCreatedEvent; + +@Slf4j +@Component +@RequiredArgsConstructor +public class NotionClient { + + private final WebClient webClient; + + @Value("${external-api.notion.secret}") + private String secret; + + @Value("${external-api.notion.database-id.feedback}") + private String feedbackId; + + public void sendFeedback(FeedbackCreatedEvent feedbackCreatedEvent) { + + // 현재 날짜 + String today = LocalDate.now().toString(); + + // 유형 + List> typeList = new java.util.ArrayList<>(); + if (feedbackCreatedEvent.type() != null && !feedbackCreatedEvent.type().isBlank()) { + for (String t : feedbackCreatedEvent.type().split(",")) { + String trimmed = t.trim(); + typeList.add(Map.of("name", FeedbackType.valueOf(trimmed).getLabel())); + } + } + + Map body = + Map.of( + "parent", Map.of("database_id", feedbackId), + "properties", + Map.of( + "내용", + Map.of( + "title", + List.of( + Map.of( + "text", + Map.of( + "content", + feedbackCreatedEvent.content() != null + ? feedbackCreatedEvent.content() + : "")))), + "별점", Map.of("number", feedbackCreatedEvent.rating()), + "유형", Map.of("multi_select", typeList), + "피드백 날짜", Map.of("date", Map.of("start", today)))); + webClient + .post() + .uri("https://api.notion.com/v1/pages") + .header("Authorization", "Bearer " + secret) + .header("Notion-Version", "2022-06-28") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(body) + .retrieve() + .bodyToMono(String.class) + .doOnSuccess(res -> log.info("노션 피드백 기록 성공")) + .doOnError(err -> log.error("노션 기록 실패: {}", err.getMessage())) + .subscribe(); + } +} diff --git a/src/main/java/com/kkinikong/be/global/config/AsyncConfig.java b/src/main/java/com/kkinikong/be/global/config/AsyncConfig.java index 7224268..ea8da3c 100644 --- a/src/main/java/com/kkinikong/be/global/config/AsyncConfig.java +++ b/src/main/java/com/kkinikong/be/global/config/AsyncConfig.java @@ -22,4 +22,15 @@ public Executor asyncExecutor() { executor.initialize(); return executor; } + + @Bean(name = "externalApiExecutor") + public Executor externalApiExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); + executor.setMaxPoolSize(15); + executor.setQueueCapacity(50); + executor.setThreadNamePrefix("ExternalApiExecutor-"); + executor.initialize(); + return executor; + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3d07585..b437169 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -46,4 +46,12 @@ springdoc: groups-order: desc tags_sorter: alpha operations_sorter: method # delete - get - patch - post - put 정렬 - use-fqn: true \ No newline at end of file + use-fqn: true + +external-api: + discord: + feedback-url: ${DISCORD_FEEDBACK_WEBHOOK_URL} + notion: + secret: ${NOTION_SECRET} + database-id: + feedback: ${NOTION_FEEDBACK_DATABASE_ID} \ No newline at end of file