From 94b4cd79390405df22ee9c971299a6a8a66379b6 Mon Sep 17 00:00:00 2001 From: rorrxr Date: Wed, 21 Jan 2026 23:40:23 +0900 Subject: [PATCH 1/4] =?UTF-8?q?chore:=20FRONTEND=5FPROD=5FURL,=20BACKEND?= =?UTF-8?q?=5FURL,=20BACKEND=5FPROD=5FURL=20Value=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../linkit/global/config/WebSocketConfig.java | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/swyp/linkit/global/config/WebSocketConfig.java b/src/main/java/org/swyp/linkit/global/config/WebSocketConfig.java index 37d98516..8d893762 100644 --- a/src/main/java/org/swyp/linkit/global/config/WebSocketConfig.java +++ b/src/main/java/org/swyp/linkit/global/config/WebSocketConfig.java @@ -1,6 +1,7 @@ package org.swyp.linkit.global.config; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; import org.springframework.messaging.simp.config.ChannelRegistration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; @@ -17,6 +18,15 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { private final WebSocketAuthInterceptor webSocketAuthInterceptor; private final StompErrorHandler stompErrorHandler; + @Value("${FRONTEND_URL}") + private String frontendUrl; + @Value("${FRONTEND_PROD_URL}") + private String frontendProdUrl; + @Value("${BACKEND_URL}") + private String backendUrl; + @Value("${BACKEND_PROD_URL}") + private String backendProdUrl; + @Override public void configureMessageBroker(MessageBrokerRegistry registry) { // 클라이언트가 구독할 prefix (서버 -> 클라이언트) @@ -34,10 +44,10 @@ public void registerStompEndpoints(StompEndpointRegistry registry) { // WebSocket 연결 엔드포인트 (SockJS fallback 포함) registry.addEndpoint("/ws") .setAllowedOriginPatterns( - "{FRONTEND_URL}", // 로컬 개발 - "{FRONTEND_PROD_URL}", // Vercel 프리뷰/배포 - "{BACKEND_URL}", // 운영 도메인 - "{BACKEND_PROD_URL}" // 서브도메인 + frontendUrl, // 로컬 개발 + frontendProdUrl, // Vercel 프리뷰/배포 + backendUrl, // 운영 도메인 + backendProdUrl // 서브도메인 ) .withSockJS(); From 1f92439e2c01867394bf4f7a52daee2b6ac81a3c Mon Sep 17 00:00:00 2001 From: rorrxr Date: Thu, 16 Apr 2026 23:59:47 +0900 Subject: [PATCH 2/4] =?UTF-8?q?refactor=20:=20=EC=8A=A4=ED=82=AC=EA=B5=90?= =?UTF-8?q?=ED=99=98=20=EC=95=8C=EB=A6=BC=20SkillExchange=20read=20flag=20?= =?UTF-8?q?=EC=99=84=EC=A0=84=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20Notificat?= =?UTF-8?q?ion=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EB=8B=A8=EC=9D=BC=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /exchange/request/notification 엔드포인트 제거 (→ /notifications/unread-count 사용) - SkillExchangeNotificationResponseDto 삭제 - SkillExchangeRepository bulkUpdateRequesterReadStatus/ReceiverReadStatus, existsBy*IsReadFalse 제거 - SentDetailQuery.isRead, ReceivedDetailQuery.isRead 필드 제거 (JPQL 포함) - acceptSkillExchange/rejectSkillExchange/cancelSkillExchange updateRequesterReadToFalse/ReadToFalse 호출 제거 - NotificationRepository.findUnreadRefIdsByUserIdAndTypes() 배치 쿼리 추가 - NotificationService.getUnreadSentRequestRefIds(), getUnreadReceivedRequestRefIds() 추가 - getSentRequests/getReceivedRequests : 읽음 처리 전 미읽음 refId 배치 조회 후 isNew per-item 계산 Co-Authored-By: Claude Sonnet 4.6 --- .../controller/SkillExchangeController.java | 20 ++---- .../response/ReceivedExchangeDetailDto.java | 4 +- .../ReceivedExchangeDetailsResponseDto.java | 5 +- .../dto/response/SentExchangeDetailDto.java | 4 +- .../SentExchangeDetailsResponseDto.java | 5 +- .../SkillExchangeNotificationResponseDto.java | 30 --------- .../repository/SkillExchangeRepository.java | 28 +------- .../projection/ReceivedDetailQuery.java | 3 +- .../projection/SentDetailQuery.java | 1 - .../service/SkillExchangeService.java | 9 ++- .../service/SkillExchangeServiceImpl.java | 64 ++++++------------- .../repository/NotificationRepository.java | 8 +++ .../service/NotificationService.java | 13 ++++ .../service/NotificationServiceImpl.java | 16 +++++ 14 files changed, 79 insertions(+), 131 deletions(-) delete mode 100644 src/main/java/org/swyp/linkit/domain/exchange/dto/response/SkillExchangeNotificationResponseDto.java diff --git a/src/main/java/org/swyp/linkit/domain/exchange/controller/SkillExchangeController.java b/src/main/java/org/swyp/linkit/domain/exchange/controller/SkillExchangeController.java index c5272f62..bf073ffd 100644 --- a/src/main/java/org/swyp/linkit/domain/exchange/controller/SkillExchangeController.java +++ b/src/main/java/org/swyp/linkit/domain/exchange/controller/SkillExchangeController.java @@ -12,9 +12,12 @@ import org.springframework.web.bind.annotation.*; import org.swyp.linkit.domain.exchange.dto.SkillExchangeDto; import org.swyp.linkit.domain.exchange.dto.request.SkillExchangeRequestDto; -import org.swyp.linkit.domain.exchange.dto.response.*; +import org.swyp.linkit.domain.exchange.dto.response.AvailableDatesResponseDto; +import org.swyp.linkit.domain.exchange.dto.response.AvailableSlotsResponseDto; import org.swyp.linkit.domain.exchange.dto.response.ReceivedExchangeDetailsResponseDto; +import org.swyp.linkit.domain.exchange.dto.response.ReceiverSkillsResponseDto; import org.swyp.linkit.domain.exchange.dto.response.SentExchangeDetailsResponseDto; +import org.swyp.linkit.domain.exchange.dto.response.SkillExchangeResponseDto; import org.swyp.linkit.domain.exchange.service.SkillExchangeService; import org.swyp.linkit.global.auth.oauth.CustomOAuth2User; import org.swyp.linkit.global.common.dto.ApiResponseDto; @@ -116,21 +119,6 @@ public ResponseEntity> createExchange( return ResponseEntity.ok(ApiResponseDto.success("요청이 정상적으로 처리되었습니다.", responseDto)); } - /** - * 네비바 요청 관리 알림 및 요청 관리 진입 시 "받은 요청", "보낸 요청" 알림 용 - */ - @Operation( - summary = "네비바 요청 관리 알림 및 요청 관리 진입 시 \"받은 요청\", \"보낸 요청\" 알림 조회", - description = "네비바 요청 관리 알림 및 요청 관리 페이지 진입 시 구분되는 탭에 거래 상태가 변경되었는지 조회합니다." - ) - @GetMapping("/request/notification") - public ResponseEntity> getNotification( - @AuthenticationPrincipal CustomOAuth2User auth2User){ - - SkillExchangeNotificationResponseDto responseDto = exchangeService.getNotification(auth2User.getUserId()); - return ResponseEntity.ok(ApiResponseDto.success("요청이 정상적으로 처리되었습니다.", responseDto)); - } - /** * 스킬 거래 보낸 요청 내역 커서 기반 페이징 조회 */ diff --git a/src/main/java/org/swyp/linkit/domain/exchange/dto/response/ReceivedExchangeDetailDto.java b/src/main/java/org/swyp/linkit/domain/exchange/dto/response/ReceivedExchangeDetailDto.java index 1ed116ba..582c8e4f 100644 --- a/src/main/java/org/swyp/linkit/domain/exchange/dto/response/ReceivedExchangeDetailDto.java +++ b/src/main/java/org/swyp/linkit/domain/exchange/dto/response/ReceivedExchangeDetailDto.java @@ -69,7 +69,7 @@ public boolean isNew() { return isNew; } - public static ReceivedExchangeDetailDto from(ReceivedDetailQuery result){ + public static ReceivedExchangeDetailDto from(ReceivedDetailQuery result, boolean isNew){ return ReceivedExchangeDetailDto.builder() .skillExchangeId(result.skillExchangeId()) .targetUserId(result.targetUserId()) @@ -84,7 +84,7 @@ public static ReceivedExchangeDetailDto from(ReceivedDetailQuery result){ .requestedDate(result.createdAt().toLocalDate()) .exchangeDateTime(result.exchangeDate().atTime(result.exchangeTime()).truncatedTo(ChronoUnit.SECONDS)) .exchangeDuration(result.exchangeDuration()) - .isNew(!result.isRead()) + .isNew(isNew) .build(); } } diff --git a/src/main/java/org/swyp/linkit/domain/exchange/dto/response/ReceivedExchangeDetailsResponseDto.java b/src/main/java/org/swyp/linkit/domain/exchange/dto/response/ReceivedExchangeDetailsResponseDto.java index a592d9c5..02cf602d 100644 --- a/src/main/java/org/swyp/linkit/domain/exchange/dto/response/ReceivedExchangeDetailsResponseDto.java +++ b/src/main/java/org/swyp/linkit/domain/exchange/dto/response/ReceivedExchangeDetailsResponseDto.java @@ -8,6 +8,7 @@ import org.swyp.linkit.domain.exchange.repository.projection.ReceivedDetailQuery; import java.util.List; +import java.util.Set; @Getter @AllArgsConstructor(access = AccessLevel.PRIVATE) @@ -23,9 +24,9 @@ public class ReceivedExchangeDetailsResponseDto { @Schema(description = "받은 스킬 거래 상세 목록") private List contents; - public static ReceivedExchangeDetailsResponseDto from(Slice slice){ + public static ReceivedExchangeDetailsResponseDto from(Slice slice, Set unreadRefIds){ List contents = slice.stream() - .map(ReceivedExchangeDetailDto::from) + .map(q -> ReceivedExchangeDetailDto.from(q, unreadRefIds.contains(q.skillExchangeId()))) .toList(); return new ReceivedExchangeDetailsResponseDto( slice.hasNext(), diff --git a/src/main/java/org/swyp/linkit/domain/exchange/dto/response/SentExchangeDetailDto.java b/src/main/java/org/swyp/linkit/domain/exchange/dto/response/SentExchangeDetailDto.java index 586a6ef0..29d1e129 100644 --- a/src/main/java/org/swyp/linkit/domain/exchange/dto/response/SentExchangeDetailDto.java +++ b/src/main/java/org/swyp/linkit/domain/exchange/dto/response/SentExchangeDetailDto.java @@ -77,7 +77,7 @@ public boolean isNew() { return isNew; } - public static SentExchangeDetailDto from(SentDetailQuery result){ + public static SentExchangeDetailDto from(SentDetailQuery result, boolean isNew){ boolean canReviewStatus = result.exchangeStatus() == ExchangeStatus.COMPLETED; return SentExchangeDetailDto.builder() @@ -96,7 +96,7 @@ public static SentExchangeDetailDto from(SentDetailQuery result){ .exchangeDuration(result.exchangeDuration()) .canReview(canReviewStatus) .reviewId(result.reviewId()) - .isNew(!result.isRead()) + .isNew(isNew) .build(); } } diff --git a/src/main/java/org/swyp/linkit/domain/exchange/dto/response/SentExchangeDetailsResponseDto.java b/src/main/java/org/swyp/linkit/domain/exchange/dto/response/SentExchangeDetailsResponseDto.java index 1bb84897..1579fba5 100644 --- a/src/main/java/org/swyp/linkit/domain/exchange/dto/response/SentExchangeDetailsResponseDto.java +++ b/src/main/java/org/swyp/linkit/domain/exchange/dto/response/SentExchangeDetailsResponseDto.java @@ -8,6 +8,7 @@ import org.swyp.linkit.domain.exchange.repository.projection.SentDetailQuery; import java.util.List; +import java.util.Set; @Getter @AllArgsConstructor(access = AccessLevel.PRIVATE) @@ -23,9 +24,9 @@ public class SentExchangeDetailsResponseDto { @Schema(description = "요청한 스킬 거래 상세 목록") private List contents; - public static SentExchangeDetailsResponseDto from(Slice slice){ + public static SentExchangeDetailsResponseDto from(Slice slice, Set unreadRefIds){ List contents = slice.stream() - .map(SentExchangeDetailDto::from) + .map(q -> SentExchangeDetailDto.from(q, unreadRefIds.contains(q.skillExchangeId()))) .toList(); return new SentExchangeDetailsResponseDto( diff --git a/src/main/java/org/swyp/linkit/domain/exchange/dto/response/SkillExchangeNotificationResponseDto.java b/src/main/java/org/swyp/linkit/domain/exchange/dto/response/SkillExchangeNotificationResponseDto.java deleted file mode 100644 index 467a8583..00000000 --- a/src/main/java/org/swyp/linkit/domain/exchange/dto/response/SkillExchangeNotificationResponseDto.java +++ /dev/null @@ -1,30 +0,0 @@ -package org.swyp.linkit.domain.exchange.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor(access = AccessLevel.PRIVATE) -@Schema(description = "스킬 거래 알림(읽지 않은 요청) 상태 응답") -public class SkillExchangeNotificationResponseDto { - - // 보낸 요청 탭 빨간 점 - @Schema(description = "보낸 요청 탭의 읽지 않은 알림 존재 여부", example = "true") - private boolean hasUnreadSent; - // 받은 요청 탭 빨간 점 - @Schema(description = "받은 요청 탭의 읽지 않은 알림 존재 여부", example = "false") - private boolean hasUnreadReceived; - // 네비바에 표시될 빨간 점 - @Schema(description = "전체(네비게이션 바) 읽지 않은 알림 존재 여부", example = "true") - private boolean hasAnyUnread; - - public static SkillExchangeNotificationResponseDto of(boolean hasUnreadSent, - boolean hasUnreadReceived){ - return new SkillExchangeNotificationResponseDto( - hasUnreadSent, - hasUnreadReceived, - hasUnreadSent || hasUnreadReceived); - } -} diff --git a/src/main/java/org/swyp/linkit/domain/exchange/repository/SkillExchangeRepository.java b/src/main/java/org/swyp/linkit/domain/exchange/repository/SkillExchangeRepository.java index a8f5d857..d50d24c5 100644 --- a/src/main/java/org/swyp/linkit/domain/exchange/repository/SkillExchangeRepository.java +++ b/src/main/java/org/swyp/linkit/domain/exchange/repository/SkillExchangeRepository.java @@ -3,7 +3,6 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; 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.swyp.linkit.domain.exchange.entity.ExchangeStatus; @@ -38,7 +37,7 @@ List findAllByReceiverIdAndDate(@Param("receiverId") Long receive "CASE WHEN cr1.id IS NOT NULL THEN cr1.id ELSE cr2.id END, " + "r.profileImageUrl, r.nickname, se.skillName, " + "se.exchangeStatus, se.creditPrice, se.message, se.createdAt, se.scheduledDate, se.startTime, " + - "se.exchangeDuration, se.isRequesterRead, rv.id) " + + "se.exchangeDuration, rv.id) " + "FROM SkillExchange se " + "JOIN se.receiver r " + "LEFT JOIN ChatRoom cr1 ON cr1.mentor.id = r.id AND cr1.mentee.id = se.requester.id " + @@ -61,7 +60,7 @@ Slice findAllByRequesterIdWithReceiver(@Param("requesterId") Lo "CASE WHEN cr1.id IS NOT NULL THEN cr1.id ELSE cr2.id END, " + "r.profileImageUrl, r.nickname, se.skillName, " + "se.exchangeStatus, se.creditPrice, se.message, se.createdAt, se.scheduledDate, se.startTime, " + - "se.exchangeDuration, se.isReceiverRead) " + + "se.exchangeDuration) " + "FROM SkillExchange se " + "JOIN se.requester r " + "LEFT JOIN ChatRoom cr1 ON cr1.mentor.id = r.id AND cr1.mentee.id = se.receiver.id " + @@ -73,29 +72,6 @@ Slice findAllByReceiverIdWithRequester(@Param("receiverId") @Param("cursorId") Long cursorId, Pageable pageable); - /** - * 보낸 요청 알림 읽음 처리 - * isRequesterRead 모두 true 로 update - */ - @Modifying(clearAutomatically = true) - @Query("UPDATE SkillExchange se SET se.isRequesterRead = true " + - "WHERE se.requester.id = :requesterId " + - "AND se.isRequesterRead = false") - int bulkUpdateRequesterReadStatus(@Param("requesterId") Long requesterId); - - /** - * 받은 요청 알림 읽음 처리 - * isReceiverRead 모두 true 로 update - */ - @Modifying(clearAutomatically = true) - @Query("UPDATE SkillExchange se SET se.isReceiverRead = true " + - "WHERE se.receiver.id = :receiverId " + - "AND se.isReceiverRead = false") - int bulkUpdateReceiverReadStatus(@Param("receiverId") Long receiverId); - - boolean existsByReceiver_IdAndIsReceiverReadFalse(Long receiverId); - boolean existsByRequester_IdAndIsRequesterReadFalse(Long requesterId); - /** * receiver 수락/거절 * SkillExchangeId로 SkillExchange 조회 diff --git a/src/main/java/org/swyp/linkit/domain/exchange/repository/projection/ReceivedDetailQuery.java b/src/main/java/org/swyp/linkit/domain/exchange/repository/projection/ReceivedDetailQuery.java index 3ac4d9fa..ed7edd7a 100644 --- a/src/main/java/org/swyp/linkit/domain/exchange/repository/projection/ReceivedDetailQuery.java +++ b/src/main/java/org/swyp/linkit/domain/exchange/repository/projection/ReceivedDetailQuery.java @@ -21,8 +21,7 @@ public record ReceivedDetailQuery( LocalDateTime createdAt, LocalDate exchangeDate, LocalTime exchangeTime, - int exchangeDuration, - boolean isRead + int exchangeDuration ) { } diff --git a/src/main/java/org/swyp/linkit/domain/exchange/repository/projection/SentDetailQuery.java b/src/main/java/org/swyp/linkit/domain/exchange/repository/projection/SentDetailQuery.java index 65803149..f0f10068 100644 --- a/src/main/java/org/swyp/linkit/domain/exchange/repository/projection/SentDetailQuery.java +++ b/src/main/java/org/swyp/linkit/domain/exchange/repository/projection/SentDetailQuery.java @@ -22,7 +22,6 @@ public record SentDetailQuery( LocalDate exchangeDate, LocalTime exchangeTime, int exchangeDuration, - boolean isRead, Long reviewId ) { } diff --git a/src/main/java/org/swyp/linkit/domain/exchange/service/SkillExchangeService.java b/src/main/java/org/swyp/linkit/domain/exchange/service/SkillExchangeService.java index f42216aa..b8d12bf3 100644 --- a/src/main/java/org/swyp/linkit/domain/exchange/service/SkillExchangeService.java +++ b/src/main/java/org/swyp/linkit/domain/exchange/service/SkillExchangeService.java @@ -1,9 +1,12 @@ package org.swyp.linkit.domain.exchange.service; import org.swyp.linkit.domain.exchange.dto.SkillExchangeDto; -import org.swyp.linkit.domain.exchange.dto.response.*; +import org.swyp.linkit.domain.exchange.dto.response.AvailableDatesResponseDto; +import org.swyp.linkit.domain.exchange.dto.response.AvailableSlotsResponseDto; import org.swyp.linkit.domain.exchange.dto.response.ReceivedExchangeDetailsResponseDto; +import org.swyp.linkit.domain.exchange.dto.response.ReceiverSkillsResponseDto; import org.swyp.linkit.domain.exchange.dto.response.SentExchangeDetailsResponseDto; +import org.swyp.linkit.domain.exchange.dto.response.SkillExchangeResponseDto; import org.swyp.linkit.domain.exchange.entity.SkillExchange; import java.time.LocalDate; @@ -47,10 +50,6 @@ public interface SkillExchangeService { * 스킬 거래 취소 */ SkillExchangeResponseDto cancelSkillExchange(Long userId, Long skillExchangeId); - /** - * 요청 관리 네비바, 탭에 사용할 신규 알림 표시 - */ - SkillExchangeNotificationResponseDto getNotification(Long userId); /** * 거래 날짜 전날까지 수락되지 않은 요청 거절 처리(expired) */ diff --git a/src/main/java/org/swyp/linkit/domain/exchange/service/SkillExchangeServiceImpl.java b/src/main/java/org/swyp/linkit/domain/exchange/service/SkillExchangeServiceImpl.java index 68100ec1..ba833d05 100644 --- a/src/main/java/org/swyp/linkit/domain/exchange/service/SkillExchangeServiceImpl.java +++ b/src/main/java/org/swyp/linkit/domain/exchange/service/SkillExchangeServiceImpl.java @@ -10,11 +10,15 @@ import org.swyp.linkit.domain.credit.entity.HistoryType; import org.swyp.linkit.domain.credit.service.CreditService; import org.swyp.linkit.domain.exchange.dto.SkillExchangeDto; -import org.swyp.linkit.domain.exchange.dto.response.*; -import org.swyp.linkit.domain.notification.entity.NotificationType; -import org.swyp.linkit.domain.notification.service.NotificationService; +import org.swyp.linkit.domain.exchange.dto.response.AvailableDatesResponseDto; +import org.swyp.linkit.domain.exchange.dto.response.AvailableSlotsResponseDto; import org.swyp.linkit.domain.exchange.dto.response.ReceivedExchangeDetailsResponseDto; +import org.swyp.linkit.domain.exchange.dto.response.ReceiverSkillsResponseDto; import org.swyp.linkit.domain.exchange.dto.response.SentExchangeDetailsResponseDto; +import org.swyp.linkit.domain.exchange.dto.response.SkillExchangeResponseDto; +import org.swyp.linkit.domain.exchange.dto.response.SlotDto; +import org.swyp.linkit.domain.notification.entity.NotificationType; +import org.swyp.linkit.domain.notification.service.NotificationService; import org.swyp.linkit.domain.exchange.entity.ExchangeStatus; import org.swyp.linkit.domain.exchange.entity.SkillExchange; import org.swyp.linkit.domain.exchange.repository.SkillExchangeRepository; @@ -226,16 +230,14 @@ public SentExchangeDetailsResponseDto getSentRequests(Long userId, Long cursorId Slice slice = exchangeRepository.findAllByRequesterIdWithReceiver(userId, cursorId, pageable); - // 3. 응답 Dto 변환 - SentExchangeDetailsResponseDto responseDto = SentExchangeDetailsResponseDto.from(slice); - - // 4. bulkUpdate (isRequesterRead = false -> true) - exchangeRepository.bulkUpdateRequesterReadStatus(userId); + // 3. 읽음 처리 전 미읽음 refId 배치 조회 (isNew per-item 계산용) + Set unreadRefIds = notificationService.getUnreadSentRequestRefIds(userId); - // 5. Notification 도메인 읽음 처리 (REQUEST_SENT + REQUEST_STATUS_CHANGED) + // 4. Notification 도메인 읽음 처리 (REQUEST_SENT + REQUEST_STATUS_CHANGED) notificationService.markSentRequestAsRead(userId); - return responseDto; + // 5. 응답 Dto 변환 + return SentExchangeDetailsResponseDto.from(slice, unreadRefIds); } /** @@ -251,16 +253,14 @@ public ReceivedExchangeDetailsResponseDto getReceivedRequests(Long userId, Long Slice slice = exchangeRepository.findAllByReceiverIdWithRequester(userId, cursorId, pageable); - // 3. 응답 Dto 변환 - ReceivedExchangeDetailsResponseDto responseDto = ReceivedExchangeDetailsResponseDto.from(slice); - - // 4. bulkUpdate (isReceiverRead = false -> true) - exchangeRepository.bulkUpdateReceiverReadStatus(userId); + // 3. 읽음 처리 전 미읽음 refId 배치 조회 (isNew per-item 계산용) + Set unreadRefIds = notificationService.getUnreadReceivedRequestRefIds(userId); - // 5. Notification 도메인 읽음 처리 (REQUEST_RECEIVED) + // 4. Notification 도메인 읽음 처리 (REQUEST_RECEIVED) notificationService.markReceivedRequestAsRead(userId); - return responseDto; + // 5. 응답 Dto 변환 + return ReceivedExchangeDetailsResponseDto.from(slice, unreadRefIds); } /** @@ -279,18 +279,14 @@ public SkillExchangeResponseDto acceptSkillExchange(Long receiverId, Long skillE // 3. 상태 변경 가능 여부 검증 및 수락 처리 -> InvalidExchangeStatus skillExchange.accept(); - // 4. requester 에게 변경 사항 표시 - skillExchange.updateRequesterReadToFalse(); - - // 5. 알림 생성 (requester에게 REQUEST_STATUS_CHANGED) + // 4. 알림 생성 (requester에게 REQUEST_STATUS_CHANGED) notificationService.createNotification( skillExchange.getRequester().getId(), receiverId, NotificationType.REQUEST_STATUS_CHANGED, skillExchangeId); - // 6. Settlement 생성 + // 5. Settlement 생성 settlementService.createSettlement(skillExchange); - // 5. 응답 Dto 변환 return SkillExchangeResponseDto.from(skillExchange); } @@ -310,18 +306,14 @@ public SkillExchangeResponseDto rejectSkillExchange(Long receiverId, Long skillE // 3. 상태 변경 가능 여부 검증 및 거절 처리 -> InvalidExchangeStatus skillExchange.reject(); - // 4. requester 에게 변경 사항 표시 - skillExchange.updateRequesterReadToFalse(); - - // 5. 알림 생성 (requester에게 REQUEST_STATUS_CHANGED) + // 4. 알림 생성 (requester에게 REQUEST_STATUS_CHANGED) notificationService.createNotification( skillExchange.getRequester().getId(), receiverId, NotificationType.REQUEST_STATUS_CHANGED, skillExchangeId); - // 6. requester 크레딧 환불 -> NotFoundCreditException, InvalidCreditAmountException + // 5. requester 크레딧 환불 -> NotFoundCreditException, InvalidCreditAmountException creditService.refundCreditForExchange(skillExchange, HistoryType.EXCHANGE_REJECTED); - // 5. 응답 Dto 변환 return SkillExchangeResponseDto.from(skillExchange); } @@ -381,18 +373,6 @@ public int expirePendingRequests() { return successCount; } - /** - * 요청 관리 네비바, 탭에 사용할 신규 알림 표시 - * - */ - @Transactional(readOnly = true) - @Override - public SkillExchangeNotificationResponseDto getNotification(Long userId) { - boolean hasUnreadSent = exchangeRepository.existsByRequester_IdAndIsRequesterReadFalse(userId); - boolean hasUnreadReceived = exchangeRepository.existsByReceiver_IdAndIsReceiverReadFalse(userId); - return SkillExchangeNotificationResponseDto.of(hasUnreadSent, hasUnreadReceived); - } - /** * 스킬 거래 조회 */ @@ -427,7 +407,6 @@ private void processParticipantCancel(Long userId, SkillExchange skillExchange) if (currentStatus == ExchangeStatus.ACCEPTED){ settlementService.cancelSettlement(skillExchange.getId()); } - skillExchange.updateReceiverReadToFalse(); // 알림 생성 (receiver에게 REQUEST_STATUS_CHANGED) notificationService.createNotification( skillExchange.getReceiver().getId(), userId, @@ -438,7 +417,6 @@ private void processParticipantCancel(Long userId, SkillExchange skillExchange) throw new InvalidExchangeStatusException("수락된 거래만 취소가 가능합니다."); } settlementService.cancelSettlement(skillExchange.getId()); - skillExchange.updateRequesterReadToFalse(); // 알림 생성 (requester에게 REQUEST_STATUS_CHANGED) notificationService.createNotification( skillExchange.getRequester().getId(), userId, diff --git a/src/main/java/org/swyp/linkit/domain/notification/repository/NotificationRepository.java b/src/main/java/org/swyp/linkit/domain/notification/repository/NotificationRepository.java index 7c470840..6996e26a 100644 --- a/src/main/java/org/swyp/linkit/domain/notification/repository/NotificationRepository.java +++ b/src/main/java/org/swyp/linkit/domain/notification/repository/NotificationRepository.java @@ -9,6 +9,7 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Set; public interface NotificationRepository extends JpaRepository { @@ -111,4 +112,11 @@ List findReadByReceiverIdAndCreatedAtAfter( @Query("SELECT n.refId, COUNT(n) FROM Notification n WHERE n.receiver.id = :userId AND n.isRead = false " + "AND n.notificationType = 'CHAT_MESSAGE' GROUP BY n.refId") List countUnreadChatGroupByRoomId(@Param("userId") Long userId); + + /** + * 특정 사용자의 여러 타입 미읽음 알림의 refId 배치 조회 + * isNew per-item 계산에 사용 (스킬 거래 요청 목록) + */ + @Query("SELECT n.refId FROM Notification n WHERE n.receiver.id = :userId AND n.isRead = false AND n.notificationType IN :types") + Set findUnreadRefIdsByUserIdAndTypes(@Param("userId") Long userId, @Param("types") List types); } \ No newline at end of file diff --git a/src/main/java/org/swyp/linkit/domain/notification/service/NotificationService.java b/src/main/java/org/swyp/linkit/domain/notification/service/NotificationService.java index e32fbc96..246910d3 100644 --- a/src/main/java/org/swyp/linkit/domain/notification/service/NotificationService.java +++ b/src/main/java/org/swyp/linkit/domain/notification/service/NotificationService.java @@ -7,6 +7,7 @@ import org.swyp.linkit.domain.notification.entity.NotificationType; import java.util.Map; +import java.util.Set; public interface NotificationService { @@ -44,6 +45,18 @@ public interface NotificationService { */ Map getUnreadChatCountsPerRoom(Long userId); + /** + * 보낸 요청 목록의 isNew per-item 계산용 + * REQUEST_SENT + REQUEST_STATUS_CHANGED 타입 미읽음 refId 배치 조회 + */ + Set getUnreadSentRequestRefIds(Long userId); + + /** + * 받은 요청 목록의 isNew per-item 계산용 + * REQUEST_RECEIVED + REQUEST_STATUS_CHANGED 타입 미읽음 refId 배치 조회 + */ + Set getUnreadReceivedRequestRefIds(Long userId); + // ===== 알림 목록 조회 ===== /** diff --git a/src/main/java/org/swyp/linkit/domain/notification/service/NotificationServiceImpl.java b/src/main/java/org/swyp/linkit/domain/notification/service/NotificationServiceImpl.java index 068d8a95..d9927dd8 100644 --- a/src/main/java/org/swyp/linkit/domain/notification/service/NotificationServiceImpl.java +++ b/src/main/java/org/swyp/linkit/domain/notification/service/NotificationServiceImpl.java @@ -60,6 +60,12 @@ public class NotificationServiceImpl implements NotificationService { NotificationType.REQUEST_STATUS_CHANGED ); + // 받은 요청 관련 알림 타입들 (신규 요청 + 요청자 취소 등 상태 변경) + private static final List RECEIVED_REQUEST_TYPES = List.of( + NotificationType.REQUEST_RECEIVED, + NotificationType.REQUEST_STATUS_CHANGED + ); + // ===== 알림 생성 + WebSocket 푸시 ===== @Override @@ -130,6 +136,16 @@ public Map getUnreadChatCountsPerRoom(Long userId) { )); } + @Override + public Set getUnreadSentRequestRefIds(Long userId) { + return notificationRepository.findUnreadRefIdsByUserIdAndTypes(userId, SENT_REQUEST_TYPES); + } + + @Override + public Set getUnreadReceivedRequestRefIds(Long userId) { + return notificationRepository.findUnreadRefIdsByUserIdAndTypes(userId, RECEIVED_REQUEST_TYPES); + } + // ===== 알림 목록 조회 ===== @Override From ba8d7a54deda47c436a83a3c2a827fc221f23a21 Mon Sep 17 00:00:00 2001 From: rorrxr Date: Fri, 17 Apr 2026 06:54:49 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix=20:=20RECEIVED=5FREQUEST=5FTYPES?= =?UTF-8?q?=EB=A5=BC=20REQUEST=5FRECEIVED=20=EB=8B=A8=EC=9D=BC=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RECEIVED_REQUEST_TYPES에 REQUEST_STATUS_CHANGED 포함 시 받은 요청 탭 진입만으로 보낸 요청 탭의 상태 변경 알림까지 읽음 처리되는 부작용 발생 - RECEIVED_REQUEST_TYPES = [REQUEST_RECEIVED] 으로 수정 - markReceivedRequestAsRead(), getUnreadCounts() receivedRequestCount 모두 RECEIVED_REQUEST_TYPES 상수를 통해 일관성 있게 처리 Co-Authored-By: Claude Sonnet 4.6 --- .../service/NotificationServiceImpl.java | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/swyp/linkit/domain/notification/service/NotificationServiceImpl.java b/src/main/java/org/swyp/linkit/domain/notification/service/NotificationServiceImpl.java index d9927dd8..34ae37ab 100644 --- a/src/main/java/org/swyp/linkit/domain/notification/service/NotificationServiceImpl.java +++ b/src/main/java/org/swyp/linkit/domain/notification/service/NotificationServiceImpl.java @@ -60,10 +60,12 @@ public class NotificationServiceImpl implements NotificationService { NotificationType.REQUEST_STATUS_CHANGED ); - // 받은 요청 관련 알림 타입들 (신규 요청 + 요청자 취소 등 상태 변경) + // 받은 요청 관련 알림 타입들 + // REQUEST_STATUS_CHANGED 는 보낸 요청(SENT) 버킷에만 속함 + // 받은 요청 탭에서 REQUEST_STATUS_CHANGED 까지 읽음 처리하면 + // 보낸 요청 탭의 미읽음 배지가 잘못 감소하는 부작용 발생 private static final List RECEIVED_REQUEST_TYPES = List.of( - NotificationType.REQUEST_RECEIVED, - NotificationType.REQUEST_STATUS_CHANGED + NotificationType.REQUEST_RECEIVED ); // ===== 알림 생성 + WebSocket 푸시 ===== @@ -110,7 +112,7 @@ public UnreadCountResponseDto getUnreadCounts(Long userId) { long requestTabCount = notificationRepository.countUnreadByUserIdAndTypes(userId, REQUEST_TYPES); // 받은 요청 탭 - long receivedRequestCount = notificationRepository.countUnreadByUserIdAndType(userId, NotificationType.REQUEST_RECEIVED); + long receivedRequestCount = notificationRepository.countUnreadByUserIdAndTypes(userId, RECEIVED_REQUEST_TYPES); // 보낸 요청 탭 (보낸 요청 + 상태 변경) long sentRequestCount = notificationRepository.countUnreadByUserIdAndTypes(userId, SENT_REQUEST_TYPES); @@ -202,7 +204,7 @@ public int markRequestNotificationsAsRead(Long userId) { @Override @Transactional public int markReceivedRequestAsRead(Long userId) { - int count = notificationRepository.markAsReadByUserIdAndType(userId, NotificationType.REQUEST_RECEIVED); + int count = notificationRepository.markAsReadByUserIdAndTypes(userId, RECEIVED_REQUEST_TYPES); log.info("받은 요청 알림 읽음 처리: userId={}, count={}", userId, count); return count; } From 393f130480c195fc960c506aa365484d6ddf109d Mon Sep 17 00:00:00 2001 From: rorrxr Date: Fri, 17 Apr 2026 21:46:47 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix=20:=20REQUEST=5FSTATUS=5FCHANGED=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EB=B6=84=EB=A6=AC=EB=A1=9C=20=EB=B3=B4?= =?UTF-8?q?=EB=82=B8/=EB=B0=9B=EC=9D=80=20=EC=9A=94=EC=B2=AD=20=ED=83=AD?= =?UTF-8?q?=20=EC=95=8C=EB=A6=BC=20=EB=B2=84=ED=82=B7=20=EB=AA=85=ED=99=95?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - REQUEST_STATUS_CHANGED 단일 타입으로는 수락/거절(→요청자)과 요청자취소(→수신자)를 서로 다른 탭 버킷에 분류하는 것이 불가능한 문제 해결 - SENT_REQUEST_STATUS_CHANGED: 수락/거절/수신자취소/만료 시 요청자에게 발송 - RECEIVED_REQUEST_STATUS_CHANGED: 요청자취소/만료 시 수신자에게 발송 - SENT_REQUEST_TYPES = [REQUEST_SENT, SENT_REQUEST_STATUS_CHANGED] - RECEIVED_REQUEST_TYPES = [REQUEST_RECEIVED, RECEIVED_REQUEST_STATUS_CHANGED] - 기존 REQUEST_STATUS_CHANGED 는 DB 호환성을 위해 enum에 @Deprecated 유지 Co-Authored-By: Claude Sonnet 4.6 --- docs/notification-api-guide.md | 297 ++++++++++++++++++ .../service/SkillExchangeExpireProcessor.java | 9 +- .../service/SkillExchangeServiceImpl.java | 16 +- .../notification/entity/NotificationType.java | 6 + .../service/NotificationServiceImpl.java | 21 +- 5 files changed, 325 insertions(+), 24 deletions(-) create mode 100644 docs/notification-api-guide.md diff --git a/docs/notification-api-guide.md b/docs/notification-api-guide.md new file mode 100644 index 00000000..15a8a190 --- /dev/null +++ b/docs/notification-api-guide.md @@ -0,0 +1,297 @@ +# 알림 탭 구현 가이드 (프론트엔드용) + +> **대상:** 알림 탭 UI를 구현하는 프론트엔드 개발자 +> **작성일:** 2026-04-03 +> **브랜치:** `feature/notification/refactoring` + +--- + +## 개요 + +알림은 **두 가지 채널**로 전달됩니다. + +| 채널 | 목적 | 시점 | +|------|------|------| +| **WebSocket (STOMP)** | 실시간 알림 푸시 | 이벤트 발생 즉시 | +| **REST API** | 알림 목록 조회 / 읽음 처리 / 배지 카운트 | 페이지 진입 시 | + +--- + +## 1. WebSocket 실시간 알림 수신 + +### 연결 방법 + +``` +엔드포인트: /ws +프로토콜: STOMP over WebSocket (SockJS fallback 지원) +인증: STOMP CONNECT 헤더에 Authorization: Bearer {accessToken} +``` + +```javascript +// SockJS + StompJS 예시 +const socket = new SockJS('/ws'); +const stompClient = Stomp.over(socket); + +stompClient.connect( + { Authorization: `Bearer ${accessToken}` }, + () => { + // 내 알림 구독 + stompClient.subscribe(`/topic/notification.${myUserId}`, (frame) => { + const notification = JSON.parse(frame.body); + handleNewNotification(notification); + }); + } +); +``` + +### WebSocket 알림 페이로드 (NotificationMessageDto) + +```json +{ + "notificationId": 42, + "receiverId": 10, + "senderId": 7, + "senderNickname": "홍길동", + "notificationType": "REQUEST_RECEIVED", + "refId": 15, + "message": "홍길동님이 스킬 교환을 요청했습니다.", + "createdAtEpochMs": 1743638400000 +} +``` + +| 필드 | 타입 | 설명 | +|------|------|------| +| `notificationId` | Long | DB에 저장된 알림 ID (읽음 처리 시 사용) | +| `receiverId` | Long | 수신자 userId | +| `senderId` | Long | 발신자 userId (`null` 가능 — 시스템 알림) | +| `senderNickname` | String | 발신자 닉네임 (`"시스템"` 가능) | +| `notificationType` | String | 아래 타입 표 참고 | +| `refId` | Long | 관련 리소스 ID (타입별 의미 다름 — 아래 표 참고) | +| `message` | String | 표시할 알림 텍스트 (서버에서 생성) | +| `createdAtEpochMs` | Long | 생성 시각 (Unix ms, UTC 기준) | + +--- + +## 2. 알림 타입별 상세 + +| `notificationType` | `message` 형식 | `refId` 의미 | 클릭 시 이동 | +|--------------------|----------------|--------------|-------------| +| `REQUEST_RECEIVED` | `{발신자}님이 스킬 교환을 요청했습니다.` | skillExchangeRequestId | 받은 요청 탭 | +| `REQUEST_SENT` | `{발신자}님에게 스킬 교환 요청을 보냈습니다.` | skillExchangeRequestId | 보낸 요청 탭 | +| `SENT_REQUEST_STATUS_CHANGED` | `스킬 교환 요청 상태가 변경되었습니다.` | skillExchangeRequestId | 보낸 요청 탭 | +| `RECEIVED_REQUEST_STATUS_CHANGED` | `스킬 교환 요청 상태가 변경되었습니다.` | skillExchangeRequestId | 받은 요청 탭 | +| `CHAT_MESSAGE` | `{발신자}님에게 메시지 요청이 왔습니다.` | **chatRoomId** | 해당 채팅방 | + +> `SENT_REQUEST_STATUS_CHANGED`: 수락/거절/수신자 취소/만료 시 **요청자(멘티)**에게 발송 +> `RECEIVED_REQUEST_STATUS_CHANGED`: 요청자 취소/만료 시 **수신자(멘토)**에게 발송 + +> `CHAT_MESSAGE`의 `refId`는 **chatRoomId**입니다. 알림 클릭 시 해당 채팅방(`/chat/rooms/{refId}`)으로 바로 이동하면 됩니다. + +--- + +## 3. REST API 목록 + +Base URL: `/notifications` +인증: 모든 API에 `Authorization: Bearer {accessToken}` 필요 + +### 3-1. 탭별 미읽음 배지 카운트 조회 + +``` +GET /notifications/unread-count +``` + +**응답:** + +```json +{ + "success": true, + "message": "미읽음 알림 개수 조회 성공", + "data": { + "requestTabCount": 3, + "receivedRequestCount": 2, + "sentRequestCount": 1, + "messageTabCount": 5 + } +} +``` + +| 필드 | 설명 | 배지 표시 위치 | +|------|------|----------------| +| `requestTabCount` | 요청 관리 탭 전체 미읽음 | 요청 관리 탭 배지 | +| `receivedRequestCount` | 받은 요청 서브탭 미읽음 | 받은 요청 탭 배지 | +| `sentRequestCount` | 보낸 요청 + 상태 변경 미읽음 | 보낸 요청 탭 배지 | +| `messageTabCount` | 채팅 메시지 알림 전체 미읽음 | 메시지 탭 배지 | + +**호출 시점:** 앱 진입 시 / WebSocket으로 신규 알림 수신 시마다 갱신 + +--- + +### 3-2. 특정 채팅방의 미읽음 개수 조회 + +``` +GET /notifications/unread-count/chat-rooms/{chatRoomId} +``` + +**응답:** + +```json +{ + "success": true, + "message": "채팅방 미읽음 알림 개수 조회 성공", + "data": { + "chatRoomId": 3, + "unreadCount": 2 + } +} +``` + +**호출 시점:** 채팅방 목록(`/chat/rooms`) 조회 시, 각 채팅방 행에 미읽음 배지를 표시할 때 + +--- + +### 3-3. 알림 목록 조회 (알림 탭) + +``` +GET /notifications +``` + +**응답:** + +```json +{ + "success": true, + "message": "알림 목록 조회 성공", + "data": { + "notifications": [ + { + "id": 42, + "receiverId": 10, + "senderId": 7, + "notificationType": "REQUEST_RECEIVED", + "refId": 15, + "isRead": false, + "createdAt": "2026-04-03T09:00:00" + }, + { + "id": 38, + "receiverId": 10, + "senderId": 5, + "notificationType": "CHAT_MESSAGE", + "refId": 3, + "isRead": true, + "createdAt": "2026-04-02T18:30:00" + } + ], + "totalCount": 2, + "unreadCount": 1 + } +} +``` + +> - **정렬:** 미읽음 알림 전체 (최신순) → 읽은 알림 7일 이내 (최신순) +> - `isRead: false` 항목은 강조(볼드, 배경색 등) 처리 권장 +> - `notificationType`과 `refId`로 클릭 시 이동 경로 결정 (위 타입 표 참고) +> - `message` 필드는 이 응답에 포함되지 않습니다 — WebSocket 수신 시점에만 제공됩니다. 알림 탭에 표시할 텍스트는 `notificationType`과 `senderId`를 조합해 클라이언트에서 생성하거나, 별도로 사용자 닉네임 API를 호출해야 합니다. + +--- + +## 4. 읽음 처리 API + +### 페이지·탭 진입 시 자동 일괄 처리 + +| 진입 페이지/탭 | 호출 API | 처리 대상 | +|----------------|----------|----------| +| 요청 관리 페이지 전체 | `POST /notifications/read/requests` | `REQUEST_RECEIVED` + `REQUEST_SENT` + `REQUEST_STATUS_CHANGED` | +| 받은 요청 서브탭 | `POST /notifications/read/requests/received` | `REQUEST_RECEIVED` | +| 보낸 요청 서브탭 | `POST /notifications/read/requests/sent` | `REQUEST_SENT` + `REQUEST_STATUS_CHANGED` | +| 메시지 목록 페이지 | `POST /notifications/read/messages` | 모든 `CHAT_MESSAGE` | +| 특정 채팅방 진입 | `POST /notifications/read/messages/{chatRoomId}` | 해당 채팅방 `CHAT_MESSAGE` | + +> 특정 채팅방 진입 시 WebSocket `/enter` 이벤트를 사용하면 서버가 자동 처리합니다. +> HTTP로 채팅방에 진입하는 경로가 있다면 REST API를 직접 호출하세요. + +**응답 공통 형태:** + +```json +{ + "success": true, + "message": "요청 알림 읽음 처리 성공", + "data": 3 +} +``` + +`data`는 읽음 처리된 알림 개수입니다. + +### 단건 읽음 처리 + +``` +POST /notifications/read/{notificationId} +``` + +**응답:** + +```json +{ + "success": true, + "message": "알림 읽음 처리 성공", + "data": 42 +} +``` + +`data`는 처리된 `notificationId`입니다. + +### 전체 읽음 처리 + +``` +POST /notifications/read/all +``` + +**응답:** 처리된 알림 개수 반환 + +--- + +## 5. 알림 탭 구현 흐름 요약 + +``` +앱 초기화 + └─ GET /notifications/unread-count → 네비게이션 배지 표시 + └─ WebSocket 구독: /topic/notification.{userId} + +알림 탭 진입 + └─ GET /notifications → 목록 렌더링 + └─ (선택) POST /notifications/read/all → 전체 읽음 처리 + +알림 항목 클릭 + └─ POST /notifications/read/{notificationId} + └─ notificationType에 따라 화면 이동: + REQUEST_RECEIVED → 받은 요청 탭 + REQUEST_SENT → 보낸 요청 탭 + REQUEST_STATUS_CHANGED → 보낸 요청 탭 + CHAT_MESSAGE → /chat/rooms/{refId} + +WebSocket 신규 알림 수신 시 + └─ 알림 목록 상단에 추가 (또는 목록 새로고침) + └─ GET /notifications/unread-count 재호출 → 배지 갱신 + └─ Toast / 스낵바로 message 필드 노출 (선택) +``` + +--- + +## 6. 에러 응답 + +| HTTP 상태 | 원인 | +|-----------|------| +| `401 Unauthorized` | 인증 토큰 없음 / 만료 | +| `403 Forbidden` | 본인 알림이 아닌 읽음 처리 시도 | +| `404 Not Found` | 존재하지 않는 notificationId | +| `409 Conflict` | 이미 읽은 알림을 단건 읽음 처리 시도 | + +에러 응답 형태: + +```json +{ + "success": false, + "message": "해당 알림에 대한 접근 권한이 없습니다.", + "data": null +} +``` diff --git a/src/main/java/org/swyp/linkit/domain/exchange/service/SkillExchangeExpireProcessor.java b/src/main/java/org/swyp/linkit/domain/exchange/service/SkillExchangeExpireProcessor.java index af5d002e..ffd86e5a 100644 --- a/src/main/java/org/swyp/linkit/domain/exchange/service/SkillExchangeExpireProcessor.java +++ b/src/main/java/org/swyp/linkit/domain/exchange/service/SkillExchangeExpireProcessor.java @@ -33,20 +33,17 @@ public void expireSingleSkillExchange(Long skillExchangeId){ // pending -> expired 변경 skillExchange.expire(); - // 확인 안함 처리(requester, receiver) - skillExchange.updateReceiverReadToFalse(); - skillExchange.updateRequesterReadToFalse(); // 크레딧 환불 처리 creditService.refundCreditForExchange(skillExchange, HistoryType.EXCHANGE_EXPIRED); - // 알림 생성 (requester, receiver 모두에게 시스템 알림 — afterCommit 시 Redis 발행) + // 알림 생성 — requester: 보낸 요청 탭, receiver: 받은 요청 탭 notificationService.createSystemNotification( skillExchange.getRequester().getId(), - NotificationType.REQUEST_STATUS_CHANGED, skillExchange.getId()); + NotificationType.SENT_REQUEST_STATUS_CHANGED, skillExchange.getId()); notificationService.createSystemNotification( skillExchange.getReceiver().getId(), - NotificationType.REQUEST_STATUS_CHANGED, skillExchange.getId()); + NotificationType.RECEIVED_REQUEST_STATUS_CHANGED, skillExchange.getId()); log.debug("거래 만료 처리 완료. skillExchangeId: {}", skillExchange.getId()); } } diff --git a/src/main/java/org/swyp/linkit/domain/exchange/service/SkillExchangeServiceImpl.java b/src/main/java/org/swyp/linkit/domain/exchange/service/SkillExchangeServiceImpl.java index ba833d05..2b495838 100644 --- a/src/main/java/org/swyp/linkit/domain/exchange/service/SkillExchangeServiceImpl.java +++ b/src/main/java/org/swyp/linkit/domain/exchange/service/SkillExchangeServiceImpl.java @@ -279,10 +279,10 @@ public SkillExchangeResponseDto acceptSkillExchange(Long receiverId, Long skillE // 3. 상태 변경 가능 여부 검증 및 수락 처리 -> InvalidExchangeStatus skillExchange.accept(); - // 4. 알림 생성 (requester에게 REQUEST_STATUS_CHANGED) + // 4. 알림 생성 (requester에게 SENT_REQUEST_STATUS_CHANGED — 보낸 요청 탭) notificationService.createNotification( skillExchange.getRequester().getId(), receiverId, - NotificationType.REQUEST_STATUS_CHANGED, skillExchangeId); + NotificationType.SENT_REQUEST_STATUS_CHANGED, skillExchangeId); // 5. Settlement 생성 settlementService.createSettlement(skillExchange); @@ -306,10 +306,10 @@ public SkillExchangeResponseDto rejectSkillExchange(Long receiverId, Long skillE // 3. 상태 변경 가능 여부 검증 및 거절 처리 -> InvalidExchangeStatus skillExchange.reject(); - // 4. 알림 생성 (requester에게 REQUEST_STATUS_CHANGED) + // 4. 알림 생성 (requester에게 SENT_REQUEST_STATUS_CHANGED — 보낸 요청 탭) notificationService.createNotification( skillExchange.getRequester().getId(), receiverId, - NotificationType.REQUEST_STATUS_CHANGED, skillExchangeId); + NotificationType.SENT_REQUEST_STATUS_CHANGED, skillExchangeId); // 5. requester 크레딧 환불 -> NotFoundCreditException, InvalidCreditAmountException creditService.refundCreditForExchange(skillExchange, HistoryType.EXCHANGE_REJECTED); @@ -407,20 +407,20 @@ private void processParticipantCancel(Long userId, SkillExchange skillExchange) if (currentStatus == ExchangeStatus.ACCEPTED){ settlementService.cancelSettlement(skillExchange.getId()); } - // 알림 생성 (receiver에게 REQUEST_STATUS_CHANGED) + // 알림 생성 (receiver에게 RECEIVED_REQUEST_STATUS_CHANGED — 받은 요청 탭) notificationService.createNotification( skillExchange.getReceiver().getId(), userId, - NotificationType.REQUEST_STATUS_CHANGED, skillExchange.getId()); + NotificationType.RECEIVED_REQUEST_STATUS_CHANGED, skillExchange.getId()); } else{ // receiver -> ACCEPTED일 때만 취소 가능 if (currentStatus != ExchangeStatus.ACCEPTED) { throw new InvalidExchangeStatusException("수락된 거래만 취소가 가능합니다."); } settlementService.cancelSettlement(skillExchange.getId()); - // 알림 생성 (requester에게 REQUEST_STATUS_CHANGED) + // 알림 생성 (requester에게 SENT_REQUEST_STATUS_CHANGED — 보낸 요청 탭) notificationService.createNotification( skillExchange.getRequester().getId(), userId, - NotificationType.REQUEST_STATUS_CHANGED, skillExchange.getId()); + NotificationType.SENT_REQUEST_STATUS_CHANGED, skillExchange.getId()); } } diff --git a/src/main/java/org/swyp/linkit/domain/notification/entity/NotificationType.java b/src/main/java/org/swyp/linkit/domain/notification/entity/NotificationType.java index 88b7905c..56804c02 100644 --- a/src/main/java/org/swyp/linkit/domain/notification/entity/NotificationType.java +++ b/src/main/java/org/swyp/linkit/domain/notification/entity/NotificationType.java @@ -9,7 +9,13 @@ public enum NotificationType { REQUEST_RECEIVED("요청 수신"), REQUEST_SENT("요청 발신"), + /** @deprecated 신규 코드에서는 SENT_REQUEST_STATUS_CHANGED / RECEIVED_REQUEST_STATUS_CHANGED 사용 */ + @Deprecated REQUEST_STATUS_CHANGED("요청 상태 변경"), + /** 보낸 요청 상태 변경 — 수락/거절/수신자 취소 시 요청자에게 발송 */ + SENT_REQUEST_STATUS_CHANGED("보낸 요청 상태 변경"), + /** 받은 요청 상태 변경 — 요청자 취소/만료 시 수신자에게 발송 */ + RECEIVED_REQUEST_STATUS_CHANGED("받은 요청 상태 변경"), CHAT_MESSAGE("채팅 메시지"); private final String description; diff --git a/src/main/java/org/swyp/linkit/domain/notification/service/NotificationServiceImpl.java b/src/main/java/org/swyp/linkit/domain/notification/service/NotificationServiceImpl.java index 34ae37ab..e91433ef 100644 --- a/src/main/java/org/swyp/linkit/domain/notification/service/NotificationServiceImpl.java +++ b/src/main/java/org/swyp/linkit/domain/notification/service/NotificationServiceImpl.java @@ -47,25 +47,24 @@ public class NotificationServiceImpl implements NotificationService { private static final String NOTIFICATION_CHANNEL_PREFIX = "notification:user:"; - // 요청 관련 알림 타입들 + // 요청 관련 알림 타입 전체 (탭 전체 배지용) private static final List REQUEST_TYPES = List.of( NotificationType.REQUEST_RECEIVED, NotificationType.REQUEST_SENT, - NotificationType.REQUEST_STATUS_CHANGED + NotificationType.SENT_REQUEST_STATUS_CHANGED, + NotificationType.RECEIVED_REQUEST_STATUS_CHANGED ); - // 보낸 요청 관련 알림 타입들 + // 보낸 요청 탭 — 요청자가 받는 알림: 요청 발신 확인 + 수락/거절/수신자취소 private static final List SENT_REQUEST_TYPES = List.of( NotificationType.REQUEST_SENT, - NotificationType.REQUEST_STATUS_CHANGED + NotificationType.SENT_REQUEST_STATUS_CHANGED ); - // 받은 요청 관련 알림 타입들 - // REQUEST_STATUS_CHANGED 는 보낸 요청(SENT) 버킷에만 속함 - // 받은 요청 탭에서 REQUEST_STATUS_CHANGED 까지 읽음 처리하면 - // 보낸 요청 탭의 미읽음 배지가 잘못 감소하는 부작용 발생 + // 받은 요청 탭 — 수신자가 받는 알림: 신규 요청 + 요청자취소/만료 private static final List RECEIVED_REQUEST_TYPES = List.of( - NotificationType.REQUEST_RECEIVED + NotificationType.REQUEST_RECEIVED, + NotificationType.RECEIVED_REQUEST_STATUS_CHANGED ); // ===== 알림 생성 + WebSocket 푸시 ===== @@ -318,7 +317,9 @@ private String generateNotificationMessage(NotificationType type, String senderN return switch (type) { case REQUEST_RECEIVED -> senderNickname + "님이 스킬 교환을 요청했습니다."; case REQUEST_SENT -> senderNickname + "님에게 스킬 교환 요청을 보냈습니다."; - case REQUEST_STATUS_CHANGED -> "스킬 교환 요청 상태가 변경되었습니다."; + case SENT_REQUEST_STATUS_CHANGED -> "스킬 교환 요청 상태가 변경되었습니다."; + case RECEIVED_REQUEST_STATUS_CHANGED -> "스킬 교환 요청 상태가 변경되었습니다."; + case REQUEST_STATUS_CHANGED -> "스킬 교환 요청 상태가 변경되었습니다."; // backward compat case CHAT_MESSAGE -> senderNickname + "님이 메시지를 보냈습니다."; }; }