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/java/com/kkinikong/be/report/domain/type/ReportType.java b/src/main/java/com/kkinikong/be/report/domain/type/ReportType.java index c5b8901..50dfab2 100644 --- a/src/main/java/com/kkinikong/be/report/domain/type/ReportType.java +++ b/src/main/java/com/kkinikong/be/report/domain/type/ReportType.java @@ -1,8 +1,15 @@ package com.kkinikong.be.report.domain.type; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor public enum ReportType { - STORE, - REVIEW, - COMMUNITY_POST, - COMMUNITY_COMMENT + STORE("가맹점"), + REVIEW("리뷰"), + COMMUNITY_POST("커뮤니티 게시글"), + COMMUNITY_COMMENT("커뮤니티 댓글"); + + private final String label; } diff --git a/src/main/java/com/kkinikong/be/report/event/ReportEventListener.java b/src/main/java/com/kkinikong/be/report/event/ReportEventListener.java new file mode 100644 index 0000000..265fdcf --- /dev/null +++ b/src/main/java/com/kkinikong/be/report/event/ReportEventListener.java @@ -0,0 +1,27 @@ +package com.kkinikong.be.report.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.report.event.payload.ReportCreatedEvent; +import com.kkinikong.be.report.util.discord.DiscordClient; +import com.kkinikong.be.report.util.notion.NotionClient; + +@Component +@RequiredArgsConstructor +public class ReportEventListener { + + private final NotionClient notionClient; + private final DiscordClient discordClient; + + @Async("externalApiExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleReportCreatedEvent(ReportCreatedEvent reportCreatedEvent) { + discordClient.sendReportAlert(reportCreatedEvent); + notionClient.sendReport(reportCreatedEvent); + } +} diff --git a/src/main/java/com/kkinikong/be/report/event/payload/ReportCreatedEvent.java b/src/main/java/com/kkinikong/be/report/event/payload/ReportCreatedEvent.java new file mode 100644 index 0000000..191d946 --- /dev/null +++ b/src/main/java/com/kkinikong/be/report/event/payload/ReportCreatedEvent.java @@ -0,0 +1,22 @@ +package com.kkinikong.be.report.event.payload; + +import com.kkinikong.be.report.domain.Report; +import com.kkinikong.be.report.domain.type.ReportType; + +public record ReportCreatedEvent( + ReportType reportType, + Long targetId, + Long userId, + String userNickname, + String reason, + String description) { + public static ReportCreatedEvent from(Report report) { + return new ReportCreatedEvent( + report.getReportType(), + report.getTargetId(), + report.getUser().getId(), + report.getUser().getNickname(), + report.getReason(), + report.getDescription()); + } +} diff --git a/src/main/java/com/kkinikong/be/report/service/ReportService.java b/src/main/java/com/kkinikong/be/report/service/ReportService.java index 2f5eb65..df4aab4 100644 --- a/src/main/java/com/kkinikong/be/report/service/ReportService.java +++ b/src/main/java/com/kkinikong/be/report/service/ReportService.java @@ -2,6 +2,7 @@ import java.time.LocalDateTime; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -19,6 +20,7 @@ import com.kkinikong.be.report.domain.type.ReportType; import com.kkinikong.be.report.domain.type.StoreReportReason; import com.kkinikong.be.report.dto.request.ReportRequest; +import com.kkinikong.be.report.event.payload.ReportCreatedEvent; import com.kkinikong.be.report.exception.ReportException; import com.kkinikong.be.report.exception.errorcode.ReportErrorCode; import com.kkinikong.be.report.respository.ReportRepository; @@ -42,6 +44,7 @@ public class ReportService { private final ReviewRepository reviewRepository; private final CommunityPostRepository communityPostRepository; private final CommentRepository commentRepository; + private final ApplicationEventPublisher applicationEventPublisher; @Transactional public void reportStore( @@ -66,6 +69,7 @@ public void reportStore( .build(); reportRepository.save(report); + applicationEventPublisher.publishEvent(ReportCreatedEvent.from(report)); } @Transactional @@ -80,7 +84,9 @@ public void reportReview( checkSelfReport(review.getUser().getId(), userId); checkTargetExist(ReportType.REVIEW, reviewId, userId); - saveReport(reviewId, ReportType.REVIEW, CommonReportReason, reportRequest, user); + Report report = + saveReport(reviewId, ReportType.REVIEW, CommonReportReason, reportRequest, user); + applicationEventPublisher.publishEvent(ReportCreatedEvent.from(report)); } @Transactional @@ -97,7 +103,9 @@ public void reportCommunityPost( communityPost.incrementReportCount(); - saveReport(postId, ReportType.COMMUNITY_POST, CommonReportReason, reportRequest, user); + Report report = + saveReport(postId, ReportType.COMMUNITY_POST, CommonReportReason, reportRequest, user); + applicationEventPublisher.publishEvent(ReportCreatedEvent.from(report)); } @Transactional @@ -115,10 +123,13 @@ public void reportComment( comment.incrementReportCount(); - saveReport(commentId, ReportType.COMMUNITY_COMMENT, CommonReportReason, reportRequest, user); + Report report = + saveReport( + commentId, ReportType.COMMUNITY_COMMENT, CommonReportReason, reportRequest, user); + applicationEventPublisher.publishEvent(ReportCreatedEvent.from(report)); } - private void saveReport( + private Report saveReport( Long targetId, ReportType reportType, CommonReportReason commonReportReason, @@ -136,7 +147,7 @@ private void saveReport( .user(user) .build(); - reportRepository.save(report); + return reportRepository.save(report); } private User findUserOrThrow(Long userId) { diff --git a/src/main/java/com/kkinikong/be/report/util/discord/DiscordClient.java b/src/main/java/com/kkinikong/be/report/util/discord/DiscordClient.java new file mode 100644 index 0000000..21478cb --- /dev/null +++ b/src/main/java/com/kkinikong/be/report/util/discord/DiscordClient.java @@ -0,0 +1,68 @@ +package com.kkinikong.be.report.util.discord; + +import java.time.LocalDateTime; +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.report.event.payload.ReportCreatedEvent; + +@Slf4j +@Component("reportDiscordClient") +@RequiredArgsConstructor +public class DiscordClient { + + private final WebClient webClient; + + @Value("${external-api.discord.report-url}") + private String reportUrl; + + public void sendReportAlert(ReportCreatedEvent reportCreatedEvent) { + // 디스코드 메시지 바디 구성 + String content = + String.format( + "**신고 유형**: %s\n" + + "**신고 사유**: %s\n" + + "**타겟 ID**: %d\n" + + "**신고자**: %s \n" + + "**상세 내용**: %s", + reportCreatedEvent.reportType().getLabel(), + reportCreatedEvent.reason(), + reportCreatedEvent.targetId(), + reportCreatedEvent.userNickname(), + reportCreatedEvent.description()); + + Map body = + Map.of( + "embeds", + List.of( + Map.of( + "title", + "📌 신고 리포트 알림", + "description", + content, + "color", + 16711680, // 빨간색 + "timestamp", + LocalDateTime.now().toString()))); + + // WebClient 호출 + webClient + .post() + .uri(reportUrl) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(body) + .retrieve() + .bodyToMono(Void.class) + .doOnSuccess(res -> log.info("디스코드 신고 알림 전송 성공")) + .doOnError(err -> log.error("디스코드 알림 실패: {}", err.getMessage())) + .subscribe(); + } +} diff --git a/src/main/java/com/kkinikong/be/report/util/notion/NotionClient.java b/src/main/java/com/kkinikong/be/report/util/notion/NotionClient.java new file mode 100644 index 0000000..79a32f5 --- /dev/null +++ b/src/main/java/com/kkinikong/be/report/util/notion/NotionClient.java @@ -0,0 +1,75 @@ +package com.kkinikong.be.report.util.notion; + +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 org.springframework.web.reactive.function.client.WebClientResponseException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import com.kkinikong.be.report.event.payload.ReportCreatedEvent; + +@Slf4j +@Component("reportNotionClient") +@RequiredArgsConstructor +public class NotionClient { + + private final WebClient webClient; + + @Value("${external-api.notion.secret}") + private String secret; + + @Value("${external-api.notion.database-id.report}") + private String reportDbId; + + public void sendReport(ReportCreatedEvent event) { + Map props = new java.util.HashMap<>(); + + props.put( + "신고자 닉네임", + Map.of("title", List.of(Map.of("text", Map.of("content", event.userNickname()))))); + + props.put("신고자 ID", Map.of("number", event.userId())); + props.put("신고 유형", Map.of("select", Map.of("name", event.reportType().getLabel()))); + props.put("대상 ID", Map.of("number", event.targetId())); + props.put("신고 사유", Map.of("select", Map.of("name", event.reason()))); + props.put( + "기타 사유", + Map.of( + "rich_text", + List.of( + Map.of( + "text", + Map.of("content", event.description() != null ? event.description() : ""))))); + + props.put("신고 일시", Map.of("date", Map.of("start", java.time.LocalDate.now().toString()))); + + Map body = + Map.of("parent", Map.of("database_id", reportDbId), "properties", props); + + // WebClient 전송 로직 + 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 -> { + if (err instanceof WebClientResponseException ex) { + log.error("노션 상세 에러 메시지: {}", ex.getResponseBodyAsString()); + } + log.error("노션 기록 실패: {}", err.getMessage()); + }) + .subscribe(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3d07585..1121dbe 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -46,4 +46,14 @@ 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} + report-url: ${DISCORD_REPORT_WEBHOOK_URL} + notion: + secret: ${NOTION_SECRET} + database-id: + feedback: ${NOTION_FEEDBACK_DATABASE_ID} + report: ${NOTION_REPORT_DATABASE_ID} \ No newline at end of file