Skip to content

Commit cce18f0

Browse files
authored
[FEAT] 알림 도메인 요구사항 개발 (#163)
* ✨feat: 알림 요구사항 개발 - 알림 목록 조회 (커서처리) - 알림 단건 Read API 개발 - 전체 알림 Read API 개발 - 읽지 않은 알림 개수 조회 API 개발 - notificaitons Entity 일부 수정 (테이블, 필드) - .http 테스트 작성 및 정상 동작 확인 * 🐛fix: Cookie 생성 로직에 도메인 추가 * ✨feat: 알림 도메인 Swagger 문서 작성 + SSE test 엔드포인트 제거
1 parent 5c59768 commit cce18f0

File tree

15 files changed

+439
-22
lines changed

15 files changed

+439
-22
lines changed

src/main/java/team/wego/wegobackend/auth/presentation/AuthController.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ private Cookie createRefreshTokenCookie(String refreshToken) {
132132
cookie.setHttpOnly(true);
133133
cookie.setSecure(true);
134134
cookie.setPath("/");
135+
cookie.setDomain(".wego.monster");
135136
cookie.setMaxAge((int) jwtTokenProvider.getRefreshTokenExpiration());
136137
cookie.setAttribute("SameSite", "Strict");
137138
return cookie;
@@ -146,6 +147,7 @@ private void deleteRefreshTokenCookie(HttpServletResponse response) {
146147
deleteCookie.setMaxAge(0);
147148
deleteCookie.setHttpOnly(true);
148149
deleteCookie.setSecure(true);
150+
deleteCookie.setDomain(".wego.monster");
149151
deleteCookie.setAttribute("SameSite", "Strict");
150152
response.addCookie(deleteCookie);
151153
}

src/main/java/team/wego/wegobackend/common/exception/AppErrorCode.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,10 @@ public enum AppErrorCode implements ErrorCode {
3636

3737
EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "인증 : 만료된 토큰입니다."),
3838
INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "인증 : 유효하지 않은 토큰입니다."),
39-
NOT_FOUND_TOKEN(HttpStatus.UNAUTHORIZED, "인증 : 토큰을 찾을 수 없습니다.")
39+
NOT_FOUND_TOKEN(HttpStatus.UNAUTHORIZED, "인증 : 토큰을 찾을 수 없습니다."),
40+
USER_ACCESS_DENIED(HttpStatus.FORBIDDEN, "인증 : 해당 리소스에 접근할 권한이 없습니다."),
41+
42+
NOT_FOUND_NOTIFICATION(HttpStatus.NOT_FOUND, "알림 : 알림을 찾을 수 없습니다.")
4043
;
4144

4245
private final HttpStatus httpStatus;
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package team.wego.wegobackend.notification.application;
2+
3+
import java.util.List;
4+
import lombok.RequiredArgsConstructor;
5+
import lombok.extern.slf4j.Slf4j;
6+
import org.springframework.stereotype.Service;
7+
import org.springframework.transaction.annotation.Transactional;
8+
import team.wego.wegobackend.auth.exception.UserNotFoundException;
9+
import team.wego.wegobackend.notification.application.dto.response.NotificationListResponse;
10+
import team.wego.wegobackend.notification.application.dto.response.NotificationResponse;
11+
import team.wego.wegobackend.notification.domain.Notification;
12+
import team.wego.wegobackend.notification.exception.NotificationAccessDeniedException;
13+
import team.wego.wegobackend.notification.exception.NotificationNotFoundException;
14+
import team.wego.wegobackend.notification.repository.NotificationRepository;
15+
import team.wego.wegobackend.user.domain.User;
16+
import team.wego.wegobackend.user.repository.UserRepository;
17+
18+
@Slf4j
19+
@Service
20+
@RequiredArgsConstructor
21+
@Transactional
22+
public class NotificationService {
23+
24+
private final UserRepository userRepository;
25+
private final NotificationRepository notificationRepository;
26+
27+
@Transactional(readOnly = true)
28+
public NotificationListResponse notificationList(Long userId, Long cursor, Integer size) {
29+
30+
User user = userRepository.findById(userId).orElseThrow(UserNotFoundException::new);
31+
32+
List<NotificationResponse> result = notificationRepository.findNotificationList(
33+
user.getId(), cursor, size);
34+
Long nextCursor = result.isEmpty() ? null : result.getLast().getId();
35+
36+
return new NotificationListResponse(result, nextCursor);
37+
}
38+
39+
@Transactional(readOnly = true)
40+
public Long unreadNotificationCount(Long userId) {
41+
42+
User user = userRepository.findById(userId).orElseThrow(UserNotFoundException::new);
43+
44+
Long unreadCount = notificationRepository.countUnread(user.getId());
45+
46+
return unreadCount;
47+
}
48+
49+
public void readNotification(Long userId, Long notificationId) {
50+
51+
User user = userRepository.findById(userId).orElseThrow(UserNotFoundException::new);
52+
53+
Notification notification = notificationRepository.findById(notificationId).orElseThrow(
54+
NotificationNotFoundException::new);
55+
56+
if(!user.getId().equals(notification.getReceiver().getId())) {
57+
log.debug("login User -> {}, receiver User -> {}", user.getId(),
58+
notification.getReceiver().getId());
59+
throw new NotificationAccessDeniedException();
60+
}
61+
62+
notification.markAsRead();
63+
}
64+
65+
public void readAllNotification(Long userId) {
66+
67+
User user = userRepository.findById(userId).orElseThrow(UserNotFoundException::new);
68+
69+
int resultCount = notificationRepository.markAllAsRead(user.getId());
70+
}
71+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package team.wego.wegobackend.notification.application.dto.response;
2+
3+
import java.util.List;
4+
5+
public record NotificationListResponse(List<NotificationResponse> notifications, Long nextCursor) {
6+
7+
}

src/main/java/team/wego/wegobackend/notification/application/dto/response/NotificationResponse.java

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package team.wego.wegobackend.notification.application.dto.response;
22

3+
import com.querydsl.core.annotations.QueryProjection;
34
import java.time.LocalDateTime;
45
import lombok.Builder;
56
import lombok.Getter;
@@ -18,7 +19,7 @@ public class NotificationResponse {
1819
private String actorProfileImage;
1920
private NotificationType type;
2021
private String message;
21-
private Boolean isRead;
22+
private LocalDateTime readAt;
2223
private Long relatedId;
2324
private String relatedType;
2425
private String redirectUrl;
@@ -35,7 +36,7 @@ public static NotificationResponse from(Notification notification) {
3536
notification.getActor() != null ? notification.getActor().getProfileImage() : null)
3637
.type(notification.getType())
3738
.message(notification.getMessage())
38-
.isRead(notification.getIsRead())
39+
.readAt(notification.getReadAt())
3940
.relatedId(notification.getRelatedId())
4041
.relatedType(notification.getRelatedType())
4142
.redirectUrl(notification.getRedirectUrl())
@@ -52,11 +53,39 @@ public static NotificationResponse from(User user) {
5253
.actorProfileImage(user.getProfileImage())
5354
.type(NotificationType.TEST)
5455
.message("테스트 알림 응답")
55-
.isRead(false)
5656
.relatedId(null)
5757
.relatedType("TEST")
5858
.redirectUrl("https://api.wego.monster/swagger-ui/index.html")
5959
.createdAt(LocalDateTime.now())
6060
.build();
6161
}
62+
63+
@QueryProjection
64+
public NotificationResponse(
65+
Long id,
66+
Long receiverId,
67+
Long actorId,
68+
String actorNickname,
69+
String actorProfileImage,
70+
NotificationType type,
71+
String message,
72+
LocalDateTime readAt,
73+
Long relatedId,
74+
String relatedType,
75+
String redirectUrl,
76+
LocalDateTime createdAt
77+
) {
78+
this.id = id;
79+
this.receiverId = receiverId;
80+
this.actorId = actorId;
81+
this.actorNickname = actorNickname;
82+
this.actorProfileImage = actorProfileImage;
83+
this.type = type;
84+
this.message = message;
85+
this.readAt = readAt;
86+
this.relatedId = relatedId;
87+
this.relatedType = relatedType;
88+
this.redirectUrl = redirectUrl;
89+
this.createdAt = createdAt;
90+
}
6291
}

src/main/java/team/wego/wegobackend/notification/domain/Notification.java

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import jakarta.persistence.JoinColumn;
1212
import jakarta.persistence.ManyToOne;
1313
import jakarta.persistence.Table;
14+
import java.time.LocalDateTime;
1415
import lombok.AccessLevel;
1516
import lombok.Builder;
1617
import lombok.Getter;
@@ -20,7 +21,7 @@
2021
import team.wego.wegobackend.user.domain.User;
2122

2223
@Entity
23-
@Table(name = "notification")
24+
@Table(name = "notifications")
2425
@Getter
2526
@NoArgsConstructor(access = AccessLevel.PROTECTED)
2627
public class Notification extends BaseTimeEntity {
@@ -46,8 +47,8 @@ public class Notification extends BaseTimeEntity {
4647
@Column(name = "type", nullable = false, length = 20)
4748
private NotificationType type;
4849

49-
@Column(name = "is_read", nullable = false)
50-
private Boolean isRead = false;
50+
@Column(name = "read_at")
51+
private LocalDateTime readAt;
5152

5253
// 관련 리소스 ID (게시글 ID, 댓글 ID 등)
5354
@Column(name = "related_id")
@@ -69,15 +70,14 @@ public Notification(User receiver, User actor, NotificationType type,
6970
this.actor = actor;
7071
this.type = type;
7172
this.message = message;
72-
this.isRead = false;
7373
this.relatedId = relatedId;
7474
this.relatedType = relatedType;
7575
this.redirectUrl = redirectUrl;
7676
}
7777

7878
// 읽음 처리
7979
public void markAsRead() {
80-
this.isRead = true;
80+
this.readAt = LocalDateTime.now();
8181
}
8282

8383
// 알림 생성 정적 팩토리
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package team.wego.wegobackend.notification.exception;
2+
3+
import team.wego.wegobackend.common.exception.AppErrorCode;
4+
import team.wego.wegobackend.common.exception.AppException;
5+
6+
public class NotificationAccessDeniedException extends AppException {
7+
8+
public NotificationAccessDeniedException() {
9+
super(AppErrorCode.USER_ACCESS_DENIED);
10+
}
11+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package team.wego.wegobackend.notification.exception;
2+
3+
import team.wego.wegobackend.common.exception.AppErrorCode;
4+
import team.wego.wegobackend.common.exception.AppException;
5+
6+
public class NotificationNotFoundException extends AppException {
7+
8+
public NotificationNotFoundException() {
9+
super(AppErrorCode.NOT_FOUND_NOTIFICATION);
10+
}
11+
}

src/main/java/team/wego/wegobackend/notification/presentation/NotificationController.java

Lines changed: 64 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,39 @@
22

33
import io.swagger.v3.oas.annotations.Operation;
44
import io.swagger.v3.oas.annotations.tags.Tag;
5+
import jakarta.validation.constraints.Max;
6+
import jakarta.validation.constraints.Min;
57
import lombok.RequiredArgsConstructor;
68
import lombok.extern.slf4j.Slf4j;
79
import org.springframework.http.HttpStatus;
810
import org.springframework.http.MediaType;
911
import org.springframework.http.ResponseEntity;
1012
import org.springframework.security.core.annotation.AuthenticationPrincipal;
1113
import org.springframework.web.bind.annotation.GetMapping;
14+
import org.springframework.web.bind.annotation.PathVariable;
15+
import org.springframework.web.bind.annotation.PostMapping;
1216
import org.springframework.web.bind.annotation.RequestMapping;
17+
import org.springframework.web.bind.annotation.RequestParam;
1318
import org.springframework.web.bind.annotation.RestController;
1419
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
1520
import team.wego.wegobackend.auth.exception.UserNotFoundException;
1621
import team.wego.wegobackend.common.response.ApiResponse;
1722
import team.wego.wegobackend.common.security.CustomUserDetails;
23+
import team.wego.wegobackend.notification.application.NotificationService;
1824
import team.wego.wegobackend.notification.application.SseEmitterService;
25+
import team.wego.wegobackend.notification.application.dto.response.NotificationListResponse;
1926
import team.wego.wegobackend.notification.application.dto.response.NotificationResponse;
2027
import team.wego.wegobackend.user.domain.User;
2128
import team.wego.wegobackend.user.repository.UserRepository;
2229

23-
@Tag(name = "SSE 엔드포인트", description = "SSE 연결을 위한 엔드포인트")
2430
@Slf4j
2531
@RestController
2632
@RequiredArgsConstructor
2733
@RequestMapping("/api/v1/notifications")
28-
public class NotificationController {
34+
public class NotificationController implements NotificationControllerDocs{
2935

3036
private final SseEmitterService sseEmitterService;
37+
private final NotificationService notificationService;
3138
private final UserRepository userRepository; //TEST 의존성 주입
3239

3340
// SSE 연결 엔드포인트
@@ -38,19 +45,66 @@ public SseEmitter subscribe(@AuthenticationPrincipal CustomUserDetails user) {
3845
return sseEmitterService.createEmitter(user.getId());
3946
}
4047

41-
@GetMapping(value = "/test")
42-
public ResponseEntity<ApiResponse<String>> test(
43-
@AuthenticationPrincipal CustomUserDetails user
48+
/**
49+
* 알림 목록 조회
50+
* */
51+
@GetMapping
52+
public ResponseEntity<ApiResponse<NotificationListResponse>> notificationList(
53+
@AuthenticationPrincipal CustomUserDetails userDetails,
54+
@RequestParam(required = false) Long cursor,
55+
@RequestParam(defaultValue = "20") @Min(1) @Max(100) Integer size
4456
) {
4557

46-
User testUser = userRepository.findById(user.getId())
47-
.orElseThrow(UserNotFoundException::new);
58+
NotificationListResponse response = notificationService.notificationList(userDetails.getId(), cursor, size);
4859

49-
NotificationResponse dto = NotificationResponse.from(testUser);
50-
sseEmitterService.sendNotification(user.getId(), dto);
60+
return ResponseEntity
61+
.status(HttpStatus.OK)
62+
.body(ApiResponse.success(200, response));
63+
}
64+
65+
/**
66+
* 읽지 않은 알림 개수 조회
67+
* */
68+
@GetMapping("/unread-count")
69+
public ResponseEntity<ApiResponse<Long>> unreadNotificationCount(
70+
@AuthenticationPrincipal CustomUserDetails userDetails
71+
) {
72+
73+
Long response = notificationService.unreadNotificationCount(userDetails.getId());
5174

5275
return ResponseEntity
5376
.status(HttpStatus.OK)
54-
.body(ApiResponse.success(200, "TEST SUCCESS"));
77+
.body(ApiResponse.success(200, response));
5578
}
79+
80+
/**
81+
* 단건 읽음 처리
82+
* POST /notifications/{id}/read
83+
* POST /notifications/read-all
84+
* */
85+
@PostMapping("/{notificationId}/read")
86+
public ResponseEntity<ApiResponse<Void>> readNotification(
87+
@AuthenticationPrincipal CustomUserDetails userDetails,
88+
@PathVariable("notificationId") Long notificationId
89+
) {
90+
91+
notificationService.readNotification(userDetails.getId(), notificationId);
92+
93+
return ResponseEntity
94+
.status(HttpStatus.NO_CONTENT)
95+
.body(ApiResponse.success(204, null));
96+
}
97+
98+
@PostMapping("/read-all")
99+
public ResponseEntity<ApiResponse<Void>> readAllNotification(
100+
@AuthenticationPrincipal CustomUserDetails userDetails
101+
) {
102+
103+
notificationService.readAllNotification(userDetails.getId());
104+
105+
return ResponseEntity
106+
.status(HttpStatus.NO_CONTENT)
107+
.body(ApiResponse.success(204, null));
108+
}
109+
56110
}

0 commit comments

Comments
 (0)