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
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,14 @@
import backend.chat.dto.ChatMessageResponse;
import backend.chat.dto.ChatRoomResponse;
import backend.chat.dto.CreateChatRoomRequest;
import backend.chat.dto.CreatePersonalChatRoomRequest;
import backend.chat.dto.SendMessageRequest;
import backend.chat.service.ChatService;
import backend.chat.service.SseEmitterService;
import backend.global.common.response.ApiResponse;
import backend.global.security.CustomUserDetails;
import backend.user.entity.UserEntity;
import backend.user.repository.UserRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
Expand All @@ -37,6 +40,7 @@ public class ChatController {

private final ChatService chatService;
private final SseEmitterService sseEmitterService;
private final UserRepository userRepository;

// ─── SSE 연결 ─────────────────────────────────

Expand All @@ -57,44 +61,65 @@ public SseEmitter connect(
@Operation(summary = "채팅방 목록 조회")
@GetMapping("/rooms")
public ResponseEntity<ApiResponse<List<ChatRoomResponse>>> getRooms(
@AuthenticationPrincipal CustomUserDetails userDetails
@AuthenticationPrincipal Object principal
) {
return ResponseEntity.ok(ApiResponse.success(chatService.getRooms(currentUserId(userDetails))));
return ResponseEntity.ok(ApiResponse.success(chatService.getRooms(currentUserId(principal))));
}

@Operation(summary = "채팅방 생성")
@Operation(
summary = "그룹 채팅방 생성",
description = "GROUP 타입만 허용. 동일 spotId 의 활성 GROUP 방이 있으면 idempotent 하게 기존 방을 반환합니다. "
+ "PERSONAL 채팅은 POST /rooms/personal 을 사용하세요."
)
@PostMapping("/rooms")
public ResponseEntity<ApiResponse<ChatRoomResponse>> createRoom(
@Valid @RequestBody CreateChatRoomRequest request
@Valid @RequestBody CreateChatRoomRequest request,
@AuthenticationPrincipal Object principal
) {
return ResponseEntity.ok(ApiResponse.success(chatService.createRoom(request)));
return ResponseEntity.ok(ApiResponse.success(
chatService.createRoom(request, currentUserId(principal))
));
}

@Operation(
summary = "1:1 채팅방 시작",
description = "현재 유저와 partnerId 사이의 PERSONAL 채팅방을 만들거나 기존 방을 반환합니다 (카카오톡 스타일)."
)
@PostMapping("/rooms/personal")
public ResponseEntity<ApiResponse<ChatRoomResponse>> createPersonalRoom(
@Valid @RequestBody CreatePersonalChatRoomRequest request,
@AuthenticationPrincipal Object principal
) {
return ResponseEntity.ok(ApiResponse.success(
chatService.createPersonalRoom(request, currentUserId(principal))
));
}

@Operation(summary = "채팅방 상세 조회")
@GetMapping("/rooms/{roomId}")
public ResponseEntity<ApiResponse<ChatRoomResponse>> getRoom(
@PathVariable Long roomId,
@AuthenticationPrincipal CustomUserDetails userDetails
@AuthenticationPrincipal Object principal
) {
return ResponseEntity.ok(ApiResponse.success(chatService.getRoom(roomId, currentUserId(userDetails))));
return ResponseEntity.ok(ApiResponse.success(chatService.getRoom(roomId, currentUserId(principal))));
}

@Operation(summary = "스팟별 채팅방 조회")
@GetMapping("/rooms/by-spot/{spotId}")
public ResponseEntity<ApiResponse<List<ChatRoomResponse>>> getRoomBySpot(
@PathVariable String spotId,
@AuthenticationPrincipal CustomUserDetails userDetails
@AuthenticationPrincipal Object principal
) {
return ResponseEntity.ok(ApiResponse.success(chatService.getRoomsBySpot(spotId, currentUserId(userDetails))));
return ResponseEntity.ok(ApiResponse.success(chatService.getRoomsBySpot(spotId, currentUserId(principal))));
}

@Operation(summary = "사용자별 채팅방 조회")
@GetMapping("/rooms/by-user/{userId}")
public ResponseEntity<ApiResponse<List<ChatRoomResponse>>> getRoomsByUser(
@PathVariable String userId,
@AuthenticationPrincipal CustomUserDetails userDetails
@AuthenticationPrincipal Object principal
) {
return ResponseEntity.ok(ApiResponse.success(chatService.getRoomsByUser(userId, currentUserId(userDetails))));
return ResponseEntity.ok(ApiResponse.success(chatService.getRoomsByUser(userId, currentUserId(principal))));
}

// ─── 메시지 (Message) ─────────────────────────
Expand All @@ -108,9 +133,12 @@ public ResponseEntity<ApiResponse<ChatMessageListResponse>> getMessages(
@PathVariable Long roomId,
@Parameter(description = "커서 (마지막 메시지 ID, 최초 조회 시 생략)")
@RequestParam(required = false) Long cursor,
@RequestParam(defaultValue = "30") int size
@RequestParam(defaultValue = "30") int size,
@AuthenticationPrincipal Object principal
) {
return ResponseEntity.ok(ApiResponse.success(chatService.getMessages(roomId, cursor, size)));
return ResponseEntity.ok(ApiResponse.success(
chatService.getMessages(roomId, cursor, size, currentUserId(principal))
));
}

@Operation(
Expand All @@ -121,24 +149,46 @@ public ResponseEntity<ApiResponse<ChatMessageListResponse>> getMessages(
public ResponseEntity<ApiResponse<ChatMessageResponse>> sendMessage(
@PathVariable Long roomId,
@Valid @RequestBody SendMessageRequest request,
@AuthenticationPrincipal CustomUserDetails userDetails
@AuthenticationPrincipal Object principal
) {
return ResponseEntity.ok(ApiResponse.success(
chatService.sendMessage(roomId, request, currentUserId(userDetails))
chatService.sendMessage(roomId, request, currentUserId(principal))
));
}

@Operation(summary = "메시지 읽음 처리")
@PostMapping("/rooms/{roomId}/read")
public ResponseEntity<ApiResponse<Void>> markAsRead(
@PathVariable Long roomId,
@AuthenticationPrincipal CustomUserDetails userDetails
@AuthenticationPrincipal Object principal
) {
chatService.markAsRead(roomId, currentUserId(userDetails));
chatService.markAsRead(roomId, currentUserId(principal));
return ResponseEntity.ok(ApiResponse.success());
}

private String currentUserId(CustomUserDetails userDetails) {
return userDetails == null ? null : userDetails.getUserId();
/**
* SecurityContext 의 principal 을 실제 user ID 로 변환.
*
* <p>JWTFilter 가 현재 principal 에 email (String) 만 셋팅하므로 (createSpot/Chat 등에서
* @AuthenticationPrincipal CustomUserDetails 가 null 로 들어오는 원인) 여기서 한 번 더
* UserRepository.findByEmail 을 거쳐 진짜 userId 로 풀어준다. CustomUserDetails 가 들어오는
* 경로 (Login 직후 등) 도 함께 처리하기 위해 Object 로 받아 type 분기.
*
* <p>TODO: 별도 PR 에서 JWTFilter 자체가 principal 에 CustomUserDetails 를 셋팅하도록
* 리팩터링되면 본 헬퍼는 제거.
*/
private String currentUserId(Object principal) {
if (principal == null || "anonymousUser".equals(principal)) {
return null;
}
if (principal instanceof CustomUserDetails userDetails) {
return userDetails.getUserId();
}
if (principal instanceof String email) {
return userRepository.findByEmail(email)
.map(UserEntity::getId)
.orElse(null);
}
return null;
}
}
44 changes: 37 additions & 7 deletions capstone-api/src/main/java/backend/chat/dto/ChatRoomResponse.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,26 @@
public class ChatRoomResponse {

private static final int LAST_MESSAGE_PREVIEW_MAX_LENGTH = 100;
private static final String PERSONAL_DEFAULT_TITLE = "대화방";

@Schema(description = "채팅방 ID", example = "1")
private Long id;

@Schema(description = "연결된 스팟 ID (그룹 채팅인 경우)", example = "550e8400-e29b-41d4-a716-446655440000")
private String spotId;

@Schema(description = "연결된 포스트 ID (있는 경우)")
private String postId;

@Schema(description = "채팅방 타입 (GROUP / PERSONAL)", example = "GROUP")
private ChatRoomType type;

@Schema(description = "그룹 채팅방 이름 (직접 지정)")
private String name;

@Schema(description = "그룹 채팅방 이미지 URL")
private String imageUrl;

@Schema(description = "생성 일시")
private LocalDateTime createdAt;

Expand All @@ -53,7 +63,13 @@ public class ChatRoomResponse {
@Schema(description = "현재 사용자 이름", example = "홍길동")
private String currentUserName;

@Schema(description = "읽지 않은 메시지 수 (임시로 항상 0)", example = "0")
@Schema(description = "PERSONAL 채팅 상대방 ID")
private String partnerId;

@Schema(description = "PERSONAL 채팅 상대방 닉네임")
private String partnerNickname;

@Schema(description = "읽지 않은 메시지 수 (PR B 에서 실 카운트 구현)", example = "0")
private Integer unreadCount;

public static ChatRoomResponse from(ChatRoom room) {
Expand All @@ -64,19 +80,25 @@ public static ChatRoomResponse from(ChatRoom room, ChatRoomEnrichment enrichment
ChatMessage lastMessage = enrichment.getLastMessage();
Spot spot = enrichment.getSpot();
UserEntity currentUser = enrichment.getCurrentUser();
UserEntity partner = enrichment.getPartner();

return ChatRoomResponse.builder()
.id(room.getId())
.spotId(room.getSpotId())
.postId(room.getPostId())
.type(room.getType())
.name(room.getName())
.imageUrl(room.getImageUrl())
.createdAt(room.getCreatedAt())
.lastMessagePreview(resolveLastMessagePreview(lastMessage))
.lastMessageAt(lastMessage == null ? null : lastMessage.getCreatedAt())
.title(resolveTitle(room, spot))
.subtitle(resolveSubtitle(room, spot))
.title(resolveTitle(room, spot, partner))
.subtitle(resolveSubtitle(room, spot, partner))
.currentUserId(currentUser == null ? null : currentUser.getId())
.currentUserName(currentUser == null ? null : currentUser.getNickname())
// TODO: per-user unread tracking — requires ChatRoomMember entity (see ChatService.markAsRead TODO)
.partnerId(partner == null ? null : partner.getId())
.partnerNickname(partner == null ? null : partner.getNickname())
// TODO: per-user unread tracking — PR B 에서 실 구현
.unreadCount(0)
.build();
}
Expand All @@ -92,14 +114,21 @@ private static String resolveLastMessagePreview(ChatMessage lastMessage) {
return content.substring(0, LAST_MESSAGE_PREVIEW_MAX_LENGTH);
}

private static String resolveTitle(ChatRoom room, Spot spot) {
private static String resolveTitle(ChatRoom room, Spot spot, UserEntity partner) {
if (room.getType() == ChatRoomType.PERSONAL) {
return "대화방";
if (partner != null && partner.getNickname() != null) {
return partner.getNickname();
}
return PERSONAL_DEFAULT_TITLE;
}
// GROUP: 직접 지정한 name 우선, 없으면 spot 제목
if (room.getName() != null && !room.getName().isBlank()) {
return room.getName();
}
return spot == null ? null : spot.getTitle();
}

private static String resolveSubtitle(ChatRoom room, Spot spot) {
private static String resolveSubtitle(ChatRoom room, Spot spot, UserEntity partner) {
if (room.getType() == ChatRoomType.PERSONAL) {
return "";
}
Expand All @@ -114,6 +143,7 @@ public static class ChatRoomEnrichment {
private ChatMessage lastMessage;
private Spot spot;
private UserEntity currentUser;
private UserEntity partner;

public static ChatRoomEnrichment empty() {
return ChatRoomEnrichment.builder().build();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package backend.chat.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@Schema(description = "1:1 채팅방 생성 요청 DTO")
public class CreatePersonalChatRoomRequest {

@NotBlank
@Schema(description = "상대방 유저 ID", example = "user-uuid-string")
private String partnerId;
}
Loading