Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
94b4cd7
chore: FRONTEND_PROD_URL, BACKEND_URL, BACKEND_PROD_URL Value 추가
chuuminggg Jan 21, 2026
1284857
Merge branch 'main' of https://github.com/SWYP-Link-it/backend
chuuminggg Jan 23, 2026
fc5ff71
Merge branch 'main' of https://github.com/SWYP-Link-it/backend
chuuminggg Jan 26, 2026
2d51667
Merge branch 'main' of https://github.com/SWYP-Link-it/backend
chuuminggg Jan 28, 2026
b963c9f
Merge branch 'main' of https://github.com/SWYP-Link-it/backend
chuuminggg Feb 5, 2026
77bdf45
Merge branch 'main' of https://github.com/SWYP-Link-it/backend
chuuminggg Feb 5, 2026
96d87fc
Merge branch 'main' of https://github.com/SWYP-Link-it/backend
chuuminggg Feb 12, 2026
031791a
Merge branch 'main' of https://github.com/SWYP-Link-it/backend
chuuminggg Feb 13, 2026
ae81b27
Merge branch 'main' of https://github.com/SWYP-Link-it/backend
chuuminggg Mar 3, 2026
14350bc
Merge branch 'main' of https://github.com/SWYP-Link-it/backend
chuuminggg Mar 11, 2026
5efc1b1
refactor : message 필드 추가
chuuminggg Apr 4, 2026
3c71556
refactor : REST API 응답에 message 포함
chuuminggg Apr 4, 2026
c0c0345
refactor : 채팅 메시지 전송 시 알림 발송
chuuminggg Apr 4, 2026
972f5af
Merge branch 'main' into feature/notification/message
chuuminggg Apr 4, 2026
761d298
refactor : null 체크 변경
chuuminggg Apr 4, 2026
bf43b7e
Merge branch 'feature/notification/message' of https://github.com/SWY…
chuuminggg Apr 4, 2026
a310cb4
refactor : 중복 발송 상태 수정
chuuminggg Apr 4, 2026
720feff
fix : RedisConfig에 알림 리스너 등록
chuuminggg Apr 6, 2026
a3642c4
fix : 이미지 메시지 전송 시 ChatMessage.content NPE 발생 문제 해결
chuuminggg Apr 6, 2026
e3ed193
Merge branch 'main' of https://github.com/SWYP-Link-it/backend into f…
chuuminggg Apr 6, 2026
8c31e08
refactor : 닉네임 처리 로직 개선 및 시스템/알 수 없음 구분 (resolveNickname()으로 null/bla…
chuuminggg Apr 6, 2026
a054172
fix : Redis에서 받은 JSON 역직렬화 오류로 알림 미수신 이슈 수정
chuuminggg Apr 7, 2026
ba2771a
fix : 의미없는 ObjectMapper를 RequiredArgsConstructor로 Spring Bean 주입 방식으로 변경
chuuminggg Apr 7, 2026
de0c260
fix : Redis publish 시 트랜잭션 커밋 이후 실행하도록 적용 (트랜잭션이 롤백되는 경우에도 알림 발행하는 이슈…
chuuminggg Apr 7, 2026
e037390
refactor : ErrorCode 추가 (N005, CH007)
chuuminggg Apr 7, 2026
ffe4c6a
feat : 새 예외 클래스 생성 (N005 : NotificationPublishFailedException, CH007 …
chuuminggg Apr 7, 2026
daf7fb9
refactor : 신규 exception Swagger Docs 추가
chuuminggg Apr 7, 2026
94183f2
Merge branch 'main' of https://github.com/SWYP-Link-it/backend into f…
chuuminggg Apr 7, 2026
c36084b
fix : 미커밋된 예외 클래스 파일 추가 (NotificationPublishFailedException, ChatPubl…
chuuminggg Apr 8, 2026
0cc0de4
feat : 스킬교환 요청 이벤트를 Notification 도메인에 연결
chuuminggg Apr 13, 2026
fddb8da
refactor : 채팅 Redis 발행을 Transactional Outbox 패턴으로 내재화
chuuminggg Apr 13, 2026
bab0f0f
Merge branch 'main' into feature/notification/message
chuuminggg Apr 14, 2026
2a5bc36
fix : ChatService 코드리뷰 반영
chuuminggg Apr 14, 2026
caae131
fix : ChatServiceTest ObjectMapper Mock 추가
chuuminggg Apr 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.stereotype.Controller;
import org.swyp.linkit.domain.chat.dto.request.ChatSendRequestDto;
import org.swyp.linkit.domain.chat.entity.ChatMessage;
import org.swyp.linkit.domain.chat.service.ChatService;

import java.security.Principal;
Expand Down Expand Up @@ -40,11 +39,9 @@ public void send(@Payload ChatSendRequestDto dto, Principal principal) {
// 권한 체크 (room 참여자 여부)
chatService.assertParticipant(senderId, roomId);

ChatMessage saved = chatService.saveMessage(
chatService.saveMessage(
roomId, senderId, dto.getText(),
dto.getMessageType(), dto.getImageUrl());

chatService.publishToRedis(saved);
}

/**
Expand Down Expand Up @@ -94,11 +91,10 @@ public void markAsRead(@DestinationVariable Long roomId, Principal principal) {
}

/**
* 읽음 처리 공통 로직 (권한 체크 + 읽음 처리 + Redis 이벤트 발행)
* 읽음 처리 공통 로직 (권한 체크 + 읽음 처리 + Redis 이벤트 발행은 afterCommit에서 자동 처리)
*/
private void processReadAndNotify(Long roomId, Long userId) {
chatService.assertParticipant(userId, roomId);
chatService.markAsRead(roomId, userId);
chatService.publishReadEvent(roomId, userId, null);
}
}
79 changes: 55 additions & 24 deletions src/main/java/org/swyp/linkit/domain/chat/service/ChatService.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.swyp.linkit.domain.chat.dto.ChatMessageDto;
import org.swyp.linkit.domain.chat.dto.response.ChatPayloadResponseDto;
import org.swyp.linkit.domain.chat.entity.*;
Expand All @@ -18,7 +20,11 @@
import org.swyp.linkit.domain.notification.service.NotificationService;
import org.swyp.linkit.domain.user.entity.User;
import org.swyp.linkit.domain.user.repository.UserRepository;
import org.swyp.linkit.global.error.exception.*;
import org.swyp.linkit.global.error.exception.ChatInvalidMessageException;
import org.swyp.linkit.global.error.exception.ChatMessageNotFoundException;
import org.swyp.linkit.global.error.exception.ChatNotParticipantException;
import org.swyp.linkit.global.error.exception.ChatRoomNotFoundException;
import org.swyp.linkit.global.error.exception.UserNotFoundException;

import java.time.ZoneOffset;
import java.util.List;
Expand Down Expand Up @@ -82,13 +88,38 @@ public ChatMessage saveMessage(Long roomId, Long senderId, String content,
ChatMessage message = ChatMessage.create(room, sender, senderRole, content, messageType, fileUrl);

ChatMessage saved = chatMessageRepository.save(message);
chatMessageRepository.flush();

room.updateLastMessage(saved.getId(), saved.getCreatedAt());

// 수신자에게 CHAT_MESSAGE 알림 생성 (Notification 기반 미읽음 카운트 관리)
Long receiverId = senderRole == SenderRole.MENTOR ? room.getMenteeId() : room.getMentorId();
notificationService.createNotification(receiverId, senderId, NotificationType.CHAT_MESSAGE, roomId);

// 트랜잭션 커밋 후 Redis 발행 (Transactional Outbox 패턴)
ChatPayloadResponseDto payload = ChatPayloadResponseDto.builder()
.roomId(roomId)
.messageId(saved.getId())
.senderId(saved.getSenderId())
.senderRole(saved.getSenderRole().name())
.text(saved.getContent())
.messageType(saved.getMessageType().name())
.imageUrl(saved.getFileUrl())
.sentAtEpochMs(saved.getCreatedAt().toInstant(ZoneOffset.UTC).toEpochMilli())
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

saved.getCreatedAt()이 null일 경우 NullPointerException이 발생할 위험이 있습니다. JPA Auditing(@CreatedDate) 필드는 트랜잭션 내에서 엔티티가 아직 flush되지 않은 상태라면 채워지지 않을 수 있습니다. 트랜잭션 커밋 전에 해당 값이 필요하다면, 엔티티 저장 후 명시적으로 flush()를 호출하여 필드 생성을 강제하는 것이 좋습니다. 또한 프로젝트의 시간대 정책에 맞게 변환 로직을 점검해 주시기 바랍니다.

chatMessageRepository.save(message);
chatMessageRepository.flush();
References
  1. In a transactional context, if a newly created entity's ID or auditing fields are needed for subsequent operations before the transaction commits, explicitly call flush() after saving the entity to force generation and persistence.

.system(false)
.build();
Long savedMessageId = saved.getId();
if (TransactionSynchronizationManager.isActualTransactionActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
doPublishToRedis(roomId, savedMessageId, payload);
}
});
} else {
doPublishToRedis(roomId, savedMessageId, payload);
}

log.info("메시지 저장: roomId={}, senderId={}, messageId={}, type={}", roomId, senderId, saved.getId(), messageType);
return saved;
}
Expand Down Expand Up @@ -154,6 +185,19 @@ public void markAsRead(Long roomId, Long userId) {
// Notification 기반 미읽음 알림 읽음 처리
notificationService.markChatRoomAsRead(userId, roomId);

// 트랜잭션 커밋 후 읽음 이벤트 Redis 발행 (Transactional Outbox 패턴)
Long lastReadId = lastMessage.getId();
if (TransactionSynchronizationManager.isActualTransactionActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
doPublishReadEvent(roomId, userId, lastReadId);
}
});
} else {
doPublishReadEvent(roomId, userId, lastReadId);
}

