Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FEAT] AWS SQS를 이용한 푸시알림 구현 #91

Merged
merged 8 commits into from
Aug 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion umbba-api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,5 @@ dependencies {

// s3
// implementation "org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE"
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package sopt.org.umbba.api.config.sqs;

import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.services.sqs.AmazonSQSAsync;
import com.amazonaws.services.sqs.AmazonSQSAsyncClientBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;

@Configuration
public class SqsConfig {

@Value("${cloud.aws.credentials.accessKey}")
private String AWS_ACCESS_KEY;

@Value("${cloud.aws.credentials.secretKey}")
private String AWS_SECRET_KEY;

@Value("${cloud.aws.region.static}")
private String AWS_REGION;

@Primary
@Bean
public AmazonSQSAsync amazonSQSAsync() {
BasicAWSCredentials basicAWSCredentials = new BasicAWSCredentials(AWS_ACCESS_KEY, AWS_SECRET_KEY);
return AmazonSQSAsyncClientBuilder.standard()
.withRegion(AWS_REGION)
.withCredentials(new AWSStaticCredentialsProvider(basicAWSCredentials))
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package sopt.org.umbba.api.config.sqs.producer;

import com.amazonaws.services.sqs.AmazonSQS;
import com.amazonaws.services.sqs.model.MessageAttributeValue;
import com.amazonaws.services.sqs.model.SendMessageRequest;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import sopt.org.umbba.common.sqs.MessageType;
import sopt.org.umbba.common.sqs.MessageUtils;
import sopt.org.umbba.common.sqs.dto.MessageDto;

import java.util.Map;
import java.util.UUID;

/**
* 큐에 메시지를 보내는 역할: API 서버에서 이벤트가 발생할 떄 푸시알림 전송
* -> 처음 SQS 대기열 생성에서 설정해둔 사항이 여기서 적용 (지연시간, 메시지 수신 대기 등)
*
* 1. 처리할 작업 메시지를 SQS에 등록
* 2. 큐에서 메시지를 소비(consume)하는 것을 실패한 경우, DLQ로 전송
*
* TODO 기존에 푸시알림을 파이어베이스로 보내기 위해 호출했던 함수를 SQS Producer로 대체
*/
@Slf4j
@Component
public class SqsProducer {

@Value("${cloud.aws.sqs.notification.url}")
private String NOTIFICATION_URL;

private static final String GROUP_ID = "sqs";
private final ObjectMapper objectMapper;
private final AmazonSQS amazonSqs;
private static final String SQS_QUEUE_REQUEST_LOG_MESSAGE = "====> [SQS Queue Request] : %s ";

public SqsProducer(ObjectMapper objectMapper, AmazonSQS amazonSqs) {
this.objectMapper = objectMapper;
this.amazonSqs = amazonSqs;
}


public void produce(MessageDto message) {
try {
SendMessageRequest request = new SendMessageRequest(NOTIFICATION_URL,
objectMapper.writeValueAsString(message))
// .withMessageGroupId(GROUP_ID)
// .withMessageDeduplicationId(UUID.randomUUID().toString()) // TODO UUID Random String으로 변경
.withMessageAttributes(createMessageAttributes(message.getType()));

amazonSqs.sendMessage(request);
log.info(MessageUtils.generate(SQS_QUEUE_REQUEST_LOG_MESSAGE, request));

} catch (JsonProcessingException e) {
log.error(e.getMessage(), e);
}
}

private Map<String, MessageAttributeValue> createMessageAttributes(String type) {

return Map.of(MessageType.MESSAGE_TYPE_HEADER, new MessageAttributeValue()
.withDataType("String")
.withStringValue(type));
}


/* Queue에 단일 메시지를 보내는 함수 -> SQS 실습에서 사용한 함수
public SendResult<String> sendMessage(String groupId, String message) {
// Message<String> newMessage = MessageBuilder.withPayload(message).build();
System.out.println("Sender: " + message);
return queueMessagingTemplate.send(to -> to
.queue(QUEUE_NAME)
.messageGroupId(groupId)
.messageDeduplicationId(groupId)
.payload(message));
}
*/
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.dao.InvalidDataAccessApiUsageException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.stereotype.Component;
import org.springframework.validation.Errors;
import org.springframework.validation.FieldError;
Expand All @@ -15,6 +19,7 @@
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.util.NestedServletException;
import sopt.org.umbba.common.exception.ErrorType;
import sopt.org.umbba.common.exception.dto.ApiResponse;
import sopt.org.umbba.common.exception.model.CustomException;
Expand All @@ -24,6 +29,7 @@
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.NoSuchElementException;

@Slf4j
@RestControllerAdvice
Expand Down Expand Up @@ -99,7 +105,7 @@ protected ApiResponse<Exception> handleException(final Exception e, final HttpSe
return ApiResponse.error(ErrorType.INTERNAL_SERVER_ERROR, e);
}

/*@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler(IllegalArgumentException.class)
public ApiResponse<Exception> handlerIllegalArgumentException(final IllegalArgumentException e) {
return ApiResponse.error(ErrorType.INTERNAL_SERVER_ERROR, e);
Expand All @@ -124,11 +130,11 @@ protected ApiResponse<Exception> handlerIndexOutOfBoundsException(final IndexOut
return ApiResponse.error(ErrorType.INDEX_OUT_OF_BOUNDS, e);
}

*//*@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
/*@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler(UnknownClassException.class)
protected ApiResponse<Exception> handlerUnknownClassException(final UnknownClassException e) {
return ApiResponse.error(ErrorType.JWT_SERIALIZE, e);
}*//*
}*/

@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler(NoSuchElementException.class)
Expand Down Expand Up @@ -170,7 +176,7 @@ public ApiResponse<Exception> handlerJpaSystemException(final JpaSystemException
@ExceptionHandler(NullPointerException.class)
public ApiResponse<Exception> handlerNullPointerException(final NullPointerException e) {
return ApiResponse.error(ErrorType.NULL_POINTER_ERROR, e);
}*/
}


/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,19 @@
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import sopt.org.umbba.api.config.sqs.producer.SqsProducer;
import sopt.org.umbba.api.service.notification.NotificationService;
import sopt.org.umbba.api.service.qna.QnAService;
import sopt.org.umbba.common.exception.SuccessType;
import sopt.org.umbba.common.exception.dto.ApiResponse;
import sopt.org.umbba.common.sqs.dto.FCMPushRequestDto;

@RestController
@RequiredArgsConstructor
public class DemoController {

private final QnAService qnAService;
private final NotificationService notificationService;

/**
* 데모데이 테스트용 QnA리스트 세팅 API
Expand Down Expand Up @@ -44,4 +48,12 @@ public ApiResponse demoQnA(@PathVariable final Long userId) {
return ApiResponse.success(SuccessType.TEST_SUCCESS);
}

@PatchMapping("/demo/qna/alarm/{userId}")
@ResponseStatus(HttpStatus.OK)
public ApiResponse qnaAnswerAlarm(@PathVariable final Long userId, @RequestBody String question) {

notificationService.pushOpponentReply(question, userId);
return ApiResponse.success(SuccessType.TEST_SUCCESS);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package sopt.org.umbba.api.service.notification;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import sopt.org.umbba.api.config.sqs.producer.SqsProducer;
import sopt.org.umbba.common.exception.ErrorType;
import sopt.org.umbba.common.exception.model.CustomException;
import sopt.org.umbba.common.sqs.dto.FCMPushRequestDto;
import sopt.org.umbba.common.sqs.dto.SlackDto;
import sopt.org.umbba.domain.domain.user.User;
import sopt.org.umbba.domain.domain.user.repository.UserRepository;

import java.io.IOException;

/**
* SQS 대기열로 알림 메시지를 추가
*/
@Slf4j
@RequiredArgsConstructor
@Service
@Transactional
public class NotificationService {

private final SqsProducer sqsProducer;
private final UserRepository userRepository;

public void pushOpponentReply(String question, Long userId) {

// 상대 측 유저의 FCM 토큰 찾기
User user = userRepository.findById(userId).orElseThrow(
() -> new CustomException(ErrorType.INVALID_USER)
);

log.info("상대방 답변 완료!");
sqsProducer.produce(FCMPushRequestDto.sendOpponentReply(user.getFcmToken(), question));

/* try {
log.info("상대방 답변 완료!");
sqsProducer.produce(FCMPushRequestDto.sendOpponentReply(user.getFcmToken(), question));
} catch (IOException e) {
log.error("푸시메시지 전송 실패 - IOException: {}", e.getMessage());
throw new CustomException(ErrorType.FAIL_TO_SEND_PUSH_ALARM);
} catch (FirebaseMessagingException e) {
log.error("푸시메시지 전송 실패 - FirebaseMessagingException: {}", e.getMessage());
throw new CustomException(ErrorType.FAIL_TO_SEND_PUSH_ALARM);
}*/
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import sopt.org.umbba.api.config.sqs.producer.SqsProducer;
import sopt.org.umbba.api.controller.qna.dto.request.TodayAnswerRequestDto;
import sopt.org.umbba.api.controller.qna.dto.response.*;
import sopt.org.umbba.api.service.notification.NotificationService;
import sopt.org.umbba.common.exception.ErrorType;
import sopt.org.umbba.common.exception.model.CustomException;
import sopt.org.umbba.domain.domain.parentchild.Parentchild;
Expand Down Expand Up @@ -41,7 +43,7 @@ public class QnAService {
private final QuestionRepository questionRepository;
private final UserRepository userRepository;
private final ParentchildDao parentchildDao;
// private final FCMService fcmService; //TODO ⭐️SQS로 변경
private final NotificationService notificationService;

public TodayQnAResponseDto getTodayQnA(Long userId) {

Expand Down Expand Up @@ -82,12 +84,12 @@ public void answerTodayQuestion(Long userId, TodayAnswerRequestDto request) {

if (myUser.isMeChild()) {
todayQnA.saveChildAnswer(request.getAnswer());
//TODO ⭐️SQS로 변경
// fcmService.pushOpponentReply(todayQnA.getQuestion().getParentQuestion(), opponentUser.getId());
notificationService.pushOpponentReply(todayQnA.getQuestion().getChildQuestion(), opponentUser.getId());
// fcmService.pushOpponentReply(todayQnA.getQuestion().getChildQuestion(), opponentUser.getId());
} else {
todayQnA.saveParentAnswer(request.getAnswer());
//TODO ⭐️SQS로 변경
// fcmService.pushOpponentReply(todayQnA.getQuestion().getChildQuestion(), opponentUser.getId());
notificationService.pushOpponentReply(todayQnA.getQuestion().getParentQuestion(), opponentUser.getId());
// fcmService.pushOpponentReply(todayQnA.getQuestion().getParentQuestion(), opponentUser.getId());
}
}

Expand Down Expand Up @@ -322,7 +324,7 @@ private GetInvitationResponseDto invitation(Long userId) {
() -> new CustomException(ErrorType.USER_HAVE_NO_PARENTCHILD)
);

return GetInvitationResponseDto.of(parentchild.getInviteCode(), user.getUsername(), "http://umbba.site/"); // TODO url 설정 필요 (Firebase)
return GetInvitationResponseDto.of(parentchild.getInviteCode(), user.getUsername(), "http://umbba.site/"); // TODO Firebase 동적링크 연결 예정
}

private GetInvitationResponseDto withdrawUser() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import feign.FeignException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import sopt.org.umbba.api.config.auth.UserAuthentication;
Expand All @@ -21,6 +22,7 @@
import java.security.NoSuchAlgorithmException;
import java.security.spec.InvalidKeySpecException;

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
Expand Down Expand Up @@ -65,6 +67,7 @@ public UserLoginResponseDto login(String socialAccessToken, SocialLoginRequestDt

// 클라이언트 요청에 따라 FCM 토큰을 로그인할 때마다 업데이트 하도록 변경
loginUser.updateFcmToken(request.getFcmToken());
log.info("🔮{}의 JWT Access Token: {}", loginUser.getUsername(), tokenDto.getAccessToken());

return UserLoginResponseDto.of(loginUser, tokenDto.getAccessToken());
}
Expand Down
18 changes: 17 additions & 1 deletion umbba-api/src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,27 @@ cloud:
bucket: ${bucket-name}
stack:
auto: false
sqs:
notification:
name: ${sqs-notification-name}
url: ${sqs-notification-url}
#api:
# name: ${sqs-api-name}
# url: ${sqs-api-url}


spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${db-url}
username: ${db-user}
password: ${db-pwd}
hikari:
pool-name: Hikari 커넥션 풀 # Pool
connection-timeout: 30000 # 30초(default: 30초)
maximum-pool-size: 10 # default: 10개
max-lifetime: 600000 # 10분(default: 30분)
leak-detection-threshold: 2000 # default: 0(이용X)
jpa:
show-sql: false
hibernate:
Expand Down Expand Up @@ -60,6 +74,8 @@ logging:
type:
descriptor:
sql: debug
com.zaxxer.hikari.pool.HikariPool: debug


server:
port: 9092
port: 9091
Loading