feat: 회의예약 시 알림 수신 로직 추가 (이메일발송 수정 필요)

This commit is contained in:
djeon 2025-10-26 00:15:44 +09:00
parent 3c7ea9d013
commit ef8e586d9a
17 changed files with 2024 additions and 2943 deletions

View File

@ -39,13 +39,14 @@
<entry key="MAIL_PASSWORD" value="" />
<!-- Azure EventHub Configuration -->
<entry key="AZURE_EVENTHUB_ENABLED" value="true" />
<entry key="EVENTHUB_CONNECTION_STRING" value="Endpoint=sb://hgzero-eventhub-ns.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=VUqZ9vFgu35E3c6RiUzoOGVUP8IZpFvlV+AEhC6sUpo=" />
<entry key="EVENTHUB_NAME" value="notification-events" />
<entry key="EVENTHUB_NAME" value="hgzero-eventhub-name" />
<entry key="AZURE_EVENTHUB_CONSUMER_GROUP" value="$Default" />
<!-- Azure Storage Configuration -->
<entry key="AZURE_STORAGE_CONNECTION_STRING" value="DefaultEndpointsProtocol=https;AccountName=hgzerostorage;AccountKey=xOQGJhDT6sqOGyTohS7K5dMgGNlryuaQSg8dNCJ40sdGpYok5T5Z88M3xVlk39oeFKiQdGYCihqC+AStBsoBPw==;EndpointSuffix=core.windows.net" />
<entry key="AZURE_STORAGE_CONTAINER_NAME" value="eventhub-checkpoints" />
<entry key="AZURE_STORAGE_CONTAINER_NAME" value="hgzero-checkpoints" />
<!-- Notification Configuration -->
<entry key="NOTIFICATION_FROM_EMAIL" value="noreply@hgzero.com" />

View File

@ -0,0 +1 @@
(eval):1: no such file or directory: ./gradlew

View File

