Skip to content

Commit

Permalink
Merge pull request #215 from bucket-back/OMCT-408-alarm-sse
Browse files Browse the repository at this point in the history
[OMCT-408] 댓글 생성에 대한 알림 기능 구현
  • Loading branch information
HandmadeCloud authored Dec 28, 2023
2 parents 7ea011a + d4b2b87 commit 86a6bfe
Show file tree
Hide file tree
Showing 12 changed files with 275 additions and 4 deletions.
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package com.programmers.bucketback.domains.comment.application;

import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;

import com.programmers.bucketback.common.cursor.CursorPageParameters;
import com.programmers.bucketback.common.cursor.CursorSummary;
import com.programmers.bucketback.domains.comment.application.dto.response.CommentGetCursorServiceResponse;
import com.programmers.bucketback.domains.comment.application.event.CommentCreateEvent;
import com.programmers.bucketback.domains.comment.domain.Comment;
import com.programmers.bucketback.domains.comment.implementation.CommentAppender;
import com.programmers.bucketback.domains.comment.implementation.CommentModifier;
Expand All @@ -13,6 +15,8 @@
import com.programmers.bucketback.domains.comment.repository.CommentSummary;
import com.programmers.bucketback.domains.feed.domain.Feed;
import com.programmers.bucketback.domains.feed.implementation.FeedReader;
import com.programmers.bucketback.domains.member.domain.Member;
import com.programmers.bucketback.domains.sse.SsePayload;
import com.programmers.bucketback.error.BusinessException;
import com.programmers.bucketback.error.ErrorCode;
import com.programmers.bucketback.global.level.PayPoint;
Expand All @@ -31,16 +35,21 @@ public class CommentService {
private final CommentReader commentReader;
private final FeedReader feedReader;
private final MemberUtils memberUtils;
private final ApplicationEventPublisher applicationEventPublisher;

@PayPoint(5)
public Long createComment(
final Long feedId,
final String content
) {
final Long memberId = memberUtils.getCurrentMemberId();
commentAppender.append(feedId, content, memberId);
final Member commentWriter = memberUtils.getCurrentMember();
final Comment comment = commentAppender.append(feedId, content, commentWriter.getId());

SsePayload ssePayload = CommentCreateEvent.toSsePayload(commentWriter.getNickname(), comment);

applicationEventPublisher.publishEvent(ssePayload);

return memberId;
return commentWriter.getId();
}

public void modifyComment(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.programmers.bucketback.domains.comment.application.event;

import java.util.Map;

import com.programmers.bucketback.domains.comment.domain.Comment;
import com.programmers.bucketback.domains.sse.PayLoadProvider;
import com.programmers.bucketback.domains.sse.SsePayload;

import lombok.Builder;

@Builder
public record CommentCreateEvent(
Long commentWriterId,
Long receiverId,
Long feedId,
String commentWriter
) implements PayLoadProvider {

public static SsePayload toSsePayload(
String nickname,
Comment comment
) {
CommentCreateEvent commentCreateEvent = of(nickname, comment);

return SsePayload.builder()
.receiverId(commentCreateEvent.receiverId())
.data(commentCreateEvent.getPayloadData())
.build();
}

private static CommentCreateEvent of(
final String nickname,
final Comment comment
) {
CommentCreateEvent commentCreateEvent = CommentCreateEvent.builder()
.commentWriterId(comment.getMemberId())
.receiverId(comment.getFeed().getMemberId())
.feedId(comment.getFeed().getId())
.commentWriter(nickname)
.build();
return commentCreateEvent;
}

@Override
public Map<String, Object> getPayloadData() {
return Map.ofEntries(
Map.entry("alarmMessage", CommentEventType.COMMENT_CREATE.getDescription()),
Map.entry("memberId", this.commentWriterId()),
Map.entry("receiverId", this.receiverId()),
Map.entry("feedId", this.feedId()),
Map.entry("commentWriter", this.commentWriter())
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.programmers.bucketback.domains.comment.application.event;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum CommentEventType {
COMMENT_CREATE("댓글 생성"),
COMMENT_ADOPTED("댓글 채택");

private final String description;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.programmers.bucketback.domains.sse;

import java.util.Map;

public interface PayLoadProvider {
Map<String, Object> getPayloadData();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.programmers.bucketback.domains.sse;

import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/sse")
public class SseController {

private final SseService sseService;

@GetMapping(value = "/subscribe", produces = "text/event-stream;charset=utf-8")
public ResponseEntity<SseEmitter> subscribe(HttpServletResponse response) {
response.setHeader(HttpHeaders.CONNECTION, "keep-alive");
response.setHeader(HttpHeaders.CONTENT_TYPE, "text/event-stream;charset=utf-8");
response.setHeader(HttpHeaders.CACHE_CONTROL, "no-cache, no-transform");

return ResponseEntity.ok(sseService.subscribe());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.programmers.bucketback.domains.sse;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@Component
public class SseEmitters {

private static final Long DEFAULT_TIMEOUT = 30 * 60 * 1000L; // 60분
private final Map<Long, SseEmitter> emitters = new ConcurrentHashMap<>();

public SseEmitter add(final Long receiverId) {
SseEmitter sseEmitter = new SseEmitter(DEFAULT_TIMEOUT);

this.emitters.put(receiverId, sseEmitter);
sseEmitter.onTimeout(sseEmitter::complete);
sseEmitter.onCompletion(() -> this.emitters.remove(receiverId));
sseEmitter.onError((e) -> this.emitters.remove(receiverId));

return sseEmitter;
}

public void send(
final Long receiverId,
final Object data
) {
SseEmitter sseEmitter = get(receiverId);
try {
sseEmitter.send(data);
log.info("알람을 보냈습니다. userId : {}", receiverId);
} catch (Exception e) {
log.error("알람을 보내는 과정에서 오류가 발생했습니다.");
throw new RuntimeException();
}
}

public SseEmitter get(
final Long receiverId
) {
var sseEmitter = emitters.get(receiverId);
if (sseEmitter == null) {
log.warn("SSE를 구독하지 않은 유저입니다. userId : {}", receiverId);
}
return sseEmitter;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.programmers.bucketback.domains.sse;

import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Component
public class SseEventListener {

private final SseEmitters sseEmitters;

@EventListener
public void sendSseEmitter(final SsePayload ssePayload) {
sseEmitters.send(
ssePayload.receiverId(),
ssePayload.data()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.programmers.bucketback.domains.sse;

import java.util.Map;

import lombok.Builder;

@Builder
public record SsePayload(
String alarmType,
Long receiverId,
Map<String, Object> data
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.programmers.bucketback.domains.sse;

import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import com.programmers.bucketback.global.config.security.SecurityUtils;

import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class SseService {
private static final String DUMMY_DATA = "connected";
private final SseEmitters sseEmitters;

public SseEmitter subscribe() {
Long receiverId = SecurityUtils.getCurrentMemberId();
SseEmitter sseEmitter = sseEmitters.add(receiverId);

//최초 연결 시점에는 더미 데이터 전송
sseEmitters.send(receiverId, DUMMY_DATA);

return sseEmitter;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.programmers.bucketback.domains.sse;

import static org.assertj.core.api.Assertions.*;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

class SseEmittersTest {

SseEmitters sseEmitters = new SseEmitters();
Long memberId = 1L;
Long defaultTimeOut = 30 * 60 * 1000L;

@Test
@DisplayName("새로운 Emitters를 추가에 성공한다.")
public void successAddEmitter() throws Exception {
//when
SseEmitter sseEmitter = sseEmitters.add(memberId);

//then
assertThat(sseEmitter.getTimeout()).isEqualTo(defaultTimeOut);
}

@Test
@DisplayName("Emitters에 있는 정보를 가져올 수 있다.")
public void successGetEmitter() throws Exception {
//when
sseEmitters.add(memberId);
SseEmitter sseEmitter = sseEmitters.get(memberId);

//then
assertThat(sseEmitter).isNotNull();
}

@Test
@DisplayName("Emitters에 저장된 정보가 없다.")
public void failGetEmitter() throws Exception {
//when
SseEmitter sseEmitter = sseEmitters.get(memberId);

//then
assertThat(sseEmitter).isNull();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,8 @@ public void changeContent(final String content) {
public void adopt() {
this.adoption = true;
}

public String getContent() {
return this.content.getContent();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ void modifyTest() {
commentModifier.modify(comment.getId(), newContent);

// then
assertThat(comment.getContent().getContent()).isEqualTo(newContent);
assertThat(comment.getContent()).isEqualTo(newContent);
}

@Test
Expand Down

0 comments on commit 86a6bfe

Please sign in to comment.