Skip to content

Commit 9362f98

Browse files
authored
[feat] 알림 목록 조회 기능 구현 #70 (#74)
* feat: 알림(Notification) 엔티티 구현 * feat: 단일 알림 항목 DTO 추가 * feat: 알림 목록 페이지네이션 DTO 추가 * feat: 알림 이동(클릭) 응답 DTO 추가 * feat: 알림(Notification) Repository에 조회 기능 추가 - `findMyNotificationsFirstPage`: 사용자 ID를 기반으로 알림 목록의 첫 페이지를 조회하는 쿼리 추가 - `findMyNotificationsAfter`: 커서(lastCreatedAt, lastId)를 사용하여 다음 페이지의 알림 목록을 효율적으로 조회하는 쿼리 추가 - `findByIdAndUserId`: 특정 사용자의 특정 알림을 안전하게 조회하는 메서드 추가 * eat: 알림 종류(NotificationType) Enum 추가 - `NotificationType`: 알림의 종류를 구분하는 열거형 * feat: 알림(Notification) 서비스 로직 구현 - `NotificationService.getNotifications()`: 사용자 ID를 기반으로 알림 목록을 커서 기반 페이지네이션으로 조회 - `NotificationService.markAsReadAndGetPostLink()`: 특정 알림을 읽음 처리하고, 연결된 게시글의 ID 및 API URL을 반환 - 알림이 존재하지 않거나 사용자 ID와 불일치할 경우 예외 처리 추가 * feat: 알림(Notification) 관련 API 엔드포인트 구현
1 parent b3c0e2e commit 9362f98

File tree

8 files changed

+263
-0
lines changed

8 files changed