@ -26,4 +26,7 @@ dependencies {
// Azure Event Hubs Checkpoint Store
implementation "com.azure:azure-messaging-eventhubs-checkpointstore-blob:${azureEventHubsCheckpointVersion}"
// H2 Database (for development)
runtimeOnly 'com.h2database:h2'
}

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,7 @@ import com.azure.storage.blob.BlobContainerAsyncClient;
import com.azure.storage.blob.BlobContainerClientBuilder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@ -19,6 +20,11 @@ import org.springframework.context.annotation.Configuration;
*/
@Slf4j
@Configuration
@ConditionalOnProperty(
name = "azure.eventhub.enabled",
havingValue = "true",
matchIfMissing = false
)
public class BlobStorageConfig {
@Value("${azure.storage.connection-string}")

View File

@ -7,6 +7,7 @@ import com.azure.messaging.eventhubs.models.EventContext;
import com.azure.storage.blob.BlobContainerAsyncClient;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@ -24,6 +25,11 @@ import java.util.function.Consumer;
*/
@Slf4j
@Configuration
@ConditionalOnProperty(
name = "azure.eventhub.enabled",
havingValue = "true",
matchIfMissing = false
)
public class EventHubConfig {
@Value("${azure.eventhub.connection-string}")

View File

@ -218,7 +218,7 @@ public class Notification {
* 알림 유형 Enum
*/
public enum NotificationType {
INVITATION, // 회의 초대
MEETING_INVITATION, // 회의 초대
TODO_ASSIGNED, // Todo 할당
TODO_REMINDER, // Todo 리마인더
MEETING_REMINDER, // 회의 리마인더

View File

@ -176,7 +176,7 @@ public class NotificationSetting {
*/
public boolean isNotificationTypeEnabled(Notification.NotificationType notificationType) {
return switch (notificationType) {
case INVITATION -> invitationEnabled;
case MEETING_INVITATION -> invitationEnabled;
case TODO_ASSIGNED -> todoAssignedEnabled;
case TODO_REMINDER -> todoReminderEnabled;
case MEETING_REMINDER -> meetingReminderEnabled;

View File

@ -3,10 +3,12 @@ package com.unicorn.hgzero.notification.event;
import com.azure.messaging.eventhubs.models.EventContext;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.unicorn.hgzero.notification.event.event.MeetingCreatedEvent;
import com.unicorn.hgzero.notification.event.event.NotificationRequestEvent;
import com.unicorn.hgzero.notification.event.event.TodoAssignedEvent;
import com.unicorn.hgzero.notification.service.NotificationService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Component;
@ -26,6 +28,11 @@ import java.util.function.Consumer;
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(
name = "azure.eventhub.enabled",
havingValue = "true",
matchIfMissing = false
)
public class EventHandler implements Consumer<EventContext> {
private final NotificationService notificationService;
@ -46,7 +53,7 @@ public class EventHandler implements Consumer<EventContext> {
// 이벤트 속성 추출
Map<String, Object> properties = eventData.getProperties();
String topic = (String) properties.get("topic");
String eventType = (String) properties.get("eventType");
String eventType = (String) properties.get("type");
log.info("이벤트 수신 - Topic: {}, EventType: {}", topic, eventType);
@ -58,6 +65,8 @@ public class EventHandler implements Consumer<EventContext> {
handleMeetingEvent(eventType, eventBody);
} else if ("todo".equals(topic)) {
handleTodoEvent(eventType, eventBody);
} else if ("notification".equals(topic)) {
handleNotificationEvent(eventType, eventBody);
} else {
log.warn("알 수 없는 토픽: {}", topic);
}
@ -174,4 +183,47 @@ public class EventHandler implements Consumer<EventContext> {
return null;
});
}
/**
* 알림 관련 이벤트 처리
*
* @param eventType 이벤트 유형
* @param eventBody 이벤트 본문 (JSON)
*/
private void handleNotificationEvent(String eventType, String eventBody) {
try {
switch (eventType) {
case "NOTIFICATION_REQUEST":
NotificationRequestEvent notificationEvent = objectMapper.readValue(
eventBody,
NotificationRequestEvent.class
);
processNotificationRequestEvent(notificationEvent);
break;
default:
log.warn("알 수 없는 알림 이벤트 유형: {}", eventType);
}
} catch (Exception e) {
log.error("알림 이벤트 처리 중 오류 발생 - EventType: {}", eventType, e);
throw new RuntimeException("알림 이벤트 처리 실패", e);
}
}
/**
* 알림 요청 이벤트 처리 (재시도 지원)
*
* @param event 알림 요청 이벤트
*/
private void processNotificationRequestEvent(NotificationRequestEvent event) {
retryTemplate.execute(context -> {
log.info("알림 발송 시작 - Type: {}, EventId: {}",
event.getNotificationType(), event.getEventId());
notificationService.processNotification(event);
log.info("알림 발송 완료 - Type: {}", event.getNotificationType());
return null;
});
}
}

View File

@ -0,0 +1,176 @@
package com.unicorn.hgzero.notification.event.event;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 알림 발송 요청 이벤트 DTO
*
* 범용 알림 발송 이벤트
* 다양한 서비스에서 알림 발송 사용
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-25
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class NotificationRequestEvent {
/**
* 이벤트 고유 ID (중복 발송 방지용)
*/
private String eventId;
/**
* 발송 채널 (EMAIL, SMS)
*/
private String channel;
/**
* 알림 유형 (INVITATION, TODO_ASSIGNED, REMINDER )
*/
private String notificationType;
/**
* 참조 대상 유형 (MEETING, TODO)
*/
private String referenceType;
/**
* 참조 대상 ID
*/
private String referenceId;
/**
* 관련 엔티티 유형 (이벤트 메시지의 relatedEntityType 매핑용)
*/
private String relatedEntityType;
/**
* 관련 엔티티 ID (이벤트 메시지의 relatedEntityId 매핑용)
*/
private String relatedEntityId;
/**
* 요청자 ID
*/
private String requestedBy;
/**
* 요청자 이름
*/
private String requestedByName;
/**
* 우선순위
*/
private String priority;
/**
* 발신자
*/
private String sender;
/**
* 제목 (subject)
*/
private String subject;
/**
* 예약 발송 시간
*/
private LocalDateTime scheduledTime;
/**
* 이벤트 발생 시간 (배열 형태로 수신)
*/
private java.util.List<Integer> eventTime;
/**
* 알림 제목
*/
private String title;
/**
* 알림 메시지
*/
private String message;
/**
* 수신자 목록 (다중 수신자용)
*/
private List<RecipientInfo> recipients;
/**
* 수신자 ID (단일 수신자용)
*/
private String recipientId;
/**
* 수신자 이름 (단일 수신자용)
*/
private String recipientName;
/**
* 수신자 이메일 (단일 수신자용)
*/
private String recipientEmail;
/**
* 참여자 (단일 수신자용, recipients와 함께 사용 가능)
*/
private String participant;
/**
* 템플릿 ID (이메일 템플릿)
*/
private String templateId;
/**
* 템플릿 데이터 (템플릿 렌더링에 사용)
*/
private Map<String, Object> templateData;
/**
* 추가 메타데이터 (회의 정보 )
*/
private Map<String, Object> metadata;
/**
* 이벤트 발행 일시
*/
private LocalDateTime createdAt;
/**
* 수신자 정보 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class RecipientInfo {
/**
* 사용자 ID
*/
private String userId;
/**
* 사용자 이름
*/
private String name;
/**
* 사용자 이메일
*/
private String email;
}
}

View File

@ -5,6 +5,7 @@ import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;
@ -22,6 +23,11 @@ import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnProperty(
name = "azure.eventhub.enabled",
havingValue = "true",
matchIfMissing = false
)
public class EventProcessorService {
private final EventProcessorClient eventProcessorClient;

View File

@ -0,0 +1,173 @@
package com.unicorn.hgzero.notification.service;
import jakarta.mail.MessagingException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* 이메일 알림 발송 서비스
*
* 템플릿 로드, 렌더링, 이메일 발송 통합 처리
* 재시도 로직 포함
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-25
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class EmailNotifier {
private final EmailTemplateService templateService;
private final EmailClient emailClient;
private final RetryTemplate retryTemplate;
/**
* 이메일 발송 (재시도 지원)
*
* @param recipientEmail 수신자 이메일
* @param subject 이메일 제목
* @param htmlContent 이메일 HTML 본문
* @return 발송 성공 여부
*/
public boolean sendEmail(String recipientEmail, String subject, String htmlContent) {
try {
return retryTemplate.execute(context -> {
try {
log.info("이메일 발송 시도 #{} - Email: {}", context.getRetryCount() + 1, recipientEmail);
emailClient.sendHtmlEmail(recipientEmail, subject, htmlContent);
log.info("이메일 발송 성공 - Email: {}", recipientEmail);
return true;
} catch (MessagingException e) {
log.error("이메일 발송 실패 (재시도 #{}) - Email: {}",
context.getRetryCount() + 1, recipientEmail, e);
if (context.getRetryCount() >= 2) {
log.error("최대 재시도 횟수 초과 - Email: {}", recipientEmail);
return false;
}
throw new RuntimeException("이메일 발송 실패", e);
}
});
} catch (Exception e) {
log.error("이메일 발송 최종 실패 - Email: {}", recipientEmail, e);
return false;
}
}
/**
* 템플릿 기반 이메일 발송
*
* 템플릿을 로드하고 데이터를 렌더링하여 이메일 발송
*
* @param recipientEmail 수신자 이메일
* @param subject 이메일 제목
* @param templateId 템플릿 ID
* @param templateData 템플릿 데이터
* @return 발송 성공 여부
*/
public boolean sendTemplateEmail(
String recipientEmail,
String subject,
String templateId,
Map<String, Object> templateData
) {
try {
log.info("템플릿 이메일 발송 시작 - Template: {}, Email: {}", templateId, recipientEmail);
// 템플릿 로드 렌더링
String htmlContent = loadAndRenderTemplate(templateId, templateData);
// 이메일 발송
return sendEmail(recipientEmail, subject, htmlContent);
} catch (Exception e) {
log.error("템플릿 이메일 발송 실패 - Template: {}, Email: {}", templateId, recipientEmail, e);
return false;
}
}
/**
* 템플릿 로드 렌더링
*
* @param templateId 템플릿 ID
* @param templateData 템플릿 데이터
* @return 렌더링된 HTML 본문
*/
public String loadAndRenderTemplate(String templateId, Map<String, Object> templateData) {
log.info("템플릿 로드 중 - TemplateId: {}", templateId);
String htmlContent;
switch (templateId) {
case "meeting-invitation":
htmlContent = templateService.renderMeetingInvitation(templateData);
break;
case "todo-assigned":
htmlContent = templateService.renderTodoAssigned(templateData);
break;
case "meeting-reminder":
// 향후 구현
log.warn("회의 알림 템플릿은 아직 구현되지 않았습니다");
htmlContent = renderDefaultTemplate(templateData);
break;
case "todo-reminder":
// 향후 구현
log.warn("Todo 알림 템플릿은 아직 구현되지 않았습니다");
htmlContent = renderDefaultTemplate(templateData);
break;
case "minutes-updated":
// 향후 구현
log.warn("회의록 수정 템플릿은 아직 구현되지 않았습니다");
htmlContent = renderDefaultTemplate(templateData);
break;
default:
log.warn("알 수 없는 템플릿 ID: {} - 기본 템플릿 사용", templateId);
htmlContent = renderDefaultTemplate(templateData);
break;
}
log.info("템플릿 렌더링 완료 - TemplateId: {}", templateId);
return htmlContent;
}
/**
* 기본 템플릿 렌더링
*
* @param templateData 템플릿 데이터
* @return 렌더링된 HTML 본문
*/
private String renderDefaultTemplate(Map<String, Object> templateData) {
String title = (String) templateData.getOrDefault("title", "알림");
String message = (String) templateData.getOrDefault("message", "");
return String.format("""
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>%s</title>
</head>
<body>
<h2>%s</h2>
<p>%s</p>
</body>
</html>
""", title, title, message);
}
}

View File

@ -0,0 +1,127 @@
package com.unicorn.hgzero.notification.service;
import com.unicorn.hgzero.notification.domain.Notification;
import com.unicorn.hgzero.notification.domain.NotificationSetting;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 알림 채널 라우팅 서비스
*
* 사용자 설정에 따라 적절한 알림 채널을 결정
* 이메일 우선, SMS 백업 전략 사용
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-25
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class NotificationRouter {
private final EmailNotifier emailNotifier;
/**
* 알림 라우팅 발송
*
* @param eventId 이벤트 ID
* @param recipientEmail 수신자 이메일
* @param recipientName 수신자 이름
* @param subject 이메일 제목
* @param htmlContent 이메일 HTML 본문
* @param preferences 사용자 알림 설정
* @param requestedChannel 요청된 채널
* @return 발송 성공 여부
*/
public boolean routeNotification(
String eventId,
String recipientEmail,
String recipientName,
String subject,
String htmlContent,
NotificationSetting preferences,
Notification.NotificationChannel requestedChannel
) {
log.info("알림 라우팅 시작 - Email: {}, Channel: {}", recipientEmail, requestedChannel);
// 채널 결정
Notification.NotificationChannel selectedChannel = determineChannel(preferences, requestedChannel);
log.info("선택된 채널: {}", selectedChannel);
// 채널별 발송
boolean success = false;
switch (selectedChannel) {
case EMAIL:
success = emailNotifier.sendEmail(recipientEmail, subject, htmlContent);
break;
case SMS:
// SMS 발송 로직 (향후 구현)
log.warn("SMS 채널은 아직 구현되지 않았습니다. 이메일로 대체 발송 시도");
success = emailNotifier.sendEmail(recipientEmail, subject, htmlContent);
break;
case PUSH:
// PUSH 알림 로직 (향후 구현)
log.warn("PUSH 채널은 아직 구현되지 않았습니다. 이메일로 대체 발송 시도");
success = emailNotifier.sendEmail(recipientEmail, subject, htmlContent);
break;
default:
log.error("알 수 없는 채널: {}", selectedChannel);
break;
}
if (success) {
log.info("알림 발송 성공 - Email: {}, Channel: {}", recipientEmail, selectedChannel);
} else {
log.error("알림 발송 실패 - Email: {}, Channel: {}", recipientEmail, selectedChannel);
}
return success;
}
/**
* 채널 결정 로직
*
* 사용자 설정과 요청 채널을 고려하여 최종 채널 결정
* 이메일 우선, SMS 백업 전략
*
* @param preferences 사용자 알림 설정
* @param requestedChannel 요청된 채널
* @return 선택된 채널
*/
private Notification.NotificationChannel determineChannel(
NotificationSetting preferences,
Notification.NotificationChannel requestedChannel
) {
// 사용자 설정이 없으면 요청된 채널 사용
if (preferences == null) {
log.info("사용자 설정 없음 - 요청 채널 사용: {}", requestedChannel);
return requestedChannel != null ? requestedChannel : Notification.NotificationChannel.EMAIL;
}
// 이메일 우선 전략
if (requestedChannel == Notification.NotificationChannel.EMAIL && preferences.isChannelEnabled(Notification.NotificationChannel.EMAIL)) {
return Notification.NotificationChannel.EMAIL;
}
// SMS 백업 전략
if (requestedChannel == Notification.NotificationChannel.SMS && preferences.isChannelEnabled(Notification.NotificationChannel.SMS)) {
return Notification.NotificationChannel.SMS;
}
// PUSH 알림 전략
if (requestedChannel == Notification.NotificationChannel.PUSH && preferences.isChannelEnabled(Notification.NotificationChannel.PUSH)) {
return Notification.NotificationChannel.PUSH;
}
// 기본값: 이메일
log.info("요청된 채널 사용 불가 - 기본 이메일 채널 사용");
return Notification.NotificationChannel.EMAIL;
}
}

View File

@ -4,6 +4,7 @@ import com.unicorn.hgzero.notification.domain.Notification;
import com.unicorn.hgzero.notification.domain.NotificationRecipient;
import com.unicorn.hgzero.notification.domain.NotificationSetting;
import com.unicorn.hgzero.notification.event.event.MeetingCreatedEvent;
import com.unicorn.hgzero.notification.event.event.NotificationRequestEvent;
import com.unicorn.hgzero.notification.event.event.TodoAssignedEvent;
import com.unicorn.hgzero.notification.repository.NotificationRecipientRepository;
import com.unicorn.hgzero.notification.repository.NotificationRepository;
@ -41,6 +42,8 @@ public class NotificationService {
private final NotificationSettingRepository settingRepository;
private final EmailTemplateService templateService;
private final EmailClient emailClient;
private final NotificationRouter notificationRouter;
private final EmailNotifier emailNotifier;
private static final DateTimeFormatter DATETIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
@ -64,7 +67,7 @@ public class NotificationService {
.eventId(event.getEventId())
.referenceId(event.getMeetingId())
.referenceType(Notification.ReferenceType.MEETING)
.notificationType(Notification.NotificationType.INVITATION)
.notificationType(Notification.NotificationType.MEETING_INVITATION)
.title("회의 초대: " + event.getTitle())
.message(event.getDescription())
.status(Notification.NotificationStatus.PROCESSING)
@ -80,7 +83,7 @@ public class NotificationService {
for (MeetingCreatedEvent.ParticipantInfo participant : event.getParticipants()) {
try {
// 3-1. 알림 설정 확인
if (!canSendNotification(participant.getUserId(), Notification.NotificationType.INVITATION)) {
if (!canSendNotification(participant.getUserId(), Notification.NotificationType.MEETING_INVITATION)) {
log.info("알림 설정에 의해 발송 제외 - UserId: {}", participant.getUserId());
continue;
}
@ -251,6 +254,254 @@ public class NotificationService {
log.info("Todo 할당 알림 처리 완료");
}
/**
* 범용 알림 발송 처리
*
* NotificationRequest 이벤트를 처리하여 알림 발송
* 중복 방지, 사용자 설정 확인, 채널 라우팅 포함
*
* @param event 알림 발송 요청 이벤트
*/
public void processNotification(NotificationRequestEvent event) {
// eventId가 없으면 UUID 생성
String eventId = event.getEventId();
if (eventId == null || eventId.isBlank()) {
eventId = java.util.UUID.randomUUID().toString();
log.info("EventId가 없어 생성함 - EventId: {}", eventId);
}
log.info("알림 발송 처리 시작 - EventId: {}, Type: {}",
eventId, event.getNotificationType());
// 1. 중복 발송 방지 체크
if (notificationRepository.existsByEventId(eventId)) {
log.warn("이미 처리된 이벤트 - EventId: {}", eventId);
return;
}
// 2. 알림 유형 참조 유형 변환
Notification.NotificationType notificationType = parseNotificationType(event.getNotificationType());
// referenceType 또는 relatedEntityType 하나를 사용
String referenceTypeStr = event.getReferenceType() != null
? event.getReferenceType()
: event.getRelatedEntityType();
Notification.ReferenceType referenceType = parseReferenceType(referenceTypeStr);
// referenceId 또는 relatedEntityId 하나를 사용
String referenceIdStr = event.getReferenceId() != null
? event.getReferenceId()
: event.getRelatedEntityId();
Notification.NotificationChannel channel = parseChannel(event.getChannel());
// 3. 알림 엔티티 생성
Notification notification = Notification.builder()
.eventId(eventId)
.referenceId(referenceIdStr)
.referenceType(referenceType)
.notificationType(notificationType)
.title(event.getTitle())
.message(event.getMessage())
.status(Notification.NotificationStatus.PROCESSING)
.channel(channel)
.build();
notificationRepository.save(notification);
// 4. 수신자 리스트 구성 (단일 수신자 또는 다중 수신자)
java.util.List<NotificationRequestEvent.RecipientInfo> recipientList = new java.util.ArrayList<>();
// 4-1. 단일 수신자 필드가 있으면 추가
if (event.getRecipientEmail() != null && !event.getRecipientEmail().isBlank()) {
NotificationRequestEvent.RecipientInfo singleRecipient = NotificationRequestEvent.RecipientInfo.builder()
.userId(event.getRecipientId())
.name(event.getRecipientName())
.email(event.getRecipientEmail())
.build();
recipientList.add(singleRecipient);
log.info("단일 수신자 처리 - Email: {}", event.getRecipientEmail());
}
// 4-2. recipients 리스트가 있으면 추가
if (event.getRecipients() != null && !event.getRecipients().isEmpty()) {
recipientList.addAll(event.getRecipients());
log.info("다중 수신자 처리 - Count: {}", event.getRecipients().size());
}
// 4-3. 수신자가 없으면 경고 종료
if (recipientList.isEmpty()) {
log.warn("수신자가 없습니다 - EventId: {}", event.getEventId());
notification.updateStatus(Notification.NotificationStatus.FAILED);
notificationRepository.save(notification);
return;
}
// 5. 수신자에게 알림 발송
int successCount = 0;
int failureCount = 0;
for (NotificationRequestEvent.RecipientInfo recipient : recipientList) {
try {
// 5-1. 사용자 알림 설정 조회
String userId = recipient.getUserId();
Optional<NotificationSetting> preferences = Optional.empty();
if (userId != null && !userId.isBlank()) {
preferences = settingRepository.findByUserId(userId);
// 5-2. 알림 설정 확인 (userId가 있는 경우만)
if (!canSendNotification(userId, notificationType)) {
log.info("알림 설정에 의해 발송 제외 - UserId: {}", userId);
continue;
}
}
// 5-3. 수신자 엔티티 생성
NotificationRecipient recipientEntity = NotificationRecipient.builder()
.recipientUserId(userId)
.recipientName(recipient.getName())
.recipientEmail(recipient.getEmail())
.status(NotificationRecipient.RecipientStatus.PENDING)
.build();
notification.addRecipient(recipientEntity);
// 5-4. 템플릿 렌더링 (템플릿 ID가 있는 경우)
String htmlContent;
if (event.getTemplateId() != null && event.getTemplateData() != null) {
htmlContent = emailNotifier.loadAndRenderTemplate(
event.getTemplateId(),
event.getTemplateData()
);
} else {
// 기본 메시지 사용
htmlContent = createDefaultHtmlContent(event.getTitle(), event.getMessage());
}
// 5-5. 채널 라우팅 발송
boolean sent = notificationRouter.routeNotification(
eventId,
recipient.getEmail(),
recipient.getName(),
event.getTitle(),
htmlContent,
preferences.orElse(null),
channel
);
// 5-6. 발송 결과 처리
if (sent) {
recipientEntity.markAsSent();
notification.incrementSentCount();
successCount++;
log.info("알림 발송 성공 - Email: {}", recipient.getEmail());
} else {
recipientEntity.markAsFailed("발송 실패");
notification.incrementFailedCount();
failureCount++;
log.error("알림 발송 실패 - Email: {}", recipient.getEmail());
}
} catch (Exception e) {
// 5-7. 예외 처리
NotificationRecipient recipientEntity = notification.getRecipients().stream()
.filter(r -> recipient.getEmail().equals(r.getRecipientEmail()))
.findFirst()
.orElse(null);
if (recipientEntity != null) {
recipientEntity.markAsFailed(e.getMessage());
notification.incrementFailedCount();
}
failureCount++;
log.error("알림 발송 중 오류 발생 - Email: {}", recipient.getEmail(), e);
}
}
// 5. 알림 상태 업데이트
if (successCount > 0 && failureCount == 0) {
notification.updateStatus(Notification.NotificationStatus.SENT);
} else if (successCount > 0 && failureCount > 0) {
notification.updateStatus(Notification.NotificationStatus.PARTIAL);
} else {
notification.updateStatus(Notification.NotificationStatus.FAILED);
}
notificationRepository.save(notification);
log.info("알림 발송 처리 완료 - 성공: {}, 실패: {}", successCount, failureCount);
}
/**
* 알림 유형 문자열을 Enum으로 변환
*
* @param typeString 알림 유형 문자열
* @return 알림 유형 Enum
*/
private Notification.NotificationType parseNotificationType(String typeString) {
try {
return Notification.NotificationType.valueOf(typeString);
} catch (Exception e) {
log.warn("알 수 없는 알림 유형: {} - MEETING_INVITATION으로 기본 설정", typeString);
return Notification.NotificationType.MEETING_INVITATION;
}
}
/**
* 참조 유형 문자열을 Enum으로 변환
*
* @param typeString 참조 유형 문자열
* @return 참조 유형 Enum
*/
private Notification.ReferenceType parseReferenceType(String typeString) {
try {
return Notification.ReferenceType.valueOf(typeString);
} catch (Exception e) {
log.warn("알 수 없는 참조 유형: {} - MEETING으로 기본 설정", typeString);
return Notification.ReferenceType.MEETING;
}
}
/**
* 채널 문자열을 Enum으로 변환
*
* @param channelString 채널 문자열
* @return 채널 Enum
*/
private Notification.NotificationChannel parseChannel(String channelString) {
try {
return Notification.NotificationChannel.valueOf(channelString);
} catch (Exception e) {
log.warn("알 수 없는 채널: {} - EMAIL로 기본 설정", channelString);
return Notification.NotificationChannel.EMAIL;
}
}
/**
* 기본 HTML 콘텐츠 생성
*
* @param title 제목
* @param message 메시지
* @return HTML 본문
*/
private String createDefaultHtmlContent(String title, String message) {
return String.format("""
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>%s</title>
</head>
<body>
<h2>%s</h2>
<p>%s</p>
</body>
</html>
""", title, title, message);
}
/**
* 알림 발송 가능 여부 확인
*
@ -265,7 +516,7 @@ public class NotificationService {
// 설정이 없으면 기본값으로 발송 허용 (이메일, 초대/할당 알림만)
if (settingOpt.isEmpty()) {
return notificationType == Notification.NotificationType.INVITATION
return notificationType == Notification.NotificationType.MEETING_INVITATION
|| notificationType == Notification.NotificationType.TODO_ASSIGNED;
}

View File

@ -24,7 +24,7 @@ spring:
format_sql: true
use_sql_comments: true
hibernate:
ddl-auto: ${JPA_DDL_AUTO:update}
ddl-auto: ${JPA_DDL_AUTO:none}
# Redis Configuration
data:
@ -83,6 +83,7 @@ cors:
# Azure Event Hubs Configuration
azure:
eventhub:
enabled: ${AZURE_EVENTHUB_ENABLED:false}
connection-string: ${EVENTHUB_CONNECTION_STRING:}
name: ${EVENTHUB_NAME:notification-events}
consumer-group: ${AZURE_EVENTHUB_CONSUMER_GROUP:$Default}
@ -90,7 +91,7 @@ azure:
# Azure Blob Storage Configuration (for Event Hub Checkpoint)
storage:
connection-string: ${AZURE_STORAGE_CONNECTION_STRING:}
container-name: ${AZURE_STORAGE_CONTAINER_NAME:eventhub-checkpoints}
container-name: ${AZURE_STORAGE_CONTAINER_NAME:hgzero-checkpoints}
# Notification Configuration
notification: