Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[NAYB-156] feat: 사용자는 서비스와 연관된 알림을 받아볼 수 있다. #119

Merged
merged 9 commits into from
Sep 20, 2023
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.prgrms.nabmart.domain.notification;

import com.prgrms.nabmart.domain.notification.exception.InvalidNotificationException;
import com.prgrms.nabmart.global.BaseTimeEntity;
import jakarta.persistence.Column;
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 java.util.Objects;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Notification extends BaseTimeEntity {

private static final int CONTENT_LENGTH = 50;

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long notificationId;

@Column(nullable = false)
private String content;

@Enumerated(EnumType.STRING)
private NotificationType notificationType;

@Column(nullable = false)
private Long userId;

@Builder
public Notification(
String content,
Long userId,
NotificationType notificationType) {
validateContent(content);
this.content = content;
this.notificationType = notificationType;
this.userId = userId;
}

private void validateContent(String content) {
if(Objects.nonNull(content) && content.length() > CONTENT_LENGTH) {
throw new InvalidNotificationException("내용의 길이는 50자 이하여야 합니다.");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.prgrms.nabmart.domain.notification;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum NotificationType {
CONNECT("connect"),
DELIVERY("delivery");

private final String value;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.prgrms.nabmart.domain.notification.controller;

import com.prgrms.nabmart.domain.notification.controller.request.ConnectNotificationCommand;
import com.prgrms.nabmart.domain.notification.service.NotificationService;
import com.prgrms.nabmart.global.auth.LoginUser;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/notifications")
public class NotificationController {

private final NotificationService notificationService;

@GetMapping(value = "/connect", produces = "text/event-stream")
public ResponseEntity<SseEmitter> sseConnection(
@RequestHeader(value = "Last-Event-ID", required = false, defaultValue = "") String lastEventId,
@LoginUser Long userId) {

ConnectNotificationCommand connectNotificationCommand
= ConnectNotificationCommand.of(userId, lastEventId);
SseEmitter sseEmitter = notificationService.connectNotification(connectNotificationCommand);
return ResponseEntity.ok(sseEmitter);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.prgrms.nabmart.domain.notification.controller.request;

public record ConnectNotificationCommand(Long userId, String lastEventId) {

public static ConnectNotificationCommand of(final Long userId, final String lastEventId) {
return new ConnectNotificationCommand(userId, lastEventId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.prgrms.nabmart.domain.notification.exception;

import com.prgrms.nabmart.domain.notification.exception.NotificationException;

public class InvalidNotificationException extends NotificationException {

public InvalidNotificationException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.prgrms.nabmart.domain.notification.exception;

public abstract class NotificationException extends RuntimeException {

public NotificationException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.prgrms.nabmart.domain.notification.repository;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import org.springframework.stereotype.Repository;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

@Repository
public class EmitterMemoryRepository implements EmitterRepository {

private final Map<String, SseEmitter> emitters = new ConcurrentHashMap<>();

@Override
public void save(String emitterId, SseEmitter sseEmitter) {
emitters.put(emitterId, sseEmitter);
}

@Override
public void deleteById(String emitterId) {
emitters.remove(emitterId);
}

@Override
public Map<String, SseEmitter> findAllByIdStartWith(Long userId) {
String emitterIdPrefix = userId + "_";
return emitters.entrySet().stream()
.filter(entry -> entry.getKey().startsWith(emitterIdPrefix))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.prgrms.nabmart.domain.notification.repository;

import java.util.Map;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

public interface EmitterRepository {

void save(String emitterId, SseEmitter sseEmitter);

void deleteById(String emitterId);

Map<String, SseEmitter> findAllByIdStartWith(Long userId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.prgrms.nabmart.domain.notification.repository;

import com.prgrms.nabmart.domain.notification.Notification;
import org.springframework.data.jpa.repository.JpaRepository;

public interface NotificationRepository extends JpaRepository<Notification, Long> {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package com.prgrms.nabmart.domain.notification.service;

import static java.text.MessageFormat.format;

import com.prgrms.nabmart.domain.notification.Notification;
import com.prgrms.nabmart.domain.notification.NotificationType;
import com.prgrms.nabmart.domain.notification.controller.request.ConnectNotificationCommand;
import com.prgrms.nabmart.domain.notification.repository.EmitterRepository;
import com.prgrms.nabmart.domain.notification.repository.NotificationRepository;
import com.prgrms.nabmart.domain.notification.service.request.SendNotificationCommand;
import com.prgrms.nabmart.domain.notification.service.response.NotificationResponse;
import com.prgrms.nabmart.domain.user.exception.NotFoundUserException;
import com.prgrms.nabmart.domain.user.repository.UserRepository;
import jakarta.transaction.Transactional;
import java.io.IOException;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

@Slf4j
@Service
@RequiredArgsConstructor
public class NotificationService {

private static final Long DEFAULT_TIMEOUT = 60L * 1000 * 120;

private final EmitterRepository emitterRepository;
private final NotificationRepository notificationRepository;
private final UserRepository userRepository;

public SseEmitter connectNotification(ConnectNotificationCommand connectNotificationCommand) {
Long userId = connectNotificationCommand.userId();
String lastEventId = connectNotificationCommand.lastEventId();

String emitterId = format("{0}_{1}",userId, System.currentTimeMillis());
SseEmitter emitter = new SseEmitter(DEFAULT_TIMEOUT);
emitterRepository.save(emitterId, emitter);

emitter.onCompletion(() -> emitterRepository.deleteById(emitterId));
emitter.onTimeout(() -> emitterRepository.deleteById(emitterId));
emitter.onError(e -> emitterRepository.deleteById(emitterId));

// 연결 직후 데이터 전송이 없으면 503 에러 발생. 에러 방지용 더미 데이터 전송
send(emitter, emitterId, format("[Connected] UserId={0}", userId));

// 클라이언트 미수신한 event를 모두 전송
if (!connectNotificationCommand.lastEventId().isEmpty()) {
Map<String, SseEmitter> events = emitterRepository.findAllByIdStartWith(userId);
events.entrySet().stream().filter(entry -> lastEventId.compareTo(entry.getKey()) < 0)
.forEach(entry -> send(emitter, entry.getKey(), entry.getValue()));
}

return emitter;
}

private void send(SseEmitter emitter, String emitterId, Object data) {
try {
emitter.send(SseEmitter.event()
.id(emitterId)
.data(data));
} catch (IOException ex) {
emitterRepository.deleteById(emitterId);
log.error("알림 전송에 실패했습니다.", ex);
}
}

@Transactional
public void sendNotification(SendNotificationCommand sendNotificationCommand) {
Long userId = sendNotificationCommand.userId();
String content = sendNotificationCommand.content();
NotificationType notificationType = sendNotificationCommand.notificationType();

verifyExistsUser(userId);
Notification notification = Notification.builder()
.content(content)
.userId(userId)
.notificationType(notificationType)
.build();
notificationRepository.save(notification);

Map<String, SseEmitter> emitters = emitterRepository.findAllByIdStartWith(userId);
emitters.forEach((key, emitter) -> {
send(emitter, key, NotificationResponse.from(notification));
});
}

private void verifyExistsUser(Long userId) {
userRepository.findById(userId)
.orElseThrow(() -> new NotFoundUserException("존재하지 않는 유저입니다."));
}

private void send(SseEmitter emitter, String emitterId, NotificationResponse data) {
try {
emitter.send(SseEmitter.event()
.id(emitterId)
.name(data.notificationType().getValue())
.data(data));
} catch (IOException ex) {
emitterRepository.deleteById(emitterId);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.prgrms.nabmart.domain.notification.service.request;

import com.prgrms.nabmart.domain.notification.NotificationType;

public record SendNotificationCommand(
Long userId,
String content,
NotificationType notificationType) {

public static SendNotificationCommand of(
final Long userId,
final String content,
final NotificationType notificationType) {
return new SendNotificationCommand(userId, content, notificationType);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.prgrms.nabmart.domain.notification.service.response;

import com.prgrms.nabmart.domain.notification.Notification;
import com.prgrms.nabmart.domain.notification.NotificationType;
import java.time.LocalDateTime;

public record NotificationResponse(
Long notificationId,
String content,
NotificationType notificationType,
Long userId,
LocalDateTime createdAt) {

public static NotificationResponse from(Notification notification) {
return new NotificationResponse(
notification.getNotificationId(),
notification.getContent(),
notification.getNotificationType(),
notification.getUserId(),
notification.getCreatedAt());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import java.util.List;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
Expand All @@ -13,4 +14,13 @@ public class WebMvcConfig implements WebMvcConfigurer {
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new LoginUserArgumentResolver());
}

@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:3000")
.allowedHeaders("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")
.allowCredentials(true);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ public SecurityFilterChain filterChain(

private RequestMatcher[] requestPermitAll() {
List<RequestMatcher> requestMatchers = List.of(
antMatcher(GET, "/api/v1/notifications/**"),
antMatcher(POST, "/oauth2/authorization/**"),
antMatcher(POST, "/api/v1/riders/**"),
antMatcher(GET, "/api/v1/categories/**"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import com.prgrms.nabmart.domain.event.service.EventService;
import com.prgrms.nabmart.domain.item.service.ItemService;
import com.prgrms.nabmart.domain.item.service.LikeItemService;
import com.prgrms.nabmart.domain.notification.service.NotificationService;
import com.prgrms.nabmart.domain.order.service.OrderService;
import com.prgrms.nabmart.domain.payment.service.PaymentClient;
import com.prgrms.nabmart.domain.payment.service.PaymentService;
Expand Down Expand Up @@ -100,6 +101,9 @@ public abstract class BaseControllerTest {
@MockBean
protected RiderAuthenticationService riderAuthenticationService;

@MockBean
protected NotificationService notificationService;

protected static final String AUTHORIZATION = "Authorization";


Expand Down