diff --git a/.github/workflows/notification-application-ci-cd-flow.yml b/.github/workflows/notification-application-ci-cd-flow.yml index 0d7d33f42..3e28a936d 100644 --- a/.github/workflows/notification-application-ci-cd-flow.yml +++ b/.github/workflows/notification-application-ci-cd-flow.yml @@ -117,14 +117,14 @@ jobs: - name: Add Github Actions IP to Security group run: | - aws ec2 authorize-security-group-ingress --group-id ${{ secrets.AWS_EC2_NOTIFICATION_SG_ID }} --group-name ${{secrets.AWS_EC2_NOTIFICATION_SG_NAME}} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 + aws ec2 authorize-security-group-ingress --group-id ${{ secrets.AWS_EC2_CORE_SG_ID }} --group-name ${{secrets.AWS_EC2_CORE_SG_NAME}} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 - name: Connect ec2 and Run Docker Container uses: appleboy/ssh-action@v0.1.6 env: AWS_ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} with: - host: ${{ secrets.SSH_NOTIFICATION_HOST }} + host: ${{ secrets.SSH_CORE_HOST }} username: ${{ secrets.SSH_USERNAME }} key: ${{ secrets.SSH_CORE_PRIVATE_KEY }} port: ${{ secrets.SSH_PORT }} @@ -134,12 +134,12 @@ jobs: aws ecr get-login-password --region ${{ secrets.AWS_REGION }} | docker login --username ${{ secrets.AWS_DOCKER_USER }} --password-stdin ${{ secrets.AWS_USER_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com docker image prune -f docker pull ${{ steps.meta.outputs.tags }} - docker run -d -p 8080:8080 --name notification --network test_backend ${{ steps.meta.outputs.tags }} + docker run -d -p 8081:8081 -e ENVIRONMENT=dev --name notification --network test_backend ${{ steps.meta.outputs.tags }} - name: Remove Github Actions IP from security group if: always() run: | - aws ec2 revoke-security-group-ingress --group-id ${{ secrets.AWS_EC2_NOTIFICATION_SG_ID }} --group-name ${{secrets.AWS_EC2_NOTIFICATION_SG_NAME}} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 + aws ec2 revoke-security-group-ingress --group-id ${{ secrets.AWS_EC2_CORE_SG_ID }} --group-name ${{secrets.AWS_EC2_CORE_SG_NAME}} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 - uses: sarisia/actions-status-discord@v1 if: success() diff --git a/backend/notification/Dockerfile b/backend/notification/Dockerfile index 5da51791a..f3fbf9a5a 100644 --- a/backend/notification/Dockerfile +++ b/backend/notification/Dockerfile @@ -18,4 +18,4 @@ COPY --from=build ${EXTRACTED}/spring-boot-loader/ ./ COPY --from=build ${EXTRACTED}/snapshot-dependencies/ ./ COPY --from=build ${EXTRACTED}/application/ ./ -ENTRYPOINT ["java","-Dspring.profiles.active=dev","org.springframework.boot.loader.JarLauncher"] \ No newline at end of file +ENTRYPOINT java -Dspring.profiles.active=$ENVIRONMENT org.springframework.boot.loader.JarLauncher \ No newline at end of file diff --git a/backend/notification/build.gradle b/backend/notification/build.gradle index db8dfd2df..3063bef3b 100644 --- a/backend/notification/build.gradle +++ b/backend/notification/build.gradle @@ -30,9 +30,8 @@ dependencies { //s3 implementation 'software.amazon.awssdk:s3:2.21.46' - //flyway - implementation 'org.flywaydb:flyway-core:9.5.1' - implementation 'org.flywaydb:flyway-mysql:9.5.1' + //RabbitMQ + implementation 'org.springframework.boot:spring-boot-starter-amqp' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' diff --git a/backend/notification/src/main/java/site/timecapsulearchive/notification/api/NotificationController.java b/backend/notification/src/main/java/site/timecapsulearchive/notification/api/NotificationController.java deleted file mode 100644 index 586b54a93..000000000 --- a/backend/notification/src/main/java/site/timecapsulearchive/notification/api/NotificationController.java +++ /dev/null @@ -1,31 +0,0 @@ -package site.timecapsulearchive.notification.api; - -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; -import site.timecapsulearchive.notification.data.mapper.NotificationMapper; -import site.timecapsulearchive.notification.data.request.CapsuleSkinNotificationSendRequest; -import site.timecapsulearchive.notification.service.NotificationService; - -@RestController -@RequestMapping("/notification") -@RequiredArgsConstructor -public class NotificationController { - - private final NotificationService notificationService; - private final NotificationMapper notificationMapper; - - @PostMapping("/capsule_skin/send") - public ResponseEntity sendCapsuleSkinNotification( - @Valid @RequestBody CapsuleSkinNotificationSendRequest request - ) { - notificationService.sendCapsuleSkinAlarm( - notificationMapper.capsuleSkinNotificationRequestToDto(request)); - - return ResponseEntity.noContent().build(); - } -} diff --git a/backend/notification/src/main/java/site/timecapsulearchive/notification/data/dto/CapsuleSkinNotificationSendDto.java b/backend/notification/src/main/java/site/timecapsulearchive/notification/data/dto/CapsuleSkinNotificationSendDto.java index ad188271c..c7bf0a346 100644 --- a/backend/notification/src/main/java/site/timecapsulearchive/notification/data/dto/CapsuleSkinNotificationSendDto.java +++ b/backend/notification/src/main/java/site/timecapsulearchive/notification/data/dto/CapsuleSkinNotificationSendDto.java @@ -1,6 +1,8 @@ package site.timecapsulearchive.notification.data.dto; import lombok.Builder; +import site.timecapsulearchive.notification.entity.Notification; +import site.timecapsulearchive.notification.entity.NotificationCategory; import site.timecapsulearchive.notification.entity.NotificationStatus; @Builder @@ -14,4 +16,14 @@ public record CapsuleSkinNotificationSendDto( String skinUrl ) { + public Notification toNotification(NotificationCategory notificationCategory) { + return Notification.builder() + .memberId(memberId) + .title(title) + .text(text) + .imageUrl(skinUrl) + .notificationCategory(notificationCategory) + .status(status) + .build(); + } } diff --git a/backend/notification/src/main/java/site/timecapsulearchive/notification/data/dto/FriendNotificationDto.java b/backend/notification/src/main/java/site/timecapsulearchive/notification/data/dto/FriendNotificationDto.java new file mode 100644 index 000000000..39642f163 --- /dev/null +++ b/backend/notification/src/main/java/site/timecapsulearchive/notification/data/dto/FriendNotificationDto.java @@ -0,0 +1,33 @@ +package site.timecapsulearchive.notification.data.dto; + +import java.util.Objects; +import lombok.Builder; +import site.timecapsulearchive.notification.entity.Notification; +import site.timecapsulearchive.notification.entity.NotificationCategory; +import site.timecapsulearchive.notification.entity.NotificationStatus; + +@Builder +public record FriendNotificationDto( + Long targetId, + NotificationStatus notificationStatus, + String text, + String title +) { + + public FriendNotificationDto { + Objects.requireNonNull(targetId); + Objects.requireNonNull(notificationStatus); + Objects.requireNonNull(text); + Objects.requireNonNull(title); + } + + public Notification toNotification(final NotificationCategory notificationCategory) { + return Notification.builder() + .memberId(targetId) + .title(title) + .text(text) + .status(notificationStatus) + .notificationCategory(notificationCategory) + .build(); + } +} diff --git a/backend/notification/src/main/java/site/timecapsulearchive/notification/data/dto/FriendNotificationsDto.java b/backend/notification/src/main/java/site/timecapsulearchive/notification/data/dto/FriendNotificationsDto.java new file mode 100644 index 000000000..7d56e91b2 --- /dev/null +++ b/backend/notification/src/main/java/site/timecapsulearchive/notification/data/dto/FriendNotificationsDto.java @@ -0,0 +1,29 @@ +package site.timecapsulearchive.notification.data.dto; + +import java.util.List; +import site.timecapsulearchive.notification.entity.Notification; +import site.timecapsulearchive.notification.entity.NotificationCategory; +import site.timecapsulearchive.notification.entity.NotificationStatus; + +public record FriendNotificationsDto( + String profileUrl, + NotificationStatus notificationStatus, + String title, + String text, + List targetIds +) { + + public List toNotification(NotificationCategory notificationCategory) { + return targetIds.stream() + .map(id -> Notification.builder() + .memberId(id) + .notificationCategory(notificationCategory) + .status(notificationStatus) + .imageUrl(profileUrl) + .title(title) + .text(text) + .build() + ) + .toList(); + } +} diff --git a/backend/notification/src/main/java/site/timecapsulearchive/notification/data/dto/GroupInviteNotificationDto.java b/backend/notification/src/main/java/site/timecapsulearchive/notification/data/dto/GroupInviteNotificationDto.java new file mode 100644 index 000000000..b48c00f02 --- /dev/null +++ b/backend/notification/src/main/java/site/timecapsulearchive/notification/data/dto/GroupInviteNotificationDto.java @@ -0,0 +1,28 @@ +package site.timecapsulearchive.notification.data.dto; + +import java.util.List; +import site.timecapsulearchive.notification.entity.Notification; +import site.timecapsulearchive.notification.entity.NotificationCategory; +import site.timecapsulearchive.notification.entity.NotificationStatus; + +public record GroupInviteNotificationDto ( + NotificationStatus notificationStatus, + String groupProfileUrl, + String title, + String text, + List targetIds +){ + public List toNotification(NotificationCategory notificationCategory) { + return targetIds.stream() + .map(id -> Notification.builder() + .notificationCategory(notificationCategory) + .memberId(id) + .status(notificationStatus) + .imageUrl(groupProfileUrl) + .title(title) + .text(text) + .build() + ) + .toList(); + } +} diff --git a/backend/notification/src/main/java/site/timecapsulearchive/notification/data/mapper/NotificationMapper.java b/backend/notification/src/main/java/site/timecapsulearchive/notification/data/mapper/NotificationMapper.java deleted file mode 100644 index 80f880b89..000000000 --- a/backend/notification/src/main/java/site/timecapsulearchive/notification/data/mapper/NotificationMapper.java +++ /dev/null @@ -1,35 +0,0 @@ -package site.timecapsulearchive.notification.data.mapper; - -import org.springframework.stereotype.Component; -import site.timecapsulearchive.notification.data.dto.CapsuleSkinNotificationSendDto; -import site.timecapsulearchive.notification.data.request.CapsuleSkinNotificationSendRequest; -import site.timecapsulearchive.notification.entity.Notification; -import site.timecapsulearchive.notification.entity.NotificationCategory; - -@Component -public class NotificationMapper { - - public CapsuleSkinNotificationSendDto capsuleSkinNotificationRequestToDto( - CapsuleSkinNotificationSendRequest request) { - return CapsuleSkinNotificationSendDto.builder() - .memberId(request.memberId()) - .status(request.status()) - .skinName(request.skinName()) - .title(request.title()) - .text(request.text()) - .skinUrl(request.skinUrl()) - .build(); - } - - public Notification capsuleSkinNotificationSendDtoToEntity(CapsuleSkinNotificationSendDto dto, - NotificationCategory notificationCategory) { - return Notification.builder() - .memberId(dto.memberId()) - .title(dto.title()) - .text(dto.text()) - .imageUrl(dto.skinUrl()) - .notificationCategory(notificationCategory) - .status(dto.status()) - .build(); - } -} diff --git a/backend/notification/src/main/java/site/timecapsulearchive/notification/data/request/CapsuleSkinNotificationSendRequest.java b/backend/notification/src/main/java/site/timecapsulearchive/notification/data/request/CapsuleSkinNotificationSendRequest.java index b7521ee92..cbad10cd3 100644 --- a/backend/notification/src/main/java/site/timecapsulearchive/notification/data/request/CapsuleSkinNotificationSendRequest.java +++ b/backend/notification/src/main/java/site/timecapsulearchive/notification/data/request/CapsuleSkinNotificationSendRequest.java @@ -6,22 +6,22 @@ public record CapsuleSkinNotificationSendRequest( - @NotNull + @NotNull(message = "멤버 아이디는 필수 입니다.") Long memberId, - @NotNull + @NotNull(message = "알림 상태는 필수 입니다.") NotificationStatus status, - @NotBlank + @NotBlank(message = "스킨 이름은 필수 입니다.") String skinName, - @NotBlank + @NotBlank(message = "알림 내용은 필수 입니다.") String title, - @NotBlank + @NotBlank(message = "알림 내용은 필수 입니다.") String text, - @NotBlank + @NotBlank(message = "스킨 URL은 필수 입니다.") String skinUrl ) { diff --git a/backend/notification/src/main/java/site/timecapsulearchive/notification/data/request/FriendNotificationRequest.java b/backend/notification/src/main/java/site/timecapsulearchive/notification/data/request/FriendNotificationRequest.java new file mode 100644 index 000000000..44956bcd7 --- /dev/null +++ b/backend/notification/src/main/java/site/timecapsulearchive/notification/data/request/FriendNotificationRequest.java @@ -0,0 +1,22 @@ +package site.timecapsulearchive.notification.data.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import site.timecapsulearchive.notification.entity.NotificationStatus; + +public record FriendNotificationRequest( + + @NotNull(message = "멤버 아이디는 필수 입니다.") + Long targetId, + + @NotNull(message = "알림 상태는 필수 입니다.") + NotificationStatus status, + + @NotBlank(message = "알림 제목은 필수 입니다.") + String text, + + @NotBlank(message = "알림 내용은 필수 입니다.") + String title +) { + +} diff --git a/backend/notification/src/main/java/site/timecapsulearchive/notification/entity/CategoryName.java b/backend/notification/src/main/java/site/timecapsulearchive/notification/entity/CategoryName.java index 29f8fd14e..d9e9b0017 100644 --- a/backend/notification/src/main/java/site/timecapsulearchive/notification/entity/CategoryName.java +++ b/backend/notification/src/main/java/site/timecapsulearchive/notification/entity/CategoryName.java @@ -1,5 +1,5 @@ package site.timecapsulearchive.notification.entity; public enum CategoryName { - CAPSULE_SKIN + CAPSULE_SKIN, FRIEND_REQUEST, FRIEND_ACCEPT, GROUP_INVITE } diff --git a/backend/notification/src/main/java/site/timecapsulearchive/notification/entity/Notification.java b/backend/notification/src/main/java/site/timecapsulearchive/notification/entity/Notification.java index bd07d8d19..23dfff2fb 100644 --- a/backend/notification/src/main/java/site/timecapsulearchive/notification/entity/Notification.java +++ b/backend/notification/src/main/java/site/timecapsulearchive/notification/entity/Notification.java @@ -55,6 +55,7 @@ private Notification(String title, String text, String imageUrl, Long memberId, this.text = text; this.imageUrl = imageUrl; this.memberId = memberId; + this.status = status; this.notificationCategory = notificationCategory; } } diff --git a/backend/notification/src/main/java/site/timecapsulearchive/notification/global/config/RabbitmqComponentConstants.java b/backend/notification/src/main/java/site/timecapsulearchive/notification/global/config/RabbitmqComponentConstants.java new file mode 100644 index 000000000..babc7b33e --- /dev/null +++ b/backend/notification/src/main/java/site/timecapsulearchive/notification/global/config/RabbitmqComponentConstants.java @@ -0,0 +1,35 @@ +package site.timecapsulearchive.notification.global.config; + +import java.util.Arrays; +import lombok.Getter; + +@Getter +public enum RabbitmqComponentConstants { + + CAPSULE_SKIN_QUEUE("notification.createCapsuleSkin.queue", "fail.notification.createCapsuleSkin.queue"), + CAPSULE_SKIN_EXCHANGE("notification.createCapsuleSkin.exchange", "fail.notification.createCapsuleSkin.exchange"), + FRIEND_REQUEST_NOTIFICATION_QUEUE("notification.friendRequest.queue", "fail.notification.friendRequest.queue"), + FRIEND_REQUEST_NOTIFICATION_EXCHANGE("notification.friendRequest.exchange", "fail.notification.friendRequest.exchange"), + FRIEND_REQUESTS_NOTIFICATION_QUEUE("notification.friendRequests.queue", "fail.notification.friendRequests.queue"), + FRIEND_REQUESTS_NOTIFICATION_EXCHANGE("notification.friendRequests.exchange", "fail.notification.friendRequests.exchange"), + FRIEND_ACCEPT_NOTIFICATION_QUEUE("notification.friendAccept.queue", "fail.notification.friendAccept.queue"), + FRIEND_ACCEPT_NOTIFICATION_EXCHANGE("notification.friendAccept.exchange", "fail.notification.friendAccept.exchange"), + GROUP_INVITE_QUEUE("notification.groupInvite.queue", "fail.notification.groupInvite.queue"), + GROUP_INVITE_EXCHANGE("notification.groupInvite.exchange", "fail.notification.groupInvite.exchange"); + + private final String successComponent; + private final String failComponent; + + RabbitmqComponentConstants(String successComponent, String failComponent) { + this.successComponent = successComponent; + this.failComponent = failComponent; + } + + public static String getFailComponent(String successComponent) { + return Arrays.stream(RabbitmqComponentConstants.values()) + .filter(constants -> constants.getSuccessComponent().equals(successComponent)) + .map(RabbitmqComponentConstants::getFailComponent) + .toList() + .get(0); + } +} \ No newline at end of file diff --git a/backend/notification/src/main/java/site/timecapsulearchive/notification/global/config/RabbitmqConfig.java b/backend/notification/src/main/java/site/timecapsulearchive/notification/global/config/RabbitmqConfig.java new file mode 100644 index 000000000..f389a3f37 --- /dev/null +++ b/backend/notification/src/main/java/site/timecapsulearchive/notification/global/config/RabbitmqConfig.java @@ -0,0 +1,126 @@ +package site.timecapsulearchive.notification.global.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.amqp.core.Binding; +import org.springframework.amqp.core.BindingBuilder; +import org.springframework.amqp.core.DirectExchange; +import org.springframework.amqp.core.Queue; +import org.springframework.amqp.rabbit.annotation.EnableRabbit; +import org.springframework.amqp.rabbit.connection.CachingConnectionFactory; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableRabbit +@RequiredArgsConstructor +public class RabbitmqConfig { + + private final RabbitmqProperties rabbitmqProperties; + + @Bean + public Queue capsuleSkinQueue() { + return new Queue(RabbitmqComponentConstants.CAPSULE_SKIN_QUEUE.getSuccessComponent(), true); + } + + @Bean + public DirectExchange capsuleSkinExchange() { + return new DirectExchange( + RabbitmqComponentConstants.CAPSULE_SKIN_EXCHANGE.getSuccessComponent()); + } + + @Bean + public Binding capsuleSkinBinding() { + return BindingBuilder + .bind(capsuleSkinQueue()) + .to(capsuleSkinExchange()) + .withQueueName(); + } + + @Bean + public Queue groupInviteQueue() { + return new Queue(RabbitmqComponentConstants.GROUP_INVITE_QUEUE.getSuccessComponent(), true); + } + + @Bean + public DirectExchange groupInviteExchange() { + return new DirectExchange( + RabbitmqComponentConstants.GROUP_INVITE_EXCHANGE.getSuccessComponent()); + } + + @Bean + public Binding groupInviteBinding() { + return BindingBuilder + .bind(groupInviteQueue()) + .to(groupInviteExchange()) + .withQueueName(); + } + + @Bean + public Queue friendRequestQueue() { + return new Queue( + RabbitmqComponentConstants.FRIEND_REQUEST_NOTIFICATION_QUEUE.getSuccessComponent(), + true); + } + + @Bean + public DirectExchange friendRequestExchange() { + return new DirectExchange( + RabbitmqComponentConstants.FRIEND_REQUEST_NOTIFICATION_EXCHANGE.getSuccessComponent()); + } + + @Bean + public Binding friendRequestBinding() { + return BindingBuilder + .bind(friendRequestQueue()) + .to(friendRequestExchange()) + .withQueueName(); + } + + @Bean + public Queue friendAcceptQueue() { + return new Queue( + RabbitmqComponentConstants.FRIEND_ACCEPT_NOTIFICATION_QUEUE.getSuccessComponent(), + true); + } + + @Bean + public DirectExchange friendAcceptExchange() { + return new DirectExchange( + RabbitmqComponentConstants.FRIEND_ACCEPT_NOTIFICATION_EXCHANGE.getSuccessComponent()); + } + + @Bean + public Binding friendAcceptBinding() { + return BindingBuilder + .bind(friendAcceptQueue()) + .to(friendAcceptExchange()) + .withQueueName(); + } + + @Bean + public RabbitTemplate rabbitTemplate() { + RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory()); + rabbitTemplate.setMessageConverter(jsonMessageConverter()); + + return rabbitTemplate; + } + + @Bean + public Jackson2JsonMessageConverter jsonMessageConverter() { + return new Jackson2JsonMessageConverter(); + } + + @Bean + public CachingConnectionFactory connectionFactory() { + CachingConnectionFactory connectionFactory = new CachingConnectionFactory(); + + connectionFactory.setHost(rabbitmqProperties.host()); + connectionFactory.setPort(rabbitmqProperties.port()); + connectionFactory.setUsername(rabbitmqProperties.userName()); + connectionFactory.setPassword(rabbitmqProperties.password()); + connectionFactory.setVirtualHost(rabbitmqProperties.virtualHost()); + return connectionFactory; + } +} \ No newline at end of file diff --git a/backend/notification/src/main/java/site/timecapsulearchive/notification/global/config/RabbitmqFailComponentConfig.java b/backend/notification/src/main/java/site/timecapsulearchive/notification/global/config/RabbitmqFailComponentConfig.java new file mode 100644 index 000000000..e1f1c986b --- /dev/null +++ b/backend/notification/src/main/java/site/timecapsulearchive/notification/global/config/RabbitmqFailComponentConfig.java @@ -0,0 +1,88 @@ +package site.timecapsulearchive.notification.global.config; + +import org.springframework.amqp.core.Binding; +import org.springframework.amqp.core.BindingBuilder; +import org.springframework.amqp.core.DirectExchange; +import org.springframework.amqp.core.Queue; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RabbitmqFailComponentConfig { + + @Bean + public Queue capsuleSkinFailQueue() { + return new Queue(RabbitmqComponentConstants.CAPSULE_SKIN_QUEUE.getFailComponent(), true); + } + + @Bean + public DirectExchange capsuleSkinFailExchange() { + return new DirectExchange(RabbitmqComponentConstants.CAPSULE_SKIN_EXCHANGE.getFailComponent()); + } + + @Bean + public Binding capsuleSkinFailBinding() { + return BindingBuilder + .bind(capsuleSkinFailQueue()) + .to(capsuleSkinFailExchange()) + .withQueueName(); + } + + @Bean + public Queue groupInviteFailQueue() { + return new Queue(RabbitmqComponentConstants.GROUP_INVITE_QUEUE.getFailComponent(), true); + } + + @Bean + public DirectExchange groupInviteFailExchange() { + return new DirectExchange(RabbitmqComponentConstants.GROUP_INVITE_EXCHANGE.getFailComponent()); + } + + @Bean + public Binding groupInviteFailBinding() { + return BindingBuilder + .bind(groupInviteFailQueue()) + .to(groupInviteFailExchange()) + .withQueueName(); + } + + @Bean + public Queue friendRequestFailQueue() { + return new Queue(RabbitmqComponentConstants.FRIEND_REQUEST_NOTIFICATION_QUEUE.getFailComponent(), + true); + } + + @Bean + public DirectExchange friendRequestFailExchange() { + return new DirectExchange( + RabbitmqComponentConstants.FRIEND_REQUEST_NOTIFICATION_EXCHANGE.getFailComponent()); + } + + @Bean + public Binding friendRequestFailBinding() { + return BindingBuilder + .bind(friendRequestFailQueue()) + .to(friendRequestFailExchange()) + .withQueueName(); + } + + @Bean + public Queue friendAcceptFailQueue() { + return new Queue(RabbitmqComponentConstants.FRIEND_ACCEPT_NOTIFICATION_QUEUE.getFailComponent(), + true); + } + + @Bean + public DirectExchange friendAcceptFailExchange() { + return new DirectExchange( + RabbitmqComponentConstants.FRIEND_ACCEPT_NOTIFICATION_EXCHANGE.getFailComponent()); + } + + @Bean + public Binding friendAcceptFailBinding() { + return BindingBuilder + .bind(friendAcceptFailQueue()) + .to(friendAcceptFailExchange()) + .withQueueName(); + } +} \ No newline at end of file diff --git a/backend/notification/src/main/java/site/timecapsulearchive/notification/global/config/RabbitmqProperties.java b/backend/notification/src/main/java/site/timecapsulearchive/notification/global/config/RabbitmqProperties.java new file mode 100644 index 000000000..b2ec8b393 --- /dev/null +++ b/backend/notification/src/main/java/site/timecapsulearchive/notification/global/config/RabbitmqProperties.java @@ -0,0 +1,14 @@ +package site.timecapsulearchive.notification.global.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "spring.rabbitmq") +public record RabbitmqProperties( + String host, + int port, + String userName, + String password, + String virtualHost +){ + +} diff --git a/backend/notification/src/main/java/site/timecapsulearchive/notification/global/config/TransactionTemplateConfig.java b/backend/notification/src/main/java/site/timecapsulearchive/notification/global/config/TransactionTemplateConfig.java new file mode 100644 index 000000000..d4865ee29 --- /dev/null +++ b/backend/notification/src/main/java/site/timecapsulearchive/notification/global/config/TransactionTemplateConfig.java @@ -0,0 +1,21 @@ +package site.timecapsulearchive.notification.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +@Configuration +public class TransactionTemplateConfig { + + @Bean + public TransactionTemplate transactionTemplate( + PlatformTransactionManager platformTransactionManager) { + TransactionTemplate transactionTemplate = new TransactionTemplate( + platformTransactionManager); + + transactionTemplate.setTimeout(7); + return transactionTemplate; + } +} + diff --git a/backend/notification/src/main/java/site/timecapsulearchive/notification/infra/fcm/FCMManager.java b/backend/notification/src/main/java/site/timecapsulearchive/notification/infra/fcm/FCMManager.java index 93338f246..a38251ac8 100644 --- a/backend/notification/src/main/java/site/timecapsulearchive/notification/infra/fcm/FCMManager.java +++ b/backend/notification/src/main/java/site/timecapsulearchive/notification/infra/fcm/FCMManager.java @@ -6,13 +6,18 @@ import com.google.firebase.messaging.FirebaseMessaging; import com.google.firebase.messaging.FirebaseMessagingException; import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.MulticastMessage; import jakarta.annotation.PostConstruct; import java.io.IOException; import java.io.InputStream; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Component; import site.timecapsulearchive.notification.data.dto.CapsuleSkinNotificationSendDto; +import site.timecapsulearchive.notification.data.dto.FriendNotificationDto; +import site.timecapsulearchive.notification.data.dto.FriendNotificationsDto; +import site.timecapsulearchive.notification.data.dto.GroupInviteNotificationDto; import site.timecapsulearchive.notification.entity.CategoryName; import site.timecapsulearchive.notification.infra.exception.MessageNotSendableException; import site.timecapsulearchive.notification.infra.s3.S3PreSignedUrlManager; @@ -44,10 +49,10 @@ private InputStream getCredential() throws IOException { return new ClassPathResource(fcmProperties.secretKeyPath()).getInputStream(); } - public void send( - CapsuleSkinNotificationSendDto dto, - CategoryName categoryName, - String fcmToken + public void sendCapsuleSkinNotification( + final CapsuleSkinNotificationSendDto dto, + final CategoryName categoryName, + final String fcmToken ) { try { FirebaseMessaging.getInstance() @@ -57,8 +62,29 @@ public void send( .putData(STATUS_DATA_NAME, String.valueOf(dto.status())) .putData(TITLE_DATA_NAME, dto.title()) .putData(TEXT_DATA_NAME, dto.text()) + .setToken(fcmToken) .putData(IMAGE_DATA_NAME, s3PreSignedUrlManager.createS3PreSignedUrlForGet(dto.skinUrl())) + .build() + ); + } catch (FirebaseMessagingException e) { + throw new MessageNotSendableException(e); + } + } + + public void sendFriendNotification( + final FriendNotificationDto dto, + final CategoryName categoryName, + final String fcmToken + ) { + try { + FirebaseMessaging.getInstance() + .send( + Message.builder() + .putData(TOPIC_DATA_NAME, String.valueOf(categoryName)) + .putData(STATUS_DATA_NAME, String.valueOf(dto.notificationStatus())) + .putData(TITLE_DATA_NAME, dto.title()) + .putData(TEXT_DATA_NAME, dto.text()) .setToken(fcmToken) .build() ); @@ -66,4 +92,46 @@ public void send( throw new MessageNotSendableException(e); } } + + public void sendFriendNotifications( + final FriendNotificationsDto dto, + final CategoryName categoryName, + final List fcmTokens + ) { + try { + FirebaseMessaging.getInstance() + .sendEachForMulticast( + MulticastMessage.builder() + .addAllTokens(fcmTokens) + .putData(TOPIC_DATA_NAME, String.valueOf(categoryName)) + .putData(STATUS_DATA_NAME, String.valueOf(dto.notificationStatus())) + .putData(TITLE_DATA_NAME, dto.title()) + .putData(TEXT_DATA_NAME, dto.text()) + .build() + ); + } catch (FirebaseMessagingException e) { + throw new MessageNotSendableException(e); + } + } + + public void sendGroupInviteNotifications( + final GroupInviteNotificationDto dto, + final CategoryName categoryName, + final List fcmTokens + ) { + try { + FirebaseMessaging.getInstance() + .sendEachForMulticast( + MulticastMessage.builder() + .addAllTokens(fcmTokens) + .putData(TOPIC_DATA_NAME, String.valueOf(categoryName)) + .putData(STATUS_DATA_NAME, String.valueOf(dto.notificationStatus())) + .putData(TITLE_DATA_NAME, dto.title()) + .putData(TEXT_DATA_NAME, dto.text()) + .build() + ); + } catch (FirebaseMessagingException e) { + throw new MessageNotSendableException(e); + } + } } diff --git a/backend/notification/src/main/java/site/timecapsulearchive/notification/repository/MemberRepository.java b/backend/notification/src/main/java/site/timecapsulearchive/notification/repository/MemberRepository.java index 0a69bf2ff..e828e11d4 100644 --- a/backend/notification/src/main/java/site/timecapsulearchive/notification/repository/MemberRepository.java +++ b/backend/notification/src/main/java/site/timecapsulearchive/notification/repository/MemberRepository.java @@ -1,7 +1,11 @@ package site.timecapsulearchive.notification.repository; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.core.namedparam.SqlParameterSource; import org.springframework.stereotype.Repository; @Repository @@ -9,6 +13,7 @@ public class MemberRepository { private final JdbcTemplate jdbcTemplate; + private final NamedParameterJdbcTemplate namedParameterJdbcTemplate; public String findFCMToken(Long memberId) { String sql = "SELECT m.fcm_token FROM member m WHERE m.member_id = ?"; @@ -19,4 +24,16 @@ public String findFCMToken(Long memberId) { memberId ); } + + public List findFCMTokens(List memberIds) { + final String sql = "SELECT m.fcm_token FROM member m WHERE m.member_id IN (:memberIds)"; + + final SqlParameterSource parameters = new MapSqlParameterSource("memberIds", memberIds); + + return namedParameterJdbcTemplate.query( + sql, + parameters, + (rs, rowNum) -> rs.getString("fcm_token") + ); + } } diff --git a/backend/notification/src/main/java/site/timecapsulearchive/notification/repository/NotificationQueryRepository.java b/backend/notification/src/main/java/site/timecapsulearchive/notification/repository/NotificationQueryRepository.java new file mode 100644 index 000000000..82b20ca86 --- /dev/null +++ b/backend/notification/src/main/java/site/timecapsulearchive/notification/repository/NotificationQueryRepository.java @@ -0,0 +1,61 @@ +package site.timecapsulearchive.notification.repository; + +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.sql.Types; +import java.time.ZonedDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import site.timecapsulearchive.notification.entity.Notification; +import site.timecapsulearchive.notification.entity.NotificationStatus; + +@Repository +@RequiredArgsConstructor +public class NotificationQueryRepository { + + private final JdbcTemplate jdbcTemplate; + + public void bulkSave(List notifications) { + jdbcTemplate.batchUpdate( + """ + INSERT INTO notification ( + notification_id, + title, + text, + member_id, + notification_category_id, + image_url, + created_at, + updated_at, + status + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + new BatchPreparedStatementSetter() { + + @Override + public void setValues(final PreparedStatement ps, final int i) throws SQLException { + final Notification notification = notifications.get(i); + ps.setNull(1, Types.BIGINT); + ps.setString(2, notification.getTitle()); + ps.setString(3, notification.getText()); + ps.setLong(4, notification.getMemberId()); + ps.setLong(5, notification.getNotificationCategory().getId()); + ps.setString(6, notification.getImageUrl()); + ps.setTimestamp(7, Timestamp.valueOf(ZonedDateTime.now().toLocalDateTime())); + ps.setTimestamp(8, Timestamp.valueOf(ZonedDateTime.now().toLocalDateTime())); + ps.setString(9, String.valueOf(NotificationStatus.SUCCESS)); + } + + @Override + public int getBatchSize() { + return notifications.size(); + } + } + ); + } +} diff --git a/backend/notification/src/main/java/site/timecapsulearchive/notification/service/NotificationService.java b/backend/notification/src/main/java/site/timecapsulearchive/notification/service/NotificationService.java index 42d2141cd..4727983cd 100644 --- a/backend/notification/src/main/java/site/timecapsulearchive/notification/service/NotificationService.java +++ b/backend/notification/src/main/java/site/timecapsulearchive/notification/service/NotificationService.java @@ -1,41 +1,130 @@ package site.timecapsulearchive.notification.service; +import java.util.List; import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; +import org.springframework.stereotype.Component; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionCallbackWithoutResult; +import org.springframework.transaction.support.TransactionTemplate; import site.timecapsulearchive.notification.data.dto.CapsuleSkinNotificationSendDto; -import site.timecapsulearchive.notification.data.mapper.NotificationMapper; +import site.timecapsulearchive.notification.data.dto.FriendNotificationDto; +import site.timecapsulearchive.notification.data.dto.FriendNotificationsDto; +import site.timecapsulearchive.notification.data.dto.GroupInviteNotificationDto; import site.timecapsulearchive.notification.entity.CategoryName; import site.timecapsulearchive.notification.entity.Notification; import site.timecapsulearchive.notification.entity.NotificationCategory; import site.timecapsulearchive.notification.infra.fcm.FCMManager; import site.timecapsulearchive.notification.repository.MemberRepository; import site.timecapsulearchive.notification.repository.NotificationCategoryRepository; +import site.timecapsulearchive.notification.repository.NotificationQueryRepository; import site.timecapsulearchive.notification.repository.NotificationRepository; -@Service +@Component @RequiredArgsConstructor -public class NotificationService { +public class NotificationService implements NotificationServiceListener { private final FCMManager fcmManager; private final NotificationRepository notificationRepository; + private final NotificationQueryRepository notificationQueryRepository; private final NotificationCategoryRepository notificationCategoryRepository; private final MemberRepository memberRepository; - private final NotificationMapper notificationMapper; + private final TransactionTemplate transactionTemplate; - @Transactional - public void sendCapsuleSkinAlarm(CapsuleSkinNotificationSendDto dto) { - NotificationCategory notificationCategory = notificationCategoryRepository.findByCategoryName( - CategoryName.CAPSULE_SKIN); + public void sendCapsuleSkinAlarm(final CapsuleSkinNotificationSendDto dto) { + transactionTemplate.execute(new TransactionCallbackWithoutResult() { + @Override + protected void doInTransactionWithoutResult(TransactionStatus status) { + final NotificationCategory notificationCategory = notificationCategoryRepository.findByCategoryName( + CategoryName.CAPSULE_SKIN); - Notification notification = notificationMapper.capsuleSkinNotificationSendDtoToEntity(dto, - notificationCategory); + final Notification notification = dto.toNotification(notificationCategory); - notificationRepository.save(notification); + notificationRepository.save(notification); + } + }); - String fcmToken = memberRepository.findFCMToken(dto.memberId()); - if (!fcmToken.isBlank()) { - fcmManager.send(dto, notificationCategory.getCategoryName(), fcmToken); + final String fcmToken = memberRepository.findFCMToken(dto.memberId()); + fcmManager.sendCapsuleSkinNotification(dto, CategoryName.CAPSULE_SKIN, fcmToken); + } + + public void sendFriendRequestNotification(final FriendNotificationDto dto) { + transactionTemplate.execute(new TransactionCallbackWithoutResult() { + @Override + protected void doInTransactionWithoutResult(TransactionStatus status) { + final NotificationCategory notificationCategory = notificationCategoryRepository.findByCategoryName( + CategoryName.FRIEND_REQUEST); + + final Notification notification = dto.toNotification(notificationCategory); + + notificationRepository.save(notification); + } + }); + + final String fcmToken = memberRepository.findFCMToken(dto.targetId()); + if (fcmToken != null && !fcmToken.isBlank()) { + fcmManager.sendFriendNotification(dto, CategoryName.FRIEND_REQUEST, fcmToken); + } + } + + public void sendFriendAcceptNotification(final FriendNotificationDto dto) { + transactionTemplate.execute(new TransactionCallbackWithoutResult() { + @Override + protected void doInTransactionWithoutResult(TransactionStatus status) { + final NotificationCategory notificationCategory = notificationCategoryRepository.findByCategoryName( + CategoryName.FRIEND_ACCEPT); + + final Notification notification = dto.toNotification(notificationCategory); + + notificationRepository.save(notification); + } + }); + + final String fcmToken = memberRepository.findFCMToken(dto.targetId()); + if (fcmToken != null && !fcmToken.isBlank()) { + fcmManager.sendFriendNotification(dto, CategoryName.FRIEND_ACCEPT, fcmToken); + } + } + + + public void sendFriendRequestNotifications(final FriendNotificationsDto dto) { + transactionTemplate.execute(new TransactionCallbackWithoutResult() { + @Override + protected void doInTransactionWithoutResult(TransactionStatus status) { + final NotificationCategory notificationCategory = notificationCategoryRepository.findByCategoryName( + CategoryName.FRIEND_ACCEPT); + + final List notifications = dto.toNotification(notificationCategory); + notificationQueryRepository.bulkSave(notifications); + } + }); + + final List fcmTokens = getTargetFcmTokens(dto.targetIds()); + if (fcmTokens != null && !fcmTokens.isEmpty()) { + fcmManager.sendFriendNotifications(dto, CategoryName.FRIEND_ACCEPT, fcmTokens); + } + } + + private List getTargetFcmTokens(List targetIds) { + return memberRepository.findFCMTokens(targetIds) + .stream() + .toList(); + } + + public void sendGroupInviteNotification(final GroupInviteNotificationDto dto) { + transactionTemplate.execute(new TransactionCallbackWithoutResult() { + @Override + protected void doInTransactionWithoutResult(TransactionStatus status) { + final NotificationCategory notificationCategory = notificationCategoryRepository.findByCategoryName( + CategoryName.GROUP_INVITE); + List notifications = dto.toNotification(notificationCategory); + notificationQueryRepository.bulkSave(notifications); + } + }); + + + List fcmTokens = getTargetFcmTokens(dto.targetIds()); + if (fcmTokens != null && !fcmTokens.isEmpty()) { + fcmManager.sendGroupInviteNotifications(dto, CategoryName.GROUP_INVITE, fcmTokens); } } } \ No newline at end of file diff --git a/backend/notification/src/main/java/site/timecapsulearchive/notification/service/NotificationServiceListener.java b/backend/notification/src/main/java/site/timecapsulearchive/notification/service/NotificationServiceListener.java new file mode 100644 index 000000000..cddd1b14b --- /dev/null +++ b/backend/notification/src/main/java/site/timecapsulearchive/notification/service/NotificationServiceListener.java @@ -0,0 +1,72 @@ +package site.timecapsulearchive.notification.service; + +import org.springframework.amqp.rabbit.annotation.Exchange; +import org.springframework.amqp.rabbit.annotation.Queue; +import org.springframework.amqp.rabbit.annotation.QueueBinding; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import site.timecapsulearchive.notification.data.dto.CapsuleSkinNotificationSendDto; +import site.timecapsulearchive.notification.data.dto.FriendNotificationDto; +import site.timecapsulearchive.notification.data.dto.FriendNotificationsDto; +import site.timecapsulearchive.notification.data.dto.GroupInviteNotificationDto; + +public interface NotificationServiceListener { + + + @RabbitListener( + bindings = @QueueBinding( + value = @Queue(value = "notification.createCapsuleSkin.queue", durable = "true"), + exchange = @Exchange(value = "notification.createCapsuleSkin.exchange"), + key = "notification.createCapsuleSkin.queue" + ), + returnExceptions = "false", + messageConverter = "jsonMessageConverter" + ) + void sendCapsuleSkinAlarm(final CapsuleSkinNotificationSendDto dto); + + @RabbitListener( + bindings = @QueueBinding( + value = @Queue(value = "notification.friendRequest.queue", durable = "true"), + exchange = @Exchange(value = "notification.friendRequest.exchange"), + key = "notification.friendRequest.queue" + ), + returnExceptions = "false", + messageConverter = "jsonMessageConverter" + ) + void sendFriendRequestNotification(final FriendNotificationDto dto); + + @RabbitListener( + bindings = @QueueBinding( + value = @Queue(value = "notification.friendAccept.queue", durable = "true"), + exchange = @Exchange(value = "notification.friendAccept.exchange"), + key = "notification.friendAccept.queue" + ), + returnExceptions = "false", + messageConverter = "jsonMessageConverter" + ) + void sendFriendAcceptNotification(final FriendNotificationDto dto); + + + @RabbitListener( + bindings = @QueueBinding( + value = @Queue(value = "notification.friendRequests.queue", durable = "true"), + exchange = @Exchange(value = "notification.friendRequests.exchange"), + key = "notification.friendRequests.queue" + ), + returnExceptions = "false", + messageConverter = "jsonMessageConverter" + ) + void sendFriendRequestNotifications(final FriendNotificationsDto dto); + + + @RabbitListener( + bindings = @QueueBinding( + value = @Queue(value = "notification.groupInvite.queue", durable = "true"), + exchange = @Exchange(value = "groupInvite.exchange"), + key = "notification.groupInvite.queue" + ), + returnExceptions = "false", + messageConverter = "jsonMessageConverter" + ) + void sendGroupInviteNotification(final GroupInviteNotificationDto dto); + +} diff --git a/backend/notification/src/main/resources/config b/backend/notification/src/main/resources/config index 9ba78f7fb..8d3af3cc4 160000 --- a/backend/notification/src/main/resources/config +++ b/backend/notification/src/main/resources/config @@ -1 +1 @@ -Subproject commit 9ba78f7fb830e195236ce34d400b5a9535683e35 +Subproject commit 8d3af3cc4263c89e6f6421770e64bc9083a7ad6d diff --git a/backend/notification/src/main/resources/data.sql b/backend/notification/src/main/resources/data.sql index 199a45526..9394c2e25 100644 --- a/backend/notification/src/main/resources/data.sql +++ b/backend/notification/src/main/resources/data.sql @@ -1,3 +1,15 @@ INSERT INTO notification_category (category_name, category_description, created_at, updated_at) SELECT 'CAPSULE_SKIN', '캡슐 스킨 생성 관련', now(), now() WHERE NOT EXISTS(SELECT * FROM notification_category where category_name='CAPSULE_SKIN'); + +INSERT INTO notification_category (category_name, category_description, created_at, updated_at) +SELECT 'FRIEND_REQUEST', '친구 요청 관련', now(), now() +WHERE NOT EXISTS(SELECT * FROM notification_category where category_name='FRIEND_REQUEST'); + +INSERT INTO notification_category (category_name, category_description, created_at, updated_at) +SELECT 'FRIEND_ACCEPT', '친구 수락 관련', now(), now() +WHERE NOT EXISTS(SELECT * FROM notification_category where category_name='FRIEND_ACCEPT'); + +INSERT INTO notification_category (category_name, category_description, created_at, updated_at) +SELECT 'GROUP_INVITE', '그룹 초대 관련', now(), now() +WHERE NOT EXISTS(SELECT * FROM notification_category where category_name='GROUP_INVITE'); \ No newline at end of file