Skip to content

Commit

Permalink
[FEAT] 이메일 알림 시스템 구현 (#90)
Browse files Browse the repository at this point in the history
- 알림 관련 클래스 추가: NotificationType, NotificationEvent, TemplateProcessor
- Thymeleaf, AWS SES 의존성 추가 및 관련 설정 클래스 구현
- 알림 이벤트 리스너와 이메일 템플릿 추가, 사용자 승인/거절 API에 이메일 발송 로직 구현
- 알림 관련 에러 코드 및 커스텀 예외 클래스 추가, EmailService 예외 처리 개선
- 비동기 처리를 위한 AsyncConfig 추가
  • Loading branch information
Chan-GN authored Nov 19, 2024
1 parent 723663c commit 99af4c4
Show file tree
Hide file tree
Showing 16 changed files with 448 additions and 6 deletions.
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ dependencies {
// AWS SDK V2 for S3
implementation platform('software.amazon.awssdk:bom:2.24.1')
implementation 'software.amazon.awssdk:s3'
// AWS SDK V2 for SES
implementation 'software.amazon.awssdk:ses'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import java.util.List;

import org.springframework.context.ApplicationEventPublisher;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
Expand All @@ -12,8 +13,11 @@

import com.example.epari.admin.dto.ApprovalRequestDTO;
import com.example.epari.admin.dto.CognitoUserDTO;
import com.example.epari.admin.dto.RejectionRequestDTO;
import com.example.epari.admin.service.AdminUserService;
import com.example.epari.admin.service.CognitoService;
import com.example.epari.global.event.NotificationEvent;
import com.example.epari.global.event.NotificationType;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -32,6 +36,8 @@ public class AdminUserManagementController {

private final AdminUserService adminUserService;

private final ApplicationEventPublisher eventPublisher;

/**
* 임시 그룹에 속한 사용자를 조회하는 엔드포인트
*/
Expand All @@ -53,12 +59,17 @@ public ResponseEntity<Void> approveUser(
@RequestBody ApprovalRequestDTO request
) {
// 1. 백엔드 DB에 승인 상태 업데이트
adminUserService.approveUser(email, request);
String courseName = adminUserService.approveUser(email, request);

// 2. Cognito 그룹 변경
cognitoService.changeUserGroup(request.getUsername(), "STUDENT");

// 3. TODO 이메일 발송
// 3. 이메일 발송
NotificationEvent event = NotificationEvent.of(email, NotificationType.USER_APPROVED)
.addProperty("name", request.getName())
.addProperty("courseName", courseName);

eventPublisher.publishEvent(event);

return ResponseEntity.ok().build();
}
Expand All @@ -67,11 +78,19 @@ public ResponseEntity<Void> approveUser(
* 임시 그룹에 속한 사용자를 반려하는 엔드포인트
*/
@PostMapping("/{userEmail}/reject")
public ResponseEntity<Void> rejectUser(@PathVariable("userEmail") String email) {
public ResponseEntity<Void> rejectUser(
@PathVariable("userEmail") String email,
@RequestBody RejectionRequestDTO request
) {
// 1. Cognito에서 사용자 삭제
cognitoService.deleteUser(email);

// 2. TODO 이메일 발송
// 2. 이메일 발송
NotificationEvent event = NotificationEvent.of(email, NotificationType.USER_REJECTED)
.addProperty("name", request.getName())
.addProperty("reason", request.getReason());

eventPublisher.publishEvent(event);

return ResponseEntity.ok().build();
}
Expand Down
21 changes: 21 additions & 0 deletions src/main/java/com/example/epari/admin/dto/RejectionRequestDTO.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.example.epari.admin.dto;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
* 사용자 반려 요청 DTO
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class RejectionRequestDTO {

private String username; // Cognito username

private String name; // Username

private String reason; // 반려 사유

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.example.epari.admin.exception;

import com.example.epari.global.exception.BusinessBaseException;
import com.example.epari.global.exception.ErrorCode;

/**
* 알림 관련 커스텀 예외
*/
public class NotificationException extends BusinessBaseException {

public NotificationException(ErrorCode errorCode) {
super(errorCode);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public class AdminUserService {
* 사용자 저장 및 과목 매핑 수행
*/
@Transactional
public void approveUser(String email, ApprovalRequestDTO request) {
public String approveUser(String email, ApprovalRequestDTO request) {
// 1. 사용자 저장
Student student = Student
.createStudent(email, "PASSWORD", request.getName(), "010-1234-5678");
Expand All @@ -45,6 +45,8 @@ public void approveUser(String email, ApprovalRequestDTO request) {
.orElseThrow(CourseNotFoundException::new);

courseStudentRepository.save(new CourseStudent(course, student));

return course.getName();
}

}
10 changes: 10 additions & 0 deletions src/main/java/com/example/epari/global/config/AsyncConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.example.epari.global.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;

@Configuration
@EnableAsync
public class AsyncConfig {

}
36 changes: 36 additions & 0 deletions src/main/java/com/example/epari/global/config/ThymeleafConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.example.epari.global.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.thymeleaf.spring6.SpringTemplateEngine;
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
import org.thymeleaf.templateresolver.ITemplateResolver;

/**
* Thymeleaf 관련 설정 클래스
*/
@Configuration
public class ThymeleafConfig {

@Bean
public SpringTemplateEngine emailTemplateEngine() {
SpringTemplateEngine templateEngine = new SpringTemplateEngine();

templateEngine.setTemplateResolver(emailTemplateResolver());

return templateEngine;
}

@Bean
public ITemplateResolver emailTemplateResolver() {
ClassLoaderTemplateResolver templateResolver = new ClassLoaderTemplateResolver();

templateResolver.setPrefix("mail-templates/");
templateResolver.setSuffix(".html");
templateResolver.setTemplateMode("HTML");
templateResolver.setCharacterEncoding("UTF-8");

return templateResolver;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.example.epari.global.config.aws;

import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.ses.SesClient;

/**
* AWS SES 사용을 위한 설정 클래스
*/
@Configuration
@EnableConfigurationProperties(AwsS3Properties.class)
public class AwsSesConfig {

@Bean
public SesClient sesClient(AwsS3Properties properties) {
return SesClient.builder()
.region(Region.of(properties.getRegion()))
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create(
properties.getAccessKey(),
properties.getSecretKey()
)
))
.build();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.example.epari.global.event;

import java.util.HashMap;
import java.util.Map;

import lombok.Builder;
import lombok.Getter;

/**
* 알림 이벤트 클래스
*/
@Getter
@Builder
public class NotificationEvent {

private String to;

private NotificationType type;

private Map<String, String> properties;

public static NotificationEvent of(String to, NotificationType type) {
return NotificationEvent.builder()
.to(to)
.type(type)
.properties(new HashMap<>())
.build();
}

public NotificationEvent addProperty(String key, String value) {
this.properties.put(key, value);
return this;
}

}
23 changes: 23 additions & 0 deletions src/main/java/com/example/epari/global/event/NotificationType.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.example.epari.global.event;

import lombok.Getter;

/**
* 알림 타입 Enum 클래스
*/
@Getter
public enum NotificationType {

USER_APPROVED("사용자 승인", "user-approved.html"),
USER_REJECTED("사용자 반려", "user-rejected.html");

private final String description;

private final String templatePath;

NotificationType(String description, String templatePath) {
this.description = description;
this.templatePath = templatePath;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,11 @@ public enum ErrorCode {
// Cognito 관련 에러 코드 (CGT)
COGNITO_USER_FETCH_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "CGT-001", "사용자 정보 조회에 실패했습니다."),
COGNITO_USER_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR, "CGT-002", "사용자 정보를 찾을 수 없습니다."),
COGNITO_USER_DELETE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "CGT-003", "사용자를 삭제하는 도중 오류가 발생했습니다.");
COGNITO_USER_DELETE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "CGT-003", "사용자를 삭제하는 도중 오류가 발생했습니다."),

// 알림 관련 에러 코드 (NTF)
NOTIFICATION_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "NTF-001", "알림 발송에 실패했습니다."),
SES_SERVICE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "NTF-002", "이메일 서비스 연동 중 오류가 발생했습니다.");

private final HttpStatus status;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package com.example.epari.global.notification;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import com.example.epari.admin.exception.NotificationException;
import com.example.epari.global.event.NotificationEvent;
import com.example.epari.global.exception.ErrorCode;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import software.amazon.awssdk.services.ses.SesClient;
import software.amazon.awssdk.services.ses.model.Body;
import software.amazon.awssdk.services.ses.model.Content;
import software.amazon.awssdk.services.ses.model.Destination;
import software.amazon.awssdk.services.ses.model.Message;
import software.amazon.awssdk.services.ses.model.SendEmailRequest;
import software.amazon.awssdk.services.ses.model.SesException;

/**
* 이메일 전송을 담당하는 서비스 클래스
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class EmailService {

private final SesClient sesClient;

private final TemplateProcessor templateProcessor;

@Value("${aws.ses.source.email}")
private String sender;

/**
* 이벤트를 기반으로 이메일을 발송
*/
public void sendEmail(NotificationEvent event) {
try {
String content = templateProcessor.processTemplate(
event.getType(),
event.getProperties()
);

SendEmailRequest request = SendEmailRequest.builder()
.source(sender)
.destination(Destination.builder()
.toAddresses(event.getTo())
.build())
.message(Message.builder()
.subject(Content.builder()
.data(event.getType().getDescription())
.build())
.body(Body.builder()
.html(Content.builder()
.data(content)
.build())
.build())
.build())
.build();

sesClient.sendEmail(request);
log.info("Email sent successfully to: {}", event.getTo());
} catch (SesException e) {
log.error("AWS SES error for: {}", event.getTo(), e);
throw new NotificationException(ErrorCode.SES_SERVICE_ERROR);
} catch (Exception e) {
log.error("Notification failed for: {}", event.getTo(), e);
throw new NotificationException(ErrorCode.NOTIFICATION_SEND_FAILED);
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.example.epari.global.notification;

import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;

import com.example.epari.global.event.NotificationEvent;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

/**
* 알림 이벤트를 Listen하는 이벤트 리스너 클래스
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class NotificationEventListener {

private final EmailService emailService;

/**
* NotificationEvent를 처리하는 메서드
* emailService.sendEmail 메서드 호출
*/
@Async
@EventListener
public void handleNotificationEvent(NotificationEvent event) {
log.info("Received notification event for: {}", event.getTo());
emailService.sendEmail(event);
}

}
Loading

0 comments on commit 99af4c4

Please sign in to comment.