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());
+ }
}