diff --git a/src/main/java/com/sopt/push/common/Constants.java b/src/main/java/com/sopt/push/common/Constants.java index 1b363cc..c942563 100644 --- a/src/main/java/com/sopt/push/common/Constants.java +++ b/src/main/java/com/sopt/push/common/Constants.java @@ -43,6 +43,11 @@ public class Constants { public static final String HEADER_CONTENT_TYPE = "Content-Type"; public static final String MEDIA_TYPE_APPLICATION_JSON = "application/json"; + public static final String HEADER_ACTION = "action"; + public static final String HEADER_PLATFORM = "platform"; + public static final String HEADER_TRANSACTION_ID = "transactionId"; + public static final String HEADER_SERVICE = "service"; + public static final String HTTP_METHOD_PATCH = "PATCH"; public static final String HTTP_METHOD_POST = "POST"; public static final String URL_PATH_FORMAT_ID = "%s/%s"; @@ -53,4 +58,5 @@ public class Constants { public static final int HTTP_CLIENT_CONNECT_TIMEOUT_SECONDS = 10; public static final int HTTP_REQUEST_TIMEOUT_SECONDS = 5; + public static final String SNS_PROTOCOL_APPLICATION = "application"; } diff --git a/src/main/java/com/sopt/push/common/DeviceTokenException.java b/src/main/java/com/sopt/push/common/DeviceTokenException.java new file mode 100644 index 0000000..feeef4c --- /dev/null +++ b/src/main/java/com/sopt/push/common/DeviceTokenException.java @@ -0,0 +1,24 @@ +package com.sopt.push.common; + +import lombok.Getter; + +@Getter +public class DeviceTokenException extends RuntimeException { + + private final ErrorMessage errorMessage; + + public DeviceTokenException(ErrorMessage errorMessage) { + super(errorMessage.getMessage()); + this.errorMessage = errorMessage; + } + + public DeviceTokenException(ErrorMessage errorMessage, String detail) { + super(errorMessage.getMessage() + ": " + detail); + this.errorMessage = errorMessage; + } + + public DeviceTokenException(ErrorMessage errorMessage, String detail, Throwable cause) { + super(errorMessage.getMessage() + ": " + detail, cause); + this.errorMessage = errorMessage; + } +} diff --git a/src/main/java/com/sopt/push/common/ErrorMessage.java b/src/main/java/com/sopt/push/common/ErrorMessage.java index 801d3c8..84cd17f 100644 --- a/src/main/java/com/sopt/push/common/ErrorMessage.java +++ b/src/main/java/com/sopt/push/common/ErrorMessage.java @@ -8,10 +8,21 @@ public enum ErrorMessage { INVALID_REQUEST(StatusCode.BAD_REQUEST, "잘못된 요청입니다."), NULL_VALUE(StatusCode.BAD_REQUEST, "필요한 값이 없습니다."), TOKEN_NOT_EXIST(StatusCode.BAD_REQUEST, "존재하지 않는 토큰입니다."), + USER_ID_REQUIRED(StatusCode.BAD_REQUEST, "userId가 필요합니다."), + TOKEN_NOT_FOUND(StatusCode.BAD_REQUEST, "토큰을 찾을 수 없습니다."), + ARN_UNDEFINED(StatusCode.BAD_REQUEST, "arn 또는 topicArn이 정의되지 않았습니다."), + INVALID_ENDPOINT(StatusCode.BAD_REQUEST, "유효하지 않은 SNS 엔드포인트입니다."), + ENDPOINT_ARN_UNDEFINED(StatusCode.INTERNAL_SERVER_ERROR, "endpointArn이 정의되지 않았습니다."), + SUBSCRIPTION_ARN_UNDEFINED(StatusCode.INTERNAL_SERVER_ERROR, "subscriptionArn이 정의되지 않았습니다."), + PLATFORM_APP_ARN_NOT_SET(StatusCode.INTERNAL_SERVER_ERROR, "플랫폼 애플리케이션 ARN이 설정되지 않았습니다."), /** 500 Internal Server Error */ SEND_FAIL(StatusCode.INTERNAL_SERVER_ERROR, "메시지 전송 실패."), - INTERNAL_SERVER_ERROR(StatusCode.INTERNAL_SERVER_ERROR, "서버 내부 오류"); + INTERNAL_SERVER_ERROR(StatusCode.INTERNAL_SERVER_ERROR, "서버 내부 오류"), + REGISTER_USER_ERROR(StatusCode.INTERNAL_SERVER_ERROR, "토큰 등록 중 오류가 발생했습니다."), + DELETE_TOKEN_ERROR(StatusCode.INTERNAL_SERVER_ERROR, "토큰 삭제 중 오류가 발생했습니다."), + SNS_PUBLISH_FAILED(StatusCode.INTERNAL_SERVER_ERROR, "SNS 발행 실패."), + UNKNOWN_PUSH_ERROR(StatusCode.INTERNAL_SERVER_ERROR, "알 수 없는 푸시 전송 오류가 발생했습니다."); private final int httpStatus; private final String message; diff --git a/src/main/java/com/sopt/push/config/AppFactory.java b/src/main/java/com/sopt/push/config/AppFactory.java index 06b66f9..7b4393f 100644 --- a/src/main/java/com/sopt/push/config/AppFactory.java +++ b/src/main/java/com/sopt/push/config/AppFactory.java @@ -6,8 +6,8 @@ import com.sopt.push.repository.HistoryRepository; import com.sopt.push.repository.UserRepository; import com.sopt.push.service.DeviceTokenService; +import com.sopt.push.service.EndpointFacade; import com.sopt.push.service.HistoryService; -import com.sopt.push.service.InvalidEndpointCleaner; import com.sopt.push.service.NotificationService; import com.sopt.push.service.SendPushFacade; import com.sopt.push.service.UserService; @@ -22,12 +22,12 @@ public class AppFactory { private static final AppFactory INSTANCE = new AppFactory(); private final SendPushFacade sendPushFacade; + private final EndpointFacade endpointFacade; private final WebHookService webHookService; private final UserService userService; private final HistoryService historyService; private final DeviceTokenService deviceTokenService; private final NotificationService notificationService; - private final InvalidEndpointCleaner invalidEndpointCleaner; private AppFactory() { @@ -50,9 +50,8 @@ private AppFactory() { this.historyService = new HistoryService(historyRepository); this.deviceTokenService = new DeviceTokenService(tokenRepository); this.notificationService = new NotificationService(snsClient, envConfig); - this.invalidEndpointCleaner = - new InvalidEndpointCleaner( - this.userService, this.deviceTokenService, this.notificationService); + this.endpointFacade = + new EndpointFacade(this.deviceTokenService, this.userService, this.notificationService); this.webHookService = new WebHookService(httpClient, envConfig); this.sendPushFacade = @@ -62,7 +61,7 @@ private AppFactory() { this.historyService, this.userService, this.deviceTokenService, - invalidEndpointCleaner); + this.endpointFacade); } public static AppFactory getInstance() { @@ -89,11 +88,7 @@ public DeviceTokenService deviceTokenService() { return deviceTokenService; } - public NotificationService notificationService() { - return notificationService; - } - - public InvalidEndpointCleaner invalidEndpointCleaner() { - return invalidEndpointCleaner; + public EndpointFacade endpointFacade() { + return endpointFacade; } } diff --git a/src/main/java/com/sopt/push/config/EnvConfig.java b/src/main/java/com/sopt/push/config/EnvConfig.java index 2c5ddd0..4f0c17a 100644 --- a/src/main/java/com/sopt/push/config/EnvConfig.java +++ b/src/main/java/com/sopt/push/config/EnvConfig.java @@ -9,17 +9,23 @@ public final class EnvConfig { private static final String ALL_TOPIC_ARN_ENV_VAR = "ALL_TOPIC_ARN"; private static final String MAKERS_APP_SERVER_URL = "MAKERS_APP_SERVER_URL"; private static final String MAKERS_OPERATION_SERVER_URL = "MAKERS_OPERATION_SERVER_URL"; + private static final String PLATFORM_APPLICATION_IOS_ENV = "PLATFORM_APPLICATION_iOS"; + private static final String PLATFORM_APPLICATION_ANDROID_ENV = "PLATFORM_APPLICATION_ANDROID"; private final String dynamoDbTableName; private final String allTopicArn; private final String makersAppServerUrl; private final String makersOperationServerUrl; + private final String platformApplicationIosArn; + private final String platformApplicationAndroidArn; public EnvConfig() { this.dynamoDbTableName = getRequiredEnv(DYNAMODB_TABLE_ENV_VAR); this.allTopicArn = getRequiredEnv(ALL_TOPIC_ARN_ENV_VAR); this.makersAppServerUrl = getRequiredEnv(MAKERS_APP_SERVER_URL); this.makersOperationServerUrl = getRequiredEnv(MAKERS_OPERATION_SERVER_URL); + this.platformApplicationIosArn = getRequiredEnv(PLATFORM_APPLICATION_IOS_ENV); + this.platformApplicationAndroidArn = getRequiredEnv(PLATFORM_APPLICATION_ANDROID_ENV); } private static String getRequiredEnv(String key) { diff --git a/src/main/java/com/sopt/push/dto/ApiGatewayRequestDto.java b/src/main/java/com/sopt/push/dto/ApiGatewayRequestDto.java new file mode 100644 index 0000000..612ff0a --- /dev/null +++ b/src/main/java/com/sopt/push/dto/ApiGatewayRequestDto.java @@ -0,0 +1,5 @@ +package com.sopt.push.dto; + +import java.util.Map; + +public record ApiGatewayRequestDto(RegisterHeaderDto header, Map body) {} diff --git a/src/main/java/com/sopt/push/dto/RegisterHeaderDto.java b/src/main/java/com/sopt/push/dto/RegisterHeaderDto.java new file mode 100644 index 0000000..29e7118 --- /dev/null +++ b/src/main/java/com/sopt/push/dto/RegisterHeaderDto.java @@ -0,0 +1,8 @@ +package com.sopt.push.dto; + +import com.sopt.push.enums.Actions; +import com.sopt.push.enums.Platform; +import com.sopt.push.enums.Services; + +public record RegisterHeaderDto( + String transactionId, Services service, Platform platform, Actions action) {} diff --git a/src/main/java/com/sopt/push/lambda/ApiGatewayHandler.java b/src/main/java/com/sopt/push/lambda/ApiGatewayHandler.java new file mode 100644 index 0000000..5987588 --- /dev/null +++ b/src/main/java/com/sopt/push/lambda/ApiGatewayHandler.java @@ -0,0 +1,282 @@ +package com.sopt.push.lambda; + +import static com.sopt.push.common.Constants.HEADER_ACTION; +import static com.sopt.push.common.Constants.HEADER_PLATFORM; +import static com.sopt.push.common.Constants.HEADER_SERVICE; +import static com.sopt.push.common.Constants.HEADER_TRANSACTION_ID; +import static com.sopt.push.common.Constants.USER_PREFIX; +import static com.sopt.push.common.StatusCode.INTERNAL_SERVER_ERROR; +import static com.sopt.push.enums.Platform.fromValue; +import static com.sopt.push.util.ValidationUtil.validateDto; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sopt.push.common.BusinessException; +import com.sopt.push.common.DeviceTokenException; +import com.sopt.push.common.ErrorMessage; +import com.sopt.push.common.SuccessMessage; +import com.sopt.push.config.AppFactory; +import com.sopt.push.config.ObjectMapperConfig; +import com.sopt.push.domain.DeviceTokenEntity; +import com.sopt.push.dto.ApiGatewayRequestDto; +import com.sopt.push.dto.CreateHistoryDto; +import com.sopt.push.dto.RegisterHeaderDto; +import com.sopt.push.dto.RequestDeleteTokenDto; +import com.sopt.push.dto.RequestRegisterUserDto; +import com.sopt.push.dto.RequestSendAllPushMessageDto; +import com.sopt.push.dto.RequestSendPushMessageDto; +import com.sopt.push.dto.UserTokenInfoDto; +import com.sopt.push.enums.Actions; +import com.sopt.push.enums.NotificationStatus; +import com.sopt.push.enums.NotificationType; +import com.sopt.push.enums.Platform; +import com.sopt.push.enums.Services; +import com.sopt.push.service.DeviceTokenService; +import com.sopt.push.service.EndpointFacade; +import com.sopt.push.service.HistoryService; +import com.sopt.push.service.SendPushFacade; +import com.sopt.push.util.ResponseUtil; +import java.util.Collections; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class ApiGatewayHandler + implements RequestHandler { + + private final DeviceTokenService deviceTokenService; + private final SendPushFacade sendPushFacade; + private final EndpointFacade endpointFacade; + private final HistoryService historyService; + private final ObjectMapper mapper; + + public ApiGatewayHandler() { + AppFactory factory = AppFactory.getInstance(); + this.deviceTokenService = factory.deviceTokenService(); + this.sendPushFacade = factory.sendPushFacade(); + this.endpointFacade = factory.endpointFacade(); + this.historyService = factory.historyService(); + this.mapper = ObjectMapperConfig.getObjectMapper(); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest( + APIGatewayProxyRequestEvent event, Context context) { + + try { + ApiGatewayRequestDto request = extractRequest(event); + Actions action = request.header().action(); + + switch (action) { + case REGISTER -> handleRegister(request); + case CANCEL -> handleCancel(request); + case SEND -> handleSend(request); + case SEND_ALL -> handleSendAll(request); + default -> throw new BusinessException(ErrorMessage.INVALID_REQUEST); + } + + SuccessMessage successMessage = getSuccessMessage(action); + Map responseMap = ResponseUtil.successResponse(successMessage); + return ResponseUtil.convertToApiGatewayResponse(responseMap); + + } catch (BusinessException ex) { + log.error("ApiGateway error: {}", ex.getMessage()); + int statusCode = ex.getErrorMessage().getHttpStatus(); + Map responseMap = ResponseUtil.errorResponse(statusCode, ex.getMessage()); + return ResponseUtil.convertToApiGatewayResponse(responseMap); + + } catch (Exception ex) { + log.error("ApiGateway error: {}", ex.getMessage(), ex); + Map responseMap = + ResponseUtil.errorResponse( + INTERNAL_SERVER_ERROR, ErrorMessage.INTERNAL_SERVER_ERROR.getMessage()); + return ResponseUtil.convertToApiGatewayResponse(responseMap); + } + } + + private ApiGatewayRequestDto extractRequest(APIGatewayProxyRequestEvent event) { + Map headers = event.getHeaders(); + Map body = parseRequestBody(event); + boolean isInvalidHeader = headers == null || headers.get(HEADER_ACTION) == null; + + if (isInvalidHeader) { + throw new BusinessException(ErrorMessage.INVALID_REQUEST, "Headers missing or invalid."); + } + + try { + String actionStr = headers.get(HEADER_ACTION); + String platformStr = headers.get(HEADER_PLATFORM); + String transactionId = headers.get(HEADER_TRANSACTION_ID); + String serviceStr = headers.get(HEADER_SERVICE); + Actions action = Actions.fromValue(actionStr); + Platform platform = Platform.fromValue(platformStr); + RegisterHeaderDto header = + new RegisterHeaderDto(transactionId, Services.fromValue(serviceStr), platform, action); + + return new ApiGatewayRequestDto(header, body); + } catch (Exception e) { + log.error("Failed to extract request: {}", e.getMessage(), e); + throw new BusinessException(ErrorMessage.INVALID_REQUEST, e.getMessage()); + } + } + + private void handleRegister(ApiGatewayRequestDto request) { + RequestRegisterUserDto body = mapper.convertValue(request.body(), RequestRegisterUserDto.class); + String transactionId = request.header().transactionId(); + Services service = request.header().service(); + Platform platform = request.header().platform(); + String deviceToken = body.deviceToken(); + Set userIds = body.userIds(); + String userId = (userIds != null && !userIds.isEmpty()) ? userIds.iterator().next() : null; + RequestRegisterUserDto finalDto = + new RequestRegisterUserDto(transactionId, service, platform, deviceToken, userIds); + + validateDto(finalDto); + + try { + endpointFacade.register(deviceToken, platform, userId); + createHistoryLog( + transactionId, + userIds, + deviceToken, + platform, + service, + NotificationStatus.SUCCESS, + Actions.REGISTER); + } catch (Exception e) { + log.error("Failed to register token: {}", e.getMessage(), e); + throw new DeviceTokenException(ErrorMessage.REGISTER_USER_ERROR, e.getMessage(), e); + } + } + + private void handleCancel(ApiGatewayRequestDto request) { + RequestDeleteTokenDto body = mapper.convertValue(request.body(), RequestDeleteTokenDto.class); + String transactionId = request.header().transactionId(); + Services service = request.header().service(); + Platform platform = request.header().platform(); + String deviceToken = body.deviceToken(); + Set userIds = body.userIds(); + RequestDeleteTokenDto requestDto = + new RequestDeleteTokenDto(transactionId, service, platform, deviceToken, userIds); + + validateDto(requestDto); + + try { + boolean isInvalidUserId = userIds != null && !userIds.isEmpty(); + String userId = isInvalidUserId ? userIds.iterator().next() : null; + DeviceTokenEntity tokenEntity = + deviceTokenService.findTokenByDeviceTokenAndUserId(deviceToken, userId); + + String endpointArn = tokenEntity.getEndpointArn(); + String subscriptionArn = tokenEntity.getSubscriptionArn(); + Platform tokenPlatform = fromValue(tokenEntity.getPlatform()); + UserTokenInfoDto userTokenInfo = + new UserTokenInfoDto(userId, deviceToken, endpointArn, tokenPlatform, subscriptionArn); + + endpointFacade.clean(userTokenInfo); + createHistoryLog( + transactionId, + Set.of(userId), + deviceToken, + platform, + service, + NotificationStatus.SUCCESS, + Actions.CANCEL); + } catch (Exception e) { + log.error("Failed to cancel token: {}", e.getMessage(), e); + throw new DeviceTokenException(ErrorMessage.DELETE_TOKEN_ERROR, e.getMessage(), e); + } + } + + private void handleSend(ApiGatewayRequestDto request) { + RequestSendPushMessageDto body = + mapper.convertValue(request.body(), RequestSendPushMessageDto.class); + RequestSendPushMessageDto sendPushMessageDto = + new RequestSendPushMessageDto( + request.header().transactionId(), + request.header().service(), + body.userIds(), + body.title(), + body.content(), + body.category(), + body.deepLink(), + body.webLink()); + + validateDto(sendPushMessageDto); + sendPushFacade.sendPush(sendPushMessageDto); + } + + private void handleSendAll(ApiGatewayRequestDto request) { + RequestSendAllPushMessageDto body = + mapper.convertValue(request.body(), RequestSendAllPushMessageDto.class); + RequestSendAllPushMessageDto sendAllPushMessageDto = + new RequestSendAllPushMessageDto( + request.header().transactionId(), + request.header().service(), + body.title(), + body.content(), + body.category(), + body.deepLink(), + body.webLink()); + + validateDto(sendAllPushMessageDto); + sendPushFacade.sendPushAll(sendAllPushMessageDto); + } + + private Map parseRequestBody(APIGatewayProxyRequestEvent event) { + if (event.getBody() == null) { + throw new BusinessException(ErrorMessage.INVALID_REQUEST, "Request body is missing."); + } + + try { + return mapper.readValue(event.getBody(), Map.class); + } catch (Exception e) { + throw new BusinessException(ErrorMessage.INVALID_REQUEST, "Failed to parse request body."); + } + } + + private SuccessMessage getSuccessMessage(Actions action) { + return switch (action) { + case REGISTER -> SuccessMessage.TOKEN_REGISTER_SUCCESS; + case CANCEL -> SuccessMessage.TOKEN_CANCEL_SUCCESS; + case SEND, SEND_ALL -> SuccessMessage.SEND_SUCCESS; + }; + } + + private void createHistoryLog( + String transactionId, + Set userIds, + String deviceToken, + Platform platform, + Services service, + NotificationStatus status, + Actions action) { + CreateHistoryDto createHistoryDto = + new CreateHistoryDto( + transactionId, + null, + null, + null, + null, + NotificationType.PUSH.getValue(), + service.getValue(), + status.getValue(), + action.getValue(), + platform != null ? platform.getValue() : null, + deviceToken, + null, + userIds != null + ? userIds.stream().map(u -> USER_PREFIX + u).collect(Collectors.toSet()) + : Collections.emptySet(), + null, + null, + null, + null); + historyService.createLog(createHistoryDto); + } +} diff --git a/src/main/java/com/sopt/push/lambda/EventBridgeHandler.java b/src/main/java/com/sopt/push/lambda/EventBridgeHandler.java index 3f71ea5..e7a6137 100644 --- a/src/main/java/com/sopt/push/lambda/EventBridgeHandler.java +++ b/src/main/java/com/sopt/push/lambda/EventBridgeHandler.java @@ -1,7 +1,7 @@ package com.sopt.push.lambda; import static com.sopt.push.common.Constants.DETAIL; -import static com.sopt.push.util.ValidationUtil.validate; +import static com.sopt.push.util.ValidationUtil.validateDto; import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; @@ -80,7 +80,7 @@ private void handleSend(CustomEventDetailDto detail) { body.deepLink(), body.webLink()); - validate(finalDto); + validateDto(finalDto); sendPushFacade.sendPush(finalDto); webHookService.scheduleSuccessWebHook(detail.header().alarmId()); @@ -101,7 +101,7 @@ private void handleSendAll(CustomEventDetailDto detail) { body.deepLink(), body.webLink()); - validate(finalDto); + validateDto(finalDto); sendPushFacade.sendPushAll(finalDto); webHookService.scheduleSuccessWebHook(detail.header().alarmId()); diff --git a/src/main/java/com/sopt/push/lambda/SnsHandler.java b/src/main/java/com/sopt/push/lambda/SnsHandler.java index 51c6d4e..255b594 100644 --- a/src/main/java/com/sopt/push/lambda/SnsHandler.java +++ b/src/main/java/com/sopt/push/lambda/SnsHandler.java @@ -15,8 +15,8 @@ import com.sopt.push.enums.NotificationStatus; import com.sopt.push.enums.NotificationType; import com.sopt.push.service.DeviceTokenService; +import com.sopt.push.service.EndpointFacade; import com.sopt.push.service.HistoryService; -import com.sopt.push.service.InvalidEndpointCleaner; import com.sopt.push.service.UserService; import java.util.HashMap; import java.util.List; @@ -33,7 +33,7 @@ public class SnsHandler implements RequestHandler { private final UserService userService; private final DeviceTokenService deviceTokenService; private final HistoryService historyService; - private final InvalidEndpointCleaner invalidEndpointCleaner; + private final EndpointFacade endpointFacade; private final ObjectMapper objectMapper; public SnsHandler() { @@ -41,7 +41,7 @@ public SnsHandler() { this.userService = factory.userService(); this.deviceTokenService = factory.deviceTokenService(); this.historyService = factory.historyService(); - this.invalidEndpointCleaner = factory.invalidEndpointCleaner(); + this.endpointFacade = factory.endpointFacade(); this.objectMapper = ObjectMapperConfig.getObjectMapper(); } @@ -127,7 +127,7 @@ private void handleInvalidPushEndpoint(UserTokenInfoDto userTokenInfoDto, String createFailLog(userTokenInfoDto.userId(), messageId); try { - invalidEndpointCleaner.clean(userTokenInfoDto); + endpointFacade.clean(userTokenInfoDto); } catch (Exception e) { log.error("Failed to clean invalid endpoint for userId={}", userTokenInfoDto.userId(), e); } diff --git a/src/main/java/com/sopt/push/service/DeviceTokenService.java b/src/main/java/com/sopt/push/service/DeviceTokenService.java index 140fc34..580a6ee 100644 --- a/src/main/java/com/sopt/push/service/DeviceTokenService.java +++ b/src/main/java/com/sopt/push/service/DeviceTokenService.java @@ -11,15 +11,14 @@ import java.time.Instant; import java.util.ArrayList; import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +@RequiredArgsConstructor public class DeviceTokenService { private final DeviceTokenRepository deviceTokenRepository; - public DeviceTokenService(DeviceTokenRepository deviceTokenRepository) { - this.deviceTokenRepository = deviceTokenRepository; - } - public void createToken( String userId, String deviceToken, @@ -44,10 +43,15 @@ public void createToken( public void deleteToken(String userId, String deviceToken) { String tokenPk = TOKEN_PREFIX + deviceToken; String userSk = USER_PREFIX + userId; - deviceTokenRepository.delete(tokenPk, userSk); } + public DeviceTokenEntity findTokenByDeviceTokenAndUserId(String deviceToken, String userId) { + String tokenPk = TOKEN_PREFIX + deviceToken; + String userSk = USER_PREFIX + userId; + return deviceTokenRepository.findByPkAndSk(tokenPk, userSk).orElse(null); + } + public List findUserByTokenIds(List deviceTokens) { List result = new ArrayList<>(); for (String deviceToken : deviceTokens) { @@ -56,6 +60,10 @@ public List findUserByTokenIds(List deviceTokens) { return result; } + public Optional findByDeviceToken(String deviceToken) { + return deviceTokenRepository.findByDeviceToken(deviceToken); + } + public UserTokenInfoDto mapDeviceTokenEntityToInfoDto(DeviceTokenEntity deviceTokenEntity) { String deviceToken = deviceTokenEntity.getPk().startsWith(TOKEN_PREFIX) diff --git a/src/main/java/com/sopt/push/service/EndpointFacade.java b/src/main/java/com/sopt/push/service/EndpointFacade.java new file mode 100644 index 0000000..d6ddf47 --- /dev/null +++ b/src/main/java/com/sopt/push/service/EndpointFacade.java @@ -0,0 +1,99 @@ +package com.sopt.push.service; + +import static com.sopt.push.common.Constants.USER_PREFIX; + +import com.sopt.push.common.DeviceTokenException; +import com.sopt.push.common.ErrorMessage; +import com.sopt.push.domain.DeviceTokenEntity; +import com.sopt.push.dto.UserTokenInfoDto; +import com.sopt.push.enums.Platform; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import software.amazon.awssdk.services.sns.model.CreatePlatformEndpointResponse; +import software.amazon.awssdk.services.sns.model.SubscribeResponse; + +@Slf4j +@RequiredArgsConstructor +public class EndpointFacade { + + private final DeviceTokenService deviceTokenService; + private final UserService userService; + private final NotificationService notificationService; + + public void register(String deviceToken, Platform platform, String inputUserId) { + Optional existingToken = deviceTokenService.findByDeviceToken(deviceToken); + checkAndCleanExistingToken(existingToken, inputUserId, deviceToken); + + String endpointArn = createSnsEndpoint(deviceToken, platform, inputUserId); + String subscriptionArn = subscribeToSnsTopic(endpointArn); + + deviceTokenService.createToken( + inputUserId, deviceToken, platform.getValue(), endpointArn, subscriptionArn); + + userService.registerUser( + inputUserId, deviceToken, platform.getValue(), endpointArn, subscriptionArn); + } + + public void clean(UserTokenInfoDto token) { + userService.deleteUser(token.userId(), token.deviceToken()); + deviceTokenService.deleteToken(token.userId(), token.deviceToken()); + + try { + notificationService.deleteEndpoint(token.endpointArn()); + notificationService.unsubscribe(token.subscriptionArn()); + } catch (Exception e) { + log.error("Failed to delete SNS endpoint: {} - {}", token.endpointArn(), e.getMessage()); + } + } + + private void checkAndCleanExistingToken( + Optional existingTokenOpt, String actualUserId, String deviceToken) { + if (existingTokenOpt.isEmpty()) return; + + DeviceTokenEntity deviceTokenEntity = existingTokenOpt.get(); + String existingUserId = extractUserId(deviceTokenEntity.getSk()); + boolean isSameUserId = actualUserId.equals(existingUserId); + if (isSameUserId) return; + + UserTokenInfoDto existingToken = + new UserTokenInfoDto( + existingUserId, + deviceToken, + deviceTokenEntity.getEndpointArn(), + Platform.fromValue(deviceTokenEntity.getPlatform()), + deviceTokenEntity.getSubscriptionArn()); + + clean(existingToken); + } + + private String createSnsEndpoint(String deviceToken, Platform platform, String userId) { + CreatePlatformEndpointResponse endpoint = + notificationService.registerEndpoint(deviceToken, platform, userId); + + String endpointArn = endpoint.endpointArn(); + boolean isInvalidEndpointArn = endpointArn == null || endpointArn.isBlank(); + if (isInvalidEndpointArn) { + throw new DeviceTokenException(ErrorMessage.ENDPOINT_ARN_UNDEFINED); + } + return endpointArn; + } + + private String subscribeToSnsTopic(String endpointArn) { + SubscribeResponse subscription = notificationService.subscribe(endpointArn); + + String subscriptionArn = subscription.subscriptionArn(); + boolean isInvalidSubscriptionArn = subscriptionArn == null || subscriptionArn.isBlank(); + if (isInvalidSubscriptionArn) { + throw new DeviceTokenException(ErrorMessage.SUBSCRIPTION_ARN_UNDEFINED); + } + return subscriptionArn; + } + + private String extractUserId(String sk) { + if (sk.startsWith(USER_PREFIX)) { + return sk.substring(USER_PREFIX.length()); + } + return sk; + } +} diff --git a/src/main/java/com/sopt/push/service/InvalidEndpointCleaner.java b/src/main/java/com/sopt/push/service/InvalidEndpointCleaner.java deleted file mode 100644 index 3be3e1e..0000000 --- a/src/main/java/com/sopt/push/service/InvalidEndpointCleaner.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.sopt.push.service; - -import com.sopt.push.dto.UserTokenInfoDto; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -public class InvalidEndpointCleaner { - - private final UserService userService; - private final DeviceTokenService tokenService; - private final NotificationService notificationService; - - public InvalidEndpointCleaner( - UserService userService, - DeviceTokenService tokenService, - NotificationService notificationService) { - this.userService = userService; - this.tokenService = tokenService; - this.notificationService = notificationService; - } - - public void clean(UserTokenInfoDto token) { - - userService.deleteUser(token.userId(), token.deviceToken()); - tokenService.deleteToken(token.userId(), token.deviceToken()); - - try { - notificationService.deleteEndpoint(token.endpointArn()); - notificationService.unsubscribe(token.subscriptionArn()); - - } catch (Exception e) { - log.error("Failed to delete SNS endpoint: {} - {}", token.endpointArn(), e.getMessage()); - } - } -} diff --git a/src/main/java/com/sopt/push/service/NotificationService.java b/src/main/java/com/sopt/push/service/NotificationService.java index b8b79cd..ca89106 100644 --- a/src/main/java/com/sopt/push/service/NotificationService.java +++ b/src/main/java/com/sopt/push/service/NotificationService.java @@ -1,7 +1,8 @@ package com.sopt.push.service; import static com.sopt.push.common.Constants.JSON; -import static com.sopt.push.util.ValidationUtil.validate; +import static com.sopt.push.common.Constants.SNS_PROTOCOL_APPLICATION; +import static com.sopt.push.util.ValidationUtil.validateDto; import com.sopt.push.common.ExternalException; import com.sopt.push.common.InvalidEndpointException; @@ -14,12 +15,16 @@ import com.sopt.push.message.MessageCreator; import jakarta.validation.Validator; import software.amazon.awssdk.services.sns.SnsClient; +import software.amazon.awssdk.services.sns.model.CreatePlatformEndpointRequest; +import software.amazon.awssdk.services.sns.model.CreatePlatformEndpointResponse; import software.amazon.awssdk.services.sns.model.DeleteEndpointRequest; import software.amazon.awssdk.services.sns.model.EndpointDisabledException; import software.amazon.awssdk.services.sns.model.InvalidParameterException; import software.amazon.awssdk.services.sns.model.PublishRequest; import software.amazon.awssdk.services.sns.model.PublishResponse; import software.amazon.awssdk.services.sns.model.SnsException; +import software.amazon.awssdk.services.sns.model.SubscribeRequest; +import software.amazon.awssdk.services.sns.model.SubscribeResponse; import software.amazon.awssdk.services.sns.model.UnsubscribeRequest; public class NotificationService { @@ -27,11 +32,15 @@ public class NotificationService { private final SnsClient snsClient; private final Validator validator; private final String allTopicArn; + private final String iosArn; + private final String androidArn; public NotificationService(SnsClient snsClient, EnvConfig envConfig) { this.snsClient = snsClient; this.validator = ValidatorConfig.getValidator(); this.allTopicArn = envConfig.getAllTopicArn(); + this.iosArn = envConfig.getPlatformApplicationIosArn(); + this.androidArn = envConfig.getPlatformApplicationAndroidArn(); } public String platformPush( @@ -44,13 +53,13 @@ public String platformPush( String messageId, Platform platform) { try { - MessageFactoryDto dto = + MessageFactoryDto messageFactoryDto = new MessageFactoryDto( platform.getTopic(), messageId, title, content, category, deepLink, webLink); - validate(dto); + validateDto(messageFactoryDto); - String messageJson = MessageCreator.create(dto); + String messageJson = MessageCreator.create(messageFactoryDto); PublishRequest publishRequest = PublishRequest.builder() @@ -84,7 +93,7 @@ public String allTopicPush( new MessageFactoryDto( PushTopic.ALL, messageId, title, content, category, deepLink, webLink); - validate(messageFactoryDto); + validateDto(messageFactoryDto); String messageJson = MessageCreator.create(messageFactoryDto); @@ -114,4 +123,36 @@ public void deleteEndpoint(String endpointArn) { public void unsubscribe(String subscriptionArn) { snsClient.unsubscribe(UnsubscribeRequest.builder().subscriptionArn(subscriptionArn).build()); } + + public CreatePlatformEndpointResponse registerEndpoint( + String deviceToken, Platform platform, String userId) { + String platformApplicationArn = getPlatformApplicationArn(platform); + CreatePlatformEndpointRequest.Builder requestBuilder = + CreatePlatformEndpointRequest.builder() + .platformApplicationArn(platformApplicationArn) + .token(deviceToken); + boolean isValidUserId = userId != null && !userId.isBlank(); + + if (isValidUserId) { + requestBuilder.customUserData(userId); + } + + CreatePlatformEndpointRequest request = requestBuilder.build(); + return snsClient.createPlatformEndpoint(request); + } + + public SubscribeResponse subscribe(String endpointArn) { + SubscribeRequest request = + SubscribeRequest.builder() + .protocol(SNS_PROTOCOL_APPLICATION) + .endpoint(endpointArn) + .topicArn(allTopicArn) + .build(); + + return snsClient.subscribe(request); + } + + private String getPlatformApplicationArn(Platform platform) { + return platform == Platform.IOS ? iosArn : androidArn; + } } diff --git a/src/main/java/com/sopt/push/service/SendPushFacade.java b/src/main/java/com/sopt/push/service/SendPushFacade.java index 5f6ff7c..2968329 100644 --- a/src/main/java/com/sopt/push/service/SendPushFacade.java +++ b/src/main/java/com/sopt/push/service/SendPushFacade.java @@ -26,7 +26,7 @@ public class SendPushFacade { private final HistoryService historyService; private final UserService userService; private final DeviceTokenService deviceTokenService; - private final InvalidEndpointCleaner cleaner; + private final EndpointFacade endpointFacade; public SendPushFacade( NotificationService notificationService, @@ -34,13 +34,13 @@ public SendPushFacade( HistoryService historyService, UserService userService, DeviceTokenService deviceTokenService, - InvalidEndpointCleaner invalidEndpointCleaner) { + EndpointFacade endpointFacade) { this.notificationService = notificationService; this.webHookService = webHookService; this.historyService = historyService; this.userService = userService; this.deviceTokenService = deviceTokenService; - this.cleaner = invalidEndpointCleaner; + this.endpointFacade = endpointFacade; } public void sendPush(RequestSendPushMessageDto dto) { @@ -125,7 +125,7 @@ private String sendToUser( userTokenInfoDto.platform()); } catch (InvalidEndpointException ex) { - cleaner.clean(userTokenInfoDto); + endpointFacade.clean(userTokenInfoDto); } catch (ExternalException ex) { log.error("Push failed for user={} err={}", userTokenInfoDto.userId(), ex.getMessage()); diff --git a/src/main/java/com/sopt/push/service/UserService.java b/src/main/java/com/sopt/push/service/UserService.java index 968c272..ed1f54a 100644 --- a/src/main/java/com/sopt/push/service/UserService.java +++ b/src/main/java/com/sopt/push/service/UserService.java @@ -1,12 +1,14 @@ package com.sopt.push.service; import static com.sopt.push.common.Constants.TOKEN_PREFIX; +import static com.sopt.push.common.Constants.USER_ENTITY; import static com.sopt.push.common.Constants.USER_PREFIX; import com.sopt.push.domain.UserEntity; import com.sopt.push.dto.UserTokenInfoDto; import com.sopt.push.enums.Platform; import com.sopt.push.repository.UserRepository; +import java.time.Instant; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -52,4 +54,25 @@ public void deleteUser(String userId, String deviceToken) { String tokenSk = TOKEN_PREFIX + deviceToken; userRepository.delete(userPk, tokenSk); } + + public void registerUser( + String userId, + String deviceToken, + String platform, + String endpointArn, + String subscriptionArn) { + String userPk = USER_PREFIX + userId; + String tokenSk = TOKEN_PREFIX + deviceToken; + + UserEntity userEntity = new UserEntity(); + userEntity.setPk(userPk); + userEntity.setSk(tokenSk); + userEntity.setEntity(USER_ENTITY); + userEntity.setPlatform(platform); + userEntity.setEndpointArn(endpointArn); + userEntity.setSubscriptionArn(subscriptionArn); + userEntity.setCreatedAt(Instant.now().toString()); + + userRepository.save(userEntity); + } } diff --git a/src/main/java/com/sopt/push/util/ResponseUtil.java b/src/main/java/com/sopt/push/util/ResponseUtil.java index c314b9f..5f7ef11 100644 --- a/src/main/java/com/sopt/push/util/ResponseUtil.java +++ b/src/main/java/com/sopt/push/util/ResponseUtil.java @@ -2,6 +2,7 @@ import static com.sopt.push.common.StatusCode.INTERNAL_SERVER_ERROR; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.sopt.push.common.SuccessMessage; @@ -38,4 +39,12 @@ public static Map errorResponse(int status, String message) { return Map.of(KEY_STATUS_CODE, INTERNAL_SERVER_ERROR, KEY_BODY, ERROR_MESSAGE_FATAL); } } + + public static APIGatewayProxyResponseEvent convertToApiGatewayResponse( + Map responseMap) { + APIGatewayProxyResponseEvent response = new APIGatewayProxyResponseEvent(); + response.setStatusCode((Integer) responseMap.get(KEY_STATUS_CODE)); + response.setBody((String) responseMap.get(KEY_BODY)); + return response; + } } diff --git a/src/main/java/com/sopt/push/util/ValidationUtil.java b/src/main/java/com/sopt/push/util/ValidationUtil.java index 2ccea58..230f4b5 100644 --- a/src/main/java/com/sopt/push/util/ValidationUtil.java +++ b/src/main/java/com/sopt/push/util/ValidationUtil.java @@ -14,7 +14,7 @@ public final class ValidationUtil { private static final Validator VALIDATOR = ValidatorConfig.getValidator(); - public static void validate(T dto) { + public static void validateDto(T dto) { Set> violations = VALIDATOR.validate(dto); boolean isNotEmpty = !violations.isEmpty(); if (isNotEmpty) { diff --git a/template.yaml b/template.yaml index f6f9491..6e95f50 100644 --- a/template.yaml +++ b/template.yaml @@ -130,7 +130,7 @@ Resources: Type: AWS::Serverless::Function Properties: FunctionName: !Sub "sopt-push-notification-lambda-${Stage}" - Handler: com.sopt.push.lambda.ApiHandler::handleRequest + Handler: com.sopt.push.lambda.ApiGatewayHandler::handleRequest CodeUri: build/libs/app.jar Role: !GetAtt PushLambdaRole.Arn Events: