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용 제외 패턴 (클래스 파일) 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..23f7c8a --- /dev/null +++ b/src/main/java/com/project/global/config/RestClientConfig.java @@ -0,0 +1,31 @@ +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 308c590..2301bfa 100644 --- a/src/main/java/com/project/notification/consumer/NotificationConsumer.java +++ b/src/main/java/com/project/notification/consumer/NotificationConsumer.java @@ -1,10 +1,7 @@ package com.project.notification.consumer; -import java.time.LocalDateTime; -import java.time.ZoneId; import java.util.ArrayList; import java.util.List; -import java.util.Map; import org.apache.kafka.clients.consumer.ConsumerRecord; import org.springframework.kafka.annotation.KafkaListener; @@ -12,7 +9,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; @@ -24,7 +21,7 @@ public class NotificationConsumer { private final NotificationSendDedupService dedupService; private final ObjectMapper objectMapper; - private final UsageNotificationMessageFormatter formatter; + private final MessageSendService messageSendService; @KafkaListener(topics = "usage-noti", containerFactory = "kafkaListenerContainerFactory") public void consume(List> records, Acknowledgment ack) { @@ -36,7 +33,6 @@ public void consume(List> records, Acknowledgment long batchStart = System.currentTimeMillis(); - // 1️⃣ eventId 수집 List events = new ArrayList<>(batchSize); List eventIds = new ArrayList<>(batchSize); @@ -51,30 +47,30 @@ public void consume(List> records, Acknowledgment } } - // redis Lua dedup (단 1회 호출) List dedupResults = dedupService.tryAcquireBatch(eventIds); int processed = 0; int skipped = 0; - // 3️⃣ 결과 기반 처리 for (int i = 0; i < events.size(); i++) { if (!dedupResults.get(i)) { skipped++; continue; } - UsageNotificationEvent event = events.get(i); - String message = formatter.format(event, LocalDateTime.now(ZoneId.of("Asia/Seoul"))); - - SendNotificationLogger.write(message); - processed++; + 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 = processed / (elapsedMs / 1000.0); + double tps = elapsedMs > 0 ? processed / (elapsedMs / 1000.0) : 0; log.info( "[BATCH DONE] thread={}, batchSize={}, processed={}, skipped={}, elapsed={}ms," @@ -86,31 +82,4 @@ public void consume(List> records, Acknowledgment elapsedMs, String.format("%.0f", tps)); } - - @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; - } - } } diff --git a/src/main/java/com/project/notification/consumer/NotificationSendDedupService.java b/src/main/java/com/project/notification/consumer/NotificationSendDedupService.java index 882284d..ad9a87b 100644 --- a/src/main/java/com/project/notification/consumer/NotificationSendDedupService.java +++ b/src/main/java/com/project/notification/consumer/NotificationSendDedupService.java @@ -14,12 +14,20 @@ public class NotificationSendDedupService { private final StringRedisTemplate redisTemplate; + private final RedisScript dedupBatchScript; private static final Duration TTL = Duration.ofDays(7); - private final RedisScript dedupBatchScript; + 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(); @@ -29,6 +37,10 @@ public List tryAcquireBatch(List eventIds) { redisTemplate.execute( dedupBatchScript, keys, String.valueOf(TTL.toSeconds())); - return results.stream().map(v -> v == 1L).toList(); + if (results == null) { + return eventIds.stream().map(id -> false).toList(); + } + + return results.stream().map(v -> v != null && v == 1L).toList(); } } 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 f636522..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.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..cf268ea 100644 --- a/src/main/java/com/project/notification/consumer/UsageNotificationEvent.java +++ b/src/main/java/com/project/notification/consumer/UsageNotificationEvent.java @@ -1,14 +1,13 @@ 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..aba5463 --- /dev/null +++ b/src/main/java/com/project/notification/dto/EmailSendRequest.java @@ -0,0 +1,17 @@ +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..2babdd2 --- /dev/null +++ b/src/main/java/com/project/notification/dto/SendResponse.java @@ -0,0 +1,8 @@ +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..0152e03 --- /dev/null +++ b/src/main/java/com/project/notification/dto/SmsSendRequest.java @@ -0,0 +1,11 @@ +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..9490207 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,11 @@ 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..88e2b23 100644 --- a/src/main/java/com/project/notification/sender/EmailSender.java +++ b/src/main/java/com/project/notification/sender/EmailSender.java @@ -1,30 +1,76 @@ 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; +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.enabled:false}") + private boolean mockServerEnabled; + + @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()); + + 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() + .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..2b41098 100644 --- a/src/main/java/com/project/notification/sender/SmsSender.java +++ b/src/main/java/com/project/notification/sender/SmsSender.java @@ -1,26 +1,71 @@ 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; +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.enabled:false}") + private boolean mockServerEnabled; - private String truncateBody(String body) { - if (body == null) { - return null; + @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())); + + if (!mockServerEnabled) { + String mockMessageId = "mock-sms-" + UUID.randomUUID(); + log.info("[SMS][MOCK] Skipped HTTP call. mockMessageId: {}", mockMessageId); + return new SendResponse(mockMessageId, "OK"); } - if (body.length() > 100) { - return body.substring(0, 100) + "..."; + + try { + 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()); + } 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"); + } + } + + 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..740a2a7 --- /dev/null +++ b/src/main/java/com/project/notification/service/MessageSendService.java @@ -0,0 +1,286 @@ +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(); + 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(subInfo.subId(), email, subInfo.phoneNumber(), subject, body); + + try { + log.debug("[EMAIL] Sending request to mock-server..."); + 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(); + 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(subInfo.subId(), subInfo.email(), phoneNumber, body); + + try { + log.debug("[SMS] Sending request to mock-server..."); + 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); + + 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/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..25a6881 100644 --- a/src/main/java/com/project/notification/service/TemplateService.java +++ b/src/main/java/com/project/notification/service/TemplateService.java @@ -34,4 +34,21 @@ 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 f736d0c..95bb7f3 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,6 +2,15 @@ project: name: Backend API 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 + endpoints: + email: /send/email + sms: /send/sms + cors: allowed-origins: ${FRONTEND_URL:http://localhost:3000}