mirror of
https://github.com/hwanny1128/HGZero.git
synced 2025-12-06 13:46:24 +00:00
Merge pull request #8 from hwanny1128/feat/notification-noti_request
Feat/notification noti request
This commit is contained in:
commit
4baf4ff1d2
File diff suppressed because one or more lines are too long
@ -18,6 +18,9 @@ task printEnv {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Module dependencies
|
||||
implementation project(':notification')
|
||||
|
||||
// WebSocket
|
||||
implementation 'org.springframework.boot:spring-boot-starter-websocket'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-reactor-netty'
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
BIN
meeting/logs/meeting-service.log.2025-10-25.0.gz
Normal file
BIN
meeting/logs/meeting-service.log.2025-10-25.0.gz
Normal file
Binary file not shown.
@ -2,71 +2,18 @@ package com.unicorn.hgzero.meeting.infra.cache;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||
|
||||
/**
|
||||
* Redis 캐시 설정
|
||||
*
|
||||
* RedisConnectionFactory와 RedisTemplate은 RedisConfig에서 정의됨
|
||||
*/
|
||||
@Configuration
|
||||
@Slf4j
|
||||
public class CacheConfig {
|
||||
|
||||
@Value("${spring.data.redis.host:localhost}")
|
||||
private String redisHost;
|
||||
|
||||
@Value("${spring.data.redis.port:6379}")
|
||||
private int redisPort;
|
||||
|
||||
@Value("${spring.data.redis.password:}")
|
||||
private String redisPassword;
|
||||
|
||||
@Value("${spring.data.redis.database:1}")
|
||||
private int database;
|
||||
|
||||
/**
|
||||
* Redis 연결 팩토리
|
||||
*/
|
||||
@Bean
|
||||
public RedisConnectionFactory redisConnectionFactory() {
|
||||
var factory = new LettuceConnectionFactory(redisHost, redisPort);
|
||||
factory.setDatabase(database);
|
||||
|
||||
// 비밀번호가 설정된 경우에만 적용
|
||||
if (redisPassword != null && !redisPassword.isEmpty()) {
|
||||
factory.setPassword(redisPassword);
|
||||
log.info("Redis 연결 설정 - host: {}, port: {}, database: {}, password: ****", redisHost, redisPort, database);
|
||||
} else {
|
||||
log.info("Redis 연결 설정 - host: {}, port: {}, database: {}", redisHost, redisPort, database);
|
||||
}
|
||||
|
||||
return factory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis 템플릿
|
||||
*/
|
||||
@Bean
|
||||
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
|
||||
RedisTemplate<String, String> template = new RedisTemplate<>();
|
||||
template.setConnectionFactory(connectionFactory);
|
||||
|
||||
// String 직렬화 설정
|
||||
template.setKeySerializer(new StringRedisSerializer());
|
||||
template.setValueSerializer(new StringRedisSerializer());
|
||||
template.setHashKeySerializer(new StringRedisSerializer());
|
||||
template.setHashValueSerializer(new StringRedisSerializer());
|
||||
|
||||
template.afterPropertiesSet();
|
||||
log.info("Redis 템플릿 설정 완료");
|
||||
return template;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 직렬화용 ObjectMapper
|
||||
*/
|
||||
|
||||
@ -0,0 +1,129 @@
|
||||
package com.unicorn.hgzero.meeting.infra.config;
|
||||
|
||||
import io.lettuce.core.ClientOptions;
|
||||
import io.lettuce.core.ReadFrom;
|
||||
import io.lettuce.core.SocketOptions;
|
||||
import io.lettuce.core.TimeoutOptions;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Redis 설정
|
||||
* Master-Replica 구조에서 읽기/쓰기 분리 지원
|
||||
*
|
||||
* ReadFrom.MASTER 설정으로 모든 읽기/쓰기를 Master에서 수행
|
||||
* Replica 연결 시 자동으로 Master로 리다이렉트 시도
|
||||
*/
|
||||
@Configuration
|
||||
@Slf4j
|
||||
public class RedisConfig {
|
||||
|
||||
@Value("${spring.data.redis.host}")
|
||||
private String redisHost;
|
||||
|
||||
@Value("${spring.data.redis.port}")
|
||||
private int redisPort;
|
||||
|
||||
@Value("${spring.data.redis.password}")
|
||||
private String redisPassword;
|
||||
|
||||
@Value("${spring.data.redis.database:0}")
|
||||
private int redisDatabase;
|
||||
|
||||
/**
|
||||
* Lettuce 클라이언트 설정
|
||||
* - ReadFrom.MASTER: 모든 읽기/쓰기를 Master에서 수행
|
||||
* - AutoReconnect: 연결 끊김 시 자동 재연결
|
||||
* - DisconnectedBehavior.REJECT_COMMANDS: 연결 끊김 시 명령 거부
|
||||
*/
|
||||
@Bean
|
||||
public LettuceClientConfiguration lettuceClientConfiguration() {
|
||||
|
||||
// 소켓 옵션 설정
|
||||
SocketOptions socketOptions = SocketOptions.builder()
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.keepAlive(true)
|
||||
.build();
|
||||
|
||||
// 타임아웃 옵션 설정
|
||||
TimeoutOptions timeoutOptions = TimeoutOptions.builder()
|
||||
.fixedTimeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
// 클라이언트 옵션 설정
|
||||
ClientOptions clientOptions = ClientOptions.builder()
|
||||
.socketOptions(socketOptions)
|
||||
.timeoutOptions(timeoutOptions)
|
||||
.autoReconnect(true)
|
||||
.disconnectedBehavior(ClientOptions.DisconnectedBehavior.REJECT_COMMANDS)
|
||||
.build();
|
||||
|
||||
// Lettuce 클라이언트 설정
|
||||
// ReadFrom.MASTER: 모든 작업을 Master에서 수행
|
||||
LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder()
|
||||
.clientOptions(clientOptions)
|
||||
.readFrom(ReadFrom.MASTER) // 모든 읽기/쓰기를 Master에서 수행
|
||||
.commandTimeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
log.info("Redis Lettuce Client 설정 완료 - ReadFrom: MASTER (모든 작업 Master에서 수행)");
|
||||
|
||||
return clientConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* LettuceConnectionFactory 설정
|
||||
* Standalone 설정과 Lettuce Client 설정 결합
|
||||
*/
|
||||
@Bean
|
||||
public LettuceConnectionFactory redisConnectionFactory(LettuceClientConfiguration lettuceClientConfiguration) {
|
||||
|
||||
// Standalone 설정
|
||||
RedisStandaloneConfiguration standaloneConfig = new RedisStandaloneConfiguration();
|
||||
standaloneConfig.setHostName(redisHost);
|
||||
standaloneConfig.setPort(redisPort);
|
||||
standaloneConfig.setPassword(redisPassword);
|
||||
standaloneConfig.setDatabase(redisDatabase);
|
||||
|
||||
// LettuceConnectionFactory 생성
|
||||
LettuceConnectionFactory factory = new LettuceConnectionFactory(standaloneConfig, lettuceClientConfiguration);
|
||||
|
||||
log.info("LettuceConnectionFactory 설정 완료 - Host: {}:{}, Database: {}",
|
||||
redisHost, redisPort, redisDatabase);
|
||||
|
||||
return factory;
|
||||
}
|
||||
|
||||
/**
|
||||
* RedisTemplate 설정
|
||||
* String Serializer 사용
|
||||
*/
|
||||
@Bean
|
||||
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
|
||||
RedisTemplate<String, String> template = new RedisTemplate<>();
|
||||
template.setConnectionFactory(connectionFactory);
|
||||
|
||||
// String Serializer 사용
|
||||
StringRedisSerializer stringSerializer = new StringRedisSerializer();
|
||||
template.setKeySerializer(stringSerializer);
|
||||
template.setValueSerializer(stringSerializer);
|
||||
template.setHashKeySerializer(stringSerializer);
|
||||
template.setHashValueSerializer(stringSerializer);
|
||||
|
||||
template.afterPropertiesSet();
|
||||
|
||||
log.info("RedisTemplate 설정 완료");
|
||||
|
||||
return template;
|
||||
}
|
||||
}
|
||||
@ -58,7 +58,7 @@ public class EventHubPublisher implements EventPublisher {
|
||||
|
||||
@Override
|
||||
public void publishNotificationRequest(NotificationRequestEvent event) {
|
||||
publishEvent(event, event.getRecipientId(),
|
||||
publishEvent(event, event.getRecipientEmail(),
|
||||
EventHubConstants.TOPIC_NOTIFICATION,
|
||||
EventHubConstants.EVENT_TYPE_NOTIFICATION_REQUEST);
|
||||
}
|
||||
|
||||
@ -40,7 +40,7 @@ public class NoOpEventPublisher implements EventPublisher {
|
||||
|
||||
@Override
|
||||
public void publishNotificationRequest(NotificationRequestEvent event) {
|
||||
log.debug("[NoOp] Notification request event: {}", event.getRecipientId());
|
||||
log.debug("[NoOp] Notification request event: {}", event.getRecipientEmail());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@ -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" />
|
||||
|
||||
1
notification/build-output.log
Normal file
1
notification/build-output.log
Normal file
@ -0,0 +1 @@
|
||||
(eval):1: no such file or directory: ./gradlew
|
||||
@ -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
BIN
notification/logs/notification-service.log.2025-10-24.0.gz
Normal file
BIN
notification/logs/notification-service.log.2025-10-24.0.gz
Normal file
Binary file not shown.
BIN
notification/logs/notification-service.log.2025-10-25.0.gz
Normal file
BIN
notification/logs/notification-service.log.2025-10-25.0.gz
Normal file
Binary file not shown.
@ -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}")
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -218,7 +218,7 @@ public class Notification {
|
||||
* 알림 유형 Enum
|
||||
*/
|
||||
public enum NotificationType {
|
||||
INVITATION, // 회의 초대
|
||||
MEETING_INVITATION, // 회의 초대
|
||||
TODO_ASSIGNED, // Todo 할당
|
||||
TODO_REMINDER, // Todo 리마인더
|
||||
MEETING_REMINDER, // 회의 리마인더
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user