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 {
|
dependencies {
|
||||||
|
// Module dependencies
|
||||||
|
implementation project(':notification')
|
||||||
|
|
||||||
// WebSocket
|
// WebSocket
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-websocket'
|
implementation 'org.springframework.boot:spring-boot-starter-websocket'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-reactor-netty'
|
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 com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
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 캐시 설정
|
* Redis 캐시 설정
|
||||||
|
*
|
||||||
|
* RedisConnectionFactory와 RedisTemplate은 RedisConfig에서 정의됨
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class CacheConfig {
|
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
|
* 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
|
@Override
|
||||||
public void publishNotificationRequest(NotificationRequestEvent event) {
|
public void publishNotificationRequest(NotificationRequestEvent event) {
|
||||||
publishEvent(event, event.getRecipientId(),
|
publishEvent(event, event.getRecipientEmail(),
|
||||||
EventHubConstants.TOPIC_NOTIFICATION,
|
EventHubConstants.TOPIC_NOTIFICATION,
|
||||||
EventHubConstants.EVENT_TYPE_NOTIFICATION_REQUEST);
|
EventHubConstants.EVENT_TYPE_NOTIFICATION_REQUEST);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,7 +40,7 @@ public class NoOpEventPublisher implements EventPublisher {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void publishNotificationRequest(NotificationRequestEvent event) {
|
public void publishNotificationRequest(NotificationRequestEvent event) {
|
||||||
log.debug("[NoOp] Notification request event: {}", event.getRecipientId());
|
log.debug("[NoOp] Notification request event: {}", event.getRecipientEmail());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@ -39,13 +39,14 @@
|
|||||||
<entry key="MAIL_PASSWORD" value="" />
|
<entry key="MAIL_PASSWORD" value="" />
|
||||||
|
|
||||||
<!-- Azure EventHub Configuration -->
|
<!-- 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_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" />
|
<entry key="AZURE_EVENTHUB_CONSUMER_GROUP" value="$Default" />
|
||||||
|
|
||||||
<!-- Azure Storage Configuration -->
|
<!-- 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_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 -->
|
<!-- Notification Configuration -->
|
||||||
<entry key="NOTIFICATION_FROM_EMAIL" value="noreply@hgzero.com" />
|
<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
|
// Azure Event Hubs Checkpoint Store
|
||||||
implementation "com.azure:azure-messaging-eventhubs-checkpointstore-blob:${azureEventHubsCheckpointVersion}"
|
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 com.azure.storage.blob.BlobContainerClientBuilder;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
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.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
@ -19,6 +20,11 @@ import org.springframework.context.annotation.Configuration;
|
|||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Configuration
|
@Configuration
|
||||||
|
@ConditionalOnProperty(
|
||||||
|
name = "azure.eventhub.enabled",
|
||||||
|
havingValue = "true",
|
||||||
|
matchIfMissing = false
|
||||||
|
)
|
||||||
public class BlobStorageConfig {
|
public class BlobStorageConfig {
|
||||||
|
|
||||||
@Value("${azure.storage.connection-string}")
|
@Value("${azure.storage.connection-string}")
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import com.azure.messaging.eventhubs.models.EventContext;
|
|||||||
import com.azure.storage.blob.BlobContainerAsyncClient;
|
import com.azure.storage.blob.BlobContainerAsyncClient;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
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.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
@ -24,6 +25,11 @@ import java.util.function.Consumer;
|
|||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Configuration
|
@Configuration
|
||||||
|
@ConditionalOnProperty(
|
||||||
|
name = "azure.eventhub.enabled",
|
||||||
|
havingValue = "true",
|
||||||
|
matchIfMissing = false
|
||||||
|
)
|
||||||
public class EventHubConfig {
|
public class EventHubConfig {
|
||||||
|
|
||||||
@Value("${azure.eventhub.connection-string}")
|
@Value("${azure.eventhub.connection-string}")
|
||||||
|
|||||||
@ -218,7 +218,7 @@ public class Notification {
|
|||||||
* 알림 유형 Enum
|
* 알림 유형 Enum
|
||||||
*/
|
*/
|
||||||
public enum NotificationType {
|
public enum NotificationType {
|
||||||
INVITATION, // 회의 초대
|
MEETING_INVITATION, // 회의 초대
|
||||||
TODO_ASSIGNED, // Todo 할당
|
TODO_ASSIGNED, // Todo 할당
|
||||||
TODO_REMINDER, // Todo 리마인더
|
TODO_REMINDER, // Todo 리마인더
|
||||||
MEETING_REMINDER, // 회의 리마인더
|
MEETING_REMINDER, // 회의 리마인더
|
||||||
|
|||||||
@ -176,7 +176,7 @@ public class NotificationSetting {
|
|||||||
*/
|
*/
|
||||||
public boolean isNotificationTypeEnabled(Notification.NotificationType notificationType) {
|
public boolean isNotificationTypeEnabled(Notification.NotificationType notificationType) {
|
||||||
return switch (notificationType) {
|
return switch (notificationType) {
|
||||||
case INVITATION -> invitationEnabled;
|
case MEETING_INVITATION -> invitationEnabled;
|
||||||
case TODO_ASSIGNED -> todoAssignedEnabled;
|
case TODO_ASSIGNED -> todoAssignedEnabled;
|
||||||
case TODO_REMINDER -> todoReminderEnabled;
|
case TODO_REMINDER -> todoReminderEnabled;
|
||||||
case MEETING_REMINDER -> meetingReminderEnabled;
|
case MEETING_REMINDER -> meetingReminderEnabled;
|
||||||
|
|||||||
@ -3,10 +3,12 @@ package com.unicorn.hgzero.notification.event;
|
|||||||
import com.azure.messaging.eventhubs.models.EventContext;
|
import com.azure.messaging.eventhubs.models.EventContext;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.unicorn.hgzero.notification.event.event.MeetingCreatedEvent;
|
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.event.event.TodoAssignedEvent;
|
||||||
import com.unicorn.hgzero.notification.service.NotificationService;
|
import com.unicorn.hgzero.notification.service.NotificationService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
import org.springframework.retry.support.RetryTemplate;
|
import org.springframework.retry.support.RetryTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
@ -26,6 +28,11 @@ import java.util.function.Consumer;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@ConditionalOnProperty(
|
||||||
|
name = "azure.eventhub.enabled",
|
||||||
|
havingValue = "true",
|
||||||
|
matchIfMissing = false
|
||||||
|
)
|
||||||
public class EventHandler implements Consumer<EventContext> {
|
public class EventHandler implements Consumer<EventContext> {
|
||||||
|
|
||||||
private final NotificationService notificationService;
|
private final NotificationService notificationService;
|
||||||
@ -46,7 +53,7 @@ public class EventHandler implements Consumer<EventContext> {
|
|||||||
// 이벤트 속성 추출
|
// 이벤트 속성 추출
|
||||||
Map<String, Object> properties = eventData.getProperties();
|
Map<String, Object> properties = eventData.getProperties();
|
||||||
String topic = (String) properties.get("topic");
|
String topic = (String) properties.get("topic");
|
||||||
String eventType = (String) properties.get("eventType");
|
String eventType = (String) properties.get("type");
|
||||||
|
|
||||||
log.info("이벤트 수신 - Topic: {}, EventType: {}", topic, eventType);
|
log.info("이벤트 수신 - Topic: {}, EventType: {}", topic, eventType);
|
||||||
|
|
||||||
@ -58,6 +65,8 @@ public class EventHandler implements Consumer<EventContext> {
|
|||||||
handleMeetingEvent(eventType, eventBody);
|
handleMeetingEvent(eventType, eventBody);
|
||||||
} else if ("todo".equals(topic)) {
|
} else if ("todo".equals(topic)) {
|
||||||
handleTodoEvent(eventType, eventBody);
|
handleTodoEvent(eventType, eventBody);
|
||||||
|
} else if ("notification".equals(topic)) {
|
||||||
|
handleNotificationEvent(eventType, eventBody);
|
||||||
} else {
|
} else {
|
||||||
log.warn("알 수 없는 토픽: {}", topic);
|
log.warn("알 수 없는 토픽: {}", topic);
|
||||||
}
|
}
|
||||||
@ -174,4 +183,47 @@ public class EventHandler implements Consumer<EventContext> {
|
|||||||
return null;
|
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 jakarta.annotation.PreDestroy;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
import org.springframework.retry.annotation.Backoff;
|
import org.springframework.retry.annotation.Backoff;
|
||||||
import org.springframework.retry.annotation.Retryable;
|
import org.springframework.retry.annotation.Retryable;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@ -22,6 +23,11 @@ import org.springframework.stereotype.Service;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@ConditionalOnProperty(
|
||||||
|
name = "azure.eventhub.enabled",
|
||||||
|
havingValue = "true",
|
||||||
|
matchIfMissing = false
|
||||||
|
)
|
||||||
public class EventProcessorService {
|
public class EventProcessorService {
|
||||||
|
|
||||||
private final EventProcessorClient eventProcessorClient;
|
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.NotificationRecipient;
|
||||||
import com.unicorn.hgzero.notification.domain.NotificationSetting;
|
import com.unicorn.hgzero.notification.domain.NotificationSetting;
|
||||||
import com.unicorn.hgzero.notification.event.event.MeetingCreatedEvent;
|
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.event.event.TodoAssignedEvent;
|
||||||
import com.unicorn.hgzero.notification.repository.NotificationRecipientRepository;
|
import com.unicorn.hgzero.notification.repository.NotificationRecipientRepository;
|
||||||
import com.unicorn.hgzero.notification.repository.NotificationRepository;
|
import com.unicorn.hgzero.notification.repository.NotificationRepository;
|
||||||
@ -41,6 +42,8 @@ public class NotificationService {
|
|||||||
private final NotificationSettingRepository settingRepository;
|
private final NotificationSettingRepository settingRepository;
|
||||||
private final EmailTemplateService templateService;
|
private final EmailTemplateService templateService;
|
||||||
private final EmailClient emailClient;
|
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");
|
private static final DateTimeFormatter DATETIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
|
||||||
|
|
||||||
@ -64,7 +67,7 @@ public class NotificationService {
|
|||||||
.eventId(event.getEventId())
|
.eventId(event.getEventId())
|
||||||
.referenceId(event.getMeetingId())
|
.referenceId(event.getMeetingId())
|
||||||
.referenceType(Notification.ReferenceType.MEETING)
|
.referenceType(Notification.ReferenceType.MEETING)
|
||||||
.notificationType(Notification.NotificationType.INVITATION)
|
.notificationType(Notification.NotificationType.MEETING_INVITATION)
|
||||||
.title("회의 초대: " + event.getTitle())
|
.title("회의 초대: " + event.getTitle())
|
||||||
.message(event.getDescription())
|
.message(event.getDescription())
|
||||||
.status(Notification.NotificationStatus.PROCESSING)
|
.status(Notification.NotificationStatus.PROCESSING)
|
||||||
@ -80,7 +83,7 @@ public class NotificationService {
|
|||||||
for (MeetingCreatedEvent.ParticipantInfo participant : event.getParticipants()) {
|
for (MeetingCreatedEvent.ParticipantInfo participant : event.getParticipants()) {
|
||||||
try {
|
try {
|
||||||
// 3-1. 알림 설정 확인
|
// 3-1. 알림 설정 확인
|
||||||
if (!canSendNotification(participant.getUserId(), Notification.NotificationType.INVITATION)) {
|
if (!canSendNotification(participant.getUserId(), Notification.NotificationType.MEETING_INVITATION)) {
|
||||||
log.info("알림 설정에 의해 발송 제외 - UserId: {}", participant.getUserId());
|
log.info("알림 설정에 의해 발송 제외 - UserId: {}", participant.getUserId());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -251,6 +254,254 @@ public class NotificationService {
|
|||||||
log.info("Todo 할당 알림 처리 완료");
|
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()) {
|
if (settingOpt.isEmpty()) {
|
||||||
return notificationType == Notification.NotificationType.INVITATION
|
return notificationType == Notification.NotificationType.MEETING_INVITATION
|
||||||
|| notificationType == Notification.NotificationType.TODO_ASSIGNED;
|
|| notificationType == Notification.NotificationType.TODO_ASSIGNED;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -24,7 +24,7 @@ spring:
|
|||||||
format_sql: true
|
format_sql: true
|
||||||
use_sql_comments: true
|
use_sql_comments: true
|
||||||
hibernate:
|
hibernate:
|
||||||
ddl-auto: ${JPA_DDL_AUTO:update}
|
ddl-auto: ${JPA_DDL_AUTO:none}
|
||||||
|
|
||||||
# Redis Configuration
|
# Redis Configuration
|
||||||
data:
|
data:
|
||||||
@ -83,6 +83,7 @@ cors:
|
|||||||
# Azure Event Hubs Configuration
|
# Azure Event Hubs Configuration
|
||||||
azure:
|
azure:
|
||||||
eventhub:
|
eventhub:
|
||||||
|
enabled: ${AZURE_EVENTHUB_ENABLED:false}
|
||||||
connection-string: ${EVENTHUB_CONNECTION_STRING:}
|
connection-string: ${EVENTHUB_CONNECTION_STRING:}
|
||||||
name: ${EVENTHUB_NAME:notification-events}
|
name: ${EVENTHUB_NAME:notification-events}
|
||||||
consumer-group: ${AZURE_EVENTHUB_CONSUMER_GROUP:$Default}
|
consumer-group: ${AZURE_EVENTHUB_CONSUMER_GROUP:$Default}
|
||||||
@ -90,7 +91,7 @@ azure:
|
|||||||
# Azure Blob Storage Configuration (for Event Hub Checkpoint)
|
# Azure Blob Storage Configuration (for Event Hub Checkpoint)
|
||||||
storage:
|
storage:
|
||||||
connection-string: ${AZURE_STORAGE_CONNECTION_STRING:}
|
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 Configuration
|
||||||
notification:
|
notification:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user