add notification

This commit is contained in:
djeon
2025-10-23 15:23:18 +09:00
parent 14bbe653ce
commit 0e4c4a90da
37 changed files with 4677 additions and 0 deletions
@@ -0,0 +1,22 @@
package com.unicorn.hgzero.notification;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Notification Service Application
*
* 회의 초대 및 Todo 할당 알림 발송 서비스
* Azure Event Hubs를 통한 이벤트 기반 알림 처리
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@SpringBootApplication
public class NotificationApplication {
public static void main(String[] args) {
SpringApplication.run(NotificationApplication.class, args);
}
}
@@ -0,0 +1,48 @@
package com.unicorn.hgzero.notification.config;
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.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Azure Blob Storage 설정
*
* Event Hub Checkpoint 저장용 Blob Storage 연결 구성
* EventProcessorClient의 체크포인트 저장소로 사용
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Slf4j
@Configuration
public class BlobStorageConfig {
@Value("${azure.storage.connection-string}")
private String storageConnectionString;
@Value("${azure.storage.container-name}")
private String containerName;
/**
* Blob Container Async Client Bean 생성
*
* @return Blob Container Async Client
*/
@Bean
public BlobContainerAsyncClient blobContainerAsyncClient() {
log.info("BlobContainerAsyncClient 생성 중 - Container: {}", containerName);
BlobContainerAsyncClient client = new BlobContainerClientBuilder()
.connectionString(storageConnectionString)
.containerName(containerName)
.buildAsyncClient();
log.info("BlobContainerAsyncClient 생성 완료");
return client;
}
}
@@ -0,0 +1,72 @@
package com.unicorn.hgzero.notification.config;
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.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import java.util.Properties;
/**
* 이메일 발송 설정
*
* JavaMailSender 구성 및 SMTP 설정
* Gmail SMTP 또는 다른 SMTP 서버 사용
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Slf4j
@Configuration
public class EmailConfig {
@Value("${spring.mail.host}")
private String host;
@Value("${spring.mail.port}")
private int port;
@Value("${spring.mail.username}")
private String username;
@Value("${spring.mail.password}")
private String password;
@Value("${spring.mail.properties.mail.smtp.auth:true}")
private String smtpAuth;
@Value("${spring.mail.properties.mail.smtp.starttls.enable:true}")
private String starttlsEnable;
/**
* JavaMailSender Bean 생성
*
* @return JavaMailSender
*/
@Bean
public JavaMailSender javaMailSender() {
log.info("JavaMailSender 구성 중 - Host: {}, Port: {}", host, port);
JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
// SMTP 서버 설정
mailSender.setHost(host);
mailSender.setPort(port);
mailSender.setUsername(username);
mailSender.setPassword(password);
// SMTP 속성 설정
Properties props = mailSender.getJavaMailProperties();
props.put("mail.transport.protocol", "smtp");
props.put("mail.smtp.auth", smtpAuth);
props.put("mail.smtp.starttls.enable", starttlsEnable);
props.put("mail.debug", "false"); // 디버그 모드 (필요 시 true)
log.info("JavaMailSender 구성 완료");
return mailSender;
}
}
@@ -0,0 +1,74 @@
package com.unicorn.hgzero.notification.config;
import com.azure.messaging.eventhubs.EventProcessorClient;
import com.azure.messaging.eventhubs.EventProcessorClientBuilder;
import com.azure.messaging.eventhubs.checkpointstore.blob.BlobCheckpointStore;
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.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.function.Consumer;
/**
* Azure Event Hubs 설정
*
* EventProcessorClient 구성 및 이벤트 처리 설정
* Blob Storage 기반 Checkpoint 관리
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Slf4j
@Configuration
public class EventHubConfig {
@Value("${azure.eventhub.connection-string}")
private String eventHubConnectionString;
@Value("${azure.eventhub.name}")
private String eventHubName;
@Value("${azure.eventhub.consumer-group}")
private String consumerGroup;
/**
* EventProcessorClient Bean 생성
*
* @param blobContainerAsyncClient Blob Storage 클라이언트
* @param eventHandler 이벤트 핸들러
* @return EventProcessorClient
*/
@Bean
public EventProcessorClient eventProcessorClient(
BlobContainerAsyncClient blobContainerAsyncClient,
Consumer<EventContext> eventHandler
) {
log.info("EventProcessorClient 생성 중 - EventHub: {}, ConsumerGroup: {}",
eventHubName, consumerGroup);
// Blob Checkpoint Store 생성
BlobCheckpointStore checkpointStore = new BlobCheckpointStore(blobContainerAsyncClient);
// EventProcessorClient 빌더 구성
EventProcessorClient eventProcessorClient = new EventProcessorClientBuilder()
.connectionString(eventHubConnectionString, eventHubName)
.consumerGroup(consumerGroup)
.checkpointStore(checkpointStore)
.processEvent(eventHandler)
.processError(errorContext -> {
log.error("이벤트 처리 오류 발생 - PartitionId: {}, ErrorType: {}",
errorContext.getPartitionContext().getPartitionId(),
errorContext.getThrowable().getClass().getSimpleName(),
errorContext.getThrowable());
})
.buildEventProcessorClient();
log.info("EventProcessorClient 생성 완료");
return eventProcessorClient;
}
}
@@ -0,0 +1,56 @@
package com.unicorn.hgzero.notification.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.retry.annotation.EnableRetry;
import org.springframework.retry.backoff.ExponentialBackOffPolicy;
import org.springframework.retry.policy.SimpleRetryPolicy;
import org.springframework.retry.support.RetryTemplate;
/**
* 재시도 정책 설정
*
* Spring Retry를 사용한 재시도 메커니즘 구성
* 이메일 발송 실패 시 Exponential Backoff 전략 적용
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Slf4j
@Configuration
@EnableRetry
public class RetryConfig {
/**
* RetryTemplate Bean 생성
*
* 재시도 정책:
* - 최대 3번 재시도
* - Exponential Backoff: 초기 5분, 최대 30분, 배수 2.0
*
* @return 구성된 RetryTemplate
*/
@Bean
public RetryTemplate retryTemplate() {
RetryTemplate retryTemplate = new RetryTemplate();
// 재시도 정책: 최대 3번
SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy();
retryPolicy.setMaxAttempts(3);
// Backoff 정책: Exponential Backoff
ExponentialBackOffPolicy backOffPolicy = new ExponentialBackOffPolicy();
backOffPolicy.setInitialInterval(300000); // 5분
backOffPolicy.setMaxInterval(1800000); // 30분
backOffPolicy.setMultiplier(2.0); // 배수
retryTemplate.setRetryPolicy(retryPolicy);
retryTemplate.setBackOffPolicy(backOffPolicy);
log.info("RetryTemplate 생성 완료 - MaxAttempts: 3, InitialInterval: 5분, MaxInterval: 30분, Multiplier: 2.0");
return retryTemplate;
}
}
@@ -0,0 +1,113 @@
package com.unicorn.hgzero.notification.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
/**
* Spring Security 설정
*
* JWT 기반 인증, CORS 설정
* Stateless 세션 관리
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Slf4j
@Configuration
@EnableWebSecurity
public class SecurityConfig {
/**
* Security Filter Chain 구성
*
* @param http HttpSecurity
* @return SecurityFilterChain
* @throws Exception 설정 오류 시
*/
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
log.info("SecurityFilterChain 구성 중...");
http
// CSRF 비활성화 (REST API이므로)
.csrf(AbstractHttpConfigurer::disable)
// CORS 설정 활성화
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// 세션 관리: Stateless (JWT 사용)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// 요청 인가 규칙
.authorizeHttpRequests(auth -> auth
// Swagger UI 및 API 문서는 인증 없이 접근 가능
.requestMatchers(
"/swagger-ui/**",
"/v3/api-docs/**",
"/swagger-resources/**",
"/webjars/**"
).permitAll()
// Actuator Health Check는 인증 없이 접근 가능
.requestMatchers("/actuator/health").permitAll()
// 그 외 모든 요청은 인증 필요
.anyRequest().authenticated()
);
log.info("SecurityFilterChain 구성 완료");
return http.build();
}
/**
* CORS 설정
*
* @return CORS Configuration Source
*/
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// 허용할 Origin (개발 환경)
configuration.setAllowedOrigins(List.of(
"http://localhost:3000",
"http://localhost:8080"
));
// 허용할 HTTP 메서드
configuration.setAllowedMethods(List.of(
"GET", "POST", "PUT", "DELETE", "OPTIONS"
));
// 허용할 헤더
configuration.setAllowedHeaders(List.of("*"));
// 인증 정보 포함 허용
configuration.setAllowCredentials(true);
// 최대 캐시 시간 (초)
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
log.info("CORS 설정 완료");
return source;
}
}
@@ -0,0 +1,65 @@
package com.unicorn.hgzero.notification.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.License;
import io.swagger.v3.oas.models.servers.Server;
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 java.util.List;
/**
* Swagger/OpenAPI 설정
*
* API 문서 자동 생성 및 Swagger UI 설정
* SpringDoc OpenAPI 3.0 사용
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Slf4j
@Configuration
public class SwaggerConfig {
@Value("${spring.application.name:notification}")
private String applicationName;
/**
* OpenAPI 설정 Bean 생성
*
* @return OpenAPI 설정
*/
@Bean
public OpenAPI openAPI() {
log.info("OpenAPI 설정 생성 중...");
OpenAPI openAPI = new OpenAPI()
.info(new Info()
.title("HGZero Notification Service API")
.description("회의 초대 및 Todo 할당 알림 발송 서비스 API")
.version("1.0.0")
.contact(new Contact()
.name("Backend Team")
.email("backend@hgzero.com"))
.license(new License()
.name("Apache 2.0")
.url("https://www.apache.org/licenses/LICENSE-2.0.html")))
.servers(List.of(
new Server()
.url("http://localhost:8080")
.description("Local Development Server"),
new Server()
.url("https://api.hgzero.com")
.description("Production Server")
));
log.info("OpenAPI 설정 생성 완료");
return openAPI;
}
}
@@ -0,0 +1,183 @@
package com.unicorn.hgzero.notification.controller;
import com.unicorn.hgzero.notification.domain.Notification;
import com.unicorn.hgzero.notification.dto.response.NotificationListResponse;
import com.unicorn.hgzero.notification.dto.response.NotificationResponse;
import com.unicorn.hgzero.notification.repository.NotificationRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
/**
* 알림 조회 API Controller
*
* 알림 이력 조회 API 제공
* 알림 목록 조회, 특정 알림 상세 조회 지원
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Slf4j
@RestController
@RequestMapping("/notifications")
@RequiredArgsConstructor
@Tag(name = "Notification", description = "알림 조회 API")
public class NotificationController {
private final NotificationRepository notificationRepository;
/**
* 알림 목록 조회
*
* @param referenceType 참조 유형 (MEETING, TODO) - optional
* @param notificationType 알림 유형 (INVITATION, TODO_ASSIGNED 등) - optional
* @param status 알림 상태 (PENDING, SENT, FAILED 등) - optional
* @param startDate 시작 일시 - optional
* @param endDate 종료 일시 - optional
* @return 알림 목록
*/
@Operation(summary = "알림 목록 조회", description = "다양한 조건으로 알림 목록을 조회합니다")
@ApiResponses(value = {
@ApiResponse(
responseCode = "200",
description = "조회 성공",
content = @Content(schema = @Schema(implementation = NotificationListResponse.class))
),
@ApiResponse(responseCode = "400", description = "잘못된 요청"),
@ApiResponse(responseCode = "500", description = "서버 오류")
})
@GetMapping
public ResponseEntity<List<NotificationListResponse>> getNotifications(
@Parameter(description = "참조 유형 (MEETING, TODO)")
@RequestParam(required = false) Notification.ReferenceType referenceType,
@Parameter(description = "알림 유형 (INVITATION, TODO_ASSIGNED, TODO_REMINDER 등)")
@RequestParam(required = false) Notification.NotificationType notificationType,
@Parameter(description = "알림 상태 (PENDING, PROCESSING, SENT, FAILED, PARTIAL)")
@RequestParam(required = false) Notification.NotificationStatus status,
@Parameter(description = "시작 일시 (yyyy-MM-dd'T'HH:mm:ss)")
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate,
@Parameter(description = "종료 일시 (yyyy-MM-dd'T'HH:mm:ss)")
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endDate
) {
log.info("알림 목록 조회 - ReferenceType: {}, NotificationType: {}, Status: {}, StartDate: {}, EndDate: {}",
referenceType, notificationType, status, startDate, endDate);
List<Notification> notifications;
// 조건별 조회
if (notificationType != null) {
notifications = notificationRepository.findByNotificationType(notificationType);
} else if (status != null) {
notifications = notificationRepository.findByStatusIn(List.of(status));
} else if (startDate != null && endDate != null) {
notifications = notificationRepository.findByCreatedAtBetween(startDate, endDate);
} else {
// 기본: 모든 알림 조회 (최근 순)
notifications = notificationRepository.findAll();
}
List<NotificationListResponse> response = notifications.stream()
.map(NotificationListResponse::from)
.collect(Collectors.toList());
log.info("알림 목록 조회 완료 - 조회 건수: {}", response.size());
return ResponseEntity.ok(response);
}
/**
* 특정 알림 상세 조회
*
* @param notificationId 알림 ID
* @return 알림 상세 정보 (수신자 목록 포함)
*/
@Operation(summary = "알림 상세 조회", description = "특정 알림의 상세 정보를 조회합니다 (수신자 목록 포함)")
@ApiResponses(value = {
@ApiResponse(
responseCode = "200",
description = "조회 성공",
content = @Content(schema = @Schema(implementation = NotificationResponse.class))
),
@ApiResponse(responseCode = "404", description = "알림을 찾을 수 없음"),
@ApiResponse(responseCode = "500", description = "서버 오류")
})
@GetMapping("/{notificationId}")
public ResponseEntity<NotificationResponse> getNotification(
@Parameter(description = "알림 ID", required = true)
@PathVariable String notificationId
) {
log.info("알림 상세 조회 - NotificationId: {}", notificationId);
Notification notification = notificationRepository.findById(notificationId)
.orElseThrow(() -> {
log.error("알림을 찾을 수 없음 - NotificationId: {}", notificationId);
return new RuntimeException("알림을 찾을 수 없습니다: " + notificationId);
});
NotificationResponse response = NotificationResponse.from(notification);
log.info("알림 상세 조회 완료 - NotificationId: {}", notificationId);
return ResponseEntity.ok(response);
}
/**
* 알림 상태별 통계 조회 (모니터링용)
*
* @param startDate 시작 일시
* @param endDate 종료 일시
* @return 상태별 알림 건수
*/
@Operation(summary = "알림 통계 조회", description = "기간별 알림 상태 통계를 조회합니다")
@GetMapping("/statistics")
public ResponseEntity<Object> getStatistics(
@Parameter(description = "시작 일시")
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate,
@Parameter(description = "종료 일시")
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endDate
) {
log.info("알림 통계 조회 - StartDate: {}, EndDate: {}", startDate, endDate);
long sentCount = notificationRepository.countByStatusAndCreatedAtBetween(
Notification.NotificationStatus.SENT, startDate, endDate
);
long failedCount = notificationRepository.countByStatusAndCreatedAtBetween(
Notification.NotificationStatus.FAILED, startDate, endDate
);
long partialCount = notificationRepository.countByStatusAndCreatedAtBetween(
Notification.NotificationStatus.PARTIAL, startDate, endDate
);
var statistics = new Object() {
public final long sent = sentCount;
public final long failed = failedCount;
public final long partial = partialCount;
public final long total = sentCount + failedCount + partialCount;
};
log.info("알림 통계 조회 완료 - Sent: {}, Failed: {}, Partial: {}, Total: {}",
sentCount, failedCount, partialCount, statistics.total);
return ResponseEntity.ok(statistics);
}
}
@@ -0,0 +1,137 @@
package com.unicorn.hgzero.notification.controller;
import com.unicorn.hgzero.notification.domain.NotificationSetting;
import com.unicorn.hgzero.notification.dto.request.UpdateSettingsRequest;
import com.unicorn.hgzero.notification.dto.response.SettingsResponse;
import com.unicorn.hgzero.notification.repository.NotificationSettingRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
/**
* 알림 설정 API Controller
*
* 사용자별 알림 설정 조회 및 업데이트 API 제공
* 채널 활성화, 알림 유형, 방해 금지 시간대 관리
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Slf4j
@RestController
@RequestMapping("/notifications/settings")
@RequiredArgsConstructor
@Tag(name = "Notification Settings", description = "알림 설정 API")
public class NotificationSettingsController {
private final NotificationSettingRepository settingRepository;
/**
* 알림 설정 조회
*
* @param userId 사용자 ID
* @return 알림 설정 정보
*/
@Operation(summary = "알림 설정 조회", description = "사용자의 알림 설정을 조회합니다")
@ApiResponses(value = {
@ApiResponse(
responseCode = "200",
description = "조회 성공",
content = @Content(schema = @Schema(implementation = SettingsResponse.class))
),
@ApiResponse(responseCode = "404", description = "설정을 찾을 수 없음"),
@ApiResponse(responseCode = "500", description = "서버 오류")
})
@GetMapping
public ResponseEntity<SettingsResponse> getSettings(
@Parameter(description = "사용자 ID", required = true)
@RequestParam String userId
) {
log.info("알림 설정 조회 - UserId: {}", userId);
NotificationSetting setting = settingRepository.findByUserId(userId)
.orElseGet(() -> {
// 설정이 없으면 기본 설정 생성
log.info("알림 설정이 없어 기본 설정 생성 - UserId: {}", userId);
NotificationSetting defaultSetting = NotificationSetting.builder()
.userId(userId)
.build();
return settingRepository.save(defaultSetting);
});
SettingsResponse response = SettingsResponse.from(setting);
log.info("알림 설정 조회 완료 - UserId: {}", userId);
return ResponseEntity.ok(response);
}
/**
* 알림 설정 업데이트
*
* @param userId 사용자 ID
* @param request 업데이트할 설정 정보
* @return 업데이트된 알림 설정 정보
*/
@Operation(summary = "알림 설정 업데이트", description = "사용자의 알림 설정을 업데이트합니다")
@ApiResponses(value = {
@ApiResponse(
responseCode = "200",
description = "업데이트 성공",
content = @Content(schema = @Schema(implementation = SettingsResponse.class))
),
@ApiResponse(responseCode = "400", description = "잘못된 요청"),
@ApiResponse(responseCode = "500", description = "서버 오류")
})
@PutMapping
public ResponseEntity<SettingsResponse> updateSettings(
@Parameter(description = "사용자 ID", required = true)
@RequestParam String userId,
@Parameter(description = "업데이트할 설정 정보", required = true)
@Valid @RequestBody UpdateSettingsRequest request
) {
log.info("알림 설정 업데이트 - UserId: {}", userId);
NotificationSetting setting = settingRepository.findByUserId(userId)
.orElseGet(() -> {
// 설정이 없으면 새로 생성
log.info("알림 설정이 없어 신규 생성 - UserId: {}", userId);
NotificationSetting newSetting = NotificationSetting.builder()
.userId(userId)
.build();
return newSetting;
});
// 설정 업데이트
setting.setEmailEnabled(request.getEmailEnabled());
setting.setSmsEnabled(request.getSmsEnabled());
setting.setPushEnabled(request.getPushEnabled());
setting.setInvitationEnabled(request.getInvitationEnabled());
setting.setTodoAssignedEnabled(request.getTodoAssignedEnabled());
setting.setTodoReminderEnabled(request.getTodoReminderEnabled());
setting.setMeetingReminderEnabled(request.getMeetingReminderEnabled());
setting.setMinutesUpdatedEnabled(request.getMinutesUpdatedEnabled());
setting.setTodoCompletedEnabled(request.getTodoCompletedEnabled());
setting.setDndEnabled(request.getDndEnabled());
setting.setDndStartTime(request.getDndStartTime());
setting.setDndEndTime(request.getDndEndTime());
NotificationSetting savedSetting = settingRepository.save(setting);
SettingsResponse response = SettingsResponse.from(savedSetting);
log.info("알림 설정 업데이트 완료 - UserId: {}", userId);
return ResponseEntity.ok(response);
}
}
@@ -0,0 +1,248 @@
package com.unicorn.hgzero.notification.domain;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.Comment;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* 알림 Entity
*
* 알림 발송 이력을 관리하는 엔티티
* 회의 초대, Todo 할당 등 다양한 알림 유형을 지원
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Entity
@Table(name = "notifications", indexes = {
@Index(name = "idx_notification_reference", columnList = "reference_id, reference_type"),
@Index(name = "idx_notification_created_at", columnList = "created_at")
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Comment("알림 정보")
public class Notification {
/**
* 알림 고유 ID (UUID)
*/
@Id
@Column(name = "notification_id", length = 36, nullable = false)
@Comment("알림 고유 ID")
private String notificationId;
/**
* 이벤트 고유 ID (중복 발송 방지용)
*/
@Column(name = "event_id", length = 100, nullable = false, unique = true)
@Comment("이벤트 고유 ID (Idempotency)")
private String eventId;
/**
* 참조 대상 ID (meetingId 또는 todoId)
*/
@Column(name = "reference_id", length = 36, nullable = false)
@Comment("참조 대상 ID (회의 또는 Todo)")
private String referenceId;
/**
* 참조 유형 (MEETING, TODO)
*/
@Enumerated(EnumType.STRING)
@Column(name = "reference_type", length = 20, nullable = false)
@Comment("참조 유형")
private ReferenceType referenceType;
/**
* 알림 유형 (INVITATION, TODO_ASSIGNED, REMINDER)
*/
@Enumerated(EnumType.STRING)
@Column(name = "notification_type", length = 30, nullable = false)
@Comment("알림 유형")
private NotificationType notificationType;
/**
* 알림 제목
*/
@Column(name = "title", length = 500, nullable = false)
@Comment("알림 제목")
private String title;
/**
* 알림 내용 (간단한 요약)
*/
@Column(name = "message", columnDefinition = "TEXT")
@Comment("알림 내용")
private String message;
/**
* 알림 상태 (PENDING, PROCESSING, SENT, FAILED, PARTIAL)
*/
@Enumerated(EnumType.STRING)
@Column(name = "status", length = 20, nullable = false)
@Comment("알림 상태")
private NotificationStatus status;
/**
* 발송 채널 (EMAIL, SMS, PUSH)
*/
@Enumerated(EnumType.STRING)
@Column(name = "channel", length = 20, nullable = false)
@Comment("발송 채널")
private NotificationChannel channel;
/**
* 발송 완료 건수
*/
@Column(name = "sent_count", nullable = false)
@Comment("발송 완료 건수")
@Builder.Default
private Integer sentCount = 0;
/**
* 발송 실패 건수
*/
@Column(name = "failed_count", nullable = false)
@Comment("발송 실패 건수")
@Builder.Default
private Integer failedCount = 0;
/**
* 생성 일시
*/
@Column(name = "created_at", nullable = false, updatable = false)
@Comment("생성 일시")
private LocalDateTime createdAt;
/**
* 발송 완료 일시
*/
@Column(name = "sent_at")
@Comment("발송 완료 일시")
private LocalDateTime sentAt;
/**
* 수신자 목록
*
* Cascade: ALL - 알림 삭제 시 수신자 정보도 함께 삭제
* Orphan Removal: true - 수신자 목록에서 제거 시 DB에서도 삭제
*/
@OneToMany(mappedBy = "notification", cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default
private List<NotificationRecipient> recipients = new ArrayList<>();
/**
* 생성 전 초기화
*
* - notificationId: UUID 생성
* - createdAt: 현재 시각
* - status: PENDING
*/
@PrePersist
protected void onCreate() {
if (this.notificationId == null) {
this.notificationId = UUID.randomUUID().toString();
}
if (this.createdAt == null) {
this.createdAt = LocalDateTime.now();
}
if (this.status == null) {
this.status = NotificationStatus.PENDING;
}
}
/**
* 수신자 추가 헬퍼 메서드
*
* @param recipient 수신자 정보
*/
public void addRecipient(NotificationRecipient recipient) {
recipients.add(recipient);
recipient.setNotification(this);
}
/**
* 수신자 제거 헬퍼 메서드
*
* @param recipient 수신자 정보
*/
public void removeRecipient(NotificationRecipient recipient) {
recipients.remove(recipient);
recipient.setNotification(null);
}
/**
* 알림 상태 업데이트
*
* @param status 새로운 상태
*/
public void updateStatus(NotificationStatus status) {
this.status = status;
if (status == NotificationStatus.SENT || status == NotificationStatus.PARTIAL) {
this.sentAt = LocalDateTime.now();
}
}
/**
* 발송 건수 증가
*/
public void incrementSentCount() {
this.sentCount++;
}
/**
* 실패 건수 증가
*/
public void incrementFailedCount() {
this.failedCount++;
}
/**
* 참조 유형 Enum
*/
public enum ReferenceType {
MEETING, // 회의
TODO // Todo
}
/**
* 알림 유형 Enum
*/
public enum NotificationType {
INVITATION, // 회의 초대
TODO_ASSIGNED, // Todo 할당
TODO_REMINDER, // Todo 리마인더
MEETING_REMINDER, // 회의 리마인더
MINUTES_UPDATED, // 회의록 수정
TODO_COMPLETED // Todo 완료
}
/**
* 알림 상태 Enum
*/
public enum NotificationStatus {
PENDING, // 대기 중
PROCESSING, // 처리 중
SENT, // 발송 완료
FAILED, // 발송 실패
PARTIAL // 부분 성공
}
/**
* 발송 채널 Enum
*/
public enum NotificationChannel {
EMAIL, // 이메일
SMS, // SMS
PUSH // Push 알림
}
}
@@ -0,0 +1,225 @@
package com.unicorn.hgzero.notification.domain;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.Comment;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* 알림 수신자 Entity
*
* 알림별 수신자 정보와 발송 상태를 관리
* 수신자별로 발송 성공/실패 상태를 추적
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Entity
@Table(name = "notification_recipients", indexes = {
@Index(name = "idx_recipient_notification", columnList = "notification_id"),
@Index(name = "idx_recipient_email", columnList = "recipient_email"),
@Index(name = "idx_recipient_status", columnList = "status")
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Comment("알림 수신자 정보")
public class NotificationRecipient {
/**
* 수신자 고유 ID (UUID)
*/
@Id
@Column(name = "recipient_id", length = 36, nullable = false)
@Comment("수신자 고유 ID")
private String recipientId;
/**
* 알림 (외래키)
*/
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "notification_id", nullable = false)
@Comment("알림 ID")
private Notification notification;
/**
* 수신자 사용자 ID
*/
@Column(name = "recipient_user_id", length = 100, nullable = false)
@Comment("수신자 사용자 ID")
private String recipientUserId;
/**
* 수신자 이름
*/
@Column(name = "recipient_name", length = 200, nullable = false)
@Comment("수신자 이름")
private String recipientName;
/**
* 수신자 이메일
*/
@Column(name = "recipient_email", length = 320, nullable = false)
@Comment("수신자 이메일")
private String recipientEmail;
/**
* 발송 상태 (PENDING, SENT, FAILED, RETRY)
*/
@Enumerated(EnumType.STRING)
@Column(name = "status", length = 20, nullable = false)
@Comment("발송 상태")
private RecipientStatus status;
/**
* 재시도 횟수
*/
@Column(name = "retry_count", nullable = false)
@Comment("재시도 횟수")
@Builder.Default
private Integer retryCount = 0;
/**
* 발송 일시
*/
@Column(name = "sent_at")
@Comment("발송 일시")
private LocalDateTime sentAt;
/**
* 실패 사유
*/
@Column(name = "error_message", length = 1000)
@Comment("실패 사유")
private String errorMessage;
/**
* 다음 재시도 일시
*/
@Column(name = "next_retry_at")
@Comment("다음 재시도 일시")
private LocalDateTime nextRetryAt;
/**
* 생성 일시
*/
@Column(name = "created_at", nullable = false, updatable = false)
@Comment("생성 일시")
private LocalDateTime createdAt;
/**
* 수정 일시
*/
@Column(name = "updated_at")
@Comment("수정 일시")
private LocalDateTime updatedAt;
/**
* 생성 전 초기화
*
* - recipientId: UUID 생성
* - createdAt: 현재 시각
* - status: PENDING
* - retryCount: 0
*/
@PrePersist
protected void onCreate() {
if (this.recipientId == null) {
this.recipientId = UUID.randomUUID().toString();
}
if (this.createdAt == null) {
this.createdAt = LocalDateTime.now();
}
if (this.status == null) {
this.status = RecipientStatus.PENDING;
}
if (this.retryCount == null) {
this.retryCount = 0;
}
}
/**
* 수정 전 업데이트
*
* - updatedAt: 현재 시각
*/
@PreUpdate
protected void onUpdate() {
this.updatedAt = LocalDateTime.now();
}
/**
* 발송 성공 처리
*/
public void markAsSent() {
this.status = RecipientStatus.SENT;
this.sentAt = LocalDateTime.now();
this.errorMessage = null;
this.nextRetryAt = null;
}
/**
* 발송 실패 처리
*
* @param errorMessage 실패 사유
*/
public void markAsFailed(String errorMessage) {
this.status = RecipientStatus.FAILED;
this.errorMessage = errorMessage;
this.incrementRetryCount();
}
/**
* 재시도 상태로 변경
*
* @param nextRetryAt 다음 재시도 일시
*/
public void markForRetry(LocalDateTime nextRetryAt) {
this.status = RecipientStatus.RETRY;
this.nextRetryAt = nextRetryAt;
this.incrementRetryCount();
}
/**
* 재시도 횟수 증가
*/
private void incrementRetryCount() {
this.retryCount++;
}
/**
* 최대 재시도 횟수 초과 여부 확인
*
* @param maxRetries 최대 재시도 횟수
* @return 초과 여부
*/
public boolean exceedsMaxRetries(int maxRetries) {
return this.retryCount >= maxRetries;
}
/**
* 재시도 가능 여부 확인
*
* @return 재시도 가능 여부
*/
public boolean canRetry() {
return this.status == RecipientStatus.RETRY
&& this.nextRetryAt != null
&& LocalDateTime.now().isAfter(this.nextRetryAt);
}
/**
* 수신자 발송 상태 Enum
*/
public enum RecipientStatus {
PENDING, // 대기 중
SENT, // 발송 완료
FAILED, // 발송 실패
RETRY // 재시도 예정
}
}
@@ -0,0 +1,252 @@
package com.unicorn.hgzero.notification.domain;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.Comment;
import java.time.LocalDateTime;
import java.time.LocalTime;
/**
* 알림 설정 Entity
*
* 사용자별 알림 설정 정보를 관리
* 알림 채널, 유형별 활성화 여부, 방해 금지 시간대 설정
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Entity
@Table(name = "notification_settings", indexes = {
@Index(name = "idx_setting_user_id", columnList = "user_id", unique = true)
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Comment("알림 설정 정보")
public class NotificationSetting {
/**
* 설정 고유 ID (사용자 ID와 동일)
*/
@Id
@Column(name = "user_id", length = 100, nullable = false)
@Comment("사용자 ID")
private String userId;
/**
* 이메일 알림 활성화 여부
*/
@Column(name = "email_enabled", nullable = false)
@Comment("이메일 알림 활성화")
@Builder.Default
private Boolean emailEnabled = true;
/**
* SMS 알림 활성화 여부
*/
@Column(name = "sms_enabled", nullable = false)
@Comment("SMS 알림 활성화")
@Builder.Default
private Boolean smsEnabled = false;
/**
* Push 알림 활성화 여부
*/
@Column(name = "push_enabled", nullable = false)
@Comment("Push 알림 활성화")
@Builder.Default
private Boolean pushEnabled = false;
/**
* 회의 초대 알림 활성화 여부
*/
@Column(name = "invitation_enabled", nullable = false)
@Comment("회의 초대 알림 활성화")
@Builder.Default
private Boolean invitationEnabled = true;
/**
* Todo 할당 알림 활성화 여부
*/
@Column(name = "todo_assigned_enabled", nullable = false)
@Comment("Todo 할당 알림 활성화")
@Builder.Default
private Boolean todoAssignedEnabled = true;
/**
* Todo 리마인더 알림 활성화 여부
*/
@Column(name = "todo_reminder_enabled", nullable = false)
@Comment("Todo 리마인더 알림 활성화")
@Builder.Default
private Boolean todoReminderEnabled = true;
/**
* 회의 리마인더 알림 활성화 여부
*/
@Column(name = "meeting_reminder_enabled", nullable = false)
@Comment("회의 리마인더 알림 활성화")
@Builder.Default
private Boolean meetingReminderEnabled = true;
/**
* 회의록 수정 알림 활성화 여부
*/
@Column(name = "minutes_updated_enabled", nullable = false)
@Comment("회의록 수정 알림 활성화")
@Builder.Default
private Boolean minutesUpdatedEnabled = true;
/**
* Todo 완료 알림 활성화 여부
*/
@Column(name = "todo_completed_enabled", nullable = false)
@Comment("Todo 완료 알림 활성화")
@Builder.Default
private Boolean todoCompletedEnabled = false;
/**
* 방해 금지 모드 활성화 여부
*/
@Column(name = "dnd_enabled", nullable = false)
@Comment("방해 금지 모드 활성화")
@Builder.Default
private Boolean dndEnabled = false;
/**
* 방해 금지 시작 시간 (예: 22:00)
*/
@Column(name = "dnd_start_time")
@Comment("방해 금지 시작 시간")
private LocalTime dndStartTime;
/**
* 방해 금지 종료 시간 (예: 08:00)
*/
@Column(name = "dnd_end_time")
@Comment("방해 금지 종료 시간")
private LocalTime dndEndTime;
/**
* 생성 일시
*/
@Column(name = "created_at", nullable = false, updatable = false)
@Comment("생성 일시")
private LocalDateTime createdAt;
/**
* 수정 일시
*/
@Column(name = "updated_at")
@Comment("수정 일시")
private LocalDateTime updatedAt;
/**
* 생성 전 초기화
*
* - createdAt: 현재 시각
* - 기본값 설정
*/
@PrePersist
protected void onCreate() {
if (this.createdAt == null) {
this.createdAt = LocalDateTime.now();
}
}
/**
* 수정 전 업데이트
*
* - updatedAt: 현재 시각
*/
@PreUpdate
protected void onUpdate() {
this.updatedAt = LocalDateTime.now();
}
/**
* 알림 유형별 활성화 여부 확인
*
* @param notificationType 알림 유형
* @return 활성화 여부
*/
public boolean isNotificationTypeEnabled(Notification.NotificationType notificationType) {
return switch (notificationType) {
case INVITATION -> invitationEnabled;
case TODO_ASSIGNED -> todoAssignedEnabled;
case TODO_REMINDER -> todoReminderEnabled;
case MEETING_REMINDER -> meetingReminderEnabled;
case MINUTES_UPDATED -> minutesUpdatedEnabled;
case TODO_COMPLETED -> todoCompletedEnabled;
};
}
/**
* 채널별 활성화 여부 확인
*
* @param channel 알림 채널
* @return 활성화 여부
*/
public boolean isChannelEnabled(Notification.NotificationChannel channel) {
return switch (channel) {
case EMAIL -> emailEnabled;
case SMS -> smsEnabled;
case PUSH -> pushEnabled;
};
}
/**
* 방해 금지 시간대 여부 확인
*
* @return 방해 금지 시간대 여부
*/
public boolean isDoNotDisturbTime() {
if (!dndEnabled || dndStartTime == null || dndEndTime == null) {
return false;
}
LocalTime now = LocalTime.now();
// 시작 시간이 종료 시간보다 이전인 경우 (예: 22:00 ~ 08:00)
if (dndStartTime.isBefore(dndEndTime)) {
return now.isAfter(dndStartTime) && now.isBefore(dndEndTime);
}
// 시작 시간이 종료 시간보다 이후인 경우 (자정을 넘는 경우)
else {
return now.isAfter(dndStartTime) || now.isBefore(dndEndTime);
}
}
/**
* 알림 발송 가능 여부 확인
*
* @param notificationType 알림 유형
* @param channel 알림 채널
* @return 발송 가능 여부
*/
public boolean canSendNotification(
Notification.NotificationType notificationType,
Notification.NotificationChannel channel
) {
// 채널 비활성화
if (!isChannelEnabled(channel)) {
return false;
}
// 알림 유형 비활성화
if (!isNotificationTypeEnabled(notificationType)) {
return false;
}
// 방해 금지 시간대
if (isDoNotDisturbTime()) {
return false;
}
return true;
}
}
@@ -0,0 +1,74 @@
package com.unicorn.hgzero.notification.dto.request;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalTime;
/**
* 알림 설정 업데이트 요청 DTO
*
* 사용자가 알림 설정을 변경할 때 사용
* 채널 활성화, 알림 유형, 방해 금지 시간대 설정
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "알림 설정 업데이트 요청")
public class UpdateSettingsRequest {
@Schema(description = "이메일 알림 활성화 여부", example = "true")
@NotNull(message = "이메일 알림 활성화 여부는 필수입니다")
private Boolean emailEnabled;
@Schema(description = "SMS 알림 활성화 여부", example = "false")
@NotNull(message = "SMS 알림 활성화 여부는 필수입니다")
private Boolean smsEnabled;
@Schema(description = "Push 알림 활성화 여부", example = "false")
@NotNull(message = "Push 알림 활성화 여부는 필수입니다")
private Boolean pushEnabled;
@Schema(description = "회의 초대 알림 활성화 여부", example = "true")
@NotNull(message = "회의 초대 알림 활성화 여부는 필수입니다")
private Boolean invitationEnabled;
@Schema(description = "Todo 할당 알림 활성화 여부", example = "true")
@NotNull(message = "Todo 할당 알림 활성화 여부는 필수입니다")
private Boolean todoAssignedEnabled;
@Schema(description = "Todo 리마인더 알림 활성화 여부", example = "true")
@NotNull(message = "Todo 리마인더 알림 활성화 여부는 필수입니다")
private Boolean todoReminderEnabled;
@Schema(description = "회의 리마인더 알림 활성화 여부", example = "true")
@NotNull(message = "회의 리마인더 알림 활성화 여부는 필수입니다")
private Boolean meetingReminderEnabled;
@Schema(description = "회의록 수정 알림 활성화 여부", example = "true")
@NotNull(message = "회의록 수정 알림 활성화 여부는 필수입니다")
private Boolean minutesUpdatedEnabled;
@Schema(description = "Todo 완료 알림 활성화 여부", example = "false")
@NotNull(message = "Todo 완료 알림 활성화 여부는 필수입니다")
private Boolean todoCompletedEnabled;
@Schema(description = "방해 금지 모드 활성화 여부", example = "false")
@NotNull(message = "방해 금지 모드 활성화 여부는 필수입니다")
private Boolean dndEnabled;
@Schema(description = "방해 금지 시작 시간 (HH:mm 형식)", example = "22:00")
private LocalTime dndStartTime;
@Schema(description = "방해 금지 종료 시간 (HH:mm 형식)", example = "08:00")
private LocalTime dndEndTime;
}
@@ -0,0 +1,83 @@
package com.unicorn.hgzero.notification.dto.response;
import com.unicorn.hgzero.notification.domain.Notification;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 알림 목록 응답 DTO
*
* 알림 목록 조회 시 사용
* 알림 기본 정보만 포함 (수신자 정보 제외)
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "알림 목록 응답")
public class NotificationListResponse {
@Schema(description = "알림 ID", example = "550e8400-e29b-41d4-a716-446655440000")
private String notificationId;
@Schema(description = "참조 대상 ID", example = "meeting-001")
private String referenceId;
@Schema(description = "참조 유형", example = "MEETING")
private String referenceType;
@Schema(description = "알림 유형", example = "INVITATION")
private String notificationType;
@Schema(description = "알림 제목", example = "회의 초대: 주간 회의")
private String title;
@Schema(description = "알림 상태", example = "SENT")
private String status;
@Schema(description = "발송 채널", example = "EMAIL")
private String channel;
@Schema(description = "발송 완료 건수", example = "5")
private Integer sentCount;
@Schema(description = "발송 실패 건수", example = "0")
private Integer failedCount;
@Schema(description = "생성 일시", example = "2025-01-23T10:00:00")
private LocalDateTime createdAt;
@Schema(description = "발송 완료 일시", example = "2025-01-23T10:05:00")
private LocalDateTime sentAt;
/**
* Entity를 DTO로 변환
*
* @param notification 알림 엔티티
* @return 알림 목록 응답 DTO
*/
public static NotificationListResponse from(Notification notification) {
return NotificationListResponse.builder()
.notificationId(notification.getNotificationId())
.referenceId(notification.getReferenceId())
.referenceType(notification.getReferenceType().name())
.notificationType(notification.getNotificationType().name())
.title(notification.getTitle())
.status(notification.getStatus().name())
.channel(notification.getChannel().name())
.sentCount(notification.getSentCount())
.failedCount(notification.getFailedCount())
.createdAt(notification.getCreatedAt())
.sentAt(notification.getSentAt())
.build();
}
}
@@ -0,0 +1,154 @@
package com.unicorn.hgzero.notification.dto.response;
import com.unicorn.hgzero.notification.domain.Notification;
import com.unicorn.hgzero.notification.domain.NotificationRecipient;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
/**
* 알림 응답 DTO
*
* 단일 알림 정보 조회 시 사용
* 알림 기본 정보 및 수신자 목록 포함
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "알림 응답")
public class NotificationResponse {
@Schema(description = "알림 ID", example = "550e8400-e29b-41d4-a716-446655440000")
private String notificationId;
@Schema(description = "이벤트 ID (중복 방지용)", example = "meeting-created-20250123-001")
private String eventId;
@Schema(description = "참조 대상 ID", example = "meeting-001")
private String referenceId;
@Schema(description = "참조 유형", example = "MEETING")
private String referenceType;
@Schema(description = "알림 유형", example = "INVITATION")
private String notificationType;
@Schema(description = "알림 제목", example = "회의 초대: 주간 회의")
private String title;
@Schema(description = "알림 내용", example = "주간 진행 상황 공유 및 이슈 논의")
private String message;
@Schema(description = "알림 상태", example = "SENT")
private String status;
@Schema(description = "발송 채널", example = "EMAIL")
private String channel;
@Schema(description = "발송 완료 건수", example = "5")
private Integer sentCount;
@Schema(description = "발송 실패 건수", example = "0")
private Integer failedCount;
@Schema(description = "생성 일시", example = "2025-01-23T10:00:00")
private LocalDateTime createdAt;
@Schema(description = "발송 완료 일시", example = "2025-01-23T10:05:00")
private LocalDateTime sentAt;
@Schema(description = "수신자 목록")
private List<RecipientInfo> recipients;
/**
* Entity를 DTO로 변환
*
* @param notification 알림 엔티티
* @return 알림 응답 DTO
*/
public static NotificationResponse from(Notification notification) {
return NotificationResponse.builder()
.notificationId(notification.getNotificationId())
.eventId(notification.getEventId())
.referenceId(notification.getReferenceId())
.referenceType(notification.getReferenceType().name())
.notificationType(notification.getNotificationType().name())
.title(notification.getTitle())
.message(notification.getMessage())
.status(notification.getStatus().name())
.channel(notification.getChannel().name())
.sentCount(notification.getSentCount())
.failedCount(notification.getFailedCount())
.createdAt(notification.getCreatedAt())
.sentAt(notification.getSentAt())
.recipients(notification.getRecipients().stream()
.map(RecipientInfo::from)
.collect(Collectors.toList()))
.build();
}
/**
* 수신자 정보 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "수신자 정보")
public static class RecipientInfo {
@Schema(description = "수신자 ID", example = "550e8400-e29b-41d4-a716-446655440001")
private String recipientId;
@Schema(description = "수신자 사용자 ID", example = "user-001")
private String recipientUserId;
@Schema(description = "수신자 이름", example = "홍길동")
private String recipientName;
@Schema(description = "수신자 이메일", example = "hong@example.com")
private String recipientEmail;
@Schema(description = "발송 상태", example = "SENT")
private String status;
@Schema(description = "재시도 횟수", example = "0")
private Integer retryCount;
@Schema(description = "발송 일시", example = "2025-01-23T10:05:00")
private LocalDateTime sentAt;
@Schema(description = "실패 사유", example = null)
private String errorMessage;
/**
* Entity를 DTO로 변환
*
* @param recipient 수신자 엔티티
* @return 수신자 정보 DTO
*/
public static RecipientInfo from(NotificationRecipient recipient) {
return RecipientInfo.builder()
.recipientId(recipient.getRecipientId())
.recipientUserId(recipient.getRecipientUserId())
.recipientName(recipient.getRecipientName())
.recipientEmail(recipient.getRecipientEmail())
.status(recipient.getStatus().name())
.retryCount(recipient.getRetryCount())
.sentAt(recipient.getSentAt())
.errorMessage(recipient.getErrorMessage())
.build();
}
}
}
@@ -0,0 +1,100 @@
package com.unicorn.hgzero.notification.dto.response;
import com.unicorn.hgzero.notification.domain.NotificationSetting;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.time.LocalTime;
/**
* 알림 설정 응답 DTO
*
* 사용자 알림 설정 조회 시 사용
* 채널 활성화, 알림 유형, 방해 금지 시간대 정보 포함
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "알림 설정 응답")
public class SettingsResponse {
@Schema(description = "사용자 ID", example = "user-001")
private String userId;
@Schema(description = "이메일 알림 활성화 여부", example = "true")
private Boolean emailEnabled;
@Schema(description = "SMS 알림 활성화 여부", example = "false")
private Boolean smsEnabled;
@Schema(description = "Push 알림 활성화 여부", example = "false")
private Boolean pushEnabled;
@Schema(description = "회의 초대 알림 활성화 여부", example = "true")
private Boolean invitationEnabled;
@Schema(description = "Todo 할당 알림 활성화 여부", example = "true")
private Boolean todoAssignedEnabled;
@Schema(description = "Todo 리마인더 알림 활성화 여부", example = "true")
private Boolean todoReminderEnabled;
@Schema(description = "회의 리마인더 알림 활성화 여부", example = "true")
private Boolean meetingReminderEnabled;
@Schema(description = "회의록 수정 알림 활성화 여부", example = "true")
private Boolean minutesUpdatedEnabled;
@Schema(description = "Todo 완료 알림 활성화 여부", example = "false")
private Boolean todoCompletedEnabled;
@Schema(description = "방해 금지 모드 활성화 여부", example = "false")
private Boolean dndEnabled;
@Schema(description = "방해 금지 시작 시간", example = "22:00:00")
private LocalTime dndStartTime;
@Schema(description = "방해 금지 종료 시간", example = "08:00:00")
private LocalTime dndEndTime;
@Schema(description = "생성 일시", example = "2025-01-23T10:00:00")
private LocalDateTime createdAt;
@Schema(description = "수정 일시", example = "2025-01-23T10:05:00")
private LocalDateTime updatedAt;
/**
* Entity를 DTO로 변환
*
* @param setting 알림 설정 엔티티
* @return 알림 설정 응답 DTO
*/
public static SettingsResponse from(NotificationSetting setting) {
return SettingsResponse.builder()
.userId(setting.getUserId())
.emailEnabled(setting.getEmailEnabled())
.smsEnabled(setting.getSmsEnabled())
.pushEnabled(setting.getPushEnabled())
.invitationEnabled(setting.getInvitationEnabled())
.todoAssignedEnabled(setting.getTodoAssignedEnabled())
.todoReminderEnabled(setting.getTodoReminderEnabled())
.meetingReminderEnabled(setting.getMeetingReminderEnabled())
.minutesUpdatedEnabled(setting.getMinutesUpdatedEnabled())
.todoCompletedEnabled(setting.getTodoCompletedEnabled())
.dndEnabled(setting.getDndEnabled())
.dndStartTime(setting.getDndStartTime())
.dndEndTime(setting.getDndEndTime())
.createdAt(setting.getCreatedAt())
.updatedAt(setting.getUpdatedAt())
.build();
}
}
@@ -0,0 +1,177 @@
package com.unicorn.hgzero.notification.event;
import com.azure.messaging.eventhubs.models.EventContext;
import com.azure.messaging.eventhubs.models.EventData;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.unicorn.hgzero.notification.event.event.MeetingCreatedEvent;
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.retry.support.RetryTemplate;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.function.Consumer;
/**
* Event Hub 이벤트 핸들러
*
* Azure Event Hub로부터 이벤트를 수신하여 처리
* 이벤트 유형에 따라 적절한 알림 발송 로직 실행
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class EventHandler implements Consumer<EventContext> {
private final NotificationService notificationService;
private final ObjectMapper objectMapper;
private final RetryTemplate retryTemplate;
/**
* Event Hub 이벤트 처리
*
* @param eventContext Event Hub 이벤트 컨텍스트
*/
@Override
public void accept(EventContext eventContext) {
EventData eventData = eventContext.getEventData();
try {
// 이벤트 속성 추출
Map<String, Object> properties = eventData.getProperties();
String topic = (String) properties.get("topic");
String eventType = (String) properties.get("eventType");
log.info("이벤트 수신 - Topic: {}, EventType: {}", topic, eventType);
// 이벤트 본문 추출
String eventBody = eventData.getBodyAsString();
// 토픽 및 이벤트 유형에 따라 처리
if ("meeting".equals(topic)) {
handleMeetingEvent(eventType, eventBody);
} else if ("todo".equals(topic)) {
handleTodoEvent(eventType, eventBody);
} else {
log.warn("알 수 없는 토픽: {}", topic);
}
// 체크포인트 업데이트 (처리 성공 시)
eventContext.updateCheckpoint();
log.info("이벤트 처리 완료 및 체크포인트 업데이트");
} catch (Exception e) {
log.error("이벤트 처리 중 오류 발생", e);
// 체크포인트를 업데이트하지 않아 재처리 가능
throw new RuntimeException("이벤트 처리 실패", e);
}
}
/**
* 회의 관련 이벤트 처리
*
* @param eventType 이벤트 유형
* @param eventBody 이벤트 본문 (JSON)
*/
private void handleMeetingEvent(String eventType, String eventBody) {
try {
switch (eventType) {
case "MeetingCreated":
MeetingCreatedEvent meetingEvent = objectMapper.readValue(
eventBody,
MeetingCreatedEvent.class
);
processMeetingCreatedEvent(meetingEvent);
break;
case "MeetingUpdated":
log.info("회의 수정 이벤트 처리 (향후 구현)");
break;
case "MeetingCancelled":
log.info("회의 취소 이벤트 처리 (향후 구현)");
break;
default:
log.warn("알 수 없는 회의 이벤트 유형: {}", eventType);
}
} catch (Exception e) {
log.error("회의 이벤트 처리 중 오류 발생 - EventType: {}", eventType, e);
throw new RuntimeException("회의 이벤트 처리 실패", e);
}
}
/**
* Todo 관련 이벤트 처리
*
* @param eventType 이벤트 유형
* @param eventBody 이벤트 본문 (JSON)
*/
private void handleTodoEvent(String eventType, String eventBody) {
try {
switch (eventType) {
case "TodoAssigned":
TodoAssignedEvent todoEvent = objectMapper.readValue(
eventBody,
TodoAssignedEvent.class
);
processTodoAssignedEvent(todoEvent);
break;
case "TodoCompleted":
log.info("Todo 완료 이벤트 처리 (향후 구현)");
break;
case "TodoUpdated":
log.info("Todo 수정 이벤트 처리 (향후 구현)");
break;
default:
log.warn("알 수 없는 Todo 이벤트 유형: {}", eventType);
}
} catch (Exception e) {
log.error("Todo 이벤트 처리 중 오류 발생 - EventType: {}", eventType, e);
throw new RuntimeException("Todo 이벤트 처리 실패", e);
}
}
/**
* 회의 생성 이벤트 처리 (재시도 지원)
*
* @param event 회의 생성 이벤트
*/
private void processMeetingCreatedEvent(MeetingCreatedEvent event) {
retryTemplate.execute(context -> {
log.info("회의 초대 알림 발송 시작 - MeetingId: {}, EventId: {}",
event.getMeetingId(), event.getEventId());
notificationService.sendMeetingInvitation(event);
log.info("회의 초대 알림 발송 완료 - MeetingId: {}", event.getMeetingId());
return null;
});
}
/**
* Todo 할당 이벤트 처리 (재시도 지원)
*
* @param event Todo 할당 이벤트
*/
private void processTodoAssignedEvent(TodoAssignedEvent event) {
retryTemplate.execute(context -> {
log.info("Todo 할당 알림 발송 시작 - TodoId: {}, EventId: {}",
event.getTodoId(), event.getEventId());
notificationService.sendTodoAssignment(event);
log.info("Todo 할당 알림 발송 완료 - TodoId: {}", event.getTodoId());
return null;
});
}
}
@@ -0,0 +1,100 @@
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;
/**
* 회의 생성 이벤트 DTO
*
* Meeting 서비스에서 회의 생성 시 발행되는 이벤트
* 회의 초대 알림 발송에 사용
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class MeetingCreatedEvent {
/**
* 이벤트 고유 ID (중복 발송 방지용)
*/
private String eventId;
/**
* 회의 ID
*/
private String meetingId;
/**
* 회의 제목
*/
private String title;
/**
* 회의 설명
*/
private String description;
/**
* 회의 시작 일시
*/
private LocalDateTime startTime;
/**
* 회의 종료 일시
*/
private LocalDateTime endTime;
/**
* 회의 장소
*/
private String location;
/**
* 회의 주최자 정보
*/
private ParticipantInfo organizer;
/**
* 참석자 목록
*/
private List<ParticipantInfo> participants;
/**
* 이벤트 발행 일시
*/
private LocalDateTime createdAt;
/**
* 참석자 정보 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class ParticipantInfo {
/**
* 사용자 ID
*/
private String userId;
/**
* 사용자 이름
*/
private String name;
/**
* 사용자 이메일
*/
private String email;
}
}
@@ -0,0 +1,99 @@
package com.unicorn.hgzero.notification.event.event;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* Todo 할당 이벤트 DTO
*
* Meeting 또는 AI 서비스에서 Todo 할당 시 발행되는 이벤트
* Todo 할당 알림 발송에 사용
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TodoAssignedEvent {
/**
* 이벤트 고유 ID (중복 발송 방지용)
*/
private String eventId;
/**
* Todo ID
*/
private String todoId;
/**
* Todo 제목
*/
private String title;
/**
* Todo 설명
*/
private String description;
/**
* 마감 기한
*/
private LocalDateTime deadline;
/**
* 우선순위 (HIGH, MEDIUM, LOW)
*/
private String priority;
/**
* 할당자 정보
*/
private UserInfo assignedBy;
/**
* 담당자 정보
*/
private UserInfo assignee;
/**
* 관련 회의 ID (회의에서 생성된 Todo인 경우)
*/
private String meetingId;
/**
* 이벤트 발행 일시
*/
private LocalDateTime createdAt;
/**
* 사용자 정보 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class UserInfo {
/**
* 사용자 ID
*/
private String userId;
/**
* 사용자 이름
*/
private String name;
/**
* 사용자 이메일
*/
private String email;
}
}
@@ -0,0 +1,65 @@
package com.unicorn.hgzero.notification.event.processor;
import com.azure.messaging.eventhubs.EventProcessorClient;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;
/**
* Event Processor 라이프사이클 관리 서비스
*
* EventProcessorClient의 시작과 종료를 관리
* 애플리케이션 시작 시 이벤트 수신 시작, 종료 시 안전하게 정리
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class EventProcessorService {
private final EventProcessorClient eventProcessorClient;
/**
* 애플리케이션 시작 시 Event Processor 시작
*
* @throws Exception 시작 실패 시 예외 발생
*/
@PostConstruct
@Retryable(
maxAttempts = 3,
backoff = @Backoff(delay = 2000, multiplier = 2.0)
)
public void start() throws Exception {
try {
log.info("Event Processor 시작 중...");
eventProcessorClient.start();
log.info("Event Processor 시작 완료");
} catch (Exception e) {
log.error("Event Processor 시작 실패", e);
throw e;
}
}
/**
* 애플리케이션 종료 시 Event Processor 정리
*
* 처리 중인 이벤트를 안전하게 완료하고 리소스 정리
*/
@PreDestroy
public void stop() {
try {
log.info("Event Processor 종료 중...");
eventProcessorClient.stop();
log.info("Event Processor 종료 완료");
} catch (Exception e) {
log.error("Event Processor 종료 중 오류 발생", e);
}
}
}
@@ -0,0 +1,120 @@
package com.unicorn.hgzero.notification.repository;
import com.unicorn.hgzero.notification.domain.NotificationRecipient;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
/**
* 알림 수신자 Repository
*
* 수신자별 알림 상태 관리 및 재시도 대상 조회
* 발송 성공/실패 추적, 재시도 스케줄링 지원
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Repository
public interface NotificationRecipientRepository extends JpaRepository<NotificationRecipient, String> {
/**
* 알림별 수신자 목록 조회
*
* @param notificationId 알림 ID
* @return 수신자 목록
*/
@Query("SELECT nr FROM NotificationRecipient nr WHERE nr.notification.notificationId = :notificationId")
List<NotificationRecipient> findByNotificationId(@Param("notificationId") String notificationId);
/**
* 재시도 대상 수신자 조회
*
* 상태가 RETRY이고 재시도 시간이 현재 시각 이전인 수신자 조회
*
* @param status 수신자 상태 (RETRY)
* @param now 현재 시각
* @return 재시도 대상 수신자 목록
*/
List<NotificationRecipient> findByStatusAndNextRetryAtBefore(
NotificationRecipient.RecipientStatus status,
LocalDateTime now
);
/**
* 이메일 주소로 수신자 히스토리 조회
*
* @param recipientEmail 수신자 이메일
* @return 수신자 목록
*/
List<NotificationRecipient> findByRecipientEmail(String recipientEmail);
/**
* 사용자 ID로 수신자 히스토리 조회
*
* @param recipientUserId 수신자 사용자 ID
* @return 수신자 목록
*/
List<NotificationRecipient> findByRecipientUserId(String recipientUserId);
/**
* 상태별 수신자 목록 조회
*
* @param status 수신자 상태
* @return 수신자 목록
*/
List<NotificationRecipient> findByStatus(NotificationRecipient.RecipientStatus status);
/**
* 특정 재시도 횟수 이상인 수신자 조회 (모니터링용)
*
* @param minRetryCount 최소 재시도 횟수
* @return 수신자 목록
*/
@Query("SELECT nr FROM NotificationRecipient nr WHERE nr.retryCount >= :minRetryCount")
List<NotificationRecipient> findRecipientsWithHighRetryCount(@Param("minRetryCount") int minRetryCount);
/**
* 발송 실패 수신자 목록 조회 (기간별)
*
* @param status 수신자 상태 (FAILED)
* @param startDate 시작 일시
* @param endDate 종료 일시
* @return 수신자 목록
*/
@Query("SELECT nr FROM NotificationRecipient nr WHERE nr.status = :status AND nr.createdAt BETWEEN :startDate AND :endDate")
List<NotificationRecipient> findFailedRecipientsByPeriod(
@Param("status") NotificationRecipient.RecipientStatus status,
@Param("startDate") LocalDateTime startDate,
@Param("endDate") LocalDateTime endDate
);
/**
* 알림별 상태별 수신자 개수 조회
*
* @param notificationId 알림 ID
* @param status 수신자 상태
* @return 수신자 개수
*/
@Query("SELECT COUNT(nr) FROM NotificationRecipient nr WHERE nr.notification.notificationId = :notificationId AND nr.status = :status")
long countByNotificationIdAndStatus(
@Param("notificationId") String notificationId,
@Param("status") NotificationRecipient.RecipientStatus status
);
/**
* 사용자별 발송 성공 알림 개수 조회 (통계용)
*
* @param recipientUserId 수신자 사용자 ID
* @param status 수신자 상태 (SENT)
* @return 발송 성공 개수
*/
long countByRecipientUserIdAndStatus(
String recipientUserId,
NotificationRecipient.RecipientStatus status
);
}
@@ -0,0 +1,110 @@
package com.unicorn.hgzero.notification.repository;
import com.unicorn.hgzero.notification.domain.Notification;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/**
* 알림 Repository
*
* 알림 이력 조회 및 저장을 담당
* 이벤트 ID 기반 중복 방지, 상태별 조회, 기간별 조회 지원
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Repository
public interface NotificationRepository extends JpaRepository<Notification, String> {
/**
* 이벤트 ID로 알림 조회 (중복 발송 방지용)
*
* @param eventId 이벤트 고유 ID
* @return 알림 정보
*/
Optional<Notification> findByEventId(String eventId);
/**
* 참조 대상으로 알림 목록 조회
*
* @param referenceId 참조 대상 ID (meetingId 또는 todoId)
* @param referenceType 참조 유형 (MEETING, TODO)
* @return 알림 목록
*/
List<Notification> findByReferenceIdAndReferenceType(
String referenceId,
Notification.ReferenceType referenceType
);
/**
* 상태별 알림 목록 조회 (배치 처리용)
*
* @param statuses 조회할 상태 목록
* @return 알림 목록
*/
List<Notification> findByStatusIn(List<Notification.NotificationStatus> statuses);
/**
* 기간별 알림 목록 조회
*
* @param startDate 시작 일시
* @param endDate 종료 일시
* @return 알림 목록
*/
List<Notification> findByCreatedAtBetween(LocalDateTime startDate, LocalDateTime endDate);
/**
* 알림 유형별 알림 목록 조회
*
* @param notificationType 알림 유형
* @return 알림 목록
*/
List<Notification> findByNotificationType(Notification.NotificationType notificationType);
/**
* 채널별 알림 목록 조회
*
* @param channel 발송 채널
* @return 알림 목록
*/
List<Notification> findByChannel(Notification.NotificationChannel channel);
/**
* 발송 실패 건수가 특정 수 이상인 알림 조회 (모니터링용)
*
* @param minFailedCount 최소 실패 건수
* @return 알림 목록
*/
@Query("SELECT n FROM Notification n WHERE n.failedCount >= :minFailedCount")
List<Notification> findNotificationsWithHighFailureRate(@Param("minFailedCount") int minFailedCount);
/**
* 이벤트 ID 존재 여부 확인 (중복 발송 방지용)
*
* @param eventId 이벤트 고유 ID
* @return 존재 여부
*/
boolean existsByEventId(String eventId);
/**
* 특정 상태이고 특정 기간 내 생성된 알림 개수 조회 (통계용)
*
* @param status 알림 상태
* @param startDate 시작 일시
* @param endDate 종료 일시
* @return 알림 개수
*/
@Query("SELECT COUNT(n) FROM Notification n WHERE n.status = :status AND n.createdAt BETWEEN :startDate AND :endDate")
long countByStatusAndCreatedAtBetween(
@Param("status") Notification.NotificationStatus status,
@Param("startDate") LocalDateTime startDate,
@Param("endDate") LocalDateTime endDate
);
}
@@ -0,0 +1,133 @@
package com.unicorn.hgzero.notification.repository;
import com.unicorn.hgzero.notification.domain.NotificationSetting;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* 알림 설정 Repository
*
* 사용자별 알림 설정 관리
* 채널 활성화, 알림 유형, 방해 금지 시간대 설정
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Repository
public interface NotificationSettingRepository extends JpaRepository<NotificationSetting, String> {
/**
* 사용자 ID로 알림 설정 조회
*
* userId가 PK이므로 findById(userId)와 동일하지만 명시적인 메서드명 제공
*
* @param userId 사용자 ID
* @return 알림 설정 (없으면 empty)
*/
Optional<NotificationSetting> findByUserId(String userId);
/**
* 사용자 ID 존재 여부 확인
*
* @param userId 사용자 ID
* @return 존재 여부
*/
boolean existsByUserId(String userId);
/**
* 이메일 알림 활성화된 사용자 목록 조회
*
* @param emailEnabled 이메일 알림 활성화 여부
* @return 알림 설정 목록
*/
List<NotificationSetting> findByEmailEnabled(boolean emailEnabled);
/**
* SMS 알림 활성화된 사용자 목록 조회
*
* @param smsEnabled SMS 알림 활성화 여부
* @return 알림 설정 목록
*/
List<NotificationSetting> findBySmsEnabled(boolean smsEnabled);
/**
* Push 알림 활성화된 사용자 목록 조회
*
* @param pushEnabled Push 알림 활성화 여부
* @return 알림 설정 목록
*/
List<NotificationSetting> findByPushEnabled(boolean pushEnabled);
/**
* 방해 금지 모드 활성화된 사용자 목록 조회
*
* @param dndEnabled 방해 금지 모드 활성화 여부
* @return 알림 설정 목록
*/
List<NotificationSetting> findByDndEnabled(boolean dndEnabled);
/**
* 특정 알림 유형이 활성화된 사용자 개수 조회 (통계용)
*
* 예: 회의 초대 알림을 활성화한 사용자 수
*
* @param invitationEnabled 회의 초대 알림 활성화 여부
* @return 사용자 개수
*/
long countByInvitationEnabled(boolean invitationEnabled);
/**
* Todo 할당 알림이 활성화된 사용자 개수 조회 (통계용)
*
* @param todoAssignedEnabled Todo 할당 알림 활성화 여부
* @return 사용자 개수
*/
long countByTodoAssignedEnabled(boolean todoAssignedEnabled);
/**
* 이메일과 특정 알림 유형이 모두 활성화된 사용자 조회
*
* 발송 대상 필터링용
*
* @param emailEnabled 이메일 알림 활성화 여부
* @param invitationEnabled 회의 초대 알림 활성화 여부
* @return 알림 설정 목록
*/
List<NotificationSetting> findByEmailEnabledAndInvitationEnabled(
boolean emailEnabled,
boolean invitationEnabled
);
/**
* 이메일과 Todo 할당 알림이 모두 활성화된 사용자 조회
*
* @param emailEnabled 이메일 알림 활성화 여부
* @param todoAssignedEnabled Todo 할당 알림 활성화 여부
* @return 알림 설정 목록
*/
List<NotificationSetting> findByEmailEnabledAndTodoAssignedEnabled(
boolean emailEnabled,
boolean todoAssignedEnabled
);
/**
* 모든 알림 채널이 비활성화된 사용자 조회 (모니터링용)
*
* @param emailEnabled 이메일 알림 활성화 여부
* @param smsEnabled SMS 알림 활성화 여부
* @param pushEnabled Push 알림 활성화 여부
* @return 알림 설정 목록
*/
@Query("SELECT ns FROM NotificationSetting ns WHERE ns.emailEnabled = :emailEnabled AND ns.smsEnabled = :smsEnabled AND ns.pushEnabled = :pushEnabled")
List<NotificationSetting> findUsersWithAllChannelsDisabled(
@Param("emailEnabled") boolean emailEnabled,
@Param("smsEnabled") boolean smsEnabled,
@Param("pushEnabled") boolean pushEnabled
);
}
@@ -0,0 +1,108 @@
package com.unicorn.hgzero.notification.service;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Component;
/**
* 이메일 발송 클라이언트
*
* SMTP를 통한 이메일 발송 처리
* HTML 템플릿 기반 이메일 전송 지원
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class EmailClient {
private final JavaMailSender mailSender;
/**
* HTML 이메일 발송 (재시도 지원)
*
* 발송 실패 시 최대 3번까지 재시도
* Exponential Backoff: 초기 5분, 최대 30분, 배수 2.0
*
* @param to 수신자 이메일 주소
* @param subject 이메일 제목
* @param htmlContent HTML 이메일 본문
* @throws MessagingException 이메일 발송 실패 시
*/
@Retryable(
retryFor = {MessagingException.class},
maxAttempts = 3,
backoff = @Backoff(
delay = 300000, // 5분
maxDelay = 1800000, // 30분
multiplier = 2.0
)
)
public void sendHtmlEmail(String to, String subject, String htmlContent) throws MessagingException {
try {
log.info("이메일 발송 시작 - To: {}, Subject: {}", to, subject);
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setTo(to);
helper.setSubject(subject);
helper.setText(htmlContent, true); // true = HTML 모드
mailSender.send(message);
log.info("이메일 발송 완료 - To: {}", to);
} catch (MessagingException e) {
log.error("이메일 발송 실패 - To: {}, Subject: {}", to, subject, e);
throw e;
}
}
/**
* 텍스트 이메일 발송 (재시도 지원)
*
* @param to 수신자 이메일 주소
* @param subject 이메일 제목
* @param textContent 텍스트 이메일 본문
* @throws MessagingException 이메일 발송 실패 시
*/
@Retryable(
retryFor = {MessagingException.class},
maxAttempts = 3,
backoff = @Backoff(
delay = 300000,
maxDelay = 1800000,
multiplier = 2.0
)
)
public void sendTextEmail(String to, String subject, String textContent) throws MessagingException {
try {
log.info("텍스트 이메일 발송 시작 - To: {}, Subject: {}", to, subject);
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setTo(to);
helper.setSubject(subject);
helper.setText(textContent, false); // false = 텍스트 모드
mailSender.send(message);
log.info("텍스트 이메일 발송 완료 - To: {}", to);
} catch (MessagingException e) {
log.error("텍스트 이메일 발송 실패 - To: {}, Subject: {}", to, subject, e);
throw e;
}
}
}
@@ -0,0 +1,143 @@
package com.unicorn.hgzero.notification.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import java.util.Map;
/**
* 이메일 템플릿 렌더링 서비스
*
* Thymeleaf 템플릿 엔진을 사용하여 동적 HTML 이메일 생성
* 회의 초대, Todo 할당 등 다양한 알림 템플릿 지원
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class EmailTemplateService {
private final TemplateEngine templateEngine;
/**
* 회의 초대 이메일 템플릿 렌더링
*
* @param variables 템플릿 변수 맵
* - title: 회의 제목
* - description: 회의 설명
* - startTime: 시작 시간
* - endTime: 종료 시간
* - location: 장소
* - organizerName: 주최자 이름
* - participantName: 참석자 이름
* @return 렌더링된 HTML 문자열
*/
public String renderMeetingInvitation(Map<String, Object> variables) {
try {
log.info("회의 초대 템플릿 렌더링 시작");
Context context = new Context();
context.setVariables(variables);
String html = templateEngine.process("meeting-invitation", context);
log.info("회의 초대 템플릿 렌더링 완료");
return html;
} catch (Exception e) {
log.error("회의 초대 템플릿 렌더링 실패", e);
throw new RuntimeException("템플릿 렌더링 실패: meeting-invitation", e);
}
}
/**
* Todo 할당 이메일 템플릿 렌더링
*
* @param variables 템플릿 변수 맵
* - title: Todo 제목
* - description: Todo 설명
* - deadline: 마감 기한
* - priority: 우선순위
* - assignedByName: 할당자 이름
* - assigneeName: 담당자 이름
* - meetingTitle: 관련 회의 제목 (optional)
* @return 렌더링된 HTML 문자열
*/
public String renderTodoAssigned(Map<String, Object> variables) {
try {
log.info("Todo 할당 템플릿 렌더링 시작");
Context context = new Context();
context.setVariables(variables);
String html = templateEngine.process("todo-assigned", context);
log.info("Todo 할당 템플릿 렌더링 완료");
return html;
} catch (Exception e) {
log.error("Todo 할당 템플릿 렌더링 실패", e);
throw new RuntimeException("템플릿 렌더링 실패: todo-assigned", e);
}
}
/**
* 리마인더 이메일 템플릿 렌더링
*
* @param variables 템플릿 변수 맵
* - reminderType: 리마인더 유형 (meeting, todo)
* - title: 제목
* - description: 설명
* - scheduledTime: 예정 시간
* - recipientName: 수신자 이름
* @return 렌더링된 HTML 문자열
*/
public String renderReminder(Map<String, Object> variables) {
try {
log.info("리마인더 템플릿 렌더링 시작");
Context context = new Context();
context.setVariables(variables);
String html = templateEngine.process("reminder", context);
log.info("리마인더 템플릿 렌더링 완료");
return html;
} catch (Exception e) {
log.error("리마인더 템플릿 렌더링 실패", e);
throw new RuntimeException("템플릿 렌더링 실패: reminder", e);
}
}
/**
* 일반 템플릿 렌더링
*
* @param templateName 템플릿 이름 (확장자 제외)
* @param variables 템플릿 변수 맵
* @return 렌더링된 HTML 문자열
*/
public String render(String templateName, Map<String, Object> variables) {
try {
log.info("템플릿 렌더링 시작 - Template: {}", templateName);
Context context = new Context();
context.setVariables(variables);
String html = templateEngine.process(templateName, context);
log.info("템플릿 렌더링 완료 - Template: {}", templateName);
return html;
} catch (Exception e) {
log.error("템플릿 렌더링 실패 - Template: {}", templateName, e);
throw new RuntimeException("템플릿 렌더링 실패: " + templateName, e);
}
}
}
@@ -0,0 +1,277 @@
package com.unicorn.hgzero.notification.service;
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.TodoAssignedEvent;
import com.unicorn.hgzero.notification.repository.NotificationRecipientRepository;
import com.unicorn.hgzero.notification.repository.NotificationRepository;
import com.unicorn.hgzero.notification.repository.NotificationSettingRepository;
import jakarta.mail.MessagingException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
/**
* 알림 발송 비즈니스 로직 서비스
*
* 회의 초대, Todo 할당 등 다양한 알림 발송 처리
* 중복 방지, 사용자 설정 확인, 재시도 관리 포함
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
public class NotificationService {
private final NotificationRepository notificationRepository;
private final NotificationRecipientRepository recipientRepository;
private final NotificationSettingRepository settingRepository;
private final EmailTemplateService templateService;
private final EmailClient emailClient;
private static final DateTimeFormatter DATETIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
/**
* 회의 초대 알림 발송
*
* @param event 회의 생성 이벤트
*/
public void sendMeetingInvitation(MeetingCreatedEvent event) {
log.info("회의 초대 알림 처리 시작 - MeetingId: {}, EventId: {}",
event.getMeetingId(), event.getEventId());
// 1. 중복 발송 방지 체크
if (notificationRepository.existsByEventId(event.getEventId())) {
log.warn("이미 처리된 이벤트 - EventId: {}", event.getEventId());
return;
}
// 2. 알림 엔티티 생성
Notification notification = Notification.builder()
.eventId(event.getEventId())
.referenceId(event.getMeetingId())
.referenceType(Notification.ReferenceType.MEETING)
.notificationType(Notification.NotificationType.INVITATION)
.title("회의 초대: " + event.getTitle())
.message(event.getDescription())
.status(Notification.NotificationStatus.PROCESSING)
.channel(Notification.NotificationChannel.EMAIL)
.build();
notificationRepository.save(notification);
// 3. 각 참석자에게 알림 발송
int successCount = 0;
int failureCount = 0;
for (MeetingCreatedEvent.ParticipantInfo participant : event.getParticipants()) {
try {
// 3-1. 알림 설정 확인
if (!canSendNotification(participant.getUserId(), Notification.NotificationType.INVITATION)) {
log.info("알림 설정에 의해 발송 제외 - UserId: {}", participant.getUserId());
continue;
}
// 3-2. 수신자 엔티티 생성
NotificationRecipient recipient = NotificationRecipient.builder()
.recipientUserId(participant.getUserId())
.recipientName(participant.getName())
.recipientEmail(participant.getEmail())
.status(NotificationRecipient.RecipientStatus.PENDING)
.build();
notification.addRecipient(recipient);
// 3-3. 이메일 템플릿 렌더링
Map<String, Object> variables = new HashMap<>();
variables.put("title", event.getTitle());
variables.put("description", event.getDescription());
variables.put("startTime", event.getStartTime().format(DATETIME_FORMATTER));
variables.put("endTime", event.getEndTime().format(DATETIME_FORMATTER));
variables.put("location", event.getLocation());
variables.put("organizerName", event.getOrganizer().getName());
variables.put("participantName", participant.getName());
String htmlContent = templateService.renderMeetingInvitation(variables);
// 3-4. 이메일 발송
emailClient.sendHtmlEmail(
participant.getEmail(),
"회의 초대: " + event.getTitle(),
htmlContent
);
// 3-5. 발송 성공 처리
recipient.markAsSent();
notification.incrementSentCount();
successCount++;
log.info("회의 초대 알림 발송 성공 - Email: {}", participant.getEmail());
} catch (MessagingException e) {
// 3-6. 발송 실패 처리
NotificationRecipient recipient = notification.getRecipients().stream()
.filter(r -> r.getRecipientUserId().equals(participant.getUserId()))
.findFirst()
.orElse(null);
if (recipient != null) {
recipient.markAsFailed(e.getMessage());
notification.incrementFailedCount();
}
failureCount++;
log.error("회의 초대 알림 발송 실패 - Email: {}", participant.getEmail(), e);
}
}
// 4. 알림 상태 업데이트
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);
}
/**
* Todo 할당 알림 발송
*
* @param event Todo 할당 이벤트
*/
public void sendTodoAssignment(TodoAssignedEvent event) {
log.info("Todo 할당 알림 처리 시작 - TodoId: {}, EventId: {}",
event.getTodoId(), event.getEventId());
// 1. 중복 발송 방지 체크
if (notificationRepository.existsByEventId(event.getEventId())) {
log.warn("이미 처리된 이벤트 - EventId: {}", event.getEventId());
return;
}
// 2. 알림 엔티티 생성
Notification notification = Notification.builder()
.eventId(event.getEventId())
.referenceId(event.getTodoId())
.referenceType(Notification.ReferenceType.TODO)
.notificationType(Notification.NotificationType.TODO_ASSIGNED)
.title("Todo 할당: " + event.getTitle())
.message(event.getDescription())
.status(Notification.NotificationStatus.PROCESSING)
.channel(Notification.NotificationChannel.EMAIL)
.build();
notificationRepository.save(notification);
try {
// 3. 알림 설정 확인
TodoAssignedEvent.UserInfo assignee = event.getAssignee();
if (!canSendNotification(assignee.getUserId(), Notification.NotificationType.TODO_ASSIGNED)) {
log.info("알림 설정에 의해 발송 제외 - UserId: {}", assignee.getUserId());
notification.updateStatus(Notification.NotificationStatus.SENT);
return;
}
// 4. 수신자 엔티티 생성
NotificationRecipient recipient = NotificationRecipient.builder()
.recipientUserId(assignee.getUserId())
.recipientName(assignee.getName())
.recipientEmail(assignee.getEmail())
.status(NotificationRecipient.RecipientStatus.PENDING)
.build();
notification.addRecipient(recipient);
// 5. 이메일 템플릿 렌더링
Map<String, Object> variables = new HashMap<>();
variables.put("title", event.getTitle());
variables.put("description", event.getDescription());
variables.put("deadline", event.getDeadline().format(DATETIME_FORMATTER));
variables.put("priority", event.getPriority());
variables.put("assignedByName", event.getAssignedBy().getName());
variables.put("assigneeName", assignee.getName());
// 회의 관련 Todo인 경우 회의 정보 추가
if (event.getMeetingId() != null) {
variables.put("meetingId", event.getMeetingId());
}
String htmlContent = templateService.renderTodoAssigned(variables);
// 6. 이메일 발송
emailClient.sendHtmlEmail(
assignee.getEmail(),
"Todo 할당: " + event.getTitle(),
htmlContent
);
// 7. 발송 성공 처리
recipient.markAsSent();
notification.incrementSentCount();
notification.updateStatus(Notification.NotificationStatus.SENT);
log.info("Todo 할당 알림 발송 성공 - Email: {}", assignee.getEmail());
} catch (MessagingException e) {
// 8. 발송 실패 처리
NotificationRecipient recipient = notification.getRecipients().stream()
.findFirst()
.orElse(null);
if (recipient != null) {
recipient.markAsFailed(e.getMessage());
}
notification.incrementFailedCount();
notification.updateStatus(Notification.NotificationStatus.FAILED);
log.error("Todo 할당 알림 발송 실패", e);
}
notificationRepository.save(notification);
log.info("Todo 할당 알림 처리 완료");
}
/**
* 알림 발송 가능 여부 확인
*
* 사용자 알림 설정, 채널, 알림 유형, 방해 금지 시간대 체크
*
* @param userId 사용자 ID
* @param notificationType 알림 유형
* @return 발송 가능 여부
*/
private boolean canSendNotification(String userId, Notification.NotificationType notificationType) {
Optional<NotificationSetting> settingOpt = settingRepository.findByUserId(userId);
// 설정이 없으면 기본값으로 발송 허용 (이메일, 초대/할당 알림만)
if (settingOpt.isEmpty()) {
return notificationType == Notification.NotificationType.INVITATION
|| notificationType == Notification.NotificationType.TODO_ASSIGNED;
}
NotificationSetting setting = settingOpt.get();
// 이메일 채널 및 알림 유형 활성화 여부 확인
return setting.canSendNotification(notificationType, Notification.NotificationChannel.EMAIL);
}
}
@@ -87,6 +87,11 @@ azure:
name: ${AZURE_EVENTHUB_NAME:notification-events}
consumer-group: ${AZURE_EVENTHUB_CONSUMER_GROUP:$Default}
# Azure Blob Storage Configuration (for Event Hub Checkpoint)
storage:
connection-string: ${AZURE_STORAGE_CONNECTION_STRING:}
container-name: ${AZURE_STORAGE_CONTAINER_NAME:eventhub-checkpoints}
# Notification Configuration
notification:
from-email: ${NOTIFICATION_FROM_EMAIL:noreply@hgzero.com}