diff --git a/docker-compose.yml b/docker-compose.yml index 60f2710..a87fed0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,7 @@ services: - redis ports: - ${SPRING_PORT}:${SPRING_PORT} - restart: always + restart: "always" database: image: mysql:8.4.4 @@ -27,7 +27,7 @@ services: - ${DB_PORT} ports: - ${DB_PORT}:${DB_PORT} - restart: no + restart: "no" volumes: - ring-us-database:/var/lib/mysql @@ -39,9 +39,9 @@ services: - ${REDIS_PORT} ports: - ${REDIS_PORT}:${REDIS_PORT} - restart: always + restart: "always" volumes: - ring-us-redis:/data volumes: ring-us-database: - ring-us-redis: + ring-us-redis: \ No newline at end of file diff --git a/src/main/java/es/princip/ringus/application/mentoring/MentoringService.java b/src/main/java/es/princip/ringus/application/mentoring/MentoringService.java index 0342dd5..14c8cff 100644 --- a/src/main/java/es/princip/ringus/application/mentoring/MentoringService.java +++ b/src/main/java/es/princip/ringus/application/mentoring/MentoringService.java @@ -1,5 +1,6 @@ package es.princip.ringus.application.mentoring; +import es.princip.ringus.application.notification.service.NotificationService; import es.princip.ringus.domain.exception.MenteeErrorCode; import es.princip.ringus.domain.exception.MentorErrorCode; import es.princip.ringus.domain.exception.MentoringErrorCode; @@ -11,6 +12,7 @@ import es.princip.ringus.domain.mentoring.MentoringRepository; import es.princip.ringus.domain.mentoring.MentoringStatus; import es.princip.ringus.global.exception.CustomRuntimeException; +import es.princip.ringus.global.sender.dto.MentoringRequestMessage; import es.princip.ringus.presentation.mentoring.dto.*; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -24,6 +26,7 @@ public class MentoringService { private final MentorRepository mentorRepository; private final MenteeRepository menteeRepository; + private final NotificationService notificationService; /** * 멘토링 신청 생성 */ @@ -39,11 +42,13 @@ public MentoringResponse createMentoring(CreateMentoringRequest request, Long me request.applyTimes(), request.mentoringMessage(), mentor, - mentee); + mentee + ); mentee.addMentoring(mentoring); mentor.addMentoring(mentoring); + notificationService.notify(MentoringRequestMessage.from(mentee, mentor, mentoring)); return MentoringResponse.from(mentoringRepository.save(mentoring)); } diff --git a/src/main/java/es/princip/ringus/application/notification/service/NotificationService.java b/src/main/java/es/princip/ringus/application/notification/service/NotificationService.java new file mode 100644 index 0000000..95aaf00 --- /dev/null +++ b/src/main/java/es/princip/ringus/application/notification/service/NotificationService.java @@ -0,0 +1,26 @@ +package es.princip.ringus.application.notification.service; + +import es.princip.ringus.domain.notification.Notification; +import es.princip.ringus.domain.notification.NotificationRepository; +import es.princip.ringus.global.factory.NotificationMessageFactory; +import es.princip.ringus.global.sender.NotificationSender; +import es.princip.ringus.global.sender.dto.MentoringRequestMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class NotificationService { + + private final NotificationSender notificationSender; + private final NotificationMessageFactory notificationMessageFactory; + private final NotificationRepository notificationRepository; + + public void notify(MentoringRequestMessage request) { + Notification notification = notificationMessageFactory.mentoringRequestMessage(request); + notificationRepository.save(notification); + notificationSender.send(notification); + } +} \ No newline at end of file diff --git a/src/main/java/es/princip/ringus/domain/notification/Notification.java b/src/main/java/es/princip/ringus/domain/notification/Notification.java new file mode 100644 index 0000000..8b35ca6 --- /dev/null +++ b/src/main/java/es/princip/ringus/domain/notification/Notification.java @@ -0,0 +1,55 @@ +package es.princip.ringus.domain.notification; + +import es.princip.ringus.domain.base.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "notification") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Notification extends BaseTimeEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "notification_id") + private Long id; + + @Column(name = "title", nullable = false, length = 255) + private String title; + + @Column(name = "content", nullable = false, length = 500) + private String content; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false) + private NotificationType type; + + @Column(name = "is_read", nullable = false) + private boolean isRead = false; + + @Column(name = "sender_id", nullable = false) + private Long senderId; + + @Column(name = "receiver_id", nullable = false) + private Long receiverId; + + @Builder + private Notification( + String title, + String content, + NotificationType type, + Long senderId, + Long receiverId + ) { + this.title = title; + this.content = content; + this.type = type; + this.senderId = senderId; + this.receiverId = receiverId; + } + + public void markAsRead() { this.isRead = true; } +} \ No newline at end of file diff --git a/src/main/java/es/princip/ringus/domain/notification/NotificationRepository.java b/src/main/java/es/princip/ringus/domain/notification/NotificationRepository.java new file mode 100644 index 0000000..ec6823f --- /dev/null +++ b/src/main/java/es/princip/ringus/domain/notification/NotificationRepository.java @@ -0,0 +1,6 @@ +package es.princip.ringus.domain.notification; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface NotificationRepository extends JpaRepository { +} diff --git a/src/main/java/es/princip/ringus/domain/notification/NotificationType.java b/src/main/java/es/princip/ringus/domain/notification/NotificationType.java new file mode 100644 index 0000000..5d50513 --- /dev/null +++ b/src/main/java/es/princip/ringus/domain/notification/NotificationType.java @@ -0,0 +1,6 @@ +package es.princip.ringus.domain.notification; + +public enum NotificationType { + MENTORING_REQUEST, + MENTORING_APPROVED +} diff --git a/src/main/java/es/princip/ringus/global/factory/NotificationMessageFactory.java b/src/main/java/es/princip/ringus/global/factory/NotificationMessageFactory.java new file mode 100644 index 0000000..b7a2699 --- /dev/null +++ b/src/main/java/es/princip/ringus/global/factory/NotificationMessageFactory.java @@ -0,0 +1,28 @@ +package es.princip.ringus.global.factory; + +import es.princip.ringus.domain.notification.Notification; +import es.princip.ringus.domain.notification.NotificationType; +import es.princip.ringus.global.sender.dto.MentoringRequestMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class NotificationMessageFactory { + + public Notification mentoringRequestMessage(MentoringRequestMessage request) { + String title = request.menteeName() + " 멘티님께서 " + request.mentorName() + " 멘토님께 멘토링을 신청했습니다."; + String content = "[링어스 멘토링 신청 알림]\n" + + "멘토링 주제" + request.mentoringTopic().name() + "\n" + + "신청 시간: " + request.applyTimes().toString() + "\n" + + "멘토링 신청 메시지: " + request.mentoringMessage() + "\n"+ + "\n\n"; + return Notification.builder() + .title(title) + .content(content) + .type(NotificationType.MENTORING_REQUEST) + .senderId(request.senderId()) + .receiverId(request.receiverId()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/es/princip/ringus/global/sender/EmitterRepository.java b/src/main/java/es/princip/ringus/global/sender/EmitterRepository.java new file mode 100644 index 0000000..3b2cace --- /dev/null +++ b/src/main/java/es/princip/ringus/global/sender/EmitterRepository.java @@ -0,0 +1,40 @@ +package es.princip.ringus.global.sender; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +@Slf4j +@Component +@RequiredArgsConstructor +public class EmitterRepository { + private final Map emitters = new ConcurrentHashMap<>(); + @Value("${app.notification.emitter.timeout}") + private Long TIMEOUT; + + public SseEmitter save(Long receiverId) { + log.info("Sending message to emitter {}", receiverId); + log.info("Emitter timeout set to {} ms", TIMEOUT); + + SseEmitter emitter = new SseEmitter(TIMEOUT); + emitters.put(receiverId, emitter); + + emitter.onCompletion(() -> emitters.remove(receiverId)); + emitter.onTimeout(() -> emitters.remove(receiverId)); + return emitter; + } + + public Optional get(Long receiverId) { + return Optional.ofNullable(emitters.get(receiverId)); + } + + public void remove(Long receiverId) { + emitters.remove(receiverId); + } +} \ No newline at end of file diff --git a/src/main/java/es/princip/ringus/global/sender/NotificationChannel.java b/src/main/java/es/princip/ringus/global/sender/NotificationChannel.java new file mode 100644 index 0000000..7a019b9 --- /dev/null +++ b/src/main/java/es/princip/ringus/global/sender/NotificationChannel.java @@ -0,0 +1,8 @@ +package es.princip.ringus.global.sender; + +public enum NotificationChannel { + SSE, + EMAIL, + KAKAO, + SMS +} \ No newline at end of file diff --git a/src/main/java/es/princip/ringus/global/sender/NotificationSender.java b/src/main/java/es/princip/ringus/global/sender/NotificationSender.java new file mode 100644 index 0000000..6b2c00c --- /dev/null +++ b/src/main/java/es/princip/ringus/global/sender/NotificationSender.java @@ -0,0 +1,8 @@ +package es.princip.ringus.global.sender; + +import es.princip.ringus.domain.notification.Notification; + +public interface NotificationSender { + void send(Notification notification); + NotificationChannel getChannelType(); +} \ No newline at end of file diff --git a/src/main/java/es/princip/ringus/global/sender/SseNotificationSender.java b/src/main/java/es/princip/ringus/global/sender/SseNotificationSender.java new file mode 100644 index 0000000..8545f38 --- /dev/null +++ b/src/main/java/es/princip/ringus/global/sender/SseNotificationSender.java @@ -0,0 +1,36 @@ +package es.princip.ringus.global.sender; + +import es.princip.ringus.domain.notification.Notification; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class SseNotificationSender implements NotificationSender { + + private final EmitterRepository emitterRepository; + + @Override + public NotificationChannel getChannelType() { + return NotificationChannel.SSE; + } + + @Override + public void send(Notification notification) { + emitterRepository.get(notification.getReceiverId()).ifPresent(emitter -> { + try { + emitter.send( + SseEmitter.event() + .name("notification") + .data(notification) // 직렬화 규칙은 Jackson 기본 + ); + } catch (IOException ex) { + emitter.completeWithError(ex); + emitterRepository.remove(notification.getReceiverId()); + } + }); + } +} \ No newline at end of file diff --git a/src/main/java/es/princip/ringus/global/sender/dto/MentoringRequestMessage.java b/src/main/java/es/princip/ringus/global/sender/dto/MentoringRequestMessage.java new file mode 100644 index 0000000..92ac204 --- /dev/null +++ b/src/main/java/es/princip/ringus/global/sender/dto/MentoringRequestMessage.java @@ -0,0 +1,35 @@ +package es.princip.ringus.global.sender.dto; + +import es.princip.ringus.domain.mentee.Mentee; +import es.princip.ringus.domain.mentor.Mentor; +import es.princip.ringus.domain.mentoring.Mentoring; +import es.princip.ringus.domain.mentoring.MentoringTime; +import es.princip.ringus.domain.mentoring.MentoringTopic; + +import java.util.List; + +public record MentoringRequestMessage( + Long receiverId, + Long senderId, + String menteeName, + String mentorName, + String mentoringMessage, + MentoringTopic mentoringTopic, + List applyTimes +) { + public static MentoringRequestMessage from( + final Mentee mentee, + final Mentor mentor, + final Mentoring mentoring + ) { + return new MentoringRequestMessage( + mentor.getMemberId(), + mentee.getMemberId(), + mentee.getNickname(), + mentor.getNickname(), + mentoring.getMentoringMessage(), + mentoring.getMentoringTopic(), + mentoring.getApplyTimes() + ); + } +} diff --git a/src/main/java/es/princip/ringus/infra/config/RedisConfig.java b/src/main/java/es/princip/ringus/infra/config/RedisConfig.java index fffdd27..7b3c021 100644 --- a/src/main/java/es/princip/ringus/infra/config/RedisConfig.java +++ b/src/main/java/es/princip/ringus/infra/config/RedisConfig.java @@ -5,6 +5,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; @@ -30,4 +31,11 @@ public RedisTemplate redisTemplate(RedisConnectionFactory connec redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); return redisTemplate; } + + @Bean + public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory connectionFactory) { + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + container.setConnectionFactory(connectionFactory); + return container; + } } \ No newline at end of file diff --git a/src/main/java/es/princip/ringus/presentation/notification/NotificationController.java b/src/main/java/es/princip/ringus/presentation/notification/NotificationController.java new file mode 100644 index 0000000..b4e2f59 --- /dev/null +++ b/src/main/java/es/princip/ringus/presentation/notification/NotificationController.java @@ -0,0 +1,64 @@ +package es.princip.ringus.presentation.notification; + +import es.princip.ringus.domain.notification.Notification; +import es.princip.ringus.domain.notification.NotificationType; +import es.princip.ringus.global.annotation.SessionCheck; +import es.princip.ringus.global.annotation.SessionMemberId; +import es.princip.ringus.global.sender.EmitterRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; + +@RestController +@RequestMapping("/notifications") +@RequiredArgsConstructor +public class NotificationController { + + private final EmitterRepository emitterRepository; + + @GetMapping("/{receiverId}") + public void testSend(@PathVariable Long receiverId) { + Notification n = Notification.builder() + .title("테스트 알림") + .content("Postman SSE 테스트입니다.") + .receiverId(receiverId) + .type(NotificationType.MENTORING_APPROVED) + .build(); + try { + emitterRepository.get(receiverId).ifPresent(emitter -> { + try { + emitter.send( + SseEmitter.event() + .name("notification") + .data(n) // 직렬화 규칙은 Jackson 기본 + ); + } catch (IOException e) { + emitter.completeWithError(e); + emitterRepository.remove(receiverId); + } + }); + } catch (Exception e) { + e.printStackTrace(); + } + } + + @SessionCheck + @GetMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter subscribe(@SessionMemberId Long memberId) { + SseEmitter emitter = emitterRepository.save(memberId); + try { + emitter.send( + SseEmitter.event() + .name("connected") + .data("SSE 연결 완료") + ); + } catch (IOException e) { + emitter.completeWithError(e); + emitterRepository.remove(memberId); + } + return emitter; + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index af76a90..65e6336 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -64,3 +64,7 @@ springdoc: swagger-ui: path: /swagger +app: + notification: + emitter: + timeout: ${NOTIFICATION_EMITTER_TIMEOUT} diff --git a/src/main/resources/db/migration/V9__create_notification.sql b/src/main/resources/db/migration/V9__create_notification.sql new file mode 100644 index 0000000..76798f7 --- /dev/null +++ b/src/main/resources/db/migration/V9__create_notification.sql @@ -0,0 +1,21 @@ +CREATE TABLE notification ( + notification_id BIGINT NOT NULL AUTO_INCREMENT, + title VARCHAR(255) NOT NULL, + content VARCHAR(500) NOT NULL, + type ENUM ( + 'MENTORING_REQUEST', + 'MENTORING_APPROVED', + 'MENTORING_REJECTED' + ) NOT NULL, + is_read TINYINT(1) NOT NULL DEFAULT 0, + sender_id BIGINT NOT NULL, + receiver_id BIGINT NOT NULL, + created_at DATETIME(6), + updated_at DATETIME(6), + + PRIMARY KEY (notification_id), + CONSTRAINT fk_notification_sender + FOREIGN KEY (sender_id) REFERENCES member(member_id) ON DELETE CASCADE, + CONSTRAINT fk_notification_receiver + FOREIGN KEY (receiver_id) REFERENCES member(member_id) ON DELETE CASCADE +); \ No newline at end of file