Skip to content
Merged
46 changes: 46 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Repository Guidelines

## Project Structure & Module Organization

This is a Java 21 AWS Lambda push notification service built with Gradle and SAM. Code lives under `src/main/java/com/sopt/push`:

- `lambda/`: handlers for API Gateway, EventBridge, and SNS.
- `service/`, `repository/`, `client/`: business logic, DynamoDB access, and AWS SDK providers.
- `dto/`, `domain/`, `enums/`, `common/`, `util/`, `config/`: request models, persistence models, shared constants, utilities, and wiring.
- `src/main/resources/logback.xml`: logging configuration.
- `events/`: SAM payloads for API Gateway, SNS, and EventBridge.
- `template.yaml`: SAM infrastructure.
- `config/checkstyle/checkstyle.xml`: style rules.

Add tests under `src/test/java` using the production package layout.

## Build, Test, and Development Commands

- `./gradlew build`: compiles, checks, tests, and builds the Lambda fat jar.
- `./gradlew shadowJar`: creates `build/libs/app.jar`.
- `./gradlew test`: runs JUnit 5 tests.
- `./gradlew check`: runs tests, Checkstyle, and Spotless format checks.
- `./gradlew format`: applies Spotless formatting with Google Java Format.
- `sam build`: builds the SAM application after `build/libs/app.jar` exists.
- `sam local invoke SnsHandlerFunction --event events/sns-event-single.json --env-vars params-dev.json`: invokes SNS locally.
- `./test-sns-handler.sh`: SNS handler local testing helper.

## Coding Style & Naming Conventions

Use Java 21 features conservatively and follow the existing package structure. Spotless applies Google Java Format, removes unused imports, trims whitespace, and requires a final newline. Checkstyle enforces naming, braces, import hygiene, 150-line methods, and 7-parameter methods.

Name DTOs with the existing `*Dto` suffix, domain entities with `*Entity`, Lambda handlers with `*Handler`, services with `*Service` or `*Facade`, and AWS clients with `*ClientProvider`.

## Testing Guidelines

Use JUnit Jupiter and Mockito. Name test classes after the class under test, for example `ApiGatewayHandlerTest`. Prefer focused unit tests for services and utilities, and use `events/*.json` plus SAM for handler-level checks. Run `./gradlew test` before opening a PR; run `./gradlew check` when style may be affected.

## Commit & Pull Request Guidelines

Recent commits use uppercase bracketed types, sometimes with an issue number, such as `[FIX] ...`, `[DOCS] ...`, and `[CHORE/#26] ...`. Keep messages concise and action-oriented.

Pull requests should include a problem summary, approach, test evidence, and any deployment or environment changes. For Lambda behavior changes, mention affected handlers and the sample events or SAM commands used.

## Security & Configuration Tips

