Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
29 changes: 16 additions & 13 deletions src/main/java/com/knoc/chat/controller/ChatController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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;
Expand All @@ -53,36 +48,45 @@ public String getChatRoomsPage(Model model, Principal principal) {

return "chat/chatrooms";
}

@Operation(summary = "채팅방 생성", description = "대상 시니어와 새로운 채팅방을 생성하고 해당 방으로 이동합니다.")
@PostMapping("/rooms")
public String createChatRoom(Principal principal, @RequestParam Long seniorId) {
ChatRoom chatRoom = chatRoomService.createChatRoom(principal.getName(), seniorId);

return "redirect:/chat/" + chatRoom.getId();
}

@Operation(summary = "채팅방 상세 페이지 조회", description = "선택한 채팅방의 정보, 메시지 내역, 결제 요청 상태 등을 포함한 상세 페이지를 조회합니다.")
@GetMapping("/{roomId}")
public String getChatRoomPage(@PathVariable("roomId") Long roomId, Model model, Principal principal) {
ChatRoomDetailDto dto = chatRoomService.getRoomDetailInfo(roomId, principal.getName());
ChatRoom chatRoom = dto.selectedRoom();
List<ChatMessage> 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<Long, Integer> orderAmounts = orderService.extractOrderAmounts(messages);
boolean hasPaymentRequest = orderService.hasActivePaymentRequest(chatRoom);
int seniorPricePerReview = seniorProfileService.getPricePerReview(chatRoom.getSenior().getId());
// PAYMENT_REQUESTED 메시지들의 금액 맵 구성 (orderId -> amount)
Map<Long, Integer> 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);
Expand All @@ -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) {
Expand All @@ -116,4 +119,4 @@ public void sendMessage(@DestinationVariable Long roomId, @Payload ChatMessageRe
public List<ChatMessageResponse> getMessagesBefore(@PathVariable("roomId") Long roomId, @RequestParam Long before, Principal principal) {
return chatMessageService.getPreviousMessages(roomId, before, principal.getName());
}
}
}
6 changes: 5 additions & 1 deletion src/main/java/com/knoc/chat/dto/ChatMessageResponse.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,17 @@ 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;
this.createdAt = createdAt;
this.messageType = messageType;
this.referenceId = referenceId;
this.amount = amount;
this.roomId = roomId;
}
}
3 changes: 2 additions & 1 deletion src/main/java/com/knoc/chat/dto/ChatRoomDetailDto.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@ public record ChatRoomDetailDto(
ChatRoom selectedRoom,
Long firstMessageId,
Map<Long, ChatMessage> latestMessages,
String roomStatus
String roomStatus,
int seniorPricePerReview
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -16,12 +18,11 @@ public interface ChatMessageRepository extends JpaRepository<ChatMessage, Long>
// 특정 주문에 대해 지정 시각 이후 동일 타입 메시지가 존재하는지 확인
boolean existsByReferenceIdAndMessageTypeAndCreatedAtAfter(
Long referenceId, MessageType messageType, LocalDateTime threshold);

// 채팅방의 메시지를 작성된 시간 기준으로 오름차순 정렬하여 전체 조회
List<ChatMessage> 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<ChatMessage> findLatestMessagesForRooms(@Param("roomIds") List<Long> roomIds);

// 특정 타입을 제외한 가장 최근 메시지 1건 조회 (사이드바 preview 필터링용)
ChatMessage findFirstByChatRoomAndMessageTypeNotOrderByCreatedAtDesc(ChatRoom chatRoom, MessageType messageType);
Expand Down
25 changes: 16 additions & 9 deletions src/main/java/com/knoc/chat/service/ChatMessageService.java
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ public class ChatMessageService {
private final ChatRoomService chatRoomService;
private final OrderRepository orderRepository;

// 메시지 목록에서 PAYMENT_REQUESTED 타입만 골라 (orderId, amount) 맵을 구성한다.
// Thymeleaf 첫 렌더링과 페이지네이션 응답 양쪽에서 결제 버튼 금액 표시에 사용.
public Map<Long, Integer> buildOrderAmounts(List<ChatMessage> messages) {
List<Long> 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. 채팅방 조회
Expand Down Expand Up @@ -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 전송
Expand Down Expand Up @@ -110,15 +125,7 @@ public List<ChatMessageResponse> getPreviousMessages(Long roomId, Long before, S


// 이번 페이지에 포함된 PAYMENT_REQUESTED 메시지들의 orderId만 모아 한 번에 금액 조회
List<Long> orderIds = messages.stream()
.filter(m -> m.getMessageType() == MessageType.PAYMENT_REQUESTED && m.getReferenceId() != null)
.map(ChatMessage::getReferenceId)
.toList();

Map<Long, Integer> amountByOrderId = orderIds.isEmpty()
? Collections.emptyMap()
: orderRepository.findAllById(orderIds).stream()
.collect(Collectors.toMap(Order::getId, Order::getAmount));
Map<Long, Integer> amountByOrderId = buildOrderAmounts(messages);

return messages.stream()
.map(m -> ChatMessageResponse.builder()
Expand Down
38 changes: 28 additions & 10 deletions src/main/java/com/knoc/chat/service/ChatRoomService.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@
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;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.*;
import java.util.stream.Collectors;

@Service
@Transactional(readOnly = true) // 데이터 변경이 없는 조회 메서드가 많으므로 기본값을 readOnly로 설정
Expand All @@ -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) {
Expand All @@ -35,22 +38,29 @@ public void verifyParticipant(ChatRoom chatRoom, Member currentMember) {
}

// 최신 메시지 map 생성 로직
// - 시니어 화면에서는 주니어 전용 시스템 메시지(REVIEW_REQUESTED)가 사이드바 미리보기에 노출되지 않도록 필터링한다.
// - 쿼리 1번으로 모든 방의 최신 메시지 조회 (N+1 방지)
// - 시니어 화면에서는 REVIEW_REQUESTED가 최신인 방만 추가 조회하여 필터링
private Map<Long, ChatMessage> buildLatestMessages(List<ChatRoom> chatRooms, Member currentMember) {
Map<Long, ChatMessage> latestMessages = new HashMap<>();
List<Long> roomIds = chatRooms.stream()
.map(ChatRoom::getId)
.toList();

Map<Long, ChatMessage> 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;
}

Expand All @@ -77,13 +87,21 @@ public ChatRoomDetailDto getRoomDetailInfo(Long roomId, String email) {
List<ChatRoom> chatRooms = chatRoomRepository.findByJuniorOrSenior(currentMember, currentMember);
List<ChatMessage> 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<Long, ChatMessage> 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
);

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 방식으로 조건에 맞게 전송
Expand Down
9 changes: 9 additions & 0 deletions src/main/java/com/knoc/global/controller/IndexController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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만 반환
}
}
16 changes: 2 additions & 14 deletions src/main/java/com/knoc/order/controller/OrderController.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -22,7 +18,6 @@
@RequestMapping("/orders")
public class OrderController {
private final OrderService orderService;
private final MemberRepository memberRepository;

@Operation(summary = "멘토링 주문 요청", description = "시니어가 멘토링 주문을 생성합니다. 요청 중복을 방지하기 위한 Idempotency-Key가 필요합니다.")
@PostMapping("/request")
Expand All @@ -31,12 +26,8 @@ public ResponseEntity<OrderResponse> 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);
Expand All @@ -47,10 +38,7 @@ public ResponseEntity<OrderResponse> requestOrder(@AuthenticationPrincipal UserD
@PreAuthorize("hasRole('USER')") // 주니어 결제 가능
public ResponseEntity<OrderResponse> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -32,7 +31,6 @@ public class TossPaymentController {

private final ObjectMapper objectMapper;
private final OrderService orderService;
private final OrderRepository orderRepository;
private final RestClient tossRestClient;

// 시크릿 키 설정 여부 가드용으로만 사용 (실제 인증 헤더는 tossRestClient에서 자동 주입)
Expand Down Expand Up @@ -132,11 +130,6 @@ public ResponseEntity<Void> 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);
}
}
Loading
Loading