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/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..210f787 --- /dev/null +++ b/application/src/main/java/com/yapp/ndgl/application/domains/travel/event/UserSuggestedTemplateNotification.java @@ -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 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) { diff --git a/application/src/main/resources/application.yml b/application/src/main/resources/application.yml index c39e314..2726f9d 100644 --- a/application/src/main/resources/application.yml +++ b/application/src/main/resources/application.yml @@ -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} 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 new file mode 100644 index 0000000..ef320e3 --- /dev/null +++ b/clients/src/main/java/com/yapp/ndgl/clients/discord/DiscordNotification.java @@ -0,0 +1,27 @@ +package com.yapp.ndgl.clients.discord; + +import java.util.List; + +import com.yapp.ndgl.clients.discord.request.DiscordEmbed; + +/** + * Discord 채널에 발송할 알림의 표현 형식. + *

알림 종류별로 구현체를 추가해 확장한다. + */ +public interface DiscordNotification { + + /** + * 알림을 전송할 Discord webhook 채널. + */ + DiscordChannel channel(); + + /** + * 메시지 상단 본문(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..22bf5f5 --- /dev/null +++ b/clients/src/main/java/com/yapp/ndgl/clients/discord/DiscordNotifier.java @@ -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); + } +} 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..7b68d24 --- /dev/null +++ b/clients/src/main/java/com/yapp/ndgl/clients/discord/DiscordWebhookClient.java @@ -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); + } + } +} 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..f41850c --- /dev/null +++ b/clients/src/main/java/com/yapp/ndgl/clients/discord/config/DiscordWebhookProperties.java @@ -0,0 +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( + Map webhooks +) { + public String webhookUrl(final DiscordChannel channel) { + if (webhooks == null) { + return null; + } + return webhooks.get(channel.getKey()); + } +} 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) + "…"; + } +} 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); }