diff --git a/src/main/java/com/knoc/chat/controller/ChatController.java b/src/main/java/com/knoc/chat/controller/ChatController.java index a1a2d74..d62724c 100644 --- a/src/main/java/com/knoc/chat/controller/ChatController.java +++ b/src/main/java/com/knoc/chat/controller/ChatController.java @@ -9,8 +9,6 @@ import com.knoc.chat.entity.MessageType; import com.knoc.chat.service.ChatMessageService; import com.knoc.chat.service.ChatRoomService; -import com.knoc.order.service.OrderService; -import com.knoc.senior.SeniorProfileService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -23,8 +21,7 @@ import org.springframework.web.bind.annotation.*; import java.security.Principal; -import java.util.List; -import java.util.Map; +import java.util.*; @Tag(name = "Chat Controller", description = "채팅방 목록, 상세 조회 및 메시지 관리 관련 API") @Controller @@ -34,8 +31,6 @@ public class ChatController { private final ChatMessageService chatMessageService; private final ChatRoomService chatRoomService; - private final OrderService orderService; - private final SeniorProfileService seniorProfileService; @Value("${toss.payments.client-key:}") private String tossClientKey; @@ -53,7 +48,6 @@ public String getChatRoomsPage(Model model, Principal principal) { return "chat/chatrooms"; } - @Operation(summary = "채팅방 생성", description = "대상 시니어와 새로운 채팅방을 생성하고 해당 방으로 이동합니다.") @PostMapping("/rooms") public String createChatRoom(Principal principal, @RequestParam Long seniorId) { @@ -61,7 +55,6 @@ public String createChatRoom(Principal principal, @RequestParam Long seniorId) { return "redirect:/chat/" + chatRoom.getId(); } - @Operation(summary = "채팅방 상세 페이지 조회", description = "선택한 채팅방의 정보, 메시지 내역, 결제 요청 상태 등을 포함한 상세 페이지를 조회합니다.") @GetMapping("/{roomId}") public String getChatRoomPage(@PathVariable("roomId") Long roomId, Model model, Principal principal) { @@ -69,20 +62,31 @@ public String getChatRoomPage(@PathVariable("roomId") Long roomId, Model model, ChatRoom chatRoom = dto.selectedRoom(); List messages = dto.messages(); + // 현재 사용자의 역할 및 상대 주니어 ID 계산 boolean isSenior = chatRoom.getSenior() != null && chatRoom.getSenior().getEmail() != null && chatRoom.getSenior().getEmail().equals(principal.getName()); Long juniorId = chatRoom.getJunior() != null ? chatRoom.getJunior().getId() : null; + // 주니어 전용 시스템 메시지(REVIEW_REQUESTED)는 시니어 화면에서는 노출하지 않는다. if (isSenior && messages != null && !messages.isEmpty()) { messages = messages.stream() .filter(m -> m.getMessageType() != MessageType.REVIEW_REQUESTED) .toList(); } - Map orderAmounts = orderService.extractOrderAmounts(messages); - boolean hasPaymentRequest = orderService.hasActivePaymentRequest(chatRoom); - int seniorPricePerReview = seniorProfileService.getPricePerReview(chatRoom.getSenior().getId()); + // PAYMENT_REQUESTED 메시지들의 금액 맵 구성 (orderId -> amount) + Map orderAmounts = chatMessageService.buildOrderAmounts(messages); + + // 해당 채팅방에 이미 결제 요청 시스템 메시지가 있었는지 여부 + // - 버튼 초기 노출 제어에만 사용 + // - DB에 Order가 남아있더라도, "결제 요청 메시지"가 없다면 버튼은 노출할 수 있다. + boolean hasPaymentRequest = messages != null && messages.stream() + .anyMatch(m -> m.getMessageType() == MessageType.PAYMENT_REQUESTED); + + // 이 채팅방 시니어의 등록 리뷰 단가 (결제 요청 모달 placeholder용) + // 프로필 미등록/미설정 시 0 → 프런트에서 기본값으로 처리 + int seniorPricePerReview = dto.seniorPricePerReview(); model.addAttribute("selectedRoomId", dto.selectedRoomId()); model.addAttribute("messages", messages); @@ -102,7 +106,6 @@ public String getChatRoomPage(@PathVariable("roomId") Long roomId, Model model, return "chat/chatrooms"; } - @Operation(summary = "메시지 전송 (WebSocket)", description = "채팅방으로 메시지를 전송합니다. (WebSocket/STOMP)") @MessageMapping("/{roomId}/send") public void sendMessage(@DestinationVariable Long roomId, @Payload ChatMessageRequest request, Principal principal) { @@ -116,4 +119,4 @@ public void sendMessage(@DestinationVariable Long roomId, @Payload ChatMessageRe public List getMessagesBefore(@PathVariable("roomId") Long roomId, @RequestParam Long before, Principal principal) { return chatMessageService.getPreviousMessages(roomId, before, principal.getName()); } -} \ No newline at end of file +} diff --git a/src/main/java/com/knoc/chat/dto/ChatMessageResponse.java b/src/main/java/com/knoc/chat/dto/ChatMessageResponse.java index 29b9102..8e56f83 100644 --- a/src/main/java/com/knoc/chat/dto/ChatMessageResponse.java +++ b/src/main/java/com/knoc/chat/dto/ChatMessageResponse.java @@ -29,7 +29,10 @@ public class ChatMessageResponse { // PAYMENT_REQUESTED 같은 금액 기반 시스템 메시지에서 결제 버튼 렌더링에 사용. 그 외엔 null private Integer amount; - public ChatMessageResponse(Long id, String senderNickname, String content, LocalDateTime createdAt, MessageType messageType, Long referenceId, Integer amount) { + // roomId를 통해 프론트에서 어느 방 메시지인지 구분에 사용 + private Long roomId; + + public ChatMessageResponse(Long id, String senderNickname, String content, LocalDateTime createdAt, MessageType messageType, Long referenceId, Integer amount, Long roomId) { this.id = id; this.senderNickname = senderNickname; this.content = content; @@ -37,5 +40,6 @@ public ChatMessageResponse(Long id, String senderNickname, String content, Local this.messageType = messageType; this.referenceId = referenceId; this.amount = amount; + this.roomId = roomId; } } diff --git a/src/main/java/com/knoc/chat/dto/ChatRoomDetailDto.java b/src/main/java/com/knoc/chat/dto/ChatRoomDetailDto.java index faa9c61..101995a 100644 --- a/src/main/java/com/knoc/chat/dto/ChatRoomDetailDto.java +++ b/src/main/java/com/knoc/chat/dto/ChatRoomDetailDto.java @@ -15,5 +15,6 @@ public record ChatRoomDetailDto( ChatRoom selectedRoom, Long firstMessageId, Map latestMessages, - String roomStatus + String roomStatus, + int seniorPricePerReview ) {} diff --git a/src/main/java/com/knoc/chat/repository/ChatMessageRepository.java b/src/main/java/com/knoc/chat/repository/ChatMessageRepository.java index 80db3a7..d5a81f9 100644 --- a/src/main/java/com/knoc/chat/repository/ChatMessageRepository.java +++ b/src/main/java/com/knoc/chat/repository/ChatMessageRepository.java @@ -5,6 +5,8 @@ import com.knoc.chat.entity.ChatRoom; import org.springframework.data.domain.PageRequest; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.time.LocalDateTime; import java.util.List; @@ -16,12 +18,11 @@ public interface ChatMessageRepository extends JpaRepository // 특정 주문에 대해 지정 시각 이후 동일 타입 메시지가 존재하는지 확인 boolean existsByReferenceIdAndMessageTypeAndCreatedAtAfter( Long referenceId, MessageType messageType, LocalDateTime threshold); - - // 채팅방의 메시지를 작성된 시간 기준으로 오름차순 정렬하여 전체 조회 - List findByChatRoomOrderByCreatedAtAsc(ChatRoom chatRoom); - // 가장 최근에 작성된 채팅 메시지 1건 조회 - ChatMessage findFirstByChatRoomOrderByCreatedAtDesc(ChatRoom chatRoom); + // 여러 채팅방의 최신 메시지를 쿼리 1번으로 조회 (N+1 방지) + @Query("SELECT m FROM ChatMessage m WHERE m.id IN (" + + "SELECT MAX(m2.id) FROM ChatMessage m2 WHERE m2.chatRoom.id IN :roomIds GROUP BY m2.chatRoom.id)") + List findLatestMessagesForRooms(@Param("roomIds") List roomIds); // 특정 타입을 제외한 가장 최근 메시지 1건 조회 (사이드바 preview 필터링용) ChatMessage findFirstByChatRoomAndMessageTypeNotOrderByCreatedAtDesc(ChatRoom chatRoom, MessageType messageType); diff --git a/src/main/java/com/knoc/chat/service/ChatMessageService.java b/src/main/java/com/knoc/chat/service/ChatMessageService.java index 0a4b582..00f178b 100644 --- a/src/main/java/com/knoc/chat/service/ChatMessageService.java +++ b/src/main/java/com/knoc/chat/service/ChatMessageService.java @@ -36,6 +36,20 @@ public class ChatMessageService { private final ChatRoomService chatRoomService; private final OrderRepository orderRepository; + // 메시지 목록에서 PAYMENT_REQUESTED 타입만 골라 (orderId, amount) 맵을 구성한다. + // Thymeleaf 첫 렌더링과 페이지네이션 응답 양쪽에서 결제 버튼 금액 표시에 사용. + public Map buildOrderAmounts(List messages) { + List paymentOrderIds = messages.stream() + .filter(m -> m.getMessageType() == MessageType.PAYMENT_REQUESTED && m.getReferenceId() != null) + .map(ChatMessage::getReferenceId) + .toList(); + + if (paymentOrderIds.isEmpty()) return Collections.emptyMap(); + + return orderRepository.findAllById(paymentOrderIds).stream() + .collect(Collectors.toMap(Order::getId, Order::getAmount)); + } + @Transactional public void sendMessage(Long roomId, String email, String content) { // 1. 채팅방 조회 @@ -73,6 +87,7 @@ public void sendMessage(Long roomId, String email, String content) { .messageType(savedMessage.getMessageType()) .referenceId(savedMessage.getReferenceId()) // USER 메시지는 null // amount는 생략 -> null (PAYMENT_REQUESTED에서만 의미 있음) + .roomId(roomId) .build(); // 6. 수신자/발신자 양쪽에 1:1 queue 전송 @@ -110,15 +125,7 @@ public List getPreviousMessages(Long roomId, Long before, S // 이번 페이지에 포함된 PAYMENT_REQUESTED 메시지들의 orderId만 모아 한 번에 금액 조회 - List orderIds = messages.stream() - .filter(m -> m.getMessageType() == MessageType.PAYMENT_REQUESTED && m.getReferenceId() != null) - .map(ChatMessage::getReferenceId) - .toList(); - - Map amountByOrderId = orderIds.isEmpty() - ? Collections.emptyMap() - : orderRepository.findAllById(orderIds).stream() - .collect(Collectors.toMap(Order::getId, Order::getAmount)); + Map amountByOrderId = buildOrderAmounts(messages); return messages.stream() .map(m -> ChatMessageResponse.builder() diff --git a/src/main/java/com/knoc/chat/service/ChatRoomService.java b/src/main/java/com/knoc/chat/service/ChatRoomService.java index bd7630e..944133e 100644 --- a/src/main/java/com/knoc/chat/service/ChatRoomService.java +++ b/src/main/java/com/knoc/chat/service/ChatRoomService.java @@ -9,6 +9,7 @@ import com.knoc.global.exception.ErrorCode; import com.knoc.member.Member; import com.knoc.member.MemberRepository; +import com.knoc.senior.repository.SeniorProfileRepository; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.PageRequest; @@ -16,6 +17,7 @@ import org.springframework.transaction.annotation.Transactional; import java.util.*; +import java.util.stream.Collectors; @Service @Transactional(readOnly = true) // 데이터 변경이 없는 조회 메서드가 많으므로 기본값을 readOnly로 설정 @@ -26,6 +28,7 @@ public class ChatRoomService { private final ApplicationEventPublisher eventPublisher; private final MemberRepository memberRepository; private final ChatMessageRepository chatMessageRepository; + private final SeniorProfileRepository seniorProfileRepository; // 권한 검증 로직 public void verifyParticipant(ChatRoom chatRoom, Member currentMember) { @@ -35,22 +38,29 @@ public void verifyParticipant(ChatRoom chatRoom, Member currentMember) { } // 최신 메시지 map 생성 로직 - // - 시니어 화면에서는 주니어 전용 시스템 메시지(REVIEW_REQUESTED)가 사이드바 미리보기에 노출되지 않도록 필터링한다. + // - 쿼리 1번으로 모든 방의 최신 메시지 조회 (N+1 방지) + // - 시니어 화면에서는 REVIEW_REQUESTED가 최신인 방만 추가 조회하여 필터링 private Map buildLatestMessages(List chatRooms, Member currentMember) { - Map latestMessages = new HashMap<>(); + List roomIds = chatRooms.stream() + .map(ChatRoom::getId) + .toList(); + + Map latestMessages = chatMessageRepository + .findLatestMessagesForRooms(roomIds).stream() + .collect(Collectors.toMap(m -> m.getChatRoom().getId(), m -> m)); + + // 시니어인 경우, REVIEW_REQUESTED가 최신인 방만 추가 조회 for (ChatRoom chatRoom : chatRooms) { - ChatMessage latest = chatMessageRepository.findFirstByChatRoomOrderByCreatedAtDesc(chatRoom); + ChatMessage latest = latestMessages.get(chatRoom.getId()); if (latest != null && latest.getMessageType() == MessageType.REVIEW_REQUESTED - && chatRoom.getSenior() != null - && chatRoom.getSenior().getId() != null && chatRoom.getSenior().getId().equals(currentMember.getId())) { - // 시니어에게는 REVIEW_REQUESTED를 숨김 (다음 최신 메시지로 대체) - latest = chatMessageRepository.findFirstByChatRoomAndMessageTypeNotOrderByCreatedAtDesc( - chatRoom, MessageType.REVIEW_REQUESTED); + latestMessages.put(chatRoom.getId(), + chatMessageRepository.findFirstByChatRoomAndMessageTypeNotOrderByCreatedAtDesc( + chatRoom, MessageType.REVIEW_REQUESTED)); } - latestMessages.put(chatRoom.getId(), latest); } + return latestMessages; } @@ -77,13 +87,21 @@ public ChatRoomDetailDto getRoomDetailInfo(Long roomId, String email) { List chatRooms = chatRoomRepository.findByJuniorOrSenior(currentMember, currentMember); List messages = chatMessageRepository .findByChatRoomAndIdLessThanOrderByIdDesc(chatRoom, Long.MAX_VALUE, PageRequest.of(0, 20)); + + messages = new ArrayList<>(messages); Collections.reverse(messages); Long firstMessageId = messages.isEmpty() ? Long.MAX_VALUE : messages.get(0).getId(); Map latestMessages = buildLatestMessages(chatRooms, currentMember); + // 이 채팅방 시니어의 등록 리뷰 단가 (결제 요청 모달 placeholder용) + // 프로필 미등록/미설정 시 0 → 프런트에서 기본값으로 처리 + int seniorPricePerReview = seniorProfileRepository.findByMemberId(chatRoom.getSenior().getId()) + .map(p -> p.getPricePerReview()) + .orElse(0); + return new ChatRoomDetailDto( roomId, messages, currentMember.getNickname(),chatRooms, - chatRoom, firstMessageId, latestMessages, chatRoom.getStatus().name() + chatRoom, firstMessageId, latestMessages, chatRoom.getStatus().name(), seniorPricePerReview ); } diff --git a/src/main/java/com/knoc/event/listener/ChatEventListener.java b/src/main/java/com/knoc/event/listener/ChatEventListener.java index 0cd1f19..ef874be 100644 --- a/src/main/java/com/knoc/event/listener/ChatEventListener.java +++ b/src/main/java/com/knoc/event/listener/ChatEventListener.java @@ -65,6 +65,7 @@ public void handleChatEventSystem(ChatSystemEvent event) { .messageType(event.type()) .referenceId(event.referenceId()) .amount(amount) + .roomId(event.roomId()) .build(); // 4. 1:1 Queue 방식으로 조건에 맞게 전송 diff --git a/src/main/java/com/knoc/global/controller/IndexController.java b/src/main/java/com/knoc/global/controller/IndexController.java index 7f18f44..452d95d 100644 --- a/src/main/java/com/knoc/global/controller/IndexController.java +++ b/src/main/java/com/knoc/global/controller/IndexController.java @@ -35,4 +35,13 @@ public String index(@ModelAttribute SeniorSearchCondition condition, Model model model.addAttribute("tossClientKey", tossClientKey); return "index"; } + + @Operation(summary = "시니어 목록 fragment 조회", description = "검색 조건에 따른 시니어 목록을 HTML fragment으로 반환 / AJAX 필터링에 사용한다.") + @GetMapping("/seniors/fragment") + public String searchFragment(@ModelAttribute SeniorSearchCondition condition, Model model) { + model.addAttribute("seniors", seniorProfileService.searchProfiles(condition)); + model.addAttribute("condition", condition); + model.addAttribute("popularSkills", POPULAR_SKILLS); + return "index :: seniorSection"; // 시니어 섹션 fragment만 반환 + } } \ No newline at end of file diff --git a/src/main/java/com/knoc/order/controller/OrderController.java b/src/main/java/com/knoc/order/controller/OrderController.java index 8f17870..572c6a1 100644 --- a/src/main/java/com/knoc/order/controller/OrderController.java +++ b/src/main/java/com/knoc/order/controller/OrderController.java @@ -1,9 +1,5 @@ package com.knoc.order.controller; -import com.knoc.global.exception.BusinessException; -import com.knoc.global.exception.ErrorCode; -import com.knoc.member.Member; -import com.knoc.member.MemberRepository; import com.knoc.order.dto.OrderRequest; import com.knoc.order.dto.OrderResponse; import com.knoc.order.service.OrderService; @@ -22,7 +18,6 @@ @RequestMapping("/orders") public class OrderController { private final OrderService orderService; - private final MemberRepository memberRepository; @Operation(summary = "멘토링 주문 요청", description = "시니어가 멘토링 주문을 생성합니다. 요청 중복을 방지하기 위한 Idempotency-Key가 필요합니다.") @PostMapping("/request") @@ -31,12 +26,8 @@ public ResponseEntity requestOrder(@AuthenticationPrincipal UserD @RequestBody OrderRequest dto, @RequestHeader("Idempotency-Key") String idempotencyKey) { // 1. 현재 로그인한 시니어 ID를 가져온다. - Member member = memberRepository.findByEmail(userDetails.getUsername()) - .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); - Long seniorId = member.getId(); - // 2. 서비스를 호출하여 주문을 생성하고 응답 DTO를 받는다. - OrderResponse orderResponse = orderService.createOrderRequest(dto, seniorId, idempotencyKey); + OrderResponse orderResponse = orderService.createOrderRequest(dto, userDetails.getUsername(), idempotencyKey); // 3. 생성된 주문 정보(JSON 데이터로 변환됨)와 함께 200 OK 응답을 브라우저로 보낸다. return ResponseEntity.ok(orderResponse); @@ -47,10 +38,7 @@ public ResponseEntity requestOrder(@AuthenticationPrincipal UserD @PreAuthorize("hasRole('USER')") // 주니어 결제 가능 public ResponseEntity requestPay(@AuthenticationPrincipal UserDetails userDetails, @PathVariable Long orderId) { - Member member = memberRepository.findByEmail(userDetails.getUsername()) - .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); - Long juniorId = member.getId(); - OrderResponse orderResponse = orderService.preparePayment(orderId, juniorId); + OrderResponse orderResponse = orderService.preparePayment(orderId, userDetails.getUsername()); return ResponseEntity.ok(orderResponse); } } diff --git a/src/main/java/com/knoc/order/controller/TossPaymentController.java b/src/main/java/com/knoc/order/controller/TossPaymentController.java index cabc163..2f7d907 100644 --- a/src/main/java/com/knoc/order/controller/TossPaymentController.java +++ b/src/main/java/com/knoc/order/controller/TossPaymentController.java @@ -3,7 +3,6 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.knoc.global.exception.BusinessException; -import com.knoc.order.repository.OrderRepository; import com.knoc.order.service.OrderService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -32,7 +31,6 @@ public class TossPaymentController { private final ObjectMapper objectMapper; private final OrderService orderService; - private final OrderRepository orderRepository; private final RestClient tossRestClient; // 시크릿 키 설정 여부 가드용으로만 사용 (실제 인증 헤더는 tossRestClient에서 자동 주입) @@ -132,11 +130,6 @@ public ResponseEntity fail( // Toss 콜백 후 돌아갈 채팅방 URL 계산 (주문 조회 실패 시 "/"으로 폴백) private String redirectToChat(String tossOrderId) { - if (!StringUtils.hasText(tossOrderId)) { - return "/"; - } - return orderRepository.findByOrderNumber(tossOrderId) - .map(o -> "/chat/" + o.getChatRoom().getId()) - .orElse("/"); + return orderService.getChatRoomUrlByOrderNumber(tossOrderId); } } diff --git a/src/main/java/com/knoc/order/service/OrderService.java b/src/main/java/com/knoc/order/service/OrderService.java index 65361fd..e0d28d3 100644 --- a/src/main/java/com/knoc/order/service/OrderService.java +++ b/src/main/java/com/knoc/order/service/OrderService.java @@ -53,8 +53,17 @@ public class OrderService { private static final long FAILURE_MESSAGE_COOLDOWN_SECONDS = 30L; private final SeniorProfileRepository seniorProfileRepository; + public String getChatRoomUrlByOrderNumber(String tossOrderId) { + if (!StringUtils.hasText(tossOrderId)) { + return "/"; + } + return orderRepository.findByOrderNumber(tossOrderId) + .map(o -> "/chat/" + o.getChatRoom().getId()) + .orElse("/"); + } + @Transactional - public OrderResponse createOrderRequest(OrderRequest dto, Long seniorId, String idempotencyKey) { + public OrderResponse createOrderRequest(OrderRequest dto, String email, String idempotencyKey) { // 0. 멱등키 검증 (비어있거나 너무 짧거나 너무 긴 경우에 대한 에러 처리) // Toss 제약: orderId 6~64자 // idempotencyKey가 60자 초과면 orderNumber가 64자 초과이므로 에러. @@ -76,11 +85,11 @@ public OrderResponse createOrderRequest(OrderRequest dto, Long seniorId, String .orElseThrow(() -> new BusinessException(ErrorCode.CHATROOM_NOT_FOUND)); Member junior = memberRepository.findById(dto.getJuniorId()) .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); - Member senior = memberRepository.findById(seniorId) // 시큐리티에서 넘겨받은 현재 사용자 + Member senior = memberRepository.findByEmail(email) // 시큐리티에서 넘겨받은 현재 사용자 .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); // 요청한 사람이 실제 해당 채팅방의 시니어가 맞는지 검증 - if (!chatRoom.getSenior().getId().equals(seniorId)) { + if (!chatRoom.getSenior().getId().equals(senior.getId())) { throw new BusinessException(ErrorCode.NOT_SENIOR_IN_ROOM); } @@ -118,7 +127,10 @@ public OrderResponse createOrderRequest(OrderRequest dto, Long seniorId, String // 결제창 호출 전 단계(사전 검증/조회) // 실제 결제 승인 후 처리는 confirmPayment(String, long) 메서드에서 수행 - public OrderResponse preparePayment(Long orderId, Long juniorId) { + public OrderResponse preparePayment(Long orderId, String email) { + Member junior = memberRepository.findByEmail(email).orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); + Long juniorId = junior.getId(); + // 입력 검증 if (juniorId == null) { throw new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR); diff --git a/src/main/resources/static/css/chatrooms.css b/src/main/resources/static/css/chatrooms.css index 298592b..39191ef 100644 --- a/src/main/resources/static/css/chatrooms.css +++ b/src/main/resources/static/css/chatrooms.css @@ -119,9 +119,25 @@ text-overflow: ellipsis; } +.room-meta { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; + flex-shrink: 0; +} + .room-time { font-size: 0.7rem; color: #555; +} + +.unread-badge { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: #00CFE8; + display: none; flex-shrink: 0; } diff --git a/src/main/resources/static/js/chat.js b/src/main/resources/static/js/chat.js index 15dc2a1..ffee7b7 100644 --- a/src/main/resources/static/js/chat.js +++ b/src/main/resources/static/js/chat.js @@ -16,6 +16,10 @@ const SENIOR_PRICE_PER_REVIEW = window.SENIOR_PRICE_PER_REVIEW ?? 0; const chatContainer = document.getElementById('messageList'); let stompClient = null; +// 현재 방 마감 여부 (페이지 로드 시 초기값 + ROOM_CLOSE 이벤트 수신 시 true로 전환) +// ROOM_STATUS 상수는 변경이 안 되므로 별도 변수로 관리 +let currentRoomClosed = ROOM_STATUS === 'CLOSED'; + // 결제 상세 모달에서 결제 진행 시 사용할 현재 주문 정보 // (GET /orders/{orderId}/prepare 응답으로 채워짐) let paymentDetailOrderId = null; // Toss orderId로 사용할 orderNumber(예: "ORD-...") @@ -167,8 +171,8 @@ function renderUserMessage(msg, options = {}) { } // 사이드바 미리보기 실시간 업데이트 -function updateSidebarPreview(content) { - const roomItem = document.querySelector(`.room-item[data-room-id="${ROOM_ID}"]`); +function updateSidebarPreview(content, roomId = ROOM_ID) { + const roomItem = document.querySelector(`.room-item[data-room-id="${roomId}"]`); if (!roomItem) return; const preview = roomItem.querySelector('.room-preview'); if (preview) preview.textContent = content; @@ -179,6 +183,34 @@ function updateSidebarPreview(content) { } } +// 읽지 않은 메시지 점 표시 관리 (localStorage로 새로고침 후에도 유지) +function updateUnreadBadge(roomId) { + localStorage.setItem('unread_' + roomId, 'true'); + showUnreadDot(roomId); +} + +function showUnreadDot(roomId) { + const roomItem = document.querySelector(`.room-item[data-room-id="${roomId}"]`); + if (!roomItem) return; + const dot = roomItem.querySelector('.unread-badge'); + if (dot) dot.style.display = 'block'; +} + +// 페이지 로드 시: 현재 방은 읽음 처리, 나머지는 localStorage에서 복원 +document.addEventListener('DOMContentLoaded', function () { + // 현재 보고 있는 방은 읽음 처리 + if (ROOM_ID) localStorage.removeItem('unread_' + ROOM_ID); + + // 다른 방들의 미확인 점 복원 + document.querySelectorAll('.room-item').forEach(function (item) { + const roomId = item.getAttribute('data-room-id'); + if (roomId && localStorage.getItem('unread_' + roomId)) { + const dot = item.querySelector('.unread-badge'); + if (dot) dot.style.display = 'block'; + } + }); +}); + // 사이드바 토글 (모바일 대응) function toggleSidebar() { const sidebar = document.getElementById('chatSidebar'); @@ -203,7 +235,8 @@ function disableChatUI() { // ========================================== // 5. STOMP 연결 및 구독 로직 (1:1 Queue) // ========================================== -if (ROOM_ID && ROOM_STATUS !== 'CLOSED') { +// CLOSED 방을 보고 있어도 다른 방의 메시지(배지/사이드바 업데이트)를 받기 위해 항상 연결한다. +if (ROOM_ID) { const socket = new SockJS('/ws'); stompClient = Stomp.over(socket); stompClient.debug = null; @@ -215,9 +248,14 @@ if (ROOM_ID && ROOM_STATUS !== 'CLOSED') { stompClient.connect(connectHeaders, function () { console.log('✅ STOMP 서버 연결 완료'); const statusEl = document.getElementById('connectionStatus'); - if(statusEl) { - statusEl.textContent = '연결됨'; - statusEl.className = 'connection-status status-connected'; + if (statusEl) { + if (currentRoomClosed) { + statusEl.textContent = '마감됨'; + statusEl.className = 'connection-status status-disconnected'; + } else { + statusEl.textContent = '연결됨'; + statusEl.className = 'connection-status status-connected'; + } } // 💡 1:1 큐 구독 방식으로 통신 @@ -225,18 +263,28 @@ if (ROOM_ID && ROOM_STATUS !== 'CLOSED') { const data = JSON.parse(message.body); const type = data.messageType || data.type; + // 다른 방 메시지: 사이드바 미리보기 + 배지만 업데이트하고 렌더링은 스킵 + if (data.roomId && data.roomId !== ROOM_ID) { + if (type === 'USER') { + updateSidebarPreview(data.content, data.roomId); + updateUnreadBadge(data.roomId); + } + return; + } + + // 현재 방이 마감 상태면 새 메시지 렌더링 스킵 + if (currentRoomClosed) return; + // 실시간 마감 이벤트 감지 if (type === 'ROOM_CLOSE') { + currentRoomClosed = true; // 이후 이 방의 메시지는 렌더링하지 않음 renderSystemMessage(data); disableChatUI(); - - stompClient.disconnect(function() { - console.log("🔒 채팅방 마감: 소켓 연결이 안전하게 종료되었습니다."); - if(statusEl) { - statusEl.textContent = '마감됨'; - statusEl.className = 'connection-status status-disconnected'; - } - }); + // WebSocket은 끊지 않음 → 다른 방 메시지(배지/사이드바)를 계속 받기 위해 + if (statusEl) { + statusEl.textContent = '마감됨'; + statusEl.className = 'connection-status status-disconnected'; + } return; } @@ -266,9 +314,6 @@ if (ROOM_ID && ROOM_STATUS !== 'CLOSED') { statusEl.className = 'connection-status status-disconnected'; } }); -} else if (ROOM_STATUS === 'CLOSED') { - console.warn("🔒 이미 마감된 채팅방입니다. 소켓 연결을 차단합니다."); - setTimeout(scrollToBottom, 100); } // ========================================== diff --git a/src/main/resources/templates/chat/chatrooms.html b/src/main/resources/templates/chat/chatrooms.html index 43615ab..63680a2 100644 --- a/src/main/resources/templates/chat/chatrooms.html +++ b/src/main/resources/templates/chat/chatrooms.html @@ -47,9 +47,12 @@ -
+
+
+
+
diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html index 53cb26e..c28f2ba 100644 --- a/src/main/resources/templates/index.html +++ b/src/main/resources/templates/index.html @@ -20,7 +20,7 @@

성장을 이끌어줄 현업 +
@@ -62,8 +62,8 @@

성장을 이끌어줄 현업 상세 필터 - 초기화 + 초기화

@@ -116,7 +116,7 @@

성장을 이끌어줄 현업 - +

@@ -127,10 +127,13 @@

성장을 이끌어줄 현업 🔥 인기 기술스택 - + @@ -142,49 +145,94 @@

성장을 이끌어줄 현업 const MAX_PRICE = 200000; + // 페이지 로드 시 Thymeleaf에서 현재 선택된 skill 값을 초기화 + let currentSkill = /*[[${condition.skill}]]*/ ''; + + // ── 슬라이더 표시 업데이트 ─────────────────────────── function updateCareerDisplay(val) { const v = parseInt(val); - const display = document.getElementById('careerDisplay'); - if (v === 0) { - display.textContent = '전체'; - document.getElementById('careerSlider').name = ''; - } else { - display.textContent = v + '년 이상'; - document.getElementById('careerSlider').name = 'careerYears'; - } + document.getElementById('careerDisplay').textContent = v === 0 ? '전체' : v + '년 이상'; } function updatePriceDisplay(val) { const v = parseInt(val); - const display = document.getElementById('priceDisplay'); - if (v >= MAX_PRICE) { - display.textContent = '제한 없음'; - document.getElementById('priceSlider').name = ''; - } else { - display.textContent = '₩' + v.toLocaleString('ko-KR') + ' 이하'; - document.getElementById('priceSlider').name = 'maxPrice'; - } + document.getElementById('priceDisplay').textContent = + v >= MAX_PRICE ? '제한 없음' : '₩' + v.toLocaleString('ko-KR') + ' 이하'; + } + + // ── 현재 필터 값을 쿼리 파라미터로 수집 ───────────── + function getFilterParams() { + const keyword = document.querySelector('input[name="keyword"]').value; + const career = parseInt(document.getElementById('careerSlider').value); + const price = parseInt(document.getElementById('priceSlider').value); + + const params = new URLSearchParams(); + if (keyword) params.set('keyword', keyword); + if (career > 0) params.set('careerYears', career); + if (price < MAX_PRICE) params.set('maxPrice', price); + if (currentSkill) params.set('skill', currentSkill); + return params; + } + + // ── AJAX로 시니어 목록만 교체 ──────────────────────── + function loadSeniors() { + const params = getFilterParams(); + history.pushState(null, '', '/?' + params.toString()); + + fetch('/seniors/fragment?' + params.toString()) + .then(res => res.text()) + .then(html => { + document.getElementById('seniorSection').outerHTML = html; + }) + .catch(err => console.error('시니어 목록 로딩 실패:', err)); + } + + // ── 기술스택 배지 토글 ─────────────────────────────── + function filterBySkill(skill) { + currentSkill = (currentSkill === skill) ? '' : skill; + + document.querySelectorAll('[data-skill]').forEach(btn => { + const isActive = btn.dataset.skill === currentSkill; + btn.classList.toggle('bg-mint', isActive); + btn.classList.toggle('text-dark', isActive); + btn.classList.toggle('badge-dark', !isActive); + }); + + loadSeniors(); + } + + // ── 필터 전체 초기화 ───────────────────────────────── + function resetFilters() { + document.querySelector('input[name="keyword"]').value = ''; + document.getElementById('careerSlider').value = 0; + document.getElementById('priceSlider').value = MAX_PRICE; + updateCareerDisplay(0); + updatePriceDisplay(MAX_PRICE); + currentSkill = ''; + document.querySelectorAll('[data-skill]').forEach(btn => { + btn.classList.remove('bg-mint', 'text-dark'); + btn.classList.add('badge-dark'); + }); + loadSeniors(); } document.addEventListener('DOMContentLoaded', function () { const careerSlider = document.getElementById('careerSlider'); - updateCareerDisplay(careerSlider.value); - careerSlider.addEventListener('change', function () { - document.getElementById('searchForm').submit(); - }); + const priceSlider = document.getElementById('priceSlider'); - const priceSlider = document.getElementById('priceSlider'); + updateCareerDisplay(careerSlider.value); updatePriceDisplay(priceSlider.value); - priceSlider.addEventListener('change', function () { - document.getElementById('searchForm').submit(); - }); + + // 슬라이더 조작 완료 시 AJAX 조회 + careerSlider.addEventListener('change', loadSeniors); + priceSlider.addEventListener('change', loadSeniors); // 필터가 적용된 상태면 패널 자동 열기 const hasFilter = parseInt(careerSlider.value) > 0 - || parseInt(priceSlider.value) < MAX_PRICE; + || parseInt(priceSlider.value) < MAX_PRICE + || currentSkill !== ''; if (hasFilter) { - const panel = document.getElementById('filterPanel'); - new bootstrap.Collapse(panel, { toggle: false }).show(); + new bootstrap.Collapse(document.getElementById('filterPanel'), { toggle: false }).show(); document.getElementById('filterToggleBtn').classList.add('active'); } }); @@ -192,7 +240,7 @@

성장을 이끌어줄 현업 +

당신에게 딱 맞는 시니어 멘토

diff --git a/src/test/java/com/knoc/order/OrderChatIntegrationTest.java b/src/test/java/com/knoc/order/OrderChatIntegrationTest.java index 4b5a546..18c6e0d 100644 --- a/src/test/java/com/knoc/order/OrderChatIntegrationTest.java +++ b/src/test/java/com/knoc/order/OrderChatIntegrationTest.java @@ -78,7 +78,7 @@ void createOrder_IntegrationTest() { OrderRequest request = new OrderRequest(chatRoom.getId(), junior.getId(), 55000); // when - OrderResponse response = orderService.createOrderRequest(request, senior.getId(), "idempotencyKey"); + OrderResponse response = orderService.createOrderRequest(request, senior.getEmail(), "idempotencyKey"); // AFTER_COMMIT 리스너를 동작시키기 위해 트랜잭션을 여기서 수동으로 커밋 TestTransaction.flagForCommit(); diff --git a/src/test/java/com/knoc/order/service/OrderServiceTest.java b/src/test/java/com/knoc/order/service/OrderServiceTest.java index 96a74c5..5097533 100644 --- a/src/test/java/com/knoc/order/service/OrderServiceTest.java +++ b/src/test/java/com/knoc/order/service/OrderServiceTest.java @@ -67,6 +67,7 @@ class OrderServiceTest { @DisplayName("결제 요청 성공: 정상적인 데이터가 입력되면 주문이 PENDING 상태로 생성된다.") void createOrderRequest_Success() { // given + String seniorEmail = "senior@knoc.com"; Long seniorId = 1L; Long juniorId = 2L; Long chatRoomId = 10L; @@ -85,7 +86,7 @@ void createOrderRequest_Success() { // Stubbing: Repository 조회 시나리오 설정 (DB 의존성 제거) given(chatRoomRepository.findById(chatRoomId)).willReturn(Optional.of(chatRoom)); given(memberRepository.findById(juniorId)).willReturn(Optional.of(junior)); - given(memberRepository.findById(seniorId)).willReturn(Optional.of(senior)); + given(memberRepository.findByEmail(seniorEmail)).willReturn(Optional.of(senior)); // Stubbing: 데이터 저장 로직 모의 처리 // willAnswer를 사용하여 저장 시도된 Order 객체를 ID값만 임의로 채워 반환 (referenceId 확인용) @@ -96,7 +97,7 @@ void createOrderRequest_Success() { }); // when - OrderResponse response = orderService.createOrderRequest(request, seniorId, "idempotencyKey"); + OrderResponse response = orderService.createOrderRequest(request, seniorEmail, "idempotencyKey"); // then assertThat(response).isNotNull(); @@ -124,12 +125,13 @@ void createOrderRequest_Success() { void createOrderRequest_Fail_NotSenior() { // given Long actualSeniorId = 1L; - Long hackerId = 99L; // 실제 방 주인(1L)과 다른 요청자 ID + String hackerEmail = "hacker@knoc.com"; // 실제 방 주인(1L)과 다른 요청자 Long chatRoomId = 10L; OrderRequest request = new OrderRequest(chatRoomId, 2L, 50000); ChatRoom chatRoom = mock(ChatRoom.class); Member actualSenior = mock(Member.class); + Member hackerMember = mock(Member.class); // Stubbing: 채팅방의 실제 소유주 설정 given(actualSenior.getId()).willReturn(actualSeniorId); @@ -137,10 +139,12 @@ void createOrderRequest_Fail_NotSenior() { // Stubbing: Repository 조회 시나리오 설정 given(chatRoomRepository.findById(chatRoomId)).willReturn(Optional.of(chatRoom)); - given(memberRepository.findById(anyLong())).willReturn(Optional.of(mock(Member.class))); + given(memberRepository.findById(anyLong())).willReturn(Optional.of(mock(Member.class))); // junior 조회용 + given(hackerMember.getId()).willReturn(99L); // 방 주인(1L)과 다른 ID + given(memberRepository.findByEmail(hackerEmail)).willReturn(Optional.of(hackerMember)); // when & then - assertThatThrownBy(() -> orderService.createOrderRequest(request, hackerId, "idempotencyKey")) + assertThatThrownBy(() -> orderService.createOrderRequest(request, hackerEmail, "idempotencyKey")) .isInstanceOf(BusinessException.class) // BusinessException이 터져야 함 .hasMessage(ErrorCode.NOT_SENIOR_IN_ROOM.getMessage()); // 메시지도 일치해야 함 @@ -157,7 +161,7 @@ void createOrderRequest_Fail_NotFoundChatRoom() { OrderRequest request = new OrderRequest(1L, 2L, 10000); // when & then - assertThatThrownBy(() -> orderService.createOrderRequest(request, 1L, "idempotencyKey")) + assertThatThrownBy(() -> orderService.createOrderRequest(request, "senior@test.com", "idempotencyKey")) .isInstanceOf(BusinessException.class) .hasMessage(ErrorCode.CHATROOM_NOT_FOUND.getMessage()); @@ -172,12 +176,14 @@ void preparePayment_Success_ReturnsOrderWithoutStateChange() { // given Long orderId = 100L; Long juniorId = 2L; + String juniorEmail = "junior@test.com"; ChatRoom chatRoom = mock(ChatRoom.class); given(chatRoom.getId()).willReturn(10L); Member junior = mock(Member.class); given(junior.getId()).willReturn(juniorId); + given(memberRepository.findByEmail(juniorEmail)).willReturn(Optional.of(junior)); Order order = Order.builder() .orderNumber("ORD-TEST") @@ -191,7 +197,7 @@ void preparePayment_Success_ReturnsOrderWithoutStateChange() { given(seniorProfileRepository.findByMemberId(any())).willReturn(Optional.empty()); // when - OrderResponse response = orderService.preparePayment(orderId, juniorId); + OrderResponse response = orderService.preparePayment(orderId, juniorEmail); // then assertThat(response.getOrderStatus()).isEqualTo(OrderStatus.PENDING); @@ -208,10 +214,12 @@ void preparePayment_Idempotent_WhenAlreadyPaid() { // given Long orderId = 101L; Long juniorId = 2L; + String juniorEmail = "junior@test.com"; ChatRoom chatRoom = mock(ChatRoom.class); Member junior = mock(Member.class); given(junior.getId()).willReturn(juniorId); + given(memberRepository.findByEmail(juniorEmail)).willReturn(Optional.of(junior)); Order order = Order.builder() .orderNumber("ORD-PAID") @@ -225,7 +233,7 @@ void preparePayment_Idempotent_WhenAlreadyPaid() { given(orderRepository.findById(orderId)).willReturn(Optional.of(order)); // when - OrderResponse response = orderService.preparePayment(orderId, juniorId); + OrderResponse response = orderService.preparePayment(orderId, juniorEmail); // then assertThat(response.getOrderStatus()).isEqualTo(OrderStatus.PAID); @@ -288,11 +296,15 @@ void payOrder_Fail_NotJunior() { // given Long orderId = 102L; Long actualJuniorId = 2L; - Long attackerJuniorId = 999L; + String attackerEmail = "attacker@test.com"; Member junior = mock(Member.class); given(junior.getId()).willReturn(actualJuniorId); + Member attackerMember = mock(Member.class); + given(attackerMember.getId()).willReturn(999L); // 실제 주니어(2L)와 다른 ID + given(memberRepository.findByEmail(attackerEmail)).willReturn(Optional.of(attackerMember)); + Order order = Order.builder() .orderNumber("ORD-NOT-JUNIOR") .chatRoom(mock(ChatRoom.class)) @@ -304,7 +316,7 @@ void payOrder_Fail_NotJunior() { given(orderRepository.findById(orderId)).willReturn(Optional.of(order)); // when & then - assertThatThrownBy(() -> orderService.preparePayment(orderId, attackerJuniorId)) + assertThatThrownBy(() -> orderService.preparePayment(orderId, attackerEmail)) .isInstanceOf(BusinessException.class) .hasMessage(ErrorCode.NOT_JUNIOR_FOR_ORDER.getMessage()); @@ -320,7 +332,7 @@ void createOrderRequest_Fail_InvalidIdempotencyKey() { OrderRequest request = new OrderRequest(1L, 2L, 50000); // when & then - assertThatThrownBy(() -> orderService.createOrderRequest(request, 1L, shortKey)) + assertThatThrownBy(() -> orderService.createOrderRequest(request, "senior@test.com", shortKey)) .isInstanceOf(BusinessException.class) .hasMessage(ErrorCode.INVALID_IDEMPOTENCY_KEY.getMessage()); @@ -331,7 +343,7 @@ void createOrderRequest_Fail_InvalidIdempotencyKey() { @DisplayName("결제 요청 멱등: 동시에 두 요청이 들어와 DB 충돌이 발생해도 기존 주문을 반환한다.") void createOrderRequest_Success_WhenConcurrencyConflict() { // given - Long seniorId = 1L; + String seniorEmail = "senior@knoc.com"; Long juniorId = 2L; OrderRequest request = new OrderRequest(10L, juniorId, 50000); String idempotencyKey = "idempotency-789"; @@ -370,12 +382,12 @@ void createOrderRequest_Success_WhenConcurrencyConflict() { // 나머지 엔티티 조회 Stubbing (orElseGet 내부 진입 시 필요) given(chatRoomRepository.findById(anyLong())).willReturn(Optional.of(mockChatRoom)); given(memberRepository.findById(juniorId)).willReturn(Optional.of(mockJunior)); - given(memberRepository.findById(seniorId)).willReturn(Optional.of(mockSenior)); + given(memberRepository.findByEmail(seniorEmail)).willReturn(Optional.of(mockSenior)); given(mockChatRoom.getSenior()).willReturn(mockSenior); // when - OrderResponse response = orderService.createOrderRequest(request, seniorId, idempotencyKey); + OrderResponse response = orderService.createOrderRequest(request, seniorEmail, idempotencyKey); // then assertThat(response.getOrderNumber()).isEqualTo(orderNumber);