-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* feat: 멤버초대 컨트롤러, 서비스 틀 구축 * feat: UUID 초대토큰 생성 * feat: ses 서비스 도입 * feat: 이메일 초대 토큰 키-값 레디스에 저장 * feat: 이메일 초대 토큰 키-값 레디스에 저장 * feat: 비동기 적용 및 외부서비스 장애 발생 시 처리 로직 구현 * feat: 타임아웃 설정 * refactor: 이메일 스레드 개수 * refactor: 딜레이 1초는 너무 길기때문에 500ms로 줄임
- Loading branch information
1 parent
1087f94
commit 7fab1bf
Showing
16 changed files
with
476 additions
and
6 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
67 changes: 67 additions & 0 deletions
67
src/main/java/dynamicquad/agilehub/email/AmazonSESService.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,67 @@ | ||
package dynamicquad.agilehub.email; | ||
|
||
import com.amazonaws.services.simpleemail.AmazonSimpleEmailService; | ||
import com.amazonaws.services.simpleemail.model.Body; | ||
import com.amazonaws.services.simpleemail.model.Content; | ||
import com.amazonaws.services.simpleemail.model.Destination; | ||
import com.amazonaws.services.simpleemail.model.Message; | ||
import com.amazonaws.services.simpleemail.model.SendEmailRequest; | ||
import dynamicquad.agilehub.global.exception.GeneralException; | ||
import dynamicquad.agilehub.global.header.status.ErrorStatus; | ||
import dynamicquad.agilehub.issue.aspect.Retry; | ||
import java.util.Map; | ||
import java.util.concurrent.CompletableFuture; | ||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.beans.factory.annotation.Value; | ||
import org.springframework.scheduling.annotation.Async; | ||
import org.springframework.stereotype.Service; | ||
import org.thymeleaf.TemplateEngine; | ||
import org.thymeleaf.context.Context; | ||
import org.thymeleaf.context.IContext; | ||
|
||
@Service | ||
@RequiredArgsConstructor | ||
public class AmazonSESService implements SMTPService { | ||
|
||
private final AmazonSimpleEmailService amazonSimpleEmailService; | ||
private final TemplateEngine htmlTemplateEngine; | ||
|
||
@Value("${aws.ses.from}") | ||
private String from; | ||
|
||
@Override | ||
@Async("emailExecutor") | ||
@Retry(maxRetries = 3, retryFor = {GeneralException.class}, delay = 500) | ||
public CompletableFuture<Void> sendEmail(String subject, Map<String, Object> variables, String... to) { | ||
// Amazon SES를 이용한 이메일 발송 | ||
return CompletableFuture.runAsync(() -> { | ||
try { | ||
String content = htmlTemplateEngine.process("invite", createContext(variables)); | ||
SendEmailRequest request = createSendEmailRequest(subject, content, to); | ||
|
||
amazonSimpleEmailService.sendEmail(request); | ||
} catch (Exception e) { | ||
throw new GeneralException(ErrorStatus.EMAIL_NOT_SENT); | ||
} | ||
}); | ||
|
||
|
||
} | ||
|
||
private SendEmailRequest createSendEmailRequest(String subject, String content, String... to) { | ||
return new SendEmailRequest() | ||
.withDestination(new Destination().withToAddresses(to)) | ||
.withMessage(new Message() | ||
.withBody(new Body() | ||
.withHtml(new Content().withCharset("UTF-8").withData(content))) | ||
.withSubject(new Content().withCharset("UTF-8").withData(subject))) | ||
.withSource(from); | ||
} | ||
|
||
private IContext createContext(Map<String, Object> variables) { | ||
Context context = new Context(); | ||
context.setVariables(variables); | ||
|
||
return context; | ||
} | ||
} |
28 changes: 28 additions & 0 deletions
28
src/main/java/dynamicquad/agilehub/email/InvitationController.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
package dynamicquad.agilehub.email; | ||
|
||
import dynamicquad.agilehub.global.auth.model.Auth; | ||
import dynamicquad.agilehub.member.dto.MemberRequestDto.AuthMember; | ||
import dynamicquad.agilehub.project.controller.request.ProjectInviteRequestDto; | ||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.http.ResponseEntity; | ||
import org.springframework.web.bind.annotation.PostMapping; | ||
import org.springframework.web.bind.annotation.RequestBody; | ||
import org.springframework.web.bind.annotation.RequestMapping; | ||
import org.springframework.web.bind.annotation.RestController; | ||
|
||
@RestController | ||
@RequiredArgsConstructor | ||
@RequestMapping("/api/v2/invitations") | ||
public class InvitationController { | ||
|
||
private final InvitationService invitationService; | ||
|
||
@PostMapping | ||
public ResponseEntity<Void> inviteMember(@Auth AuthMember authMember, | ||
@RequestBody ProjectInviteRequestDto.SendInviteMail sendInviteMail) { | ||
invitationService.sendInvitation(authMember, sendInviteMail); | ||
return ResponseEntity.ok().build(); | ||
} | ||
|
||
|
||
} |
12 changes: 12 additions & 0 deletions
12
src/main/java/dynamicquad/agilehub/email/InvitationService.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
package dynamicquad.agilehub.email; | ||
|
||
import dynamicquad.agilehub.member.dto.MemberRequestDto.AuthMember; | ||
import dynamicquad.agilehub.project.controller.request.ProjectInviteRequestDto; | ||
|
||
public interface InvitationService { | ||
void sendInvitation(AuthMember authMember, ProjectInviteRequestDto.SendInviteMail sendInviteMail); | ||
|
||
//초대토큰 유효한지 확인 | ||
void validateInvitation(String inviteToken); | ||
} | ||
|
128 changes: 128 additions & 0 deletions
128
src/main/java/dynamicquad/agilehub/email/InvitationServiceImpl.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,128 @@ | ||
package dynamicquad.agilehub.email; | ||
|
||
import dynamicquad.agilehub.global.exception.GeneralException; | ||
import dynamicquad.agilehub.global.header.status.ErrorStatus; | ||
import dynamicquad.agilehub.global.util.RandomStringUtil; | ||
import dynamicquad.agilehub.member.dto.MemberRequestDto.AuthMember; | ||
import dynamicquad.agilehub.project.controller.request.ProjectInviteRequestDto.SendInviteMail; | ||
import dynamicquad.agilehub.project.service.MemberProjectService; | ||
import dynamicquad.agilehub.project.service.ProjectQueryService; | ||
import java.util.HashMap; | ||
import java.util.Map; | ||
import java.util.concurrent.TimeUnit; | ||
import lombok.RequiredArgsConstructor; | ||
import lombok.extern.slf4j.Slf4j; | ||
import org.springframework.data.redis.core.RedisTemplate; | ||
import org.springframework.stereotype.Service; | ||
import org.springframework.transaction.annotation.Transactional; | ||
|
||
@Service | ||
@RequiredArgsConstructor | ||
@Slf4j | ||
@Transactional | ||
public class InvitationServiceImpl implements InvitationService { | ||
|
||
private final MemberProjectService memberProjectService; | ||
private final RedisTemplate<String, String> redisTemplate; | ||
private final SMTPService smtpService; | ||
private final ProjectQueryService projectQueryService; | ||
|
||
// 초대 코드 저장 key prefix | ||
private static final String KEY_PREFIX = "i:"; | ||
|
||
// 초대 코드 상태 저장 key prefix | ||
private static final String STATUS_PREFIX = "status:"; | ||
|
||
// 초대 코드 만료 시간 | ||
private static final int EXPIRATION_MINUTES = 10; | ||
|
||
@Override | ||
public void sendInvitation(AuthMember authMember, SendInviteMail sendInviteMail) { | ||
validateMember(authMember, sendInviteMail.getProjectId()); | ||
// 진행 중인 초대가 있는 지 확인 | ||
String email = validateMail(sendInviteMail); | ||
|
||
String token = generateInviteToken(); | ||
storeInviteToken(token, sendInviteMail); | ||
storeInvitationStatus(email, InvitationStatus.PENDING); | ||
|
||
sendEmail(sendInviteMail, token); | ||
} | ||
|
||
private String validateMail(SendInviteMail sendInviteMail) { | ||
String email = sendInviteMail.getEmail(); | ||
if (hasActiveInvitation(email)) { | ||
throw new GeneralException(ErrorStatus.ALREADY_INVITATION); | ||
} | ||
else if (isEmailServiceDown(email)) { | ||
// 이메일 서비스가 고장나 있을 때 | ||
throw new GeneralException(ErrorStatus.EMAIL_SEND_FAIL); | ||
} | ||
return email; | ||
} | ||
|
||
private boolean isEmailServiceDown(String email) { | ||
String statusKey = STATUS_PREFIX + email; | ||
String status = (String) redisTemplate.opsForValue().get(statusKey); | ||
return status != null && (InvitationStatus.isFailed(status)); | ||
} | ||
|
||
private void storeInvitationStatus(String email, InvitationStatus invitationStatus) { | ||
String statusKey = STATUS_PREFIX + email; | ||
// 10분 동안 이메일 상태 유지 | ||
redisTemplate.opsForValue().set(statusKey, invitationStatus.name(), EXPIRATION_MINUTES, TimeUnit.MINUTES); | ||
} | ||
|
||
private boolean hasActiveInvitation(String email) { | ||
String statusKey = STATUS_PREFIX + email; | ||
String status = redisTemplate.opsForValue().get(statusKey); | ||
return status != null && (InvitationStatus.isPendingOrSending(status)); | ||
} | ||
|
||
private void storeInviteToken(String token, SendInviteMail sendInviteMail) { | ||
String tokenBase64 = RandomStringUtil.uuidToBase64(token); | ||
String key = KEY_PREFIX + tokenBase64; | ||
|
||
Map<String, Object> fields = new HashMap<>(); | ||
fields.put("p", String.valueOf(sendInviteMail.getProjectId())); // projectId -> p | ||
fields.put("u", "0"); // used 상태 | ||
|
||
redisTemplate.opsForHash().putAll(key, fields); | ||
redisTemplate.expire(key, EXPIRATION_MINUTES, TimeUnit.MINUTES); | ||
} | ||
|
||
|
||
private void sendEmail(SendInviteMail sendInviteMail, String token) { | ||
final String projectName = projectQueryService.findProjectById(sendInviteMail.getProjectId()).getName(); | ||
|
||
Map<String, Object> variables = new HashMap<>(); | ||
variables.put("inviteCode", token); | ||
variables.put("projectName", projectName); | ||
|
||
smtpService.sendEmail("AgileHub 초대 메일", variables, sendInviteMail.getEmail()) | ||
.thenRun(() -> { | ||
storeInvitationStatus(sendInviteMail.getEmail(), InvitationStatus.SENT); | ||
log.info("이메일 전송 완료"); | ||
}) | ||
.exceptionally(e -> { | ||
log.error("이메일 전송 실패", e); | ||
storeInvitationStatus(sendInviteMail.getEmail(), InvitationStatus.FAILED); | ||
return null; | ||
}); | ||
} | ||
|
||
private void validateMember(AuthMember authMember, long projectId) { | ||
memberProjectService.validateMemberInProject(authMember.getId(), projectId); | ||
memberProjectService.validateMemberRole(authMember, projectId); | ||
} | ||
|
||
private String generateInviteToken() { | ||
return RandomStringUtil.generateUUID(); | ||
} | ||
|
||
|
||
@Override | ||
public void validateInvitation(String inviteToken) { | ||
|
||
} | ||
} |
26 changes: 26 additions & 0 deletions
26
src/main/java/dynamicquad/agilehub/email/InvitationStatus.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
package dynamicquad.agilehub.email; | ||
|
||
public enum InvitationStatus { | ||
PENDING, // 초기 상태 | ||
SENDING, // 발송 중 | ||
SENT, // 발송 완료 | ||
RETRY, // 재시도 대기 | ||
FAILED // 최종 실패 | ||
; | ||
|
||
|
||
// string을 전달하면 enum으로 변환 | ||
public static InvitationStatus fromString(String status) { | ||
return InvitationStatus.valueOf(status.toUpperCase()); | ||
} | ||
|
||
public static boolean isPendingOrSending(String status) { | ||
InvitationStatus invitationStatus = fromString(status); | ||
return invitationStatus == PENDING || invitationStatus == SENDING; | ||
} | ||
|
||
public static boolean isFailed(String status) { | ||
InvitationStatus invitationStatus = fromString(status); | ||
return invitationStatus == FAILED; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
package dynamicquad.agilehub.email; | ||
|
||
import java.util.Map; | ||
import java.util.concurrent.CompletableFuture; | ||
|
||
public interface SMTPService { | ||
CompletableFuture<Void> sendEmail(String subject, Map<String, Object> variables, String... to); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
33 changes: 33 additions & 0 deletions
33
src/main/java/dynamicquad/agilehub/global/config/DefaultSESConfig.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
package dynamicquad.agilehub.global.config; | ||
|
||
import com.amazonaws.ClientConfiguration; | ||
import com.amazonaws.auth.AWSStaticCredentialsProvider; | ||
import com.amazonaws.auth.BasicAWSCredentials; | ||
import com.amazonaws.regions.Regions; | ||
import com.amazonaws.services.simpleemail.AmazonSimpleEmailService; | ||
import com.amazonaws.services.simpleemail.AmazonSimpleEmailServiceClientBuilder; | ||
import org.springframework.beans.factory.annotation.Value; | ||
import org.springframework.context.annotation.Bean; | ||
import org.springframework.context.annotation.Configuration; | ||
|
||
@Configuration | ||
public class DefaultSESConfig { | ||
@Value("${aws.ses.accessKey}") | ||
private String accessKey; | ||
|
||
@Value("${aws.ses.secretKey}") | ||
private String secretKey; | ||
|
||
@Bean | ||
public AmazonSimpleEmailService amazonSimpleEmailService() { | ||
BasicAWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey); | ||
return AmazonSimpleEmailServiceClientBuilder.standard() | ||
.withRegion(Regions.AP_NORTHEAST_2) | ||
.withCredentials(new AWSStaticCredentialsProvider(credentials)) | ||
.withClientConfiguration(new ClientConfiguration() | ||
.withConnectionTimeout(3000) | ||
.withSocketTimeout(5000) | ||
.withRequestTimeout(10000)) | ||
.build(); | ||
} | ||
} |
18 changes: 18 additions & 0 deletions
18
src/main/java/dynamicquad/agilehub/global/config/TemplateConfig.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
package dynamicquad.agilehub.global.config; | ||
|
||
import org.springframework.context.annotation.Bean; | ||
import org.springframework.context.annotation.Configuration; | ||
import org.thymeleaf.TemplateEngine; | ||
import org.thymeleaf.spring6.SpringTemplateEngine; | ||
import org.thymeleaf.spring6.templateresolver.SpringResourceTemplateResolver; | ||
|
||
@Configuration | ||
public class TemplateConfig { | ||
@Bean | ||
public TemplateEngine htmlTemplateEngine(SpringResourceTemplateResolver springResourceTemplateResolver) { | ||
TemplateEngine templateEngine = new SpringTemplateEngine(); | ||
templateEngine.addTemplateResolver(springResourceTemplateResolver); | ||
|
||
return templateEngine; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.