From 538f01288d0617effc7b583d41d7ac75dec494f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4?= Date: Wed, 24 Dec 2025 01:13:05 +0900 Subject: [PATCH 01/34] =?UTF-8?q?[FEAT]=20DeviceTokenException=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 관련 ErrorMessage 추가 --- .../push/common/DeviceTokenException.java | 25 +++++++++++++++++++ .../com/sopt/push/common/ErrorMessage.java | 13 +++++++++- .../push/common/InvalidEndpointException.java | 2 +- .../sopt/push/common/PushFailException.java | 2 +- 4 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/sopt/push/common/DeviceTokenException.java 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..070ec8f --- /dev/null +++ b/src/main/java/com/sopt/push/common/DeviceTokenException.java @@ -0,0 +1,25 @@ +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/common/InvalidEndpointException.java b/src/main/java/com/sopt/push/common/InvalidEndpointException.java index 9c2caf1..d04783c 100644 --- a/src/main/java/com/sopt/push/common/InvalidEndpointException.java +++ b/src/main/java/com/sopt/push/common/InvalidEndpointException.java @@ -8,4 +8,4 @@ public InvalidEndpointException(String endpointArn, Throwable cause) { super("Invalid SNS endpoint: " + endpointArn, cause); this.endpointArn = endpointArn; } -} +} \ No newline at end of file diff --git a/src/main/java/com/sopt/push/common/PushFailException.java b/src/main/java/com/sopt/push/common/PushFailException.java index b7e72ed..a719d7e 100644 --- a/src/main/java/com/sopt/push/common/PushFailException.java +++ b/src/main/java/com/sopt/push/common/PushFailException.java @@ -5,4 +5,4 @@ public class PushFailException extends RuntimeException { public PushFailException(String message, Throwable cause) { super(message, cause); } -} +} \ No newline at end of file From 8323f6760381d0ae76d9699c3977ced49b2aaf42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4?= Date: Wed, 24 Dec 2025 01:16:00 +0900 Subject: [PATCH 02/34] =?UTF-8?q?[REFACTOR]=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EC=9E=90=20=EB=A1=AC=EB=B3=B5=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=8C=80=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/sopt/push/service/UserService.java | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/sopt/push/service/UserService.java b/src/main/java/com/sopt/push/service/UserService.java index f32fd57..968c272 100644 --- a/src/main/java/com/sopt/push/service/UserService.java +++ b/src/main/java/com/sopt/push/service/UserService.java @@ -10,15 +10,13 @@ import java.util.HashSet; import java.util.List; import java.util.Set; +import lombok.RequiredArgsConstructor; +@RequiredArgsConstructor public class UserService { private final UserRepository userRepository; - public UserService(UserRepository userRepository) { - this.userRepository = userRepository; - } - public Set findTokenByUserIds(Set userIds) { Set allUserTokens = new HashSet<>(); @@ -31,12 +29,6 @@ public Set findTokenByUserIds(Set userIds) { return allUserTokens; } - public void deleteUser(String userId, String deviceToken) { - String userPk = USER_PREFIX + userId; - String tokenSk = TOKEN_PREFIX + deviceToken; - userRepository.delete(userPk, tokenSk); - } - private UserTokenInfoDto mapUserEntityToInfoDto(UserEntity userEntity) { String userId = userEntity.getPk().startsWith(USER_PREFIX) @@ -54,4 +46,10 @@ private UserTokenInfoDto mapUserEntityToInfoDto(UserEntity userEntity) { Platform.fromValue(userEntity.getPlatform()), userEntity.getSubscriptionArn()); } + + public void deleteUser(String userId, String deviceToken) { + String userPk = USER_PREFIX + userId; + String tokenSk = TOKEN_PREFIX + deviceToken; + userRepository.delete(userPk, tokenSk); + } } From 3a16f115732f63e4d7ce688b53db19a07b6ef6a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4?= Date: Wed, 24 Dec 2025 01:20:11 +0900 Subject: [PATCH 03/34] =?UTF-8?q?[FIX]=20DeviceTokenService=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84(=ED=86=A0=ED=81=B0=20=EB=93=B1=EB=A1=9D,=20=20?= =?UTF-8?q?=EC=9C=A0=EC=A0=80=20=EC=A0=80=EC=9E=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sopt/push/service/DeviceTokenService.java | 103 +++++++++++++++++- 1 file changed, 102 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/sopt/push/service/DeviceTokenService.java b/src/main/java/com/sopt/push/service/DeviceTokenService.java index 1856fb6..7bc9cb7 100644 --- a/src/main/java/com/sopt/push/service/DeviceTokenService.java +++ b/src/main/java/com/sopt/push/service/DeviceTokenService.java @@ -2,18 +2,33 @@ import static com.sopt.push.common.Constants.DEVICE_TOKEN_ENTITY; 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.common.DeviceTokenException; +import com.sopt.push.common.ErrorMessage; +import com.sopt.push.config.SnsFactory; import com.sopt.push.domain.DeviceTokenEntity; +import com.sopt.push.domain.UserEntity; +import com.sopt.push.enums.Platform; import com.sopt.push.repository.DeviceTokenRepository; +import com.sopt.push.repository.UserRepository; import java.time.Instant; +import java.util.List; public class DeviceTokenService { private final DeviceTokenRepository deviceTokenRepository; + private final UserRepository userRepository; + private final SnsFactory snsFactory; - public DeviceTokenService(DeviceTokenRepository deviceTokenRepository) { + public DeviceTokenService( + DeviceTokenRepository deviceTokenRepository, + UserRepository userRepository, + SnsFactory snsFactory) { this.deviceTokenRepository = deviceTokenRepository; + this.userRepository = userRepository; + this.snsFactory = snsFactory; } public void createToken( @@ -42,5 +57,91 @@ public void deleteToken(String userId, String deviceToken) { String userSk = USER_PREFIX + userId; deviceTokenRepository.delete(tokenPk, userSk); + deleteUser(userId, deviceToken); + } + + 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 void registerToken(String deviceToken, Platform platform, String userId) { + String actualUserId = userId != null ? userId : "unknown"; + String tokenPk = TOKEN_PREFIX + deviceToken; + List existingTokens = deviceTokenRepository.queryByPk(tokenPk); + + if (!existingTokens.isEmpty()) { + DeviceTokenEntity existing = existingTokens.get(0); + String existingUserId = extractUserIdFromSk(existing.getSk()); + if (!changedUserPayload(existingUserId, actualUserId)) { + return; + } + + } + + var endpoint = snsFactory.registerEndPoint(deviceToken, platform, userId); + + String endpointArn = endpoint.endpointArn(); + if (endpointArn == null || endpointArn.isBlank()) { + throw new DeviceTokenException(ErrorMessage.ENDPOINT_ARN_UNDEFINED); + } + + var sub = snsFactory.subscribe(endpointArn); + + String subscriptionArn = sub.subscriptionArn(); + if (subscriptionArn == null || subscriptionArn.isBlank()) { + throw new DeviceTokenException(ErrorMessage.SUBSCRIPTION_ARN_UNDEFINED); + } + + createToken(actualUserId, deviceToken, platform.getValue(), endpointArn, subscriptionArn); + saveUserEntity(actualUserId, deviceToken, platform.getValue(), endpointArn, subscriptionArn); + } + + private void saveUserEntity( + 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); + } + + public void deleteUser(String userId, String deviceToken) { + String userPk = USER_PREFIX + userId; + String tokenSk = TOKEN_PREFIX + deviceToken; + userRepository.delete(userPk, tokenSk); + } + + private boolean changedUserPayload(String tokenUserId, String inputUserId) { + if (inputUserId == null && tokenUserId.equals("unknown")) { + return false; + } + if (inputUserId != null && inputUserId.equals(tokenUserId)) { + return false; + } + return true; + } + + private String extractUserIdFromSk(String sk) { + if (sk.startsWith(USER_PREFIX)) { + return sk.substring(USER_PREFIX.length()); + } + return sk; + } + + private String extractDeviceTokenFromPk(String pk) { + if (pk.startsWith(TOKEN_PREFIX)) { + return pk.substring(TOKEN_PREFIX.length()); + } + return pk; } } From 5298b81075cca1cd6356987d6cc8b24310bb573f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4?= Date: Wed, 24 Dec 2025 01:21:12 +0900 Subject: [PATCH 04/34] =?UTF-8?q?[FEAT]=20pk=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EB=94=94=EB=B0=94=EC=9D=B4=EC=8A=A4=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/sopt/push/repository/DeviceTokenRepository.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/java/com/sopt/push/repository/DeviceTokenRepository.java b/src/main/java/com/sopt/push/repository/DeviceTokenRepository.java index 3e7a91d..e5dc6f6 100644 --- a/src/main/java/com/sopt/push/repository/DeviceTokenRepository.java +++ b/src/main/java/com/sopt/push/repository/DeviceTokenRepository.java @@ -1,11 +1,13 @@ package com.sopt.push.repository; import com.sopt.push.domain.DeviceTokenEntity; +import java.util.List; import java.util.Optional; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.Key; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; public class DeviceTokenRepository { @@ -28,4 +30,10 @@ public Optional findByPkAndSk(String pk, String sk) { Key key = Key.builder().partitionValue(pk).sortValue(sk).build(); return Optional.ofNullable(deviceTokenTable.getItem(key)); } + + public List queryByPk(String pk) { + QueryConditional queryConditional = + QueryConditional.keyEqualTo(Key.builder().partitionValue(pk).build()); + return deviceTokenTable.query(queryConditional).items().stream().toList(); + } } From 46ab2e7c67e825943bb687e71a2f1501cdfe03c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4?= Date: Wed, 24 Dec 2025 01:21:47 +0900 Subject: [PATCH 05/34] =?UTF-8?q?[FIX]=20=EC=B6=94=EA=B0=80=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=EC=97=90=20=EB=94=B0=EB=A5=B8=20AppFactory=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/sopt/push/config/AppFactory.java | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/sopt/push/config/AppFactory.java b/src/main/java/com/sopt/push/config/AppFactory.java index 1022c2b..9fd43f4 100644 --- a/src/main/java/com/sopt/push/config/AppFactory.java +++ b/src/main/java/com/sopt/push/config/AppFactory.java @@ -19,6 +19,11 @@ public class AppFactory { private final SendPushFacade sendPushFacade; private final WebHookService webHookService; + private final UserService userService; + private final DeviceTokenService deviceTokenService; + private final NotificationService notificationService; + private final InvalidEndpointCleaner invalidEndpointCleaner; + private final HistoryService historyService; private AppFactory() { @@ -32,10 +37,11 @@ private AppFactory() { HistoryRepository historyRepository = new HistoryRepository(dynamoClient, tableName); DeviceTokenRepository tokenRepository = new DeviceTokenRepository(dynamoClient, tableName); - UserService userService = new UserService(userRepository); HistoryService historyService = new HistoryService(historyRepository); - DeviceTokenService deviceTokenService = new DeviceTokenService(tokenRepository); NotificationService notificationService = new NotificationService(snsClient, envConfig); + SnsFactory snsFactory = new SnsFactory(snsClient, envConfig); + DeviceTokenService deviceTokenService = new DeviceTokenService(tokenRepository, userRepository, snsFactory); + UserService userService = new UserService(userRepository); InvalidEndpointCleaner invalidEndpointCleaner = new InvalidEndpointCleaner(userService, deviceTokenService, notificationService); @@ -48,6 +54,11 @@ private AppFactory() { userService, deviceTokenService, invalidEndpointCleaner); + this.userService = userService; + this.deviceTokenService = deviceTokenService; + this.notificationService = notificationService; + this.invalidEndpointCleaner = invalidEndpointCleaner; + this.historyService = historyService; } public static AppFactory getInstance() { @@ -61,4 +72,25 @@ public SendPushFacade sendPushFacade() { public WebHookService webHookService() { return webHookService; } + + public DeviceTokenService deviceTokenService() { + return deviceTokenService; + } + + public UserService userService() { + return userService; + } + + public NotificationService notificationService() { + return notificationService; + } + + public InvalidEndpointCleaner invalidEndpointCleaner() { + return invalidEndpointCleaner; + } + + public HistoryService historyService() { + return historyService; + } + } From 4f8a3eff34fc3755f43e7a0d09ec51891afdc6a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4?= Date: Wed, 24 Dec 2025 01:22:44 +0900 Subject: [PATCH 06/34] =?UTF-8?q?[FEAT]=20SnsFactory=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sns 구독, endpoint ARN 등록 등 --- .../java/com/sopt/push/config/SnsFactory.java | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 src/main/java/com/sopt/push/config/SnsFactory.java diff --git a/src/main/java/com/sopt/push/config/SnsFactory.java b/src/main/java/com/sopt/push/config/SnsFactory.java new file mode 100644 index 0000000..5b83289 --- /dev/null +++ b/src/main/java/com/sopt/push/config/SnsFactory.java @@ -0,0 +1,69 @@ +package com.sopt.push.config; + +import com.sopt.push.common.DeviceTokenException; +import com.sopt.push.common.ErrorMessage; +import com.sopt.push.config.EnvConfig; +import com.sopt.push.enums.Platform; +import lombok.extern.slf4j.Slf4j; +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.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; + +@Slf4j +public class SnsFactory { + + private final SnsClient snsClient; + private final String allTopicArn; + private static final String PLATFORM_APPLICATION_IOS_ENV = "PLATFORM_APPLICATION_iOS"; + private static final String PLATFORM_APPLICATION_ANDROID_ENV = "PLATFORM_APPLICATION_ANDROID"; + + public SnsFactory(SnsClient snsClient, EnvConfig envConfig) { + this.snsClient = snsClient; + this.allTopicArn = envConfig.getAllTopicArn(); + } + + public SubscribeResponse subscribe(String endpointArn) { + SubscribeRequest request = + SubscribeRequest.builder() + .protocol("application") + .endpoint(endpointArn) + .topicArn(allTopicArn) + .build(); + + return snsClient.subscribe(request); + } + + public CreatePlatformEndpointResponse registerEndPoint( + String deviceToken, Platform platform, String userId) { + String platformApplicationArn = getPlatformApplicationArn(platform); + + CreatePlatformEndpointRequest.Builder requestBuilder = + CreatePlatformEndpointRequest.builder() + .platformApplicationArn(platformApplicationArn) + .token(deviceToken); + + if (userId != null && !userId.isBlank()) { + requestBuilder.customUserData(userId); + } + + CreatePlatformEndpointRequest request = requestBuilder.build(); + return snsClient.createPlatformEndpoint(request); + } + + private String getPlatformApplicationArn(Platform platform) { + String envVar = + platform == Platform.IOS ? PLATFORM_APPLICATION_IOS_ENV : PLATFORM_APPLICATION_ANDROID_ENV; + String value = System.getenv(envVar); + if (value == null || value.isBlank()) { + throw new DeviceTokenException( + ErrorMessage.PLATFORM_APP_ARN_NOT_SET, "Platform application ARN not set: " + envVar); + } + return value; + } +} + From e8d4aaeab9b218c9a6065ea432a3ce78006ebdfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4?= Date: Wed, 24 Dec 2025 01:24:24 +0900 Subject: [PATCH 07/34] =?UTF-8?q?[FEAT]=20=ED=86=A0=ED=81=B0=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20&=20=EC=82=AD=EC=A0=9C=20=EA=B4=80=EB=A0=A8=20DTO?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sopt/push/dto/ApiGatewayRequestDto.java | 8 ++++ .../com/sopt/push/dto/DeleteTokenDto.java | 8 ++++ .../sopt/push/dto/LogCreateRequestDto.java | 37 +++++++++++++++++++ .../com/sopt/push/dto/RegisterHeaderDto.java | 19 ++++++++++ .../com/sopt/push/dto/RegisterUserDto.java | 8 ++++ 5 files changed, 80 insertions(+) create mode 100644 src/main/java/com/sopt/push/dto/ApiGatewayRequestDto.java create mode 100644 src/main/java/com/sopt/push/dto/DeleteTokenDto.java create mode 100644 src/main/java/com/sopt/push/dto/LogCreateRequestDto.java create mode 100644 src/main/java/com/sopt/push/dto/RegisterHeaderDto.java create mode 100644 src/main/java/com/sopt/push/dto/RegisterUserDto.java 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..59324e2 --- /dev/null +++ b/src/main/java/com/sopt/push/dto/ApiGatewayRequestDto.java @@ -0,0 +1,8 @@ +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/DeleteTokenDto.java b/src/main/java/com/sopt/push/dto/DeleteTokenDto.java new file mode 100644 index 0000000..732298d --- /dev/null +++ b/src/main/java/com/sopt/push/dto/DeleteTokenDto.java @@ -0,0 +1,8 @@ +package com.sopt.push.dto; + +import java.util.Set; + +public record DeleteTokenDto( + String deviceToken, + Set userIds) {} + diff --git a/src/main/java/com/sopt/push/dto/LogCreateRequestDto.java b/src/main/java/com/sopt/push/dto/LogCreateRequestDto.java new file mode 100644 index 0000000..2bba181 --- /dev/null +++ b/src/main/java/com/sopt/push/dto/LogCreateRequestDto.java @@ -0,0 +1,37 @@ +package com.sopt.push.dto; + +import com.sopt.push.enums.Actions; +import com.sopt.push.enums.Category; +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 java.util.Set; + +public record LogCreateRequestDto( + String transactionId, + String entity, + String title, + String content, + String deviceToken, + String webLink, + String applink, + NotificationType notificationType, + Services orderServiceName, + NotificationStatus status, + Actions action, + Platform platform, + Category category, + String errorCode, + String errorMessage, + Set userIds, + Set messageIds, + String id +) { + private static final String NULL = "NULL"; + + private static String nvl(String v) { return (v == null || v.isBlank()) ? NULL : v; } + private static Set normalizeSet(Set s) { return (s == null || s.isEmpty()) ? Set.of(NULL) : s; } +} + 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..85c1ca1 --- /dev/null +++ b/src/main/java/com/sopt/push/dto/RegisterHeaderDto.java @@ -0,0 +1,19 @@ +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) { + + public RegisterHeaderDto { + if ((action == Actions.REGISTER || action == Actions.CANCEL) && platform == null) { + throw new IllegalArgumentException("Platform is required for REGISTER and CANCEL actions"); + } + } +} + diff --git a/src/main/java/com/sopt/push/dto/RegisterUserDto.java b/src/main/java/com/sopt/push/dto/RegisterUserDto.java new file mode 100644 index 0000000..3ee1c74 --- /dev/null +++ b/src/main/java/com/sopt/push/dto/RegisterUserDto.java @@ -0,0 +1,8 @@ +package com.sopt.push.dto; + +import java.util.Set; + +public record RegisterUserDto( + String deviceToken, + Set userIds) {} + From 8384d59b306db7387e9dabc196f5834f0930b019 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4?= Date: Wed, 24 Dec 2025 01:24:41 +0900 Subject: [PATCH 08/34] =?UTF-8?q?[FEAT]=20=EC=95=8C=EB=A6=BC=20=EC=A0=84?= =?UTF-8?q?=EC=86=A1=20=EA=B4=80=EB=A0=A8=20DTO=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/sopt/push/dto/SendAllPushDto.java | 11 +++++++++++ src/main/java/com/sopt/push/dto/SendPushDto.java | 13 +++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 src/main/java/com/sopt/push/dto/SendAllPushDto.java create mode 100644 src/main/java/com/sopt/push/dto/SendPushDto.java diff --git a/src/main/java/com/sopt/push/dto/SendAllPushDto.java b/src/main/java/com/sopt/push/dto/SendAllPushDto.java new file mode 100644 index 0000000..36fcd51 --- /dev/null +++ b/src/main/java/com/sopt/push/dto/SendAllPushDto.java @@ -0,0 +1,11 @@ +package com.sopt.push.dto; + +import com.sopt.push.enums.Category; + +public record SendAllPushDto( + String title, + String content, + Category category, + String deepLink, + String webLink) {} + diff --git a/src/main/java/com/sopt/push/dto/SendPushDto.java b/src/main/java/com/sopt/push/dto/SendPushDto.java new file mode 100644 index 0000000..92b3ef5 --- /dev/null +++ b/src/main/java/com/sopt/push/dto/SendPushDto.java @@ -0,0 +1,13 @@ +package com.sopt.push.dto; + +import com.sopt.push.enums.Category; +import java.util.Set; + +public record SendPushDto( + Set userIds, + String title, + String content, + Category category, + String deepLink, + String webLink) {} + From 2ae563186c43b5a647edce58a33fac51493a636a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4?= Date: Wed, 24 Dec 2025 01:24:57 +0900 Subject: [PATCH 09/34] =?UTF-8?q?[FEAT]=20ApiGatewayHandler=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sopt/push/lambda/ApiGatewayHandler.java | 365 ++++++++++++++++++ 1 file changed, 365 insertions(+) create mode 100644 src/main/java/com/sopt/push/lambda/ApiGatewayHandler.java 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..9172e62 --- /dev/null +++ b/src/main/java/com/sopt/push/lambda/ApiGatewayHandler.java @@ -0,0 +1,365 @@ +package com.sopt.push.lambda; + +import static com.sopt.push.util.ValidationUtil.validate; + +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 static com.sopt.push.common.StatusCode.BAD_REQUEST; +import static com.sopt.push.common.StatusCode.INTERNAL_SERVER_ERROR; + +import com.sopt.push.common.BusinessException; +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.dto.*; +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.common.DeviceTokenException; +import com.sopt.push.domain.DeviceTokenEntity; +import com.sopt.push.service.DeviceTokenService; +import com.sopt.push.service.HistoryService; +import com.sopt.push.service.InvalidEndpointCleaner; +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 static com.sopt.push.common.Constants.USER_PREFIX; +import static com.sopt.push.common.Constants.TOKEN_PREFIX; + +public class ApiGatewayHandler implements RequestHandler { + + private final DeviceTokenService deviceTokenService; + private final SendPushFacade sendPushFacade; + private final InvalidEndpointCleaner invalidEndpointCleaner; + private final HistoryService historyService; + private final ObjectMapper mapper; + + public ApiGatewayHandler() { + AppFactory factory = AppFactory.getInstance(); + this.deviceTokenService = factory.deviceTokenService(); + this.sendPushFacade = factory.sendPushFacade(); + this.invalidEndpointCleaner = factory.invalidEndpointCleaner(); + 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 convertToApiGatewayResponse(responseMap); + + } catch (BusinessException ex) { + context.getLogger().log("ApiGateway error: " + ex.getMessage()); + Map responseMap = ResponseUtil.errorResponse(BAD_REQUEST, ex.getMessage()); + return convertToApiGatewayResponse(responseMap); + + } catch (Exception ex) { + context.getLogger().log("ApiGateway error: " + ex.getMessage()); + Map responseMap = ResponseUtil.errorResponse( + INTERNAL_SERVER_ERROR, ErrorMessage.INTERNAL_SERVER_ERROR.getMessage()); + return convertToApiGatewayResponse(responseMap); + } + } + + private ApiGatewayRequestDto extractRequest(APIGatewayProxyRequestEvent event) { + Map headers = event.getHeaders(); + + if (headers == null || headers.get("action") == null) { + throw new BusinessException( + ErrorMessage.INVALID_REQUEST, "Headers missing or invalid."); + } + + Map body = parseBody(event); + + try { + String actionStr = headers.get("action"); + String platformStr = headers.get("platform"); + String transactionId = headers.get("transactionId"); + String serviceStr = headers.get("service"); + + Actions action = Actions.fromValue(actionStr); + Platform platform = null; + if (action == Actions.REGISTER || action == Actions.CANCEL) { + if (platformStr == null || platformStr.isBlank()) { + throw new BusinessException(ErrorMessage.INVALID_REQUEST, "Platform is required for REGISTER and CANCEL actions"); + } + platform = Platform.fromValue(platformStr); + } else if (platformStr != null && !platformStr.isBlank()) { + platform = Platform.fromValue(platformStr); + } + + RegisterHeaderDto header = + new RegisterHeaderDto( + transactionId, + Services.fromValue(serviceStr), + platform, + action); + + return new ApiGatewayRequestDto(header, body); + } catch (IllegalArgumentException e) { + throw new BusinessException(ErrorMessage.INVALID_REQUEST, e.getMessage()); + } + } + + private void handleRegister(ApiGatewayRequestDto request) { + RegisterUserDto body = + mapper.convertValue(request.body(), RegisterUserDto.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); + + validate(finalDto); + + try { + deviceTokenService.registerToken(deviceToken, platform, userId); + createRegisterLog( + transactionId, + userIds, + deviceToken, + platform, + service, + NotificationStatus.SUCCESS); + } catch (Exception e) { + throw new DeviceTokenException(ErrorMessage.REGISTER_USER_ERROR, e.getMessage(), e); + } + } + + private void handleCancel(ApiGatewayRequestDto request) { + DeleteTokenDto body = + mapper.convertValue(request.body(), DeleteTokenDto.class); + String transactionId = request.header().transactionId(); + Services service = request.header().service(); + Platform platform = request.header().platform(); + String deviceToken = body.deviceToken(); + Set userIds = body.userIds(); + Set logUserIds = Set.of("NULL"); + + RequestDeleteTokenDto finalDto = + new RequestDeleteTokenDto( + transactionId, + service, + platform, + deviceToken, + userIds); + + validate(finalDto); + + try { + String userId = (userIds != null && !userIds.isEmpty()) ? userIds.iterator().next() : null; + if (userId == null) { + throw new DeviceTokenException(ErrorMessage.USER_ID_REQUIRED); + } + + DeviceTokenEntity tokenEntity = deviceTokenService.findTokenByDeviceTokenAndUserId(deviceToken, userId); + if (tokenEntity == null) { + throw new DeviceTokenException(ErrorMessage.TOKEN_NOT_FOUND); + } + + String endpointArn = tokenEntity.getEndpointArn(); + String subscriptionArn = tokenEntity.getSubscriptionArn(); + + if (endpointArn == null || subscriptionArn == null) { + throw new DeviceTokenException(ErrorMessage.ARN_UNDEFINED); + } + + String actualUserId = extractUserIdFromSk(tokenEntity.getSk()); + String actualDeviceToken = extractDeviceTokenFromPk(tokenEntity.getPk()); + Platform tokenPlatform = Platform.fromValue(tokenEntity.getPlatform()); + + UserTokenInfoDto userTokenInfo = new UserTokenInfoDto( + actualUserId, + actualDeviceToken, + endpointArn, + tokenPlatform, + subscriptionArn); + + invalidEndpointCleaner.clean(userTokenInfo); + createCancelLog( + transactionId, + logUserIds, + deviceToken, + platform, + service, + NotificationStatus.SUCCESS); + } catch (Exception e) { + throw new DeviceTokenException(ErrorMessage.DELETE_TOKEN_ERROR, e.getMessage(), e); + } + } + + private void handleSend(ApiGatewayRequestDto request) { + SendPushDto body = + mapper.convertValue(request.body(), SendPushDto.class); + + RequestSendPushMessageDto finalDto = + new RequestSendPushMessageDto( + request.header().transactionId(), + request.header().service(), + body.userIds(), + body.title(), + body.content(), + body.category(), + body.deepLink(), + body.webLink()); + + validate(finalDto); + sendPushFacade.sendPush(finalDto); + } + + private void handleSendAll(ApiGatewayRequestDto request) { + SendAllPushDto body = + mapper.convertValue(request.body(), SendAllPushDto.class); + + RequestSendAllPushMessageDto finalDto = + new RequestSendAllPushMessageDto( + request.header().transactionId(), + request.header().service(), + body.title(), + body.content(), + body.category(), + body.deepLink(), + body.webLink()); + + validate(finalDto); + sendPushFacade.sendPushAll(finalDto); + } + + private Map parseBody(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 APIGatewayProxyResponseEvent convertToApiGatewayResponse(Map responseMap) { + APIGatewayProxyResponseEvent response = new APIGatewayProxyResponseEvent(); + response.setStatusCode((Integer) responseMap.get("statusCode")); + response.setBody((String) responseMap.get("body")); + return response; + } + + private String extractUserIdFromSk(String sk) { + if (sk.startsWith(USER_PREFIX)) { + return sk.substring(USER_PREFIX.length()); + } + return sk; + } + + private String extractDeviceTokenFromPk(String pk) { + if (pk.startsWith(TOKEN_PREFIX)) { + return pk.substring(TOKEN_PREFIX.length()); + } + return pk; + } + + private void createRegisterLog( + String transactionId, + Set userIds, + String deviceToken, + Platform platform, + Services service, + NotificationStatus status) { + CreateHistoryDto createHistoryDto = + new CreateHistoryDto( + transactionId, + null, + null, + null, + null, + NotificationType.PUSH.getValue(), + service.getValue(), + status.getValue(), + Actions.REGISTER.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); + } + + private void createCancelLog( + String transactionId, + Set userIds, + String deviceToken, + Platform platform, + Services service, + NotificationStatus status) { + CreateHistoryDto createHistoryDto = + new CreateHistoryDto( + transactionId, + null, + null, + null, + null, + NotificationType.PUSH.getValue(), + service.getValue(), + status.getValue(), + Actions.CANCEL.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); + } +} From 8a6563ae95e5c74e79b0ffcefce728893f99e6a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4?= Date: Wed, 24 Dec 2025 01:26:11 +0900 Subject: [PATCH 10/34] =?UTF-8?q?[FIX]=20template.yml=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ApiGatewayHandler 클래스명과 함수명 수정 --- template.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/template.yaml b/template.yaml index 58a0a6a..6dac002 100644 --- a/template.yaml +++ b/template.yaml @@ -125,9 +125,9 @@ 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 + Role: . Events: Root: Type: Api @@ -142,7 +142,7 @@ Resources: FunctionName: !Sub "sopt-push-notification-lambda-eventbridge-${Stage}" Handler: com.sopt.push.lambda.EventBridgeHandler::handleRequest CodeUri: build/libs/app.jar - Role: !GetAtt PushLambdaRole.Arn + Role: . Events: PushEvent: Type: EventBridgeRule From 1f629b6dea751586fdcff6f5de9cbb93a21e71b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4?= Date: Wed, 24 Dec 2025 01:53:29 +0900 Subject: [PATCH 11/34] =?UTF-8?q?[MERGE]=20=EC=B6=A9=EB=8F=8C=20=EB=A8=B8?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- LOCAL_TESTING.md | 225 ++++++++++++++++++ SNSHANDLER_LOCAL_TESTING_GUIDE.md | 181 ++++++++++++++ event.json | 22 ++ events/README.md | 146 ++++++++++++ events/sns-event-sample.json | 41 ++++ events/sns-event-single.json | 23 ++ .../sopt/push/common/BusinessException.java | 10 + .../java/com/sopt/push/common/Constants.java | 17 +- .../sopt/push/common/ExternalException.java | 8 + .../java/com/sopt/push/common/StatusCode.java | 1 + .../sopt/push/dto/PushSuccessMessageDto.java | 2 - .../push/dto/ScheduleSuccessWebHookDto.java | 3 + .../sopt/push/enums/NotificationStatus.java | 2 + .../sopt/push/lambda/EventBridgeHandler.java | 9 +- .../java/com/sopt/push/lambda/SnsHandler.java | 195 ++++++++++++++- .../com/sopt/push/service/SendPushFacade.java | 12 +- .../com/sopt/push/service/WebHookService.java | 117 ++++++++- template.yaml | 18 ++ test-sns-handler.sh | 49 ++++ 19 files changed, 1061 insertions(+), 20 deletions(-) create mode 100644 LOCAL_TESTING.md create mode 100644 SNSHANDLER_LOCAL_TESTING_GUIDE.md create mode 100644 event.json create mode 100644 events/README.md create mode 100644 events/sns-event-sample.json create mode 100644 events/sns-event-single.json create mode 100644 src/main/java/com/sopt/push/common/ExternalException.java create mode 100644 src/main/java/com/sopt/push/dto/ScheduleSuccessWebHookDto.java create mode 100755 test-sns-handler.sh diff --git a/LOCAL_TESTING.md b/LOCAL_TESTING.md new file mode 100644 index 0000000..c8e2eff --- /dev/null +++ b/LOCAL_TESTING.md @@ -0,0 +1,225 @@ +# 로컬 테스트 가이드 + +이 문서는 AWS SAM CLI를 사용하여 Lambda 함수를 로컬에서 테스트하는 방법을 설명합니다. + +## 사전 요구사항 + +1. **AWS SAM CLI 설치** + ```bash + # macOS + brew install aws-sam-cli + + # 또는 공식 문서 참조 + # https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html + ``` + +2. **Docker 설치 및 실행** + - SAM CLI는 로컬 테스트를 위해 Docker를 사용합니다 + - Docker Desktop이 실행 중이어야 합니다 + +3. **AWS 자격 증명 설정** (선택사항) + - 실제 AWS 서비스에 접근할 필요가 없는 경우 생략 가능 + - 필요시 `aws configure` 실행 + +## 빌드 + +먼저 프로젝트를 빌드합니다: + +```bash +./gradlew build +``` + +## 환경 변수 설정 + +로컬 테스트를 위한 환경 변수를 설정합니다. `sam local` 명령어에서 `--env-vars` 옵션을 사용하거나, `env.json` 파일을 생성할 수 있습니다. + +### 방법 1: env.json 파일 생성 (권장) + +프로젝트 루트에 `env.json` 파일을 생성합니다: + +```json +{ + "ApiHandlerFunction": { + "STAGE": "local", + "DYNAMODB_TABLE": "sopt-push-notification-local", + "PLATFORM_APPLICATION_iOS": "arn:aws:sns:ap-northeast-2:123456789012:app/APNS/iOS-App", + "PLATFORM_APPLICATION_ANDROID": "arn:aws:sns:ap-northeast-2:123456789012:app/GCM/Android-App", + "ALL_TOPIC_ARN": "arn:aws:sns:ap-northeast-2:123456789012:sopt-push-all", + "MAKERS_APP_SERVER_URL": "https://api.makers.sopt.org", + "MAKERS_OPERATION_SERVER_URL": "https://operation.makers.sopt.org" + } +} +``` + +### 방법 2: 명령줄에서 직접 전달 + +```bash +sam local invoke ApiHandlerFunction \ + --env-vars env.json \ + --event events/api-gateway-register.json +``` + +## API Gateway 이벤트 테스트 + +### 1. REGISTER 액션 테스트 + +`events/api-gateway-register.json` 파일을 사용: + +```bash +sam local invoke ApiHandlerFunction \ + --env-vars env.json \ + --event events/api-gateway-register.json +``` + +### 2. CANCEL 액션 테스트 + +`events/api-gateway-cancel.json` 파일을 사용: + +```bash +sam local invoke ApiHandlerFunction \ + --env-vars env.json \ + --event events/api-gateway-cancel.json +``` + +### 3. SEND 액션 테스트 + +`events/api-gateway-send.json` 파일을 사용: + +```bash +sam local invoke ApiHandlerFunction \ + --env-vars env.json \ + --event events/api-gateway-send.json +``` + +### 4. SEND_ALL 액션 테스트 + +`events/api-gateway-send-all.json` 파일을 사용: + +```bash +sam local invoke ApiHandlerFunction \ + --env-vars env.json \ + --event events/api-gateway-send-all.json +``` + +## 로컬 API 서버 실행 (API Gateway 시뮬레이션) + +로컬에서 API Gateway를 시뮬레이션하여 HTTP 요청을 테스트할 수 있습니다: + +```bash +sam local start-api --env-vars env.json +``` + +이 명령어를 실행하면 기본적으로 `http://localhost:3000`에서 API가 실행됩니다. + +### cURL로 테스트 + +```bash +# REGISTER +curl -X POST http://localhost:3000/ \ + -H "Content-Type: application/json" \ + -H "action: REGISTER" \ + -H "platform: iOS" \ + -H "transactionId: test-123" \ + -H "service: app" \ + -d '{ + "deviceToken": "test-device-token-123", + "userIds": ["user-123"] + }' + +# CANCEL +curl -X POST http://localhost:3000/ \ + -H "Content-Type: application/json" \ + -H "action: CANCEL" \ + -H "platform: iOS" \ + -H "transactionId: test-123" \ + -H "service: app" \ + -d '{ + "deviceToken": "test-device-token-123", + "userIds": ["user-123"] + }' + +# SEND +curl -X POST http://localhost:3000/ \ + -H "Content-Type: application/json" \ + -H "action: SEND" \ + -H "transactionId: test-123" \ + -H "service: app" \ + -d '{ + "userIds": ["user-123"], + "title": "테스트 제목", + "content": "테스트 내용", + "category": "NOTICE", + "deepLink": "", + "webLink": "https://makers.sopt.org" + }' + +# SEND_ALL +curl -X POST http://localhost:3000/ \ + -H "Content-Type: application/json" \ + -H "action: SEND_ALL" \ + -H "transactionId: test-123" \ + -H "service: app" \ + -d '{ + "title": "전체 공지", + "content": "전체 공지 내용", + "category": "NOTICE", + "deepLink": "", + "webLink": "https://makers.sopt.org" + }' +``` + +## EventBridge 이벤트 테스트 + +EventBridge 핸들러를 테스트하려면: + +```bash +sam local invoke EventBridgeHandlerFunction \ + --env-vars env.json \ + --event event.json +``` + +## 디버깅 + +### 로그 확인 + +SAM CLI는 실행 중인 Lambda 함수의 로그를 콘솔에 출력합니다. 더 자세한 로그를 보려면: + +```bash +sam local invoke ApiHandlerFunction \ + --env-vars env.json \ + --event events/api-gateway-register.json \ + --debug +``` + +### 포트 변경 + +로컬 API 서버의 포트를 변경하려면: + +```bash +sam local start-api --env-vars env.json --port 8080 +``` + +## 주의사항 + +1. **DynamoDB**: 로컬 테스트 시 실제 AWS DynamoDB에 접근하려면 AWS 자격 증명이 필요합니다. 또는 DynamoDB Local을 사용할 수 있습니다. + +2. **SNS**: 로컬 테스트 시 실제 AWS SNS에 접근하려면 AWS 자격 증명이 필요합니다. 테스트용 ARN을 사용하거나 모킹을 고려하세요. + +3. **환경 변수**: `env.json`의 ARN 값들은 실제 AWS 리소스의 ARN으로 변경해야 합니다. + +4. **비용**: 실제 AWS 서비스를 사용하는 경우 비용이 발생할 수 있습니다. + +## 문제 해결 + +### Docker 관련 오류 +- Docker Desktop이 실행 중인지 확인 +- `docker ps` 명령어로 Docker가 정상 작동하는지 확인 + +### 빌드 오류 +- `./gradlew clean build` 실행 +- `build/libs/app.jar` 파일이 생성되었는지 확인 + +### 환경 변수 오류 +- `env.json` 파일이 올바른 형식인지 확인 +- 필수 환경 변수가 모두 설정되었는지 확인 + diff --git a/SNSHANDLER_LOCAL_TESTING_GUIDE.md b/SNSHANDLER_LOCAL_TESTING_GUIDE.md new file mode 100644 index 0000000..f4011c3 --- /dev/null +++ b/SNSHANDLER_LOCAL_TESTING_GUIDE.md @@ -0,0 +1,181 @@ +# SnsHandler 로컬 테스트 가이드 + +이 가이드는 SAM CLI를 사용하여 로컬에서 SnsHandler Lambda 함수를 테스트하는 방법을 설명합니다. + +## 사전 요구사항 + +1. **SAM CLI 설치** + ```bash + # macOS + brew install aws-sam-cli + + # 또는 pip 사용 + pip install aws-sam-cli + ``` + +2. **AWS 자격 증명 설정** + ```bash + aws configure + ``` + 또는 환경 변수 설정: + ```bash + export AWS_ACCESS_KEY_ID=your-access-key + export AWS_SECRET_ACCESS_KEY=your-secret-key + export AWS_DEFAULT_REGION=ap-northeast-2 + ``` + +3. **Java 21 설치 확인** + ```bash + java -version + ``` + +## 1. 프로젝트 빌드 + +```bash +cd sopt-push-notification +./gradlew shadowJar +``` + +빌드가 완료되면 `build/libs/app.jar` 파일이 생성됩니다. + +## 2. SAM 빌드 + +```bash +cd sopt-push-notification # 이미 프로젝트 경로라면 생략 가능 +sam build +``` + +## 3. 로컬 테스트 방법 + +### 방법 1: SAM CLI로 직접 invoke (권장) + +```bash +# 단일 레코드 테스트 +sam local invoke SnsHandlerFunction \ + --event events/sns-event-single.json \ + --env-vars params-dev.json + +# 여러 레코드 테스트 +sam local invoke SnsHandlerFunction \ + --event events/sns-event-sample.json \ + --env-vars params-dev.json +``` + +### 방법 2: 환경 변수 파일 확인 + +`params-dev.json` 파일에 `SnsHandlerFunction` 섹션이 있는지 확인합니다. 다른 handler 개발자들과 통일된 형식을 사용합니다. + +기존 `params-dev.json`에 `SnsHandlerFunction` 섹션을 추가하세요: + +```json +{ + "SnsHandlerFunction": { + "DYNAMODB_TABLE": "your-dynamodb-table-name", + "PLATFORM_APPLICATION_iOS": "arn:aws:sns:...", + "PLATFORM_APPLICATION_ANDROID": "arn:aws:sns:...", + "ALL_TOPIC_ARN": "arn:aws:sns:...", + "STAGE": "dev", + "MAKERS_APP_SERVER_URL": "https://...", + "MAKERS_OPERATION_SERVER_URL": "https://..." + } +} +``` + +### 방법 3: DynamoDB Local 사용 (선택사항) + +로컬 DynamoDB를 사용하려면: + +```bash +# DynamoDB Local 실행 +docker run -p 8000:8000 amazon/dynamodb-local + +# SAM local invoke 시 DynamoDB 엔드포인트 지정 +sam local invoke SnsHandlerFunction \ + --event events/sns-event-single.json \ + --env-vars params-dev.json \ + --docker-network host \ + --parameter-overrides DynamoDbEndpoint=http://localhost:8000 +``` + +## 4. 테스트 이벤트 파일 커스터마이징 + +`events/sns-event-single.json` 파일을 수정하여 실제 테스트 시나리오에 맞게 변경할 수 있습니다: + +- `Message` 필드의 `Token` 값을 실제 DynamoDB에 존재하는 디바이스 토큰으로 변경 +- 여러 레코드를 추가하여 배치 처리 테스트 +- 잘못된 형식의 메시지로 에러 처리 테스트 + +## 5. 실제 AWS 환경에서 테스트 + +### SNS Topic에 Lambda 함수 구독 + +1. AWS 콘솔에서 SNS Topic 생성 +2. Lambda 함수를 구독자로 추가 +3. 푸시 알림 실패 시 자동으로 Lambda 함수가 트리거됨 + +### 수동으로 SNS 이벤트 발행 + +```bash +aws sns publish \ + --topic-arn arn:aws:sns:ap-northeast-2:123456789012:push-failures \ + --message '{"Token":"test-device-token-12345","EndpointArn":"arn:aws:sns:ap-northeast-2:123456789012:endpoint/APNS/test-app/test-endpoint-123"}' +``` + +### test-sns-handler.sh 실행파일을 통한 테스트 +```bash +cd sopt-push-notification +./test-sns-handler.sh +``` + +## 6. 디버깅 팁 + +### 로그 확인 + +```bash +# SAM local invoke 실행 시 로그가 콘솔에 출력됩니다 +sam local invoke SnsHandlerFunction \ + --event events/sns-event-single.json \ + --env-vars params-dev.json \ + --debug +``` + +### CloudWatch Logs 확인 (배포 후) + +AWS 콘솔에서 Lambda 함수의 CloudWatch Logs를 확인하여 실행 로그를 볼 수 있습니다. + +## 7. 테스트 시나리오 + +### 시나리오 1: 정상 처리 +- DynamoDB에 존재하는 디바이스 토큰으로 테스트 +- 실패 로그가 생성되고 토큰이 정리되는지 확인 + +### 시나리오 2: 존재하지 않는 토큰 +- DynamoDB에 존재하지 않는 디바이스 토큰으로 테스트 +- 핸들러가 정상적으로 처리하고 에러 없이 종료되는지 확인 + +### 시나리오 3: 잘못된 메시지 형식 +- `Message` 필드에 `Token`이 없는 경우 +- 핸들러가 안전하게 처리하는지 확인 + +### 시나리오 4: 배치 처리 +- 여러 레코드를 포함한 이벤트로 테스트 +- 각 레코드가 독립적으로 처리되는지 확인 + +## 8. 문제 해결 + +### 문제: "Handler not found" +- `Handler` 경로가 정확한지 확인: `com.sopt.push.lambda.SnsHandler::handleRequest` +- JAR 파일이 올바르게 빌드되었는지 확인: `./gradlew shadowJar` + +### 문제: "ClassNotFoundException" +- `shadowJar` 태스크가 모든 의존성을 포함하는지 확인 +- `build/libs/app.jar` 파일 크기가 충분한지 확인 + +### 문제: "DynamoDB 연결 실패" +- AWS 자격 증명이 올바르게 설정되었는지 확인 +- DynamoDB 테이블이 존재하고 접근 권한이 있는지 확인 + +### 문제: "SNS 권한 오류" +- Lambda 실행 역할에 필요한 SNS 권한이 있는지 확인 +- `template.yaml`의 IAM 역할 설정 확인 + diff --git a/event.json b/event.json new file mode 100644 index 0000000..bf6779a --- /dev/null +++ b/event.json @@ -0,0 +1,22 @@ +{ + "detail-type": "PushRequest", + "source": "sopt.makers", + "detail": { + "header": { + "action": "send", + "transactionId": "test-txn-local-123", + "service": "app", + "alarmId": "test-alarm-local-456" + }, + "body": { + "userIds": [ + "240" + ], + "title": "로컬 CURL 테스트", + "content": "sam에서 실행하는 테스트 메시지입니다.", + "category": "NOTICE", + "deepLink": "", + "webLink": "https://makers.sopt.org" + } + } +} diff --git a/events/README.md b/events/README.md new file mode 100644 index 0000000..af23df3 --- /dev/null +++ b/events/README.md @@ -0,0 +1,146 @@ +# SNS 이벤트 파일 수정 가이드 + +## 필드별 수정 가이드 + +### 🔴 필수 수정 항목 (실제 테스트를 위해) + +#### 1. `Message` 필드 내부의 `Token` (가장 중요!) +```json +"Message": "{\"Token\":\"실제-디바이스-토큰-값\",...}" +``` + +**수정 방법:** +- DynamoDB의 `DeviceTokenEntity` 테이블에서 실제 디바이스 토큰 조회 +- `pk`가 `d#`로 시작하는 레코드에서 `d#`를 제거한 값이 디바이스 토큰 +- 예: `pk = "d#abc123def456"` → `Token = "abc123def456"` + +**확인 방법:** +```bash +# AWS CLI로 확인 +aws dynamodb query \ + --table-name notification-dev \ + --key-condition-expression "pk = :pk" \ + --expression-attribute-values '{":pk":{"S":"d#your-device-token"}}' +``` + +#### 2. `Message` 필드 내부의 `EndpointArn` (선택사항) +```json +"Message": "{...,\"EndpointArn\":\"실제-엔드포인트-ARN\",...}" +``` +- DynamoDB의 `DeviceTokenEntity`에서 `endpointArn` 필드 값 사용 +- 또는 `UserEntity`에서 `endpointArn` 필드 값 사용 + +**예시:** +```json +"EndpointArn": "arn:aws:sns:ap-northeast-2:123456789012:endpoint/APNS/Makers-test-iOS/12345678-1234-1234-1234-123456789012" +``` + +### 🟡 선택적 수정 항목 (로컬 테스트에서는 예시 값으로도 가능) + +#### 3. `MessageId` +- UUID 형식의 메시지 ID +- 로컬 테스트에서는 예시 값으로도 가능 +- 실제 값으로 변경하려면: `uuidgen` 명령어 사용 + +#### 4. `TopicArn` +- SNS Topic ARN (푸시 실패 알림을 받는 Topic) +- 로컬 테스트에서는 예시 값으로도 가능 +- 실제 값: AWS 콘솔에서 SNS Topic ARN 확인 + +#### 5. `EventSubscriptionArn` +- Lambda가 SNS Topic을 구독할 때 생성되는 구독 ARN +- 로컬 테스트에서는 예시 값으로도 가능 +- 형식: `arn:aws:sns:{region}:{account-id}:{topic-name}:{subscription-id}` + +#### 6. `Timestamp` +- 이벤트 발생 시간 (ISO 8601 형식) +- 로컬 테스트에서는 예시 값으로도 가능 +- 현재 시간으로 변경: `date -u +"%Y-%m-%dT%H:%M:%S.000Z"` + +### 🟢 수정 불필요 항목 + +- `EventSource`: 항상 `"aws:sns"` +- `EventVersion`: 항상 `"1.0"` +- `Type`: 항상 `"Notification"` +- `Subject`: 항상 `"Amazon SNS Notification"` +- `SignatureVersion`, `Signature`, `SigningCertUrl`, `UnsubscribeUrl`: 로컬 테스트에서는 `"EXAMPLE"`로 유지 가능 +- `MessageAttributes`: 빈 객체 `{}`로 유지 + +## 실제 수정 예시 + +### 시나리오 1: 최소 수정 (핵심 테스트) +```json +{ + "Records": [ + { + "EventSource": "aws:sns", + "EventVersion": "1.0", + "EventSubscriptionArn": "arn:aws:sns:ap-northeast-2:123456789012:push-failures:12345678-1234-1234-1234-123456789012", + "Sns": { + "Type": "Notification", + "MessageId": "95df01b4-ee98-5cb9-9903-4c221d41eb5e", + "TopicArn": "arn:aws:sns:ap-northeast-2:123456789012:push-failures", + "Subject": "Amazon SNS Notification", + "Message": "{\"Token\":\"실제-DynamoDB에-존재하는-디바이스-토큰\",\"EndpointArn\":\"arn:aws:sns:ap-northeast-2:379013966998:endpoint/APNS/Makers-test-iOS/실제-엔드포인트-ID\",\"MessageId\":\"95df01b4-ee98-5cb9-9903-4c221d41eb5e\"}", + "Timestamp": "2024-01-15T12:00:00.000Z", + "SignatureVersion": "1", + "Signature": "EXAMPLE", + "SigningCertUrl": "EXAMPLE", + "UnsubscribeUrl": "EXAMPLE", + "MessageAttributes": {} + } + } + ] +} +``` + +### 시나리오 2: 완전한 실제 값 사용 +```json +{ + "Records": [ + { + "EventSource": "aws:sns", + "EventVersion": "1.0", + "EventSubscriptionArn": "arn:aws:sns:ap-northeast-2:12345678912:SOPT-PUSH-FAILURES-DEV:실제-구독-ID", + "Sns": { + "Type": "Notification", + "MessageId": "실제-UUID-생성", + "TopicArn": "arn:aws:sns:ap-northeast-2:12345678912:SOPT-PUSH-FAILURES-DEV", + "Subject": "Amazon SNS Notification", + "Message": "{\"Token\":\"실제-디바이스-토큰\",\"EndpointArn\":\"실제-엔드포인트-ARN\",\"MessageId\":\"실제-UUID\"}", + "Timestamp": "2024-12-19T10:30:00.000Z", + "SignatureVersion": "1", + "Signature": "EXAMPLE", + "SigningCertUrl": "EXAMPLE", + "UnsubscribeUrl": "EXAMPLE", + "MessageAttributes": {} + } + } + ] +} +``` + +## 테스트 시나리오별 권장 값 + +### 1. 정상 처리 테스트 +- `Token`: DynamoDB에 존재하는 실제 디바이스 토큰 +- `EndpointArn`: 해당 토큰과 연결된 실제 엔드포인트 ARN + +### 2. 존재하지 않는 토큰 테스트 +- `Token`: DynamoDB에 존재하지 않는 임의의 값 +- 핸들러가 안전하게 처리하는지 확인 + +### 3. 잘못된 메시지 형식 테스트 +- `Message`에서 `Token` 필드 제거 +- 핸들러가 null을 안전하게 처리하는지 확인 + +## 주의사항 + +⚠️ **로컬 테스트 시:** +- `Token`만 실제 값으로 변경해도 핵심 로직 테스트 가능 +- 나머지 필드는 예시 값으로도 동작 (로컬에서는 실제 SNS와 연결되지 않음) + +⚠️ **실제 AWS 환경 테스트 시:** +- 모든 ARN 값들을 실제 값으로 변경 필요 +- SNS Topic에 Lambda 함수가 구독되어 있어야 함 + diff --git a/events/sns-event-sample.json b/events/sns-event-sample.json new file mode 100644 index 0000000..9b011f7 --- /dev/null +++ b/events/sns-event-sample.json @@ -0,0 +1,41 @@ +{ + "Records": [ + { + "EventSource": "aws:sns", + "EventVersion": "1.0", + "EventSubscriptionArn": "arn:aws:sns:ap-northeast-2:123456789012:push-failures:12345678-1234-1234-1234-123456789012", + "Sns": { + "Type": "Notification", + "MessageId": "95df01b4-ee98-5cb9-9903-4c221d41eb5e", + "TopicArn": "arn:aws:sns:ap-northeast-2:123456789012:push-failures", + "Subject": "Amazon SNS Notification", + "Message": "{\"Token\":\"test-device-token-12345\",\"MessageId\":\"95df01b4-ee98-5cb9-9903-4c221d41eb5e\"}", + "Timestamp": "2024-01-15T12:00:00.000Z", + "SignatureVersion": "1", + "Signature": "EXAMPLE", + "SigningCertUrl": "EXAMPLE", + "UnsubscribeUrl": "EXAMPLE", + "MessageAttributes": {} + } + }, + { + "EventSource": "aws:sns", + "EventVersion": "1.0", + "EventSubscriptionArn": "arn:aws:sns:ap-northeast-2:123456789012:push-failures:87654321-4321-4321-4321-210987654321", + "Sns": { + "Type": "Notification", + "MessageId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "TopicArn": "arn:aws:sns:ap-northeast-2:123456789012:push-failures", + "Subject": "Amazon SNS Notification", + "Message": "{\"Token\":\"test-device-token-67890\",\"MessageId\":\"a1b2c3d4-e5f6-7890-abcd-ef1234567890\"}", + "Timestamp": "2024-01-15T12:00:01.000Z", + "SignatureVersion": "1", + "Signature": "EXAMPLE", + "SigningCertUrl": "EXAMPLE", + "UnsubscribeUrl": "EXAMPLE", + "MessageAttributes": {} + } + } + ] +} + diff --git a/events/sns-event-single.json b/events/sns-event-single.json new file mode 100644 index 0000000..af5acee --- /dev/null +++ b/events/sns-event-single.json @@ -0,0 +1,23 @@ +{ + "Records": [ + { + "EventSource": "aws:sns", + "EventVersion": "1.0", + "EventSubscriptionArn": "arn:aws:sns:ap-northeast-2:123456789012:push-failures:12345678-1234-1234-1234-123456789012", + "Sns": { + "Type": "Notification", + "MessageId": "95df01b4-ee98-5cb9-9903-4c221d41eb5e", + "TopicArn": "arn:aws:sns:ap-northeast-2:123456789012:push-failures", + "Subject": "Amazon SNS Notification", + "Message": "{\"Token\":\"test-device-token-12345\",\"MessageId\":\"95df01b4-ee98-5cb9-9903-4c221d41eb5e\"}", + "Timestamp": "2024-01-15T12:00:00.000Z", + "SignatureVersion": "1", + "Signature": "EXAMPLE", + "SigningCertUrl": "EXAMPLE", + "UnsubscribeUrl": "EXAMPLE", + "MessageAttributes": {} + } + } + ] +} + diff --git a/src/main/java/com/sopt/push/common/BusinessException.java b/src/main/java/com/sopt/push/common/BusinessException.java index 978f9d1..193761c 100644 --- a/src/main/java/com/sopt/push/common/BusinessException.java +++ b/src/main/java/com/sopt/push/common/BusinessException.java @@ -16,4 +16,14 @@ public BusinessException(ErrorMessage errorMessage, String detail) { super(errorMessage.getMessage() + ": " + detail); this.errorMessage = errorMessage; } + + public BusinessException(ErrorMessage errorMessage, Throwable cause) { + super(errorMessage.getMessage(), cause); + this.errorMessage = errorMessage; + } + + public BusinessException(ErrorMessage errorMessage, String detail, Throwable cause) { + super(errorMessage.getMessage() + ": " + detail, cause); + this.errorMessage = errorMessage; + } } diff --git a/src/main/java/com/sopt/push/common/Constants.java b/src/main/java/com/sopt/push/common/Constants.java index d7b8fe5..1b363cc 100644 --- a/src/main/java/com/sopt/push/common/Constants.java +++ b/src/main/java/com/sopt/push/common/Constants.java @@ -1,5 +1,6 @@ package com.sopt.push.common; +import java.time.format.DateTimeFormatter; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -36,6 +37,20 @@ public class Constants { public static final String WEB_LINK = "webLink"; public static final String DEEP_LINK = "deepLink"; public static final String DATA = "data"; + public static final String TOKEN = "Token"; - public static final String DEFAULT_MESSAGE = ""; + public static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + public static final String HEADER_CONTENT_TYPE = "Content-Type"; + public static final String MEDIA_TYPE_APPLICATION_JSON = "application/json"; + + 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"; + + public static final String SYSTEM_NAME_APP_SERVER = "APP SERVER"; + public static final String SYSTEM_NAME_OPERATION_SERVER = "OPERATION SERVER"; + public static final String TIME_ZONE_KST = "Asia/Seoul"; + + public static final int HTTP_CLIENT_CONNECT_TIMEOUT_SECONDS = 10; + public static final int HTTP_REQUEST_TIMEOUT_SECONDS = 5; } diff --git a/src/main/java/com/sopt/push/common/ExternalException.java b/src/main/java/com/sopt/push/common/ExternalException.java new file mode 100644 index 0000000..9433c6f --- /dev/null +++ b/src/main/java/com/sopt/push/common/ExternalException.java @@ -0,0 +1,8 @@ +package com.sopt.push.common; + +public class ExternalException extends RuntimeException { + + public ExternalException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/sopt/push/common/StatusCode.java b/src/main/java/com/sopt/push/common/StatusCode.java index af99597..dd1c77e 100644 --- a/src/main/java/com/sopt/push/common/StatusCode.java +++ b/src/main/java/com/sopt/push/common/StatusCode.java @@ -9,6 +9,7 @@ public class StatusCode { public static final int OK = 200; public static final int CREATED = 201; public static final int NO_CONTENT = 204; + public static final int MULTIPLE_CHOICES = 300; public static final int BAD_REQUEST = 400; public static final int UNAUTHORIZED = 401; public static final int FORBIDDEN = 403; diff --git a/src/main/java/com/sopt/push/dto/PushSuccessMessageDto.java b/src/main/java/com/sopt/push/dto/PushSuccessMessageDto.java index 66125c9..e61c72c 100644 --- a/src/main/java/com/sopt/push/dto/PushSuccessMessageDto.java +++ b/src/main/java/com/sopt/push/dto/PushSuccessMessageDto.java @@ -1,7 +1,6 @@ package com.sopt.push.dto; import com.sopt.push.enums.Category; -import com.sopt.push.enums.Services; import com.sopt.push.enums.WebHookType; import java.util.Set; @@ -10,7 +9,6 @@ public record PushSuccessMessageDto( String title, String content, Category category, - Services service, WebHookType type, String deepLink, String webLink, diff --git a/src/main/java/com/sopt/push/dto/ScheduleSuccessWebHookDto.java b/src/main/java/com/sopt/push/dto/ScheduleSuccessWebHookDto.java new file mode 100644 index 0000000..5a110be --- /dev/null +++ b/src/main/java/com/sopt/push/dto/ScheduleSuccessWebHookDto.java @@ -0,0 +1,3 @@ +package com.sopt.push.dto; + +public record ScheduleSuccessWebHookDto(String sendAt) {} diff --git a/src/main/java/com/sopt/push/enums/NotificationStatus.java b/src/main/java/com/sopt/push/enums/NotificationStatus.java index d68e33e..c9721ba 100644 --- a/src/main/java/com/sopt/push/enums/NotificationStatus.java +++ b/src/main/java/com/sopt/push/enums/NotificationStatus.java @@ -6,6 +6,7 @@ import com.sopt.push.common.ErrorMessage; public enum NotificationStatus { + START("start"), FAIL("fail"), SUCCESS("success"), PARTIAL_SUCCESS("partial_success"); @@ -29,6 +30,7 @@ public static NotificationStatus fromValue(String value) { } return switch (value.trim()) { + case "start" -> START; case "fail" -> FAIL; case "success" -> SUCCESS; case "partial_success" -> PARTIAL_SUCCESS; diff --git a/src/main/java/com/sopt/push/lambda/EventBridgeHandler.java b/src/main/java/com/sopt/push/lambda/EventBridgeHandler.java index e1ac172..3f71ea5 100644 --- a/src/main/java/com/sopt/push/lambda/EventBridgeHandler.java +++ b/src/main/java/com/sopt/push/lambda/EventBridgeHandler.java @@ -17,7 +17,9 @@ import com.sopt.push.service.SendPushFacade; import com.sopt.push.service.WebHookService; import java.util.Map; +import lombok.extern.slf4j.Slf4j; +@Slf4j public class EventBridgeHandler implements RequestHandler, String> { private final SendPushFacade sendPushFacade; @@ -25,11 +27,12 @@ public class EventBridgeHandler implements RequestHandler, S private final ObjectMapper mapper; public EventBridgeHandler() { - AppFactory factory = AppFactory.getInstance(); + this(AppFactory.getInstance()); + } + EventBridgeHandler(AppFactory factory) { this.sendPushFacade = factory.sendPushFacade(); this.webHookService = factory.webHookService(); - this.mapper = ObjectMapperConfig.getObjectMapper(); } @@ -48,7 +51,7 @@ public String handleRequest(Map event, Context context) { return "EventBridge processed"; } catch (Exception ex) { - context.getLogger().log("EventBridge error: " + ex.getMessage()); + log.error("EventBridge error: {}", ex.getMessage()); return "EventBridge failed"; } } diff --git a/src/main/java/com/sopt/push/lambda/SnsHandler.java b/src/main/java/com/sopt/push/lambda/SnsHandler.java index 054b14e..51c6d4e 100644 --- a/src/main/java/com/sopt/push/lambda/SnsHandler.java +++ b/src/main/java/com/sopt/push/lambda/SnsHandler.java @@ -1,5 +1,196 @@ package com.sopt.push.lambda; -public class SnsHandler { - // TODO: Implement SNS handler +import static com.sopt.push.common.Constants.TOKEN; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.SNSEvent; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sopt.push.config.AppFactory; +import com.sopt.push.config.ObjectMapperConfig; +import com.sopt.push.domain.DeviceTokenEntity; +import com.sopt.push.dto.CreateHistoryDto; +import com.sopt.push.dto.UserTokenInfoDto; +import com.sopt.push.enums.NotificationStatus; +import com.sopt.push.enums.NotificationType; +import com.sopt.push.service.DeviceTokenService; +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; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class SnsHandler implements RequestHandler { + + private final UserService userService; + private final DeviceTokenService deviceTokenService; + private final HistoryService historyService; + private final InvalidEndpointCleaner invalidEndpointCleaner; + private final ObjectMapper objectMapper; + + public SnsHandler() { + AppFactory factory = AppFactory.getInstance(); + this.userService = factory.userService(); + this.deviceTokenService = factory.deviceTokenService(); + this.historyService = factory.historyService(); + this.invalidEndpointCleaner = factory.invalidEndpointCleaner(); + this.objectMapper = ObjectMapperConfig.getObjectMapper(); + } + + @Override + public String handleRequest(SNSEvent event, Context context) { + boolean invalidEvent = + event == null || event.getRecords() == null || event.getRecords().isEmpty(); + if (invalidEvent) { + log.warn("SNS event is null or has no records"); + return "No records found"; + } + + log.info("Received SNS records count={}", event.getRecords().size()); + + try { + Map recordTokenMap = extractRecordTokens(event.getRecords()); + List deviceTokens = + recordTokenMap.values().stream() + .filter(token -> token != null && !token.isBlank()) + .toList(); + + List deviceTokenEntities = + deviceTokenService.findUserByTokenIds(deviceTokens); + + Map tokenMap = + deviceTokenEntities.stream() + .map(deviceTokenService::mapDeviceTokenEntityToInfoDto) + .collect( + Collectors.toMap( + UserTokenInfoDto::deviceToken, Function.identity(), (a, b) -> a)); + + for (Map.Entry entry : recordTokenMap.entrySet()) { + try { + processRecord(entry.getKey(), entry.getValue(), tokenMap); + } catch (Exception ex) { + log.error("Failed to process SNS record: {}", entry.getKey(), ex); + } + } + + return "SNS handler processed successfully"; + + } catch (RuntimeException ex) { + log.error("SNS handler failed with runtime exception", ex); + return "SNS handler processed with errors (logged)"; + } catch (Exception ex) { + log.error("SNS handler failed with unexpected exception", ex); + return "SNS handler processed with errors (logged)"; + } + } + + private Map extractRecordTokens(List records) { + Map recordTokenMap = new HashMap<>(); + for (SNSEvent.SNSRecord record : records) { + String token = extractTokenFromRecord(record); + recordTokenMap.put(record, token); + } + return recordTokenMap; + } + + private void processRecord( + SNSEvent.SNSRecord record, String token, Map tokenMap) { + if (token == null || token.isBlank()) { + return; + } + + UserTokenInfoDto userTokenInfoDto = tokenMap.get(token); + if (userTokenInfoDto == null) { + log.info("No UserTokenInfoDto found for token: {}", token); + return; + } + + SNSEvent.SNS sns = record.getSNS(); + String messageId = sns != null ? sns.getMessageId() : null; + + log.info( + "Processing invalid push endpoint for userId={}, messageId={}", + userTokenInfoDto.userId(), + messageId); + handleInvalidPushEndpoint(userTokenInfoDto, messageId); + } + + private void handleInvalidPushEndpoint(UserTokenInfoDto userTokenInfoDto, String messageId) { + createFailLog(userTokenInfoDto.userId(), messageId); + + try { + invalidEndpointCleaner.clean(userTokenInfoDto); + } catch (Exception e) { + log.error("Failed to clean invalid endpoint for userId={}", userTokenInfoDto.userId(), e); + } + } + + private String extractTokenFromRecord(SNSEvent.SNSRecord record) { + try { + SNSEvent.SNS sns = record.getSNS(); + if (sns == null) { + return null; + } + + String message = sns.getMessage(); + boolean invalidMessage = message == null || message.isBlank(); + if (invalidMessage) { + return null; + } + + JsonNode root = objectMapper.readTree(message); + JsonNode tokenNode = root.path(TOKEN); + boolean missingOrBlankToken = tokenNode.isMissingNode() || tokenNode.asText().isBlank(); + if (missingOrBlankToken) { + return null; + } + + return tokenNode.asText(); + + } catch (Exception e) { + log.error("Failed to extract token from SNS record message", e); + return null; + } + } + + private void createFailLog(String userId, String messageId) { + boolean hasValidUserId = userId != null && !userId.isBlank(); + Set userIds = hasValidUserId ? Set.of(userId) : null; + + boolean hasValidMessageId = messageId != null && !messageId.isBlank(); + Set messageIds = hasValidMessageId ? Set.of(messageId) : null; + + CreateHistoryDto createHistoryDto = createFailureHistoryDto(userIds, messageIds); + historyService.createLog(createHistoryDto); + } + + private CreateHistoryDto createFailureHistoryDto(Set userIds, Set messageIds) { + return new CreateHistoryDto( + UUID.randomUUID().toString(), // transactionId + null, // title + null, // content + null, // webLink + null, // applink + NotificationType.PUSH.getValue(), // notificationType + null, // orderServiceName + NotificationStatus.FAIL.getValue(), // status + null, // action + null, // platform + null, // deviceToken + null, // category + userIds, // userIds + null, // id + messageIds, // messageIds + null, // errorCode + null // errorMessage + ); + } } diff --git a/src/main/java/com/sopt/push/service/SendPushFacade.java b/src/main/java/com/sopt/push/service/SendPushFacade.java index b1bf5e5..5f6ff7c 100644 --- a/src/main/java/com/sopt/push/service/SendPushFacade.java +++ b/src/main/java/com/sopt/push/service/SendPushFacade.java @@ -1,7 +1,7 @@ package com.sopt.push.service; +import com.sopt.push.common.ExternalException; import com.sopt.push.common.InvalidEndpointException; -import com.sopt.push.common.PushFailException; import com.sopt.push.dto.CreateHistoryDto; import com.sopt.push.dto.PushContext; import com.sopt.push.dto.PushSuccessMessageDto; @@ -81,7 +81,6 @@ public void sendPush(RequestSendPushMessageDto dto) { pushContext.title(), pushContext.content(), pushContext.category(), - pushContext.service(), WebHookType.SEND, pushContext.deepLink(), pushContext.webLink(), @@ -128,7 +127,7 @@ private String sendToUser( } catch (InvalidEndpointException ex) { cleaner.clean(userTokenInfoDto); - } catch (PushFailException ex) { + } catch (ExternalException ex) { log.error("Push failed for user={} err={}", userTokenInfoDto.userId(), ex.getMessage()); } return null; @@ -150,22 +149,21 @@ public void sendPushAll(RequestSendAllPushMessageDto dto) { } catch (Exception e) { String errorMessage = String.format("Send Push All error: %s", e.getMessage()); log.error(errorMessage, e); - throw new PushFailException(errorMessage, e); + throw new ExternalException(errorMessage, e); } try { - PushSuccessMessageDto webHookDto = + PushSuccessMessageDto pushSuccessMessageDto = new PushSuccessMessageDto( messageId, pushContext.title(), pushContext.content(), pushContext.category(), - pushContext.service(), WebHookType.SEND_ALL, pushContext.deepLink(), pushContext.webLink(), Set.of(User.ALL.getValue())); - webHookService.pushSuccessWebHook(webHookDto); + webHookService.pushSuccessWebHook(pushSuccessMessageDto); } catch (Exception e) { log.warn("Failed to send webhook for successful push. messageId: {}", messageId, e); } diff --git a/src/main/java/com/sopt/push/service/WebHookService.java b/src/main/java/com/sopt/push/service/WebHookService.java index aa32d90..e3a0104 100644 --- a/src/main/java/com/sopt/push/service/WebHookService.java +++ b/src/main/java/com/sopt/push/service/WebHookService.java @@ -1,13 +1,120 @@ package com.sopt.push.service; +import static com.sopt.push.common.Constants.FORMATTER; +import static com.sopt.push.common.Constants.HEADER_CONTENT_TYPE; +import static com.sopt.push.common.Constants.HTTP_METHOD_PATCH; +import static com.sopt.push.common.Constants.HTTP_METHOD_POST; +import static com.sopt.push.common.Constants.HTTP_REQUEST_TIMEOUT_SECONDS; +import static com.sopt.push.common.Constants.MEDIA_TYPE_APPLICATION_JSON; +import static com.sopt.push.common.Constants.SYSTEM_NAME_APP_SERVER; +import static com.sopt.push.common.Constants.SYSTEM_NAME_OPERATION_SERVER; +import static com.sopt.push.common.Constants.TIME_ZONE_KST; +import static com.sopt.push.common.Constants.URL_PATH_FORMAT_ID; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sopt.push.common.ExternalException; +import com.sopt.push.common.StatusCode; +import com.sopt.push.config.EnvConfig; +import com.sopt.push.config.ObjectMapperConfig; +import com.sopt.push.dto.PushSuccessMessageDto; +import com.sopt.push.dto.ScheduleSuccessWebHookDto; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import lombok.extern.slf4j.Slf4j; + +@Slf4j public class WebHookService { + + private final HttpClient httpClient; + private final ObjectMapper objectMapper; + private final EnvConfig envConfig; + + public WebHookService(HttpClient httpClient, EnvConfig envConfig) { + this.httpClient = httpClient; + this.objectMapper = ObjectMapperConfig.getObjectMapper(); + this.envConfig = envConfig; + } + + public void pushSuccessWebHook(PushSuccessMessageDto dto) { + String url = envConfig.getMakersAppServerUrl(); + String body = toJson(dto); + sendWebhook(url, HTTP_METHOD_POST, body, SYSTEM_NAME_APP_SERVER); + } + public void scheduleSuccessWebHook(String alarmId) { - // TODO: Implement schedule success webhook logic - System.out.println("Processing schedule success webhook for alarmId: " + alarmId); + validateAlarmId(alarmId); + + String url = + String.format(URL_PATH_FORMAT_ID, envConfig.getMakersOperationServerUrl(), alarmId); + + ScheduleSuccessWebHookDto dto = + new ScheduleSuccessWebHookDto( + ZonedDateTime.now(ZoneId.of(TIME_ZONE_KST)).format(FORMATTER)); + + String body = toJson(dto); + sendWebhook(url, HTTP_METHOD_PATCH, body, SYSTEM_NAME_OPERATION_SERVER); + } + + private void sendWebhook(String url, String method, String body, String systemName) { + try { + HttpRequest request = createRequest(url, method, body); + + HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + checkResponse(response, systemName); + + } catch (IOException | InterruptedException e) { + log.error("{} webhook failed", systemName, e); + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + throw new ExternalException(systemName + " webhook failed", e); + } + } + + private String toJson(Object dto) { + try { + return objectMapper.writeValueAsString(dto); + } catch (Exception e) { + throw new ExternalException("Failed to serialize webhook body", e); + } + } + + private void checkResponse(HttpResponse response, String systemName) { + int status = response.statusCode(); + + if (!isSuccess(status)) { + String error = + String.format( + "%s webhook failed with status %d and body: %s", systemName, status, response.body()); + log.error(error); + throw new ExternalException(error, null); + } + } + + private HttpRequest createRequest(String url, String method, String body) { + return HttpRequest.newBuilder() + .uri(URI.create(url)) + .header(HEADER_CONTENT_TYPE, MEDIA_TYPE_APPLICATION_JSON) + .method(method, HttpRequest.BodyPublishers.ofString(body)) + .timeout(Duration.ofSeconds(HTTP_REQUEST_TIMEOUT_SECONDS)) + .build(); + } + + private boolean isSuccess(int status) { + return status >= StatusCode.OK && status < StatusCode.MULTIPLE_CHOICES; } - public void pushSuccessWebHook(Object dto) { - // TODO: Implement push success webhook logic - System.out.println("Processing push success webhook"); + private void validateAlarmId(String alarmId) { + if (alarmId == null || alarmId.isBlank()) { + throw new IllegalArgumentException("schedule alarm id not defined"); + } } } diff --git a/template.yaml b/template.yaml index 6dac002..086db51 100644 --- a/template.yaml +++ b/template.yaml @@ -20,6 +20,10 @@ Parameters: AllTopicArn: Type: String + PushFailuresTopicArn: + Type: String + Description: SNS Topic ARN for push notification failures + MakersAppServerUrl: Type: String @@ -105,6 +109,7 @@ Resources: - !Ref PlatformAppIOS - !Ref PlatformAppAndroid - !Ref AllTopicArn + - !Ref PushFailuresTopicArn - PolicyName: LambdaBasicLogging PolicyDocument: @@ -154,6 +159,19 @@ Resources: - SEND - SEND_ALL + SnsHandlerFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "sopt-push-notification-lambda-sns-${Stage}" + Handler: com.sopt.push.lambda.SnsHandler::handleRequest + CodeUri: build/libs/app.jar + Role: !GetAtt PushLambdaRole.Arn + Events: + PushFailureEvent: + Type: SNS + Properties: + Topic: !Ref PushFailuresTopicArn + Outputs: ApiUrl: Description: API Gateway Root URL for this Stage diff --git a/test-sns-handler.sh b/test-sns-handler.sh new file mode 100755 index 0000000..7eca0f7 --- /dev/null +++ b/test-sns-handler.sh @@ -0,0 +1,49 @@ +#!/bin/bash + +# SnsHandler 로컬 테스트 스크립트 + +set -e + +echo "🚀 SnsHandler 로컬 테스트 시작" + +# 1. 빌드 확인 +if [ ! -f "build/libs/app.jar" ]; then + echo "📦 JAR 파일이 없습니다. 빌드를 시작합니다..." + ./gradlew shadowJar +else + echo "✅ JAR 파일이 존재합니다." +fi + +# 2. params-dev.json 확인 +if [ ! -f "params-dev.json" ]; then + echo "⚠️ params-dev.json 파일이 없습니다." + echo "params-dev.json 파일을 생성하고 실제 값으로 채워넣으세요." + exit 1 +fi + +# 3. 이벤트 파일 확인 +EVENT_FILE=${1:-"events/sns-event-single.json"} +if [ ! -f "$EVENT_FILE" ]; then + echo "❌ 이벤트 파일을 찾을 수 없습니다: $EVENT_FILE" + exit 1 +fi + +echo "📄 이벤트 파일: $EVENT_FILE" + +# 4. SAM CLI 확인 +if ! command -v sam &> /dev/null; then + echo "❌ SAM CLI가 설치되어 있지 않습니다." + echo "설치 방법: brew install aws-sam-cli 또는 pip install aws-sam-cli" + exit 1 +fi + +# 5. 테스트 실행 +echo "🧪 SnsHandler 테스트 실행 중..." +sam local invoke SnsHandlerFunction \ + --event "$EVENT_FILE" \ + --env-vars params-dev.json \ + --profile sopt-platform \ + --debug + +echo "✅ 테스트 완료!" + From baf67c247a5f7997326648b1aaf43f476e48b459 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4?= Date: Wed, 24 Dec 2025 01:55:51 +0900 Subject: [PATCH 12/34] =?UTF-8?q?[REFACTOR]=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=ED=8C=8C=EC=9D=BC=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- LOCAL_TESTING.md | 225 ----------------------------------------------- event.json | 22 ----- 2 files changed, 247 deletions(-) delete mode 100644 LOCAL_TESTING.md delete mode 100644 event.json diff --git a/LOCAL_TESTING.md b/LOCAL_TESTING.md deleted file mode 100644 index c8e2eff..0000000 --- a/LOCAL_TESTING.md +++ /dev/null @@ -1,225 +0,0 @@ -# 로컬 테스트 가이드 - -이 문서는 AWS SAM CLI를 사용하여 Lambda 함수를 로컬에서 테스트하는 방법을 설명합니다. - -## 사전 요구사항 - -1. **AWS SAM CLI 설치** - ```bash - # macOS - brew install aws-sam-cli - - # 또는 공식 문서 참조 - # https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html - ``` - -2. **Docker 설치 및 실행** - - SAM CLI는 로컬 테스트를 위해 Docker를 사용합니다 - - Docker Desktop이 실행 중이어야 합니다 - -3. **AWS 자격 증명 설정** (선택사항) - - 실제 AWS 서비스에 접근할 필요가 없는 경우 생략 가능 - - 필요시 `aws configure` 실행 - -## 빌드 - -먼저 프로젝트를 빌드합니다: - -```bash -./gradlew build -``` - -## 환경 변수 설정 - -로컬 테스트를 위한 환경 변수를 설정합니다. `sam local` 명령어에서 `--env-vars` 옵션을 사용하거나, `env.json` 파일을 생성할 수 있습니다. - -### 방법 1: env.json 파일 생성 (권장) - -프로젝트 루트에 `env.json` 파일을 생성합니다: - -```json -{ - "ApiHandlerFunction": { - "STAGE": "local", - "DYNAMODB_TABLE": "sopt-push-notification-local", - "PLATFORM_APPLICATION_iOS": "arn:aws:sns:ap-northeast-2:123456789012:app/APNS/iOS-App", - "PLATFORM_APPLICATION_ANDROID": "arn:aws:sns:ap-northeast-2:123456789012:app/GCM/Android-App", - "ALL_TOPIC_ARN": "arn:aws:sns:ap-northeast-2:123456789012:sopt-push-all", - "MAKERS_APP_SERVER_URL": "https://api.makers.sopt.org", - "MAKERS_OPERATION_SERVER_URL": "https://operation.makers.sopt.org" - } -} -``` - -### 방법 2: 명령줄에서 직접 전달 - -```bash -sam local invoke ApiHandlerFunction \ - --env-vars env.json \ - --event events/api-gateway-register.json -``` - -## API Gateway 이벤트 테스트 - -### 1. REGISTER 액션 테스트 - -`events/api-gateway-register.json` 파일을 사용: - -```bash -sam local invoke ApiHandlerFunction \ - --env-vars env.json \ - --event events/api-gateway-register.json -``` - -### 2. CANCEL 액션 테스트 - -`events/api-gateway-cancel.json` 파일을 사용: - -```bash -sam local invoke ApiHandlerFunction \ - --env-vars env.json \ - --event events/api-gateway-cancel.json -``` - -### 3. SEND 액션 테스트 - -`events/api-gateway-send.json` 파일을 사용: - -```bash -sam local invoke ApiHandlerFunction \ - --env-vars env.json \ - --event events/api-gateway-send.json -``` - -### 4. SEND_ALL 액션 테스트 - -`events/api-gateway-send-all.json` 파일을 사용: - -```bash -sam local invoke ApiHandlerFunction \ - --env-vars env.json \ - --event events/api-gateway-send-all.json -``` - -## 로컬 API 서버 실행 (API Gateway 시뮬레이션) - -로컬에서 API Gateway를 시뮬레이션하여 HTTP 요청을 테스트할 수 있습니다: - -```bash -sam local start-api --env-vars env.json -``` - -이 명령어를 실행하면 기본적으로 `http://localhost:3000`에서 API가 실행됩니다. - -### cURL로 테스트 - -```bash -# REGISTER -curl -X POST http://localhost:3000/ \ - -H "Content-Type: application/json" \ - -H "action: REGISTER" \ - -H "platform: iOS" \ - -H "transactionId: test-123" \ - -H "service: app" \ - -d '{ - "deviceToken": "test-device-token-123", - "userIds": ["user-123"] - }' - -# CANCEL -curl -X POST http://localhost:3000/ \ - -H "Content-Type: application/json" \ - -H "action: CANCEL" \ - -H "platform: iOS" \ - -H "transactionId: test-123" \ - -H "service: app" \ - -d '{ - "deviceToken": "test-device-token-123", - "userIds": ["user-123"] - }' - -# SEND -curl -X POST http://localhost:3000/ \ - -H "Content-Type: application/json" \ - -H "action: SEND" \ - -H "transactionId: test-123" \ - -H "service: app" \ - -d '{ - "userIds": ["user-123"], - "title": "테스트 제목", - "content": "테스트 내용", - "category": "NOTICE", - "deepLink": "", - "webLink": "https://makers.sopt.org" - }' - -# SEND_ALL -curl -X POST http://localhost:3000/ \ - -H "Content-Type: application/json" \ - -H "action: SEND_ALL" \ - -H "transactionId: test-123" \ - -H "service: app" \ - -d '{ - "title": "전체 공지", - "content": "전체 공지 내용", - "category": "NOTICE", - "deepLink": "", - "webLink": "https://makers.sopt.org" - }' -``` - -## EventBridge 이벤트 테스트 - -EventBridge 핸들러를 테스트하려면: - -```bash -sam local invoke EventBridgeHandlerFunction \ - --env-vars env.json \ - --event event.json -``` - -## 디버깅 - -### 로그 확인 - -SAM CLI는 실행 중인 Lambda 함수의 로그를 콘솔에 출력합니다. 더 자세한 로그를 보려면: - -```bash -sam local invoke ApiHandlerFunction \ - --env-vars env.json \ - --event events/api-gateway-register.json \ - --debug -``` - -### 포트 변경 - -로컬 API 서버의 포트를 변경하려면: - -```bash -sam local start-api --env-vars env.json --port 8080 -``` - -## 주의사항 - -1. **DynamoDB**: 로컬 테스트 시 실제 AWS DynamoDB에 접근하려면 AWS 자격 증명이 필요합니다. 또는 DynamoDB Local을 사용할 수 있습니다. - -2. **SNS**: 로컬 테스트 시 실제 AWS SNS에 접근하려면 AWS 자격 증명이 필요합니다. 테스트용 ARN을 사용하거나 모킹을 고려하세요. - -3. **환경 변수**: `env.json`의 ARN 값들은 실제 AWS 리소스의 ARN으로 변경해야 합니다. - -4. **비용**: 실제 AWS 서비스를 사용하는 경우 비용이 발생할 수 있습니다. - -## 문제 해결 - -### Docker 관련 오류 -- Docker Desktop이 실행 중인지 확인 -- `docker ps` 명령어로 Docker가 정상 작동하는지 확인 - -### 빌드 오류 -- `./gradlew clean build` 실행 -- `build/libs/app.jar` 파일이 생성되었는지 확인 - -### 환경 변수 오류 -- `env.json` 파일이 올바른 형식인지 확인 -- 필수 환경 변수가 모두 설정되었는지 확인 - diff --git a/event.json b/event.json deleted file mode 100644 index bf6779a..0000000 --- a/event.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "detail-type": "PushRequest", - "source": "sopt.makers", - "detail": { - "header": { - "action": "send", - "transactionId": "test-txn-local-123", - "service": "app", - "alarmId": "test-alarm-local-456" - }, - "body": { - "userIds": [ - "240" - ], - "title": "로컬 CURL 테스트", - "content": "sam에서 실행하는 테스트 메시지입니다.", - "category": "NOTICE", - "deepLink": "", - "webLink": "https://makers.sopt.org" - } - } -} From deb22afe2a21885bbaaa7509d5d0497499c5254e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4?= Date: Wed, 24 Dec 2025 11:50:44 +0900 Subject: [PATCH 13/34] =?UTF-8?q?[REFACTOR]=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../push/common/DeviceTokenException.java | 1 - .../push/common/InvalidEndpointException.java | 2 +- .../sopt/push/common/PushFailException.java | 2 +- .../java/com/sopt/push/config/AppFactory.java | 59 +- .../java/com/sopt/push/config/EnvConfig.java | 6 + .../java/com/sopt/push/config/SnsFactory.java | 5 - .../sopt/push/dto/ApiGatewayRequestDto.java | 5 +- .../com/sopt/push/dto/DeleteTokenDto.java | 5 +- .../sopt/push/dto/LogCreateRequestDto.java | 50 +- .../com/sopt/push/dto/RegisterHeaderDto.java | 8 +- .../com/sopt/push/dto/RegisterUserDto.java | 5 +- .../com/sopt/push/dto/SendAllPushDto.java | 7 +- .../java/com/sopt/push/dto/SendPushDto.java | 1 - .../sopt/push/lambda/ApiGatewayHandler.java | 602 +++++++++--------- .../repository/DeviceTokenRepository.java | 2 +- .../sopt/push/service/DeviceTokenService.java | 7 +- 16 files changed, 362 insertions(+), 405 deletions(-) diff --git a/src/main/java/com/sopt/push/common/DeviceTokenException.java b/src/main/java/com/sopt/push/common/DeviceTokenException.java index 070ec8f..feeef4c 100644 --- a/src/main/java/com/sopt/push/common/DeviceTokenException.java +++ b/src/main/java/com/sopt/push/common/DeviceTokenException.java @@ -22,4 +22,3 @@ public DeviceTokenException(ErrorMessage errorMessage, String detail, Throwable this.errorMessage = errorMessage; } } - diff --git a/src/main/java/com/sopt/push/common/InvalidEndpointException.java b/src/main/java/com/sopt/push/common/InvalidEndpointException.java index d04783c..9c2caf1 100644 --- a/src/main/java/com/sopt/push/common/InvalidEndpointException.java +++ b/src/main/java/com/sopt/push/common/InvalidEndpointException.java @@ -8,4 +8,4 @@ public InvalidEndpointException(String endpointArn, Throwable cause) { super("Invalid SNS endpoint: " + endpointArn, cause); this.endpointArn = endpointArn; } -} \ No newline at end of file +} diff --git a/src/main/java/com/sopt/push/common/PushFailException.java b/src/main/java/com/sopt/push/common/PushFailException.java index a719d7e..b7e72ed 100644 --- a/src/main/java/com/sopt/push/common/PushFailException.java +++ b/src/main/java/com/sopt/push/common/PushFailException.java @@ -5,4 +5,4 @@ public class PushFailException extends RuntimeException { public PushFailException(String message, Throwable cause) { super(message, cause); } -} \ No newline at end of file +} diff --git a/src/main/java/com/sopt/push/config/AppFactory.java b/src/main/java/com/sopt/push/config/AppFactory.java index 0956f53..4422bb4 100644 --- a/src/main/java/com/sopt/push/config/AppFactory.java +++ b/src/main/java/com/sopt/push/config/AppFactory.java @@ -21,13 +21,13 @@ public class AppFactory { private static final AppFactory INSTANCE = new AppFactory(); - private final SendPushFacade sendPushFacade; - 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 final SendPushFacade sendPushFacade; + 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() { @@ -49,7 +49,8 @@ private AppFactory() { HistoryService historyService = new HistoryService(historyRepository); NotificationService notificationService = new NotificationService(snsClient, envConfig); SnsFactory snsFactory = new SnsFactory(snsClient, envConfig); - DeviceTokenService deviceTokenService = new DeviceTokenService(tokenRepository, userRepository, snsFactory); + DeviceTokenService deviceTokenService = + new DeviceTokenService(tokenRepository, userRepository, snsFactory); UserService userService = new UserService(userRepository); InvalidEndpointCleaner invalidEndpointCleaner = new InvalidEndpointCleaner(userService, deviceTokenService, notificationService); @@ -76,31 +77,31 @@ public static AppFactory getInstance() { return INSTANCE; } - public SendPushFacade sendPushFacade() { - return sendPushFacade; - } + public SendPushFacade sendPushFacade() { + return sendPushFacade; + } - public WebHookService webHookService() { - return webHookService; - } + public WebHookService webHookService() { + return webHookService; + } - public UserService userService() { - return userService; - } + public UserService userService() { + return userService; + } - public HistoryService historyService() { - return historyService; - } + public HistoryService historyService() { + return historyService; + } - public DeviceTokenService deviceTokenService() { - return deviceTokenService; - } + public DeviceTokenService deviceTokenService() { + return deviceTokenService; + } - public NotificationService notificationService() { - return notificationService; - } + public NotificationService notificationService() { + return notificationService; + } - public InvalidEndpointCleaner invalidEndpointCleaner() { - return invalidEndpointCleaner; - } + public InvalidEndpointCleaner invalidEndpointCleaner() { + return invalidEndpointCleaner; + } } diff --git a/src/main/java/com/sopt/push/config/EnvConfig.java b/src/main/java/com/sopt/push/config/EnvConfig.java index d680b1c..2c5ddd0 100644 --- a/src/main/java/com/sopt/push/config/EnvConfig.java +++ b/src/main/java/com/sopt/push/config/EnvConfig.java @@ -7,13 +7,19 @@ public final class EnvConfig { private static final String DYNAMODB_TABLE_ENV_VAR = "DYNAMODB_TABLE"; 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 final String dynamoDbTableName; private final String allTopicArn; + private final String makersAppServerUrl; + private final String makersOperationServerUrl; 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); } private static String getRequiredEnv(String key) { diff --git a/src/main/java/com/sopt/push/config/SnsFactory.java b/src/main/java/com/sopt/push/config/SnsFactory.java index 5b83289..4532b43 100644 --- a/src/main/java/com/sopt/push/config/SnsFactory.java +++ b/src/main/java/com/sopt/push/config/SnsFactory.java @@ -2,17 +2,13 @@ import com.sopt.push.common.DeviceTokenException; import com.sopt.push.common.ErrorMessage; -import com.sopt.push.config.EnvConfig; import com.sopt.push.enums.Platform; import lombok.extern.slf4j.Slf4j; 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.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; @Slf4j public class SnsFactory { @@ -66,4 +62,3 @@ private String getPlatformApplicationArn(Platform platform) { return value; } } - diff --git a/src/main/java/com/sopt/push/dto/ApiGatewayRequestDto.java b/src/main/java/com/sopt/push/dto/ApiGatewayRequestDto.java index 59324e2..612ff0a 100644 --- a/src/main/java/com/sopt/push/dto/ApiGatewayRequestDto.java +++ b/src/main/java/com/sopt/push/dto/ApiGatewayRequestDto.java @@ -2,7 +2,4 @@ import java.util.Map; -public record ApiGatewayRequestDto( - RegisterHeaderDto header, - Map body) {} - +public record ApiGatewayRequestDto(RegisterHeaderDto header, Map body) {} diff --git a/src/main/java/com/sopt/push/dto/DeleteTokenDto.java b/src/main/java/com/sopt/push/dto/DeleteTokenDto.java index 732298d..3b19763 100644 --- a/src/main/java/com/sopt/push/dto/DeleteTokenDto.java +++ b/src/main/java/com/sopt/push/dto/DeleteTokenDto.java @@ -2,7 +2,4 @@ import java.util.Set; -public record DeleteTokenDto( - String deviceToken, - Set userIds) {} - +public record DeleteTokenDto(String deviceToken, Set userIds) {} diff --git a/src/main/java/com/sopt/push/dto/LogCreateRequestDto.java b/src/main/java/com/sopt/push/dto/LogCreateRequestDto.java index 2bba181..4957c93 100644 --- a/src/main/java/com/sopt/push/dto/LogCreateRequestDto.java +++ b/src/main/java/com/sopt/push/dto/LogCreateRequestDto.java @@ -6,32 +6,34 @@ import com.sopt.push.enums.NotificationType; import com.sopt.push.enums.Platform; import com.sopt.push.enums.Services; - import java.util.Set; public record LogCreateRequestDto( - String transactionId, - String entity, - String title, - String content, - String deviceToken, - String webLink, - String applink, - NotificationType notificationType, - Services orderServiceName, - NotificationStatus status, - Actions action, - Platform platform, - Category category, - String errorCode, - String errorMessage, - Set userIds, - Set messageIds, - String id -) { - private static final String NULL = "NULL"; + String transactionId, + String entity, + String title, + String content, + String deviceToken, + String webLink, + String applink, + NotificationType notificationType, + Services orderServiceName, + NotificationStatus status, + Actions action, + Platform platform, + Category category, + String errorCode, + String errorMessage, + Set userIds, + Set messageIds, + String id) { + private static final String NULL = "NULL"; - private static String nvl(String v) { return (v == null || v.isBlank()) ? NULL : v; } - private static Set normalizeSet(Set s) { return (s == null || s.isEmpty()) ? Set.of(NULL) : s; } -} + private static String nvl(String v) { + return (v == null || v.isBlank()) ? NULL : v; + } + private static Set normalizeSet(Set s) { + return (s == null || s.isEmpty()) ? Set.of(NULL) : s; + } +} diff --git a/src/main/java/com/sopt/push/dto/RegisterHeaderDto.java b/src/main/java/com/sopt/push/dto/RegisterHeaderDto.java index 85c1ca1..93b35d4 100644 --- a/src/main/java/com/sopt/push/dto/RegisterHeaderDto.java +++ b/src/main/java/com/sopt/push/dto/RegisterHeaderDto.java @@ -5,15 +5,11 @@ import com.sopt.push.enums.Services; public record RegisterHeaderDto( - String transactionId, - Services service, - Platform platform, - Actions action) { - + String transactionId, Services service, Platform platform, Actions action) { + public RegisterHeaderDto { if ((action == Actions.REGISTER || action == Actions.CANCEL) && platform == null) { throw new IllegalArgumentException("Platform is required for REGISTER and CANCEL actions"); } } } - diff --git a/src/main/java/com/sopt/push/dto/RegisterUserDto.java b/src/main/java/com/sopt/push/dto/RegisterUserDto.java index 3ee1c74..81f1b7d 100644 --- a/src/main/java/com/sopt/push/dto/RegisterUserDto.java +++ b/src/main/java/com/sopt/push/dto/RegisterUserDto.java @@ -2,7 +2,4 @@ import java.util.Set; -public record RegisterUserDto( - String deviceToken, - Set userIds) {} - +public record RegisterUserDto(String deviceToken, Set userIds) {} diff --git a/src/main/java/com/sopt/push/dto/SendAllPushDto.java b/src/main/java/com/sopt/push/dto/SendAllPushDto.java index 36fcd51..8fad0e3 100644 --- a/src/main/java/com/sopt/push/dto/SendAllPushDto.java +++ b/src/main/java/com/sopt/push/dto/SendAllPushDto.java @@ -3,9 +3,4 @@ import com.sopt.push.enums.Category; public record SendAllPushDto( - String title, - String content, - Category category, - String deepLink, - String webLink) {} - + String title, String content, Category category, String deepLink, String webLink) {} diff --git a/src/main/java/com/sopt/push/dto/SendPushDto.java b/src/main/java/com/sopt/push/dto/SendPushDto.java index 92b3ef5..b29fe12 100644 --- a/src/main/java/com/sopt/push/dto/SendPushDto.java +++ b/src/main/java/com/sopt/push/dto/SendPushDto.java @@ -10,4 +10,3 @@ public record SendPushDto( Category category, String deepLink, String webLink) {} - diff --git a/src/main/java/com/sopt/push/lambda/ApiGatewayHandler.java b/src/main/java/com/sopt/push/lambda/ApiGatewayHandler.java index 9172e62..15a2ffa 100644 --- a/src/main/java/com/sopt/push/lambda/ApiGatewayHandler.java +++ b/src/main/java/com/sopt/push/lambda/ApiGatewayHandler.java @@ -1,5 +1,9 @@ package com.sopt.push.lambda; +import static com.sopt.push.common.Constants.TOKEN_PREFIX; +import static com.sopt.push.common.Constants.USER_PREFIX; +import static com.sopt.push.common.StatusCode.BAD_REQUEST; +import static com.sopt.push.common.StatusCode.INTERNAL_SERVER_ERROR; import static com.sopt.push.util.ValidationUtil.validate; import com.amazonaws.services.lambda.runtime.Context; @@ -7,22 +11,19 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.fasterxml.jackson.databind.ObjectMapper; -import static com.sopt.push.common.StatusCode.BAD_REQUEST; -import static com.sopt.push.common.StatusCode.INTERNAL_SERVER_ERROR; - 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.*; 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.common.DeviceTokenException; -import com.sopt.push.domain.DeviceTokenEntity; import com.sopt.push.service.DeviceTokenService; import com.sopt.push.service.HistoryService; import com.sopt.push.service.InvalidEndpointCleaner; @@ -32,334 +33,303 @@ import java.util.Map; import java.util.Set; import java.util.stream.Collectors; -import static com.sopt.push.common.Constants.USER_PREFIX; -import static com.sopt.push.common.Constants.TOKEN_PREFIX; -public class ApiGatewayHandler implements RequestHandler { - - private final DeviceTokenService deviceTokenService; - private final SendPushFacade sendPushFacade; - private final InvalidEndpointCleaner invalidEndpointCleaner; - private final HistoryService historyService; - private final ObjectMapper mapper; - - public ApiGatewayHandler() { - AppFactory factory = AppFactory.getInstance(); - this.deviceTokenService = factory.deviceTokenService(); - this.sendPushFacade = factory.sendPushFacade(); - this.invalidEndpointCleaner = factory.invalidEndpointCleaner(); - this.historyService = factory.historyService(); - this.mapper = ObjectMapperConfig.getObjectMapper(); +public class ApiGatewayHandler + implements RequestHandler { + + private final DeviceTokenService deviceTokenService; + private final SendPushFacade sendPushFacade; + private final InvalidEndpointCleaner invalidEndpointCleaner; + private final HistoryService historyService; + private final ObjectMapper mapper; + + public ApiGatewayHandler() { + AppFactory factory = AppFactory.getInstance(); + this.deviceTokenService = factory.deviceTokenService(); + this.sendPushFacade = factory.sendPushFacade(); + this.invalidEndpointCleaner = factory.invalidEndpointCleaner(); + 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 convertToApiGatewayResponse(responseMap); + + } catch (BusinessException ex) { + context.getLogger().log("ApiGateway error: " + ex.getMessage()); + Map responseMap = ResponseUtil.errorResponse(BAD_REQUEST, ex.getMessage()); + return convertToApiGatewayResponse(responseMap); + + } catch (Exception ex) { + context.getLogger().log("ApiGateway error: " + ex.getMessage()); + Map responseMap = + ResponseUtil.errorResponse( + INTERNAL_SERVER_ERROR, ErrorMessage.INTERNAL_SERVER_ERROR.getMessage()); + return convertToApiGatewayResponse(responseMap); } + } - @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 convertToApiGatewayResponse(responseMap); - - } catch (BusinessException ex) { - context.getLogger().log("ApiGateway error: " + ex.getMessage()); - Map responseMap = ResponseUtil.errorResponse(BAD_REQUEST, ex.getMessage()); - return convertToApiGatewayResponse(responseMap); - - } catch (Exception ex) { - context.getLogger().log("ApiGateway error: " + ex.getMessage()); - Map responseMap = ResponseUtil.errorResponse( - INTERNAL_SERVER_ERROR, ErrorMessage.INTERNAL_SERVER_ERROR.getMessage()); - return convertToApiGatewayResponse(responseMap); - } - } - - private ApiGatewayRequestDto extractRequest(APIGatewayProxyRequestEvent event) { - Map headers = event.getHeaders(); - - if (headers == null || headers.get("action") == null) { - throw new BusinessException( - ErrorMessage.INVALID_REQUEST, "Headers missing or invalid."); - } + private ApiGatewayRequestDto extractRequest(APIGatewayProxyRequestEvent event) { + Map headers = event.getHeaders(); - Map body = parseBody(event); - - try { - String actionStr = headers.get("action"); - String platformStr = headers.get("platform"); - String transactionId = headers.get("transactionId"); - String serviceStr = headers.get("service"); - - Actions action = Actions.fromValue(actionStr); - Platform platform = null; - if (action == Actions.REGISTER || action == Actions.CANCEL) { - if (platformStr == null || platformStr.isBlank()) { - throw new BusinessException(ErrorMessage.INVALID_REQUEST, "Platform is required for REGISTER and CANCEL actions"); - } - platform = Platform.fromValue(platformStr); - } else if (platformStr != null && !platformStr.isBlank()) { - platform = Platform.fromValue(platformStr); - } - - RegisterHeaderDto header = - new RegisterHeaderDto( - transactionId, - Services.fromValue(serviceStr), - platform, - action); - - return new ApiGatewayRequestDto(header, body); - } catch (IllegalArgumentException e) { - throw new BusinessException(ErrorMessage.INVALID_REQUEST, e.getMessage()); - } + if (headers == null || headers.get("action") == null) { + throw new BusinessException(ErrorMessage.INVALID_REQUEST, "Headers missing or invalid."); } - private void handleRegister(ApiGatewayRequestDto request) { - RegisterUserDto body = - mapper.convertValue(request.body(), RegisterUserDto.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); - - validate(finalDto); - - try { - deviceTokenService.registerToken(deviceToken, platform, userId); - createRegisterLog( - transactionId, - userIds, - deviceToken, - platform, - service, - NotificationStatus.SUCCESS); - } catch (Exception e) { - throw new DeviceTokenException(ErrorMessage.REGISTER_USER_ERROR, e.getMessage(), e); - } - } + Map body = parseBody(event); - private void handleCancel(ApiGatewayRequestDto request) { - DeleteTokenDto body = - mapper.convertValue(request.body(), DeleteTokenDto.class); - String transactionId = request.header().transactionId(); - Services service = request.header().service(); - Platform platform = request.header().platform(); - String deviceToken = body.deviceToken(); - Set userIds = body.userIds(); - Set logUserIds = Set.of("NULL"); - - RequestDeleteTokenDto finalDto = - new RequestDeleteTokenDto( - transactionId, - service, - platform, - deviceToken, - userIds); - - validate(finalDto); - - try { - String userId = (userIds != null && !userIds.isEmpty()) ? userIds.iterator().next() : null; - if (userId == null) { - throw new DeviceTokenException(ErrorMessage.USER_ID_REQUIRED); - } - - DeviceTokenEntity tokenEntity = deviceTokenService.findTokenByDeviceTokenAndUserId(deviceToken, userId); - if (tokenEntity == null) { - throw new DeviceTokenException(ErrorMessage.TOKEN_NOT_FOUND); - } - - String endpointArn = tokenEntity.getEndpointArn(); - String subscriptionArn = tokenEntity.getSubscriptionArn(); - - if (endpointArn == null || subscriptionArn == null) { - throw new DeviceTokenException(ErrorMessage.ARN_UNDEFINED); - } - - String actualUserId = extractUserIdFromSk(tokenEntity.getSk()); - String actualDeviceToken = extractDeviceTokenFromPk(tokenEntity.getPk()); - Platform tokenPlatform = Platform.fromValue(tokenEntity.getPlatform()); - - UserTokenInfoDto userTokenInfo = new UserTokenInfoDto( - actualUserId, - actualDeviceToken, - endpointArn, - tokenPlatform, - subscriptionArn); - - invalidEndpointCleaner.clean(userTokenInfo); - createCancelLog( - transactionId, - logUserIds, - deviceToken, - platform, - service, - NotificationStatus.SUCCESS); - } catch (Exception e) { - throw new DeviceTokenException(ErrorMessage.DELETE_TOKEN_ERROR, e.getMessage(), e); - } - } - - private void handleSend(ApiGatewayRequestDto request) { - SendPushDto body = - mapper.convertValue(request.body(), SendPushDto.class); - - RequestSendPushMessageDto finalDto = - new RequestSendPushMessageDto( - request.header().transactionId(), - request.header().service(), - body.userIds(), - body.title(), - body.content(), - body.category(), - body.deepLink(), - body.webLink()); - - validate(finalDto); - sendPushFacade.sendPush(finalDto); - } - - private void handleSendAll(ApiGatewayRequestDto request) { - SendAllPushDto body = - mapper.convertValue(request.body(), SendAllPushDto.class); - - RequestSendAllPushMessageDto finalDto = - new RequestSendAllPushMessageDto( - request.header().transactionId(), - request.header().service(), - body.title(), - body.content(), - body.category(), - body.deepLink(), - body.webLink()); - - validate(finalDto); - sendPushFacade.sendPushAll(finalDto); - } + try { + String actionStr = headers.get("action"); + String platformStr = headers.get("platform"); + String transactionId = headers.get("transactionId"); + String serviceStr = headers.get("service"); - private Map parseBody(APIGatewayProxyRequestEvent event) { - if (event.getBody() == null) { - throw new BusinessException( - ErrorMessage.INVALID_REQUEST, "Request body is missing."); + Actions action = Actions.fromValue(actionStr); + Platform platform = null; + if (action == Actions.REGISTER || action == Actions.CANCEL) { + if (platformStr == null || platformStr.isBlank()) { + throw new BusinessException( + ErrorMessage.INVALID_REQUEST, "Platform is required for REGISTER and CANCEL actions"); } + platform = Platform.fromValue(platformStr); + } else if (platformStr != null && !platformStr.isBlank()) { + platform = Platform.fromValue(platformStr); + } - try { - return mapper.readValue(event.getBody(), Map.class); - } catch (Exception e) { - throw new BusinessException( - ErrorMessage.INVALID_REQUEST, "Failed to parse request body."); - } - } + RegisterHeaderDto header = + new RegisterHeaderDto(transactionId, Services.fromValue(serviceStr), platform, action); - 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; - }; + return new ApiGatewayRequestDto(header, body); + } catch (IllegalArgumentException e) { + throw new BusinessException(ErrorMessage.INVALID_REQUEST, e.getMessage()); } - - private APIGatewayProxyResponseEvent convertToApiGatewayResponse(Map responseMap) { - APIGatewayProxyResponseEvent response = new APIGatewayProxyResponseEvent(); - response.setStatusCode((Integer) responseMap.get("statusCode")); - response.setBody((String) responseMap.get("body")); - return response; + } + + private void handleRegister(ApiGatewayRequestDto request) { + RegisterUserDto body = mapper.convertValue(request.body(), RegisterUserDto.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); + + validate(finalDto); + + try { + deviceTokenService.registerToken(deviceToken, platform, userId); + createRegisterLog( + transactionId, userIds, deviceToken, platform, service, NotificationStatus.SUCCESS); + } catch (Exception e) { + throw new DeviceTokenException(ErrorMessage.REGISTER_USER_ERROR, e.getMessage(), e); } - - private String extractUserIdFromSk(String sk) { - if (sk.startsWith(USER_PREFIX)) { - return sk.substring(USER_PREFIX.length()); - } - return sk; + } + + private void handleCancel(ApiGatewayRequestDto request) { + DeleteTokenDto body = mapper.convertValue(request.body(), DeleteTokenDto.class); + String transactionId = request.header().transactionId(); + Services service = request.header().service(); + Platform platform = request.header().platform(); + String deviceToken = body.deviceToken(); + Set userIds = body.userIds(); + Set logUserIds = Set.of("NULL"); + + RequestDeleteTokenDto finalDto = + new RequestDeleteTokenDto(transactionId, service, platform, deviceToken, userIds); + + validate(finalDto); + + try { + String userId = (userIds != null && !userIds.isEmpty()) ? userIds.iterator().next() : null; + if (userId == null) { + throw new DeviceTokenException(ErrorMessage.USER_ID_REQUIRED); + } + + DeviceTokenEntity tokenEntity = + deviceTokenService.findTokenByDeviceTokenAndUserId(deviceToken, userId); + if (tokenEntity == null) { + throw new DeviceTokenException(ErrorMessage.TOKEN_NOT_FOUND); + } + + String endpointArn = tokenEntity.getEndpointArn(); + String subscriptionArn = tokenEntity.getSubscriptionArn(); + + if (endpointArn == null || subscriptionArn == null) { + throw new DeviceTokenException(ErrorMessage.ARN_UNDEFINED); + } + + String actualUserId = extractUserIdFromSk(tokenEntity.getSk()); + String actualDeviceToken = extractDeviceTokenFromPk(tokenEntity.getPk()); + Platform tokenPlatform = Platform.fromValue(tokenEntity.getPlatform()); + + UserTokenInfoDto userTokenInfo = + new UserTokenInfoDto( + actualUserId, actualDeviceToken, endpointArn, tokenPlatform, subscriptionArn); + + invalidEndpointCleaner.clean(userTokenInfo); + createCancelLog( + transactionId, logUserIds, deviceToken, platform, service, NotificationStatus.SUCCESS); + } catch (Exception e) { + throw new DeviceTokenException(ErrorMessage.DELETE_TOKEN_ERROR, e.getMessage(), e); } - - private String extractDeviceTokenFromPk(String pk) { - if (pk.startsWith(TOKEN_PREFIX)) { - return pk.substring(TOKEN_PREFIX.length()); - } - return pk; + } + + private void handleSend(ApiGatewayRequestDto request) { + SendPushDto body = mapper.convertValue(request.body(), SendPushDto.class); + + RequestSendPushMessageDto finalDto = + new RequestSendPushMessageDto( + request.header().transactionId(), + request.header().service(), + body.userIds(), + body.title(), + body.content(), + body.category(), + body.deepLink(), + body.webLink()); + + validate(finalDto); + sendPushFacade.sendPush(finalDto); + } + + private void handleSendAll(ApiGatewayRequestDto request) { + SendAllPushDto body = mapper.convertValue(request.body(), SendAllPushDto.class); + + RequestSendAllPushMessageDto finalDto = + new RequestSendAllPushMessageDto( + request.header().transactionId(), + request.header().service(), + body.title(), + body.content(), + body.category(), + body.deepLink(), + body.webLink()); + + validate(finalDto); + sendPushFacade.sendPushAll(finalDto); + } + + private Map parseBody(APIGatewayProxyRequestEvent event) { + if (event.getBody() == null) { + throw new BusinessException(ErrorMessage.INVALID_REQUEST, "Request body is missing."); } - private void createRegisterLog( - String transactionId, - Set userIds, - String deviceToken, - Platform platform, - Services service, - NotificationStatus status) { - CreateHistoryDto createHistoryDto = - new CreateHistoryDto( - transactionId, - null, - null, - null, - null, - NotificationType.PUSH.getValue(), - service.getValue(), - status.getValue(), - Actions.REGISTER.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); + 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 APIGatewayProxyResponseEvent convertToApiGatewayResponse( + Map responseMap) { + APIGatewayProxyResponseEvent response = new APIGatewayProxyResponseEvent(); + response.setStatusCode((Integer) responseMap.get("statusCode")); + response.setBody((String) responseMap.get("body")); + return response; + } + + private String extractUserIdFromSk(String sk) { + if (sk.startsWith(USER_PREFIX)) { + return sk.substring(USER_PREFIX.length()); } + return sk; + } - private void createCancelLog( - String transactionId, - Set userIds, - String deviceToken, - Platform platform, - Services service, - NotificationStatus status) { - CreateHistoryDto createHistoryDto = - new CreateHistoryDto( - transactionId, - null, - null, - null, - null, - NotificationType.PUSH.getValue(), - service.getValue(), - status.getValue(), - Actions.CANCEL.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); + private String extractDeviceTokenFromPk(String pk) { + if (pk.startsWith(TOKEN_PREFIX)) { + return pk.substring(TOKEN_PREFIX.length()); } + return pk; + } + + private void createRegisterLog( + String transactionId, + Set userIds, + String deviceToken, + Platform platform, + Services service, + NotificationStatus status) { + CreateHistoryDto createHistoryDto = + new CreateHistoryDto( + transactionId, + null, + null, + null, + null, + NotificationType.PUSH.getValue(), + service.getValue(), + status.getValue(), + Actions.REGISTER.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); + } + + private void createCancelLog( + String transactionId, + Set userIds, + String deviceToken, + Platform platform, + Services service, + NotificationStatus status) { + CreateHistoryDto createHistoryDto = + new CreateHistoryDto( + transactionId, + null, + null, + null, + null, + NotificationType.PUSH.getValue(), + service.getValue(), + status.getValue(), + Actions.CANCEL.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/repository/DeviceTokenRepository.java b/src/main/java/com/sopt/push/repository/DeviceTokenRepository.java index 9efb0e1..eefa566 100644 --- a/src/main/java/com/sopt/push/repository/DeviceTokenRepository.java +++ b/src/main/java/com/sopt/push/repository/DeviceTokenRepository.java @@ -35,7 +35,7 @@ public Optional findByPkAndSk(String pk, String sk) { public List queryByPk(String pk) { QueryConditional queryConditional = - QueryConditional.keyEqualTo(Key.builder().partitionValue(pk).build()); + QueryConditional.keyEqualTo(Key.builder().partitionValue(pk).build()); return deviceTokenTable.query(queryConditional).items().stream().toList(); } diff --git a/src/main/java/com/sopt/push/service/DeviceTokenService.java b/src/main/java/com/sopt/push/service/DeviceTokenService.java index 07bb888..6375f94 100644 --- a/src/main/java/com/sopt/push/service/DeviceTokenService.java +++ b/src/main/java/com/sopt/push/service/DeviceTokenService.java @@ -79,7 +79,6 @@ public void registerToken(String deviceToken, Platform platform, String userId) if (!changedUserPayload(existingUserId, actualUserId)) { return; } - } var endpoint = snsFactory.registerEndPoint(deviceToken, platform, userId); @@ -101,7 +100,11 @@ public void registerToken(String deviceToken, Platform platform, String userId) } private void saveUserEntity( - String userId, String deviceToken, String platform, String endpointArn, String subscriptionArn) { + String userId, + String deviceToken, + String platform, + String endpointArn, + String subscriptionArn) { String userPk = USER_PREFIX + userId; String tokenSk = TOKEN_PREFIX + deviceToken; From 2504a926a1f309d4f239324b57087c873f19403c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4?= Date: Fri, 26 Dec 2025 20:52:43 +0900 Subject: [PATCH 14/34] =?UTF-8?q?[REFACTOR]=20header=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=20=EC=83=81=EC=88=98=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/sopt/push/common/Constants.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/com/sopt/push/common/Constants.java b/src/main/java/com/sopt/push/common/Constants.java index 1b363cc..9a2fabd 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"; From 1461ae9bdb50a6401a129ae940a88e67c032d4c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4?= Date: Fri, 26 Dec 2025 21:02:20 +0900 Subject: [PATCH 15/34] =?UTF-8?q?[REFACTOR]=20=ED=95=84=EC=9A=94=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=ED=8C=8C=EC=9D=BC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sopt/push/common/PushFailException.java | 8 --- .../java/com/sopt/push/config/SnsFactory.java | 64 ------------------- .../com/sopt/push/dto/DeleteTokenDto.java | 5 -- .../sopt/push/dto/LogCreateRequestDto.java | 39 ----------- .../com/sopt/push/dto/RegisterUserDto.java | 5 -- .../com/sopt/push/dto/SendAllPushDto.java | 6 -- .../java/com/sopt/push/dto/SendPushDto.java | 12 ---- 7 files changed, 139 deletions(-) delete mode 100644 src/main/java/com/sopt/push/common/PushFailException.java delete mode 100644 src/main/java/com/sopt/push/config/SnsFactory.java delete mode 100644 src/main/java/com/sopt/push/dto/DeleteTokenDto.java delete mode 100644 src/main/java/com/sopt/push/dto/LogCreateRequestDto.java delete mode 100644 src/main/java/com/sopt/push/dto/RegisterUserDto.java delete mode 100644 src/main/java/com/sopt/push/dto/SendAllPushDto.java delete mode 100644 src/main/java/com/sopt/push/dto/SendPushDto.java diff --git a/src/main/java/com/sopt/push/common/PushFailException.java b/src/main/java/com/sopt/push/common/PushFailException.java deleted file mode 100644 index b7e72ed..0000000 --- a/src/main/java/com/sopt/push/common/PushFailException.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.sopt.push.common; - -public class PushFailException extends RuntimeException { - - public PushFailException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/src/main/java/com/sopt/push/config/SnsFactory.java b/src/main/java/com/sopt/push/config/SnsFactory.java deleted file mode 100644 index 4532b43..0000000 --- a/src/main/java/com/sopt/push/config/SnsFactory.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.sopt.push.config; - -import com.sopt.push.common.DeviceTokenException; -import com.sopt.push.common.ErrorMessage; -import com.sopt.push.enums.Platform; -import lombok.extern.slf4j.Slf4j; -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.SubscribeRequest; -import software.amazon.awssdk.services.sns.model.SubscribeResponse; - -@Slf4j -public class SnsFactory { - - private final SnsClient snsClient; - private final String allTopicArn; - private static final String PLATFORM_APPLICATION_IOS_ENV = "PLATFORM_APPLICATION_iOS"; - private static final String PLATFORM_APPLICATION_ANDROID_ENV = "PLATFORM_APPLICATION_ANDROID"; - - public SnsFactory(SnsClient snsClient, EnvConfig envConfig) { - this.snsClient = snsClient; - this.allTopicArn = envConfig.getAllTopicArn(); - } - - public SubscribeResponse subscribe(String endpointArn) { - SubscribeRequest request = - SubscribeRequest.builder() - .protocol("application") - .endpoint(endpointArn) - .topicArn(allTopicArn) - .build(); - - return snsClient.subscribe(request); - } - - public CreatePlatformEndpointResponse registerEndPoint( - String deviceToken, Platform platform, String userId) { - String platformApplicationArn = getPlatformApplicationArn(platform); - - CreatePlatformEndpointRequest.Builder requestBuilder = - CreatePlatformEndpointRequest.builder() - .platformApplicationArn(platformApplicationArn) - .token(deviceToken); - - if (userId != null && !userId.isBlank()) { - requestBuilder.customUserData(userId); - } - - CreatePlatformEndpointRequest request = requestBuilder.build(); - return snsClient.createPlatformEndpoint(request); - } - - private String getPlatformApplicationArn(Platform platform) { - String envVar = - platform == Platform.IOS ? PLATFORM_APPLICATION_IOS_ENV : PLATFORM_APPLICATION_ANDROID_ENV; - String value = System.getenv(envVar); - if (value == null || value.isBlank()) { - throw new DeviceTokenException( - ErrorMessage.PLATFORM_APP_ARN_NOT_SET, "Platform application ARN not set: " + envVar); - } - return value; - } -} diff --git a/src/main/java/com/sopt/push/dto/DeleteTokenDto.java b/src/main/java/com/sopt/push/dto/DeleteTokenDto.java deleted file mode 100644 index 3b19763..0000000 --- a/src/main/java/com/sopt/push/dto/DeleteTokenDto.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.sopt.push.dto; - -import java.util.Set; - -public record DeleteTokenDto(String deviceToken, Set userIds) {} diff --git a/src/main/java/com/sopt/push/dto/LogCreateRequestDto.java b/src/main/java/com/sopt/push/dto/LogCreateRequestDto.java deleted file mode 100644 index 4957c93..0000000 --- a/src/main/java/com/sopt/push/dto/LogCreateRequestDto.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.sopt.push.dto; - -import com.sopt.push.enums.Actions; -import com.sopt.push.enums.Category; -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 java.util.Set; - -public record LogCreateRequestDto( - String transactionId, - String entity, - String title, - String content, - String deviceToken, - String webLink, - String applink, - NotificationType notificationType, - Services orderServiceName, - NotificationStatus status, - Actions action, - Platform platform, - Category category, - String errorCode, - String errorMessage, - Set userIds, - Set messageIds, - String id) { - private static final String NULL = "NULL"; - - private static String nvl(String v) { - return (v == null || v.isBlank()) ? NULL : v; - } - - private static Set normalizeSet(Set s) { - return (s == null || s.isEmpty()) ? Set.of(NULL) : s; - } -} diff --git a/src/main/java/com/sopt/push/dto/RegisterUserDto.java b/src/main/java/com/sopt/push/dto/RegisterUserDto.java deleted file mode 100644 index 81f1b7d..0000000 --- a/src/main/java/com/sopt/push/dto/RegisterUserDto.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.sopt.push.dto; - -import java.util.Set; - -public record RegisterUserDto(String deviceToken, Set userIds) {} diff --git a/src/main/java/com/sopt/push/dto/SendAllPushDto.java b/src/main/java/com/sopt/push/dto/SendAllPushDto.java deleted file mode 100644 index 8fad0e3..0000000 --- a/src/main/java/com/sopt/push/dto/SendAllPushDto.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.sopt.push.dto; - -import com.sopt.push.enums.Category; - -public record SendAllPushDto( - String title, String content, Category category, String deepLink, String webLink) {} diff --git a/src/main/java/com/sopt/push/dto/SendPushDto.java b/src/main/java/com/sopt/push/dto/SendPushDto.java deleted file mode 100644 index b29fe12..0000000 --- a/src/main/java/com/sopt/push/dto/SendPushDto.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.sopt.push.dto; - -import com.sopt.push.enums.Category; -import java.util.Set; - -public record SendPushDto( - Set userIds, - String title, - String content, - Category category, - String deepLink, - String webLink) {} From 7e9e109ac300d5537cfc79e4fbf0025291685670 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4?= Date: Fri, 26 Dec 2025 21:25:35 +0900 Subject: [PATCH 16/34] =?UTF-8?q?[FEAT]=20TokenRegisterFacade=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존 ApiGatewayHandler에서 토큰 등록하는 부분을 파사드 클래스로 분리 --- .../java/com/sopt/push/config/AppFactory.java | 23 +++-- .../push/service/TokenRegisterFacade.java | 87 +++++++++++++++++++ 2 files changed, 97 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/sopt/push/service/TokenRegisterFacade.java diff --git a/src/main/java/com/sopt/push/config/AppFactory.java b/src/main/java/com/sopt/push/config/AppFactory.java index 4422bb4..fa5c93c 100644 --- a/src/main/java/com/sopt/push/config/AppFactory.java +++ b/src/main/java/com/sopt/push/config/AppFactory.java @@ -10,6 +10,7 @@ import com.sopt.push.service.InvalidEndpointCleaner; import com.sopt.push.service.NotificationService; import com.sopt.push.service.SendPushFacade; +import com.sopt.push.service.TokenRegisterFacade; import com.sopt.push.service.UserService; import com.sopt.push.service.WebHookService; import java.net.http.HttpClient; @@ -22,6 +23,7 @@ public class AppFactory { private static final AppFactory INSTANCE = new AppFactory(); private final SendPushFacade sendPushFacade; + private final TokenRegisterFacade tokenRegisterFacade; private final WebHookService webHookService; private final UserService userService; private final HistoryService historyService; @@ -46,17 +48,9 @@ private AppFactory() { HistoryRepository historyRepository = new HistoryRepository(dynamoClient, tableName); DeviceTokenRepository tokenRepository = new DeviceTokenRepository(dynamoClient, tableName); - HistoryService historyService = new HistoryService(historyRepository); - NotificationService notificationService = new NotificationService(snsClient, envConfig); - SnsFactory snsFactory = new SnsFactory(snsClient, envConfig); - DeviceTokenService deviceTokenService = - new DeviceTokenService(tokenRepository, userRepository, snsFactory); - UserService userService = new UserService(userRepository); - InvalidEndpointCleaner invalidEndpointCleaner = - new InvalidEndpointCleaner(userService, deviceTokenService, notificationService); this.userService = new UserService(userRepository); this.historyService = new HistoryService(historyRepository); - this.deviceTokenService = new DeviceTokenService(tokenRepository, userRepository, snsFactory); + this.deviceTokenService = new DeviceTokenService(tokenRepository, userRepository); this.notificationService = new NotificationService(snsClient, envConfig); this.invalidEndpointCleaner = new InvalidEndpointCleaner( @@ -71,6 +65,9 @@ private AppFactory() { this.userService, this.deviceTokenService, invalidEndpointCleaner); + this.tokenRegisterFacade = + new TokenRegisterFacade( + this.deviceTokenService, this.userService, this.notificationService, tokenRepository); } public static AppFactory getInstance() { @@ -97,11 +94,11 @@ public DeviceTokenService deviceTokenService() { return deviceTokenService; } - public NotificationService notificationService() { - return notificationService; - } - public InvalidEndpointCleaner invalidEndpointCleaner() { return invalidEndpointCleaner; } + + public TokenRegisterFacade tokenRegisterFacade() { + return tokenRegisterFacade; + } } diff --git a/src/main/java/com/sopt/push/service/TokenRegisterFacade.java b/src/main/java/com/sopt/push/service/TokenRegisterFacade.java new file mode 100644 index 0000000..bc3084e --- /dev/null +++ b/src/main/java/com/sopt/push/service/TokenRegisterFacade.java @@ -0,0 +1,87 @@ +package com.sopt.push.service; + +import static com.sopt.push.common.Constants.TOKEN_PREFIX; +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.enums.Platform; +import com.sopt.push.repository.DeviceTokenRepository; +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 TokenRegisterFacade { + + private final DeviceTokenService deviceTokenService; + private final UserService userService; + private final NotificationService notificationService; + private final DeviceTokenRepository deviceTokenRepository; + + public void register(String deviceToken, Platform platform, String userId) { + String actualUserId = userId != null ? userId : "unknown"; + String tokenPk = TOKEN_PREFIX + deviceToken; + java.util.List existingTokens = + deviceTokenRepository.findAllByDeviceToken(tokenPk); + + // 0. 기존 토큰 체크 + if (!existingTokens.isEmpty()) { + DeviceTokenEntity deviceTokenEntity = existingTokens.get(0); + String existingUserId = extractUserId(deviceTokenEntity.getSk()); + if (shouldSkipRegistration(existingUserId, actualUserId)) { + return; + } + deviceTokenService.deleteToken(existingUserId, deviceToken); + } + + // 1. SNS 엔드포인트 생성 + CreatePlatformEndpointResponse endpoint = + notificationService.registerEndpoint(deviceToken, platform, userId); + + String endpointArn = endpoint.endpointArn(); + if (endpointArn == null || endpointArn.isBlank()) { + throw new DeviceTokenException(ErrorMessage.ENDPOINT_ARN_UNDEFINED); + } + + // 2. SNS 구독 + SubscribeResponse subscription = notificationService.subscribe(endpointArn); + + String subscriptionArn = subscription.subscriptionArn(); + if (subscriptionArn == null || subscriptionArn.isBlank()) { + throw new DeviceTokenException(ErrorMessage.SUBSCRIPTION_ARN_UNDEFINED); + } + + // 3. DeviceTokenEntity 저장 + deviceTokenService.registerToken( + actualUserId, deviceToken, platform.getValue(), endpointArn, subscriptionArn); + + // 4. UserEntity 저장 + userService.registerUser( + actualUserId, deviceToken, platform.getValue(), endpointArn, subscriptionArn); + } + + private boolean shouldSkipRegistration(String existingUserId, String newUserId) { + return !changedUserPayload(existingUserId, newUserId); + } + + private boolean changedUserPayload(String tokenUserId, String inputUserId) { + if (inputUserId == null && tokenUserId.equals("unknown")) { + return false; + } + if (inputUserId != null && inputUserId.equals(tokenUserId)) { + return false; + } + return true; + } + + private String extractUserId(String sk) { + if (sk.startsWith(USER_PREFIX)) { + return sk.substring(USER_PREFIX.length()); + } + return sk; + } +} From 09cee502021b57b111078b5705ce9cc3710b8f45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4?= Date: Fri, 26 Dec 2025 21:30:39 +0900 Subject: [PATCH 17/34] =?UTF-8?q?[FEAT]=20EnvConfig=20env=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 토큰 등록시 aws 엔드포인트 등록을 위한 env를 추가합니다. --- src/main/java/com/sopt/push/config/EnvConfig.java | 6 ++++++ 1 file changed, 6 insertions(+) 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) { From 4593796b7b49c7db6d027971e0ef7d18cc85297d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4?= Date: Fri, 26 Dec 2025 21:31:33 +0900 Subject: [PATCH 18/34] =?UTF-8?q?[REFACTOR]=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/sopt/push/dto/RegisterHeaderDto.java | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/main/java/com/sopt/push/dto/RegisterHeaderDto.java b/src/main/java/com/sopt/push/dto/RegisterHeaderDto.java index 93b35d4..29e7118 100644 --- a/src/main/java/com/sopt/push/dto/RegisterHeaderDto.java +++ b/src/main/java/com/sopt/push/dto/RegisterHeaderDto.java @@ -5,11 +5,4 @@ import com.sopt.push.enums.Services; public record RegisterHeaderDto( - String transactionId, Services service, Platform platform, Actions action) { - - public RegisterHeaderDto { - if ((action == Actions.REGISTER || action == Actions.CANCEL) && platform == null) { - throw new IllegalArgumentException("Platform is required for REGISTER and CANCEL actions"); - } - } -} + String transactionId, Services service, Platform platform, Actions action) {} From 75b9e44715839822040b86cc54ad31dc047e08ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4?= Date: Fri, 26 Dec 2025 21:34:42 +0900 Subject: [PATCH 19/34] =?UTF-8?q?[REFACTOR]=20ApiGatewayHandler=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 토큰 관련 로직은 DeviceTokenService, 유저 관련 로직은 UserService로 이관 - 로깅 방식 @Slf4j으로 수정 - 토큰 등록&삭제, 알림 전송&수정 시 사용하는 DTO를 기존에 존재하는 DTO 재사용하도록 수정 - createRegisterLog, createCancelLogf를 하나의 메서드로 통합 --- .../sopt/push/lambda/ApiGatewayHandler.java | 159 ++++++++---------- .../repository/DeviceTokenRepository.java | 2 +- .../sopt/push/service/DeviceTokenService.java | 88 +--------- .../push/service/InvalidEndpointCleaner.java | 2 +- .../push/service/NotificationService.java | 50 +++++- .../com/sopt/push/service/UserService.java | 23 +++ 6 files changed, 141 insertions(+), 183 deletions(-) diff --git a/src/main/java/com/sopt/push/lambda/ApiGatewayHandler.java b/src/main/java/com/sopt/push/lambda/ApiGatewayHandler.java index 15a2ffa..793172c 100644 --- a/src/main/java/com/sopt/push/lambda/ApiGatewayHandler.java +++ b/src/main/java/com/sopt/push/lambda/ApiGatewayHandler.java @@ -1,9 +1,13 @@ package com.sopt.push.lambda; -import static com.sopt.push.common.Constants.TOKEN_PREFIX; +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.BAD_REQUEST; 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.validate; import com.amazonaws.services.lambda.runtime.Context; @@ -28,12 +32,16 @@ import com.sopt.push.service.HistoryService; import com.sopt.push.service.InvalidEndpointCleaner; import com.sopt.push.service.SendPushFacade; +import com.sopt.push.service.TokenRegisterFacade; 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.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j public class ApiGatewayHandler implements RequestHandler { @@ -41,6 +49,7 @@ public class ApiGatewayHandler private final SendPushFacade sendPushFacade; private final InvalidEndpointCleaner invalidEndpointCleaner; private final HistoryService historyService; + private final TokenRegisterFacade tokenRegisterFacade; private final ObjectMapper mapper; public ApiGatewayHandler() { @@ -49,6 +58,7 @@ public ApiGatewayHandler() { this.sendPushFacade = factory.sendPushFacade(); this.invalidEndpointCleaner = factory.invalidEndpointCleaner(); this.historyService = factory.historyService(); + this.tokenRegisterFacade = factory.tokenRegisterFacade(); this.mapper = ObjectMapperConfig.getObjectMapper(); } @@ -73,12 +83,12 @@ public APIGatewayProxyResponseEvent handleRequest( return convertToApiGatewayResponse(responseMap); } catch (BusinessException ex) { - context.getLogger().log("ApiGateway error: " + ex.getMessage()); + log.error("ApiGateway error: {}", ex.getMessage()); Map responseMap = ResponseUtil.errorResponse(BAD_REQUEST, ex.getMessage()); return convertToApiGatewayResponse(responseMap); } catch (Exception ex) { - context.getLogger().log("ApiGateway error: " + ex.getMessage()); + log.error("ApiGateway error: {}", ex.getMessage(), ex); Map responseMap = ResponseUtil.errorResponse( INTERNAL_SERVER_ERROR, ErrorMessage.INTERNAL_SERVER_ERROR.getMessage()); @@ -88,48 +98,42 @@ public APIGatewayProxyResponseEvent handleRequest( private ApiGatewayRequestDto extractRequest(APIGatewayProxyRequestEvent event) { Map headers = event.getHeaders(); + Map body = parseRequestBody(event); + boolean isInvalidHeader = headers == null || headers.get(HEADER_ACTION) == null; - if (headers == null || headers.get("action") == null) { + if (isInvalidHeader) { throw new BusinessException(ErrorMessage.INVALID_REQUEST, "Headers missing or invalid."); } - Map body = parseBody(event); - try { - String actionStr = headers.get("action"); - String platformStr = headers.get("platform"); - String transactionId = headers.get("transactionId"); - String serviceStr = headers.get("service"); - + 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 = null; + Platform platform = fromValue(platformStr); + if (action == Actions.REGISTER || action == Actions.CANCEL) { - if (platformStr == null || platformStr.isBlank()) { - throw new BusinessException( - ErrorMessage.INVALID_REQUEST, "Platform is required for REGISTER and CANCEL actions"); - } - platform = Platform.fromValue(platformStr); - } else if (platformStr != null && !platformStr.isBlank()) { - platform = Platform.fromValue(platformStr); + checkPlatform(platformStr); } RegisterHeaderDto header = new RegisterHeaderDto(transactionId, Services.fromValue(serviceStr), platform, action); return new ApiGatewayRequestDto(header, body); - } catch (IllegalArgumentException e) { + } 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) { - RegisterUserDto body = mapper.convertValue(request.body(), RegisterUserDto.class); + 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); @@ -137,30 +141,35 @@ private void handleRegister(ApiGatewayRequestDto request) { validate(finalDto); try { - deviceTokenService.registerToken(deviceToken, platform, userId); - createRegisterLog( - transactionId, userIds, deviceToken, platform, service, NotificationStatus.SUCCESS); + tokenRegisterFacade.register(deviceToken, platform, userId); + createHistoryLog( + transactionId, + userIds, + deviceToken, + platform, + service, + NotificationStatus.SUCCESS, + Actions.REGISTER); } catch (Exception e) { - throw new DeviceTokenException(ErrorMessage.REGISTER_USER_ERROR, e.getMessage(), e); + log.error("Failed to register token: {}", e.getMessage(), e); } } private void handleCancel(ApiGatewayRequestDto request) { - DeleteTokenDto body = mapper.convertValue(request.body(), DeleteTokenDto.class); + 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(); - Set logUserIds = Set.of("NULL"); - RequestDeleteTokenDto finalDto = new RequestDeleteTokenDto(transactionId, service, platform, deviceToken, userIds); validate(finalDto); try { - String userId = (userIds != null && !userIds.isEmpty()) ? userIds.iterator().next() : null; + boolean isInvalidUserId = userIds != null && !userIds.isEmpty(); + String userId = isInvalidUserId ? userIds.iterator().next() : null; if (userId == null) { throw new DeviceTokenException(ErrorMessage.USER_ID_REQUIRED); } @@ -178,25 +187,27 @@ private void handleCancel(ApiGatewayRequestDto request) { throw new DeviceTokenException(ErrorMessage.ARN_UNDEFINED); } - String actualUserId = extractUserIdFromSk(tokenEntity.getSk()); - String actualDeviceToken = extractDeviceTokenFromPk(tokenEntity.getPk()); - Platform tokenPlatform = Platform.fromValue(tokenEntity.getPlatform()); - + Platform tokenPlatform = fromValue(tokenEntity.getPlatform()); UserTokenInfoDto userTokenInfo = - new UserTokenInfoDto( - actualUserId, actualDeviceToken, endpointArn, tokenPlatform, subscriptionArn); + new UserTokenInfoDto(userId, deviceToken, endpointArn, tokenPlatform, subscriptionArn); invalidEndpointCleaner.clean(userTokenInfo); - createCancelLog( - transactionId, logUserIds, deviceToken, platform, service, NotificationStatus.SUCCESS); + createHistoryLog( + transactionId, + Set.of(userId), + deviceToken, + platform, + service, + NotificationStatus.SUCCESS, + Actions.CANCEL); } catch (Exception e) { - throw new DeviceTokenException(ErrorMessage.DELETE_TOKEN_ERROR, e.getMessage(), e); + log.error("Failed to cancel token: {}", e.getMessage(), e); } } private void handleSend(ApiGatewayRequestDto request) { - SendPushDto body = mapper.convertValue(request.body(), SendPushDto.class); - + RequestSendPushMessageDto body = + mapper.convertValue(request.body(), RequestSendPushMessageDto.class); RequestSendPushMessageDto finalDto = new RequestSendPushMessageDto( request.header().transactionId(), @@ -213,8 +224,8 @@ private void handleSend(ApiGatewayRequestDto request) { } private void handleSendAll(ApiGatewayRequestDto request) { - SendAllPushDto body = mapper.convertValue(request.body(), SendAllPushDto.class); - + RequestSendAllPushMessageDto body = + mapper.convertValue(request.body(), RequestSendAllPushMessageDto.class); RequestSendAllPushMessageDto finalDto = new RequestSendAllPushMessageDto( request.header().transactionId(), @@ -229,7 +240,15 @@ private void handleSendAll(ApiGatewayRequestDto request) { sendPushFacade.sendPushAll(finalDto); } - private Map parseBody(APIGatewayProxyRequestEvent event) { + private void checkPlatform(String platformStr) { + boolean isValidPlatform = platformStr == null || platformStr.isBlank(); + if (isValidPlatform) { + throw new BusinessException( + ErrorMessage.INVALID_REQUEST, "Platform is required for REGISTER and CANCEL actions"); + } + } + + private Map parseRequestBody(APIGatewayProxyRequestEvent event) { if (event.getBody() == null) { throw new BusinessException(ErrorMessage.INVALID_REQUEST, "Request body is missing."); } @@ -257,58 +276,14 @@ private APIGatewayProxyResponseEvent convertToApiGatewayResponse( return response; } - private String extractUserIdFromSk(String sk) { - if (sk.startsWith(USER_PREFIX)) { - return sk.substring(USER_PREFIX.length()); - } - return sk; - } - - private String extractDeviceTokenFromPk(String pk) { - if (pk.startsWith(TOKEN_PREFIX)) { - return pk.substring(TOKEN_PREFIX.length()); - } - return pk; - } - - private void createRegisterLog( - String transactionId, - Set userIds, - String deviceToken, - Platform platform, - Services service, - NotificationStatus status) { - CreateHistoryDto createHistoryDto = - new CreateHistoryDto( - transactionId, - null, - null, - null, - null, - NotificationType.PUSH.getValue(), - service.getValue(), - status.getValue(), - Actions.REGISTER.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); - } - - private void createCancelLog( + private void createHistoryLog( String transactionId, Set userIds, String deviceToken, Platform platform, Services service, - NotificationStatus status) { + NotificationStatus status, + Actions action) { CreateHistoryDto createHistoryDto = new CreateHistoryDto( transactionId, @@ -319,7 +294,7 @@ private void createCancelLog( NotificationType.PUSH.getValue(), service.getValue(), status.getValue(), - Actions.CANCEL.getValue(), + action.getValue(), platform != null ? platform.getValue() : null, deviceToken, null, diff --git a/src/main/java/com/sopt/push/repository/DeviceTokenRepository.java b/src/main/java/com/sopt/push/repository/DeviceTokenRepository.java index eefa566..759a8d3 100644 --- a/src/main/java/com/sopt/push/repository/DeviceTokenRepository.java +++ b/src/main/java/com/sopt/push/repository/DeviceTokenRepository.java @@ -33,7 +33,7 @@ public Optional findByPkAndSk(String pk, String sk) { return Optional.ofNullable(deviceTokenTable.getItem(key)); } - public List queryByPk(String pk) { + public List findAllByDeviceToken(String pk) { QueryConditional queryConditional = QueryConditional.keyEqualTo(Key.builder().partitionValue(pk).build()); return deviceTokenTable.query(queryConditional).items().stream().toList(); diff --git a/src/main/java/com/sopt/push/service/DeviceTokenService.java b/src/main/java/com/sopt/push/service/DeviceTokenService.java index 6375f94..f771929 100644 --- a/src/main/java/com/sopt/push/service/DeviceTokenService.java +++ b/src/main/java/com/sopt/push/service/DeviceTokenService.java @@ -2,14 +2,9 @@ import static com.sopt.push.common.Constants.DEVICE_TOKEN_ENTITY; 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.common.DeviceTokenException; -import com.sopt.push.common.ErrorMessage; -import com.sopt.push.config.SnsFactory; import com.sopt.push.domain.DeviceTokenEntity; -import com.sopt.push.domain.UserEntity; import com.sopt.push.dto.UserTokenInfoDto; import com.sopt.push.enums.Platform; import com.sopt.push.repository.DeviceTokenRepository; @@ -17,21 +12,13 @@ import java.time.Instant; import java.util.ArrayList; import java.util.List; +import lombok.RequiredArgsConstructor; +@RequiredArgsConstructor public class DeviceTokenService { private final DeviceTokenRepository deviceTokenRepository; private final UserRepository userRepository; - private final SnsFactory snsFactory; - - public DeviceTokenService( - DeviceTokenRepository deviceTokenRepository, - UserRepository userRepository, - SnsFactory snsFactory) { - this.deviceTokenRepository = deviceTokenRepository; - this.userRepository = userRepository; - this.snsFactory = snsFactory; - } public void createToken( String userId, @@ -68,56 +55,13 @@ public DeviceTokenEntity findTokenByDeviceTokenAndUserId(String deviceToken, Str return deviceTokenRepository.findByPkAndSk(tokenPk, userSk).orElse(null); } - public void registerToken(String deviceToken, Platform platform, String userId) { - String actualUserId = userId != null ? userId : "unknown"; - String tokenPk = TOKEN_PREFIX + deviceToken; - List existingTokens = deviceTokenRepository.queryByPk(tokenPk); - - if (!existingTokens.isEmpty()) { - DeviceTokenEntity existing = existingTokens.get(0); - String existingUserId = extractUserIdFromSk(existing.getSk()); - if (!changedUserPayload(existingUserId, actualUserId)) { - return; - } - } - - var endpoint = snsFactory.registerEndPoint(deviceToken, platform, userId); - - String endpointArn = endpoint.endpointArn(); - if (endpointArn == null || endpointArn.isBlank()) { - throw new DeviceTokenException(ErrorMessage.ENDPOINT_ARN_UNDEFINED); - } - - var sub = snsFactory.subscribe(endpointArn); - - String subscriptionArn = sub.subscriptionArn(); - if (subscriptionArn == null || subscriptionArn.isBlank()) { - throw new DeviceTokenException(ErrorMessage.SUBSCRIPTION_ARN_UNDEFINED); - } - - createToken(actualUserId, deviceToken, platform.getValue(), endpointArn, subscriptionArn); - saveUserEntity(actualUserId, deviceToken, platform.getValue(), endpointArn, subscriptionArn); - } - - private void saveUserEntity( + public void registerToken( 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); + createToken(userId, deviceToken, platform, endpointArn, subscriptionArn); } public void deleteUser(String userId, String deviceToken) { @@ -126,30 +70,6 @@ public void deleteUser(String userId, String deviceToken) { userRepository.delete(userPk, tokenSk); } - private boolean changedUserPayload(String tokenUserId, String inputUserId) { - if (inputUserId == null && tokenUserId.equals("unknown")) { - return false; - } - if (inputUserId != null && inputUserId.equals(tokenUserId)) { - return false; - } - return true; - } - - private String extractUserIdFromSk(String sk) { - if (sk.startsWith(USER_PREFIX)) { - return sk.substring(USER_PREFIX.length()); - } - return sk; - } - - private String extractDeviceTokenFromPk(String pk) { - if (pk.startsWith(TOKEN_PREFIX)) { - return pk.substring(TOKEN_PREFIX.length()); - } - return pk; - } - public List findUserByTokenIds(List deviceTokens) { List result = new ArrayList<>(); for (String deviceToken : deviceTokens) { diff --git a/src/main/java/com/sopt/push/service/InvalidEndpointCleaner.java b/src/main/java/com/sopt/push/service/InvalidEndpointCleaner.java index 3a35ff6..3be3e1e 100644 --- a/src/main/java/com/sopt/push/service/InvalidEndpointCleaner.java +++ b/src/main/java/com/sopt/push/service/InvalidEndpointCleaner.java @@ -22,7 +22,7 @@ public InvalidEndpointCleaner( public void clean(UserTokenInfoDto token) { userService.deleteUser(token.userId(), token.deviceToken()); - tokenService.deleteToken(token.deviceToken(), token.userId()); + tokenService.deleteToken(token.userId(), token.deviceToken()); try { notificationService.deleteEndpoint(token.endpointArn()); diff --git a/src/main/java/com/sopt/push/service/NotificationService.java b/src/main/java/com/sopt/push/service/NotificationService.java index 4d46d19..5365747 100644 --- a/src/main/java/com/sopt/push/service/NotificationService.java +++ b/src/main/java/com/sopt/push/service/NotificationService.java @@ -3,8 +3,8 @@ import static com.sopt.push.common.Constants.JSON; import static com.sopt.push.util.ValidationUtil.validate; +import com.sopt.push.common.ExternalException; import com.sopt.push.common.InvalidEndpointException; -import com.sopt.push.common.PushFailException; import com.sopt.push.config.EnvConfig; import com.sopt.push.config.ValidatorConfig; import com.sopt.push.dto.MessageFactoryDto; @@ -14,12 +14,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 +31,13 @@ public class NotificationService { private final SnsClient snsClient; private final Validator validator; private final String allTopicArn; + private final EnvConfig envConfig; public NotificationService(SnsClient snsClient, EnvConfig envConfig) { this.snsClient = snsClient; this.validator = ValidatorConfig.getValidator(); this.allTopicArn = envConfig.getAllTopicArn(); + this.envConfig = envConfig; } public String platformPush( @@ -66,9 +72,9 @@ public String platformPush( throw new InvalidEndpointException(endpointArn, ex); } catch (SnsException ex) { - throw new PushFailException("SNS publish failed: " + ex.getMessage(), ex); + throw new ExternalException("SNS publish failed: " + ex.getMessage(), ex); } catch (Exception ex) { - throw new PushFailException("Unknown error while sending push: " + ex.getMessage(), ex); + throw new ExternalException("Unknown error while sending push: " + ex.getMessage(), ex); } } @@ -101,9 +107,9 @@ public String allTopicPush( } catch (EndpointDisabledException | InvalidParameterException ex) { throw new InvalidEndpointException(allTopicArn, ex); } catch (SnsException ex) { - throw new PushFailException("SNS publish failed: " + ex.getMessage(), ex); + throw new ExternalException("SNS publish failed: " + ex.getMessage(), ex); } catch (Exception ex) { - throw new PushFailException("Unknown error while sending push: " + ex.getMessage(), ex); + throw new ExternalException("Unknown error while sending push: " + ex.getMessage(), ex); } } @@ -114,4 +120,38 @@ 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("application") + .endpoint(endpointArn) + .topicArn(allTopicArn) + .build(); + + return snsClient.subscribe(request); + } + + private String getPlatformApplicationArn(Platform platform) { + return platform == Platform.IOS + ? envConfig.getPlatformApplicationIosArn() + : envConfig.getPlatformApplicationAndroidArn(); + } } 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); + } } From 97bd0e749b8c6032190a0400e40790eb78129d3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4?= Date: Fri, 26 Dec 2025 21:35:20 +0900 Subject: [PATCH 20/34] =?UTF-8?q?[FIX]=20=EC=9E=98=EB=AA=BB=EB=90=9C=20rol?= =?UTF-8?q?e=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- template.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/template.yaml b/template.yaml index 086db51..6e95f50 100644 --- a/template.yaml +++ b/template.yaml @@ -132,7 +132,7 @@ Resources: FunctionName: !Sub "sopt-push-notification-lambda-${Stage}" Handler: com.sopt.push.lambda.ApiGatewayHandler::handleRequest CodeUri: build/libs/app.jar - Role: . + Role: !GetAtt PushLambdaRole.Arn Events: Root: Type: Api @@ -147,7 +147,7 @@ Resources: FunctionName: !Sub "sopt-push-notification-lambda-eventbridge-${Stage}" Handler: com.sopt.push.lambda.EventBridgeHandler::handleRequest CodeUri: build/libs/app.jar - Role: . + Role: !GetAtt PushLambdaRole.Arn Events: PushEvent: Type: EventBridgeRule From a46e6383705d93177786193c6e55680bb9af806b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4?= Date: Fri, 26 Dec 2025 21:39:52 +0900 Subject: [PATCH 21/34] =?UTF-8?q?[REFACTOR]=20spotleess=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/sopt/push/lambda/ApiGatewayHandler.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/sopt/push/lambda/ApiGatewayHandler.java b/src/main/java/com/sopt/push/lambda/ApiGatewayHandler.java index 793172c..767bd26 100644 --- a/src/main/java/com/sopt/push/lambda/ApiGatewayHandler.java +++ b/src/main/java/com/sopt/push/lambda/ApiGatewayHandler.java @@ -38,7 +38,6 @@ import java.util.Map; import java.util.Set; import java.util.stream.Collectors; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @Slf4j From 29568fe7e6ccab9b40fbceeb5fab69f84c5e28e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4?= Date: Fri, 26 Dec 2025 21:51:50 +0900 Subject: [PATCH 22/34] =?UTF-8?q?[REFACTOR]=20=ED=95=84=EC=9A=94=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EB=B0=8F=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/sopt/push/service/DeviceTokenService.java | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/main/java/com/sopt/push/service/DeviceTokenService.java b/src/main/java/com/sopt/push/service/DeviceTokenService.java index f771929..aa41e2c 100644 --- a/src/main/java/com/sopt/push/service/DeviceTokenService.java +++ b/src/main/java/com/sopt/push/service/DeviceTokenService.java @@ -8,7 +8,6 @@ import com.sopt.push.dto.UserTokenInfoDto; import com.sopt.push.enums.Platform; import com.sopt.push.repository.DeviceTokenRepository; -import com.sopt.push.repository.UserRepository; import java.time.Instant; import java.util.ArrayList; import java.util.List; @@ -18,7 +17,6 @@ public class DeviceTokenService { private final DeviceTokenRepository deviceTokenRepository; - private final UserRepository userRepository; public void createToken( String userId, @@ -46,7 +44,6 @@ public void deleteToken(String userId, String deviceToken) { String userSk = USER_PREFIX + userId; deviceTokenRepository.delete(tokenPk, userSk); - deleteUser(userId, deviceToken); } public DeviceTokenEntity findTokenByDeviceTokenAndUserId(String deviceToken, String userId) { @@ -64,12 +61,6 @@ public void registerToken( createToken(userId, deviceToken, platform, endpointArn, subscriptionArn); } - public void deleteUser(String userId, String deviceToken) { - String userPk = USER_PREFIX + userId; - String tokenSk = TOKEN_PREFIX + deviceToken; - userRepository.delete(userPk, tokenSk); - } - public List findUserByTokenIds(List deviceTokens) { List result = new ArrayList<>(); for (String deviceToken : deviceTokens) { From e68e22daa42d72b090ac7813b1f12eccaebde978 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4?= Date: Fri, 26 Dec 2025 21:52:53 +0900 Subject: [PATCH 23/34] =?UTF-8?q?[FIX]=20=ED=86=A0=ED=81=B0=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C,=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EC=82=AD=EC=A0=9C=20=EC=8B=A4=ED=8C=A8=EC=8B=9C=20?= =?UTF-8?q?=EC=84=B1=EA=B3=B5=20=EC=9D=91=EB=8B=B5=EC=9D=B4=20=EB=B0=98?= =?UTF-8?q?=ED=99=98=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/sopt/push/lambda/ApiGatewayHandler.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/sopt/push/lambda/ApiGatewayHandler.java b/src/main/java/com/sopt/push/lambda/ApiGatewayHandler.java index 767bd26..2c0b0d2 100644 --- a/src/main/java/com/sopt/push/lambda/ApiGatewayHandler.java +++ b/src/main/java/com/sopt/push/lambda/ApiGatewayHandler.java @@ -151,6 +151,7 @@ private void handleRegister(ApiGatewayRequestDto request) { Actions.REGISTER); } catch (Exception e) { log.error("Failed to register token: {}", e.getMessage(), e); + throw new DeviceTokenException(ErrorMessage.REGISTER_USER_ERROR, e.getMessage(), e); } } @@ -201,6 +202,7 @@ private void handleCancel(ApiGatewayRequestDto request) { Actions.CANCEL); } catch (Exception e) { log.error("Failed to cancel token: {}", e.getMessage(), e); + throw new DeviceTokenException(ErrorMessage.DELETE_TOKEN_ERROR, e.getMessage(), e); } } From 40547b505ca90bf351c6304b22fae16421104559 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4?= Date: Fri, 26 Dec 2025 21:56:30 +0900 Subject: [PATCH 24/34] =?UTF-8?q?[REFACTOR]=20actualUserId=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/sopt/push/service/TokenRegisterFacade.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/sopt/push/service/TokenRegisterFacade.java b/src/main/java/com/sopt/push/service/TokenRegisterFacade.java index bc3084e..a857709 100644 --- a/src/main/java/com/sopt/push/service/TokenRegisterFacade.java +++ b/src/main/java/com/sopt/push/service/TokenRegisterFacade.java @@ -40,7 +40,7 @@ public void register(String deviceToken, Platform platform, String userId) { // 1. SNS 엔드포인트 생성 CreatePlatformEndpointResponse endpoint = - notificationService.registerEndpoint(deviceToken, platform, userId); + notificationService.registerEndpoint(deviceToken, platform, actualUserId); String endpointArn = endpoint.endpointArn(); if (endpointArn == null || endpointArn.isBlank()) { From 303c72fcdeedcc40dd3956904d316d8560fb0541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4?= Date: Fri, 26 Dec 2025 22:00:02 +0900 Subject: [PATCH 25/34] =?UTF-8?q?[FIX]=20AppFactory=20-=20DeviceTokenServi?= =?UTF-8?q?ce=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/sopt/push/config/AppFactory.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/sopt/push/config/AppFactory.java b/src/main/java/com/sopt/push/config/AppFactory.java index fa5c93c..6bb9c16 100644 --- a/src/main/java/com/sopt/push/config/AppFactory.java +++ b/src/main/java/com/sopt/push/config/AppFactory.java @@ -50,7 +50,7 @@ private AppFactory() { this.userService = new UserService(userRepository); this.historyService = new HistoryService(historyRepository); - this.deviceTokenService = new DeviceTokenService(tokenRepository, userRepository); + this.deviceTokenService = new DeviceTokenService(tokenRepository); this.notificationService = new NotificationService(snsClient, envConfig); this.invalidEndpointCleaner = new InvalidEndpointCleaner( From 4a52d915cb551806c7491e06b609604089c1fe7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4?= Date: Sun, 28 Dec 2025 16:15:25 +0900 Subject: [PATCH 26/34] =?UTF-8?q?[REFACTOR]=20validate=20>=20validateDto?= =?UTF-8?q?=20=EB=A9=94=EC=84=9C=EB=93=9C=EB=AA=85=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/sopt/push/lambda/EventBridgeHandler.java | 6 +++--- src/main/java/com/sopt/push/util/ValidationUtil.java | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) 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/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) { From 976862ecf8e8f5c74988eaadbc3e890994b41e76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4?= Date: Sun, 28 Dec 2025 16:22:00 +0900 Subject: [PATCH 27/34] =?UTF-8?q?[REFACTOR]=20InvalidEndpointCleaner=20&?= =?UTF-8?q?=20TokenRegisterFacade=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 두 클래스를 하나의 EndpointFacade로 통합 --- .../java/com/sopt/push/config/AppFactory.java | 24 ++--- .../sopt/push/lambda/ApiGatewayHandler.java | 87 ++++++---------- .../java/com/sopt/push/lambda/SnsHandler.java | 8 +- .../com/sopt/push/service/EndpointFacade.java | 98 +++++++++++++++++++ .../push/service/InvalidEndpointCleaner.java | 35 ------- .../com/sopt/push/service/SendPushFacade.java | 8 +- .../push/service/TokenRegisterFacade.java | 87 ---------------- 7 files changed, 142 insertions(+), 205 deletions(-) create mode 100644 src/main/java/com/sopt/push/service/EndpointFacade.java delete mode 100644 src/main/java/com/sopt/push/service/InvalidEndpointCleaner.java delete mode 100644 src/main/java/com/sopt/push/service/TokenRegisterFacade.java diff --git a/src/main/java/com/sopt/push/config/AppFactory.java b/src/main/java/com/sopt/push/config/AppFactory.java index 6bb9c16..7b4393f 100644 --- a/src/main/java/com/sopt/push/config/AppFactory.java +++ b/src/main/java/com/sopt/push/config/AppFactory.java @@ -6,11 +6,10 @@ 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.TokenRegisterFacade; import com.sopt.push.service.UserService; import com.sopt.push.service.WebHookService; import java.net.http.HttpClient; @@ -23,13 +22,12 @@ public class AppFactory { private static final AppFactory INSTANCE = new AppFactory(); private final SendPushFacade sendPushFacade; - private final TokenRegisterFacade tokenRegisterFacade; + 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() { @@ -52,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 = @@ -64,10 +61,7 @@ private AppFactory() { this.historyService, this.userService, this.deviceTokenService, - invalidEndpointCleaner); - this.tokenRegisterFacade = - new TokenRegisterFacade( - this.deviceTokenService, this.userService, this.notificationService, tokenRepository); + this.endpointFacade); } public static AppFactory getInstance() { @@ -94,11 +88,7 @@ public DeviceTokenService deviceTokenService() { return deviceTokenService; } - public InvalidEndpointCleaner invalidEndpointCleaner() { - return invalidEndpointCleaner; - } - - public TokenRegisterFacade tokenRegisterFacade() { - return tokenRegisterFacade; + public EndpointFacade endpointFacade() { + return endpointFacade; } } diff --git a/src/main/java/com/sopt/push/lambda/ApiGatewayHandler.java b/src/main/java/com/sopt/push/lambda/ApiGatewayHandler.java index 2c0b0d2..5987588 100644 --- a/src/main/java/com/sopt/push/lambda/ApiGatewayHandler.java +++ b/src/main/java/com/sopt/push/lambda/ApiGatewayHandler.java @@ -5,10 +5,9 @@ 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.BAD_REQUEST; 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.validate; +import static com.sopt.push.util.ValidationUtil.validateDto; import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; @@ -22,17 +21,23 @@ import com.sopt.push.config.AppFactory; import com.sopt.push.config.ObjectMapperConfig; import com.sopt.push.domain.DeviceTokenEntity; -import com.sopt.push.dto.*; +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.InvalidEndpointCleaner; import com.sopt.push.service.SendPushFacade; -import com.sopt.push.service.TokenRegisterFacade; import com.sopt.push.util.ResponseUtil; import java.util.Collections; import java.util.Map; @@ -46,18 +51,16 @@ public class ApiGatewayHandler private final DeviceTokenService deviceTokenService; private final SendPushFacade sendPushFacade; - private final InvalidEndpointCleaner invalidEndpointCleaner; + private final EndpointFacade endpointFacade; private final HistoryService historyService; - private final TokenRegisterFacade tokenRegisterFacade; private final ObjectMapper mapper; public ApiGatewayHandler() { AppFactory factory = AppFactory.getInstance(); this.deviceTokenService = factory.deviceTokenService(); this.sendPushFacade = factory.sendPushFacade(); - this.invalidEndpointCleaner = factory.invalidEndpointCleaner(); + this.endpointFacade = factory.endpointFacade(); this.historyService = factory.historyService(); - this.tokenRegisterFacade = factory.tokenRegisterFacade(); this.mapper = ObjectMapperConfig.getObjectMapper(); } @@ -79,19 +82,20 @@ public APIGatewayProxyResponseEvent handleRequest( SuccessMessage successMessage = getSuccessMessage(action); Map responseMap = ResponseUtil.successResponse(successMessage); - return convertToApiGatewayResponse(responseMap); + return ResponseUtil.convertToApiGatewayResponse(responseMap); } catch (BusinessException ex) { log.error("ApiGateway error: {}", ex.getMessage()); - Map responseMap = ResponseUtil.errorResponse(BAD_REQUEST, ex.getMessage()); - return convertToApiGatewayResponse(responseMap); + 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 convertToApiGatewayResponse(responseMap); + return ResponseUtil.convertToApiGatewayResponse(responseMap); } } @@ -110,12 +114,7 @@ private ApiGatewayRequestDto extractRequest(APIGatewayProxyRequestEvent event) { String transactionId = headers.get(HEADER_TRANSACTION_ID); String serviceStr = headers.get(HEADER_SERVICE); Actions action = Actions.fromValue(actionStr); - Platform platform = fromValue(platformStr); - - if (action == Actions.REGISTER || action == Actions.CANCEL) { - checkPlatform(platformStr); - } - + Platform platform = Platform.fromValue(platformStr); RegisterHeaderDto header = new RegisterHeaderDto(transactionId, Services.fromValue(serviceStr), platform, action); @@ -137,10 +136,10 @@ private void handleRegister(ApiGatewayRequestDto request) { RequestRegisterUserDto finalDto = new RequestRegisterUserDto(transactionId, service, platform, deviceToken, userIds); - validate(finalDto); + validateDto(finalDto); try { - tokenRegisterFacade.register(deviceToken, platform, userId); + endpointFacade.register(deviceToken, platform, userId); createHistoryLog( transactionId, userIds, @@ -162,36 +161,24 @@ private void handleCancel(ApiGatewayRequestDto request) { Platform platform = request.header().platform(); String deviceToken = body.deviceToken(); Set userIds = body.userIds(); - RequestDeleteTokenDto finalDto = + RequestDeleteTokenDto requestDto = new RequestDeleteTokenDto(transactionId, service, platform, deviceToken, userIds); - validate(finalDto); + validateDto(requestDto); try { boolean isInvalidUserId = userIds != null && !userIds.isEmpty(); String userId = isInvalidUserId ? userIds.iterator().next() : null; - if (userId == null) { - throw new DeviceTokenException(ErrorMessage.USER_ID_REQUIRED); - } - DeviceTokenEntity tokenEntity = deviceTokenService.findTokenByDeviceTokenAndUserId(deviceToken, userId); - if (tokenEntity == null) { - throw new DeviceTokenException(ErrorMessage.TOKEN_NOT_FOUND); - } String endpointArn = tokenEntity.getEndpointArn(); String subscriptionArn = tokenEntity.getSubscriptionArn(); - - if (endpointArn == null || subscriptionArn == null) { - throw new DeviceTokenException(ErrorMessage.ARN_UNDEFINED); - } - Platform tokenPlatform = fromValue(tokenEntity.getPlatform()); UserTokenInfoDto userTokenInfo = new UserTokenInfoDto(userId, deviceToken, endpointArn, tokenPlatform, subscriptionArn); - invalidEndpointCleaner.clean(userTokenInfo); + endpointFacade.clean(userTokenInfo); createHistoryLog( transactionId, Set.of(userId), @@ -209,7 +196,7 @@ private void handleCancel(ApiGatewayRequestDto request) { private void handleSend(ApiGatewayRequestDto request) { RequestSendPushMessageDto body = mapper.convertValue(request.body(), RequestSendPushMessageDto.class); - RequestSendPushMessageDto finalDto = + RequestSendPushMessageDto sendPushMessageDto = new RequestSendPushMessageDto( request.header().transactionId(), request.header().service(), @@ -220,14 +207,14 @@ private void handleSend(ApiGatewayRequestDto request) { body.deepLink(), body.webLink()); - validate(finalDto); - sendPushFacade.sendPush(finalDto); + validateDto(sendPushMessageDto); + sendPushFacade.sendPush(sendPushMessageDto); } private void handleSendAll(ApiGatewayRequestDto request) { RequestSendAllPushMessageDto body = mapper.convertValue(request.body(), RequestSendAllPushMessageDto.class); - RequestSendAllPushMessageDto finalDto = + RequestSendAllPushMessageDto sendAllPushMessageDto = new RequestSendAllPushMessageDto( request.header().transactionId(), request.header().service(), @@ -237,16 +224,8 @@ private void handleSendAll(ApiGatewayRequestDto request) { body.deepLink(), body.webLink()); - validate(finalDto); - sendPushFacade.sendPushAll(finalDto); - } - - private void checkPlatform(String platformStr) { - boolean isValidPlatform = platformStr == null || platformStr.isBlank(); - if (isValidPlatform) { - throw new BusinessException( - ErrorMessage.INVALID_REQUEST, "Platform is required for REGISTER and CANCEL actions"); - } + validateDto(sendAllPushMessageDto); + sendPushFacade.sendPushAll(sendAllPushMessageDto); } private Map parseRequestBody(APIGatewayProxyRequestEvent event) { @@ -269,14 +248,6 @@ private SuccessMessage getSuccessMessage(Actions action) { }; } - private APIGatewayProxyResponseEvent convertToApiGatewayResponse( - Map responseMap) { - APIGatewayProxyResponseEvent response = new APIGatewayProxyResponseEvent(); - response.setStatusCode((Integer) responseMap.get("statusCode")); - response.setBody((String) responseMap.get("body")); - return response; - } - private void createHistoryLog( String transactionId, Set userIds, 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/EndpointFacade.java b/src/main/java/com/sopt/push/service/EndpointFacade.java new file mode 100644 index 0000000..d9d2fee --- /dev/null +++ b/src/main/java/com/sopt/push/service/EndpointFacade.java @@ -0,0 +1,98 @@ +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.registerToken( + 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/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/TokenRegisterFacade.java b/src/main/java/com/sopt/push/service/TokenRegisterFacade.java deleted file mode 100644 index a857709..0000000 --- a/src/main/java/com/sopt/push/service/TokenRegisterFacade.java +++ /dev/null @@ -1,87 +0,0 @@ -package com.sopt.push.service; - -import static com.sopt.push.common.Constants.TOKEN_PREFIX; -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.enums.Platform; -import com.sopt.push.repository.DeviceTokenRepository; -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 TokenRegisterFacade { - - private final DeviceTokenService deviceTokenService; - private final UserService userService; - private final NotificationService notificationService; - private final DeviceTokenRepository deviceTokenRepository; - - public void register(String deviceToken, Platform platform, String userId) { - String actualUserId = userId != null ? userId : "unknown"; - String tokenPk = TOKEN_PREFIX + deviceToken; - java.util.List existingTokens = - deviceTokenRepository.findAllByDeviceToken(tokenPk); - - // 0. 기존 토큰 체크 - if (!existingTokens.isEmpty()) { - DeviceTokenEntity deviceTokenEntity = existingTokens.get(0); - String existingUserId = extractUserId(deviceTokenEntity.getSk()); - if (shouldSkipRegistration(existingUserId, actualUserId)) { - return; - } - deviceTokenService.deleteToken(existingUserId, deviceToken); - } - - // 1. SNS 엔드포인트 생성 - CreatePlatformEndpointResponse endpoint = - notificationService.registerEndpoint(deviceToken, platform, actualUserId); - - String endpointArn = endpoint.endpointArn(); - if (endpointArn == null || endpointArn.isBlank()) { - throw new DeviceTokenException(ErrorMessage.ENDPOINT_ARN_UNDEFINED); - } - - // 2. SNS 구독 - SubscribeResponse subscription = notificationService.subscribe(endpointArn); - - String subscriptionArn = subscription.subscriptionArn(); - if (subscriptionArn == null || subscriptionArn.isBlank()) { - throw new DeviceTokenException(ErrorMessage.SUBSCRIPTION_ARN_UNDEFINED); - } - - // 3. DeviceTokenEntity 저장 - deviceTokenService.registerToken( - actualUserId, deviceToken, platform.getValue(), endpointArn, subscriptionArn); - - // 4. UserEntity 저장 - userService.registerUser( - actualUserId, deviceToken, platform.getValue(), endpointArn, subscriptionArn); - } - - private boolean shouldSkipRegistration(String existingUserId, String newUserId) { - return !changedUserPayload(existingUserId, newUserId); - } - - private boolean changedUserPayload(String tokenUserId, String inputUserId) { - if (inputUserId == null && tokenUserId.equals("unknown")) { - return false; - } - if (inputUserId != null && inputUserId.equals(tokenUserId)) { - return false; - } - return true; - } - - private String extractUserId(String sk) { - if (sk.startsWith(USER_PREFIX)) { - return sk.substring(USER_PREFIX.length()); - } - return sk; - } -} From 8dc2eb232cd42e9ced0591c688445594c839ea43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4?= Date: Sun, 28 Dec 2025 16:25:14 +0900 Subject: [PATCH 28/34] =?UTF-8?q?[REFACTOR]=20ApiGatewayHandler=20-=20resp?= =?UTF-8?q?onse=EB=A5=BC=20ResponseUtil=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/sopt/push/util/ResponseUtil.java | 9 +++++++++ 1 file changed, 9 insertions(+) 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; + } } From 0327c5bfacf706bab577e2688984122a199b664b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4?= Date: Sun, 28 Dec 2025 16:25:45 +0900 Subject: [PATCH 29/34] =?UTF-8?q?[REFACTOR]=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EB=B6=80=EB=B6=84=20DeviceTokenService?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/sopt/push/service/DeviceTokenService.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/com/sopt/push/service/DeviceTokenService.java b/src/main/java/com/sopt/push/service/DeviceTokenService.java index aa41e2c..43e1fd4 100644 --- a/src/main/java/com/sopt/push/service/DeviceTokenService.java +++ b/src/main/java/com/sopt/push/service/DeviceTokenService.java @@ -11,6 +11,7 @@ import java.time.Instant; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import lombok.RequiredArgsConstructor; @RequiredArgsConstructor @@ -69,6 +70,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) From 953518dc823a7f23b330a33be63beabd451f4ddf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4?= Date: Sun, 28 Dec 2025 16:26:13 +0900 Subject: [PATCH 30/34] =?UTF-8?q?[REFACTOR]=20SNS=5FPROTOCOL=5FAPPLICATION?= =?UTF-8?q?=20=EC=83=81=EC=88=98=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/sopt/push/common/Constants.java | 1 + .../push/service/NotificationService.java | 19 ++++++++++--------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/sopt/push/common/Constants.java b/src/main/java/com/sopt/push/common/Constants.java index 9a2fabd..c942563 100644 --- a/src/main/java/com/sopt/push/common/Constants.java +++ b/src/main/java/com/sopt/push/common/Constants.java @@ -58,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/service/NotificationService.java b/src/main/java/com/sopt/push/service/NotificationService.java index 5365747..393ab57 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; @@ -31,13 +32,15 @@ public class NotificationService { private final SnsClient snsClient; private final Validator validator; private final String allTopicArn; - private final EnvConfig envConfig; + 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.envConfig = envConfig; + this.iosArn = envConfig.getPlatformApplicationIosArn(); + this.androidArn = envConfig.getPlatformApplicationAndroidArn(); } public String platformPush( @@ -54,7 +57,7 @@ public String platformPush( new MessageFactoryDto( platform.getTopic(), messageId, title, content, category, deepLink, webLink); - validate(dto); + validateDto(dto); String messageJson = MessageCreator.create(dto); @@ -90,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); @@ -141,7 +144,7 @@ public CreatePlatformEndpointResponse registerEndpoint( public SubscribeResponse subscribe(String endpointArn) { SubscribeRequest request = SubscribeRequest.builder() - .protocol("application") + .protocol(SNS_PROTOCOL_APPLICATION) .endpoint(endpointArn) .topicArn(allTopicArn) .build(); @@ -150,8 +153,6 @@ public SubscribeResponse subscribe(String endpointArn) { } private String getPlatformApplicationArn(Platform platform) { - return platform == Platform.IOS - ? envConfig.getPlatformApplicationIosArn() - : envConfig.getPlatformApplicationAndroidArn(); + return platform == Platform.IOS ? iosArn : androidArn; } } From 556638941c53d7082614d771fa5cc7a292d43f70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4?= Date: Sun, 28 Dec 2025 16:39:50 +0900 Subject: [PATCH 31/34] =?UTF-8?q?[REFACTOR]=20DeviceTokenService=20-=20reg?= =?UTF-8?q?isterToken=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 동일한 메서드가 중복되어 registerToken 메서드 삭제 --- .../java/com/sopt/push/service/DeviceTokenService.java | 10 ---------- .../java/com/sopt/push/service/EndpointFacade.java | 5 +++-- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/sopt/push/service/DeviceTokenService.java b/src/main/java/com/sopt/push/service/DeviceTokenService.java index 43e1fd4..580a6ee 100644 --- a/src/main/java/com/sopt/push/service/DeviceTokenService.java +++ b/src/main/java/com/sopt/push/service/DeviceTokenService.java @@ -43,7 +43,6 @@ public void createToken( public void deleteToken(String userId, String deviceToken) { String tokenPk = TOKEN_PREFIX + deviceToken; String userSk = USER_PREFIX + userId; - deviceTokenRepository.delete(tokenPk, userSk); } @@ -53,15 +52,6 @@ public DeviceTokenEntity findTokenByDeviceTokenAndUserId(String deviceToken, Str return deviceTokenRepository.findByPkAndSk(tokenPk, userSk).orElse(null); } - public void registerToken( - String userId, - String deviceToken, - String platform, - String endpointArn, - String subscriptionArn) { - createToken(userId, deviceToken, platform, endpointArn, subscriptionArn); - } - public List findUserByTokenIds(List deviceTokens) { List result = new ArrayList<>(); for (String deviceToken : deviceTokens) { diff --git a/src/main/java/com/sopt/push/service/EndpointFacade.java b/src/main/java/com/sopt/push/service/EndpointFacade.java index d9d2fee..7b73f49 100644 --- a/src/main/java/com/sopt/push/service/EndpointFacade.java +++ b/src/main/java/com/sopt/push/service/EndpointFacade.java @@ -28,8 +28,9 @@ public void register(String deviceToken, Platform platform, String inputUserId) String endpointArn = createSnsEndpoint(deviceToken, platform, inputUserId); String subscriptionArn = subscribeToSnsTopic(endpointArn); - deviceTokenService.registerToken( - inputUserId, deviceToken, platform.getValue(), endpointArn, subscriptionArn); + deviceTokenService.createToken( + inputUserId, deviceToken, platform.getValue(), endpointArn, subscriptionArn); + userService.registerUser( inputUserId, deviceToken, platform.getValue(), endpointArn, subscriptionArn); } From 75ffea20ae1adfd2fdfb56ef071e9964e64f9474 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4?= Date: Sun, 28 Dec 2025 16:40:34 +0900 Subject: [PATCH 32/34] =?UTF-8?q?[REFACTOR]=20DeviceTokenRepository?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=82=AC=EC=9A=A9=ED=95=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/sopt/push/repository/DeviceTokenRepository.java | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/main/java/com/sopt/push/repository/DeviceTokenRepository.java b/src/main/java/com/sopt/push/repository/DeviceTokenRepository.java index 759a8d3..74ba68c 100644 --- a/src/main/java/com/sopt/push/repository/DeviceTokenRepository.java +++ b/src/main/java/com/sopt/push/repository/DeviceTokenRepository.java @@ -33,12 +33,6 @@ public Optional findByPkAndSk(String pk, String sk) { return Optional.ofNullable(deviceTokenTable.getItem(key)); } - public List findAllByDeviceToken(String pk) { - QueryConditional queryConditional = - QueryConditional.keyEqualTo(Key.builder().partitionValue(pk).build()); - return deviceTokenTable.query(queryConditional).items().stream().toList(); - } - public Optional findByDeviceToken(String deviceToken) { String pk = TOKEN_PREFIX + deviceToken; QueryConditional queryConditional = From 770d86e2938d450ee644c83f9598903f606c4639 Mon Sep 17 00:00:00 2001 From: seoyeonjiin Date: Sun, 28 Dec 2025 16:50:58 +0900 Subject: [PATCH 33/34] =?UTF-8?q?[REFACTOR]=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/sopt/push/repository/DeviceTokenRepository.java | 1 - src/main/java/com/sopt/push/service/EndpointFacade.java | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/com/sopt/push/repository/DeviceTokenRepository.java b/src/main/java/com/sopt/push/repository/DeviceTokenRepository.java index 74ba68c..47e2d53 100644 --- a/src/main/java/com/sopt/push/repository/DeviceTokenRepository.java +++ b/src/main/java/com/sopt/push/repository/DeviceTokenRepository.java @@ -3,7 +3,6 @@ import static com.sopt.push.common.Constants.TOKEN_PREFIX; import com.sopt.push.domain.DeviceTokenEntity; -import java.util.List; import java.util.Optional; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; diff --git a/src/main/java/com/sopt/push/service/EndpointFacade.java b/src/main/java/com/sopt/push/service/EndpointFacade.java index 7b73f49..d6ddf47 100644 --- a/src/main/java/com/sopt/push/service/EndpointFacade.java +++ b/src/main/java/com/sopt/push/service/EndpointFacade.java @@ -29,7 +29,7 @@ public void register(String deviceToken, Platform platform, String inputUserId) String subscriptionArn = subscribeToSnsTopic(endpointArn); deviceTokenService.createToken( - inputUserId, deviceToken, platform.getValue(), endpointArn, subscriptionArn); + inputUserId, deviceToken, platform.getValue(), endpointArn, subscriptionArn); userService.registerUser( inputUserId, deviceToken, platform.getValue(), endpointArn, subscriptionArn); From d298340f36acb14c1b7b92bac4532816730f756c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4?= Date: Mon, 29 Dec 2025 18:29:45 +0900 Subject: [PATCH 34/34] =?UTF-8?q?[REFACTOR]=20=EB=B3=80=EC=88=98=EB=AA=85?= =?UTF-8?q?=20messageFactoryDto=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/sopt/push/repository/DeviceTokenRepository.java | 1 - src/main/java/com/sopt/push/service/EndpointFacade.java | 2 +- .../java/com/sopt/push/service/NotificationService.java | 6 +++--- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/sopt/push/repository/DeviceTokenRepository.java b/src/main/java/com/sopt/push/repository/DeviceTokenRepository.java index 74ba68c..47e2d53 100644 --- a/src/main/java/com/sopt/push/repository/DeviceTokenRepository.java +++ b/src/main/java/com/sopt/push/repository/DeviceTokenRepository.java @@ -3,7 +3,6 @@ import static com.sopt.push.common.Constants.TOKEN_PREFIX; import com.sopt.push.domain.DeviceTokenEntity; -import java.util.List; import java.util.Optional; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; diff --git a/src/main/java/com/sopt/push/service/EndpointFacade.java b/src/main/java/com/sopt/push/service/EndpointFacade.java index 7b73f49..d6ddf47 100644 --- a/src/main/java/com/sopt/push/service/EndpointFacade.java +++ b/src/main/java/com/sopt/push/service/EndpointFacade.java @@ -29,7 +29,7 @@ public void register(String deviceToken, Platform platform, String inputUserId) String subscriptionArn = subscribeToSnsTopic(endpointArn); deviceTokenService.createToken( - inputUserId, deviceToken, platform.getValue(), endpointArn, subscriptionArn); + inputUserId, deviceToken, platform.getValue(), endpointArn, subscriptionArn); userService.registerUser( inputUserId, deviceToken, platform.getValue(), endpointArn, subscriptionArn); diff --git a/src/main/java/com/sopt/push/service/NotificationService.java b/src/main/java/com/sopt/push/service/NotificationService.java index 393ab57..ca89106 100644 --- a/src/main/java/com/sopt/push/service/NotificationService.java +++ b/src/main/java/com/sopt/push/service/NotificationService.java @@ -53,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); - validateDto(dto); + validateDto(messageFactoryDto); - String messageJson = MessageCreator.create(dto); + String messageJson = MessageCreator.create(messageFactoryDto); PublishRequest publishRequest = PublishRequest.builder()