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/events/README.md b/events/README.md new file mode 100644 index 0000000..3e80239 --- /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:379013966998: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/Constants.java b/src/main/java/com/sopt/push/common/Constants.java index 5821408..1b363cc 100644 --- a/src/main/java/com/sopt/push/common/Constants.java +++ b/src/main/java/com/sopt/push/common/Constants.java @@ -37,6 +37,7 @@ 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 DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); public static final String HEADER_CONTENT_TYPE = "Content-Type"; diff --git a/src/main/java/com/sopt/push/config/AppFactory.java b/src/main/java/com/sopt/push/config/AppFactory.java index 33cd7c1..06b66f9 100644 --- a/src/main/java/com/sopt/push/config/AppFactory.java +++ b/src/main/java/com/sopt/push/config/AppFactory.java @@ -23,6 +23,11 @@ public class 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 AppFactory() { @@ -41,21 +46,22 @@ 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); - InvalidEndpointCleaner invalidEndpointCleaner = - new InvalidEndpointCleaner(userService, deviceTokenService, notificationService); + this.userService = new UserService(userRepository); + 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.webHookService = new WebHookService(httpClient, envConfig); this.sendPushFacade = new SendPushFacade( - notificationService, - webHookService, - historyService, - userService, - deviceTokenService, + this.notificationService, + this.webHookService, + this.historyService, + this.userService, + this.deviceTokenService, invalidEndpointCleaner); } @@ -70,4 +76,24 @@ public SendPushFacade sendPushFacade() { public WebHookService webHookService() { return webHookService; } + + public UserService userService() { + return userService; + } + + public HistoryService historyService() { + return historyService; + } + + public DeviceTokenService deviceTokenService() { + return deviceTokenService; + } + + public NotificationService notificationService() { + return notificationService; + } + + public InvalidEndpointCleaner invalidEndpointCleaner() { + return invalidEndpointCleaner; + } } diff --git a/src/main/java/com/sopt/push/lambda/EventBridgeHandler.java b/src/main/java/com/sopt/push/lambda/EventBridgeHandler.java index 4e7730e..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; @@ -49,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/repository/DeviceTokenRepository.java b/src/main/java/com/sopt/push/repository/DeviceTokenRepository.java index 3e7a91d..47e2d53 100644 --- a/src/main/java/com/sopt/push/repository/DeviceTokenRepository.java +++ b/src/main/java/com/sopt/push/repository/DeviceTokenRepository.java @@ -1,11 +1,14 @@ package com.sopt.push.repository; +import static com.sopt.push.common.Constants.TOKEN_PREFIX; + import com.sopt.push.domain.DeviceTokenEntity; 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 +31,11 @@ public Optional findByPkAndSk(String pk, String sk) { Key key = Key.builder().partitionValue(pk).sortValue(sk).build(); return Optional.ofNullable(deviceTokenTable.getItem(key)); } + + public Optional findByDeviceToken(String deviceToken) { + String pk = TOKEN_PREFIX + deviceToken; + QueryConditional queryConditional = + QueryConditional.keyEqualTo(Key.builder().partitionValue(pk).build()); + return deviceTokenTable.query(queryConditional).items().stream().findFirst(); + } } diff --git a/src/main/java/com/sopt/push/service/DeviceTokenService.java b/src/main/java/com/sopt/push/service/DeviceTokenService.java index 1856fb6..140fc34 100644 --- a/src/main/java/com/sopt/push/service/DeviceTokenService.java +++ b/src/main/java/com/sopt/push/service/DeviceTokenService.java @@ -5,8 +5,12 @@ import static com.sopt.push.common.Constants.USER_PREFIX; import com.sopt.push.domain.DeviceTokenEntity; +import com.sopt.push.dto.UserTokenInfoDto; +import com.sopt.push.enums.Platform; import com.sopt.push.repository.DeviceTokenRepository; import java.time.Instant; +import java.util.ArrayList; +import java.util.List; public class DeviceTokenService { @@ -43,4 +47,30 @@ public void deleteToken(String userId, String deviceToken) { deviceTokenRepository.delete(tokenPk, userSk); } + + public List findUserByTokenIds(List deviceTokens) { + List result = new ArrayList<>(); + for (String deviceToken : deviceTokens) { + deviceTokenRepository.findByDeviceToken(deviceToken).ifPresent(result::add); + } + return result; + } + + public UserTokenInfoDto mapDeviceTokenEntityToInfoDto(DeviceTokenEntity deviceTokenEntity) { + String deviceToken = + deviceTokenEntity.getPk().startsWith(TOKEN_PREFIX) + ? deviceTokenEntity.getPk().substring(TOKEN_PREFIX.length()) + : deviceTokenEntity.getPk(); + String userId = + deviceTokenEntity.getSk().startsWith(USER_PREFIX) + ? deviceTokenEntity.getSk().substring(USER_PREFIX.length()) + : deviceTokenEntity.getSk(); + + return new UserTokenInfoDto( + userId, + deviceToken, + deviceTokenEntity.getEndpointArn(), + Platform.fromValue(deviceTokenEntity.getPlatform()), + deviceTokenEntity.getSubscriptionArn()); + } } 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/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); + } } diff --git a/template.yaml b/template.yaml index 6003969..c9b895d 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: . + 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 "✅ 테스트 완료!" +