diff --git a/.gradle/8.14/checksums/checksums.lock b/.gradle/8.14/checksums/checksums.lock new file mode 100644 index 0000000..2a68f3c Binary files /dev/null and b/.gradle/8.14/checksums/checksums.lock differ diff --git a/.gradle/8.14/fileChanges/last-build.bin b/.gradle/8.14/fileChanges/last-build.bin new file mode 100644 index 0000000..f76dd23 Binary files /dev/null and b/.gradle/8.14/fileChanges/last-build.bin differ diff --git a/.gradle/8.14/fileHashes/fileHashes.lock b/.gradle/8.14/fileHashes/fileHashes.lock new file mode 100644 index 0000000..a76855d Binary files /dev/null and b/.gradle/8.14/fileHashes/fileHashes.lock differ diff --git a/.gradle/8.14/gc.properties b/.gradle/8.14/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/.gradle/buildOutputCleanup/buildOutputCleanup.lock new file mode 100644 index 0000000..e919f99 Binary files /dev/null and b/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/.gradle/buildOutputCleanup/cache.properties b/.gradle/buildOutputCleanup/cache.properties new file mode 100644 index 0000000..835b5b8 --- /dev/null +++ b/.gradle/buildOutputCleanup/cache.properties @@ -0,0 +1,2 @@ +#Thu Oct 23 15:10:42 KST 2025 +gradle.version=8.14 diff --git a/.gradle/vcs-1/gc.properties b/.gradle/vcs-1/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/build/reports/problems/problems-report.html b/build/reports/problems/problems-report.html new file mode 100644 index 0000000..54d21ff --- /dev/null +++ b/build/reports/problems/problems-report.html @@ -0,0 +1,663 @@ + + + + + + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/develop/dev/dev-notification.md b/develop/dev/dev-notification.md new file mode 100644 index 0000000..850bb71 --- /dev/null +++ b/develop/dev/dev-notification.md @@ -0,0 +1,623 @@ +# Notification Service - 백엔드 개발 결과서 + +## 1. 개요 + +### 1.1 서비스 설명 +- **서비스명**: Notification Service +- **목적**: 회의 초대 및 Todo 할당 알림 발송 서비스 +- **아키텍처 패턴**: Layered Architecture +- **주요 기능**: + - Azure Event Hubs를 통한 이벤트 기반 알림 발송 + - 이메일 템플릿 기반 알림 생성 + - 사용자별 알림 설정 관리 + - 재시도 메커니즘 (Exponential Backoff) + - 중복 발송 방지 (Idempotency) + +### 1.2 개발 기간 +- **시작일**: 2025-10-23 +- **완료일**: 2025-10-23 +- **개발자**: 준호 (Backend Developer) + +--- + +## 2. 기술 스택 + +### 2.1 프레임워크 및 라이브러리 +- **Spring Boot**: 3.3.0 +- **Java**: 21 +- **Spring Data JPA**: 3.3.0 +- **Spring Security**: 6.3.0 +- **Spring Mail**: 3.3.0 +- **Spring Retry**: 2.0.6 +- **Thymeleaf**: 3.1.2 +- **Azure Event Hubs**: 5.18.4 +- **Azure Storage Blob**: 12.26.1 +- **PostgreSQL Driver**: 42.7.3 +- **SpringDoc OpenAPI**: 2.5.0 +- **Lombok**: 1.18.32 + +### 2.2 데이터베이스 +- **DBMS**: PostgreSQL 16.4 +- **Host**: 4.230.159.143:5432 +- **Database**: notificationdb +- **User**: hgzerouser + +### 2.3 메시지 큐 +- **Azure Event Hubs**: 이벤트 기반 알림 처리 +- **Consumer Group**: notification-service +- **Checkpoint Store**: Azure Blob Storage + +### 2.4 이메일 발송 +- **SMTP**: Gmail SMTP 또는 사용자 정의 SMTP +- **Port**: 587 (TLS) +- **Template Engine**: Thymeleaf + +--- + +## 3. 구현 내용 + +### 3.1 패키지 구조 + +``` +notification/ +└── src/main/java/com/unicorn/hgzero/notification/ + ├── NotificationApplication.java # Spring Boot 메인 클래스 + │ + ├── domain/ # Domain Layer + │ ├── Notification.java # 알림 Entity + │ ├── NotificationRecipient.java # 수신자 Entity + │ └── NotificationSetting.java # 알림 설정 Entity + │ + ├── repository/ # Data Access Layer + │ ├── NotificationRepository.java + │ ├── NotificationRecipientRepository.java + │ └── NotificationSettingRepository.java + │ + ├── service/ # Business Logic Layer + │ ├── NotificationService.java # 알림 비즈니스 로직 + │ ├── EmailTemplateService.java # 템플릿 렌더링 + │ └── EmailClient.java # 이메일 발송 + │ + ├── controller/ # Presentation Layer + │ ├── NotificationController.java # 알림 조회 API + │ └── NotificationSettingsController.java # 설정 API + │ + ├── event/ # Event Handler Layer + │ ├── EventHandler.java # Event Hub 핸들러 + │ ├── event/ + │ │ ├── MeetingCreatedEvent.java # 회의 생성 이벤트 DTO + │ │ └── TodoAssignedEvent.java # Todo 할당 이벤트 DTO + │ └── processor/ + │ └── EventProcessorService.java # Processor 라이프사이클 + │ + ├── dto/ # Data Transfer Objects + │ ├── request/ + │ │ └── UpdateSettingsRequest.java + │ └── response/ + │ ├── NotificationResponse.java + │ ├── NotificationListResponse.java + │ └── SettingsResponse.java + │ + ├── config/ # Configuration Layer + │ ├── EventHubConfig.java # Event Hub 설정 + │ ├── BlobStorageConfig.java # Blob Storage 설정 + │ ├── RetryConfig.java # 재시도 정책 + │ ├── SecurityConfig.java # Spring Security + │ ├── SwaggerConfig.java # Swagger 설정 + │ └── EmailConfig.java # Email 설정 + │ + └── exception/ # Exception Handling + └── (향후 추가 예정) +``` + +### 3.2 구현된 주요 클래스 + +#### 3.2.1 Domain Layer (Entity) + +**Notification.java** (notification/src/main/java/com/unicorn/hgzero/notification/domain/Notification.java:1) +- 알림 기본 정보 관리 +- 수신자 목록 (OneToMany) +- 중복 방지를 위한 eventId (unique) +- 상태 추적 (PENDING, PROCESSING, SENT, FAILED, PARTIAL) + +**NotificationRecipient.java** (notification/src/main/java/com/unicorn/hgzero/notification/domain/NotificationRecipient.java:1) +- 수신자별 발송 상태 추적 +- 재시도 로직 지원 (retryCount, nextRetryAt) +- 발송 성공/실패 처리 메서드 + +**NotificationSetting.java** (notification/src/main/java/com/unicorn/hgzero/notification/domain/NotificationSetting.java:1) +- 사용자별 알림 설정 +- 채널 활성화 (email, SMS, push) +- 알림 유형별 활성화 +- 방해 금지 시간대 (DND) + +#### 3.2.2 Repository Layer + +**NotificationRepository.java** (notification/src/main/java/com/unicorn/hgzero/notification/repository/NotificationRepository.java:1) +- JpaRepository 확장 +- 커스텀 쿼리 메서드: + - findByEventId() - 중복 체크 + - findByReferenceIdAndReferenceType() - 참조 조회 + - findByStatusIn() - 배치 처리 + - countByStatusAndCreatedAtBetween() - 통계 + +**NotificationRecipientRepository.java** (notification/src/main/java/com/unicorn/hgzero/notification/repository/NotificationRecipientRepository.java:1) +- 수신자 관리 +- 커스텀 쿼리 메서드: + - findByNotificationId() - 알림별 수신자 + - findByStatusAndNextRetryAtBefore() - 재시도 대상 + - findByRecipientEmail() - 사용자 히스토리 + +**NotificationSettingRepository.java** (notification/src/main/java/com/unicorn/hgzero/notification/repository/NotificationSettingRepository.java:1) +- 알림 설정 관리 +- 커스텀 쿼리 메서드: + - findByUserId() - 사용자 설정 조회 + - findByEmailEnabledAndInvitationEnabled() - 발송 대상 필터링 + +#### 3.2.3 Service Layer + +**NotificationService.java** (notification/src/main/java/com/unicorn/hgzero/notification/service/NotificationService.java:1) +- 핵심 비즈니스 로직 +- 주요 메서드: + - sendMeetingInvitation() - 회의 초대 알림 발송 + - sendTodoAssignment() - Todo 할당 알림 발송 + - canSendNotification() - 알림 발송 가능 여부 확인 +- 기능: + - 이벤트 중복 체크 (eventId) + - 사용자 알림 설정 확인 + - 템플릿 렌더링 및 이메일 발송 + - 수신자별 상태 추적 + +**EmailTemplateService.java** (notification/src/main/java/com/unicorn/hgzero/notification/service/EmailTemplateService.java:1) +- Thymeleaf 템플릿 렌더링 +- 주요 메서드: + - renderMeetingInvitation() - 회의 초대 템플릿 + - renderTodoAssigned() - Todo 할당 템플릿 + - renderReminder() - 리마인더 템플릿 + +**EmailClient.java** (notification/src/main/java/com/unicorn/hgzero/notification/service/EmailClient.java:1) +- SMTP 이메일 발송 +- 재시도 메커니즘 (@Retryable) +- Exponential Backoff (5분 → 15분 → 30분) +- HTML 및 텍스트 이메일 지원 + +#### 3.2.4 Controller Layer + +**NotificationController.java** (notification/src/main/java/com/unicorn/hgzero/notification/controller/NotificationController.java:1) +- 알림 조회 API +- 엔드포인트: + - GET /notifications - 알림 목록 조회 + - GET /notifications/{id} - 특정 알림 조회 + - GET /notifications/statistics - 통계 조회 + +**NotificationSettingsController.java** (notification/src/main/java/com/unicorn/hgzero/notification/controller/NotificationSettingsController.java:1) +- 알림 설정 API +- 엔드포인트: + - GET /notifications/settings - 설정 조회 + - PUT /notifications/settings - 설정 업데이트 + +#### 3.2.5 Event Handler Layer + +**EventHandler.java** (notification/src/main/java/com/unicorn/hgzero/notification/event/EventHandler.java:1) +- Consumer 구현 +- 이벤트 수신 및 처리 +- 토픽별 라우팅 (meeting, todo) +- 이벤트 유형별 처리 (MeetingCreated, TodoAssigned) +- Checkpoint 업데이트 + +**EventProcessorService.java** (notification/src/main/java/com/unicorn/hgzero/notification/event/processor/EventProcessorService.java:1) +- EventProcessorClient 라이프사이클 관리 +- @PostConstruct - 시작 +- @PreDestroy - 종료 +- @Retryable - 재시도 지원 + +#### 3.2.6 Config Layer + +**EventHubConfig.java** (notification/src/main/java/com/unicorn/hgzero/notification/config/EventHubConfig.java:1) +- EventProcessorClient Bean 생성 +- BlobCheckpointStore 설정 +- 오류 핸들러 등록 + +**BlobStorageConfig.java** (notification/src/main/java/com/unicorn/hgzero/notification/config/BlobStorageConfig.java:1) +- Blob Container Async Client +- Checkpoint 저장소 연결 + +**RetryConfig.java** (notification/src/main/java/com/unicorn/hgzero/notification/config/RetryConfig.java:1) +- @EnableRetry +- RetryTemplate 구성 +- Exponential Backoff Policy + +**SecurityConfig.java** (notification/src/main/java/com/unicorn/hgzero/notification/config/SecurityConfig.java:1) +- Spring Security 설정 +- CORS 설정 +- Stateless 세션 관리 + +**SwaggerConfig.java** (notification/src/main/java/com/unicorn/hgzero/notification/config/SwaggerConfig.java:1) +- OpenAPI 3.0 설정 +- Swagger UI 구성 + +**EmailConfig.java** (notification/src/main/java/com/unicorn/hgzero/notification/config/EmailConfig.java:1) +- JavaMailSender Bean +- SMTP 설정 + +--- + +## 4. 데이터베이스 설계 + +### 4.1 테이블 구조 + +#### notifications (알림 테이블) +```sql +CREATE TABLE notifications ( + notification_id VARCHAR(36) PRIMARY KEY, + event_id VARCHAR(100) UNIQUE NOT NULL, + reference_id VARCHAR(36) NOT NULL, + reference_type VARCHAR(20) NOT NULL, + notification_type VARCHAR(30) NOT NULL, + title VARCHAR(500) NOT NULL, + message TEXT, + status VARCHAR(20) NOT NULL, + channel VARCHAR(20) NOT NULL, + sent_count INTEGER NOT NULL DEFAULT 0, + failed_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL, + sent_at TIMESTAMP +); + +CREATE INDEX idx_notification_reference ON notifications(reference_id, reference_type); +CREATE INDEX idx_notification_created_at ON notifications(created_at); +``` + +#### notification_recipients (수신자 테이블) +```sql +CREATE TABLE notification_recipients ( + recipient_id VARCHAR(36) PRIMARY KEY, + notification_id VARCHAR(36) NOT NULL, + recipient_user_id VARCHAR(100) NOT NULL, + recipient_name VARCHAR(200) NOT NULL, + recipient_email VARCHAR(320) NOT NULL, + status VARCHAR(20) NOT NULL, + retry_count INTEGER NOT NULL DEFAULT 0, + sent_at TIMESTAMP, + error_message VARCHAR(1000), + next_retry_at TIMESTAMP, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP, + FOREIGN KEY (notification_id) REFERENCES notifications(notification_id) +); + +CREATE INDEX idx_recipient_notification ON notification_recipients(notification_id); +CREATE INDEX idx_recipient_email ON notification_recipients(recipient_email); +CREATE INDEX idx_recipient_status ON notification_recipients(status); +``` + +#### notification_settings (알림 설정 테이블) +```sql +CREATE TABLE notification_settings ( + user_id VARCHAR(100) PRIMARY KEY, + email_enabled BOOLEAN NOT NULL DEFAULT TRUE, + sms_enabled BOOLEAN NOT NULL DEFAULT FALSE, + push_enabled BOOLEAN NOT NULL DEFAULT FALSE, + invitation_enabled BOOLEAN NOT NULL DEFAULT TRUE, + todo_assigned_enabled BOOLEAN NOT NULL DEFAULT TRUE, + todo_reminder_enabled BOOLEAN NOT NULL DEFAULT TRUE, + meeting_reminder_enabled BOOLEAN NOT NULL DEFAULT TRUE, + minutes_updated_enabled BOOLEAN NOT NULL DEFAULT TRUE, + todo_completed_enabled BOOLEAN NOT NULL DEFAULT FALSE, + dnd_enabled BOOLEAN NOT NULL DEFAULT FALSE, + dnd_start_time TIME, + dnd_end_time TIME, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP +); + +CREATE UNIQUE INDEX idx_setting_user_id ON notification_settings(user_id); +``` + +--- + +## 5. API 명세 + +### 5.1 알림 조회 API + +#### 알림 목록 조회 +- **URL**: GET /notifications +- **Query Parameters**: + - referenceType: MEETING | TODO (optional) + - notificationType: INVITATION | TODO_ASSIGNED | ... (optional) + - status: PENDING | SENT | FAILED | PARTIAL (optional) + - startDate: ISO DateTime (optional) + - endDate: ISO DateTime (optional) +- **Response**: List + +#### 알림 상세 조회 +- **URL**: GET /notifications/{notificationId} +- **Path Parameter**: notificationId (String) +- **Response**: NotificationResponse + +#### 알림 통계 조회 +- **URL**: GET /notifications/statistics +- **Query Parameters**: + - startDate: ISO DateTime (required) + - endDate: ISO DateTime (required) +- **Response**: { sent, failed, partial, total } + +### 5.2 알림 설정 API + +#### 알림 설정 조회 +- **URL**: GET /notifications/settings +- **Query Parameter**: userId (String, required) +- **Response**: SettingsResponse + +#### 알림 설정 업데이트 +- **URL**: PUT /notifications/settings +- **Query Parameter**: userId (String, required) +- **Request Body**: UpdateSettingsRequest +- **Response**: SettingsResponse + +--- + +## 6. 이벤트 처리 흐름 + +### 6.1 회의 초대 알림 발송 + +``` +1. Meeting Service → Event Hub (MeetingCreatedEvent) +2. Event Hub → NotificationService (EventHandler.accept()) +3. EventHandler → NotificationService.sendMeetingInvitation() +4. NotificationService: + - 중복 체크 (eventId) + - Notification 엔티티 생성 + - 각 참석자별: + - 알림 설정 확인 + - NotificationRecipient 생성 + - EmailTemplateService.renderMeetingInvitation() + - EmailClient.sendHtmlEmail() + - 상태 업데이트 (SENT/FAILED) + - Notification 상태 업데이트 (SENT/PARTIAL/FAILED) +5. EventHandler → eventContext.updateCheckpoint() +``` + +### 6.2 Todo 할당 알림 발송 + +``` +1. Meeting/AI Service → Event Hub (TodoAssignedEvent) +2. Event Hub → NotificationService (EventHandler.accept()) +3. EventHandler → NotificationService.sendTodoAssignment() +4. NotificationService: + - 중복 체크 (eventId) + - Notification 엔티티 생성 + - 알림 설정 확인 (assignee) + - NotificationRecipient 생성 + - EmailTemplateService.renderTodoAssigned() + - EmailClient.sendHtmlEmail() + - 상태 업데이트 (SENT/FAILED) +5. EventHandler → eventContext.updateCheckpoint() +``` + +--- + +## 7. 재시도 메커니즘 + +### 7.1 재시도 정책 +- **최대 재시도 횟수**: 3회 +- **Backoff 전략**: Exponential Backoff +- **초기 대기 시간**: 5분 (300,000ms) +- **최대 대기 시간**: 30분 (1,800,000ms) +- **배수**: 2.0 + +### 7.2 재시도 시나리오 + +1. **이메일 발송 실패 시**: + - EmailClient의 @Retryable 어노테이션 적용 + - 1차 실패 → 5분 후 재시도 + - 2차 실패 → 10분 후 재시도 + - 3차 실패 → 20분 후 재시도 + - 최종 실패 → NotificationRecipient 상태를 FAILED로 변경 + +2. **Event Processor 시작 실패 시**: + - EventProcessorService의 @Retryable 적용 + - 최대 3번 재시도 (2초, 4초, 8초 대기) + +--- + +## 8. 설정 파일 + +### 8.1 application.yml +- **위치**: notification/src/main/resources/application.yml +- **주요 설정**: + - 데이터베이스 연결 (PostgreSQL) + - Azure Event Hubs 연결 + - Azure Blob Storage 연결 + - SMTP 설정 + - Thymeleaf 템플릿 + - Actuator + - Logging + - SpringDoc OpenAPI + +### 8.2 환경 변수 (필수) +```bash +# 데이터베이스 +DB_PASSWORD= + +# Azure Event Hub +AZURE_EVENTHUB_CONNECTION_STRING= + +# Azure Blob Storage +AZURE_STORAGE_CONNECTION_STRING= + +# 이메일 +MAIL_USERNAME= +MAIL_PASSWORD= + +# JWT (향후 추가) +JWT_SECRET= +``` + +--- + +## 9. 빌드 및 실행 + +### 9.1 빌드 방법 + +#### IntelliJ IDEA 사용 +1. IntelliJ에서 프로젝트 열기 +2. Gradle 탭에서 notification → Tasks → build → build 더블클릭 +3. 또는 우측 상단 Build → Build Project + +#### 커맨드 라인 (Gradle 설치 필요) +```bash +# Gradle Wrapper 생성 (프로젝트 루트에서) +gradle wrapper + +# 빌드 +./gradlew :notification:build + +# 빌드 결과 확인 +ls notification/build/libs/ +``` + +### 9.2 실행 방법 + +#### IntelliJ IDEA 사용 +1. NotificationApplication.java 파일 열기 +2. main() 메서드 좌측의 실행 버튼 클릭 +3. 또는 Run → Run 'NotificationApplication' + +#### JAR 파일 실행 +```bash +java -jar notification/build/libs/notification.jar +``` + +### 9.3 서버 포트 +- **기본 포트**: 8084 +- **변경 방법**: 환경 변수 SERVER_PORT 설정 + +--- + +## 10. 테스트 + +### 10.1 API 테스트 (Swagger UI) +1. 서버 실행 후 브라우저에서 접속: + ``` + http://localhost:8084/swagger-ui.html + ``` + +2. API 엔드포인트 테스트: + - GET /notifications - 알림 목록 조회 + - GET /notifications/{id} - 특정 알림 조회 + - GET /notifications/settings - 알림 설정 조회 + - PUT /notifications/settings - 알림 설정 업데이트 + +### 10.2 이벤트 발행 테스트 +1. Meeting Service에서 MeetingCreatedEvent 발행 +2. Notification Service 로그 확인: + ``` + 이벤트 수신 - Topic: meeting, EventType: MeetingCreated + 회의 초대 알림 처리 시작 - MeetingId: xxx, EventId: xxx + 회의 초대 알림 발송 성공 - Email: xxx@xxx.com + ``` + +### 10.3 Health Check +```bash +curl http://localhost:8084/actuator/health +``` + +--- + +## 11. 향후 개선 사항 + +### 11.1 기능 개선 +1. **SMS 발송 지원**: 현재 이메일만 지원, SMS 발송 기능 추가 +2. **Push 알림 지원**: 모바일 Push 알림 기능 추가 +3. **리마인더 스케줄링**: 회의 및 Todo 리마인더 자동 발송 +4. **배치 알림 발송**: 대량 알림 발송 최적화 +5. **알림 템플릿 관리**: 템플릿 동적 관리 및 다국어 지원 + +### 11.2 성능 개선 +1. **비동기 이메일 발송**: @Async를 사용한 비동기 처리 +2. **캐싱 적용**: 알림 설정 캐싱으로 DB 부하 감소 +3. **배치 처리**: 대량 수신자 알림의 배치 처리 +4. **Connection Pool 최적화**: HikariCP 설정 최적화 + +### 11.3 모니터링 개선 +1. **메트릭 수집**: Prometheus 메트릭 추가 +2. **로그 집계**: ELK Stack 연동 +3. **알림 대시보드**: Grafana 대시보드 구성 +4. **알림 실패 알람**: 발송 실패 시 관리자 알림 + +--- + +## 12. 문제 해결 가이드 + +### 12.1 이메일 발송 실패 +**증상**: 이메일이 발송되지 않음 + +**원인 및 해결방법**: +1. SMTP 인증 정보 확인: + ```bash + MAIL_USERNAME=<이메일 주소> + MAIL_PASSWORD=<앱 비밀번호> + ``` + +2. Gmail 사용 시 앱 비밀번호 생성: + - Google 계정 → 보안 → 2단계 인증 활성화 + - 앱 비밀번호 생성 + +3. 방화벽 확인: + - 포트 587 (TLS) 또는 465 (SSL) 개방 확인 + +### 12.2 Event Hub 연결 실패 +**증상**: 이벤트를 수신하지 못함 + +**원인 및 해결방법**: +1. 연결 문자열 확인: + ```bash + AZURE_EVENTHUB_CONNECTION_STRING=<연결 문자열> + ``` + +2. Consumer Group 확인: + ```yaml + azure: + eventhub: + consumer-group: notification-service + ``` + +3. Event Hub 방화벽 설정 확인 + +### 12.3 데이터베이스 연결 실패 +**증상**: 애플리케이션 시작 실패 + +**원인 및 해결방법**: +1. PostgreSQL 서버 상태 확인 +2. 연결 정보 확인: + ```yaml + datasource: + url: jdbc:postgresql://4.230.159.143:5432/notificationdb + username: hgzerouser + password: ${DB_PASSWORD} + ``` + +3. 방화벽 확인: 포트 5432 개방 확인 + +--- + +## 13. 참고 자료 + +### 13.1 문서 +- [백엔드개발가이드](claude/dev-backend.md) +- [패키지구조도](develop/dev/package-structure-notification.md) +- [API설계서](design/backend/api/API설계서.md) +- [데이터베이스설치결과서](develop/database/exec/db-exec-dev.md) + +### 13.2 외부 라이브러리 +- [Spring Boot Documentation](https://spring.io/projects/spring-boot) +- [Azure Event Hubs Java SDK](https://learn.microsoft.com/en-us/azure/event-hubs/event-hubs-java-get-started-send) +- [Spring Retry](https://github.com/spring-projects/spring-retry) +- [Thymeleaf](https://www.thymeleaf.org/) + +--- + +**작성일**: 2025-10-23 +**작성자**: 준호 (Backend Developer) +**버전**: 1.0 diff --git a/develop/dev/package-structure-notification.md b/develop/dev/package-structure-notification.md new file mode 100644 index 0000000..5d05f60 --- /dev/null +++ b/develop/dev/package-structure-notification.md @@ -0,0 +1,146 @@ +# Notification Service - 패키지 구조도 + +## 아키텍처 패턴 +- **Layered Architecture**: 단순하고 명확한 계층 구조 + +## 패키지 구조 + +``` +notification/ +└── src/ + └── main/ + ├── java/ + │ └── com/ + │ └── unicorn/ + │ └── hgzero/ + │ └── notification/ + │ ├── NotificationApplication.java + │ │ + │ ├── domain/ # Domain Layer + │ │ ├── Notification.java # 알림 Entity + │ │ ├── NotificationRecipient.java # 수신자 Entity + │ │ └── NotificationSetting.java # 알림 설정 Entity + │ │ + │ ├── repository/ # Data Access Layer + │ │ ├── NotificationRepository.java + │ │ ├── NotificationRecipientRepository.java + │ │ └── NotificationSettingRepository.java + │ │ + │ ├── service/ # Business Logic Layer + │ │ ├── NotificationService.java # 알림 비즈니스 로직 + │ │ ├── EmailTemplateService.java # 이메일 템플릿 렌더링 + │ │ └── EmailClient.java # 이메일 발송 클라이언트 + │ │ + │ ├── controller/ # Presentation Layer + │ │ ├── NotificationController.java # Public API + │ │ └── NotificationSettingsController.java # 설정 API + │ │ + │ ├── event/ # Event Handler Layer + │ │ ├── EventHandler.java # Event Hub 이벤트 핸들러 + │ │ ├── event/ + │ │ │ ├── MeetingCreatedEvent.java # 회의 생성 이벤트 + │ │ │ └── TodoAssignedEvent.java # Todo 할당 이벤트 + │ │ └── processor/ + │ │ └── EventProcessorService.java # Processor 라이프사이클 + │ │ + │ ├── dto/ # Data Transfer Objects + │ │ ├── request/ + │ │ │ ├── SendNotificationRequest.java + │ │ │ └── UpdateSettingsRequest.java + │ │ └── response/ + │ │ ├── NotificationResponse.java + │ │ ├── NotificationListResponse.java + │ │ └── SettingsResponse.java + │ │ + │ ├── config/ # Configuration Layer + │ │ ├── EventHubConfig.java # Event Hub 설정 + │ │ ├── BlobStorageConfig.java # Blob Storage 설정 + │ │ ├── RetryConfig.java # 재시도 정책 설정 + │ │ ├── SecurityConfig.java # Spring Security 설정 + │ │ ├── SwaggerConfig.java # Swagger 설정 + │ │ └── EmailConfig.java # Email 설정 + │ │ + │ └── exception/ # Exception Handling + │ ├── GlobalExceptionHandler.java + │ ├── NotificationException.java + │ └── EventProcessingException.java + │ + └── resources/ + ├── application.yml # 메인 설정 파일 + ├── application-dev.yml # 개발 환경 설정 + ├── application-prod.yml # 운영 환경 설정 + └── templates/ # Email Templates + ├── meeting-invitation.html # 회의 초대 템플릿 + ├── todo-assigned.html # Todo 할당 템플릿 + └── reminder.html # 리마인더 템플릿 +``` + +## 주요 클래스 역할 + +### Domain Layer +- **Notification**: 알림 정보 엔티티 (알림ID, 유형, 상태, 발송일시) +- **NotificationRecipient**: 수신자별 알림 상태 (발송완료, 실패, 재시도) +- **NotificationSetting**: 사용자별 알림 설정 (채널, 유형, 방해금지 시간대) + +### Repository Layer +- **NotificationRepository**: 알림 이력 조회/저장 +- **NotificationRecipientRepository**: 수신자별 상태 관리 +- **NotificationSettingRepository**: 알림 설정 관리 + +### Service Layer +- **NotificationService**: 알림 발송 비즈니스 로직, 중복 방지, 재시도 관리 +- **EmailTemplateService**: Thymeleaf 템플릿 렌더링 +- **EmailClient**: SMTP 이메일 발송, 에러 처리 + +### Controller Layer +- **NotificationController**: 알림 발송 API, 알림 이력 조회 API +- **NotificationSettingsController**: 알림 설정 조회/업데이트 API + +### Event Handler Layer +- **EventHandler**: Event Hub 이벤트 수신 및 처리 (Consumer 구현) +- **EventProcessorService**: EventProcessorClient 라이프사이클 관리 +- **MeetingCreatedEvent**: 회의 생성 이벤트 DTO +- **TodoAssignedEvent**: Todo 할당 이벤트 DTO + +### Config Layer +- **EventHubConfig**: EventProcessorClient Bean 생성, CheckpointStore 설정 +- **BlobStorageConfig**: Azure Blob Storage 연결 설정 +- **RetryConfig**: @EnableRetry, ExponentialBackOffPolicy 설정 +- **SecurityConfig**: JWT 인증, CORS 설정 +- **SwaggerConfig**: OpenAPI 문서화 설정 +- **EmailConfig**: JavaMailSender 설정 + +## 의존성 흐름 +``` +Controller → Service → Repository → Entity + ↓ + EmailClient + ↓ + EmailTemplateService + +EventHandler → Service → Repository +``` + +## 기술 스택 +- **Framework**: Spring Boot 3.3.0, Java 21 +- **Database**: PostgreSQL (JPA/Hibernate) +- **Messaging**: Azure Event Hubs +- **Storage**: Azure Blob Storage (Checkpoint) +- **Email**: Spring Mail (SMTP) +- **Template**: Thymeleaf +- **Retry**: Spring Retry +- **Security**: Spring Security + JWT +- **Documentation**: SpringDoc OpenAPI + +## 특징 +1. **Layered Architecture**: 계층 분리로 명확한 역할과 책임 +2. **Event-Driven**: Azure Event Hubs 기반 비동기 처리 +3. **Retry Mechanism**: Exponential Backoff 기반 재시도 +4. **Template Engine**: Thymeleaf로 동적 이메일 생성 +5. **Idempotency**: 이벤트 ID 기반 중복 발송 방지 +6. **Monitoring**: Actuator Health Check, Metrics + +--- + +**작성일**: 2025-10-23 +**작성자**: 준호 (Backend Developer) diff --git a/notification/src/main/java/com/unicorn/hgzero/notification/NotificationApplication.java b/notification/src/main/java/com/unicorn/hgzero/notification/NotificationApplication.java new file mode 100644 index 0000000..187e6fc --- /dev/null +++ b/notification/src/main/java/com/unicorn/hgzero/notification/NotificationApplication.java @@ -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); + } +} diff --git a/notification/src/main/java/com/unicorn/hgzero/notification/config/BlobStorageConfig.java b/notification/src/main/java/com/unicorn/hgzero/notification/config/BlobStorageConfig.java new file mode 100644 index 0000000..3284d70 --- /dev/null +++ b/notification/src/main/java/com/unicorn/hgzero/notification/config/BlobStorageConfig.java @@ -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; + } +} diff --git a/notification/src/main/java/com/unicorn/hgzero/notification/config/EmailConfig.java b/notification/src/main/java/com/unicorn/hgzero/notification/config/EmailConfig.java new file mode 100644 index 0000000..e6f76b2 --- /dev/null +++ b/notification/src/main/java/com/unicorn/hgzero/notification/config/EmailConfig.java @@ -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; + } +} diff --git a/notification/src/main/java/com/unicorn/hgzero/notification/config/EventHubConfig.java b/notification/src/main/java/com/unicorn/hgzero/notification/config/EventHubConfig.java new file mode 100644 index 0000000..e789ae5 --- /dev/null +++ b/notification/src/main/java/com/unicorn/hgzero/notification/config/EventHubConfig.java @@ -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 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; + } +} diff --git a/notification/src/main/java/com/unicorn/hgzero/notification/config/RetryConfig.java b/notification/src/main/java/com/unicorn/hgzero/notification/config/RetryConfig.java new file mode 100644 index 0000000..7813a50 --- /dev/null +++ b/notification/src/main/java/com/unicorn/hgzero/notification/config/RetryConfig.java @@ -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; + } +} diff --git a/notification/src/main/java/com/unicorn/hgzero/notification/config/SecurityConfig.java b/notification/src/main/java/com/unicorn/hgzero/notification/config/SecurityConfig.java new file mode 100644 index 0000000..44af01d --- /dev/null +++ b/notification/src/main/java/com/unicorn/hgzero/notification/config/SecurityConfig.java @@ -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; + } +} diff --git a/notification/src/main/java/com/unicorn/hgzero/notification/config/SwaggerConfig.java b/notification/src/main/java/com/unicorn/hgzero/notification/config/SwaggerConfig.java new file mode 100644 index 0000000..84a88d6 --- /dev/null +++ b/notification/src/main/java/com/unicorn/hgzero/notification/config/SwaggerConfig.java @@ -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; + } +} diff --git a/notification/src/main/java/com/unicorn/hgzero/notification/controller/NotificationController.java b/notification/src/main/java/com/unicorn/hgzero/notification/controller/NotificationController.java new file mode 100644 index 0000000..23ce4ee --- /dev/null +++ b/notification/src/main/java/com/unicorn/hgzero/notification/controller/NotificationController.java @@ -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> 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 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 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 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 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); + } +} diff --git a/notification/src/main/java/com/unicorn/hgzero/notification/controller/NotificationSettingsController.java b/notification/src/main/java/com/unicorn/hgzero/notification/controller/NotificationSettingsController.java new file mode 100644 index 0000000..19fd339 --- /dev/null +++ b/notification/src/main/java/com/unicorn/hgzero/notification/controller/NotificationSettingsController.java @@ -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 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 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); + } +} diff --git a/notification/src/main/java/com/unicorn/hgzero/notification/domain/Notification.java b/notification/src/main/java/com/unicorn/hgzero/notification/domain/Notification.java new file mode 100644 index 0000000..748ce78 --- /dev/null +++ b/notification/src/main/java/com/unicorn/hgzero/notification/domain/Notification.java @@ -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 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 알림 + } +} diff --git a/notification/src/main/java/com/unicorn/hgzero/notification/domain/NotificationRecipient.java b/notification/src/main/java/com/unicorn/hgzero/notification/domain/NotificationRecipient.java new file mode 100644 index 0000000..2a74ff9 --- /dev/null +++ b/notification/src/main/java/com/unicorn/hgzero/notification/domain/NotificationRecipient.java @@ -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 // 재시도 예정 + } +} diff --git a/notification/src/main/java/com/unicorn/hgzero/notification/domain/NotificationSetting.java b/notification/src/main/java/com/unicorn/hgzero/notification/domain/NotificationSetting.java new file mode 100644 index 0000000..c3e7e8b --- /dev/null +++ b/notification/src/main/java/com/unicorn/hgzero/notification/domain/NotificationSetting.java @@ -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; + } +} diff --git a/notification/src/main/java/com/unicorn/hgzero/notification/dto/request/UpdateSettingsRequest.java b/notification/src/main/java/com/unicorn/hgzero/notification/dto/request/UpdateSettingsRequest.java new file mode 100644 index 0000000..969bca0 --- /dev/null +++ b/notification/src/main/java/com/unicorn/hgzero/notification/dto/request/UpdateSettingsRequest.java @@ -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; +} diff --git a/notification/src/main/java/com/unicorn/hgzero/notification/dto/response/NotificationListResponse.java b/notification/src/main/java/com/unicorn/hgzero/notification/dto/response/NotificationListResponse.java new file mode 100644 index 0000000..0d2e85b --- /dev/null +++ b/notification/src/main/java/com/unicorn/hgzero/notification/dto/response/NotificationListResponse.java @@ -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(); + } +} diff --git a/notification/src/main/java/com/unicorn/hgzero/notification/dto/response/NotificationResponse.java b/notification/src/main/java/com/unicorn/hgzero/notification/dto/response/NotificationResponse.java new file mode 100644 index 0000000..86ba12b --- /dev/null +++ b/notification/src/main/java/com/unicorn/hgzero/notification/dto/response/NotificationResponse.java @@ -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 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(); + } + } +} diff --git a/notification/src/main/java/com/unicorn/hgzero/notification/dto/response/SettingsResponse.java b/notification/src/main/java/com/unicorn/hgzero/notification/dto/response/SettingsResponse.java new file mode 100644 index 0000000..e5a9df3 --- /dev/null +++ b/notification/src/main/java/com/unicorn/hgzero/notification/dto/response/SettingsResponse.java @@ -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(); + } +} diff --git a/notification/src/main/java/com/unicorn/hgzero/notification/event/EventHandler.java b/notification/src/main/java/com/unicorn/hgzero/notification/event/EventHandler.java new file mode 100644 index 0000000..0244887 --- /dev/null +++ b/notification/src/main/java/com/unicorn/hgzero/notification/event/EventHandler.java @@ -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 { + + 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 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; + }); + } +} diff --git a/notification/src/main/java/com/unicorn/hgzero/notification/event/event/MeetingCreatedEvent.java b/notification/src/main/java/com/unicorn/hgzero/notification/event/event/MeetingCreatedEvent.java new file mode 100644 index 0000000..e16f76c --- /dev/null +++ b/notification/src/main/java/com/unicorn/hgzero/notification/event/event/MeetingCreatedEvent.java @@ -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 participants; + + /** + * 이벤트 발행 일시 + */ + private LocalDateTime createdAt; + + /** + * 참석자 정보 DTO + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class ParticipantInfo { + /** + * 사용자 ID + */ + private String userId; + + /** + * 사용자 이름 + */ + private String name; + + /** + * 사용자 이메일 + */ + private String email; + } +} diff --git a/notification/src/main/java/com/unicorn/hgzero/notification/event/event/TodoAssignedEvent.java b/notification/src/main/java/com/unicorn/hgzero/notification/event/event/TodoAssignedEvent.java new file mode 100644 index 0000000..882470c --- /dev/null +++ b/notification/src/main/java/com/unicorn/hgzero/notification/event/event/TodoAssignedEvent.java @@ -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; + } +} diff --git a/notification/src/main/java/com/unicorn/hgzero/notification/event/processor/EventProcessorService.java b/notification/src/main/java/com/unicorn/hgzero/notification/event/processor/EventProcessorService.java new file mode 100644 index 0000000..f05e2ae --- /dev/null +++ b/notification/src/main/java/com/unicorn/hgzero/notification/event/processor/EventProcessorService.java @@ -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); + } + } +} diff --git a/notification/src/main/java/com/unicorn/hgzero/notification/repository/NotificationRecipientRepository.java b/notification/src/main/java/com/unicorn/hgzero/notification/repository/NotificationRecipientRepository.java new file mode 100644 index 0000000..f3f5176 --- /dev/null +++ b/notification/src/main/java/com/unicorn/hgzero/notification/repository/NotificationRecipientRepository.java @@ -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 { + + /** + * 알림별 수신자 목록 조회 + * + * @param notificationId 알림 ID + * @return 수신자 목록 + */ + @Query("SELECT nr FROM NotificationRecipient nr WHERE nr.notification.notificationId = :notificationId") + List findByNotificationId(@Param("notificationId") String notificationId); + + /** + * 재시도 대상 수신자 조회 + * + * 상태가 RETRY이고 재시도 시간이 현재 시각 이전인 수신자 조회 + * + * @param status 수신자 상태 (RETRY) + * @param now 현재 시각 + * @return 재시도 대상 수신자 목록 + */ + List findByStatusAndNextRetryAtBefore( + NotificationRecipient.RecipientStatus status, + LocalDateTime now + ); + + /** + * 이메일 주소로 수신자 히스토리 조회 + * + * @param recipientEmail 수신자 이메일 + * @return 수신자 목록 + */ + List findByRecipientEmail(String recipientEmail); + + /** + * 사용자 ID로 수신자 히스토리 조회 + * + * @param recipientUserId 수신자 사용자 ID + * @return 수신자 목록 + */ + List findByRecipientUserId(String recipientUserId); + + /** + * 상태별 수신자 목록 조회 + * + * @param status 수신자 상태 + * @return 수신자 목록 + */ + List findByStatus(NotificationRecipient.RecipientStatus status); + + /** + * 특정 재시도 횟수 이상인 수신자 조회 (모니터링용) + * + * @param minRetryCount 최소 재시도 횟수 + * @return 수신자 목록 + */ + @Query("SELECT nr FROM NotificationRecipient nr WHERE nr.retryCount >= :minRetryCount") + List 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 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 + ); +} diff --git a/notification/src/main/java/com/unicorn/hgzero/notification/repository/NotificationRepository.java b/notification/src/main/java/com/unicorn/hgzero/notification/repository/NotificationRepository.java new file mode 100644 index 0000000..4b6c868 --- /dev/null +++ b/notification/src/main/java/com/unicorn/hgzero/notification/repository/NotificationRepository.java @@ -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 { + + /** + * 이벤트 ID로 알림 조회 (중복 발송 방지용) + * + * @param eventId 이벤트 고유 ID + * @return 알림 정보 + */ + Optional findByEventId(String eventId); + + /** + * 참조 대상으로 알림 목록 조회 + * + * @param referenceId 참조 대상 ID (meetingId 또는 todoId) + * @param referenceType 참조 유형 (MEETING, TODO) + * @return 알림 목록 + */ + List findByReferenceIdAndReferenceType( + String referenceId, + Notification.ReferenceType referenceType + ); + + /** + * 상태별 알림 목록 조회 (배치 처리용) + * + * @param statuses 조회할 상태 목록 + * @return 알림 목록 + */ + List findByStatusIn(List statuses); + + /** + * 기간별 알림 목록 조회 + * + * @param startDate 시작 일시 + * @param endDate 종료 일시 + * @return 알림 목록 + */ + List findByCreatedAtBetween(LocalDateTime startDate, LocalDateTime endDate); + + /** + * 알림 유형별 알림 목록 조회 + * + * @param notificationType 알림 유형 + * @return 알림 목록 + */ + List findByNotificationType(Notification.NotificationType notificationType); + + /** + * 채널별 알림 목록 조회 + * + * @param channel 발송 채널 + * @return 알림 목록 + */ + List findByChannel(Notification.NotificationChannel channel); + + /** + * 발송 실패 건수가 특정 수 이상인 알림 조회 (모니터링용) + * + * @param minFailedCount 최소 실패 건수 + * @return 알림 목록 + */ + @Query("SELECT n FROM Notification n WHERE n.failedCount >= :minFailedCount") + List 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 + ); +} diff --git a/notification/src/main/java/com/unicorn/hgzero/notification/repository/NotificationSettingRepository.java b/notification/src/main/java/com/unicorn/hgzero/notification/repository/NotificationSettingRepository.java new file mode 100644 index 0000000..736b4be --- /dev/null +++ b/notification/src/main/java/com/unicorn/hgzero/notification/repository/NotificationSettingRepository.java @@ -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 { + + /** + * 사용자 ID로 알림 설정 조회 + * + * userId가 PK이므로 findById(userId)와 동일하지만 명시적인 메서드명 제공 + * + * @param userId 사용자 ID + * @return 알림 설정 (없으면 empty) + */ + Optional findByUserId(String userId); + + /** + * 사용자 ID 존재 여부 확인 + * + * @param userId 사용자 ID + * @return 존재 여부 + */ + boolean existsByUserId(String userId); + + /** + * 이메일 알림 활성화된 사용자 목록 조회 + * + * @param emailEnabled 이메일 알림 활성화 여부 + * @return 알림 설정 목록 + */ + List findByEmailEnabled(boolean emailEnabled); + + /** + * SMS 알림 활성화된 사용자 목록 조회 + * + * @param smsEnabled SMS 알림 활성화 여부 + * @return 알림 설정 목록 + */ + List findBySmsEnabled(boolean smsEnabled); + + /** + * Push 알림 활성화된 사용자 목록 조회 + * + * @param pushEnabled Push 알림 활성화 여부 + * @return 알림 설정 목록 + */ + List findByPushEnabled(boolean pushEnabled); + + /** + * 방해 금지 모드 활성화된 사용자 목록 조회 + * + * @param dndEnabled 방해 금지 모드 활성화 여부 + * @return 알림 설정 목록 + */ + List 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 findByEmailEnabledAndInvitationEnabled( + boolean emailEnabled, + boolean invitationEnabled + ); + + /** + * 이메일과 Todo 할당 알림이 모두 활성화된 사용자 조회 + * + * @param emailEnabled 이메일 알림 활성화 여부 + * @param todoAssignedEnabled Todo 할당 알림 활성화 여부 + * @return 알림 설정 목록 + */ + List 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 findUsersWithAllChannelsDisabled( + @Param("emailEnabled") boolean emailEnabled, + @Param("smsEnabled") boolean smsEnabled, + @Param("pushEnabled") boolean pushEnabled + ); +} diff --git a/notification/src/main/java/com/unicorn/hgzero/notification/service/EmailClient.java b/notification/src/main/java/com/unicorn/hgzero/notification/service/EmailClient.java new file mode 100644 index 0000000..29185e9 --- /dev/null +++ b/notification/src/main/java/com/unicorn/hgzero/notification/service/EmailClient.java @@ -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; + } + } +} diff --git a/notification/src/main/java/com/unicorn/hgzero/notification/service/EmailTemplateService.java b/notification/src/main/java/com/unicorn/hgzero/notification/service/EmailTemplateService.java new file mode 100644 index 0000000..570e1a2 --- /dev/null +++ b/notification/src/main/java/com/unicorn/hgzero/notification/service/EmailTemplateService.java @@ -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 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 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 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 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); + } + } +} diff --git a/notification/src/main/java/com/unicorn/hgzero/notification/service/NotificationService.java b/notification/src/main/java/com/unicorn/hgzero/notification/service/NotificationService.java new file mode 100644 index 0000000..2f37f37 --- /dev/null +++ b/notification/src/main/java/com/unicorn/hgzero/notification/service/NotificationService.java @@ -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 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 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 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); + } +} diff --git a/notification/src/main/resources/application.yml b/notification/src/main/resources/application.yml index fabd4f3..008894f 100644 --- a/notification/src/main/resources/application.yml +++ b/notification/src/main/resources/application.yml @@ -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}