Skip to content

Commit d5e8e8b

Browse files
형성빈형성빈
authored andcommitted
Merge branch 'develop' of https://github.com/prgrms-aibe-devcourse/AIBE5_Project2_Team8 into feat/#137-junior-dashboard-mentoring-list
2 parents 262e3b7 + 877ae4f commit d5e8e8b

17 files changed

Lines changed: 297 additions & 136 deletions

File tree

src/main/java/com/knoc/chat/controller/ChatController.java

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@
99
import com.knoc.chat.entity.MessageType;
1010
import com.knoc.chat.service.ChatMessageService;
1111
import com.knoc.chat.service.ChatRoomService;
12-
import com.knoc.order.service.OrderService;
13-
import com.knoc.senior.SeniorProfileService;
1412
import io.swagger.v3.oas.annotations.Operation;
1513
import io.swagger.v3.oas.annotations.tags.Tag;
1614
import lombok.RequiredArgsConstructor;
@@ -23,8 +21,7 @@
2321
import org.springframework.web.bind.annotation.*;
2422

2523
import java.security.Principal;
26-
import java.util.List;
27-
import java.util.Map;
24+
import java.util.*;
2825

2926
@Tag(name = "Chat Controller", description = "채팅방 목록, 상세 조회 및 메시지 관리 관련 API")
3027
@Controller
@@ -34,8 +31,6 @@ public class ChatController {
3431

3532
private final ChatMessageService chatMessageService;
3633
private final ChatRoomService chatRoomService;
37-
private final OrderService orderService;
38-
private final SeniorProfileService seniorProfileService;
3934

4035
@Value("${toss.payments.client-key:}")
4136
private String tossClientKey;
@@ -53,36 +48,45 @@ public String getChatRoomsPage(Model model, Principal principal) {
5348

5449
return "chat/chatrooms";
5550
}
56-
5751
@Operation(summary = "채팅방 생성", description = "대상 시니어와 새로운 채팅방을 생성하고 해당 방으로 이동합니다.")
5852
@PostMapping("/rooms")
5953
public String createChatRoom(Principal principal, @RequestParam Long seniorId) {
6054
ChatRoom chatRoom = chatRoomService.createChatRoom(principal.getName(), seniorId);
6155

6256
return "redirect:/chat/" + chatRoom.getId();
6357
}
64-
6558
@Operation(summary = "채팅방 상세 페이지 조회", description = "선택한 채팅방의 정보, 메시지 내역, 결제 요청 상태 등을 포함한 상세 페이지를 조회합니다.")
6659
@GetMapping("/{roomId}")
6760
public String getChatRoomPage(@PathVariable("roomId") Long roomId, Model model, Principal principal) {
6861
ChatRoomDetailDto dto = chatRoomService.getRoomDetailInfo(roomId, principal.getName());
6962
ChatRoom chatRoom = dto.selectedRoom();
7063
List<ChatMessage> messages = dto.messages();
7164

65+
// 현재 사용자의 역할 및 상대 주니어 ID 계산
7266
boolean isSenior = chatRoom.getSenior() != null
7367
&& chatRoom.getSenior().getEmail() != null
7468
&& chatRoom.getSenior().getEmail().equals(principal.getName());
7569
Long juniorId = chatRoom.getJunior() != null ? chatRoom.getJunior().getId() : null;
7670

71+
// 주니어 전용 시스템 메시지(REVIEW_REQUESTED)는 시니어 화면에서는 노출하지 않는다.
7772
if (isSenior && messages != null && !messages.isEmpty()) {
7873
messages = messages.stream()
7974
.filter(m -> m.getMessageType() != MessageType.REVIEW_REQUESTED)
8075
.toList();
8176
}
8277

83-
Map<Long, Integer> orderAmounts = orderService.extractOrderAmounts(messages);
84-
boolean hasPaymentRequest = orderService.hasActivePaymentRequest(chatRoom);
85-
int seniorPricePerReview = seniorProfileService.getPricePerReview(chatRoom.getSenior().getId());
78+
// PAYMENT_REQUESTED 메시지들의 금액 맵 구성 (orderId -> amount)
79+
Map<Long, Integer> orderAmounts = chatMessageService.buildOrderAmounts(messages);
80+
81+
// 해당 채팅방에 이미 결제 요청 시스템 메시지가 있었는지 여부
82+
// - 버튼 초기 노출 제어에만 사용
83+
// - DB에 Order가 남아있더라도, "결제 요청 메시지"가 없다면 버튼은 노출할 수 있다.
84+
boolean hasPaymentRequest = messages != null && messages.stream()
85+
.anyMatch(m -> m.getMessageType() == MessageType.PAYMENT_REQUESTED);
86+
87+
// 이 채팅방 시니어의 등록 리뷰 단가 (결제 요청 모달 placeholder용)
88+
// 프로필 미등록/미설정 시 0 → 프런트에서 기본값으로 처리
89+
int seniorPricePerReview = dto.seniorPricePerReview();
8690

8791
model.addAttribute("selectedRoomId", dto.selectedRoomId());
8892
model.addAttribute("messages", messages);
@@ -102,7 +106,6 @@ public String getChatRoomPage(@PathVariable("roomId") Long roomId, Model model,
102106

103107
return "chat/chatrooms";
104108
}
105-
106109
@Operation(summary = "메시지 전송 (WebSocket)", description = "채팅방으로 메시지를 전송합니다. (WebSocket/STOMP)")
107110
@MessageMapping("/{roomId}/send")
108111
public void sendMessage(@DestinationVariable Long roomId, @Payload ChatMessageRequest request, Principal principal) {
@@ -116,4 +119,4 @@ public void sendMessage(@DestinationVariable Long roomId, @Payload ChatMessageRe
116119
public List<ChatMessageResponse> getMessagesBefore(@PathVariable("roomId") Long roomId, @RequestParam Long before, Principal principal) {
117120
return chatMessageService.getPreviousMessages(roomId, before, principal.getName());
118121
}
119-
}
122+
}

src/main/java/com/knoc/chat/dto/ChatMessageResponse.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,17 @@ public class ChatMessageResponse {
2929
// PAYMENT_REQUESTED 같은 금액 기반 시스템 메시지에서 결제 버튼 렌더링에 사용. 그 외엔 null
3030
private Integer amount;
3131

32-
public ChatMessageResponse(Long id, String senderNickname, String content, LocalDateTime createdAt, MessageType messageType, Long referenceId, Integer amount) {
32+
// roomId를 통해 프론트에서 어느 방 메시지인지 구분에 사용
33+
private Long roomId;
34+
35+
public ChatMessageResponse(Long id, String senderNickname, String content, LocalDateTime createdAt, MessageType messageType, Long referenceId, Integer amount, Long roomId) {
3336
this.id = id;
3437
this.senderNickname = senderNickname;
3538
this.content = content;
3639
this.createdAt = createdAt;
3740
this.messageType = messageType;
3841
this.referenceId = referenceId;
3942
this.amount = amount;
43+
this.roomId = roomId;
4044
}
4145
}

src/main/java/com/knoc/chat/dto/ChatRoomDetailDto.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,6 @@ public record ChatRoomDetailDto(
1515
ChatRoom selectedRoom,
1616
Long firstMessageId,
1717
Map<Long, ChatMessage> latestMessages,
18-
String roomStatus
18+
String roomStatus,
19+
int seniorPricePerReview
1920
) {}

src/main/java/com/knoc/chat/repository/ChatMessageRepository.java

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import com.knoc.chat.entity.ChatRoom;
66
import org.springframework.data.domain.PageRequest;
77
import org.springframework.data.jpa.repository.JpaRepository;
8+
import org.springframework.data.jpa.repository.Query;
9+
import org.springframework.data.repository.query.Param;
810
import java.time.LocalDateTime;
911

1012
import java.util.List;
@@ -16,12 +18,11 @@ public interface ChatMessageRepository extends JpaRepository<ChatMessage, Long>
1618
// 특정 주문에 대해 지정 시각 이후 동일 타입 메시지가 존재하는지 확인
1719
boolean existsByReferenceIdAndMessageTypeAndCreatedAtAfter(
1820
Long referenceId, MessageType messageType, LocalDateTime threshold);
19-
20-
// 채팅방의 메시지를 작성된 시간 기준으로 오름차순 정렬하여 전체 조회
21-
List<ChatMessage> findByChatRoomOrderByCreatedAtAsc(ChatRoom chatRoom);
2221

23-
// 가장 최근에 작성된 채팅 메시지 1건 조회
24-
ChatMessage findFirstByChatRoomOrderByCreatedAtDesc(ChatRoom chatRoom);
22+
// 여러 채팅방의 최신 메시지를 쿼리 1번으로 조회 (N+1 방지)
23+
@Query("SELECT m FROM ChatMessage m WHERE m.id IN (" +
24+
"SELECT MAX(m2.id) FROM ChatMessage m2 WHERE m2.chatRoom.id IN :roomIds GROUP BY m2.chatRoom.id)")
25+
List<ChatMessage> findLatestMessagesForRooms(@Param("roomIds") List<Long> roomIds);
2526

2627
// 특정 타입을 제외한 가장 최근 메시지 1건 조회 (사이드바 preview 필터링용)
2728
ChatMessage findFirstByChatRoomAndMessageTypeNotOrderByCreatedAtDesc(ChatRoom chatRoom, MessageType messageType);

src/main/java/com/knoc/chat/service/ChatMessageService.java

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,20 @@ public class ChatMessageService {
3636
private final ChatRoomService chatRoomService;
3737
private final OrderRepository orderRepository;
3838

39+
// 메시지 목록에서 PAYMENT_REQUESTED 타입만 골라 (orderId, amount) 맵을 구성한다.
40+
// Thymeleaf 첫 렌더링과 페이지네이션 응답 양쪽에서 결제 버튼 금액 표시에 사용.
41+
public Map<Long, Integer> buildOrderAmounts(List<ChatMessage> messages) {
42+
List<Long> paymentOrderIds = messages.stream()
43+
.filter(m -> m.getMessageType() == MessageType.PAYMENT_REQUESTED && m.getReferenceId() != null)
44+
.map(ChatMessage::getReferenceId)
45+
.toList();
46+
47+
if (paymentOrderIds.isEmpty()) return Collections.emptyMap();
48+
49+
return orderRepository.findAllById(paymentOrderIds).stream()
50+
.collect(Collectors.toMap(Order::getId, Order::getAmount));
51+
}
52+
3953
@Transactional
4054
public void sendMessage(Long roomId, String email, String content) {
4155
// 1. 채팅방 조회
@@ -73,6 +87,7 @@ public void sendMessage(Long roomId, String email, String content) {
7387
.messageType(savedMessage.getMessageType())
7488
.referenceId(savedMessage.getReferenceId()) // USER 메시지는 null
7589
// amount는 생략 -> null (PAYMENT_REQUESTED에서만 의미 있음)
90+
.roomId(roomId)
7691
.build();
7792

7893
// 6. 수신자/발신자 양쪽에 1:1 queue 전송
@@ -110,15 +125,7 @@ public List<ChatMessageResponse> getPreviousMessages(Long roomId, Long before, S
110125

111126

112127
// 이번 페이지에 포함된 PAYMENT_REQUESTED 메시지들의 orderId만 모아 한 번에 금액 조회
113-
List<Long> orderIds = messages.stream()
114-
.filter(m -> m.getMessageType() == MessageType.PAYMENT_REQUESTED && m.getReferenceId() != null)
115-
.map(ChatMessage::getReferenceId)
116-
.toList();
117-
118-
Map<Long, Integer> amountByOrderId = orderIds.isEmpty()
119-
? Collections.emptyMap()
120-
: orderRepository.findAllById(orderIds).stream()
121-
.collect(Collectors.toMap(Order::getId, Order::getAmount));
128+
Map<Long, Integer> amountByOrderId = buildOrderAmounts(messages);
122129

123130
return messages.stream()
124131
.map(m -> ChatMessageResponse.builder()

src/main/java/com/knoc/chat/service/ChatRoomService.java

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@
99
import com.knoc.global.exception.ErrorCode;
1010
import com.knoc.member.Member;
1111
import com.knoc.member.MemberRepository;
12+
import com.knoc.senior.repository.SeniorProfileRepository;
1213
import lombok.RequiredArgsConstructor;
1314
import org.springframework.context.ApplicationEventPublisher;
1415
import org.springframework.data.domain.PageRequest;
1516
import org.springframework.stereotype.Service;
1617
import org.springframework.transaction.annotation.Transactional;
1718

1819
import java.util.*;
20+
import java.util.stream.Collectors;
1921

2022
@Service
2123
@Transactional(readOnly = true) // 데이터 변경이 없는 조회 메서드가 많으므로 기본값을 readOnly로 설정
@@ -26,6 +28,7 @@ public class ChatRoomService {
2628
private final ApplicationEventPublisher eventPublisher;
2729
private final MemberRepository memberRepository;
2830
private final ChatMessageRepository chatMessageRepository;
31+
private final SeniorProfileRepository seniorProfileRepository;
2932

3033
// 권한 검증 로직
3134
public void verifyParticipant(ChatRoom chatRoom, Member currentMember) {
@@ -35,22 +38,29 @@ public void verifyParticipant(ChatRoom chatRoom, Member currentMember) {
3538
}
3639

3740
// 최신 메시지 map 생성 로직
38-
// - 시니어 화면에서는 주니어 전용 시스템 메시지(REVIEW_REQUESTED)가 사이드바 미리보기에 노출되지 않도록 필터링한다.
41+
// - 쿼리 1번으로 모든 방의 최신 메시지 조회 (N+1 방지)
42+
// - 시니어 화면에서는 REVIEW_REQUESTED가 최신인 방만 추가 조회하여 필터링
3943
private Map<Long, ChatMessage> buildLatestMessages(List<ChatRoom> chatRooms, Member currentMember) {
40-
Map<Long, ChatMessage> latestMessages = new HashMap<>();
44+
List<Long> roomIds = chatRooms.stream()
45+
.map(ChatRoom::getId)
46+
.toList();
47+
48+
Map<Long, ChatMessage> latestMessages = chatMessageRepository
49+
.findLatestMessagesForRooms(roomIds).stream()
50+
.collect(Collectors.toMap(m -> m.getChatRoom().getId(), m -> m));
51+
52+
// 시니어인 경우, REVIEW_REQUESTED가 최신인 방만 추가 조회
4153
for (ChatRoom chatRoom : chatRooms) {
42-
ChatMessage latest = chatMessageRepository.findFirstByChatRoomOrderByCreatedAtDesc(chatRoom);
54+
ChatMessage latest = latestMessages.get(chatRoom.getId());
4355
if (latest != null
4456
&& latest.getMessageType() == MessageType.REVIEW_REQUESTED
45-
&& chatRoom.getSenior() != null
46-
&& chatRoom.getSenior().getId() != null
4757
&& chatRoom.getSenior().getId().equals(currentMember.getId())) {
48-
// 시니어에게는 REVIEW_REQUESTED를 숨김 (다음 최신 메시지로 대체)
49-
latest = chatMessageRepository.findFirstByChatRoomAndMessageTypeNotOrderByCreatedAtDesc(
50-
chatRoom, MessageType.REVIEW_REQUESTED);
58+
latestMessages.put(chatRoom.getId(),
59+
chatMessageRepository.findFirstByChatRoomAndMessageTypeNotOrderByCreatedAtDesc(
60+
chatRoom, MessageType.REVIEW_REQUESTED));
5161
}
52-
latestMessages.put(chatRoom.getId(), latest);
5362
}
63+
5464
return latestMessages;
5565
}
5666

@@ -77,13 +87,21 @@ public ChatRoomDetailDto getRoomDetailInfo(Long roomId, String email) {
7787
List<ChatRoom> chatRooms = chatRoomRepository.findByJuniorOrSenior(currentMember, currentMember);
7888
List<ChatMessage> messages = chatMessageRepository
7989
.findByChatRoomAndIdLessThanOrderByIdDesc(chatRoom, Long.MAX_VALUE, PageRequest.of(0, 20));
90+
91+
messages = new ArrayList<>(messages);
8092
Collections.reverse(messages);
8193
Long firstMessageId = messages.isEmpty() ? Long.MAX_VALUE : messages.get(0).getId();
8294
Map<Long, ChatMessage> latestMessages = buildLatestMessages(chatRooms, currentMember);
8395

96+
// 이 채팅방 시니어의 등록 리뷰 단가 (결제 요청 모달 placeholder용)
97+
// 프로필 미등록/미설정 시 0 → 프런트에서 기본값으로 처리
98+
int seniorPricePerReview = seniorProfileRepository.findByMemberId(chatRoom.getSenior().getId())
99+
.map(p -> p.getPricePerReview())
100+
.orElse(0);
101+
84102
return new ChatRoomDetailDto(
85103
roomId, messages, currentMember.getNickname(),chatRooms,
86-
chatRoom, firstMessageId, latestMessages, chatRoom.getStatus().name()
104+
chatRoom, firstMessageId, latestMessages, chatRoom.getStatus().name(), seniorPricePerReview
87105
);
88106

89107
}

src/main/java/com/knoc/event/listener/ChatEventListener.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ public void handleChatEventSystem(ChatSystemEvent event) {
6565
.messageType(event.type())
6666
.referenceId(event.referenceId())
6767
.amount(amount)
68+
.roomId(event.roomId())
6869
.build();
6970

7071
// 4. 1:1 Queue 방식으로 조건에 맞게 전송

src/main/java/com/knoc/global/controller/IndexController.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,13 @@ public String index(@ModelAttribute SeniorSearchCondition condition, Model model
3535
model.addAttribute("tossClientKey", tossClientKey);
3636
return "index";
3737
}
38+
39+
@Operation(summary = "시니어 목록 fragment 조회", description = "검색 조건에 따른 시니어 목록을 HTML fragment으로 반환 / AJAX 필터링에 사용한다.")
40+
@GetMapping("/seniors/fragment")
41+
public String searchFragment(@ModelAttribute SeniorSearchCondition condition, Model model) {
42+
model.addAttribute("seniors", seniorProfileService.searchProfiles(condition));
43+
model.addAttribute("condition", condition);
44+
model.addAttribute("popularSkills", POPULAR_SKILLS);
45+
return "index :: seniorSection"; // 시니어 섹션 fragment만 반환
46+
}
3847
}

src/main/java/com/knoc/order/controller/OrderController.java

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
package com.knoc.order.controller;
22

3-
import com.knoc.global.exception.BusinessException;
4-
import com.knoc.global.exception.ErrorCode;
5-
import com.knoc.member.Member;
6-
import com.knoc.member.MemberRepository;
73
import com.knoc.order.dto.OrderRequest;
84
import com.knoc.order.dto.OrderResponse;
95
import com.knoc.order.service.OrderService;
@@ -22,7 +18,6 @@
2218
@RequestMapping("/orders")
2319
public class OrderController {
2420
private final OrderService orderService;
25-
private final MemberRepository memberRepository;
2621

2722
@Operation(summary = "멘토링 주문 요청", description = "시니어가 멘토링 주문을 생성합니다. 요청 중복을 방지하기 위한 Idempotency-Key가 필요합니다.")
2823
@PostMapping("/request")
@@ -31,12 +26,8 @@ public ResponseEntity<OrderResponse> requestOrder(@AuthenticationPrincipal UserD
3126
@RequestBody OrderRequest dto,
3227
@RequestHeader("Idempotency-Key") String idempotencyKey) {
3328
// 1. 현재 로그인한 시니어 ID를 가져온다.
34-
Member member = memberRepository.findByEmail(userDetails.getUsername())
35-
.orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND));
36-
Long seniorId = member.getId();
37-
3829
// 2. 서비스를 호출하여 주문을 생성하고 응답 DTO를 받는다.
39-
OrderResponse orderResponse = orderService.createOrderRequest(dto, seniorId, idempotencyKey);
30+
OrderResponse orderResponse = orderService.createOrderRequest(dto, userDetails.getUsername(), idempotencyKey);
4031

4132
// 3. 생성된 주문 정보(JSON 데이터로 변환됨)와 함께 200 OK 응답을 브라우저로 보낸다.
4233
return ResponseEntity.ok(orderResponse);
@@ -47,10 +38,7 @@ public ResponseEntity<OrderResponse> requestOrder(@AuthenticationPrincipal UserD
4738
@PreAuthorize("hasRole('USER')") // 주니어 결제 가능
4839
public ResponseEntity<OrderResponse> requestPay(@AuthenticationPrincipal UserDetails userDetails,
4940
@PathVariable Long orderId) {
50-
Member member = memberRepository.findByEmail(userDetails.getUsername())
51-
.orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND));
52-
Long juniorId = member.getId();
53-
OrderResponse orderResponse = orderService.preparePayment(orderId, juniorId);
41+
OrderResponse orderResponse = orderService.preparePayment(orderId, userDetails.getUsername());
5442
return ResponseEntity.ok(orderResponse);
5543
}
5644
}

src/main/java/com/knoc/order/controller/TossPaymentController.java

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import com.fasterxml.jackson.databind.JsonNode;
44
import com.fasterxml.jackson.databind.ObjectMapper;
55
import com.knoc.global.exception.BusinessException;
6-
import com.knoc.order.repository.OrderRepository;
76
import com.knoc.order.service.OrderService;
87
import lombok.RequiredArgsConstructor;
98
import lombok.extern.slf4j.Slf4j;
@@ -32,7 +31,6 @@ public class TossPaymentController {
3231

3332
private final ObjectMapper objectMapper;
3433
private final OrderService orderService;
35-
private final OrderRepository orderRepository;
3634
private final RestClient tossRestClient;
3735

3836
// 시크릿 키 설정 여부 가드용으로만 사용 (실제 인증 헤더는 tossRestClient에서 자동 주입)
@@ -132,11 +130,6 @@ public ResponseEntity<Void> fail(
132130

133131
// Toss 콜백 후 돌아갈 채팅방 URL 계산 (주문 조회 실패 시 "/"으로 폴백)
134132
private String redirectToChat(String tossOrderId) {
135-
if (!StringUtils.hasText(tossOrderId)) {
136-
return "/";
137-
}
138-
return orderRepository.findByOrderNumber(tossOrderId)
139-
.map(o -> "/chat/" + o.getChatRoom().getId())
140-
.orElse("/");
133+
return orderService.getChatRoomUrlByOrderNumber(tossOrderId);
141134
}
142135
}

0 commit comments

Comments
 (0)