diff --git a/.github/workflows/Dev_CD.yml b/.github/workflows/Dev_CD.yml new file mode 100644 index 0000000..cff61a5 --- /dev/null +++ b/.github/workflows/Dev_CD.yml @@ -0,0 +1,78 @@ +name: dev-cd + +on: + push: + branches: + - "develop" + +permissions: + contents: read + checks: write + actions: read + pull-requests: write + +jobs: + test: + uses: ./.github/workflows/Dev_CI.yml + secrets: inherit + + build: + needs: test + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setting dev-secret.yml + run: | + echo "${{ secrets.DEV_SECRET_YML }}" > ./src/main/resources/dev-secret.yml + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '21' + + - name: Make gradlew executable + run: chmod +x gradlew + + - name: bootJar with Gradle + run: ./gradlew bootJar --info + + - name: Change artifact file name + run: mv build/libs/*.jar build/libs/app.jar + + - name: Upload artifact file + uses: actions/upload-artifact@v4 + with: + name: app-artifact + path: ./build/libs/app.jar + if-no-files-found: error + + - name: Upload deploy scripts + uses: actions/upload-artifact@v4 + with: + name: deploy-scripts + path: ./scripts/dev/ + if-no-files-found: error + + deploy: + needs: build + runs-on: dev + + steps: + - name: Download artifact file + uses: actions/download-artifact@v4 + with: + name: app-artifact + path: ~/app + + - name: Download deploy scripts + uses: actions/download-artifact@v4 + with: + name: deploy-scripts + path: ~/app/scripts + + - name: Replace application to latest + run: sudo sh ~/app/scripts/replace-new-version.sh diff --git a/.github/workflows/Dev_CI.yml b/.github/workflows/Dev_CI.yml new file mode 100644 index 0000000..2c1bb2e --- /dev/null +++ b/.github/workflows/Dev_CI.yml @@ -0,0 +1,93 @@ +name: dev-ci + +on: + pull_request: + branches: + - develop + workflow_call: + +permissions: + contents: read + checks: write + pull-requests: write + +jobs: + build-and-push: + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + TEST_REPORT: true + + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: "" + MYSQL_ALLOW_EMPTY_PASSWORD: "yes" + MYSQL_DATABASE: wellmeet_noti_test + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping" + --health-interval=10s + --health-timeout=5s + --health-retries=3 + + zookeeper: + image: confluentinc/cp-zookeeper:7.0.1 + env: + ZOOKEEPER_CLIENT_PORT: 2181 + ZOOKEEPER_TICK_TIME: 2000 + ports: + - 2181:2181 + options: >- + --health-cmd="curl -f http://localhost:8080/commands/stat || exit 1" + --health-interval=10s + --health-timeout=10s + --health-retries=5 + + kafka: + image: confluentinc/cp-kafka:7.0.1 + env: + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092 + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 + KAFKA_AUTO_CREATE_TOPICS_ENABLE: true + ports: + - 9092:9092 + options: >- + --health-cmd="timeout 10s bash -c 'until printf \"\" 2>>/dev/null >>/dev/tcp/localhost/9092; do sleep 1; done'" + --health-interval=10s + --health-timeout=10s + --health-retries=10 + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + + - name: Setting local-secret.yml + run: | + echo "${{ secrets.LOCAL_SECRET_YML }}" > ./src/main/resources/local-secret.yml + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Grant Permission + run: chmod +x ./gradlew + + - name: Build With Gradle + run: ./gradlew clean build -x test + + - name: Run Tests With Gradle + run: ./gradlew test diff --git a/.gitignore b/.gitignore index 5d7bd8d..8586c1a 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ out/ ### VS Code ### .vscode/ /src/main/resources/local-secret.yml +.serena/ diff --git a/build.gradle b/build.gradle index a4c59f0..28f67a6 100644 --- a/build.gradle +++ b/build.gradle @@ -27,6 +27,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'org.springframework.kafka:spring-kafka' implementation('nl.martijndwars:web-push:5.1.1') { exclude group: 'org.asynchttpclient', module: 'async-http-client' } diff --git a/scripts/dev/replace-new-version.sh b/scripts/dev/replace-new-version.sh new file mode 100644 index 0000000..8f3900c --- /dev/null +++ b/scripts/dev/replace-new-version.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +PID=$(lsof -t -i:8080) + +# 프로세스 종료 +if [ -z "$PID" ]; then + echo "No process is using port 8080." +else + echo "Killing process with PID: $PID" + kill -15 "$PID" + + # 직전 명령(프로세스 종료 명령)이 정상 동작했는지 확인 + if [ $? -eq 0 ]; then + echo "Process $PID terminated successfully." + else + echo "Failed to terminate process $PID." + fi +fi + +JAR_FILE=$(ls /home/ubuntu/app/*.jar | head -n 1) + +echo "JAR 파일 실행: $JAR_FILE" + +# 애플리케이션 로그 파일 설정 +APP_LOG_DIR="/home/ubuntu/app/logs" +APP_LOG_FILE="$APP_LOG_DIR/application-$(date +%Y%m%d-%H%M%S).log" + +echo "애플리케이션 로그 파일: $APP_LOG_FILE" + +sudo nohup java \ + -Dspring.profiles.active=dev \ + -Duser.timezone=Asia/Seoul \ + -Dserver.port=8080 \ + -Ddd.service=wellmeet-notification \ + -Ddd.env=dev \ + -jar "$JAR_FILE" > "$APP_LOG_FILE" 2>&1 & + +echo "애플리케이션이 백그라운드에서 실행되었습니다." +echo "로그 확인: tail -f $APP_LOG_FILE" +echo "=== 배포 완료 ===" diff --git a/src/main/java/com/wellmeet/config/KafkaConfig.java b/src/main/java/com/wellmeet/config/KafkaConfig.java new file mode 100644 index 0000000..d8a4d8d --- /dev/null +++ b/src/main/java/com/wellmeet/config/KafkaConfig.java @@ -0,0 +1,48 @@ +package com.wellmeet.config; + +import com.wellmeet.notification.consumer.dto.NotificationMessage; +import java.util.HashMap; +import java.util.Map; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.annotation.EnableKafka; +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; +import org.springframework.kafka.core.ConsumerFactory; +import org.springframework.kafka.core.DefaultKafkaConsumerFactory; +import org.springframework.kafka.support.serializer.JsonDeserializer; + +@EnableKafka +@Configuration +public class KafkaConfig { + + @Value("${spring.kafka.bootstrap-servers}") + private String bootstrapServers; + + @Value("${spring.kafka.consumer.group-id}") + private String groupId; + + @Bean + public ConsumerFactory consumerFactory() { + Map props = new HashMap<>(); + props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId); + props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, JsonDeserializer.class); + props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); + + props.put(JsonDeserializer.USE_TYPE_INFO_HEADERS, false); + props.put(JsonDeserializer.VALUE_DEFAULT_TYPE, NotificationMessage.class); + return new DefaultKafkaConsumerFactory<>(props); + } + + @Bean + public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory() { + ConcurrentKafkaListenerContainerFactory factory = + new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(consumerFactory()); + return factory; + } +} diff --git a/src/main/java/com/wellmeet/exception/ErrorCode.java b/src/main/java/com/wellmeet/exception/ErrorCode.java index b68947d..9d27760 100644 --- a/src/main/java/com/wellmeet/exception/ErrorCode.java +++ b/src/main/java/com/wellmeet/exception/ErrorCode.java @@ -16,7 +16,7 @@ public enum ErrorCode { INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."), CORS_ORIGIN_EMPTY(HttpStatus.INTERNAL_SERVER_ERROR, "CORS Origin 은 적어도 한 개 있어야 합니다"), WEB_PUSH_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "웹 푸시 전송에 실패했습니다."), - ; + SENDER_NOT_FOUND(HttpStatus.BAD_REQUEST, "알림을 발송할 수 없습니다."); private final HttpStatus status; private final String message; diff --git a/src/main/java/com/wellmeet/notification/Sender.java b/src/main/java/com/wellmeet/notification/Sender.java new file mode 100644 index 0000000..785cbdc --- /dev/null +++ b/src/main/java/com/wellmeet/notification/Sender.java @@ -0,0 +1,11 @@ +package com.wellmeet.notification; + +import com.wellmeet.notification.consumer.dto.NotificationMessage; +import com.wellmeet.notification.domain.NotificationChannel; + +public interface Sender { + + boolean isEnabled(NotificationChannel channel); + + void send(NotificationMessage message); +} diff --git a/src/main/java/com/wellmeet/notification/consumer/NotificationConsumer.java b/src/main/java/com/wellmeet/notification/consumer/NotificationConsumer.java new file mode 100644 index 0000000..998189f --- /dev/null +++ b/src/main/java/com/wellmeet/notification/consumer/NotificationConsumer.java @@ -0,0 +1,28 @@ +package com.wellmeet.notification.consumer; + +import com.wellmeet.notification.consumer.dto.NotificationMessage; +import com.wellmeet.notification.domain.NotificationEnabled; +import com.wellmeet.notification.repository.NotificationEnabledRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class NotificationConsumer { + + private final NotificationEnabledRepository notificationEnabledRepository; + private final NotificationSender notificationSender; + + @KafkaListener(topics = "notification", groupId = "notification-group") + public void consume(NotificationMessage message) { + List enables = notificationEnabledRepository.findByUserIdAndType( + message.getNotification().getRecipient(), + message.getNotification().getType() + ); + notificationSender.send(message, enables); + } +} diff --git a/src/main/java/com/wellmeet/notification/consumer/NotificationSender.java b/src/main/java/com/wellmeet/notification/consumer/NotificationSender.java new file mode 100644 index 0000000..17dff12 --- /dev/null +++ b/src/main/java/com/wellmeet/notification/consumer/NotificationSender.java @@ -0,0 +1,33 @@ +package com.wellmeet.notification.consumer; + +import com.wellmeet.exception.ErrorCode; +import com.wellmeet.exception.WellMeetNotificationException; +import com.wellmeet.notification.Sender; +import com.wellmeet.notification.consumer.dto.NotificationMessage; +import com.wellmeet.notification.domain.NotificationEnabled; +import com.wellmeet.notification.domain.NotificationHistory; +import com.wellmeet.notification.repository.NotificationHistoryRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class NotificationSender { + + private final List senders; + private final NotificationHistoryRepository notificationHistoryRepository; + + public void send(NotificationMessage message, List enables) { + NotificationHistory history = new NotificationHistory(message.getRecipient(), + message.getPayload().toString()); + for (NotificationEnabled enabled : enables) { + notificationHistoryRepository.save(history); + Sender sender = senders.stream() + .filter(low -> low.isEnabled(enabled.getChannel())) + .findFirst() + .orElseThrow(() -> new WellMeetNotificationException(ErrorCode.SENDER_NOT_FOUND)); + sender.send(message); + } + } +} diff --git a/src/main/java/com/wellmeet/notification/consumer/dto/MessageHeader.java b/src/main/java/com/wellmeet/notification/consumer/dto/MessageHeader.java new file mode 100644 index 0000000..2ac3a81 --- /dev/null +++ b/src/main/java/com/wellmeet/notification/consumer/dto/MessageHeader.java @@ -0,0 +1,14 @@ +package com.wellmeet.notification.consumer.dto; + +import java.time.LocalDateTime; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class MessageHeader { + + private String messageId; + private LocalDateTime timestamp; + private String source; +} diff --git a/src/main/java/com/wellmeet/notification/consumer/dto/NotificationInfo.java b/src/main/java/com/wellmeet/notification/consumer/dto/NotificationInfo.java new file mode 100644 index 0000000..e6a226f --- /dev/null +++ b/src/main/java/com/wellmeet/notification/consumer/dto/NotificationInfo.java @@ -0,0 +1,12 @@ +package com.wellmeet.notification.consumer.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class NotificationInfo { + + private NotificationType type; + private String recipient; +} diff --git a/src/main/java/com/wellmeet/notification/consumer/dto/NotificationMessage.java b/src/main/java/com/wellmeet/notification/consumer/dto/NotificationMessage.java new file mode 100644 index 0000000..e8086cc --- /dev/null +++ b/src/main/java/com/wellmeet/notification/consumer/dto/NotificationMessage.java @@ -0,0 +1,18 @@ +package com.wellmeet.notification.consumer.dto; + +import java.util.Map; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class NotificationMessage { + + private MessageHeader header; + private NotificationInfo notification; + private Map payload; + + public String getRecipient() { + return notification.getRecipient(); + } +} diff --git a/src/main/java/com/wellmeet/notification/consumer/dto/NotificationType.java b/src/main/java/com/wellmeet/notification/consumer/dto/NotificationType.java new file mode 100644 index 0000000..fd4f05f --- /dev/null +++ b/src/main/java/com/wellmeet/notification/consumer/dto/NotificationType.java @@ -0,0 +1,9 @@ +package com.wellmeet.notification.consumer.dto; + +import lombok.Getter; + +@Getter +public enum NotificationType { + + RESERVATION_CREATED +} diff --git a/src/main/java/com/wellmeet/notification/domain/NotificationChannel.java b/src/main/java/com/wellmeet/notification/domain/NotificationChannel.java new file mode 100644 index 0000000..6f47047 --- /dev/null +++ b/src/main/java/com/wellmeet/notification/domain/NotificationChannel.java @@ -0,0 +1,7 @@ +package com.wellmeet.notification.domain; + +public enum NotificationChannel { + + WEB_PUSH, + EMAIL, +} diff --git a/src/main/java/com/wellmeet/notification/domain/NotificationEnabled.java b/src/main/java/com/wellmeet/notification/domain/NotificationEnabled.java new file mode 100644 index 0000000..2000e5b --- /dev/null +++ b/src/main/java/com/wellmeet/notification/domain/NotificationEnabled.java @@ -0,0 +1,32 @@ +package com.wellmeet.notification.domain; + +import com.wellmeet.notification.consumer.dto.NotificationType; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class NotificationEnabled { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String userId; + + @Enumerated(value = EnumType.STRING) + private NotificationType type; + + @Enumerated(value = EnumType.STRING) + private NotificationChannel channel; + + private boolean enabled; +} diff --git a/src/main/java/com/wellmeet/notification/domain/NotificationHistory.java b/src/main/java/com/wellmeet/notification/domain/NotificationHistory.java new file mode 100644 index 0000000..b9a586e --- /dev/null +++ b/src/main/java/com/wellmeet/notification/domain/NotificationHistory.java @@ -0,0 +1,34 @@ +package com.wellmeet.notification.domain; + +import com.wellmeet.common.domain.BaseEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class NotificationHistory extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String userId; + private boolean readStatus; + private String contents; + + public NotificationHistory(String userId, String contents) { + this.userId = userId; + this.readStatus = false; + this.contents = contents; + } + + public void markAsRead() { + this.readStatus = true; + } +} diff --git a/src/main/java/com/wellmeet/notification/repository/NotificationEnabledRepository.java b/src/main/java/com/wellmeet/notification/repository/NotificationEnabledRepository.java new file mode 100644 index 0000000..26f26ee --- /dev/null +++ b/src/main/java/com/wellmeet/notification/repository/NotificationEnabledRepository.java @@ -0,0 +1,13 @@ +package com.wellmeet.notification.repository; + +import com.wellmeet.notification.consumer.dto.NotificationType; +import com.wellmeet.notification.domain.NotificationEnabled; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface NotificationEnabledRepository extends JpaRepository { + + List findByUserIdAndType(String userId, NotificationType type); +} diff --git a/src/main/java/com/wellmeet/notification/repository/NotificationHistoryRepository.java b/src/main/java/com/wellmeet/notification/repository/NotificationHistoryRepository.java new file mode 100644 index 0000000..d7681b2 --- /dev/null +++ b/src/main/java/com/wellmeet/notification/repository/NotificationHistoryRepository.java @@ -0,0 +1,9 @@ +package com.wellmeet.notification.repository; + +import com.wellmeet.notification.domain.NotificationHistory; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface NotificationHistoryRepository extends JpaRepository { +} diff --git a/src/main/java/com/wellmeet/webpush/WebPushController.java b/src/main/java/com/wellmeet/notification/webpush/WebPushController.java similarity index 83% rename from src/main/java/com/wellmeet/webpush/WebPushController.java rename to src/main/java/com/wellmeet/notification/webpush/WebPushController.java index 17b2fd4..e2678be 100644 --- a/src/main/java/com/wellmeet/webpush/WebPushController.java +++ b/src/main/java/com/wellmeet/notification/webpush/WebPushController.java @@ -1,9 +1,9 @@ -package com.wellmeet.webpush; +package com.wellmeet.notification.webpush; -import com.wellmeet.webpush.dto.SubscribeRequest; -import com.wellmeet.webpush.dto.SubscribeResponse; -import com.wellmeet.webpush.dto.TestPushRequest; -import com.wellmeet.webpush.dto.UnsubscribeRequest; +import com.wellmeet.notification.webpush.dto.SubscribeRequest; +import com.wellmeet.notification.webpush.dto.SubscribeResponse; +import com.wellmeet.notification.webpush.dto.TestPushRequest; +import com.wellmeet.notification.webpush.dto.UnsubscribeRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; diff --git a/src/main/java/com/wellmeet/webpush/WebPushService.java b/src/main/java/com/wellmeet/notification/webpush/WebPushService.java similarity index 75% rename from src/main/java/com/wellmeet/webpush/WebPushService.java rename to src/main/java/com/wellmeet/notification/webpush/WebPushService.java index 8784251..6785a06 100644 --- a/src/main/java/com/wellmeet/webpush/WebPushService.java +++ b/src/main/java/com/wellmeet/notification/webpush/WebPushService.java @@ -1,14 +1,14 @@ -package com.wellmeet.webpush; +package com.wellmeet.notification.webpush; import com.wellmeet.exception.ErrorCode; import com.wellmeet.exception.WellMeetNotificationException; -import com.wellmeet.webpush.domain.PushSubscription; -import com.wellmeet.webpush.dto.SubscribeRequest; -import com.wellmeet.webpush.dto.SubscribeResponse; -import com.wellmeet.webpush.dto.TestPushRequest; -import com.wellmeet.webpush.dto.UnsubscribeRequest; -import com.wellmeet.webpush.infrastructure.WebPushSender; -import com.wellmeet.webpush.repository.PushSubscriptionRepository; +import com.wellmeet.notification.webpush.domain.PushSubscription; +import com.wellmeet.notification.webpush.dto.SubscribeRequest; +import com.wellmeet.notification.webpush.dto.SubscribeResponse; +import com.wellmeet.notification.webpush.dto.TestPushRequest; +import com.wellmeet.notification.webpush.dto.UnsubscribeRequest; +import com.wellmeet.notification.webpush.infrastructure.WebPushSender; +import com.wellmeet.notification.webpush.repository.PushSubscriptionRepository; import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; @@ -20,7 +20,7 @@ public class WebPushService { private final PushSubscriptionRepository pushSubscriptionRepository; - private final WebPushSender pushService; + private final WebPushSender webPushSender; @Transactional public SubscribeResponse subscribe(String userId, SubscribeRequest request) { @@ -28,11 +28,13 @@ public SubscribeResponse subscribe(String userId, SubscribeRequest request) { Optional pushSubscription = existingSubscriptions.stream() .filter(subscription -> subscription.isSameEndpoint(request.endpoint())) .findAny(); + if (pushSubscription.isPresent()) { PushSubscription subscription = pushSubscription.get(); subscription.update(request.toDomain(userId)); return new SubscribeResponse(subscription); } + PushSubscription subscription = request.toDomain(userId); PushSubscription savedSubscription = pushSubscriptionRepository.save(subscription); return new SubscribeResponse(savedSubscription); @@ -44,7 +46,7 @@ public void sendTestPush(String userId, TestPushRequest request) { throw new WellMeetNotificationException(ErrorCode.SUBSCRIPTION_NOT_FOUND); } - subscriptions.forEach(subscription -> pushService.send(subscription, request)); + subscriptions.forEach(subscription -> webPushSender.send(subscription, request)); } @Transactional diff --git a/src/main/java/com/wellmeet/webpush/domain/PushSubscription.java b/src/main/java/com/wellmeet/notification/webpush/domain/PushSubscription.java similarity index 96% rename from src/main/java/com/wellmeet/webpush/domain/PushSubscription.java rename to src/main/java/com/wellmeet/notification/webpush/domain/PushSubscription.java index 488a428..9520142 100644 --- a/src/main/java/com/wellmeet/webpush/domain/PushSubscription.java +++ b/src/main/java/com/wellmeet/notification/webpush/domain/PushSubscription.java @@ -1,4 +1,4 @@ -package com.wellmeet.webpush.domain; +package com.wellmeet.notification.webpush.domain; import com.wellmeet.common.domain.BaseEntity; import jakarta.persistence.Entity; diff --git a/src/main/java/com/wellmeet/webpush/dto/SubscribeRequest.java b/src/main/java/com/wellmeet/notification/webpush/dto/SubscribeRequest.java similarity index 87% rename from src/main/java/com/wellmeet/webpush/dto/SubscribeRequest.java rename to src/main/java/com/wellmeet/notification/webpush/dto/SubscribeRequest.java index b1fb8fa..3281cc9 100644 --- a/src/main/java/com/wellmeet/webpush/dto/SubscribeRequest.java +++ b/src/main/java/com/wellmeet/notification/webpush/dto/SubscribeRequest.java @@ -1,6 +1,6 @@ -package com.wellmeet.webpush.dto; +package com.wellmeet.notification.webpush.dto; -import com.wellmeet.webpush.domain.PushSubscription; +import com.wellmeet.notification.webpush.domain.PushSubscription; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/com/wellmeet/webpush/dto/SubscribeResponse.java b/src/main/java/com/wellmeet/notification/webpush/dto/SubscribeResponse.java similarity index 80% rename from src/main/java/com/wellmeet/webpush/dto/SubscribeResponse.java rename to src/main/java/com/wellmeet/notification/webpush/dto/SubscribeResponse.java index 832cc1d..0202344 100644 --- a/src/main/java/com/wellmeet/webpush/dto/SubscribeResponse.java +++ b/src/main/java/com/wellmeet/notification/webpush/dto/SubscribeResponse.java @@ -1,6 +1,6 @@ -package com.wellmeet.webpush.dto; +package com.wellmeet.notification.webpush.dto; -import com.wellmeet.webpush.domain.PushSubscription; +import com.wellmeet.notification.webpush.domain.PushSubscription; public record SubscribeResponse( Long subscriptionId, diff --git a/src/main/java/com/wellmeet/webpush/dto/TestPushRequest.java b/src/main/java/com/wellmeet/notification/webpush/dto/TestPushRequest.java similarity index 85% rename from src/main/java/com/wellmeet/webpush/dto/TestPushRequest.java rename to src/main/java/com/wellmeet/notification/webpush/dto/TestPushRequest.java index 133edf1..d4bc6fa 100644 --- a/src/main/java/com/wellmeet/webpush/dto/TestPushRequest.java +++ b/src/main/java/com/wellmeet/notification/webpush/dto/TestPushRequest.java @@ -1,4 +1,4 @@ -package com.wellmeet.webpush.dto; +package com.wellmeet.notification.webpush.dto; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; diff --git a/src/main/java/com/wellmeet/webpush/dto/UnsubscribeRequest.java b/src/main/java/com/wellmeet/notification/webpush/dto/UnsubscribeRequest.java similarity index 73% rename from src/main/java/com/wellmeet/webpush/dto/UnsubscribeRequest.java rename to src/main/java/com/wellmeet/notification/webpush/dto/UnsubscribeRequest.java index eb8833e..a8cbbec 100644 --- a/src/main/java/com/wellmeet/webpush/dto/UnsubscribeRequest.java +++ b/src/main/java/com/wellmeet/notification/webpush/dto/UnsubscribeRequest.java @@ -1,4 +1,4 @@ -package com.wellmeet.webpush.dto; +package com.wellmeet.notification.webpush.dto; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/com/wellmeet/webpush/infrastructure/WebPushSender.java b/src/main/java/com/wellmeet/notification/webpush/infrastructure/WebPushSender.java similarity index 58% rename from src/main/java/com/wellmeet/webpush/infrastructure/WebPushSender.java rename to src/main/java/com/wellmeet/notification/webpush/infrastructure/WebPushSender.java index 2bee5e4..42c09f9 100644 --- a/src/main/java/com/wellmeet/webpush/infrastructure/WebPushSender.java +++ b/src/main/java/com/wellmeet/notification/webpush/infrastructure/WebPushSender.java @@ -1,16 +1,21 @@ -package com.wellmeet.webpush.infrastructure; +package com.wellmeet.notification.webpush.infrastructure; import com.fasterxml.jackson.databind.ObjectMapper; import com.wellmeet.config.VapidConfig; import com.wellmeet.exception.ErrorCode; import com.wellmeet.exception.WellMeetNotificationException; -import com.wellmeet.webpush.domain.PushSubscription; -import com.wellmeet.webpush.dto.TestPushRequest; +import com.wellmeet.notification.Sender; +import com.wellmeet.notification.consumer.dto.NotificationMessage; +import com.wellmeet.notification.domain.NotificationChannel; +import com.wellmeet.notification.webpush.domain.PushSubscription; +import com.wellmeet.notification.webpush.dto.TestPushRequest; +import com.wellmeet.notification.webpush.repository.PushSubscriptionRepository; import jakarta.annotation.PostConstruct; import java.io.IOException; import java.security.GeneralSecurityException; import java.security.Security; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.ExecutionException; import lombok.RequiredArgsConstructor; @@ -24,8 +29,9 @@ @Service @RequiredArgsConstructor -public class WebPushSender { +public class WebPushSender implements Sender { + private final PushSubscriptionRepository pushSubscriptionRepository; private final VapidConfig vapidConfig; private final ObjectMapper objectMapper = new ObjectMapper(); private PushService pushService; @@ -43,6 +49,43 @@ public void init() { } } + @Override + public boolean isEnabled(NotificationChannel channel) { + return NotificationChannel.WEB_PUSH == channel; + } + + @Override + public void send(NotificationMessage message) { + List subscriptions = pushSubscriptionRepository.findByUserId( + message.getNotification().getRecipient()); + if (subscriptions.isEmpty()) { + throw new WellMeetNotificationException(ErrorCode.SUBSCRIPTION_NOT_FOUND); + } + + subscriptions.forEach(subscription -> { + Keys keys = new Keys(subscription.getP256dh(), subscription.getAuth()); + Subscription sub = new Subscription(subscription.getEndpoint(), keys); + Map notificationPayload = getNotificationPayload(message); + webPushSend(notificationPayload, sub); + }); + } + + private Map getNotificationPayload(NotificationMessage message) { + Map notificationPayload = new HashMap<>(); + notificationPayload.put("title", "WellMeet 알림"); + notificationPayload.put("body", message.getPayload()); + notificationPayload.put("icon", "/icon-192x192.png"); + notificationPayload.put("badge", "/badge-72x72.png"); + notificationPayload.put("vibrate", new int[]{100, 50, 100}); + notificationPayload.put("requireInteraction", false); + + Map defaultData = new HashMap<>(); + defaultData.put("url", "/notifications"); + defaultData.put("timestamp", System.currentTimeMillis()); + notificationPayload.put("data", defaultData); + return notificationPayload; + } + public void send(PushSubscription subscription, TestPushRequest request) { Keys keys = new Keys(subscription.getP256dh(), subscription.getAuth()); Subscription sub = new Subscription(subscription.getEndpoint(), keys); diff --git a/src/main/java/com/wellmeet/webpush/repository/PushSubscriptionRepository.java b/src/main/java/com/wellmeet/notification/webpush/repository/PushSubscriptionRepository.java similarity index 78% rename from src/main/java/com/wellmeet/webpush/repository/PushSubscriptionRepository.java rename to src/main/java/com/wellmeet/notification/webpush/repository/PushSubscriptionRepository.java index 5b60aa9..51e31bb 100644 --- a/src/main/java/com/wellmeet/webpush/repository/PushSubscriptionRepository.java +++ b/src/main/java/com/wellmeet/notification/webpush/repository/PushSubscriptionRepository.java @@ -1,6 +1,6 @@ -package com.wellmeet.webpush.repository; +package com.wellmeet.notification.webpush.repository; -import com.wellmeet.webpush.domain.PushSubscription; +import com.wellmeet.notification.webpush.domain.PushSubscription; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..023f46d --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,52 @@ +spring: + application: + name: wellmeet-notification + config: + import: classpath:dev-secret.yml + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://${secret.datasource.url}:${secret.datasource.port}/${secret.datasource.database} + username: ${secret.datasource.username} + password: ${secret.datasource.password} + jpa: + show-sql: true + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.MySQLDialect + sql: + init: + mode: always + schema-locations: classpath:schema.sql + platform: mysql + encoding: UTF-8 + mail: + host: smtp.gmail.com + port: 587 + username: ${secret.mail.username} + password: ${secret.mail.password} + properties: + mail: + smtp: + auth: true + starttls: + enable: true + required: true + timeout: 5000 + connectiontimeout: 5000 + writetimeout: 5000 + kafka: + bootstrap-servers: ${secret.kafka.bootstrap-servers} + consumer: + group-id: ${secret.kafka.consumer.group-id} + auto-offset-reset: ${secret.kafka.consumer.auto-offset-reset} + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer + +cors: + origin: http://localhost:5173 + +vapid: + public-key: ${secret.vapid.public-key} + private-key: ${secret.vapid.private-key} + subject: ${secret.vapid.subject} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 7a9b59c..7c29757 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -20,6 +20,28 @@ spring: schema-locations: classpath:schema.sql platform: mysql encoding: UTF-8 + mail: + host: smtp.gmail.com + port: 587 + username: ${secret.mail.username:test@example.com} + password: ${secret.mail.password:testpassword} + properties: + mail: + smtp: + auth: true + starttls: + enable: true + required: true + timeout: 5000 + connectiontimeout: 5000 + writetimeout: 5000 + kafka: + bootstrap-servers: localhost:9092 + consumer: + group-id: notification-group + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer cors: origin: http://localhost:5173 @@ -28,3 +50,5 @@ vapid: public-key: ${secret.vapid.public-key} private-key: ${secret.vapid.private-key} subject: ${secret.vapid.subject} +server: + port: 8082 diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 4b6fd43..a5e49d5 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -20,6 +20,13 @@ spring: schema-locations: classpath:schema.sql platform: mysql encoding: UTF-8 + kafka: + bootstrap-servers: localhost:9092 + consumer: + group-id: notification-group + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer cors: origin: http://localhost:5173 diff --git a/src/main/resources/dev-secret.yml b/src/main/resources/dev-secret.yml new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/local-secret.yml b/src/main/resources/local-secret.yml new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index c870e07..29757b2 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -1,10 +1,33 @@ CREATE TABLE IF NOT EXISTS push_subscription ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id VARCHAR(255) NOT NULL, - endpoint VARCHAR(255) NOT NULL, - p256dh VARCHAR(100) NOT NULL, - auth VARCHAR(100) NOT NULL, + endpoint VARCHAR(500) NOT NULL, + p256dh VARCHAR(255) NOT NULL, + auth VARCHAR(255) NOT NULL, active BOOLEAN NOT NULL DEFAULT TRUE, created_at DATETIME(6) NOT NULL, - updated_at DATETIME(6) NOT NULL + updated_at DATETIME(6) NOT NULL, + INDEX idx_user_id (user_id), + INDEX idx_endpoint (endpoint) +); + +CREATE TABLE IF NOT EXISTS notification_enabled ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id VARCHAR(255) NOT NULL, + type VARCHAR(50) NOT NULL, + channel VARCHAR(50) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + INDEX idx_user_id (user_id), + INDEX idx_type_channel (type, channel) +); + +CREATE TABLE IF NOT EXISTS notification_history ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id VARCHAR(255) NOT NULL, + read_status BOOLEAN NOT NULL DEFAULT FALSE, + contents TEXT, + created_at DATETIME(6), + updated_at DATETIME(6), + INDEX idx_user_id (user_id), + INDEX idx_created_at (created_at) ); diff --git a/src/test/java/com/wellmeet/BaseControllerTest.java b/src/test/java/com/wellmeet/BaseControllerTest.java index caa2ebd..e669f4d 100644 --- a/src/test/java/com/wellmeet/BaseControllerTest.java +++ b/src/test/java/com/wellmeet/BaseControllerTest.java @@ -1,6 +1,6 @@ package com.wellmeet; -import com.wellmeet.webpush.repository.PushSubscriptionRepository; +import com.wellmeet.notification.webpush.repository.PushSubscriptionRepository; import io.restassured.RestAssured; import io.restassured.builder.RequestSpecBuilder; import io.restassured.filter.log.RequestLoggingFilter; diff --git a/src/test/java/com/wellmeet/BaseServiceTest.java b/src/test/java/com/wellmeet/BaseServiceTest.java index d1813aa..dbe7792 100644 --- a/src/test/java/com/wellmeet/BaseServiceTest.java +++ b/src/test/java/com/wellmeet/BaseServiceTest.java @@ -1,6 +1,6 @@ package com.wellmeet; -import com.wellmeet.webpush.repository.PushSubscriptionRepository; +import com.wellmeet.notification.webpush.repository.PushSubscriptionRepository; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; diff --git a/src/test/java/com/wellmeet/DataBaseCleaner.java b/src/test/java/com/wellmeet/DataBaseCleaner.java index b219456..071bf3d 100644 --- a/src/test/java/com/wellmeet/DataBaseCleaner.java +++ b/src/test/java/com/wellmeet/DataBaseCleaner.java @@ -40,7 +40,7 @@ private List findTableNames(EntityManager em) { String tableNameSelectQuery = """ SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES - WHERE TABLE_SCHEMA = 'test' + WHERE TABLE_SCHEMA = 'wellmeet_noti_test' AND TABLE_TYPE = 'BASE TABLE' """; diff --git a/src/test/java/com/wellmeet/webpush/WebPushControllerTest.java b/src/test/java/com/wellmeet/webpush/WebPushControllerTest.java index 8091f6c..69fbfef 100644 --- a/src/test/java/com/wellmeet/webpush/WebPushControllerTest.java +++ b/src/test/java/com/wellmeet/webpush/WebPushControllerTest.java @@ -5,11 +5,11 @@ import com.wellmeet.BaseControllerTest; import com.wellmeet.fixture.NullAndEmptyAndBlankSource; -import com.wellmeet.webpush.domain.PushSubscription; -import com.wellmeet.webpush.dto.SubscribeRequest; -import com.wellmeet.webpush.dto.SubscribeResponse; -import com.wellmeet.webpush.dto.TestPushRequest; -import com.wellmeet.webpush.dto.UnsubscribeRequest; +import com.wellmeet.notification.webpush.domain.PushSubscription; +import com.wellmeet.notification.webpush.dto.SubscribeRequest; +import com.wellmeet.notification.webpush.dto.SubscribeResponse; +import com.wellmeet.notification.webpush.dto.TestPushRequest; +import com.wellmeet.notification.webpush.dto.UnsubscribeRequest; import io.restassured.http.ContentType; import java.util.Map; import org.junit.jupiter.api.Nested; diff --git a/src/test/java/com/wellmeet/webpush/WebPushServiceTest.java b/src/test/java/com/wellmeet/webpush/WebPushServiceTest.java index 5f181d4..8a94b96 100644 --- a/src/test/java/com/wellmeet/webpush/WebPushServiceTest.java +++ b/src/test/java/com/wellmeet/webpush/WebPushServiceTest.java @@ -7,10 +7,11 @@ import com.wellmeet.BaseServiceTest; import com.wellmeet.exception.ErrorCode; import com.wellmeet.exception.WellMeetNotificationException; -import com.wellmeet.webpush.domain.PushSubscription; -import com.wellmeet.webpush.dto.SubscribeRequest; -import com.wellmeet.webpush.dto.SubscribeResponse; -import com.wellmeet.webpush.dto.UnsubscribeRequest; +import com.wellmeet.notification.webpush.WebPushService; +import com.wellmeet.notification.webpush.domain.PushSubscription; +import com.wellmeet.notification.webpush.dto.SubscribeRequest; +import com.wellmeet.notification.webpush.dto.SubscribeResponse; +import com.wellmeet.notification.webpush.dto.UnsubscribeRequest; import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.Nested; @@ -42,7 +43,7 @@ class Subscribe { } @Test - void 동일한_유저_아이디와_엔드포인트로_구독하면_기존_구독을_반환한다() { + void 동일한_유저_아이디와_엔드포인트로_구독하면_기존_구독을_업데이트한다() { String userId = UUID.randomUUID().toString(); String endpoint = "endpoint"; PushSubscription pushSubscription = new PushSubscription(userId, endpoint, "p256dh", "auth"); @@ -55,8 +56,8 @@ class Subscribe { assertAll( () -> assertThat(subscription.getUserId()).isEqualTo(userId), () -> assertThat(subscription.getEndpoint()).isEqualTo(endpoint), - () -> assertThat(subscription.getP256dh()).isEqualTo(pushSubscription.getP256dh()), - () -> assertThat(subscription.getAuth()).isEqualTo(pushSubscription.getAuth()) + () -> assertThat(subscription.getP256dh()).isEqualTo(request.p256dh()), + () -> assertThat(subscription.getAuth()).isEqualTo(request.auth()) ); } }