Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import com.jiwon.mylog.domain.comment.entity.Comment;
import com.jiwon.mylog.domain.comment.repository.CommentRepository;
import com.jiwon.mylog.domain.event.dto.CommentCreatedEvent;
import com.jiwon.mylog.domain.notification.repository.NotificationRepository;
import com.jiwon.mylog.domain.notification.service.NotificationService;
import com.jiwon.mylog.domain.post.entity.Post;
import com.jiwon.mylog.domain.user.entity.User;
import com.jiwon.mylog.global.common.error.ErrorCode;
Expand All @@ -14,11 +16,13 @@
import com.jiwon.mylog.domain.post.repository.PostRepository;
import com.jiwon.mylog.domain.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@RequiredArgsConstructor
@Service
public class CommentService {
Expand All @@ -45,7 +49,18 @@ public CommentResponse createComment(Long userId, Long postId, CommentCreateRequ
Comment comment = Comment.create(request, parent, user, post);
Comment savedComment = commentRepository.save(comment);

eventPublisher.publishEvent(new CommentCreatedEvent(userId, savedComment.getId()));
Long receiverId = post.getUser().getId();

if (!receiverId.equals(userId)) {
eventPublisher.publishEvent(
new CommentCreatedEvent(
postId,
post.getUser().getId(),
comment.getId(),
userId,
user.getUsername())
);
}

return CommentResponse.fromComment(savedComment);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.jiwon.mylog.domain.event;

import com.jiwon.mylog.domain.event.dto.CommentCreatedEvent;
import com.jiwon.mylog.domain.notification.entity.Notification;
import com.jiwon.mylog.domain.notification.repository.NotificationRepository;
import com.jiwon.mylog.domain.notification.service.NotificationService;
import com.jiwon.mylog.domain.notification.entity.NotificationType;
import com.jiwon.mylog.domain.user.entity.User;
import com.jiwon.mylog.domain.user.repository.UserRepository;
import com.jiwon.mylog.global.common.error.ErrorCode;
import com.jiwon.mylog.global.common.error.exception.NotFoundException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@RequiredArgsConstructor
@Component
public class NotificationEventListener {

private final NotificationService notificationService;
private final NotificationRepository notificationRepository;
private final UserRepository userRepository;

@Transactional
@EventListener
public void handleCommentCreated(CommentCreatedEvent event) {
Long receiverId = event.getPostWriterId();
User receiver = userRepository.findById(receiverId)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_USER));

Notification notification = Notification.create(
receiver,
event.getCommentWriterName() + "왹이 외계 수집물에 발사를 남겼습니다.",
"/posts/" + event.getPostId(),
NotificationType.COMMENT
);
notificationRepository.save(notification);

try {
notificationService.sendNotification(receiverId, notification);
} catch(Exception e) {
log.warn("SSE 전송 실패");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,10 @@ public void handlePostCreated(PostCreatedEvent event) {
@EventListener
public void handleCommentCreated(CommentCreatedEvent event) {
if (historyRepository.countDailyPointByDescription(
event.getUserId(),
event.getCommentWriterId(),
COMMENT_EARN_DESCRIPTION) >= COMMENT_POINT_LIMIT) {
return;
}
pointService.earnPoint(event.getUserId(), 44, COMMENT_EARN_DESCRIPTION);
pointService.earnPoint(event.getCommentWriterId(), 44, COMMENT_EARN_DESCRIPTION);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
@Getter
@RequiredArgsConstructor
public class CommentCreatedEvent {
private final Long userId;
private final Long postId;
private final Long postWriterId;
private final Long commentId;
private final Long commentWriterId;
private final String commentWriterName;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.jiwon.mylog.domain.notification.controller;

import com.jiwon.mylog.domain.notification.dto.NotificationCountResponse;
import com.jiwon.mylog.domain.notification.service.NotificationService;
import com.jiwon.mylog.global.common.entity.PageResponse;
import com.jiwon.mylog.global.common.error.ErrorCode;
import com.jiwon.mylog.global.common.error.exception.UnauthorizedException;
import com.jiwon.mylog.global.security.auth.annotation.LoginUser;
import com.jiwon.mylog.global.security.jwt.JwtService;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

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

private final NotificationService notificationService;
private final JwtService jwtService;

@PatchMapping("/notifications")
public ResponseEntity<Void> updateNotificationRead(@LoginUser Long userId) {
notificationService.updateNotificationRead(userId);
return new ResponseEntity<>(HttpStatus.OK);
}

@GetMapping("/notifications")
public ResponseEntity<PageResponse> getNotifications(
@LoginUser Long userId,
@PageableDefault(sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) {
PageResponse response = notificationService.getAllNotifications(userId, pageable);
return ResponseEntity.ok(response);
}

@GetMapping("/notifications/count")
public ResponseEntity<NotificationCountResponse> countUnreadNotification(@LoginUser Long userId) {
NotificationCountResponse response = notificationService.countUnreadNotifications(userId);
return ResponseEntity.ok(response);
}

@GetMapping(value = "/sse/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter subscribe(
@CookieValue(value = "accessToken", required = false) String accessToken,
HttpServletResponse response) {

if (accessToken == null || !jwtService.validateToken(accessToken)) {
throw new UnauthorizedException(ErrorCode.UNAUTHORIZED);
}

response.setHeader("Cache-Control", "no-cache");
response.setHeader("Connection", "keep-alive");
response.setHeader("X-Accel-Buffering", "no");

Long userId = jwtService.getUserId(accessToken);
return notificationService.subscribe(userId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.jiwon.mylog.domain.notification.dto;

import lombok.AllArgsConstructor;
import lombok.Getter;

@AllArgsConstructor
@Getter
public class NotificationCountResponse {
private long unreadCount;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.jiwon.mylog.domain.notification.dto;

import com.jiwon.mylog.domain.notification.entity.Notification;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;

import java.time.LocalDateTime;

@AllArgsConstructor
@Builder
@Getter
public class NotificationResponse {
private Long notificationId;
private String content;
private String url;
private LocalDateTime createdAt;

public static NotificationResponse from(Notification notification) {
return NotificationResponse.builder()
.notificationId(notification.getId())
.content(notification.getContent())
.url(notification.getUrl())
.createdAt(notification.getCreatedAt())
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.jiwon.mylog.domain.notification.entity;

import com.jiwon.mylog.domain.user.entity.User;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.LocalDateTime;

@EntityListeners(AuditingEntityListener.class)
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Getter
@Entity
public class Notification {

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

@Column(nullable = false)
private String content;

@Column(nullable = false)
private String url;

@ManyToOne(fetch = FetchType.LAZY)
private User receiver;

@Enumerated(EnumType.STRING)
@Column(nullable = false)
private NotificationType type;

@Column(nullable = false)
private boolean isRead = false;

@CreatedDate
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;

public static Notification create(User user, String content, String url, NotificationType type) {
return Notification.builder()
.receiver(user)
.content(content)
.url(url)
.type(type)
.isRead(false)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.jiwon.mylog.domain.notification.entity;

public enum NotificationType {
COMMENT,
REPLY,
FOLLOW,
UNFOLLOW,
SERVER,
ETC
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.jiwon.mylog.domain.notification.repository;

import com.jiwon.mylog.domain.notification.entity.Notification;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface NotificationRepository extends JpaRepository<Notification, Long> {

@Modifying(clearAutomatically = true)
@Query("update Notification n set n.isRead = true where n.receiver.id = :receiverId and n.isRead = false")
void updateReadStateByReceiverId(@Param("receiverId") Long receiverId);

@Query("select count(n.id) from Notification n where n.receiver.id = :receiverId and n.isRead = false")
long countByReceiverIdAndReadIsFalse(@Param("receiverId") Long receiverId);

Page<Notification> findAllByReceiverId(Long receiverId, Pageable pageable);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.jiwon.mylog.domain.notification.repository;

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

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

@Repository
public class SseEmitterRepository {

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

public String save(Long userId, SseEmitter emitter) {
String emitterId = generateConnectionId(userId);
emitters.put(emitterId, emitter);
connectionsByUserId.put(emitterId, userId);
emitterTimestamps.put(emitterId, System.currentTimeMillis());
return emitterId;
}

public Optional<SseEmitter> get(String emitterId) {
return Optional.ofNullable(emitters.get(emitterId));
}

public void delete(String emitterId) {
emitters.remove(emitterId);
connectionsByUserId.remove(emitterId);
emitterTimestamps.remove(emitterId);
}

public List<String> findEmitterIdsByUserId(Long userId) {
return connectionsByUserId.entrySet().stream()
.filter(entry -> entry.getValue().equals(userId))
.map(Map.Entry::getKey)
.toList();
}

public List<SseEmitter> findEmittersByUserId(Long userId) {
return connectionsByUserId.entrySet().stream()
.filter(entry -> entry.getValue().equals(userId))
.map(entry -> emitters.get(entry.getKey()))
.filter(Objects::nonNull)
.toList();
}

public Map<String, Long> findAllEmitterTimestamps() {
return new HashMap<>(emitterTimestamps);
}

private String generateConnectionId(Long userId) {
return userId + "_" + UUID.randomUUID().toString().substring(0, 8);
}
}
Loading