Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
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.DiscordChannel;
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 DiscordChannel channel() {
return DiscordChannel.USER_SUGGESTED_TEMPLATE;
}

@Override
public String createContent() {
return CONTENT_HEADER;
}

@Override
public List<DiscordEmbed> createEmbeds() {

List<Field> 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)
);
Comment thread
WooJJam marked this conversation as resolved.

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) + "…";
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions application/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,5 +71,9 @@ google:
youtube:
api-key: ${YOUTUBE_DATA_API_KEY:default_youtube_api_key}

discord:
webhooks:
user-suggested-template: ${DISCORD_WEBHOOK_USER_SUGGESTED_TEMPLATE:}

admin:
access-token: ${ADMIN_ACCESS_TOKEN:default_admin_access_token}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.yapp.ndgl.clients.discord;

import java.util.List;

import com.yapp.ndgl.clients.discord.request.DiscordEmbed;

/**
* Discord 채널에 발송할 알림의 표현 형식.
* <p>알림 종류별로 구현체를 추가해 확장한다.
*/
public interface DiscordNotification {

/**
* 알림을 전송할 Discord webhook 채널.
*/
DiscordChannel channel();

/**
* 메시지 상단 본문(content). 없으면 {@code null}.
*/
String createContent();

/**
* Discord embed 목록. 없으면 {@code null} 또는 빈 리스트.
*/
List<DiscordEmbed> createEmbeds();
}
Original file line number Diff line number Diff line change
@@ -0,0 +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(webhookUrl, request);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.yapp.ndgl.clients.discord;

import lombok.AccessLevel;
import lombok.NoArgsConstructor;

/**
* Discord 페이로드의 길이/개수 제약 상수.
* <p>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";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.yapp.ndgl.clients.discord;

import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;

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;

public void send(final String webhookUrl, final DiscordWebhookRequest request) {
try {
discordWebhookRestClient.post()
.uri(webhookUrl)
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.retrieve()
.toBodilessEntity();
log.debug("Discord 알림 전송 성공");
} catch (Exception e) {
log.error("Discord 알림 전송에 실패했습니다.", e);
}
}
}
Loading