Do not commit AWS credentials, real device tokens, production ARNs, or local `params-dev.json` files containing secrets. Keep environment-specific values in AWS/SAM parameters or local ignored files, and use example values in `events/` unless a test explicitly requires real data.
20 changes: 20 additions & 0 deletions events/sqs-push-failure.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"Records": [
{
"messageId": "11111111-2222-3333-4444-555555555555",
"receiptHandle": "example-receipt-handle",
"body": "{\"Type\":\"Notification\",\"MessageId\":\"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\",\"TopicArn\":\"arn:aws:sns:ap-northeast-2:123456789012:push-failures\",\"Message\":\"{\\\"Token\\\":\\\"actual-device-token\\\",\\\"EndpointArn\\\":\\\"arn:aws:sns:ap-northeast-2:123456789012:endpoint/APNS/test-app/test-endpoint\\\",\\\"MessageId\\\":\\\"알림 서버 실패 추적 개발 테스트용 알림입니다 ㅎ ㅎ\\\"}\",\"Timestamp\":\"2026-08-10T00:00:00.000Z\"}",
Comment thread
jeong1112 marked this conversation as resolved.
"attributes": {
"ApproximateReceiveCount": "1",
"SentTimestamp": "1786320000000",
"SenderId": "AIDAEXAMPLE",
"ApproximateFirstReceiveTimestamp": "1786320000001"
},
"messageAttributes": {},
"md5OfBody": "example-md5",
"eventSource": "aws:sqs",
"eventSourceARN": "arn:aws:sqs:ap-northeast-2:123456789012:sopt-push-failure-queue-dev",
"awsRegion": "ap-northeast-2"
}
]
}
7 changes: 7 additions & 0 deletions src/main/java/com/sopt/push/config/AppFactory.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import com.sopt.push.service.HistoryService;
import com.sopt.push.service.NotificationService;
import com.sopt.push.service.SendPushFacade;
import com.sopt.push.service.SlackAlertService;
import com.sopt.push.service.UserService;
import com.sopt.push.service.WebHookService;
import java.net.http.HttpClient;
Expand All @@ -28,6 +29,7 @@ public class AppFactory {
private final HistoryService historyService;
private final DeviceTokenService deviceTokenService;
private final NotificationService notificationService;
private final SlackAlertService slackAlertService;

private AppFactory() {

Expand All @@ -50,6 +52,7 @@ private AppFactory() {
this.historyService = new HistoryService(historyRepository);
this.deviceTokenService = new DeviceTokenService(tokenRepository);
this.notificationService = new NotificationService(snsClient, envConfig);
this.slackAlertService = new SlackAlertService(httpClient, envConfig);
this.endpointFacade =
new EndpointFacade(this.deviceTokenService, this.userService, this.notificationService);

Expand Down Expand Up @@ -91,4 +94,8 @@ public DeviceTokenService deviceTokenService() {
public EndpointFacade endpointFacade() {
return endpointFacade;
}

public SlackAlertService slackAlertService() {
return slackAlertService;
}
}
8 changes: 8 additions & 0 deletions src/main/java/com/sopt/push/config/EnvConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ public final class EnvConfig {
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 static final String SLACK_FAILURE_WEBHOOK_URL_ENV = "SLACK_FAILURE_WEBHOOK_URL";

private final String dynamoDbTableName;
private final String allTopicArn;
private final String makersAppServerUrl;
private final String makersOperationServerUrl;
private final String platformApplicationIosArn;
private final String platformApplicationAndroidArn;
private final String slackFailureWebhookUrl;

public EnvConfig() {
this.dynamoDbTableName = getRequiredEnv(DYNAMODB_TABLE_ENV_VAR);
Expand All @@ -26,6 +28,7 @@ public EnvConfig() {
this.makersOperationServerUrl = getRequiredEnv(MAKERS_OPERATION_SERVER_URL);
this.platformApplicationIosArn = getRequiredEnv(PLATFORM_APPLICATION_IOS_ENV);
this.platformApplicationAndroidArn = getRequiredEnv(PLATFORM_APPLICATION_ANDROID_ENV);
this.slackFailureWebhookUrl = getOptionalEnv(SLACK_FAILURE_WEBHOOK_URL_ENV);
}

private static String getRequiredEnv(String key) {
Expand All @@ -35,4 +38,9 @@ private static String getRequiredEnv(String key) {
}
return value;
}

private static String getOptionalEnv(String key) {
String value = System.getenv(key);
return value == null || value.isBlank() ? null : value;
}
}
211 changes: 211 additions & 0 deletions src/main/java/com/sopt/push/lambda/SqsHandler.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
package com.sopt.push.lambda;

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.SQSBatchResponse;
import com.amazonaws.services.lambda.runtime.events.SQSEvent;
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.EndpointFacade;
import com.sopt.push.service.HistoryService;
import com.sopt.push.service.SlackAlertService;
import com.sopt.push.service.SlackAlertService.ProcessingFailureAlert;
import com.sopt.push.service.SlackAlertService.PushFailureAlert;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class SqsHandler implements RequestHandler<SQSEvent, SQSBatchResponse> {

private static final String MESSAGE = "Message";
private static final String MESSAGE_ID = "MessageId";

private final DeviceTokenService deviceTokenService;
private final HistoryService historyService;
private final EndpointFacade endpointFacade;
private final SlackAlertService slackAlertService;
private final ObjectMapper objectMapper;

public SqsHandler() {
AppFactory factory = AppFactory.getInstance();
this.deviceTokenService = factory.deviceTokenService();
this.historyService = factory.historyService();
this.endpointFacade = factory.endpointFacade();
this.slackAlertService = factory.slackAlertService();
this.objectMapper = ObjectMapperConfig.getObjectMapper();
}

@Override
public SQSBatchResponse handleRequest(SQSEvent event, Context context) {
List<SQSBatchResponse.BatchItemFailure> failures = new ArrayList<>();
List<PushFailureAlert> pushFailureAlerts = new ArrayList<>();
List<ProcessingFailureAlert> processingFailureAlerts = new ArrayList<>();

if (event == null || event.getRecords() == null || event.getRecords().isEmpty()) {
log.warn("SQS event is null or has no records");
return new SQSBatchResponse(failures);
}

log.info("Received SQS records count={}", event.getRecords().size());

for (SQSEvent.SQSMessage record : event.getRecords()) {
try {
ProcessingResult result = processRecord(record);
if (result.pushFailureAlert() != null) {
pushFailureAlerts.add(result.pushFailureAlert());
}
if (result.processingFailureAlert() != null) {
processingFailureAlerts.add(result.processingFailureAlert());
}
} catch (Exception ex) {
log.error("Failed to process SQS record. messageId={}", record.getMessageId(), ex);
processingFailureAlerts.add(
SlackAlertService.processingFailureAlert(record.getMessageId(), ex.getMessage()));
failures.add(new SQSBatchResponse.BatchItemFailure(record.getMessageId()));
}
}

slackAlertService.notifyPushFailures(pushFailureAlerts);
slackAlertService.notifyProcessingFailures(processingFailureAlerts);

return new SQSBatchResponse(failures);
}

private ProcessingResult processRecord(SQSEvent.SQSMessage record) throws Exception {
FailureMessage failureMessage = extractFailureMessage(record);
String token = failureMessage.token();

if (token == null || token.isBlank()) {
log.warn("Push failure message has no token. sqsMessageId={}", record.getMessageId());
return ProcessingResult.processingFailure(
SlackAlertService.processingFailureAlert(record.getMessageId(), "Missing device token"));
}

DeviceTokenEntity tokenEntity = deviceTokenService.findByDeviceToken(token).orElse(null);
if (tokenEntity == null) {
log.info("No token entity found for failed token. sqsMessageId={}", record.getMessageId());
createFailLog(null, failureMessage.messageId());
return ProcessingResult.pushFailure(
SlackAlertService.pushFailureAlert(null, token, failureMessage.messageId()));
}

UserTokenInfoDto userTokenInfoDto =
deviceTokenService.mapDeviceTokenEntityToInfoDto(tokenEntity);
log.info(
"Processing invalid push endpoint for userId={}, messageId={}",
userTokenInfoDto.userId(),
failureMessage.messageId());

endpointFacade.clean(userTokenInfoDto);
createFailLog(userTokenInfoDto.userId(), failureMessage.messageId());
return ProcessingResult.pushFailure(
SlackAlertService.pushFailureAlert(
userTokenInfoDto.userId(), userTokenInfoDto.deviceToken(), failureMessage.messageId()));
}

private FailureMessage extractFailureMessage(SQSEvent.SQSMessage record) throws Exception {
JsonNode body = objectMapper.readTree(record.getBody());
String messageId = textOrNull(body.path(MESSAGE_ID));
JsonNode payload = body;

if (body.hasNonNull(MESSAGE)) {
payload = objectMapper.readTree(body.path(MESSAGE).asText());
}

String token = textOrNull(payload.path(TOKEN));
String payloadMessageId = textOrNull(payload.path(MESSAGE_ID));
String fallbackMessageId = messageId != null ? messageId : record.getMessageId();
return new FailureMessage(
token, payloadMessageId != null ? payloadMessageId : fallbackMessageId);
}

private void createFailLog(String userId, String messageId) {
Set<String> userIds = userId != null && !userId.isBlank() ? Set.of(userId) : null;
Set<String> messageIds = messageId != null && !messageId.isBlank() ? Set.of(messageId) : null;

CreateHistoryDto createHistoryDto =
new CreateHistoryDto(
UUID.randomUUID().toString(),
null,
null,
null,
null,
NotificationType.PUSH.getValue(),
null,
NotificationStatus.FAIL.getValue(),
null,
null,
null,
null,
userIds,
null,
messageIds,
null,
null);
historyService.createLog(createHistoryDto);
}

private String textOrNull(JsonNode node) {
return node == null || node.isMissingNode() || node.asText().isBlank() ? null : node.asText();
}

private static final class FailureMessage {

private final String token;
private final String messageId;

private FailureMessage(String token, String messageId) {
this.token = token;
this.messageId = messageId;
}

private String token() {
return token;
}

private String messageId() {
return messageId;
}
}

private static final class ProcessingResult {

private final PushFailureAlert pushFailureAlert;
private final ProcessingFailureAlert processingFailureAlert;

private ProcessingResult(
PushFailureAlert pushFailureAlert, ProcessingFailureAlert processingFailureAlert) {
this.pushFailureAlert = pushFailureAlert;
this.processingFailureAlert = processingFailureAlert;
}

private static ProcessingResult pushFailure(PushFailureAlert alert) {
return new ProcessingResult(alert, null);
}

private static ProcessingResult processingFailure(ProcessingFailureAlert alert) {
return new ProcessingResult(null, alert);
}

private PushFailureAlert pushFailureAlert() {
return pushFailureAlert;
}

private ProcessingFailureAlert processingFailureAlert() {
return processingFailureAlert;
}
}
}
Loading
Loading