From a096dff715e35f9573790fcae7bb31494d8d4fc7 Mon Sep 17 00:00:00 2001 From: WooJJam <111514410+WooJJam@users.noreply.github.com> Date: Tue, 12 May 2026 23:30:45 +0900 Subject: [PATCH 1/5] =?UTF-8?q?chore:=20discord-webhook-url=20=EB=B3=80?= =?UTF-8?q?=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- application/src/main/resources/application.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/application/src/main/resources/application.yml b/application/src/main/resources/application.yml index c39e314..37c2585 100644 --- a/application/src/main/resources/application.yml +++ b/application/src/main/resources/application.yml @@ -71,5 +71,8 @@ google: youtube: api-key: ${YOUTUBE_DATA_API_KEY:default_youtube_api_key} +discord: + webhook-url: ${DISCORD_WEBHOOK_URL:} + admin: access-token: ${ADMIN_ACCESS_TOKEN:default_admin_access_token} From cfb8afc294be18db60097db3d3c843fc891a196f Mon Sep 17 00:00:00 2001 From: WooJJam <111514410+WooJJam@users.noreply.github.com> Date: Wed, 13 May 2026 02:38:52 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20=EB=B6=84=EC=82=B0=EB=9D=BD=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/common/aop/DistributedLockAspect.java | 7 +++++-- .../yapp/ndgl/domain/common/lock/NamedLockRepository.java | 8 ++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/application/src/main/java/com/yapp/ndgl/application/common/aop/DistributedLockAspect.java b/application/src/main/java/com/yapp/ndgl/application/common/aop/DistributedLockAspect.java index 0ceca4a..be1f8a2 100644 --- a/application/src/main/java/com/yapp/ndgl/application/common/aop/DistributedLockAspect.java +++ b/application/src/main/java/com/yapp/ndgl/application/common/aop/DistributedLockAspect.java @@ -35,8 +35,11 @@ public class DistributedLockAspect { private final DistributedLockRepository lockRepository; - @Around("@annotation(distributedLock)") - public Object around(final ProceedingJoinPoint pjp, final DistributedLock distributedLock) throws Throwable { + @Around("@annotation(com.yapp.ndgl.application.common.annotation.DistributedLock)") + public Object around(final ProceedingJoinPoint pjp) throws Throwable { + MethodSignature signature = (MethodSignature) pjp.getSignature(); + DistributedLock distributedLock = signature.getMethod().getAnnotation(DistributedLock.class); + String key = resolveKey(distributedLock.key(), pjp); if (!StringUtils.hasText(key)) { log.error("분산락 키 표현식 평가 결과가 비어있습니다. expression={}", distributedLock.key()); diff --git a/domain/domain-rdb/src/main/java/com/yapp/ndgl/domain/common/lock/NamedLockRepository.java b/domain/domain-rdb/src/main/java/com/yapp/ndgl/domain/common/lock/NamedLockRepository.java index 818afdb..1d3daf8 100644 --- a/domain/domain-rdb/src/main/java/com/yapp/ndgl/domain/common/lock/NamedLockRepository.java +++ b/domain/domain-rdb/src/main/java/com/yapp/ndgl/domain/common/lock/NamedLockRepository.java @@ -73,8 +73,8 @@ private void acquire(final Connection conn, final String key, final int timeoutS log.error("GET_LOCK 결과가 비어있습니다. key={}", key); throw new GlobalException(CommonErrorCode.LOCK_ACQUISITION_TIMEOUT); } - Integer acquired = (Integer) rs.getObject(1); - if (acquired == null || acquired != 1) { + Number acquired = (Number) rs.getObject(1); + if (acquired == null || acquired.intValue() != 1) { log.warn("분산락 획득 실패. key={}, result={}", key, acquired); throw new GlobalException(CommonErrorCode.LOCK_ACQUISITION_TIMEOUT); } @@ -91,8 +91,8 @@ private void release(final Connection conn, final String key) { leaked = true; log.error("RELEASE_LOCK 결과가 비어있습니다. key={}", key); } else { - Integer released = (Integer) rs.getObject(1); - if (released == null || released != 1) { + Number released = (Number) rs.getObject(1); + if (released == null || released.intValue() != 1) { leaked = true; log.error("RELEASE_LOCK 실패. 락이 해제되지 않았습니다. key={}, result={}", key, released); } From 5aa855ddfac016e0c7a1055ba8b7dda82ba00886 Mon Sep 17 00:00:00 2001 From: WooJJam <111514410+WooJJam@users.noreply.github.com> Date: Wed, 13 May 2026 02:39:06 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20Discord=20=EC=9B=B9=ED=9B=85=20?= =?UTF-8?q?=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../clients/discord/DiscordNotification.java | 22 +++++++ .../ndgl/clients/discord/DiscordNotifier.java | 25 ++++++++ .../discord/DiscordPayloadConstraints.java | 21 +++++++ .../clients/discord/DiscordWebhookClient.java | 40 ++++++++++++ .../discord/config/DiscordWebhookConfig.java | 32 ++++++++++ .../config/DiscordWebhookProperties.java | 9 +++ .../clients/discord/request/DiscordEmbed.java | 61 +++++++++++++++++++ .../request/DiscordWebhookRequest.java | 29 +++++++++ .../com/yapp/ndgl/common/util/TextUtils.java | 25 ++++++++ 9 files changed, 264 insertions(+) create mode 100644 clients/src/main/java/com/yapp/ndgl/clients/discord/DiscordNotification.java create mode 100644 clients/src/main/java/com/yapp/ndgl/clients/discord/DiscordNotifier.java create mode 100644 clients/src/main/java/com/yapp/ndgl/clients/discord/DiscordPayloadConstraints.java create mode 100644 clients/src/main/java/com/yapp/ndgl/clients/discord/DiscordWebhookClient.java create mode 100644 clients/src/main/java/com/yapp/ndgl/clients/discord/config/DiscordWebhookConfig.java create mode 100644 clients/src/main/java/com/yapp/ndgl/clients/discord/config/DiscordWebhookProperties.java create mode 100644 clients/src/main/java/com/yapp/ndgl/clients/discord/request/DiscordEmbed.java create mode 100644 clients/src/main/java/com/yapp/ndgl/clients/discord/request/DiscordWebhookRequest.java create mode 100644 common/src/main/java/com/yapp/ndgl/common/util/TextUtils.java diff --git a/clients/src/main/java/com/yapp/ndgl/clients/discord/DiscordNotification.java b/clients/src/main/java/com/yapp/ndgl/clients/discord/DiscordNotification.java new file mode 100644 index 0000000..9e390de --- /dev/null +++ b/clients/src/main/java/com/yapp/ndgl/clients/discord/DiscordNotification.java @@ -0,0 +1,22 @@ +package com.yapp.ndgl.clients.discord; + +import java.util.List; + +import com.yapp.ndgl.clients.discord.request.DiscordEmbed; + +/** + * Discord 채널에 발송할 알림의 표현 형식. + *

알림 종류별로 구현체를 추가해 확장한다. + */ +public interface DiscordNotification { + + /** + * 메시지 상단 본문(content). 없으면 {@code null}. + */ + String createContent(); + + /** + * Discord embed 목록. 없으면 {@code null} 또는 빈 리스트. + */ + List createEmbeds(); +} diff --git a/clients/src/main/java/com/yapp/ndgl/clients/discord/DiscordNotifier.java b/clients/src/main/java/com/yapp/ndgl/clients/discord/DiscordNotifier.java new file mode 100644 index 0000000..b30b0f1 --- /dev/null +++ b/clients/src/main/java/com/yapp/ndgl/clients/discord/DiscordNotifier.java @@ -0,0 +1,25 @@ +package com.yapp.ndgl.clients.discord; + +import org.springframework.stereotype.Component; + +import com.yapp.ndgl.clients.discord.request.DiscordWebhookRequest; + +import lombok.RequiredArgsConstructor; + +/** + * 알림 종류({@link DiscordNotification})를 Discord webhook 페이로드로 감싸 전송한다. + */ +@Component +@RequiredArgsConstructor +public class DiscordNotifier { + + private final DiscordWebhookClient discordWebhookClient; + + public void notify(final DiscordNotification notification) { + DiscordWebhookRequest request = DiscordWebhookRequest.of( + notification.createContent(), + notification.createEmbeds() + ); + discordWebhookClient.send(request); + } +} diff --git a/clients/src/main/java/com/yapp/ndgl/clients/discord/DiscordPayloadConstraints.java b/clients/src/main/java/com/yapp/ndgl/clients/discord/DiscordPayloadConstraints.java new file mode 100644 index 0000000..f239e59 --- /dev/null +++ b/clients/src/main/java/com/yapp/ndgl/clients/discord/DiscordPayloadConstraints.java @@ -0,0 +1,21 @@ +package com.yapp.ndgl.clients.discord; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * Discord 페이로드의 길이/개수 제약 상수. + *

Discord API 문서를 기준으로 한 메시지/embed의 한도를 한 곳에서 관리한다. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class DiscordPayloadConstraints { + + public static final int CONTENT_MAX = 2000; + public static final int EMBED_TITLE_MAX = 256; + public static final int EMBED_DESCRIPTION_MAX = 4096; + public static final int FIELD_NAME_MAX = 256; + public static final int FIELD_VALUE_MAX = 1024; + public static final int FIELDS_MAX_COUNT = 25; + + public static final String YOUTUBE_THUMBNAIL_URL_FORMAT = "https://img.youtube.com/vi/%s/hqdefault.jpg"; +} diff --git a/clients/src/main/java/com/yapp/ndgl/clients/discord/DiscordWebhookClient.java b/clients/src/main/java/com/yapp/ndgl/clients/discord/DiscordWebhookClient.java new file mode 100644 index 0000000..4e2216f --- /dev/null +++ b/clients/src/main/java/com/yapp/ndgl/clients/discord/DiscordWebhookClient.java @@ -0,0 +1,40 @@ +package com.yapp.ndgl.clients.discord; + +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClient; + +import com.yapp.ndgl.clients.discord.config.DiscordWebhookProperties; +import com.yapp.ndgl.clients.discord.request.DiscordWebhookRequest; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DiscordWebhookClient { + + private final RestClient discordWebhookRestClient; + private final DiscordWebhookProperties properties; + + public void send(final DiscordWebhookRequest request) { + if (!StringUtils.hasText(properties.webhookUrl())) { + log.warn("Discord webhook URL이 설정되지 않아 알림을 스킵합니다."); + return; + } + + try { + discordWebhookRestClient.post() + .uri(properties.webhookUrl()) + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .retrieve() + .toBodilessEntity(); + log.debug("Discord 알림 전송 성공"); + } catch (Exception e) { + log.error("Discord 알림 전송에 실패했습니다.", e); + } + } +} diff --git a/clients/src/main/java/com/yapp/ndgl/clients/discord/config/DiscordWebhookConfig.java b/clients/src/main/java/com/yapp/ndgl/clients/discord/config/DiscordWebhookConfig.java new file mode 100644 index 0000000..8660506 --- /dev/null +++ b/clients/src/main/java/com/yapp/ndgl/clients/discord/config/DiscordWebhookConfig.java @@ -0,0 +1,32 @@ +package com.yapp.ndgl.clients.discord.config; + +import java.time.Duration; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestClient; + +@Configuration +@EnableConfigurationProperties(DiscordWebhookProperties.class) +public class DiscordWebhookConfig { + + private static final int CONNECT_TIMEOUT_SECONDS = 3; + private static final int READ_TIMEOUT_SECONDS = 5; + + @Bean + public RestClient discordWebhookRestClient() { + return RestClient.builder() + .requestFactory(clientHttpRequestFactory()) + .build(); + } + + private ClientHttpRequestFactory clientHttpRequestFactory() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(Duration.ofSeconds(CONNECT_TIMEOUT_SECONDS)); + factory.setReadTimeout(Duration.ofSeconds(READ_TIMEOUT_SECONDS)); + return factory; + } +} diff --git a/clients/src/main/java/com/yapp/ndgl/clients/discord/config/DiscordWebhookProperties.java b/clients/src/main/java/com/yapp/ndgl/clients/discord/config/DiscordWebhookProperties.java new file mode 100644 index 0000000..e6ee740 --- /dev/null +++ b/clients/src/main/java/com/yapp/ndgl/clients/discord/config/DiscordWebhookProperties.java @@ -0,0 +1,9 @@ +package com.yapp.ndgl.clients.discord.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "discord") +public record DiscordWebhookProperties( + String webhookUrl +) { +} diff --git a/clients/src/main/java/com/yapp/ndgl/clients/discord/request/DiscordEmbed.java b/clients/src/main/java/com/yapp/ndgl/clients/discord/request/DiscordEmbed.java new file mode 100644 index 0000000..66c7ab2 --- /dev/null +++ b/clients/src/main/java/com/yapp/ndgl/clients/discord/request/DiscordEmbed.java @@ -0,0 +1,61 @@ +package com.yapp.ndgl.clients.discord.request; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.yapp.ndgl.clients.discord.DiscordPayloadConstraints; +import com.yapp.ndgl.common.util.TextUtils; + +import lombok.Builder; + +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public record DiscordEmbed( + String title, + String description, + String url, + Integer color, + String timestamp, + Image image, + Thumbnail thumbnail, + List fields +) { + public DiscordEmbed { + title = TextUtils.truncate(title, DiscordPayloadConstraints.EMBED_TITLE_MAX); + description = TextUtils.truncate(description, DiscordPayloadConstraints.EMBED_DESCRIPTION_MAX); + + if (fields != null && fields.size() > DiscordPayloadConstraints.FIELDS_MAX_COUNT) { + fields = List.copyOf(fields.subList(0, DiscordPayloadConstraints.FIELDS_MAX_COUNT)); + } + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record Field( + String name, + String value, + Boolean inline + ) { + public Field { + name = TextUtils.truncate(name, DiscordPayloadConstraints.FIELD_NAME_MAX); + value = TextUtils.truncate(value, DiscordPayloadConstraints.FIELD_VALUE_MAX); + } + + public static Field of(final String name, final String value, final boolean inline) { + return new Field(name, value, inline); + } + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record Image(String url) { + public static Image of(final String url) { + return url == null ? null : new Image(url); + } + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record Thumbnail(String url) { + public static Thumbnail of(final String url) { + return url == null ? null : new Thumbnail(url); + } + } +} diff --git a/clients/src/main/java/com/yapp/ndgl/clients/discord/request/DiscordWebhookRequest.java b/clients/src/main/java/com/yapp/ndgl/clients/discord/request/DiscordWebhookRequest.java new file mode 100644 index 0000000..73b2bb5 --- /dev/null +++ b/clients/src/main/java/com/yapp/ndgl/clients/discord/request/DiscordWebhookRequest.java @@ -0,0 +1,29 @@ +package com.yapp.ndgl.clients.discord.request; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.yapp.ndgl.clients.discord.DiscordPayloadConstraints; +import com.yapp.ndgl.common.util.TextUtils; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record DiscordWebhookRequest( + String content, + List embeds +) { + public DiscordWebhookRequest { + content = TextUtils.truncate(content, DiscordPayloadConstraints.CONTENT_MAX); + } + + public static DiscordWebhookRequest of(final List embeds) { + return new DiscordWebhookRequest(null, embeds); + } + + public static DiscordWebhookRequest of(final String content) { + return new DiscordWebhookRequest(content, null); + } + + public static DiscordWebhookRequest of(final String content, final List embeds) { + return new DiscordWebhookRequest(content, embeds); + } +} diff --git a/common/src/main/java/com/yapp/ndgl/common/util/TextUtils.java b/common/src/main/java/com/yapp/ndgl/common/util/TextUtils.java new file mode 100644 index 0000000..14fb9d5 --- /dev/null +++ b/common/src/main/java/com/yapp/ndgl/common/util/TextUtils.java @@ -0,0 +1,25 @@ +package com.yapp.ndgl.common.util; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class TextUtils { + + /** + * 입력 텍스트가 한도를 초과하면 끝을 ellipsis(…)로 잘라낸다. + * + * @param text 원본 텍스트 (nullable) + * @param max 허용 최대 길이 + * @return 한도를 만족하는 텍스트, 입력이 null이면 null + */ + public static String truncate(final String text, final int max) { + if (text == null) { + return null; + } + if (text.length() <= max) { + return text; + } + return text.substring(0, max - 1) + "…"; + } +} From 9a1e5aa5b6d1766cfc5d00687bc1d882fcd62737 Mon Sep 17 00:00:00 2001 From: WooJJam <111514410+WooJJam@users.noreply.github.com> Date: Wed, 13 May 2026 02:39:44 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=A0=9C=EC=95=88=20=ED=85=9C=ED=94=8C=EB=A6=BF=20Discord=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UserSuggestedTemplateCreatedEvent.java | 12 +++ .../UserSuggestedTemplateNotification.java | 74 +++++++++++++++++++ .../TravelTemplateEventListener.java | 3 +- .../UserSuggestedTemplateEventListener.java | 31 ++++++++ .../UserSuggestedTemplateEventPublisher.java | 38 ++++++++++ .../facade/UserSuggestedTemplateFacade.java | 6 +- .../service/UserSuggestedTemplateService.java | 3 +- 7 files changed, 164 insertions(+), 3 deletions(-) create mode 100644 application/src/main/java/com/yapp/ndgl/application/domains/travel/event/UserSuggestedTemplateCreatedEvent.java create mode 100644 application/src/main/java/com/yapp/ndgl/application/domains/travel/event/UserSuggestedTemplateNotification.java rename application/src/main/java/com/yapp/ndgl/application/domains/travel/event/{ => listener}/TravelTemplateEventListener.java (86%) create mode 100644 application/src/main/java/com/yapp/ndgl/application/domains/travel/event/listener/UserSuggestedTemplateEventListener.java create mode 100644 application/src/main/java/com/yapp/ndgl/application/domains/travel/event/publisher/UserSuggestedTemplateEventPublisher.java diff --git a/application/src/main/java/com/yapp/ndgl/application/domains/travel/event/UserSuggestedTemplateCreatedEvent.java b/application/src/main/java/com/yapp/ndgl/application/domains/travel/event/UserSuggestedTemplateCreatedEvent.java new file mode 100644 index 0000000..ae18ec1 --- /dev/null +++ b/application/src/main/java/com/yapp/ndgl/application/domains/travel/event/UserSuggestedTemplateCreatedEvent.java @@ -0,0 +1,12 @@ +package com.yapp.ndgl.application.domains.travel.event; + +public record UserSuggestedTemplateCreatedEvent( + Long templateId, + String videoId, + String videoLink, + String suggesterUuid, + String category, + String region, + String recommendReason +) { +} diff --git a/application/src/main/java/com/yapp/ndgl/application/domains/travel/event/UserSuggestedTemplateNotification.java b/application/src/main/java/com/yapp/ndgl/application/domains/travel/event/UserSuggestedTemplateNotification.java new file mode 100644 index 0000000..76497a6 --- /dev/null +++ b/application/src/main/java/com/yapp/ndgl/application/domains/travel/event/UserSuggestedTemplateNotification.java @@ -0,0 +1,74 @@ +package com.yapp.ndgl.application.domains.travel.event; + +import java.time.Instant; +import java.util.List; + +import org.springframework.util.StringUtils; + +import com.yapp.ndgl.clients.discord.DiscordNotification; +import com.yapp.ndgl.clients.discord.DiscordPayloadConstraints; +import com.yapp.ndgl.clients.discord.request.DiscordEmbed; +import com.yapp.ndgl.clients.discord.request.DiscordEmbed.Field; +import com.yapp.ndgl.clients.discord.request.DiscordEmbed.Image; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public final class UserSuggestedTemplateNotification implements DiscordNotification { + + private static final int EMBED_COLOR = 5763719; + private static final String CONTENT_HEADER = "## 🎬 새로운 영상이 요청되었어요. 지금 확인해보세요!"; + private static final String EMBED_TITLE = "영상 보기"; + + private final UserSuggestedTemplateCreatedEvent event; + + public static UserSuggestedTemplateNotification from(final UserSuggestedTemplateCreatedEvent event) { + return new UserSuggestedTemplateNotification(event); + } + + @Override + public String createContent() { + return CONTENT_HEADER; + } + + @Override + public List createEmbeds() { + + List fields = List.of( + Field.of("카테고리", emptyIfBlank(event.category()), true), + Field.of("지역", emptyIfBlank(event.region()), true), + Field.of("템플릿 ID", "#" + event.templateId(), true), + Field.of("제안자", maskUuid(event.suggesterUuid()), false) + ); + + String description = StringUtils.hasText(event.recommendReason()) ? event.recommendReason() : null; + Image image = StringUtils.hasText(event.videoId()) ? + Image.of(DiscordPayloadConstraints.YOUTUBE_THUMBNAIL_URL_FORMAT.formatted(event.videoId())) : null; + + DiscordEmbed embed = DiscordEmbed.builder() + .title(EMBED_TITLE) + .url(event.videoLink()) + .color(EMBED_COLOR) + .timestamp(Instant.now().toString()) + .description(description) + .image(image) + .fields(fields) + .build(); + + return List.of(embed); + } + + private static String emptyIfBlank(final String value) { + return StringUtils.hasText(value) ? value : "-"; + } + + private static String maskUuid(final String uuid) { + if (!StringUtils.hasText(uuid)) { + return "-"; + } + if (uuid.length() <= 8) { + return uuid; + } + return uuid.substring(0, 8) + "…"; + } +} diff --git a/application/src/main/java/com/yapp/ndgl/application/domains/travel/event/TravelTemplateEventListener.java b/application/src/main/java/com/yapp/ndgl/application/domains/travel/event/listener/TravelTemplateEventListener.java similarity index 86% rename from application/src/main/java/com/yapp/ndgl/application/domains/travel/event/TravelTemplateEventListener.java rename to application/src/main/java/com/yapp/ndgl/application/domains/travel/event/listener/TravelTemplateEventListener.java index d1f7843..37c811b 100644 --- a/application/src/main/java/com/yapp/ndgl/application/domains/travel/event/TravelTemplateEventListener.java +++ b/application/src/main/java/com/yapp/ndgl/application/domains/travel/event/listener/TravelTemplateEventListener.java @@ -1,9 +1,10 @@ -package com.yapp.ndgl.application.domains.travel.event; +package com.yapp.ndgl.application.domains.travel.event.listener; import org.springframework.stereotype.Component; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; +import com.yapp.ndgl.application.domains.travel.event.TravelTemplateViewCountEvent; import com.yapp.ndgl.domain.travel.service.TravelTemplateDomainService; import lombok.RequiredArgsConstructor; diff --git a/application/src/main/java/com/yapp/ndgl/application/domains/travel/event/listener/UserSuggestedTemplateEventListener.java b/application/src/main/java/com/yapp/ndgl/application/domains/travel/event/listener/UserSuggestedTemplateEventListener.java new file mode 100644 index 0000000..e41d42b --- /dev/null +++ b/application/src/main/java/com/yapp/ndgl/application/domains/travel/event/listener/UserSuggestedTemplateEventListener.java @@ -0,0 +1,31 @@ +package com.yapp.ndgl.application.domains.travel.event.listener; + +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +import com.yapp.ndgl.application.domains.travel.event.UserSuggestedTemplateCreatedEvent; +import com.yapp.ndgl.application.domains.travel.event.UserSuggestedTemplateNotification; +import com.yapp.ndgl.clients.discord.DiscordNotifier; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class UserSuggestedTemplateEventListener { + + private final DiscordNotifier discordNotifier; + + @Async + @EventListener + public void handleUserSuggestedTemplateCreatedEvent(final UserSuggestedTemplateCreatedEvent event) { + try { + UserSuggestedTemplateNotification notification = UserSuggestedTemplateNotification.from(event); + discordNotifier.notify(notification); + } catch (Exception e) { + log.error("사용자 제안 컨텐츠 Discord 알림 처리 중 오류가 발생했습니다. templateId={}", event.templateId(), e); + } + } +} diff --git a/application/src/main/java/com/yapp/ndgl/application/domains/travel/event/publisher/UserSuggestedTemplateEventPublisher.java b/application/src/main/java/com/yapp/ndgl/application/domains/travel/event/publisher/UserSuggestedTemplateEventPublisher.java new file mode 100644 index 0000000..cf2ea92 --- /dev/null +++ b/application/src/main/java/com/yapp/ndgl/application/domains/travel/event/publisher/UserSuggestedTemplateEventPublisher.java @@ -0,0 +1,38 @@ +package com.yapp.ndgl.application.domains.travel.event.publisher; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +import com.yapp.ndgl.application.domains.travel.controller.dto.CreateUserSuggestedTemplateRequest; +import com.yapp.ndgl.application.domains.travel.event.UserSuggestedTemplateCreatedEvent; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class UserSuggestedTemplateEventPublisher { + + private final ApplicationEventPublisher applicationEventPublisher; + + public void publish( + final Long templateId, + final String videoId, + final String uuid, + final CreateUserSuggestedTemplateRequest request + ) { + String category = request.category() != null ? request.category().name() : null; + String region = request.region() != null ? request.region().name() : null; + + UserSuggestedTemplateCreatedEvent event = new UserSuggestedTemplateCreatedEvent( + templateId, + videoId, + request.videoLink(), + uuid, + category, + region, + request.recommendReason() + ); + + applicationEventPublisher.publishEvent(event); + } +} diff --git a/application/src/main/java/com/yapp/ndgl/application/domains/travel/facade/UserSuggestedTemplateFacade.java b/application/src/main/java/com/yapp/ndgl/application/domains/travel/facade/UserSuggestedTemplateFacade.java index 15e9edf..53b1026 100644 --- a/application/src/main/java/com/yapp/ndgl/application/domains/travel/facade/UserSuggestedTemplateFacade.java +++ b/application/src/main/java/com/yapp/ndgl/application/domains/travel/facade/UserSuggestedTemplateFacade.java @@ -2,6 +2,7 @@ import com.yapp.ndgl.application.common.annotation.Facade; import com.yapp.ndgl.application.domains.travel.controller.dto.CreateUserSuggestedTemplateRequest; +import com.yapp.ndgl.application.domains.travel.event.publisher.UserSuggestedTemplateEventPublisher; import com.yapp.ndgl.application.domains.travel.service.UserSuggestedTemplateService; import com.yapp.ndgl.application.utils.YoutubeUrlParser; @@ -12,13 +13,16 @@ public class UserSuggestedTemplateFacade { private final UserSuggestedTemplateService userSuggestedTemplateService; + private final UserSuggestedTemplateEventPublisher userSuggestedTemplateEventPublisher; public void createUserSuggestedTemplate( final String uuid, final CreateUserSuggestedTemplateRequest request ) { String videoId = YoutubeUrlParser.extractVideoId(request.videoLink()); - userSuggestedTemplateService.createUserSuggestedTemplate(uuid, videoId, request); + Long templateId = userSuggestedTemplateService.createUserSuggestedTemplate(uuid, videoId, request); + + userSuggestedTemplateEventPublisher.publish(templateId, videoId, uuid, request); } public void subscribe(final Long templateId, final String uuid) { diff --git a/application/src/main/java/com/yapp/ndgl/application/domains/travel/service/UserSuggestedTemplateService.java b/application/src/main/java/com/yapp/ndgl/application/domains/travel/service/UserSuggestedTemplateService.java index 9b4b3ab..b37870a 100644 --- a/application/src/main/java/com/yapp/ndgl/application/domains/travel/service/UserSuggestedTemplateService.java +++ b/application/src/main/java/com/yapp/ndgl/application/domains/travel/service/UserSuggestedTemplateService.java @@ -25,7 +25,7 @@ public class UserSuggestedTemplateService { @DistributedLock(key = "'suggested_template:' + #videoId") @Transactional - public void createUserSuggestedTemplate( + public Long createUserSuggestedTemplate( final String uuid, final String videoId, final CreateUserSuggestedTemplateRequest request @@ -57,6 +57,7 @@ public void createUserSuggestedTemplate( ); log.info("새로운 여행 영상을 요청하였습니다. userId={}, templateId={}, videoId={}", uuid, template.getId(), videoId); + return template.getId(); } public void subscribe(final Long templateId, final String uuid) { From 62557a755eda3b711694c34c35f4e5537730d409 Mon Sep 17 00:00:00 2001 From: WooJJam <111514410+WooJJam@users.noreply.github.com> Date: Wed, 13 May 2026 02:55:07 +0900 Subject: [PATCH 5/5] =?UTF-8?q?refactor:=20Discord=20=EC=9B=B9=ED=9B=85=20?= =?UTF-8?q?=EC=B1=84=EB=84=90=EB=B3=84=20=EC=84=A4=EC=A0=95=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/UserSuggestedTemplateNotification.java | 6 ++++++ application/src/main/resources/application.yml | 3 ++- .../yapp/ndgl/clients/discord/DiscordChannel.java | 13 +++++++++++++ .../ndgl/clients/discord/DiscordNotification.java | 5 +++++ .../yapp/ndgl/clients/discord/DiscordNotifier.java | 13 ++++++++++++- .../ndgl/clients/discord/DiscordWebhookClient.java | 12 ++---------- .../discord/config/DiscordWebhookProperties.java | 12 +++++++++++- 7 files changed, 51 insertions(+), 13 deletions(-) create mode 100644 clients/src/main/java/com/yapp/ndgl/clients/discord/DiscordChannel.java diff --git a/application/src/main/java/com/yapp/ndgl/application/domains/travel/event/UserSuggestedTemplateNotification.java b/application/src/main/java/com/yapp/ndgl/application/domains/travel/event/UserSuggestedTemplateNotification.java index 76497a6..210f787 100644 --- a/application/src/main/java/com/yapp/ndgl/application/domains/travel/event/UserSuggestedTemplateNotification.java +++ b/application/src/main/java/com/yapp/ndgl/application/domains/travel/event/UserSuggestedTemplateNotification.java @@ -5,6 +5,7 @@ import org.springframework.util.StringUtils; +import com.yapp.ndgl.clients.discord.DiscordChannel; import com.yapp.ndgl.clients.discord.DiscordNotification; import com.yapp.ndgl.clients.discord.DiscordPayloadConstraints; import com.yapp.ndgl.clients.discord.request.DiscordEmbed; @@ -26,6 +27,11 @@ public static UserSuggestedTemplateNotification from(final UserSuggestedTemplate return new UserSuggestedTemplateNotification(event); } + @Override + public DiscordChannel channel() { + return DiscordChannel.USER_SUGGESTED_TEMPLATE; + } + @Override public String createContent() { return CONTENT_HEADER; diff --git a/application/src/main/resources/application.yml b/application/src/main/resources/application.yml index 37c2585..2726f9d 100644 --- a/application/src/main/resources/application.yml +++ b/application/src/main/resources/application.yml @@ -72,7 +72,8 @@ google: api-key: ${YOUTUBE_DATA_API_KEY:default_youtube_api_key} discord: - webhook-url: ${DISCORD_WEBHOOK_URL:} + webhooks: + user-suggested-template: ${DISCORD_WEBHOOK_USER_SUGGESTED_TEMPLATE:} admin: access-token: ${ADMIN_ACCESS_TOKEN:default_admin_access_token} diff --git a/clients/src/main/java/com/yapp/ndgl/clients/discord/DiscordChannel.java b/clients/src/main/java/com/yapp/ndgl/clients/discord/DiscordChannel.java new file mode 100644 index 0000000..4ec9253 --- /dev/null +++ b/clients/src/main/java/com/yapp/ndgl/clients/discord/DiscordChannel.java @@ -0,0 +1,13 @@ +package com.yapp.ndgl.clients.discord; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum DiscordChannel { + + USER_SUGGESTED_TEMPLATE("user-suggested-template"); + + private final String key; +} diff --git a/clients/src/main/java/com/yapp/ndgl/clients/discord/DiscordNotification.java b/clients/src/main/java/com/yapp/ndgl/clients/discord/DiscordNotification.java index 9e390de..ef320e3 100644 --- a/clients/src/main/java/com/yapp/ndgl/clients/discord/DiscordNotification.java +++ b/clients/src/main/java/com/yapp/ndgl/clients/discord/DiscordNotification.java @@ -10,6 +10,11 @@ */ public interface DiscordNotification { + /** + * 알림을 전송할 Discord webhook 채널. + */ + DiscordChannel channel(); + /** * 메시지 상단 본문(content). 없으면 {@code null}. */ diff --git a/clients/src/main/java/com/yapp/ndgl/clients/discord/DiscordNotifier.java b/clients/src/main/java/com/yapp/ndgl/clients/discord/DiscordNotifier.java index b30b0f1..22bf5f5 100644 --- a/clients/src/main/java/com/yapp/ndgl/clients/discord/DiscordNotifier.java +++ b/clients/src/main/java/com/yapp/ndgl/clients/discord/DiscordNotifier.java @@ -1,25 +1,36 @@ package com.yapp.ndgl.clients.discord; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import com.yapp.ndgl.clients.discord.config.DiscordWebhookProperties; import com.yapp.ndgl.clients.discord.request.DiscordWebhookRequest; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; /** * 알림 종류({@link DiscordNotification})를 Discord webhook 페이로드로 감싸 전송한다. */ +@Slf4j @Component @RequiredArgsConstructor public class DiscordNotifier { private final DiscordWebhookClient discordWebhookClient; + private final DiscordWebhookProperties properties; public void notify(final DiscordNotification notification) { + String webhookUrl = properties.webhookUrl(notification.channel()); + if (!StringUtils.hasText(webhookUrl)) { + log.warn("Discord webhook URL이 설정되지 않아 알림을 스킵합니다. channel={}", notification.channel()); + return; + } + DiscordWebhookRequest request = DiscordWebhookRequest.of( notification.createContent(), notification.createEmbeds() ); - discordWebhookClient.send(request); + discordWebhookClient.send(webhookUrl, request); } } diff --git a/clients/src/main/java/com/yapp/ndgl/clients/discord/DiscordWebhookClient.java b/clients/src/main/java/com/yapp/ndgl/clients/discord/DiscordWebhookClient.java index 4e2216f..7b68d24 100644 --- a/clients/src/main/java/com/yapp/ndgl/clients/discord/DiscordWebhookClient.java +++ b/clients/src/main/java/com/yapp/ndgl/clients/discord/DiscordWebhookClient.java @@ -2,10 +2,8 @@ import org.springframework.http.MediaType; import org.springframework.stereotype.Component; -import org.springframework.util.StringUtils; import org.springframework.web.client.RestClient; -import com.yapp.ndgl.clients.discord.config.DiscordWebhookProperties; import com.yapp.ndgl.clients.discord.request.DiscordWebhookRequest; import lombok.RequiredArgsConstructor; @@ -17,17 +15,11 @@ public class DiscordWebhookClient { private final RestClient discordWebhookRestClient; - private final DiscordWebhookProperties properties; - - public void send(final DiscordWebhookRequest request) { - if (!StringUtils.hasText(properties.webhookUrl())) { - log.warn("Discord webhook URL이 설정되지 않아 알림을 스킵합니다."); - return; - } + public void send(final String webhookUrl, final DiscordWebhookRequest request) { try { discordWebhookRestClient.post() - .uri(properties.webhookUrl()) + .uri(webhookUrl) .contentType(MediaType.APPLICATION_JSON) .body(request) .retrieve() diff --git a/clients/src/main/java/com/yapp/ndgl/clients/discord/config/DiscordWebhookProperties.java b/clients/src/main/java/com/yapp/ndgl/clients/discord/config/DiscordWebhookProperties.java index e6ee740..f41850c 100644 --- a/clients/src/main/java/com/yapp/ndgl/clients/discord/config/DiscordWebhookProperties.java +++ b/clients/src/main/java/com/yapp/ndgl/clients/discord/config/DiscordWebhookProperties.java @@ -1,9 +1,19 @@ package com.yapp.ndgl.clients.discord.config; +import java.util.Map; + import org.springframework.boot.context.properties.ConfigurationProperties; +import com.yapp.ndgl.clients.discord.DiscordChannel; + @ConfigurationProperties(prefix = "discord") public record DiscordWebhookProperties( - String webhookUrl + Map webhooks ) { + public String webhookUrl(final DiscordChannel channel) { + if (webhooks == null) { + return null; + } + return webhooks.get(channel.getKey()); + } }