diff --git a/src/main/java/com/back/domain/notification/controller/NotificationController.java b/src/main/java/com/back/domain/notification/controller/NotificationController.java new file mode 100644 index 00000000..903c5b0b --- /dev/null +++ b/src/main/java/com/back/domain/notification/controller/NotificationController.java @@ -0,0 +1,157 @@ +package com.back.domain.notification.controller; + +import com.back.domain.notification.dto.NotificationCreateRequest; +import com.back.domain.notification.dto.NotificationResponse; +import com.back.domain.notification.dto.NotificationListResponse; +import com.back.domain.notification.entity.Notification; +import com.back.domain.notification.service.NotificationService; +import com.back.domain.studyroom.entity.Room; +import com.back.domain.studyroom.repository.RoomRepository; +import com.back.domain.user.entity.User; +import com.back.domain.user.repository.UserRepository; +import com.back.global.common.dto.RsData; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import com.back.global.security.user.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/notifications") +@Tag(name = "알림", description = "알림 관련 API") +public class NotificationController { + + private final NotificationService notificationService; + private final UserRepository userRepository; + private final RoomRepository roomRepository; + + @Operation(summary = "알림 전송", description = "USER/ROOM/COMMUNITY/SYSTEM 타입별 알림 생성 및 전송") + @PostMapping + public ResponseEntity> createNotification( + @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody NotificationCreateRequest request) { + + log.info("알림 전송 요청 - 타입: {}, 제목: {}", request.targetType(), request.title()); + + Notification notification = switch (request.targetType()) { + case "USER" -> { + User targetUser = userRepository.findById(request.targetId()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + yield notificationService.createPersonalNotification( + targetUser, + request.title(), + request.message(), + request.redirectUrl() + ); + } + case "ROOM" -> { + Room room = roomRepository.findById(request.targetId()) + .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); + yield notificationService.createRoomNotification( + room, + request.title(), + request.message(), + request.redirectUrl() + ); + } + case "COMMUNITY" -> { + User targetUser = userRepository.findById(request.targetId()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + yield notificationService.createCommunityNotification( + targetUser, + request.title(), + request.message(), + request.redirectUrl() + ); + } + case "SYSTEM" -> { + yield notificationService.createSystemNotification( + request.title(), + request.message(), + request.redirectUrl() + ); + } + default -> throw new IllegalArgumentException("유효하지 않은 알림 타입입니다: " + request.targetType()); + }; + + NotificationResponse response = NotificationResponse.from(notification); + + return ResponseEntity.ok(RsData.success("알림 전송 성공", response)); + } + + @Operation(summary = "알림 목록 조회", description = "사용자의 알림 목록 조회 (페이징)") + @GetMapping + public ResponseEntity> getNotifications( + @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails, + @Parameter(description = "페이지 번호 (0부터 시작)") @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지 크기") @RequestParam(defaultValue = "20") int size, + @Parameter(description = "읽지 않은 알림만 조회") @RequestParam(defaultValue = "false") boolean unreadOnly) { + + log.info("알림 목록 조회 - 유저 ID: {}, 읽지 않은 것만: {}", userDetails.getUserId(), unreadOnly); + + Pageable pageable = PageRequest.of(page, size); + Page notifications; + + if (unreadOnly) { + notifications = notificationService.getUnreadNotifications(userDetails.getUserId(), pageable); + } else { + notifications = notificationService.getUserNotifications(userDetails.getUserId(), pageable); + } + + long unreadCount = notificationService.getUnreadCount(userDetails.getUserId()); + + NotificationListResponse response = NotificationListResponse.from( + notifications, + userDetails.getUserId(), + unreadCount, + notificationService + ); + + return ResponseEntity.ok(RsData.success("알림 목록 조회 성공", response)); + } + + @Operation(summary = "알림 읽음 처리", description = "특정 알림을 읽음 상태로 변경") + @PutMapping("/{notificationId}/read") + public ResponseEntity> markAsRead( + @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails, + @Parameter(description = "알림 ID") @PathVariable Long notificationId) { + + log.info("알림 읽음 처리 - 알림 ID: {}, 유저 ID: {}", notificationId, userDetails.getUserId()); + + User user = userRepository.findById(userDetails.getUserId()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + notificationService.markAsRead(notificationId, user); + + Notification notification = notificationService.getNotification(notificationId); + NotificationResponse response = NotificationResponse.from(notification); + + return ResponseEntity.ok(RsData.success("알림 읽음 처리 성공", response)); + } + + @Operation(summary = "모든 알림 읽음 처리", description = "사용자의 읽지 않은 모든 알림을 읽음 상태로 변경") + @PutMapping("/read-all") + public ResponseEntity> markAllAsRead( + @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails) { + + log.info("전체 알림 읽음 처리 - 유저 ID: {}", userDetails.getUserId()); + + User user = userRepository.findById(userDetails.getUserId()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + notificationService.markMultipleAsRead(userDetails.getUserId(), user); + + return ResponseEntity.ok(RsData.success("전체 알림 읽음 처리 성공")); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/dto/NotificationCreateRequest.java b/src/main/java/com/back/domain/notification/dto/NotificationCreateRequest.java new file mode 100644 index 00000000..315b2668 --- /dev/null +++ b/src/main/java/com/back/domain/notification/dto/NotificationCreateRequest.java @@ -0,0 +1,13 @@ +package com.back.domain.notification.dto; + +public record NotificationCreateRequest( + String targetType, // USER, ROOM, SYSTEM + Long targetId, // nullable (SYSTEM일 때 null) + String title, + String message, + String notificationType, // STUDY_REMINDER, ROOM_JOIN 등 + String redirectUrl, // targetUrl + String scheduleType, // ONE_TIME (향후 확장용) + String scheduledAt // 예약 시간 (향후 확장용) +) { +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/dto/NotificationItemDto.java b/src/main/java/com/back/domain/notification/dto/NotificationItemDto.java new file mode 100644 index 00000000..3137c4f1 --- /dev/null +++ b/src/main/java/com/back/domain/notification/dto/NotificationItemDto.java @@ -0,0 +1,31 @@ +package com.back.domain.notification.dto; + +import com.back.domain.notification.entity.Notification; +import com.back.domain.notification.entity.NotificationType; + +import java.time.LocalDateTime; + +/** + * 알림 목록 아이템 DTO + */ +public record NotificationItemDto( + Long notificationId, + String title, + String message, + NotificationType notificationType, + String targetUrl, + boolean isRead, + LocalDateTime createdAt +) { + public static NotificationItemDto from(Notification notification, boolean isRead) { + return new NotificationItemDto( + notification.getId(), + notification.getTitle(), + notification.getContent(), + notification.getType(), + notification.getTargetUrl(), + isRead, + notification.getCreatedAt() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/dto/NotificationListResponse.java b/src/main/java/com/back/domain/notification/dto/NotificationListResponse.java new file mode 100644 index 00000000..3e7496dc --- /dev/null +++ b/src/main/java/com/back/domain/notification/dto/NotificationListResponse.java @@ -0,0 +1,48 @@ +package com.back.domain.notification.dto; + +import com.back.domain.notification.entity.Notification; +import com.back.domain.notification.service.NotificationService; +import org.springframework.data.domain.Page; + +import java.util.List; + +/** + * 알림 목록 응답 DTO + */ +public record NotificationListResponse( + List content, + PageableDto pageable, + long unreadCount +) { + + // 페이지 정보 DTO + public record PageableDto( + int page, + int size, + boolean hasNext + ) {} + + public static NotificationListResponse from( + Page notifications, + Long userId, + long unreadCount, + NotificationService notificationService) { + + List items = notifications.getContent().stream() + .map(notification -> { + boolean isRead = notificationService.isNotificationRead(notification.getId(), userId); + return NotificationItemDto.from(notification, isRead); + }) + .toList(); + + return new NotificationListResponse( + items, + new PageableDto( + notifications.getNumber(), + notifications.getSize(), + notifications.hasNext() + ), + unreadCount + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/dto/NotificationResponse.java b/src/main/java/com/back/domain/notification/dto/NotificationResponse.java new file mode 100644 index 00000000..b0a56ab8 --- /dev/null +++ b/src/main/java/com/back/domain/notification/dto/NotificationResponse.java @@ -0,0 +1,46 @@ +package com.back.domain.notification.dto; + +import com.back.domain.notification.entity.Notification; +import com.back.domain.notification.entity.NotificationType; + +import java.time.LocalDateTime; + +/** + * 알림 응답 DTO + */ +public record NotificationResponse( + Long notificationId, + String title, + String message, + NotificationType notificationType, + String targetUrl, + boolean isRead, + LocalDateTime createdAt, + LocalDateTime readAt +) { + public static NotificationResponse from(Notification notification) { + return new NotificationResponse( + notification.getId(), + notification.getTitle(), + notification.getContent(), + notification.getType(), + notification.getTargetUrl(), + false, // 읽음 여부는 NotificationListResponse에서 처리 + notification.getCreatedAt(), + null // readAt은 NotificationRead에서 가져와야 함 + ); + } + + public static NotificationResponse from(Notification notification, boolean isRead, LocalDateTime readAt) { + return new NotificationResponse( + notification.getId(), + notification.getTitle(), + notification.getContent(), + notification.getType(), + notification.getTargetUrl(), + isRead, + notification.getCreatedAt(), + readAt + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/dto/NotificationWebSocketDto.java b/src/main/java/com/back/domain/notification/dto/NotificationWebSocketDto.java new file mode 100644 index 00000000..232f246e --- /dev/null +++ b/src/main/java/com/back/domain/notification/dto/NotificationWebSocketDto.java @@ -0,0 +1,33 @@ +package com.back.domain.notification.dto; + +import com.back.domain.notification.entity.NotificationType; + +import java.time.LocalDateTime; + +public record NotificationWebSocketDto( + Long notificationId, + String title, + String message, + NotificationType notificationType, + String targetUrl, + LocalDateTime createdAt +) { + + public static NotificationWebSocketDto from( + Long notificationId, + String title, + String content, + NotificationType type, + String targetUrl, + LocalDateTime createdAt) { + + return new NotificationWebSocketDto( + notificationId, + title, + content, + type, + targetUrl, + createdAt + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/entity/Notification.java b/src/main/java/com/back/domain/notification/entity/Notification.java new file mode 100644 index 00000000..47778c3c --- /dev/null +++ b/src/main/java/com/back/domain/notification/entity/Notification.java @@ -0,0 +1,105 @@ +package com.back.domain.notification.entity; + +import com.back.domain.studyroom.entity.Room; +import com.back.domain.user.entity.User; +import com.back.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor +public class Notification extends BaseEntity { + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private NotificationType type; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "room_id") + private Room room; + + @Column(nullable = false) + private String title; + + @Column(columnDefinition = "TEXT", nullable = false) + private String content; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private NotificationStatus status; + + private String targetUrl; + + @OneToMany(mappedBy = "notification", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private List notificationReads = new ArrayList<>(); + + private Notification(NotificationType type, String title, String content, String targetUrl) { + this.type = type; + this.title = title; + this.content = content; + this.targetUrl = targetUrl; + this.status = NotificationStatus.UNREAD; + } + + // 개인 알림 생성 + public static Notification createPersonalNotification(User user, String title, String content, String targetUrl) { + Notification notification = new Notification(NotificationType.PERSONAL, title, content, targetUrl); + notification.user = user; + return notification; + } + + // 스터디룸 알림 생성 + public static Notification createRoomNotification(Room room, String title, String content, String targetUrl) { + Notification notification = new Notification(NotificationType.ROOM, title, content, targetUrl); + notification.room = room; + return notification; + } + + // 시스템 알림 생성 + public static Notification createSystemNotification(String title, String content, String targetUrl) { + return new Notification(NotificationType.SYSTEM, title, content, targetUrl); + } + + // 커뮤니티 알림 생성 + public static Notification createCommunityNotification(User user, String title, String content, String targetUrl) { + Notification notification = new Notification(NotificationType.COMMUNITY, title, content, targetUrl); + notification.user = user; + return notification; + } + + // 알림을 읽음 상태로 변경 + public void markAsRead() { + this.status = NotificationStatus.READ; + } + + // 전체 알림인지 확인 + public boolean isSystemNotification() { + return this.type == NotificationType.SYSTEM; + } + + // 개인 알림인지 확인 + public boolean isPersonalNotification() { + return this.type == NotificationType.PERSONAL; + } + + // 특정 유저에게 표시되어야 하는 알림인지 확인 + public boolean isVisibleToUser(Long userId) { + + // 시스템 알림은 모두에게 표시 + if (isSystemNotification()) { + return true; + } + + // 개인 알림은 해당 유저에게만 표시 + return user != null && user.getId().equals(userId); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/entity/NotificationRead.java b/src/main/java/com/back/domain/notification/entity/NotificationRead.java new file mode 100644 index 00000000..34dc62f4 --- /dev/null +++ b/src/main/java/com/back/domain/notification/entity/NotificationRead.java @@ -0,0 +1,42 @@ +package com.back.domain.notification.entity; + +import com.back.domain.user.entity.User; +import com.back.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor +public class NotificationRead extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "notification_id", nullable = false) + private Notification notification; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false) + private LocalDateTime readAt; + + public NotificationRead(Notification notification, User user) { + this.notification = notification; + this.user = user; + this.readAt = LocalDateTime.now(); + } + + // 읽음 기록 생성 + public static NotificationRead create(Notification notification, User user) { + return new NotificationRead(notification, user); + } + + // 특정 시간 이후에 읽었는지 확인 + public boolean isReadAfter(LocalDateTime dateTime) { + return readAt.isAfter(dateTime); + } +} diff --git a/src/main/java/com/back/domain/notification/entity/NotificationSetting.java b/src/main/java/com/back/domain/notification/entity/NotificationSetting.java new file mode 100644 index 00000000..f409af72 --- /dev/null +++ b/src/main/java/com/back/domain/notification/entity/NotificationSetting.java @@ -0,0 +1,59 @@ +package com.back.domain.notification.entity; + +import com.back.domain.user.entity.User; +import com.back.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +@Table( + uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "type"}) +) +public class NotificationSetting extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private NotificationSettingType type; + + @Column(nullable = false) + private boolean enabled; + + public NotificationSetting(User user, NotificationSettingType type, boolean enabled) { + this.user = user; + this.type = type; + this.enabled = enabled; + } + + // 알림 설정 생성 (기본 활성화 상태) + public static NotificationSetting create(User user, NotificationSettingType type) { + return new NotificationSetting(user, type, true); + } + + // 알림 설정 생성 (활성화 여부 직접 지정) + public static NotificationSetting create(User user, NotificationSettingType type, boolean enabled) { + return new NotificationSetting(user, type, enabled); + } + + // 알림 설정 토글 + public void toggle() { + this.enabled = !this.enabled; + } + + // 알림 활성화 + public void enable() { + this.enabled = true; + } + + // 알림 비활성화 + public void disable() { + this.enabled = false; + } + +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/entity/NotificationSettingType.java b/src/main/java/com/back/domain/notification/entity/NotificationSettingType.java new file mode 100644 index 00000000..54376155 --- /dev/null +++ b/src/main/java/com/back/domain/notification/entity/NotificationSettingType.java @@ -0,0 +1,9 @@ +package com.back.domain.notification.entity; + +public enum NotificationSettingType { + SYSTEM, // 시스템 알림 + ROOM_JOIN, // 스터디룸 입장 알림 + ROOM_NOTICE, // 스터디룸 공지 알림 + POST_COMMENT, // 게시글 댓글 알림 + POST_LIKE // 게시글 좋아요 알림 +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/entity/NotificationStatus.java b/src/main/java/com/back/domain/notification/entity/NotificationStatus.java new file mode 100644 index 00000000..d54b39ba --- /dev/null +++ b/src/main/java/com/back/domain/notification/entity/NotificationStatus.java @@ -0,0 +1,6 @@ +package com.back.domain.notification.entity; + +public enum NotificationStatus { + UNREAD, // 읽지 않음 + READ // 읽음 +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/entity/NotificationType.java b/src/main/java/com/back/domain/notification/entity/NotificationType.java new file mode 100644 index 00000000..bba0734e --- /dev/null +++ b/src/main/java/com/back/domain/notification/entity/NotificationType.java @@ -0,0 +1,8 @@ +package com.back.domain.notification.entity; + +public enum NotificationType { + PERSONAL, // 개인 알림 + ROOM, // 스터디룸 관련 알림 + COMMUNITY, // 커뮤니티 관련 알림 + SYSTEM // 시스템 전체 알림 +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/repository/NotificationReadRepository.java b/src/main/java/com/back/domain/notification/repository/NotificationReadRepository.java new file mode 100644 index 00000000..53a5cc8d --- /dev/null +++ b/src/main/java/com/back/domain/notification/repository/NotificationReadRepository.java @@ -0,0 +1,18 @@ +package com.back.domain.notification.repository; + +import com.back.domain.notification.entity.NotificationRead; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface NotificationReadRepository extends JpaRepository { + + // 특정 유저가 특정 알림을 읽었는지 확인 + boolean existsByNotificationIdAndUserId(Long notificationId, Long userId); + + // 특정 유저의 특정 알림 읽음 기록 조회 + Optional findByNotificationIdAndUserId(Long notificationId, Long userId); + + // 특정 알림의 모든 읽음 기록 삭제 + void deleteByNotificationId(Long notificationId); +} diff --git a/src/main/java/com/back/domain/notification/repository/NotificationRepository.java b/src/main/java/com/back/domain/notification/repository/NotificationRepository.java new file mode 100644 index 00000000..d9bbeabc --- /dev/null +++ b/src/main/java/com/back/domain/notification/repository/NotificationRepository.java @@ -0,0 +1,41 @@ +package com.back.domain.notification.repository; + +import com.back.domain.notification.entity.Notification; +import com.back.domain.notification.entity.NotificationType; +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.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface NotificationRepository extends JpaRepository { + + // 특정 유저의 알림 목록 조회 (개인 알림 + 시스템 알림) + @Query("SELECT n FROM Notification n " + + "WHERE n.user.id = :userId OR n.type = 'SYSTEM' " + + "ORDER BY n.createdAt DESC") + Page findByUserIdOrSystemType(@Param("userId") Long userId, Pageable pageable); + + // 특정 유저의 읽지 않은 알림 개수 조회 + @Query("SELECT COUNT(n) FROM Notification n " + + "LEFT JOIN NotificationRead nr ON n.id = nr.notification.id AND nr.user.id = :userId " + + "WHERE (n.user.id = :userId OR n.type = 'SYSTEM') " + + "AND nr.id IS NULL") + long countUnreadByUserId(@Param("userId") Long userId); + + // 특정 유저의 읽지 않은 알림 목록 조회 + @Query("SELECT n FROM Notification n " + + "LEFT JOIN NotificationRead nr ON n.id = nr.notification.id AND nr.user.id = :userId " + + "WHERE (n.user.id = :userId OR n.type = 'SYSTEM') " + + "AND nr.id IS NULL " + + "ORDER BY n.createdAt DESC") + Page findUnreadByUserId(@Param("userId") Long userId, Pageable pageable); + + // 특정 스터디룸의 알림 조회 + Page findByRoomIdOrderByCreatedAtDesc(Long roomId, Pageable pageable); + + // 특정 타입의 알림 조회 + List findByType(NotificationType type); +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/repository/NotificationSettingRepository.java b/src/main/java/com/back/domain/notification/repository/NotificationSettingRepository.java new file mode 100644 index 00000000..4acc5677 --- /dev/null +++ b/src/main/java/com/back/domain/notification/repository/NotificationSettingRepository.java @@ -0,0 +1,23 @@ +package com.back.domain.notification.repository; + +import com.back.domain.notification.entity.NotificationSetting; +import com.back.domain.notification.entity.NotificationSettingType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface NotificationSettingRepository extends JpaRepository { + + // 특정 유저의 모든 알림 설정 조회 + List findByUserId(Long userId); + + // 특정 유저의 특정 타입 알림 설정 조회 + Optional findByUserIdAndType(Long userId, NotificationSettingType type); + + // 특정 유저의 특정 타입 알림 설정 존재 여부 + boolean existsByUserIdAndType(Long userId, NotificationSettingType type); + + // 특정 유저의 활성화된 알림 설정만 조회 + List findByUserIdAndEnabledTrue(Long userId); +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/service/NotificationService.java b/src/main/java/com/back/domain/notification/service/NotificationService.java new file mode 100644 index 00000000..8647b8de --- /dev/null +++ b/src/main/java/com/back/domain/notification/service/NotificationService.java @@ -0,0 +1,184 @@ +package com.back.domain.notification.service; + +import com.back.domain.notification.dto.NotificationWebSocketDto; +import com.back.domain.notification.entity.Notification; +import com.back.domain.notification.entity.NotificationRead; +import com.back.domain.notification.repository.NotificationReadRepository; +import com.back.domain.notification.repository.NotificationRepository; +import com.back.domain.studyroom.entity.Room; +import com.back.domain.user.entity.User; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class NotificationService { + + private final NotificationRepository notificationRepository; + private final NotificationReadRepository notificationReadRepository; + private final NotificationWebSocketService webSocketService; + + // ==================== 알림 생성 및 전송 ==================== + + // 개인 알림 생성 및 전송 + @Transactional + public Notification createPersonalNotification(User user, String title, String content, String targetUrl) { + + // DB에 알림 저장 + Notification notification = Notification.createPersonalNotification(user, title, content, targetUrl); + notificationRepository.save(notification); + + // WebSocket으로 실시간 전송 + NotificationWebSocketDto dto = NotificationWebSocketDto.from( + notification.getId(), + notification.getTitle(), + notification.getContent(), + notification.getType(), + notification.getTargetUrl(), + notification.getCreatedAt() + ); + webSocketService.sendNotificationToUser(user.getId(), dto); + + log.info("개인 알림 생성 - 유저 ID: {}, 알림 ID: {}", user.getId(), notification.getId()); + return notification; + } + + // 스터디룸 알림 생성 및 전송 + @Transactional + public Notification createRoomNotification(Room room, String title, String content, String targetUrl) { + + Notification notification = Notification.createRoomNotification(room, title, content, targetUrl); + notificationRepository.save(notification); + + NotificationWebSocketDto dto = NotificationWebSocketDto.from( + notification.getId(), + notification.getTitle(), + notification.getContent(), + notification.getType(), + notification.getTargetUrl(), + notification.getCreatedAt() + ); + webSocketService.sendNotificationToRoom(room.getId(), dto); + + log.info("스터디룸 알림 생성 - 룸 ID: {}, 알림 ID: {}", room.getId(), notification.getId()); + return notification; + } + + // 시스템 전체 알림 생성 및 브로드캐스트 + @Transactional + public Notification createSystemNotification(String title, String content, String targetUrl) { + + Notification notification = Notification.createSystemNotification(title, content, targetUrl); + notificationRepository.save(notification); + + NotificationWebSocketDto dto = NotificationWebSocketDto.from( + notification.getId(), + notification.getTitle(), + notification.getContent(), + notification.getType(), + notification.getTargetUrl(), + notification.getCreatedAt() + ); + webSocketService.broadcastSystemNotification(dto); + + log.info("시스템 알림 생성 - 알림 ID: {}", notification.getId()); + return notification; + } + + // 커뮤니티 알림 생성 및 전송 + @Transactional + public Notification createCommunityNotification(User user, String title, String content, String targetUrl) { + + Notification notification = Notification.createCommunityNotification(user, title, content, targetUrl); + notificationRepository.save(notification); + + NotificationWebSocketDto dto = NotificationWebSocketDto.from( + notification.getId(), + notification.getTitle(), + notification.getContent(), + notification.getType(), + notification.getTargetUrl(), + notification.getCreatedAt() + ); + webSocketService.sendNotificationToUser(user.getId(), dto); + + log.info("커뮤니티 알림 생성 - 유저 ID: {}, 알림 ID: {}", user.getId(), notification.getId()); + return notification; + } + + // ==================== 알림 조회 ==================== + + // 특정 유저의 알림 목록 조회 (개인 알림 + 시스템 알림) + public Page getUserNotifications(Long userId, Pageable pageable) { + return notificationRepository.findByUserIdOrSystemType(userId, pageable); + } + + // 특정 유저의 읽지 않은 알림 목록 조회 + public Page getUnreadNotifications(Long userId, Pageable pageable) { + return notificationRepository.findUnreadByUserId(userId, pageable); + } + + // 특정 유저의 읽지 않은 알림 개수 조회 + public long getUnreadCount(Long userId) { + return notificationRepository.countUnreadByUserId(userId); + } + + // 알림 단건 조회 + public Notification getNotification(Long notificationId) { + return notificationRepository.findById(notificationId) + .orElseThrow(() -> new CustomException(ErrorCode.NOTIFICATION_NOT_FOUND)); + } + + // 특정 유저가 특정 알림을 읽었는지 확인 + public boolean isNotificationRead(Long notificationId, Long userId) { + return notificationReadRepository.existsByNotificationIdAndUserId(notificationId, userId); + } + + // ==================== 알림 읽음 처리 ==================== + + // 알림 읽음 처리 + @Transactional + public void markAsRead(Long notificationId, User user) { + // 1. 알림 존재 확인 + Notification notification = getNotification(notificationId); + + // 2. 이미 읽은 알림인지 확인 + if (notificationReadRepository.existsByNotificationIdAndUserId(notificationId, user.getId())) { + log.debug("이미 읽은 알림 - 알림 ID: {}, 유저 ID: {}", notificationId, user.getId()); + return; + } + + // 3. 읽음 기록 생성 + NotificationRead notificationRead = NotificationRead.create(notification, user); + notificationReadRepository.save(notificationRead); + + // 4. 알림 상태 업데이트 (선택적) + notification.markAsRead(); + + log.info("알림 읽음 처리 - 알림 ID: {}, 유저 ID: {}", notificationId, user.getId()); + } + + // 여러 알림 일괄 읽음 처리 + @Transactional + public void markMultipleAsRead(Long userId, User user) { + Page unreadNotifications = getUnreadNotifications(userId, Pageable.unpaged()); + + for (Notification notification : unreadNotifications) { + if (!notificationReadRepository.existsByNotificationIdAndUserId(notification.getId(), user.getId())) { + NotificationRead notificationRead = NotificationRead.create(notification, user); + notificationReadRepository.save(notificationRead); + notification.markAsRead(); + } + } + + log.info("일괄 읽음 처리 - 유저 ID: {}, 처리 개수: {}", userId, unreadNotifications.getTotalElements()); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/service/NotificationWebSocketService.java b/src/main/java/com/back/domain/notification/service/NotificationWebSocketService.java new file mode 100644 index 00000000..c7da31c0 --- /dev/null +++ b/src/main/java/com/back/domain/notification/service/NotificationWebSocketService.java @@ -0,0 +1,57 @@ +package com.back.domain.notification.service; + +import com.back.domain.notification.dto.NotificationWebSocketDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class NotificationWebSocketService { // WebSocket을 통한 실시간 알림 전송 서비스 + + private final SimpMessagingTemplate messagingTemplate; + + // 특정 유저에게 알림 전송 + public void sendNotificationToUser(Long userId, NotificationWebSocketDto notificationDto) { + try { + String destination = "/topic/user/" + userId + "/notifications"; + messagingTemplate.convertAndSend(destination, notificationDto); + + log.info("실시간 알림 전송 성공 - 유저 ID: {}, 알림 ID: {}, 제목: {}", + userId, notificationDto.notificationId(), notificationDto.title()); + + } catch (Exception e) { + log.error("실시간 알림 전송 실패 - 유저 ID: {}, 오류: {}", userId, e.getMessage(), e); + } + } + + // 전체 유저에게 시스템 알림 브로드캐스트 + public void broadcastSystemNotification(NotificationWebSocketDto notificationDto) { + try { + String destination = "/topic/notifications/system"; + messagingTemplate.convertAndSend(destination, notificationDto); + + log.info("시스템 알림 브로드캐스트 성공 - 알림 ID: {}, 제목: {}", + notificationDto.notificationId(), notificationDto.title()); + + } catch (Exception e) { + log.error("시스템 알림 브로드캐스트 실패 - 오류: {}", e.getMessage(), e); + } + } + + // 스터디룸 멤버들에게 알림 전송 + public void sendNotificationToRoom(Long roomId, NotificationWebSocketDto notificationDto) { + try { + String destination = "/topic/room/" + roomId + "/notifications"; + messagingTemplate.convertAndSend(destination, notificationDto); + + log.info("스터디룸 알림 전송 성공 - 룸 ID: {}, 알림 ID: {}, 제목: {}", + roomId, notificationDto.notificationId(), notificationDto.title()); + + } catch (Exception e) { + log.error("스터디룸 알림 전송 실패 - 룸 ID: {}, 오류: {}", roomId, e.getMessage(), e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/back/global/exception/ErrorCode.java b/src/main/java/com/back/global/exception/ErrorCode.java index 8f16e7e7..03b96782 100644 --- a/src/main/java/com/back/global/exception/ErrorCode.java +++ b/src/main/java/com/back/global/exception/ErrorCode.java @@ -52,6 +52,10 @@ public enum ErrorCode { TODO_NOT_FOUND(HttpStatus.NOT_FOUND, "TODO_001", "존재하지 않는 할 일입니다."), TODO_FORBIDDEN(HttpStatus.FORBIDDEN, "TODO_002", "할 일에 대한 접근 권한이 없습니다."), + // ======================== 알림 관련 ======================== + NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTIFICATION_001", "존재하지 않는 알림입니다."), + NOTIFICATION_FORBIDDEN(HttpStatus.FORBIDDEN, "NOTIFICATION_002", "알림에 대한 접근 권한이 없습니다."), + NOTIFICATION_ALREADY_READ(HttpStatus.BAD_REQUEST, "NOTIFICATION_003", "이미 읽은 알림입니다."), // ======================== 메시지 관련 ======================== MESSAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "MESSAGE_001", "존재하지 않는 메시지입니다."),