log.info("메시지 읽음 처리: roomId={}, userId={}, lastReadMessageId={}", roomId, userId, lastMessage.getId());
}

Expand Down Expand Up @@ -185,38 +229,28 @@ public void deleteMessages(Long roomId, Long userId, List<Long> messageIds) {
log.info("메시지 삭제: roomId={}, userId={}, count={}", roomId, userId, messageIds.size());
}

// === Private Helper Methods ===

/**
* Redis Pub/Sub을 통해 메시지 발행
* Redis Pub/Sub 메시지 발행 (afterCommit 내부 전용)
* afterCommit에서 발생하는 예외는 Spring이 억제하므로 로그로 대체
*/
public void publishToRedis(ChatMessage message) {
Long roomId = message.getChatRoom().getId();
ChatPayloadResponseDto payload = ChatPayloadResponseDto.builder()
.roomId(roomId)
.messageId(message.getId())
.senderId(message.getSenderId())
.senderRole(message.getSenderRole().name())
.text(message.getContent())
.messageType(message.getMessageType().name())
.imageUrl(message.getFileUrl())
.sentAtEpochMs(message.getCreatedAt().toInstant(ZoneOffset.UTC).toEpochMilli())
.system(false)
.build();

private void doPublishToRedis(Long roomId, Long messageId, ChatPayloadResponseDto payload) {
try {
String json = objectMapper.writeValueAsString(payload);
String channel = CHAT_CHANNEL_PREFIX + roomId;
redisTemplate.convertAndSend(channel, json);
log.info("Redis 메시지 발행: channel={}, messageId={}", channel, message.getId());
log.info("Redis 메시지 발행: channel={}, messageId={}", channel, messageId);
} catch (JsonProcessingException e) {
log.error("채팅 메시지 직렬화 실패: roomId={}, messageId={}", roomId, message.getId(), e);
throw new ChatPublishFailedException(roomId);
log.error("채팅 메시지 직렬화 실패: roomId={}, messageId={}", roomId, messageId, e);
}
}

/**
* 읽음 처리 이벤트 Redis 발행
* 읽음 이벤트 Redis 발행 (afterCommit 내부 전용)
* afterCommit에서 발생하는 예외는 Spring이 억제하므로 로그로 대체
*/
public void publishReadEvent(Long roomId, Long userId, Long lastReadMessageId) {
private void doPublishReadEvent(Long roomId, Long userId, Long lastReadMessageId) {
ChatPayloadResponseDto payload = ChatPayloadResponseDto.builder()
.roomId(roomId)
.readerId(userId)
Expand All @@ -231,12 +265,9 @@ public void publishReadEvent(Long roomId, Long userId, Long lastReadMessageId) {
log.info("읽음 이벤트 발행: channel={}, readerId={}", channel, userId);
} catch (JsonProcessingException e) {
log.error("읽음 이벤트 직렬화 실패: roomId={}, userId={}", roomId, userId, e);
throw new ChatPublishFailedException(roomId);
}
}

// === Private Helper Methods ===

private User findUserById(Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException("User not found: " + userId));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import org.swyp.linkit.domain.credit.service.CreditService;
import org.swyp.linkit.domain.exchange.entity.SkillExchange;
import org.swyp.linkit.domain.exchange.repository.SkillExchangeRepository;
import org.swyp.linkit.domain.notification.entity.NotificationType;
import org.swyp.linkit.domain.notification.service.NotificationService;
import org.swyp.linkit.global.error.exception.ExchangeNotFoundException;

@Component
Expand All @@ -18,6 +20,7 @@ public class SkillExchangeExpireProcessor {

private final SkillExchangeRepository skillExchangeRepository;
private final CreditService creditService;
private final NotificationService notificationService;

// 새로운 트랜잭션 적용
@Transactional(propagation = Propagation.REQUIRES_NEW)
Expand All @@ -36,6 +39,14 @@ public void expireSingleSkillExchange(Long skillExchangeId){

// 크레딧 환불 처리
creditService.refundCreditForExchange(skillExchange, HistoryType.EXCHANGE_EXPIRED);

// 알림 생성 (requester, receiver 모두에게 시스템 알림 — afterCommit 시 Redis 발행)
notificationService.createSystemNotification(
skillExchange.getRequester().getId(),
NotificationType.REQUEST_STATUS_CHANGED, skillExchange.getId());
notificationService.createSystemNotification(
skillExchange.getReceiver().getId(),
NotificationType.REQUEST_STATUS_CHANGED, skillExchange.getId());
log.debug("거래 만료 처리 완료. skillExchangeId: {}", skillExchange.getId());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import org.swyp.linkit.domain.exchange.entity.ExchangeStatus;
import org.swyp.linkit.domain.exchange.entity.SkillExchange;
import org.swyp.linkit.domain.exchange.repository.SkillExchangeRepository;
import org.swyp.linkit.domain.notification.entity.NotificationType;
import org.swyp.linkit.domain.notification.service.NotificationService;
import org.swyp.linkit.domain.user.entity.User;
import org.swyp.linkit.domain.user.entity.UserSkill;
import org.swyp.linkit.domain.user.service.UserService;
Expand All @@ -32,6 +34,7 @@ public class SkillExchangeRequestProcessor {
private final CreditService creditService;
private final UserService userService;
private final UserSkillService userSkillService;
private final NotificationService notificationService;

/**
* 처리 순서:
Expand Down Expand Up @@ -78,6 +81,14 @@ public SkillExchangeResponseDto executeWithLock(Long requesterId,
// 6. 크레딧 차감 및 사용 내역 생성
creditService.useCreditForExchangeRequest(saved);

// 7. 알림 생성 (멘토: REQUEST_RECEIVED, 멘티: REQUEST_SENT) — afterCommit 시 Redis 발행
notificationService.createNotification(
saved.getReceiver().getId(), saved.getRequester().getId(),
NotificationType.REQUEST_RECEIVED, saved.getId());
notificationService.createNotification(
saved.getRequester().getId(), saved.getReceiver().getId(),
NotificationType.REQUEST_SENT, saved.getId());

// TX 커밋 → 락 해제
return SkillExchangeResponseDto.from(saved);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
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.ReceivedExchangeDetailsResponseDto;
import org.swyp.linkit.domain.exchange.dto.response.SentExchangeDetailsResponseDto;
import org.swyp.linkit.domain.exchange.entity.ExchangeStatus;
Expand Down Expand Up @@ -50,6 +52,7 @@ public class SkillExchangeServiceImpl implements SkillExchangeService {
private final SkillExchangeExpireProcessor exchangeExpireProcessor;
private final SkillExchangeRequestProcessor exchangeRequestProcessor;
private final SkillExchangePreValidator exchangePreValidator;
private final NotificationService notificationService;

/**
* 멘토의 거래 가능 스킬 목록 조회
Expand Down Expand Up @@ -228,6 +231,10 @@ public SentExchangeDetailsResponseDto getSentRequests(Long userId, Long cursorId

// 4. bulkUpdate (isRequesterRead = false -> true)
exchangeRepository.bulkUpdateRequesterReadStatus(userId);

// 5. Notification 도메인 읽음 처리 (REQUEST_SENT + REQUEST_STATUS_CHANGED)
notificationService.markSentRequestAsRead(userId);

return responseDto;
}

Expand All @@ -247,8 +254,12 @@ public ReceivedExchangeDetailsResponseDto getReceivedRequests(Long userId, Long
// 3. 응답 Dto 변환
ReceivedExchangeDetailsResponseDto responseDto = ReceivedExchangeDetailsResponseDto.from(slice);

// 4.bulkUpdate (isReceiverRead = false -> true)
// 4. bulkUpdate (isReceiverRead = false -> true)
exchangeRepository.bulkUpdateReceiverReadStatus(userId);

// 5. Notification 도메인 읽음 처리 (REQUEST_RECEIVED)
notificationService.markReceivedRequestAsRead(userId);

return responseDto;
}

Expand All @@ -271,7 +282,12 @@ public SkillExchangeResponseDto acceptSkillExchange(Long receiverId, Long skillE
// 4. requester 에게 변경 사항 표시
skillExchange.updateRequesterReadToFalse();

// 5. Settlement 생성
// 5. 알림 생성 (requester에게 REQUEST_STATUS_CHANGED)
notificationService.createNotification(
skillExchange.getRequester().getId(), receiverId,
NotificationType.REQUEST_STATUS_CHANGED, skillExchangeId);

// 6. Settlement 생성
settlementService.createSettlement(skillExchange);

// 5. 응답 Dto 변환
Expand All @@ -297,7 +313,12 @@ public SkillExchangeResponseDto rejectSkillExchange(Long receiverId, Long skillE
// 4. requester 에게 변경 사항 표시
skillExchange.updateRequesterReadToFalse();

// 5. requester 크레딧 환불 -> NotFoundCreditException, InvalidCreditAmountException
// 5. 알림 생성 (requester에게 REQUEST_STATUS_CHANGED)
notificationService.createNotification(
skillExchange.getRequester().getId(), receiverId,
NotificationType.REQUEST_STATUS_CHANGED, skillExchangeId);

// 6. requester 크레딧 환불 -> NotFoundCreditException, InvalidCreditAmountException
creditService.refundCreditForExchange(skillExchange, HistoryType.EXCHANGE_REJECTED);

// 5. 응답 Dto 변환
Expand Down Expand Up @@ -407,13 +428,21 @@ private void processParticipantCancel(Long userId, SkillExchange skillExchange)
settlementService.cancelSettlement(skillExchange.getId());
}
skillExchange.updateReceiverReadToFalse();
// 알림 생성 (receiver에게 REQUEST_STATUS_CHANGED)
notificationService.createNotification(
skillExchange.getReceiver().getId(), userId,
NotificationType.REQUEST_STATUS_CHANGED, skillExchange.getId());
} else{
// receiver -> ACCEPTED일 때만 취소 가능
if (currentStatus != ExchangeStatus.ACCEPTED) {
throw new InvalidExchangeStatusException("수락된 거래만 취소가 가능합니다.");
}
settlementService.cancelSettlement(skillExchange.getId());
skillExchange.updateRequesterReadToFalse();
// 알림 생성 (requester에게 REQUEST_STATUS_CHANGED)
notificationService.createNotification(
skillExchange.getRequester().getId(), userId,
NotificationType.REQUEST_STATUS_CHANGED, skillExchange.getId());
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.swyp.linkit.domain.chat.service;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
Expand Down Expand Up @@ -64,6 +65,9 @@ class ChatServiceTest {
@Mock
private NotificationService notificationService;

@Mock
private ObjectMapper objectMapper;

private ChatRoom chatRoom;
private Long mentorId;
private Long menteeId;
Expand Down
Loading