+263
-0
lines changed
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package com.back.domain.notification.controller;
2+
3+
import com.back.domain.notification.dto.NotificationGoResponseDto;
4+
import com.back.domain.notification.dto.NotificationListResponseDto;
5+
import com.back.domain.notification.service.NotificationService;
6+
import com.back.global.rsData.RsData;
7+
import jakarta.validation.constraints.Max;
8+
import jakarta.validation.constraints.Min;
9+
import lombok.RequiredArgsConstructor;
10+
import org.springframework.format.annotation.DateTimeFormat;
11+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
12+
import org.springframework.validation.annotation.Validated;
13+
import org.springframework.web.bind.annotation.*;
14+
15+
import java.time.LocalDateTime;
16+
17+
@RestController
18+
@RequestMapping("/api/me")
19+
@RequiredArgsConstructor
20+
@Validated
21+
public class NotificationController {
22+
23+
private final NotificationService notificationService;
24+
25+
@GetMapping("/notifications")
26+
public RsData<NotificationListResponseDto> getNotifications(
27+
@AuthenticationPrincipal(expression = "id") Long userId,
28+
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime lastCreatedAt,
29+
@RequestParam(required = false) Long lastId,
30+
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int limit
31+
) {
32+
NotificationListResponseDto body = notificationService.getNotifications(userId, lastCreatedAt, lastId, limit);
33+
return RsData.successOf(body);
34+
}
35+
36+
@PostMapping("/notifications/{id}")
37+
public RsData<NotificationGoResponseDto> goPostLink(
38+
@AuthenticationPrincipal(expression = "id") Long userId,
39+
@PathVariable("id") Long notificationId
40+
) {
41+
var body = notificationService.markAsReadAndGetPostLink(userId, notificationId);
42+
return RsData.successOf(body);
43+
}
44+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.back.domain.notification.dto;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Getter;
5+
6+
@Getter
7+
@AllArgsConstructor
8+
public class NotificationGoResponseDto {
9+
private Long postId;
10+
private String postApiUrl;
11+
}
12+
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.back.domain.notification.dto;
2+
3+
import com.back.domain.notification.entity.Notification;
4+
import com.back.domain.notification.enums.NotificationType;
5+
import java.time.LocalDateTime;
6+
import lombok.Builder;
7+
import lombok.Getter;
8+
9+
@Getter
10+
@Builder
11+
public class NotificationItemDto {
12+
private Long id;
13+
private NotificationType type;
14+
private Long postId;
15+
private String postTitle;
16+
private boolean read;
17+
private LocalDateTime createdAt;
18+
19+
public static NotificationItemDto from(Notification n) {
20+
return NotificationItemDto.builder()
21+
.id(n.getId())
22+
.type(n.getType())
23+
.postId(n.getPost().getId())
24+
.postTitle(n.getPost().getTitle())
25+
.read(n.isRead())
26+
.createdAt(n.getCreatedAt())
27+
.build();
28+
}
29+
}
30+
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.back.domain.notification.dto;
2+
3+
import java.time.LocalDateTime;
4+
import java.util.List;
5+
import lombok.AllArgsConstructor;
6+
import lombok.Getter;
7+
8+
@Getter
9+
@AllArgsConstructor
10+
public class NotificationListResponseDto {
11+
private List<NotificationItemDto> items;
12+
private boolean hasNext;
13+
private LocalDateTime nextCreatedAt;
14+
private Long nextId;
15+
}
16+
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package com.back.domain.notification.entity;
2+
3+
import com.back.domain.notification.enums.NotificationType;
4+
import com.back.domain.post.post.entity.Post;
5+
import com.back.domain.user.entity.User;
6+
import jakarta.persistence.*;
7+
import lombok.*;
8+
import org.springframework.data.annotation.CreatedDate;
9+
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
10+
11+
import java.time.LocalDateTime;
12+
13+
@Entity
14+
@Getter
15+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
16+
@AllArgsConstructor
17+
@Builder
18+
@EntityListeners(AuditingEntityListener.class)
19+
public class Notification {
20+
21+
@Id
22+
@GeneratedValue(strategy = GenerationType.IDENTITY)
23+
private Long id;
24+
25+
@ManyToOne(fetch = FetchType.LAZY)
26+
@JoinColumn(name = "user_id", nullable = false)
27+
private User user;
28+
29+
@ManyToOne(fetch = FetchType.LAZY)
30+
@JoinColumn(name = "post_id", nullable = false)
31+
private Post post;
32+
33+
@Enumerated(EnumType.STRING)
34+
@Column(name = "type", nullable = false, length = 20)
35+
private NotificationType type;
36+
37+
@Builder.Default
38+
@Column(name = "is_read", nullable = false) //read는 DB 예약어 충돌 가능성 있어 @Column(name = "is_read") 설정
39+
private boolean read = false;
40+
41+
@CreatedDate
42+
@Column(name = "created_at", nullable = false, updatable = false)
43+
private LocalDateTime createdAt;
44+
45+
public void markRead() {
46+
this.read = true;
47+
}
48+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.back.domain.notification.enums;
2+
3+
public enum NotificationType {
4+
COMMENT,
5+
LIKE
6+
}
7+
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package com.back.domain.notification.repository;
2+
3+
import com.back.domain.notification.entity.Notification;
4+
import org.springframework.data.domain.Pageable;
5+
import org.springframework.data.jpa.repository.JpaRepository;
6+
import org.springframework.data.jpa.repository.Query;
7+
import org.springframework.data.repository.query.Param;
8+
import org.springframework.stereotype.Repository;
9+
10+
import java.time.LocalDateTime;
11+
import java.util.List;
12+
13+
@Repository
14+
public interface NotificationRepository extends JpaRepository<Notification, Long> {
15+
16+
@Query("""
17+
select n from Notification n
18+
where n.user.id = :userId
19+
order by n.createdAt desc, n.id desc
20+
""")
21+
List<Notification> findMyNotificationsFirstPage(@Param("userId") Long userId,
22+
Pageable pageable);
23+
24+
@Query("""
25+
select n from Notification n
26+
where n.user.id = :userId
27+
and (n.createdAt < :lastCreatedAt or (n.createdAt = :lastCreatedAt and n.id < :lastId))
28+
order by n.createdAt desc, n.id desc
29+
""")
30+
List<Notification> findMyNotificationsAfter(@Param("userId") Long userId,
31+
@Param("lastCreatedAt") LocalDateTime lastCreatedAt,
32+
@Param("lastId") Long lastId,
33+
Pageable pageable);
34+
35+
@Query("""
36+
select n from Notification n
37+
where n.id = :id and n.user.id = :userId
38+
""")
39+
Notification findByIdAndUserId(@Param("id") Long id, @Param("userId") Long userId);
40+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package com.back.domain.notification.service;
2+
3+
import com.back.domain.notification.dto.NotificationGoResponseDto;
4+
import com.back.domain.notification.dto.NotificationItemDto;
5+
import com.back.domain.notification.dto.NotificationListResponseDto;
6+
import com.back.domain.notification.entity.Notification;
7+
import com.back.domain.notification.repository.NotificationRepository;
8+
import com.back.global.exception.ServiceException;
9+
import lombok.RequiredArgsConstructor;
10+
import org.springframework.data.domain.PageRequest;
11+
import org.springframework.stereotype.Service;
12+
import org.springframework.transaction.annotation.Transactional;
13+
14+
import java.time.LocalDateTime;
15+
import java.util.ArrayList;
16+
import java.util.List;
17+
18+
@Service
19+
@RequiredArgsConstructor
20+
public class NotificationService {
21+
22+
private final NotificationRepository notificationRepository;
23+
24+
@Transactional(readOnly = true)
25+
public NotificationListResponseDto getNotifications(Long userId, LocalDateTime lastCreatedAt, Long lastId, int limit) {
26+
int safeLimit = Math.max(1, Math.min(limit, 100));
27+
int fetchSize = safeLimit + 1;
28+
29+
List<Notification> rows;
30+
if (lastCreatedAt == null || lastId == null) {
31+
rows = notificationRepository.findMyNotificationsFirstPage(userId, PageRequest.of(0, fetchSize));
32+
} else {
33+
rows = notificationRepository.findMyNotificationsAfter(userId, lastCreatedAt, lastId, PageRequest.of(0, fetchSize));
34+
}
35+
36+
boolean hasNext = rows.size() > safeLimit;
37+
if (hasNext) rows = rows.subList(0, safeLimit);
38+
39+
List<NotificationItemDto> items = new ArrayList<>();
40+
for (Notification n : rows) items.add(NotificationItemDto.from(n));
41+
42+
LocalDateTime nextCreatedAt = null;
43+
Long nextId = null;
44+
if (hasNext && !rows.isEmpty()) {
45+
Notification last = rows.get(rows.size() - 1);
46+
nextCreatedAt = last.getCreatedAt();
47+
nextId = last.getId();
48+
}
49+
50+
return new NotificationListResponseDto(items, hasNext, nextCreatedAt, nextId);
51+
}
52+
53+
@Transactional
54+
public NotificationGoResponseDto markAsReadAndGetPostLink(Long userId, Long notificationId) {
55+
Notification notification = notificationRepository.findByIdAndUserId(notificationId, userId);
56+
if (notification == null) {
57+
throw new ServiceException(404, "알림을 찾을 수 없습니다.");
58+
}
59+
if (!notification.isRead()) {
60+
notification.markRead();
61+
}
62+
Long postId = notification.getPost().getId();
63+
String apiUrl = "/api/posts/" + postId;
64+
return new NotificationGoResponseDto(postId, apiUrl);
65+
}
66+
}

0 commit comments

Comments
 (0)