From 77289810d8f71b65733fafdcf7776120b1ceae51 Mon Sep 17 00:00:00 2001 From: swthewhite Date: Sun, 25 Jan 2026 17:04:54 +0900 Subject: [PATCH 1/5] =?UTF-8?q?UPLUS-139=20refactor:=20Kafka=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=EB=A5=BC=20mock-server=EB=A1=9C=20HTTP=20?= =?UTF-8?q?=EB=B0=9C=EC=86=A1=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UsageNotificationEvent 구조 변경 (templateGroupId, subscriptionInfo, variables) - EmailSendRequest, SmsSendRequest, SendResponse DTO 추가 - RestClient 설정 및 mock-server 엔드포인트 연동 - EmailSender/SmsSender HTTP POST 호출로 변경 - MessageSendService 추가 (EMAIL 우선, SMS 폴백) - 불필요한 파일 삭제 (NotificationService, Customer/Subscription 관련) --- .../global/config/RestClientConfig.java | 34 ++ .../consumer/NotificationConsumer.java | 40 +-- .../consumer/SendNotificationLogger.java | 37 -- .../consumer/UsageNotificationEvent.java | 18 +- .../UsageNotificationMessageFormatter.java | 37 -- .../notification/dto/EmailSendRequest.java | 16 + .../dto/NotificationRequestEvent.java | 6 - .../notification/dto/RenderedMessage.java | 6 - .../notification/dto/SendResponse.java | 10 + .../notification/dto/SmsSendRequest.java | 15 + .../notification/infra/entity/Customer.java | 53 --- .../infra/entity/Subscription.java | 57 ---- .../infra/entity/enums/Grade.java | 9 - .../entity/enums/SubscriptionStatus.java | 6 - .../repository/CustomerJpaRepository.java | 7 - .../infra/repository/CustomerRepository.java | 10 - .../repository/CustomerRepositoryImpl.java | 21 -- .../repository/SubscriptionJpaRepository.java | 15 - .../repository/SubscriptionRepository.java | 10 - .../SubscriptionRepositoryImpl.java | 21 -- .../TemplateVersionJpaRepository.java | 12 + .../repository/TemplateVersionRepository.java | 2 + .../TemplateVersionRepositoryImpl.java | 6 + .../notification/sender/EmailSender.java | 58 +++- .../notification/sender/SmsSender.java | 53 ++- .../service/MessageSendService.java | 261 ++++++++++++++ .../service/NotificationService.java | 322 ------------------ .../notification/service/TemplateService.java | 15 + src/main/resources/application.yml | 8 + 29 files changed, 482 insertions(+), 683 deletions(-) create mode 100644 src/main/java/com/project/global/config/RestClientConfig.java delete mode 100644 src/main/java/com/project/notification/consumer/SendNotificationLogger.java delete mode 100644 src/main/java/com/project/notification/consumer/UsageNotificationMessageFormatter.java create mode 100644 src/main/java/com/project/notification/dto/EmailSendRequest.java delete mode 100644 src/main/java/com/project/notification/dto/NotificationRequestEvent.java delete mode 100644 src/main/java/com/project/notification/dto/RenderedMessage.java create mode 100644 src/main/java/com/project/notification/dto/SendResponse.java create mode 100644 src/main/java/com/project/notification/dto/SmsSendRequest.java delete mode 100644 src/main/java/com/project/notification/infra/entity/Customer.java delete mode 100644 src/main/java/com/project/notification/infra/entity/Subscription.java delete mode 100644 src/main/java/com/project/notification/infra/entity/enums/Grade.java delete mode 100644 src/main/java/com/project/notification/infra/entity/enums/SubscriptionStatus.java delete mode 100644 src/main/java/com/project/notification/infra/repository/CustomerJpaRepository.java delete mode 100644 src/main/java/com/project/notification/infra/repository/CustomerRepository.java delete mode 100644 src/main/java/com/project/notification/infra/repository/CustomerRepositoryImpl.java delete mode 100644 src/main/java/com/project/notification/infra/repository/SubscriptionJpaRepository.java delete mode 100644 src/main/java/com/project/notification/infra/repository/SubscriptionRepository.java delete mode 100644 src/main/java/com/project/notification/infra/repository/SubscriptionRepositoryImpl.java create mode 100644 src/main/java/com/project/notification/service/MessageSendService.java delete mode 100644 src/main/java/com/project/notification/service/NotificationService.java diff --git a/src/main/java/com/project/global/config/RestClientConfig.java b/src/main/java/com/project/global/config/RestClientConfig.java new file mode 100644 index 0000000..12e1716 --- /dev/null +++ b/src/main/java/com/project/global/config/RestClientConfig.java @@ -0,0 +1,34 @@ +package com.project.global.config; + +import java.time.Duration; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestClient; + +@Configuration +public class RestClientConfig { + + @Value("${mock-server.base-url}") + private String baseUrl; + + @Value("${mock-server.connect-timeout}") + private int connectTimeout; + + @Value("${mock-server.read-timeout}") + private int readTimeout; + + @Bean + public RestClient mockServerRestClient() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(Duration.ofMillis(connectTimeout)); + factory.setReadTimeout(Duration.ofMillis(readTimeout)); + + return RestClient.builder() + .baseUrl(baseUrl) + .requestFactory(factory) + .build(); + } +} diff --git a/src/main/java/com/project/notification/consumer/NotificationConsumer.java b/src/main/java/com/project/notification/consumer/NotificationConsumer.java index 549af1b..aaefead 100644 --- a/src/main/java/com/project/notification/consumer/NotificationConsumer.java +++ b/src/main/java/com/project/notification/consumer/NotificationConsumer.java @@ -1,8 +1,5 @@ package com.project.notification.consumer; -import java.time.LocalDateTime; -import java.util.Map; - import org.apache.kafka.clients.consumer.ConsumerRecord; import org.springframework.context.annotation.Profile; import org.springframework.kafka.annotation.KafkaListener; @@ -10,7 +7,7 @@ import org.springframework.stereotype.Component; import com.fasterxml.jackson.databind.ObjectMapper; -import com.project.notification.dto.NotificationRequestEvent; +import com.project.notification.service.MessageSendService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -22,7 +19,7 @@ public class NotificationConsumer { private final NotificationSendDedupService dedupService; private final ObjectMapper objectMapper; - private final UsageNotificationMessageFormatter formatter; + private final MessageSendService messageSendService; @KafkaListener( topics = "notification-usage", @@ -43,41 +40,12 @@ public void consume(ConsumerRecord record, Acknowledgment ack) { return; } - String format = formatter.format(event, LocalDateTime.now()); - - SendNotificationLogger.write(format); + messageSendService.processEvent(event); ack.acknowledge(); } catch (Exception e) { log.error("[CONSUME FAIL]", e); - ack.acknowledge(); // 지금 구조상 스킵이 맞음 - } - } - - @SuppressWarnings("unchecked") - private NotificationRequestEvent parseEvent(Map rawPayload) { - try { - String traceId = (String) rawPayload.get("traceId"); - Object subIdObj = rawPayload.get("subscriptionId"); - Long subscriptionId = - subIdObj instanceof Number ? ((Number) subIdObj).longValue() : null; - String templateCode = (String) rawPayload.get("templateCode"); - Map variables = (Map) rawPayload.get("variables"); - - if (traceId == null || subscriptionId == null || templateCode == null) { - log.error( - "Required fields missing. traceId: {}, subscriptionId: {}, templateCode:" - + " {}", - traceId, - subscriptionId, - templateCode); - return null; - } - - return new NotificationRequestEvent(traceId, subscriptionId, templateCode, variables); - } catch (Exception e) { - log.error("Failed to parse event from payload", e); - return null; + ack.acknowledge(); } } } diff --git a/src/main/java/com/project/notification/consumer/SendNotificationLogger.java b/src/main/java/com/project/notification/consumer/SendNotificationLogger.java deleted file mode 100644 index 8095bba..0000000 --- a/src/main/java/com/project/notification/consumer/SendNotificationLogger.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.project.notification.consumer; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardOpenOption; -import java.time.LocalDateTime; - -import lombok.extern.slf4j.Slf4j; - -@Slf4j -public class SendNotificationLogger { - - private static final Path LOG_PATH = Path.of("logs/notification-preview.log"); - - public static void write(String content) { - try { - Files.createDirectories(LOG_PATH.getParent()); - - Files.writeString( - LOG_PATH, - """ - =============================== - %s - %s - =============================== - - """ - .formatted(LocalDateTime.now(), content), - StandardOpenOption.CREATE, - StandardOpenOption.WRITE, - StandardOpenOption.APPEND); - } catch (IOException e) { - log.error("Failed to write notification preview file", e); - } - } -} diff --git a/src/main/java/com/project/notification/consumer/UsageNotificationEvent.java b/src/main/java/com/project/notification/consumer/UsageNotificationEvent.java index 485f2eb..f6e2e26 100644 --- a/src/main/java/com/project/notification/consumer/UsageNotificationEvent.java +++ b/src/main/java/com/project/notification/consumer/UsageNotificationEvent.java @@ -1,14 +1,16 @@ package com.project.notification.consumer; +import java.util.Map; import java.util.UUID; public record UsageNotificationEvent( UUID eventId, - Long id, - Long subId, - String period, - String unit, - int threshold, - int percent, - long totalUsedMb, - long allotmentMb) {} + Long templateGroupId, + SubscriptionInfo subscriptionInfo, + Map variables) { + + public record SubscriptionInfo( + Long subId, + String phoneNumber, + String email) {} +} diff --git a/src/main/java/com/project/notification/consumer/UsageNotificationMessageFormatter.java b/src/main/java/com/project/notification/consumer/UsageNotificationMessageFormatter.java deleted file mode 100644 index ccbbb00..0000000 --- a/src/main/java/com/project/notification/consumer/UsageNotificationMessageFormatter.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.project.notification.consumer; - -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; - -import org.springframework.stereotype.Component; - -@Component -public class UsageNotificationMessageFormatter { - - public String format(UsageNotificationEvent event, LocalDateTime now) { - - String providedGb = formatGb(event.allotmentMb()); - String usedGb = formatGb(event.totalUsedMb()); - String time = now.format(DateTimeFormatter.ofPattern("MM/dd HH:mm:ss")); - - return """ - [LG U+] - [Web발신] - [LG U+] 이번 달 데이터 사용량 안내 - - 고객님, 「유쓰 5G 데이터 플러스」 - 요금제의 기본 데이터 사용량을 안내해 드립니다. - - ▶ 데이터 사용량 안내 - - 제공량: %sGB - - 사용량: %d%% %sGB - ※ %s 기준 - - """ - .formatted(providedGb, event.percent(), usedGb, time); - } - - private String formatGb(long mb) { - return String.format("%.2f", mb / 1024.0); - } -} diff --git a/src/main/java/com/project/notification/dto/EmailSendRequest.java b/src/main/java/com/project/notification/dto/EmailSendRequest.java new file mode 100644 index 0000000..8073c72 --- /dev/null +++ b/src/main/java/com/project/notification/dto/EmailSendRequest.java @@ -0,0 +1,16 @@ +package com.project.notification.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record EmailSendRequest( + @JsonProperty("sub_id") Long subId, + String email, + String phone, + String type, + String subject, + String body) { + + public static EmailSendRequest of(Long subId, String email, String phone, String subject, String body) { + return new EmailSendRequest(subId, email, phone, "EMAIL", subject, body); + } +} diff --git a/src/main/java/com/project/notification/dto/NotificationRequestEvent.java b/src/main/java/com/project/notification/dto/NotificationRequestEvent.java deleted file mode 100644 index dbf5864..0000000 --- a/src/main/java/com/project/notification/dto/NotificationRequestEvent.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.project.notification.dto; - -import java.util.Map; - -public record NotificationRequestEvent( - String traceId, Long subscriptionId, String templateCode, Map variables) {} diff --git a/src/main/java/com/project/notification/dto/RenderedMessage.java b/src/main/java/com/project/notification/dto/RenderedMessage.java deleted file mode 100644 index 0dc6a83..0000000 --- a/src/main/java/com/project/notification/dto/RenderedMessage.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.project.notification.dto; - -import com.project.notification.infra.entity.enums.Channel; - -public record RenderedMessage( - Channel channel, Long templateVersionId, String subject, String body, String recipient) {} diff --git a/src/main/java/com/project/notification/dto/SendResponse.java b/src/main/java/com/project/notification/dto/SendResponse.java new file mode 100644 index 0000000..1238c34 --- /dev/null +++ b/src/main/java/com/project/notification/dto/SendResponse.java @@ -0,0 +1,10 @@ +package com.project.notification.dto; + +public record SendResponse( + String messageId, + String status) { + + public boolean isSuccess() { + return "OK".equalsIgnoreCase(status); + } +} diff --git a/src/main/java/com/project/notification/dto/SmsSendRequest.java b/src/main/java/com/project/notification/dto/SmsSendRequest.java new file mode 100644 index 0000000..c5d6d9c --- /dev/null +++ b/src/main/java/com/project/notification/dto/SmsSendRequest.java @@ -0,0 +1,15 @@ +package com.project.notification.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record SmsSendRequest( + @JsonProperty("sub_id") Long subId, + String email, + String phone, + String type, + String body) { + + public static SmsSendRequest of(Long subId, String email, String phone, String body) { + return new SmsSendRequest(subId, email, phone, "SMS", body); + } +} diff --git a/src/main/java/com/project/notification/infra/entity/Customer.java b/src/main/java/com/project/notification/infra/entity/Customer.java deleted file mode 100644 index 7009137..0000000 --- a/src/main/java/com/project/notification/infra/entity/Customer.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.project.notification.infra.entity; - -import java.time.LocalDateTime; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Table; - -import com.project.notification.infra.entity.enums.Grade; - -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; - -/** - * Read-Only Customer entity for notification service. This is a copy from api-core, used only for - * reading customer information. - */ -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@Table(name = "customer") -public class Customer { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "customer_id") - private Long customerId; - - @Column(name = "name", nullable = false) - private String name; - - @Column(name = "contact_enc", nullable = false) - private String contactEnc; - - @Column(name = "email_enc", nullable = false) - private String emailEnc; - - @Enumerated(EnumType.STRING) - @Column(name = "grade", nullable = false, length = 20) - private Grade grade; - - @Column(name = "created_at", nullable = false) - private LocalDateTime createdAt; - - @Column(name = "is_deleted", nullable = false) - private Boolean isDeleted; -} diff --git a/src/main/java/com/project/notification/infra/entity/Subscription.java b/src/main/java/com/project/notification/infra/entity/Subscription.java deleted file mode 100644 index c0f965e..0000000 --- a/src/main/java/com/project/notification/infra/entity/Subscription.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.project.notification.infra.entity; - -import java.time.LocalDateTime; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; - -import com.project.notification.infra.entity.enums.SubscriptionStatus; - -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; - -/** - * Read-Only Subscription entity for notification service. This is a copy from api-core, used only - * for reading subscription information. - */ -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@Table(name = "subscription") -public class Subscription { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "sub_id") - private Long subId; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "customer_id", nullable = false) - private Customer customer; - - @Column(name = "phone_number", nullable = false) - private String phoneNumber; - - @Column(name = "start_date", nullable = false) - private LocalDateTime startDate; - - @Column(name = "end_date") - private LocalDateTime endDate; - - @Enumerated(EnumType.STRING) - @Column(name = "status", nullable = false, length = 10) - private SubscriptionStatus status; - - @Column(name = "send_day", nullable = false) - private Integer sendDay; -} diff --git a/src/main/java/com/project/notification/infra/entity/enums/Grade.java b/src/main/java/com/project/notification/infra/entity/enums/Grade.java deleted file mode 100644 index 2a9d9d2..0000000 --- a/src/main/java/com/project/notification/infra/entity/enums/Grade.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.project.notification.infra.entity.enums; - -public enum Grade { - BRONZE, - SILVER, - GOLD, - PLATINUM, - VIP -} diff --git a/src/main/java/com/project/notification/infra/entity/enums/SubscriptionStatus.java b/src/main/java/com/project/notification/infra/entity/enums/SubscriptionStatus.java deleted file mode 100644 index bd25456..0000000 --- a/src/main/java/com/project/notification/infra/entity/enums/SubscriptionStatus.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.project.notification.infra.entity.enums; - -public enum SubscriptionStatus { - ACTIVE, - TERMINATED -} diff --git a/src/main/java/com/project/notification/infra/repository/CustomerJpaRepository.java b/src/main/java/com/project/notification/infra/repository/CustomerJpaRepository.java deleted file mode 100644 index e0c04fd..0000000 --- a/src/main/java/com/project/notification/infra/repository/CustomerJpaRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.project.notification.infra.repository; - -import org.springframework.data.jpa.repository.JpaRepository; - -import com.project.notification.infra.entity.Customer; - -public interface CustomerJpaRepository extends JpaRepository {} diff --git a/src/main/java/com/project/notification/infra/repository/CustomerRepository.java b/src/main/java/com/project/notification/infra/repository/CustomerRepository.java deleted file mode 100644 index 6b4207c..0000000 --- a/src/main/java/com/project/notification/infra/repository/CustomerRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.project.notification.infra.repository; - -import java.util.Optional; - -import com.project.notification.infra.entity.Customer; - -public interface CustomerRepository { - - Optional findById(Long customerId); -} diff --git a/src/main/java/com/project/notification/infra/repository/CustomerRepositoryImpl.java b/src/main/java/com/project/notification/infra/repository/CustomerRepositoryImpl.java deleted file mode 100644 index f61960e..0000000 --- a/src/main/java/com/project/notification/infra/repository/CustomerRepositoryImpl.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.project.notification.infra.repository; - -import java.util.Optional; - -import org.springframework.stereotype.Repository; - -import com.project.notification.infra.entity.Customer; - -import lombok.RequiredArgsConstructor; - -@Repository -@RequiredArgsConstructor -public class CustomerRepositoryImpl implements CustomerRepository { - - private final CustomerJpaRepository customerJpaRepository; - - @Override - public Optional findById(Long customerId) { - return customerJpaRepository.findById(customerId); - } -} diff --git a/src/main/java/com/project/notification/infra/repository/SubscriptionJpaRepository.java b/src/main/java/com/project/notification/infra/repository/SubscriptionJpaRepository.java deleted file mode 100644 index eea0464..0000000 --- a/src/main/java/com/project/notification/infra/repository/SubscriptionJpaRepository.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.project.notification.infra.repository; - -import java.util.Optional; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -import com.project.notification.infra.entity.Subscription; - -public interface SubscriptionJpaRepository extends JpaRepository { - - @Query("SELECT s FROM Subscription s JOIN FETCH s.customer WHERE s.subId = :subId") - Optional findByIdWithCustomer(@Param("subId") Long subId); -} diff --git a/src/main/java/com/project/notification/infra/repository/SubscriptionRepository.java b/src/main/java/com/project/notification/infra/repository/SubscriptionRepository.java deleted file mode 100644 index 97711e9..0000000 --- a/src/main/java/com/project/notification/infra/repository/SubscriptionRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.project.notification.infra.repository; - -import java.util.Optional; - -import com.project.notification.infra.entity.Subscription; - -public interface SubscriptionRepository { - - Optional findByIdWithCustomer(Long subId); -} diff --git a/src/main/java/com/project/notification/infra/repository/SubscriptionRepositoryImpl.java b/src/main/java/com/project/notification/infra/repository/SubscriptionRepositoryImpl.java deleted file mode 100644 index 37c6c21..0000000 --- a/src/main/java/com/project/notification/infra/repository/SubscriptionRepositoryImpl.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.project.notification.infra.repository; - -import java.util.Optional; - -import org.springframework.stereotype.Repository; - -import com.project.notification.infra.entity.Subscription; - -import lombok.RequiredArgsConstructor; - -@Repository -@RequiredArgsConstructor -public class SubscriptionRepositoryImpl implements SubscriptionRepository { - - private final SubscriptionJpaRepository subscriptionJpaRepository; - - @Override - public Optional findByIdWithCustomer(Long subId) { - return subscriptionJpaRepository.findByIdWithCustomer(subId); - } -} diff --git a/src/main/java/com/project/notification/infra/repository/TemplateVersionJpaRepository.java b/src/main/java/com/project/notification/infra/repository/TemplateVersionJpaRepository.java index 5ff89f1..1279575 100644 --- a/src/main/java/com/project/notification/infra/repository/TemplateVersionJpaRepository.java +++ b/src/main/java/com/project/notification/infra/repository/TemplateVersionJpaRepository.java @@ -23,4 +23,16 @@ Optional findLatestByGroupCodeAndChannelAndStatus( @Param("groupCode") String groupCode, @Param("channel") Channel channel, @Param("status") TemplateStatus status); + + @Query( + "SELECT tv FROM TemplateVersion tv " + + "WHERE tv.templateGroup.groupId = :groupId " + + "AND tv.channel = :channel " + + "AND tv.status = :status " + + "ORDER BY tv.version DESC " + + "LIMIT 1") + Optional findLatestByGroupIdAndChannelAndStatus( + @Param("groupId") Long groupId, + @Param("channel") Channel channel, + @Param("status") TemplateStatus status); } diff --git a/src/main/java/com/project/notification/infra/repository/TemplateVersionRepository.java b/src/main/java/com/project/notification/infra/repository/TemplateVersionRepository.java index 7d18036..377c63e 100644 --- a/src/main/java/com/project/notification/infra/repository/TemplateVersionRepository.java +++ b/src/main/java/com/project/notification/infra/repository/TemplateVersionRepository.java @@ -9,4 +9,6 @@ public interface TemplateVersionRepository { Optional findLatestActiveByGroupCodeAndChannel( String groupCode, Channel channel); + + Optional findLatestActiveByGroupIdAndChannel(Long groupId, Channel channel); } diff --git a/src/main/java/com/project/notification/infra/repository/TemplateVersionRepositoryImpl.java b/src/main/java/com/project/notification/infra/repository/TemplateVersionRepositoryImpl.java index 9a785f0..6cabd73 100644 --- a/src/main/java/com/project/notification/infra/repository/TemplateVersionRepositoryImpl.java +++ b/src/main/java/com/project/notification/infra/repository/TemplateVersionRepositoryImpl.java @@ -22,4 +22,10 @@ public Optional findLatestActiveByGroupCodeAndChannel( return templateVersionJpaRepository.findLatestByGroupCodeAndChannelAndStatus( groupCode, channel, TemplateStatus.ACTIVE); } + + @Override + public Optional findLatestActiveByGroupIdAndChannel(Long groupId, Channel channel) { + return templateVersionJpaRepository.findLatestByGroupIdAndChannelAndStatus( + groupId, channel, TemplateStatus.ACTIVE); + } } diff --git a/src/main/java/com/project/notification/sender/EmailSender.java b/src/main/java/com/project/notification/sender/EmailSender.java index 00421e7..179541f 100644 --- a/src/main/java/com/project/notification/sender/EmailSender.java +++ b/src/main/java/com/project/notification/sender/EmailSender.java @@ -1,30 +1,64 @@ package com.project.notification.sender; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; -import com.project.notification.dto.RenderedMessage; +import com.project.notification.dto.EmailSendRequest; +import com.project.notification.dto.SendResponse; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @Slf4j @Component +@RequiredArgsConstructor public class EmailSender { - public void send(RenderedMessage message) { + private final RestClient mockServerRestClient; + + @Value("${mock-server.endpoints.email}") + private String emailEndpoint; + + public SendResponse send(EmailSendRequest request) { log.info( - "[MOCK EMAIL] To: {}, Subject: {}, Body: {}", - message.recipient(), - message.subject(), - truncateBody(message.body())); + "[EMAIL] Sending to subId: {}, email: {}, subject: {}", + request.subId(), + maskEmail(request.email()), + request.subject()); + + try { + SendResponse response = mockServerRestClient + .post() + .uri(emailEndpoint) + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .retrieve() + .body(SendResponse.class); + + if (response != null && response.isSuccess()) { + log.info("[EMAIL] Successfully sent, messageId: {}", response.messageId()); + } else { + log.warn("[EMAIL] Send failed, response: {}", response); + } + + return response; + } catch (RestClientException e) { + log.error("[EMAIL] Failed to send email: {}", e.getMessage(), e); + return new SendResponse(null, "FAIL"); + } } - private String truncateBody(String body) { - if (body == null) { - return null; + private String maskEmail(String email) { + if (email == null || email.length() < 5) { + return "***"; } - if (body.length() > 100) { - return body.substring(0, 100) + "..."; + int atIndex = email.indexOf('@'); + if (atIndex <= 1) { + return "***"; } - return body; + return email.substring(0, 2) + "***" + email.substring(atIndex); } } diff --git a/src/main/java/com/project/notification/sender/SmsSender.java b/src/main/java/com/project/notification/sender/SmsSender.java index 98034e9..88e1e6e 100644 --- a/src/main/java/com/project/notification/sender/SmsSender.java +++ b/src/main/java/com/project/notification/sender/SmsSender.java @@ -1,26 +1,59 @@ package com.project.notification.sender; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; -import com.project.notification.dto.RenderedMessage; +import com.project.notification.dto.SendResponse; +import com.project.notification.dto.SmsSendRequest; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @Slf4j @Component +@RequiredArgsConstructor public class SmsSender { - public void send(RenderedMessage message) { - log.info("[MOCK SMS] To: {}, Body: {}", message.recipient(), truncateBody(message.body())); - } + private final RestClient mockServerRestClient; + + @Value("${mock-server.endpoints.sms}") + private String smsEndpoint; + + public SendResponse send(SmsSendRequest request) { + log.info( + "[SMS] Sending to subId: {}, phone: {}", + request.subId(), + maskPhone(request.phone())); + + try { + SendResponse response = mockServerRestClient + .post() + .uri(smsEndpoint) + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .retrieve() + .body(SendResponse.class); - private String truncateBody(String body) { - if (body == null) { - return null; + if (response != null && response.isSuccess()) { + log.info("[SMS] Successfully sent, messageId: {}", response.messageId()); + } else { + log.warn("[SMS] Send failed, response: {}", response); + } + + return response; + } catch (RestClientException e) { + log.error("[SMS] Failed to send SMS: {}", e.getMessage(), e); + return new SendResponse(null, "FAIL"); } - if (body.length() > 100) { - return body.substring(0, 100) + "..."; + } + + private String maskPhone(String phone) { + if (phone == null || phone.length() < 4) { + return "***"; } - return body; + return phone.substring(0, phone.length() - 4) + "****"; } } diff --git a/src/main/java/com/project/notification/service/MessageSendService.java b/src/main/java/com/project/notification/service/MessageSendService.java new file mode 100644 index 0000000..c5631eb --- /dev/null +++ b/src/main/java/com/project/notification/service/MessageSendService.java @@ -0,0 +1,261 @@ +package com.project.notification.service; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.project.notification.consumer.UsageNotificationEvent; +import com.project.notification.dto.EmailSendRequest; +import com.project.notification.dto.SendResponse; +import com.project.notification.dto.SmsSendRequest; +import com.project.notification.infra.entity.MessageLog; +import com.project.notification.infra.entity.TemplateVersion; +import com.project.notification.infra.entity.enums.Channel; +import com.project.notification.infra.entity.enums.MessageStatus; +import com.project.notification.infra.repository.MessageLogRepository; +import com.project.notification.sender.EmailSender; +import com.project.notification.sender.SmsSender; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.Timer; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class MessageSendService { + + private final MessageLogRepository messageLogRepository; + private final TemplateService templateService; + private final TemplateEngine templateEngine; + private final EmailSender emailSender; + private final SmsSender smsSender; + + private final Counter emailSuccessCounter; + private final Counter emailFailCounter; + private final Counter smsSuccessCounter; + private final Counter smsFailCounter; + private final Counter smsFallbackCounter; + private final Timer emailProcessingTimer; + private final Timer smsProcessingTimer; + + @Transactional + public void processEvent(UsageNotificationEvent event) { + long startTime = System.currentTimeMillis(); + + log.info( + "Processing notification event. eventId: {}, templateGroupId: {}, subId: {}", + event.eventId(), + event.templateGroupId(), + event.subscriptionInfo().subId()); + + boolean emailSuccess = tryEmailSend(event, startTime); + + if (!emailSuccess) { + trySmsFallback(event); + } + } + + private boolean tryEmailSend(UsageNotificationEvent event, long startTime) { + long emailStartTime = System.currentTimeMillis(); + UsageNotificationEvent.SubscriptionInfo subInfo = event.subscriptionInfo(); + + String email = subInfo.email(); + if (email == null || email.isBlank()) { + log.warn("Email is empty for subId: {}", subInfo.subId()); + saveMessageLog( + event, + null, + Channel.EMAIL, + MessageStatus.FAIL, + "Email address is empty", + System.currentTimeMillis() - startTime); + emailFailCounter.increment(); + return false; + } + + Optional templateOpt = + templateService.findActiveTemplateByGroupId(event.templateGroupId(), Channel.EMAIL); + + if (templateOpt.isEmpty()) { + log.warn("Email template not found for groupId: {}", event.templateGroupId()); + saveMessageLog( + event, + null, + Channel.EMAIL, + MessageStatus.FAIL, + "Email template not found", + System.currentTimeMillis() - startTime); + emailFailCounter.increment(); + return false; + } + + TemplateVersion template = templateOpt.get(); + String subject = templateEngine.render(template.getSubject(), event.variables()); + String body = templateEngine.render(template.getBody(), event.variables()); + + EmailSendRequest request = EmailSendRequest.of( + subInfo.subId(), + email, + subInfo.phoneNumber(), + subject, + body); + + try { + SendResponse response = emailSender.send(request); + + long processingTime = System.currentTimeMillis() - startTime; + emailProcessingTimer.record(processingTime, TimeUnit.MILLISECONDS); + + if (response != null && response.isSuccess()) { + saveMessageLog( + event, + template.getVersionId(), + Channel.EMAIL, + MessageStatus.SUCCESS, + null, + processingTime); + emailSuccessCounter.increment(); + log.info("Email sent successfully. eventId: {}", event.eventId()); + return true; + } else { + String errorMsg = response != null ? response.status() : "No response"; + saveMessageLog( + event, + template.getVersionId(), + Channel.EMAIL, + MessageStatus.FAIL, + errorMsg, + processingTime); + emailFailCounter.increment(); + return false; + } + } catch (Exception e) { + log.error("Email send failed. eventId: {}", event.eventId(), e); + saveMessageLog( + event, + template.getVersionId(), + Channel.EMAIL, + MessageStatus.FAIL, + e.getMessage(), + System.currentTimeMillis() - emailStartTime); + emailFailCounter.increment(); + return false; + } + } + + private void trySmsFallback(UsageNotificationEvent event) { + long smsStartTime = System.currentTimeMillis(); + UsageNotificationEvent.SubscriptionInfo subInfo = event.subscriptionInfo(); + + String phoneNumber = subInfo.phoneNumber(); + if (phoneNumber == null || phoneNumber.isBlank()) { + log.warn("Phone number is empty for subId: {}", subInfo.subId()); + saveMessageLog( + event, + null, + Channel.SMS, + MessageStatus.FAIL, + "Phone number is empty", + System.currentTimeMillis() - smsStartTime); + smsFailCounter.increment(); + return; + } + + Optional templateOpt = + templateService.findActiveTemplateByGroupId(event.templateGroupId(), Channel.SMS); + + if (templateOpt.isEmpty()) { + log.warn( + "SMS template not found for groupId: {}, fallback not possible", + event.templateGroupId()); + return; + } + + TemplateVersion template = templateOpt.get(); + String body = templateEngine.render(template.getBody(), event.variables()); + + SmsSendRequest request = SmsSendRequest.of( + subInfo.subId(), + subInfo.email(), + phoneNumber, + body); + + try { + SendResponse response = smsSender.send(request); + + long processingTime = System.currentTimeMillis() - smsStartTime; + smsProcessingTimer.record(processingTime, TimeUnit.MILLISECONDS); + + if (response != null && response.isSuccess()) { + saveMessageLog( + event, + template.getVersionId(), + Channel.SMS, + MessageStatus.SUCCESS_FALLBACK, + null, + processingTime); + smsFallbackCounter.increment(); + log.info("SMS fallback sent successfully. eventId: {}", event.eventId()); + } else { + String errorMsg = response != null ? response.status() : "No response"; + saveMessageLog( + event, + template.getVersionId(), + Channel.SMS, + MessageStatus.FAIL, + errorMsg, + processingTime); + smsFailCounter.increment(); + } + } catch (Exception e) { + log.error("SMS fallback failed. eventId: {}", event.eventId(), e); + saveMessageLog( + event, + template.getVersionId(), + Channel.SMS, + MessageStatus.FAIL, + e.getMessage(), + System.currentTimeMillis() - smsStartTime); + smsFailCounter.increment(); + } + } + + private void saveMessageLog( + UsageNotificationEvent event, + Long templateVersionId, + Channel channel, + MessageStatus status, + String errorMessage, + Long processingTimeMs) { + + UsageNotificationEvent.SubscriptionInfo subInfo = event.subscriptionInfo(); + String recipientEnc = channel == Channel.EMAIL ? subInfo.email() : subInfo.phoneNumber(); + + Map payload = new HashMap<>(); + payload.put("eventId", event.eventId().toString()); + payload.put("templateGroupId", event.templateGroupId()); + payload.put("variables", event.variables()); + + MessageLog messageLog = + MessageLog.builder() + .traceId(event.eventId().toString()) + .subId(subInfo.subId()) + .recipientEnc(recipientEnc) + .templateVersionId(templateVersionId) + .channel(channel) + .status(status) + .errorMessage(errorMessage) + .requestPayload(payload) + .processingTimeMs(processingTimeMs) + .build(); + + messageLogRepository.save(messageLog); + } +} diff --git a/src/main/java/com/project/notification/service/NotificationService.java b/src/main/java/com/project/notification/service/NotificationService.java deleted file mode 100644 index b25f5f6..0000000 --- a/src/main/java/com/project/notification/service/NotificationService.java +++ /dev/null @@ -1,322 +0,0 @@ -package com.project.notification.service; - -import java.util.Map; -import java.util.Optional; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.project.global.util.AesUtil; -import com.project.notification.dto.NotificationRequestEvent; -import com.project.notification.dto.RenderedMessage; -import com.project.notification.infra.entity.Customer; -import com.project.notification.infra.entity.MessageLog; -import com.project.notification.infra.entity.Subscription; -import com.project.notification.infra.entity.TemplateVersion; -import com.project.notification.infra.entity.enums.Channel; -import com.project.notification.infra.entity.enums.MessageStatus; -import com.project.notification.infra.repository.MessageLogRepository; -import com.project.notification.infra.repository.SubscriptionRepository; -import com.project.notification.sender.EmailSender; -import com.project.notification.sender.SmsSender; - -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.Timer; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@Service -@RequiredArgsConstructor -public class NotificationService { - - private final SubscriptionRepository subscriptionRepository; - private final MessageLogRepository messageLogRepository; - private final TemplateService templateService; - private final TemplateEngine templateEngine; - private final EmailSender emailSender; - private final SmsSender smsSender; - private final AesUtil aesUtil; - - private final Counter emailSuccessCounter; - private final Counter emailFailCounter; - private final Counter smsSuccessCounter; - private final Counter smsFailCounter; - private final Counter smsFallbackCounter; - private final Timer emailProcessingTimer; - private final Timer smsProcessingTimer; - - @Transactional - public void processNotification( - NotificationRequestEvent event, Map rawPayload) { - long startTime = System.currentTimeMillis(); - - if (isDuplicateRequest(event.traceId())) { - log.info("Duplicate request detected, skipping. traceId: {}", event.traceId()); - return; - } - - Optional subscriptionOpt = - subscriptionRepository.findByIdWithCustomer(event.subscriptionId()); - - if (subscriptionOpt.isEmpty()) { - log.error("Subscription not found. subId: {}", event.subscriptionId()); - return; - } - - Subscription subscription = subscriptionOpt.get(); - Customer customer = subscription.getCustomer(); - - if (Boolean.TRUE.equals(customer.getIsDeleted())) { - log.info( - "Customer is deleted, skipping notification. customerId: {}", - customer.getCustomerId()); - return; - } - - boolean emailSuccess = tryEmailSend(event, subscription, customer, rawPayload, startTime); - - if (!emailSuccess) { - trySmsFollback(event, subscription, customer, rawPayload); - } - } - - private boolean isDuplicateRequest(String traceId) { - return messageLogRepository.existsByTraceIdAndChannel(traceId, Channel.EMAIL) - || messageLogRepository.existsByTraceIdAndChannel(traceId, Channel.SMS); - } - - private boolean tryEmailSend( - NotificationRequestEvent event, - Subscription subscription, - Customer customer, - Map rawPayload, - long startTime) { - - long emailStartTime = System.currentTimeMillis(); - - String email = aesUtil.decrypt(customer.getEmailEnc()); - - if (email == null || email.isBlank()) { - log.warn("Email is empty for customer: {}", customer.getCustomerId()); - saveMessageLog( - event, - subscription.getSubId(), - null, - null, - Channel.EMAIL, - MessageStatus.FAIL, - "Email address is empty", - rawPayload, - System.currentTimeMillis() - startTime); - emailFailCounter.increment(); - return false; - } - - Optional templateOpt = - templateService.findActiveTemplate(event.templateCode(), Channel.EMAIL); - - if (templateOpt.isEmpty()) { - log.warn("Email template not found for code: {}", event.templateCode()); - saveMessageLog( - event, - subscription.getSubId(), - aesUtil.encrypt(email), - null, - Channel.EMAIL, - MessageStatus.FAIL, - "Email template not found", - rawPayload, - System.currentTimeMillis() - startTime); - emailFailCounter.increment(); - return false; - } - - TemplateVersion template = templateOpt.get(); - String subject = templateEngine.render(template.getSubject(), event.variables()); - String body = templateEngine.render(template.getBody(), event.variables()); - - RenderedMessage message = - new RenderedMessage(Channel.EMAIL, template.getVersionId(), subject, body, email); - - try { - emailSender.send(message); - - long processingTime = System.currentTimeMillis() - startTime; - emailProcessingTimer.record(processingTime, java.util.concurrent.TimeUnit.MILLISECONDS); - - saveMessageLog( - event, - subscription.getSubId(), - aesUtil.encrypt(email), - template.getVersionId(), - Channel.EMAIL, - MessageStatus.SUCCESS, - null, - rawPayload, - processingTime); - - emailSuccessCounter.increment(); - log.info( - "Email sent successfully. traceId: {}, recipient: {}", - event.traceId(), - maskEmail(email)); - return true; - - } catch (Exception e) { - log.error("Email send failed. traceId: {}", event.traceId(), e); - - saveMessageLog( - event, - subscription.getSubId(), - aesUtil.encrypt(email), - template.getVersionId(), - Channel.EMAIL, - MessageStatus.FAIL, - e.getMessage(), - rawPayload, - System.currentTimeMillis() - emailStartTime); - - emailFailCounter.increment(); - return false; - } - } - - private void trySmsFollback( - NotificationRequestEvent event, - Subscription subscription, - Customer customer, - Map rawPayload) { - - long smsStartTime = System.currentTimeMillis(); - - String phoneNumber = resolvePhoneNumber(subscription, customer); - - if (phoneNumber == null || phoneNumber.isBlank()) { - log.warn("Phone number is empty for subscription: {}", subscription.getSubId()); - saveMessageLog( - event, - subscription.getSubId(), - null, - null, - Channel.SMS, - MessageStatus.FAIL, - "Phone number is empty", - rawPayload, - System.currentTimeMillis() - smsStartTime); - smsFailCounter.increment(); - return; - } - - Optional templateOpt = - templateService.findActiveTemplate(event.templateCode(), Channel.SMS); - - if (templateOpt.isEmpty()) { - log.warn( - "SMS template not found for code: {}, fallback not possible", - event.templateCode()); - return; - } - - TemplateVersion template = templateOpt.get(); - String body = templateEngine.render(template.getBody(), event.variables()); - - RenderedMessage message = - new RenderedMessage(Channel.SMS, template.getVersionId(), null, body, phoneNumber); - - try { - smsSender.send(message); - - long processingTime = System.currentTimeMillis() - smsStartTime; - smsProcessingTimer.record(processingTime, java.util.concurrent.TimeUnit.MILLISECONDS); - - saveMessageLog( - event, - subscription.getSubId(), - aesUtil.encrypt(phoneNumber), - template.getVersionId(), - Channel.SMS, - MessageStatus.SUCCESS_FALLBACK, - null, - rawPayload, - processingTime); - - smsFallbackCounter.increment(); - log.info( - "SMS fallback sent successfully. traceId: {}, recipient: {}", - event.traceId(), - maskPhoneNumber(phoneNumber)); - - } catch (Exception e) { - log.error("SMS fallback failed. traceId: {}", event.traceId(), e); - - saveMessageLog( - event, - subscription.getSubId(), - aesUtil.encrypt(phoneNumber), - template.getVersionId(), - Channel.SMS, - MessageStatus.FAIL, - e.getMessage(), - rawPayload, - System.currentTimeMillis() - smsStartTime); - - smsFailCounter.increment(); - } - } - - private String resolvePhoneNumber(Subscription subscription, Customer customer) { - String subscriptionPhone = subscription.getPhoneNumber(); - if (subscriptionPhone != null && !subscriptionPhone.isBlank()) { - return subscriptionPhone; - } - - return aesUtil.decrypt(customer.getContactEnc()); - } - - private void saveMessageLog( - NotificationRequestEvent event, - Long subId, - String recipientEnc, - Long templateVersionId, - Channel channel, - MessageStatus status, - String errorMessage, - Map rawPayload, - Long processingTimeMs) { - - MessageLog log = - MessageLog.builder() - .traceId(event.traceId()) - .subId(subId) - .recipientEnc(recipientEnc) - .templateVersionId(templateVersionId) - .channel(channel) - .status(status) - .errorMessage(errorMessage) - .requestPayload(rawPayload) - .processingTimeMs(processingTimeMs) - .build(); - - messageLogRepository.save(log); - } - - private String maskEmail(String email) { - if (email == null || !email.contains("@")) { - return "***"; - } - int atIndex = email.indexOf("@"); - if (atIndex <= 2) { - return "***" + email.substring(atIndex); - } - return email.substring(0, 2) + "***" + email.substring(atIndex); - } - - private String maskPhoneNumber(String phone) { - if (phone == null || phone.length() < 4) { - return "***"; - } - return phone.substring(0, phone.length() - 4) + "****"; - } -} diff --git a/src/main/java/com/project/notification/service/TemplateService.java b/src/main/java/com/project/notification/service/TemplateService.java index 098a22b..263e174 100644 --- a/src/main/java/com/project/notification/service/TemplateService.java +++ b/src/main/java/com/project/notification/service/TemplateService.java @@ -34,4 +34,19 @@ public Optional findActiveTemplate(String templateCode, Channel return versionOpt; } + + @Transactional(readOnly = true) + public Optional findActiveTemplateByGroupId(Long templateGroupId, Channel channel) { + Optional versionOpt = + templateVersionRepository.findLatestActiveByGroupIdAndChannel(templateGroupId, channel); + + if (versionOpt.isEmpty()) { + log.warn( + "Active template version not found for groupId: {}, channel: {}", + templateGroupId, + channel); + } + + return versionOpt; + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b222242..7077fc6 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,6 +2,14 @@ project: name: API Message version: 1.0.0 +mock-server: + base-url: ${MOCK_SERVER_URL:http://localhost:8081} + connect-timeout: 5000 + read-timeout: 10000 + endpoints: + email: /send/email + sms: /send/sms + cors: allowed-origins: ${FRONTEND_URL:http://localhost:3000} From 924d96195dfdc721e541bbee3133a268fe5e04a5 Mon Sep 17 00:00:00 2001 From: swthewhite Date: Sun, 25 Jan 2026 17:29:40 +0900 Subject: [PATCH 2/5] =?UTF-8?q?UPLUS-139=20fix:=20conflict=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../consumer/NotificationConsumer.java | 73 ++++++++++++++----- .../NotificationSendDedupService.java | 44 +++++++++++ src/main/resources/application.yml | 23 +++--- 3 files changed, 109 insertions(+), 31 deletions(-) diff --git a/src/main/java/com/project/notification/consumer/NotificationConsumer.java b/src/main/java/com/project/notification/consumer/NotificationConsumer.java index aaefead..f70e699 100644 --- a/src/main/java/com/project/notification/consumer/NotificationConsumer.java +++ b/src/main/java/com/project/notification/consumer/NotificationConsumer.java @@ -1,7 +1,9 @@ package com.project.notification.consumer; +import java.util.ArrayList; +import java.util.List; + import org.apache.kafka.clients.consumer.ConsumerRecord; -import org.springframework.context.annotation.Profile; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.kafka.support.Acknowledgment; import org.springframework.stereotype.Component; @@ -21,31 +23,62 @@ public class NotificationConsumer { private final ObjectMapper objectMapper; private final MessageSendService messageSendService; - @KafkaListener( - topics = "notification-usage", - containerFactory = "kafkaListenerContainerFactory") - @Profile("notification-worker") - public void consume(ConsumerRecord record, Acknowledgment ack) { - log.info("CONSUME START offset={}, value={}", record.offset(), record.value()); + @KafkaListener(topics = "usage-noti", containerFactory = "kafkaListenerContainerFactory") + public void consume(List> records, Acknowledgment ack) { + + final String threadName = Thread.currentThread().getName(); + final int batchSize = records.size(); + + log.info("[BATCH START] thread={}, records={}", threadName, batchSize); - try { - UsageNotificationEvent event = - objectMapper.readValue(record.value(), UsageNotificationEvent.class); + long batchStart = System.currentTimeMillis(); - String eventId = event.eventId().toString(); + List events = new ArrayList<>(batchSize); + List eventIds = new ArrayList<>(batchSize); - if (!dedupService.tryAcquire(eventId)) { - log.info("[SKIP] duplicated eventId={}", eventId); - ack.acknowledge(); - return; + for (ConsumerRecord record : records) { + try { + UsageNotificationEvent event = + objectMapper.readValue(record.value(), UsageNotificationEvent.class); + events.add(event); + eventIds.add(event.eventId().toString()); + } catch (Exception e) { + log.warn("[DESERIALIZE FAIL] offset={}", record.offset(), e); } + } + + List dedupResults = dedupService.tryAcquireBatch(eventIds); - messageSendService.processEvent(event); + int processed = 0; + int skipped = 0; - ack.acknowledge(); - } catch (Exception e) { - log.error("[CONSUME FAIL]", e); - ack.acknowledge(); + for (int i = 0; i < events.size(); i++) { + if (!dedupResults.get(i)) { + skipped++; + continue; + } + + try { + UsageNotificationEvent event = events.get(i); + messageSendService.processEvent(event); + processed++; + } catch (Exception e) { + log.error("[PROCESS FAIL] eventId={}", eventIds.get(i), e); + } } + + ack.acknowledge(); + + long elapsedMs = System.currentTimeMillis() - batchStart; + double tps = elapsedMs > 0 ? processed / (elapsedMs / 1000.0) : 0; + + log.info( + "[BATCH DONE] thread={}, batchSize={}, processed={}, skipped={}, elapsed={}ms, tps={}", + threadName, + batchSize, + processed, + skipped, + elapsedMs, + String.format("%.0f", tps)); } } diff --git a/src/main/java/com/project/notification/consumer/NotificationSendDedupService.java b/src/main/java/com/project/notification/consumer/NotificationSendDedupService.java index ea00c50..03168cc 100644 --- a/src/main/java/com/project/notification/consumer/NotificationSendDedupService.java +++ b/src/main/java/com/project/notification/consumer/NotificationSendDedupService.java @@ -1,8 +1,11 @@ package com.project.notification.consumer; import java.time.Duration; +import java.util.ArrayList; +import java.util.List; import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; @@ -12,10 +15,51 @@ public class NotificationSendDedupService { private final StringRedisTemplate redisTemplate; private static final Duration TTL = Duration.ofDays(7); + private static final long TTL_SECONDS = TTL.toSeconds(); + + private static final String BATCH_DEDUP_SCRIPT = + """ + local results = {} + local ttl = tonumber(ARGV[1]) + for i, key in ipairs(KEYS) do + local success = redis.call('SET', key, '1', 'NX', 'EX', ttl) + if success then + results[i] = 1 + else + results[i] = 0 + end + end + return results + """; public boolean tryAcquire(String eventId) { Boolean success = redisTemplate.opsForValue().setIfAbsent("notification:event:" + eventId, "1", TTL); return Boolean.TRUE.equals(success); } + + public List tryAcquireBatch(List eventIds) { + if (eventIds == null || eventIds.isEmpty()) { + return List.of(); + } + + List keys = eventIds.stream() + .map(id -> "notification:event:" + id) + .toList(); + + DefaultRedisScript script = new DefaultRedisScript<>(BATCH_DEDUP_SCRIPT, List.class); + + @SuppressWarnings("unchecked") + List results = redisTemplate.execute(script, keys, String.valueOf(TTL_SECONDS)); + + if (results == null) { + return eventIds.stream().map(id -> false).toList(); + } + + List boolResults = new ArrayList<>(results.size()); + for (Long result : results) { + boolResults.add(result != null && result == 1L); + } + return boolResults; + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7077fc6..8f00aa4 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -21,7 +21,7 @@ spring: name: api-message datasource: - url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5433}/${POSTGRES_DATABASE:app} + url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DATABASE:app} username: ${POSTGRES_USER:postgres} password: ${POSTGRES_PASSWORD:postgres} driver-class-name: org.postgresql.Driver @@ -81,25 +81,24 @@ spring: max.in.flight.requests.per.connection: 5 consumer: - group-id: usage-notification-worker + group-id: usage-noti-worker auto-offset-reset: earliest enable-auto-commit: false key-deserializer: org.apache.kafka.common.serialization.StringDeserializer value-deserializer: org.apache.kafka.common.serialization.StringDeserializer - max-poll-records: 1 - max-poll-interval-ms: 300000 - session-timeout-ms: 10000 - heartbeat-interval-ms: 3000 + max-poll-records: 10000 + fetch.min.bytes: 1048576 # 1MB + fetch.max.wait.ms: 500 - properties: - spring.json.trusted.packages: com.project.rdb.batch.model.dto - - listener: - auto-startup: false + fetch.max.bytes: 52428800 # 50MB + max.partition.fetch.bytes: 10485760 # 10MB + max-poll-interval-ms: 600000 + listener: + auto-startup: true server: port: 8080 @@ -155,3 +154,5 @@ logging: org.hibernate.SQL: WARN pattern: level: "%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]" + + From 7160db8e404661862c69f98c5fe116263fd9873f Mon Sep 17 00:00:00 2001 From: swthewhite Date: Sun, 25 Jan 2026 17:58:48 +0900 Subject: [PATCH 3/5] =?UTF-8?q?UPLUS-139=20feat:=20mock=20server=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20on/off=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/project/notification/sender/EmailSender.java | 11 +++++++++++ .../com/project/notification/sender/SmsSender.java | 11 +++++++++++ src/main/resources/application.yml | 1 + 3 files changed, 23 insertions(+) diff --git a/src/main/java/com/project/notification/sender/EmailSender.java b/src/main/java/com/project/notification/sender/EmailSender.java index 179541f..0f25993 100644 --- a/src/main/java/com/project/notification/sender/EmailSender.java +++ b/src/main/java/com/project/notification/sender/EmailSender.java @@ -1,5 +1,7 @@ package com.project.notification.sender; +import java.util.UUID; + import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; @@ -19,6 +21,9 @@ public class EmailSender { private final RestClient mockServerRestClient; + @Value("${mock-server.enabled:false}") + private boolean mockServerEnabled; + @Value("${mock-server.endpoints.email}") private String emailEndpoint; @@ -29,6 +34,12 @@ public SendResponse send(EmailSendRequest request) { maskEmail(request.email()), request.subject()); + if (!mockServerEnabled) { + String mockMessageId = "mock-email-" + UUID.randomUUID(); + log.info("[EMAIL][MOCK] Skipped HTTP call. mockMessageId: {}", mockMessageId); + return new SendResponse(mockMessageId, "OK"); + } + try { SendResponse response = mockServerRestClient .post() diff --git a/src/main/java/com/project/notification/sender/SmsSender.java b/src/main/java/com/project/notification/sender/SmsSender.java index 88e1e6e..ce413a3 100644 --- a/src/main/java/com/project/notification/sender/SmsSender.java +++ b/src/main/java/com/project/notification/sender/SmsSender.java @@ -1,5 +1,7 @@ package com.project.notification.sender; +import java.util.UUID; + import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; @@ -19,6 +21,9 @@ public class SmsSender { private final RestClient mockServerRestClient; + @Value("${mock-server.enabled:false}") + private boolean mockServerEnabled; + @Value("${mock-server.endpoints.sms}") private String smsEndpoint; @@ -28,6 +33,12 @@ public SendResponse send(SmsSendRequest request) { request.subId(), maskPhone(request.phone())); + if (!mockServerEnabled) { + String mockMessageId = "mock-sms-" + UUID.randomUUID(); + log.info("[SMS][MOCK] Skipped HTTP call. mockMessageId: {}", mockMessageId); + return new SendResponse(mockMessageId, "OK"); + } + try { SendResponse response = mockServerRestClient .post() diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4867629..95bb7f3 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,6 +3,7 @@ project: version: 1.0.0 mock-server: + enabled: ${MOCK_SERVER_ENABLED:false} base-url: ${MOCK_SERVER_URL:http://localhost:8081} connect-timeout: 5000 read-timeout: 10000 From 0baf514865e7283ad34afdbe04e10c311cc9906d Mon Sep 17 00:00:00 2001 From: swthewhite Date: Sun, 25 Jan 2026 18:29:29 +0900 Subject: [PATCH 4/5] =?UTF-8?q?UPLUS-139=20style:=20spotless=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/config/RestClientConfig.java | 5 +- .../consumer/NotificationConsumer.java | 3 +- .../NotificationSendDedupService.java | 10 ++-- .../consumer/UsageNotificationEvent.java | 5 +- .../notification/dto/EmailSendRequest.java | 3 +- .../notification/dto/SendResponse.java | 4 +- .../notification/dto/SmsSendRequest.java | 6 +-- .../TemplateVersionRepositoryImpl.java | 3 +- .../notification/sender/EmailSender.java | 15 +++--- .../notification/sender/SmsSender.java | 15 +++--- .../service/MessageSendService.java | 48 +++++++++++-------- .../notification/service/TemplateService.java | 6 ++- 12 files changed, 63 insertions(+), 60 deletions(-) diff --git a/src/main/java/com/project/global/config/RestClientConfig.java b/src/main/java/com/project/global/config/RestClientConfig.java index 12e1716..23f7c8a 100644 --- a/src/main/java/com/project/global/config/RestClientConfig.java +++ b/src/main/java/com/project/global/config/RestClientConfig.java @@ -26,9 +26,6 @@ public RestClient mockServerRestClient() { factory.setConnectTimeout(Duration.ofMillis(connectTimeout)); factory.setReadTimeout(Duration.ofMillis(readTimeout)); - return RestClient.builder() - .baseUrl(baseUrl) - .requestFactory(factory) - .build(); + return RestClient.builder().baseUrl(baseUrl).requestFactory(factory).build(); } } diff --git a/src/main/java/com/project/notification/consumer/NotificationConsumer.java b/src/main/java/com/project/notification/consumer/NotificationConsumer.java index f70e699..2301bfa 100644 --- a/src/main/java/com/project/notification/consumer/NotificationConsumer.java +++ b/src/main/java/com/project/notification/consumer/NotificationConsumer.java @@ -73,7 +73,8 @@ public void consume(List> records, Acknowledgment double tps = elapsedMs > 0 ? processed / (elapsedMs / 1000.0) : 0; log.info( - "[BATCH DONE] thread={}, batchSize={}, processed={}, skipped={}, elapsed={}ms, tps={}", + "[BATCH DONE] thread={}, batchSize={}, processed={}, skipped={}, elapsed={}ms," + + " tps={}", threadName, batchSize, processed, diff --git a/src/main/java/com/project/notification/consumer/NotificationSendDedupService.java b/src/main/java/com/project/notification/consumer/NotificationSendDedupService.java index b6c6e35..ad9a87b 100644 --- a/src/main/java/com/project/notification/consumer/NotificationSendDedupService.java +++ b/src/main/java/com/project/notification/consumer/NotificationSendDedupService.java @@ -29,13 +29,13 @@ public List tryAcquireBatch(List eventIds) { return List.of(); } - List keys = eventIds.stream() - .map(id -> "notification:event:" + id) - .toList(); + List keys = eventIds.stream().map(id -> "notification:event:" + id).toList(); @SuppressWarnings("unchecked") - List results = (List) redisTemplate.execute( - dedupBatchScript, keys, String.valueOf(TTL.toSeconds())); + List results = + (List) + redisTemplate.execute( + dedupBatchScript, keys, String.valueOf(TTL.toSeconds())); if (results == null) { return eventIds.stream().map(id -> false).toList(); diff --git a/src/main/java/com/project/notification/consumer/UsageNotificationEvent.java b/src/main/java/com/project/notification/consumer/UsageNotificationEvent.java index f6e2e26..cf268ea 100644 --- a/src/main/java/com/project/notification/consumer/UsageNotificationEvent.java +++ b/src/main/java/com/project/notification/consumer/UsageNotificationEvent.java @@ -9,8 +9,5 @@ public record UsageNotificationEvent( SubscriptionInfo subscriptionInfo, Map variables) { - public record SubscriptionInfo( - Long subId, - String phoneNumber, - String email) {} + public record SubscriptionInfo(Long subId, String phoneNumber, String email) {} } diff --git a/src/main/java/com/project/notification/dto/EmailSendRequest.java b/src/main/java/com/project/notification/dto/EmailSendRequest.java index 8073c72..aba5463 100644 --- a/src/main/java/com/project/notification/dto/EmailSendRequest.java +++ b/src/main/java/com/project/notification/dto/EmailSendRequest.java @@ -10,7 +10,8 @@ public record EmailSendRequest( String subject, String body) { - public static EmailSendRequest of(Long subId, String email, String phone, String subject, String body) { + public static EmailSendRequest of( + Long subId, String email, String phone, String subject, String body) { return new EmailSendRequest(subId, email, phone, "EMAIL", subject, body); } } diff --git a/src/main/java/com/project/notification/dto/SendResponse.java b/src/main/java/com/project/notification/dto/SendResponse.java index 1238c34..2babdd2 100644 --- a/src/main/java/com/project/notification/dto/SendResponse.java +++ b/src/main/java/com/project/notification/dto/SendResponse.java @@ -1,8 +1,6 @@ package com.project.notification.dto; -public record SendResponse( - String messageId, - String status) { +public record SendResponse(String messageId, String status) { public boolean isSuccess() { return "OK".equalsIgnoreCase(status); diff --git a/src/main/java/com/project/notification/dto/SmsSendRequest.java b/src/main/java/com/project/notification/dto/SmsSendRequest.java index c5d6d9c..0152e03 100644 --- a/src/main/java/com/project/notification/dto/SmsSendRequest.java +++ b/src/main/java/com/project/notification/dto/SmsSendRequest.java @@ -3,11 +3,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; public record SmsSendRequest( - @JsonProperty("sub_id") Long subId, - String email, - String phone, - String type, - String body) { + @JsonProperty("sub_id") Long subId, String email, String phone, String type, String body) { public static SmsSendRequest of(Long subId, String email, String phone, String body) { return new SmsSendRequest(subId, email, phone, "SMS", body); diff --git a/src/main/java/com/project/notification/infra/repository/TemplateVersionRepositoryImpl.java b/src/main/java/com/project/notification/infra/repository/TemplateVersionRepositoryImpl.java index 6cabd73..9490207 100644 --- a/src/main/java/com/project/notification/infra/repository/TemplateVersionRepositoryImpl.java +++ b/src/main/java/com/project/notification/infra/repository/TemplateVersionRepositoryImpl.java @@ -24,7 +24,8 @@ public Optional findLatestActiveByGroupCodeAndChannel( } @Override - public Optional findLatestActiveByGroupIdAndChannel(Long groupId, Channel channel) { + public Optional findLatestActiveByGroupIdAndChannel( + Long groupId, Channel channel) { return templateVersionJpaRepository.findLatestByGroupIdAndChannelAndStatus( groupId, channel, TemplateStatus.ACTIVE); } diff --git a/src/main/java/com/project/notification/sender/EmailSender.java b/src/main/java/com/project/notification/sender/EmailSender.java index 0f25993..88e2b23 100644 --- a/src/main/java/com/project/notification/sender/EmailSender.java +++ b/src/main/java/com/project/notification/sender/EmailSender.java @@ -41,13 +41,14 @@ public SendResponse send(EmailSendRequest request) { } try { - SendResponse response = mockServerRestClient - .post() - .uri(emailEndpoint) - .contentType(MediaType.APPLICATION_JSON) - .body(request) - .retrieve() - .body(SendResponse.class); + SendResponse response = + mockServerRestClient + .post() + .uri(emailEndpoint) + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .retrieve() + .body(SendResponse.class); if (response != null && response.isSuccess()) { log.info("[EMAIL] Successfully sent, messageId: {}", response.messageId()); diff --git a/src/main/java/com/project/notification/sender/SmsSender.java b/src/main/java/com/project/notification/sender/SmsSender.java index ce413a3..2b41098 100644 --- a/src/main/java/com/project/notification/sender/SmsSender.java +++ b/src/main/java/com/project/notification/sender/SmsSender.java @@ -40,13 +40,14 @@ public SendResponse send(SmsSendRequest request) { } try { - SendResponse response = mockServerRestClient - .post() - .uri(smsEndpoint) - .contentType(MediaType.APPLICATION_JSON) - .body(request) - .retrieve() - .body(SendResponse.class); + SendResponse response = + mockServerRestClient + .post() + .uri(smsEndpoint) + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .retrieve() + .body(SendResponse.class); if (response != null && response.isSuccess()) { log.info("[SMS] Successfully sent, messageId: {}", response.messageId()); diff --git a/src/main/java/com/project/notification/service/MessageSendService.java b/src/main/java/com/project/notification/service/MessageSendService.java index ac60b08..740a2a7 100644 --- a/src/main/java/com/project/notification/service/MessageSendService.java +++ b/src/main/java/com/project/notification/service/MessageSendService.java @@ -97,21 +97,23 @@ private boolean tryEmailSend(UsageNotificationEvent event, long startTime) { } TemplateVersion template = templateOpt.get(); - log.debug("[EMAIL] Template loaded. versionId={}, groupId={}", - template.getVersionId(), event.templateGroupId()); + log.debug( + "[EMAIL] Template loaded. versionId={}, groupId={}", + template.getVersionId(), + event.templateGroupId()); String subject = templateEngine.render(template.getSubject(), event.variables()); String body = templateEngine.render(template.getBody(), event.variables()); - log.info("[EMAIL] Rendered. eventId={}, subId={}, subject={}, bodyLength={}", - event.eventId(), subInfo.subId(), subject, body != null ? body.length() : 0); - - EmailSendRequest request = EmailSendRequest.of( + log.info( + "[EMAIL] Rendered. eventId={}, subId={}, subject={}, bodyLength={}", + event.eventId(), subInfo.subId(), - email, - subInfo.phoneNumber(), subject, - body); + body != null ? body.length() : 0); + + EmailSendRequest request = + EmailSendRequest.of(subInfo.subId(), email, subInfo.phoneNumber(), subject, body); try { log.debug("[EMAIL] Sending request to mock-server..."); @@ -186,19 +188,21 @@ private void trySmsFallback(UsageNotificationEvent event) { } TemplateVersion template = templateOpt.get(); - log.debug("[SMS] Template loaded. versionId={}, groupId={}", - template.getVersionId(), event.templateGroupId()); + log.debug( + "[SMS] Template loaded. versionId={}, groupId={}", + template.getVersionId(), + event.templateGroupId()); String body = templateEngine.render(template.getBody(), event.variables()); - log.info("[SMS] Rendered. eventId={}, subId={}, bodyLength={}", - event.eventId(), subInfo.subId(), body != null ? body.length() : 0); - - SmsSendRequest request = SmsSendRequest.of( + log.info( + "[SMS] Rendered. eventId={}, subId={}, bodyLength={}", + event.eventId(), subInfo.subId(), - subInfo.email(), - phoneNumber, - body); + body != null ? body.length() : 0); + + SmsSendRequest request = + SmsSendRequest.of(subInfo.subId(), subInfo.email(), phoneNumber, body); try { log.debug("[SMS] Sending request to mock-server..."); @@ -272,7 +276,11 @@ private void saveMessageLog( messageLogRepository.save(messageLog); - log.info("[MESSAGE_LOG] Saved. eventId={}, channel={}, status={}, processingTime={}ms", - event.eventId(), channel, status, processingTimeMs); + log.info( + "[MESSAGE_LOG] Saved. eventId={}, channel={}, status={}, processingTime={}ms", + event.eventId(), + channel, + status, + processingTimeMs); } } diff --git a/src/main/java/com/project/notification/service/TemplateService.java b/src/main/java/com/project/notification/service/TemplateService.java index 263e174..25a6881 100644 --- a/src/main/java/com/project/notification/service/TemplateService.java +++ b/src/main/java/com/project/notification/service/TemplateService.java @@ -36,9 +36,11 @@ public Optional findActiveTemplate(String templateCode, Channel } @Transactional(readOnly = true) - public Optional findActiveTemplateByGroupId(Long templateGroupId, Channel channel) { + public Optional findActiveTemplateByGroupId( + Long templateGroupId, Channel channel) { Optional versionOpt = - templateVersionRepository.findLatestActiveByGroupIdAndChannel(templateGroupId, channel); + templateVersionRepository.findLatestActiveByGroupIdAndChannel( + templateGroupId, channel); if (versionOpt.isEmpty()) { log.warn( From 8002dc0c07c88591d002f197d1eae5205135fd31 Mon Sep 17 00:00:00 2001 From: swthewhite Date: Sun, 25 Jan 2026 18:36:24 +0900 Subject: [PATCH 5/5] =?UTF-8?q?UPLUS-139=20chore:=20=EB=B9=A0=EB=A5=B8=20?= =?UTF-8?q?=EA=B0=9C=EB=B0=9C=EC=9D=84=20=EC=9C=84=ED=95=9C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=ED=95=B4=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/build.gradle b/build.gradle index a7f33ce..60e7260 100644 --- a/build.gradle +++ b/build.gradle @@ -16,9 +16,7 @@ description = 'API Message Service' // Coverage Exclusion Patterns (공통 제외 패턴) // ============================================================================ def coverageExcludePackages = [ - '**/config/**', // Config 클래스 제외 - '**/exception/**', // Exception 클래스 제외 - '**/dto/**', // DTO 클래스 제외 + '**' ] // JaCoCo용 제외 패턴 (클래스 파일)