Skip to content

Commit f4c994b

Browse files
authored
feat: 알림 기능 구현 (#114)
1 parent 6260d01 commit f4c994b

30 files changed

Lines changed: 2010 additions & 3 deletions
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package com.back.api.notification.controller;
2+
3+
import java.util.List;
4+
5+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
6+
import org.springframework.web.bind.annotation.PathVariable;
7+
8+
import com.back.api.notification.dto.NotificationResponseDto;
9+
import com.back.api.notification.dto.UnreadCountResponseDto;
10+
import com.back.global.config.swagger.ApiErrorCode;
11+
import com.back.global.response.ApiResponse;
12+
import com.back.global.security.SecurityUser;
13+
14+
import io.swagger.v3.oas.annotations.Operation;
15+
import io.swagger.v3.oas.annotations.tags.Tag;
16+
17+
@Tag(name = "Notification API", description = "알림 API")
18+
public interface NotificationApi {
19+
@Operation(summary = "알림 조회", description = "웹소켓이 연결되기 전 발생한 알림들을 조회할 수 있습니다(초기 데이터 로딩). 웹소켓이 연결되면 새로운 알림은 웹소켓으로 전달됩니다 ")
20+
@ApiErrorCode({
21+
"NOTIFICATION_NOT_FOUND",
22+
"NOTIFICATION_ACCESS_DENIED"
23+
})
24+
ApiResponse<List<NotificationResponseDto>> getNotifications(
25+
@AuthenticationPrincipal SecurityUser securityUser
26+
);
27+
28+
@Operation(summary = "읽지 않은 알림 개수 조회", description = "웹소켓이 연결된 직후 초기 데이터 로딩을 위해 이용됩니다")
29+
@ApiErrorCode({
30+
"NOTIFICATION_ACCESS_DENIED"
31+
})
32+
ApiResponse<UnreadCountResponseDto> getUnreadCount(
33+
@AuthenticationPrincipal SecurityUser securityUser
34+
);
35+
36+
@Operation(summary = "단일 알림 읽음 처리")
37+
@ApiErrorCode({
38+
"NOTIFICATION_ACCESS_DENIED",
39+
"INVALID_NOTIFICATION_ID",
40+
"NOTIFICATION_PROCESS_FAILED"
41+
})
42+
ApiResponse<Void> markAsRead(
43+
@AuthenticationPrincipal SecurityUser securityUser,
44+
@PathVariable Long notificationId
45+
);
46+
47+
@Operation(summary = "모든 알림 읽음 처리")
48+
@ApiErrorCode({
49+
"NOTIFICATION_ACCESS_DENIED",
50+
"INVALID_NOTIFICATION_ID",
51+
"NOTIFICATION_PROCESS_FAILED"
52+
})
53+
ApiResponse<Void> markAllAsRead(
54+
@AuthenticationPrincipal SecurityUser securityUser
55+
);
56+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package com.back.api.notification.controller;
2+
3+
import java.util.List;
4+
5+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
6+
import org.springframework.web.bind.annotation.GetMapping;
7+
import org.springframework.web.bind.annotation.PatchMapping;
8+
import org.springframework.web.bind.annotation.PathVariable;
9+
import org.springframework.web.bind.annotation.RequestMapping;
10+
import org.springframework.web.bind.annotation.RestController;
11+
12+
import com.back.api.notification.dto.NotificationResponseDto;
13+
import com.back.api.notification.dto.UnreadCountResponseDto;
14+
import com.back.api.notification.service.NotificationService;
15+
import com.back.global.response.ApiResponse;
16+
import com.back.global.security.SecurityUser;
17+
18+
import lombok.RequiredArgsConstructor;
19+
20+
@RestController
21+
@RequiredArgsConstructor
22+
@RequestMapping("/api/notifications")
23+
public class NotificationController implements NotificationApi {
24+
25+
private final NotificationService notificationService;
26+
27+
@Override
28+
@GetMapping
29+
public ApiResponse<List<NotificationResponseDto>> getNotifications(
30+
@AuthenticationPrincipal SecurityUser securityUser
31+
) {
32+
Long userId = securityUser.getId();
33+
34+
List<NotificationResponseDto> notifications =
35+
notificationService.getNotifications(userId);
36+
37+
return ApiResponse.ok(notifications);
38+
}
39+
40+
/**
41+
* 읽지 않은 알림 개수 조회
42+
*/
43+
@Override
44+
@GetMapping("/unread-count")
45+
public ApiResponse<UnreadCountResponseDto> getUnreadCount(
46+
@AuthenticationPrincipal SecurityUser securityUser
47+
) {
48+
long count = notificationService.getUnreadCount(securityUser.getId());
49+
return ApiResponse.ok(new UnreadCountResponseDto(count));
50+
}
51+
52+
/**
53+
* 개별 알림 읽음 처리
54+
*/
55+
@Override
56+
@PatchMapping("/{notificationId}/read")
57+
public ApiResponse<Void> markAsRead(
58+
@AuthenticationPrincipal SecurityUser securityUser,
59+
@PathVariable Long notificationId
60+
) {
61+
notificationService.markAsRead(notificationId, securityUser.getId());
62+
63+
return ApiResponse.noContent("개별 알림을 읽음 처리 하였습니다.");
64+
}
65+
66+
/**
67+
* 전체 알림 읽음 처리
68+
*/
69+
@Override
70+
@PatchMapping("/read-all")
71+
public ApiResponse<Void> markAllAsRead(
72+
@AuthenticationPrincipal SecurityUser securityUser
73+
) {
74+
notificationService.markAllAsRead(securityUser.getId());
75+
return ApiResponse.noContent("모든 알림을 읽음 처리 하였습니다.");
76+
}
77+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package com.back.api.notification.dto;
2+
3+
import java.time.LocalDateTime;
4+
5+
import com.back.domain.notification.entity.Notification;
6+
7+
import io.swagger.v3.oas.annotations.media.Schema;
8+
9+
@Schema(description = "알림 조회 응답용 DTO")
10+
public record NotificationResponseDto(
11+
Long id,
12+
String type, // enum name
13+
String typeDetail,
14+
String title,
15+
String message,
16+
boolean isRead,
17+
LocalDateTime createdAt,
18+
LocalDateTime readAt
19+
) {
20+
public static NotificationResponseDto from(Notification notification) {
21+
return new NotificationResponseDto(
22+
notification.getId(),
23+
notification.getType().name(),
24+
notification.getTypeDetail().name(),
25+
notification.getTitle(),
26+
notification.getMessage(),
27+
notification.isRead(),
28+
notification.getCreateAt(),
29+
notification.getReadAt()
30+
);
31+
}
32+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.back.api.notification.dto;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
5+
@Schema(description = "읽지 않은 알림 개수 조회 응답용 DTO")
6+
public record UnreadCountResponseDto(
7+
long unreadCount
8+
) { }
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package com.back.api.notification.listener;
2+
3+
import java.util.NoSuchElementException;
4+
5+
import org.springframework.messaging.simp.SimpMessagingTemplate;
6+
import org.springframework.scheduling.annotation.Async;
7+
import org.springframework.stereotype.Service;
8+
import org.springframework.transaction.event.TransactionPhase;
9+
import org.springframework.transaction.event.TransactionalEventListener;
10+
11+
import com.back.api.notification.dto.NotificationResponseDto;
12+
import com.back.domain.notification.entity.Notification;
13+
import com.back.domain.notification.repository.NotificationRepository;
14+
import com.back.domain.notification.systemMessage.NotificationMessage;
15+
import com.back.domain.user.repository.UserRepository;
16+
import com.back.global.websocket.session.WebSocketSessionManager;
17+
18+
import lombok.RequiredArgsConstructor;
19+
import lombok.extern.slf4j.Slf4j;
20+
21+
@Slf4j
22+
@Service
23+
@RequiredArgsConstructor
24+
public class NotificationEventListener {
25+
private final NotificationRepository notificationRepository;
26+
private final UserRepository userRepository;
27+
private final SimpMessagingTemplate messagingTemplate;
28+
private final WebSocketSessionManager sessionManager;
29+
30+
@Async
31+
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
32+
public void handleNotificationMessage(NotificationMessage message) {
33+
try {
34+
Notification notification = Notification.builder()
35+
.user(
36+
userRepository.findById(message.getUserId())
37+
.orElseThrow(() -> new NoSuchElementException("ID " + message.getUserId() + "에 해당하는 사용자가 존재하지 않습니다."))
38+
)
39+
.type(message.getNotificationType())
40+
.typeDetail(message.getTypeDetail())
41+
.fromWhere(message.getFromWhere())
42+
.whereId(message.getWhereId())
43+
.title(message.getTitle())
44+
.message(message.getMessage())
45+
.isRead(false)
46+
.build();
47+
48+
notificationRepository.save(notification);
49+
log.info("알림 생성 완료 - userId: {}, type: {}, from: {}",
50+
message.getUserId(),
51+
message.getNotificationType(),
52+
message.getFromWhere());
53+
54+
// 웹소켓으로 실시간 알림 전송
55+
sendNotificationViaWebSocket(message.getUserId(), notification);
56+
57+
} catch (Exception e) {
58+
log.error("알림 생성 실패 - userId: {}, type: {}",
59+
message.getUserId(),
60+
message.getNotificationType(),
61+
e);
62+
// 알림 생성 실패가 원본 트랜잭션에 영향 주지 않음
63+
}
64+
}
65+
66+
/**
67+
* 웹소켓으로 실시간 알림 전송
68+
*
69+
* @param userId 대상 사용자 ID
70+
* @param notification 전송할 알림 엔티티
71+
*/
72+
private void sendNotificationViaWebSocket(Long userId, Notification notification) {
73+
// 사용자 온라인 여부 확인
74+
if (!sessionManager.isUserOnline(userId)) {
75+
log.debug("사용자 오프라인 - 웹소켓 전송 생략 - userId: {}", userId);
76+
return;
77+
}
78+
79+
try {
80+
// DTO 변환
81+
NotificationResponseDto dto = NotificationResponseDto.from(notification);
82+
83+
// 웹소켓 전송
84+
messagingTemplate.convertAndSendToUser(
85+
userId.toString(),
86+
"/notifications",
87+
dto
88+
);
89+
90+
log.info("웹소켓 전송 성공 - userId: {}, notificationId: {}", userId, notification.getId());
91+
92+
} catch (Exception e) {
93+
log.warn("웹소켓 전송 실패 - userId: {}, notificationId: {}, error: {}",
94+
userId, notification.getId(), e.getMessage());
95+
// 전송 실패해도 DB에는 저장되어 있으므로 예외를 던지지 않음
96+
}
97+
}
98+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package com.back.api.notification.service;
2+
3+
import java.util.List;
4+
5+
import org.springframework.stereotype.Service;
6+
import org.springframework.transaction.annotation.Transactional;
7+
8+
import com.back.api.notification.dto.NotificationResponseDto;
9+
import com.back.domain.notification.entity.Notification;
10+
import com.back.domain.notification.repository.NotificationRepository;
11+
12+
import lombok.RequiredArgsConstructor;
13+
14+
@Service
15+
@RequiredArgsConstructor
16+
@Transactional(readOnly = true)
17+
public class NotificationService {
18+
private final NotificationRepository notificationRepository;
19+
20+
public List<NotificationResponseDto> getNotifications(Long userId) {
21+
List<Notification> notifications = notificationRepository
22+
.findByUserIdOrderByCreateAtDesc(userId);
23+
24+
return notifications.stream()
25+
.map(NotificationResponseDto::from)
26+
.toList();
27+
}
28+
29+
public long getUnreadCount(Long userId) {
30+
return notificationRepository.countByUserIdAndIsReadFalse(userId);
31+
}
32+
33+
@Transactional
34+
public void markAsRead(Long notificationId, Long userId) {
35+
Notification notification = notificationRepository
36+
.findByIdAndUserId(notificationId, userId)
37+
.orElseThrow(() -> new IllegalArgumentException("알림을 찾을 수 없습니다"));
38+
39+
notification.markAsRead();
40+
}
41+
42+
@Transactional
43+
public void markAllAsRead(Long userId) {
44+
List<Notification> notifications = notificationRepository
45+
.findByUserIdAndIsReadFalse(userId);
46+
47+
notifications.forEach(Notification::markAsRead);
48+
}
49+
}

backend/src/main/java/com/back/api/payment/payment/service/PaymentService.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.back.api.payment.payment.service;
22

3+
import org.springframework.context.ApplicationEventPublisher;
34
import org.springframework.stereotype.Service;
45
import org.springframework.transaction.annotation.Transactional;
56

@@ -10,6 +11,7 @@
1011
import com.back.api.payment.payment.dto.response.PaymentConfirmResult;
1112
import com.back.api.queue.service.QueueEntryProcessService;
1213
import com.back.api.ticket.service.TicketService;
14+
import com.back.domain.notification.systemMessage.OrdersSuccessMessage;
1315
import com.back.domain.payment.order.entity.Order;
1416
import com.back.domain.ticket.entity.Ticket;
1517
import com.back.global.error.code.PaymentErrorCode;
@@ -29,6 +31,7 @@ public class PaymentService {
2931
private final PaymentClient paymentClient;
3032
private final TicketService ticketService;
3133
private final QueueEntryProcessService queueEntryProcessService;
34+
private final ApplicationEventPublisher eventPublisher;
3235

3336
@Transactional
3437
public PaymentConfirmResponse confirmPayment(
@@ -75,6 +78,18 @@ public PaymentConfirmResponse confirmPayment(
7578
userId
7679
);
7780

81+
String eventTitle = ticket.getEvent().getTitle();
82+
83+
// 알림 메시지 발행
84+
eventPublisher.publishEvent(
85+
new OrdersSuccessMessage(
86+
userId,
87+
orderId,
88+
order.getAmount(),
89+
eventTitle
90+
)
91+
);
92+
7893
return PaymentConfirmResponse.from(order, ticket);
7994
}
8095
}

0 commit comments

Comments
 (0)