From 317005ef82f356c26a0448c10d8560c1fdc7f231 Mon Sep 17 00:00:00 2001 From: donghyunkim Date: Fri, 15 May 2026 16:45:14 +0900 Subject: [PATCH 1/4] docs(migrations): make spot_vote_answer_multiselect script idempotent Hibernate ddl-auto=update already creates uq_vote_user_option on app boot, causing the migration's plain ADD CONSTRAINT to fail with "already exists" on re-runs. Guard with pg_constraint lookup so the script can be safely re-applied in any environment. Co-Authored-By: Claude Opus 4.7 --- .../2026-05-14_spot_vote_answer_multiselect.sql | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/migrations/2026-05-14_spot_vote_answer_multiselect.sql b/docs/migrations/2026-05-14_spot_vote_answer_multiselect.sql index 32e7159..f3af990 100644 --- a/docs/migrations/2026-05-14_spot_vote_answer_multiselect.sql +++ b/docs/migrations/2026-05-14_spot_vote_answer_multiselect.sql @@ -35,8 +35,17 @@ BEGIN; ALTER TABLE spot_vote_answers DROP CONSTRAINT IF EXISTS uq_vote_user; -- 2) 새 constraint 추가 (Hibernate 가 동일 이름으로 만들어두면 중복 생성 방지) -ALTER TABLE spot_vote_answers - ADD CONSTRAINT uq_vote_user_option UNIQUE (vote_id, user_id, option_id); +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'spot_vote_answers'::regclass + AND conname = 'uq_vote_user_option' + ) THEN + ALTER TABLE spot_vote_answers + ADD CONSTRAINT uq_vote_user_option UNIQUE (vote_id, user_id, option_id); + END IF; +END $$; COMMIT; From 864877901e4b0431f962910d64b5fbfc8eeb1a57 Mon Sep 17 00:00:00 2001 From: donghyunkim Date: Fri, 15 May 2026 17:04:39 +0900 Subject: [PATCH 2/4] feat(chat): introduce ChatRoomMember and membership-based access control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds first-class membership to the chat domain so that "who can see / write in which room" is a hard, queryable contract rather than implicit from message history. ## Domain - New ChatRoomMember entity (chat_room_members) - UNIQUE (chat_room_id, user_id) — one membership row per (room, user) - INDEX (user_id) — fast "rooms I'm in" lookup - ChatRoom gains name / imageUrl / postId (String UUID — Post.id is UUID) / isDeleted columns - New ChatRoomMemberRepository with: - membership exists/find/count helpers - findChatRoomIdsByUserId for filtered list endpoints - findPersonalRoomIdsBetween(a, b) — PERSONAL reuse policy core query - ChatRoomRepository: findFirstBySpotIdAndTypeAndIsDeletedFalse for the GROUP-per-spot idempotent reuse path ## Service - ChatService.getRooms / getRoom / getMessages / sendMessage / markAsRead now hard-require membership; non-members get CH003 (FORBIDDEN) - ChatService.createRoom restricted to GROUP and idempotent on (spot_id): returning the existing room when one is already open - ChatService.createPersonalRoom: KakaoTalk-style reuse — if A↔B already have a PERSONAL room, return it; otherwise create with both members - ChatService.ensureGroupRoomForSpot exposed so SpotService can auto-add participants on matchSpot without reaching into chat internals - ChatRoomResponse: PERSONAL title/subtitle now resolves to the actual partner's nickname (was placeholder "대화방"). New partnerId / partnerNickname fields surface the resolved partner for the client. ## Spot integration - SpotService.matchSpot now calls ensureGroupRoomForSpot with the spot's participants, so the group chat exists and is populated the moment a spot transitions OPEN → MATCHED. ## Migration - docs/migrations/2026-05-15_chat_room_member.sql - CREATE chat_room_members + indices - ALTER chat_rooms ADD name / image_url / post_id / is_deleted - Partial unique index (spot_id) WHERE type='GROUP' AND is_deleted=false AND spot_id IS NOT NULL — enforces "one active group room per spot" at the DB level (JPA can't express partial unique declaratively) - Backfill: existing chat_messages.sender_id pairs become membership rows so pre-existing rooms remain reachable for prior participants ## Error codes - CH003 CHAT_ROOM_ACCESS_DENIED (403) - CH004 CHAT_PARTNER_NOT_FOUND (404) - CH005 CHAT_PERSONAL_SELF_NOT_ALLOWED (400) - CH006 CHAT_PERSONAL_REQUIRES_PARTNER (400) PR B (unread tracking) and PR C (leave) build on this foundation. Co-Authored-By: Claude Opus 4.7 --- .../chat/controller/ChatController.java | 35 ++- .../backend/chat/dto/ChatRoomResponse.java | 44 +++- .../dto/CreatePersonalChatRoomRequest.java | 16 ++ .../backend/chat/service/ChatService.java | 238 +++++++++++++++--- .../backend/spot/service/SpotService.java | 11 + .../global/error/exception/ErrorCode.java | 4 + .../java/backend/chat/entity/ChatRoom.java | 17 ++ .../backend/chat/entity/ChatRoomMember.java | 61 +++++ .../repository/ChatRoomMemberRepository.java | 51 ++++ .../chat/repository/ChatRoomRepository.java | 7 + .../2026-05-15_chat_room_member.sql | 81 ++++++ 11 files changed, 525 insertions(+), 40 deletions(-) create mode 100644 capstone-api/src/main/java/backend/chat/dto/CreatePersonalChatRoomRequest.java create mode 100644 capstone-domain/src/main/java/backend/chat/entity/ChatRoomMember.java create mode 100644 capstone-domain/src/main/java/backend/chat/repository/ChatRoomMemberRepository.java create mode 100644 docs/migrations/2026-05-15_chat_room_member.sql diff --git a/capstone-api/src/main/java/backend/chat/controller/ChatController.java b/capstone-api/src/main/java/backend/chat/controller/ChatController.java index 2901c2c..c312774 100644 --- a/capstone-api/src/main/java/backend/chat/controller/ChatController.java +++ b/capstone-api/src/main/java/backend/chat/controller/ChatController.java @@ -18,6 +18,7 @@ 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; @@ -62,12 +63,33 @@ public ResponseEntity>> getRooms( return ResponseEntity.ok(ApiResponse.success(chatService.getRooms(currentUserId(userDetails)))); } - @Operation(summary = "채팅방 생성") + @Operation( + summary = "그룹 채팅방 생성", + description = "GROUP 타입만 허용. 동일 spotId 의 활성 GROUP 방이 있으면 idempotent 하게 기존 방을 반환합니다. " + + "PERSONAL 채팅은 POST /rooms/personal 을 사용하세요." + ) @PostMapping("/rooms") public ResponseEntity> createRoom( - @Valid @RequestBody CreateChatRoomRequest request + @Valid @RequestBody CreateChatRoomRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + return ResponseEntity.ok(ApiResponse.success( + chatService.createRoom(request, currentUserId(userDetails)) + )); + } + + @Operation( + summary = "1:1 채팅방 시작", + description = "현재 유저와 partnerId 사이의 PERSONAL 채팅방을 만들거나 기존 방을 반환합니다 (카카오톡 스타일)." + ) + @PostMapping("/rooms/personal") + public ResponseEntity> createPersonalRoom( + @Valid @RequestBody CreatePersonalChatRoomRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails ) { - return ResponseEntity.ok(ApiResponse.success(chatService.createRoom(request))); + return ResponseEntity.ok(ApiResponse.success( + chatService.createPersonalRoom(request, currentUserId(userDetails)) + )); } @Operation(summary = "채팅방 상세 조회") @@ -108,9 +130,12 @@ public ResponseEntity> getMessages( @PathVariable Long roomId, @Parameter(description = "커서 (마지막 메시지 ID, 최초 조회 시 생략)") @RequestParam(required = false) Long cursor, - @RequestParam(defaultValue = "30") int size + @RequestParam(defaultValue = "30") int size, + @AuthenticationPrincipal CustomUserDetails userDetails ) { - return ResponseEntity.ok(ApiResponse.success(chatService.getMessages(roomId, cursor, size))); + return ResponseEntity.ok(ApiResponse.success( + chatService.getMessages(roomId, cursor, size, currentUserId(userDetails)) + )); } @Operation( diff --git a/capstone-api/src/main/java/backend/chat/dto/ChatRoomResponse.java b/capstone-api/src/main/java/backend/chat/dto/ChatRoomResponse.java index 97ac29c..bce8942 100644 --- a/capstone-api/src/main/java/backend/chat/dto/ChatRoomResponse.java +++ b/capstone-api/src/main/java/backend/chat/dto/ChatRoomResponse.java @@ -22,6 +22,7 @@ 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; @@ -29,9 +30,18 @@ public class ChatRoomResponse { @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; @@ -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) { @@ -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(); } @@ -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 ""; } @@ -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(); diff --git a/capstone-api/src/main/java/backend/chat/dto/CreatePersonalChatRoomRequest.java b/capstone-api/src/main/java/backend/chat/dto/CreatePersonalChatRoomRequest.java new file mode 100644 index 0000000..66e9762 --- /dev/null +++ b/capstone-api/src/main/java/backend/chat/dto/CreatePersonalChatRoomRequest.java @@ -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; +} diff --git a/capstone-api/src/main/java/backend/chat/service/ChatService.java b/capstone-api/src/main/java/backend/chat/service/ChatService.java index 5231f86..2da64c2 100644 --- a/capstone-api/src/main/java/backend/chat/service/ChatService.java +++ b/capstone-api/src/main/java/backend/chat/service/ChatService.java @@ -1,8 +1,10 @@ package backend.chat.service; import java.util.Collection; +import java.util.Comparator; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; @@ -18,11 +20,14 @@ import backend.chat.dto.ChatRoomResponse; import backend.chat.dto.ChatRoomResponse.ChatRoomEnrichment; import backend.chat.dto.CreateChatRoomRequest; +import backend.chat.dto.CreatePersonalChatRoomRequest; import backend.chat.dto.SendMessageRequest; import backend.chat.entity.ChatMessage; import backend.chat.entity.ChatRoom; +import backend.chat.entity.ChatRoomMember; import backend.chat.entity.ChatRoomType; import backend.chat.repository.ChatMessageRepository; +import backend.chat.repository.ChatRoomMemberRepository; import backend.chat.repository.ChatRoomRepository; import backend.global.error.exception.BusinessException; import backend.global.error.exception.ErrorCode; @@ -39,6 +44,7 @@ public class ChatService { private final ChatRoomRepository chatRoomRepository; private final ChatMessageRepository chatMessageRepository; + private final ChatRoomMemberRepository chatRoomMemberRepository; private final SseEmitterService sseEmitterService; private final SpotRepository spotRepository; private final UserRepository userRepository; @@ -49,7 +55,7 @@ public class ChatService { /** * 전체 채팅방 목록을 조회합니다. - * TODO: 인증 도입 후 현재 로그인 유저가 참여한 방만 반환하도록 수정 + * 인증된 유저만 호출 가능 — 본인이 멤버로 속한 방만 반환합니다. */ @Transactional(readOnly = true) public List getRooms() { @@ -59,7 +65,14 @@ public List getRooms() { @Transactional(readOnly = true) public List getRooms(String currentUserId) { UserEntity currentUser = findCurrentUser(currentUserId); - List rooms = chatRoomRepository.findAll(); + List rooms; + if (currentUser == null) { + // 비인증 — 멤버십 기반 필터가 불가능하므로 빈 리스트 반환 + rooms = List.of(); + } else { + List roomIds = chatRoomMemberRepository.findChatRoomIdsByUserId(currentUser.getId()); + rooms = roomIds.isEmpty() ? List.of() : chatRoomRepository.findAllById(roomIds); + } Map enrichments = buildEnrichments(rooms, currentUser); return rooms.stream() .map(room -> ChatRoomResponse.from(room, enrichments.getOrDefault(room.getId(), ChatRoomEnrichment.empty()))) @@ -67,24 +80,85 @@ public List getRooms(String currentUserId) { } /** - * 채팅방을 생성합니다. - * GROUP 타입은 반드시 spotId 가 있어야 합니다. + * 그룹 채팅방을 생성합니다. (PERSONAL 은 createPersonalRoom 사용) + * 동일 spotId 의 GROUP 방이 이미 있으면 idempotent 하게 기존 방을 반환합니다. */ public ChatRoomResponse createRoom(CreateChatRoomRequest request) { + return createRoom(request, null); + } + + public ChatRoomResponse createRoom(CreateChatRoomRequest request, String currentUserId) { + if (request.getType() == ChatRoomType.PERSONAL) { + throw new BusinessException(ErrorCode.CHAT_PERSONAL_REQUIRES_PARTNER); + } if (request.getType() == ChatRoomType.GROUP && request.getSpotId() == null) { throw new BusinessException(ErrorCode.GROUP_CHAT_REQUIRES_SPOT); } - ChatRoom room = ChatRoom.builder() - .spotId(request.getSpotId()) - .type(request.getType()) - .build(); + // 동일 spot 의 활성 GROUP 방 idempotent 재사용 + ChatRoom room = chatRoomRepository + .findFirstBySpotIdAndTypeAndIsDeletedFalse(request.getSpotId(), ChatRoomType.GROUP) + .orElseGet(() -> chatRoomRepository.save( + ChatRoom.builder() + .spotId(request.getSpotId()) + .type(ChatRoomType.GROUP) + .isDeleted(false) + .build() + )); + + // 호출 유저를 멤버로 등록 (이미 있으면 no-op) + if (currentUserId != null && !currentUserId.isBlank()) { + ensureMember(room.getId(), currentUserId); + } + + UserEntity currentUser = findCurrentUser(currentUserId); + return ChatRoomResponse.from(room, buildEnrichment(room, currentUser)); + } + + /** + * 1:1 채팅방을 시작합니다. A↔B 가 이미 있으면 기존 방을 반환 (카카오톡 스타일). + */ + public ChatRoomResponse createPersonalRoom(CreatePersonalChatRoomRequest request, String currentUserId) { + if (currentUserId == null || currentUserId.isBlank()) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + String partnerId = request.getPartnerId(); + if (partnerId == null || partnerId.isBlank()) { + throw new BusinessException(ErrorCode.CHAT_PERSONAL_REQUIRES_PARTNER); + } + if (Objects.equals(currentUserId, partnerId)) { + throw new BusinessException(ErrorCode.CHAT_PERSONAL_SELF_NOT_ALLOWED); + } + + UserEntity me = userRepository.findById(currentUserId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + UserEntity partner = userRepository.findById(partnerId) + .orElseThrow(() -> new BusinessException(ErrorCode.CHAT_PARTNER_NOT_FOUND)); + + // 기존 PERSONAL 방 재사용 + List existing = chatRoomMemberRepository.findPersonalRoomIdsBetween(me.getId(), partner.getId()); + ChatRoom room = existing.stream() + .flatMap(id -> chatRoomRepository.findById(id).stream()) + .filter(r -> r.getType() == ChatRoomType.PERSONAL && !r.isDeleted()) + .min(Comparator.comparing(ChatRoom::getId)) + .orElse(null); + + if (room == null) { + room = chatRoomRepository.save( + ChatRoom.builder() + .type(ChatRoomType.PERSONAL) + .isDeleted(false) + .build() + ); + ensureMember(room.getId(), me.getId()); + ensureMember(room.getId(), partner.getId()); + } - return ChatRoomResponse.from(chatRoomRepository.save(room)); + return ChatRoomResponse.from(room, buildEnrichment(room, me)); } /** - * 채팅방 단건 상세 조회를 합니다. + * 채팅방 단건 상세 조회. 멤버만 접근 가능. */ @Transactional(readOnly = true) public ChatRoomResponse getRoom(Long roomId) { @@ -94,12 +168,13 @@ public ChatRoomResponse getRoom(Long roomId) { @Transactional(readOnly = true) public ChatRoomResponse getRoom(Long roomId, String currentUserId) { ChatRoom room = findRoomOrThrow(roomId); + assertMembership(roomId, currentUserId); UserEntity currentUser = findCurrentUser(currentUserId); return ChatRoomResponse.from(room, buildEnrichment(room, currentUser)); } /** - * 스팟 ID로 연결된 채팅방을 조회합니다. + * 스팟 ID로 연결된 채팅방을 조회합니다. 멤버 방만 반환. */ @Transactional(readOnly = true) public List getRoomsBySpot(String spotId) { @@ -110,6 +185,12 @@ public List getRoomsBySpot(String spotId) { public List getRoomsBySpot(String spotId, String currentUserId) { UserEntity currentUser = findCurrentUser(currentUserId); List rooms = chatRoomRepository.findBySpotId(spotId); + if (currentUser != null) { + Set myRoomIds = Set.copyOf(chatRoomMemberRepository.findChatRoomIdsByUserId(currentUser.getId())); + rooms = rooms.stream().filter(r -> myRoomIds.contains(r.getId())).toList(); + } else { + rooms = List.of(); + } Map enrichments = buildEnrichments(rooms, currentUser); return rooms.stream() .map(room -> ChatRoomResponse.from(room, enrichments.getOrDefault(room.getId(), ChatRoomEnrichment.empty()))) @@ -117,8 +198,7 @@ public List getRoomsBySpot(String spotId, String currentUserId } /** - * 유저 ID로 참여 중인 채팅방 목록을 조회합니다. - * TODO: ChatRoomMember 테이블 도입 후 실제 필터링 구현 + * 유저 ID로 참여 중인 채팅방 목록을 조회합니다. 본인 멤버십과 교집합. */ @Transactional(readOnly = true) public List getRoomsByUser(String userId) { @@ -128,31 +208,81 @@ public List getRoomsByUser(String userId) { @Transactional(readOnly = true) public List getRoomsByUser(String userId, String currentUserId) { UserEntity currentUser = findCurrentUser(currentUserId); - List roomIds = chatMessageRepository.findDistinctChatRoomIdsBySenderId(userId); - List rooms = chatRoomRepository.findAllById(roomIds); + List targetRoomIds = chatRoomMemberRepository.findChatRoomIdsByUserId(userId); + if (currentUser != null) { + Set myRoomIds = Set.copyOf(chatRoomMemberRepository.findChatRoomIdsByUserId(currentUser.getId())); + targetRoomIds = targetRoomIds.stream().filter(myRoomIds::contains).toList(); + } else { + targetRoomIds = List.of(); + } + List rooms = targetRoomIds.isEmpty() ? List.of() : chatRoomRepository.findAllById(targetRoomIds); Map enrichments = buildEnrichments(rooms, currentUser); return rooms.stream() .map(room -> ChatRoomResponse.from(room, enrichments.getOrDefault(room.getId(), ChatRoomEnrichment.empty()))) .toList(); } + // ───────────────────────────────────────────── + // 멤버십 (Membership) — 외부 도메인 (Spot) 에서도 호출 가능 + // ───────────────────────────────────────────── + + /** + * 채팅방에 유저를 멤버로 추가합니다. 이미 멤버면 no-op. + * SpotService 의 matchSpot 등에서 자동 가입 시 호출. + */ + public void ensureMember(Long roomId, String userId) { + if (userId == null || userId.isBlank()) { + return; + } + if (chatRoomMemberRepository.existsByChatRoomIdAndUserId(roomId, userId)) { + return; + } + chatRoomMemberRepository.save( + ChatRoomMember.builder() + .chatRoomId(roomId) + .userId(userId) + .build() + ); + } + + /** + * 스팟 GROUP 방을 idempotent 하게 보장 + 참가자 일괄 가입. + * SpotService 측에서 매칭 완료 시 호출. + */ + public ChatRoom ensureGroupRoomForSpot(String spotId, Collection participantUserIds) { + ChatRoom room = chatRoomRepository + .findFirstBySpotIdAndTypeAndIsDeletedFalse(spotId, ChatRoomType.GROUP) + .orElseGet(() -> chatRoomRepository.save( + ChatRoom.builder() + .spotId(spotId) + .type(ChatRoomType.GROUP) + .isDeleted(false) + .build() + )); + if (participantUserIds != null) { + for (String userId : participantUserIds) { + ensureMember(room.getId(), userId); + } + } + return room; + } + // ───────────────────────────────────────────── // 메시지 (Message) // ───────────────────────────────────────────── /** - * 채팅방의 메시지를 커서 기반 페이지네이션으로 조회합니다. - * - *

실제 size+1 개를 조회하여 다음 페이지 존재 여부(hasMore)를 정확히 판단합니다. - * 반환 목록은 요청한 size 개로 잘려 반환됩니다. - * - * @param roomId 채팅방 ID - * @param cursor 마지막으로 받은 메시지 ID (없으면 null, 최신부터 조회) - * @param size 한 번에 가져올 메시지 수 + * 채팅방의 메시지를 커서 기반 페이지네이션으로 조회합니다. 멤버만 가능. */ @Transactional(readOnly = true) public ChatMessageListResponse getMessages(Long roomId, Long cursor, int size) { + return getMessages(roomId, cursor, size, null); + } + + @Transactional(readOnly = true) + public ChatMessageListResponse getMessages(Long roomId, Long cursor, int size, String currentUserId) { findRoomOrThrow(roomId); + assertMembership(roomId, currentUserId); PageRequest pageRequest = PageRequest.of(0, size + 1); // +1 로 hasMore 판단 List messages; @@ -171,7 +301,7 @@ public ChatMessageListResponse getMessages(Long roomId, Long cursor, int size) { } /** - * 메시지를 전송합니다. senderId 는 인증된 유저 ID 를 사용합니다. + * 메시지를 전송합니다. 멤버만 가능. senderId 는 인증된 유저 ID 를 사용합니다. * *

트랜잭션 커밋 완료 후 SSE 브로드캐스트를 실행하여 * DB 미커밋 상태의 메시지가 클라이언트에 전달되는 phantom message 를 방지합니다. @@ -182,10 +312,11 @@ public ChatMessageResponse sendMessage(Long roomId, SendMessageRequest request) public ChatMessageResponse sendMessage(Long roomId, SendMessageRequest request, String currentUserId) { findRoomOrThrow(roomId); + assertMembership(roomId, currentUserId); ChatMessage message = ChatMessage.builder() .chatRoomId(roomId) - .senderId(currentUserId != null && !currentUserId.isBlank() ? currentUserId : "dummy-user-id") + .senderId(currentUserId) .content(request.getContent()) .build(); @@ -203,8 +334,8 @@ public void afterCommit() { } /** - * 채팅방의 메시지를 읽음 처리합니다. - * TODO: ChatMessageReadStatus 테이블 도입 후 유저별 읽음 상태 저장 구현 + * 채팅방의 메시지를 읽음 처리합니다. 멤버만 가능. + * TODO: ChatMessageReadStatus 테이블 도입 후 유저별 읽음 상태 저장 구현 (PR B) */ public void markAsRead(Long roomId) { markAsRead(roomId, null); @@ -215,6 +346,7 @@ public void markAsRead(Long roomId, String currentUserId) { throw new BusinessException(ErrorCode.UNAUTHORIZED); } findRoomOrThrow(roomId); + assertMembership(roomId, currentUserId); sseEmitterService.broadcastRead(roomId, currentUserId); } @@ -224,9 +356,19 @@ public void markAsRead(Long roomId, String currentUserId) { public ChatRoom findRoomOrThrow(Long roomId) { return chatRoomRepository.findById(roomId) + .filter(r -> !r.isDeleted()) .orElseThrow(() -> new BusinessException(ErrorCode.CHAT_ROOM_NOT_FOUND)); } + private void assertMembership(Long roomId, String currentUserId) { + if (currentUserId == null || currentUserId.isBlank()) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + if (!chatRoomMemberRepository.existsByChatRoomIdAndUserId(roomId, currentUserId)) { + throw new BusinessException(ErrorCode.CHAT_ROOM_ACCESS_DENIED); + } + } + private ChatRoomEnrichment buildEnrichment(ChatRoom room, UserEntity currentUser) { return buildEnrichments(List.of(room), currentUser) .getOrDefault(room.getId(), ChatRoomEnrichment.empty()); @@ -256,6 +398,9 @@ private Map buildEnrichments(Collection room .stream() .collect(Collectors.toMap(Spot::getId, Function.identity())); + // PERSONAL 방 파트너 lookup + Map partnerByRoomId = resolvePersonalPartners(rooms, currentUser); + return rooms.stream() .collect(Collectors.toMap( ChatRoom::getId, @@ -263,12 +408,49 @@ private Map buildEnrichments(Collection room .lastMessage(lastMessagesByRoomId.get(room.getId())) .spot(room.getSpotId() == null ? null : spotsById.get(room.getSpotId())) .currentUser(currentUser) + .partner(partnerByRoomId.get(room.getId())) .build() )); } + private Map resolvePersonalPartners(Collection rooms, UserEntity currentUser) { + if (currentUser == null) { + return Map.of(); + } + List personalRoomIds = rooms.stream() + .filter(r -> r.getType() == ChatRoomType.PERSONAL) + .map(ChatRoom::getId) + .toList(); + if (personalRoomIds.isEmpty()) { + return Map.of(); + } + + // 각 PERSONAL 방의 멤버 중 본인이 아닌 유저 ID 추출 + Map partnerIdByRoomId = personalRoomIds.stream() + .collect(Collectors.toMap( + Function.identity(), + roomId -> chatRoomMemberRepository.findUserIdsByChatRoomId(roomId).stream() + .filter(uid -> !Objects.equals(uid, currentUser.getId())) + .findFirst() + .orElse(null) + )); + + Set partnerIds = partnerIdByRoomId.values().stream() + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + if (partnerIds.isEmpty()) { + return Map.of(); + } + Map partnerById = userRepository.findAllById(partnerIds).stream() + .collect(Collectors.toMap(UserEntity::getId, Function.identity())); + + return partnerIdByRoomId.entrySet().stream() + .filter(e -> e.getValue() != null && partnerById.containsKey(e.getValue())) + .collect(Collectors.toMap(Map.Entry::getKey, e -> partnerById.get(e.getValue()))); + } + private UserEntity findCurrentUser(String currentUserId) { - if (currentUserId == null) { + if (currentUserId == null || currentUserId.isBlank()) { return null; } return userRepository.findById(currentUserId).orElse(null); diff --git a/capstone-api/src/main/java/backend/spot/service/SpotService.java b/capstone-api/src/main/java/backend/spot/service/SpotService.java index 4c4e911..08eb7d3 100644 --- a/capstone-api/src/main/java/backend/spot/service/SpotService.java +++ b/capstone-api/src/main/java/backend/spot/service/SpotService.java @@ -12,6 +12,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import backend.chat.service.ChatService; import backend.global.dto.ApiResponseMeta; import backend.global.error.exception.BusinessException; import backend.global.error.exception.ErrorCode; @@ -70,6 +71,7 @@ public class SpotService { private final SpotFileRepository spotFileRepository; private final SpotNoteRepository spotNoteRepository; private final UserRepository userRepository; + private final ChatService chatService; private static final String FALLBACK_USER_ID = "dummy-user-id"; private static final String FALLBACK_NICKNAME = "테스트유저"; @@ -145,6 +147,15 @@ public SpotResponse getSpot(String spotId) { public SpotResponse matchSpot(String spotId) { Spot spot = findSpotOrThrow(spotId); spot.match(); + + // 매칭 시 GROUP 채팅방을 보장하고 참가자를 자동 가입 + List participantUserIds = spotParticipantRepository.findBySpotId(spotId).stream() + .map(p -> p.getUserId()) + .filter(uid -> uid != null && !uid.isBlank()) + .distinct() + .toList(); + chatService.ensureGroupRoomForSpot(spotId, participantUserIds); + return toSpotResponse(spot); } diff --git a/capstone-common/src/main/java/backend/global/error/exception/ErrorCode.java b/capstone-common/src/main/java/backend/global/error/exception/ErrorCode.java index d72252b..475be08 100644 --- a/capstone-common/src/main/java/backend/global/error/exception/ErrorCode.java +++ b/capstone-common/src/main/java/backend/global/error/exception/ErrorCode.java @@ -39,6 +39,10 @@ public enum ErrorCode { // Chat CHAT_ROOM_NOT_FOUND(HttpStatus.NOT_FOUND, "CH001", "Chat room not found"), GROUP_CHAT_REQUIRES_SPOT(HttpStatus.BAD_REQUEST, "CH002", "Group chat room requires a spotId"), + CHAT_ROOM_ACCESS_DENIED(HttpStatus.FORBIDDEN, "CH003", "You are not a member of this chat room"), + CHAT_PARTNER_NOT_FOUND(HttpStatus.NOT_FOUND, "CH004", "Chat partner user not found"), + CHAT_PERSONAL_SELF_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "CH005", "Cannot start a personal chat with yourself"), + CHAT_PERSONAL_REQUIRES_PARTNER(HttpStatus.BAD_REQUEST, "CH006", "Personal chat requires a partnerId"), // User USER_NOT_FOUND(HttpStatus.NOT_FOUND, "U001", "User not found"), diff --git a/capstone-domain/src/main/java/backend/chat/entity/ChatRoom.java b/capstone-domain/src/main/java/backend/chat/entity/ChatRoom.java index cf2b6c4..d7c0448 100644 --- a/capstone-domain/src/main/java/backend/chat/entity/ChatRoom.java +++ b/capstone-domain/src/main/java/backend/chat/entity/ChatRoom.java @@ -36,11 +36,28 @@ public class ChatRoom { @Column(name = "spot_id") private String spotId; + @Column(name = "post_id") + private String postId; + @Enumerated(EnumType.STRING) @Column(nullable = false) private ChatRoomType type; + @Column(name = "name") + private String name; + + @Column(name = "image_url") + private String imageUrl; + + @Column(name = "is_deleted", nullable = false) + @Builder.Default + private boolean isDeleted = false; + @CreatedDate @Column(nullable = false, updatable = false) private LocalDateTime createdAt; + + public void markDeleted() { + this.isDeleted = true; + } } diff --git a/capstone-domain/src/main/java/backend/chat/entity/ChatRoomMember.java b/capstone-domain/src/main/java/backend/chat/entity/ChatRoomMember.java new file mode 100644 index 0000000..43bac23 --- /dev/null +++ b/capstone-domain/src/main/java/backend/chat/entity/ChatRoomMember.java @@ -0,0 +1,61 @@ +package backend.chat.entity; + +import java.time.LocalDateTime; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 채팅방 멤버십. (chat_room_id, user_id) 가 유니크. + * + *

PERSONAL 방은 정확히 2 명, GROUP 방은 N 명의 멤버를 가진다. + * 멤버가 아닌 사용자는 해당 채팅방의 메시지 조회/전송이 불가능하다. + * 가입 시점 이전 메시지도 가시 (Slack 스타일) — joinedAt 은 추후 정책 변경/unread 베이스라인 용도. + */ +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@EntityListeners(AuditingEntityListener.class) +@Table( + name = "chat_room_members", + uniqueConstraints = @UniqueConstraint( + name = "uq_chat_room_member", + columnNames = {"chat_room_id", "user_id"} + ), + indexes = { + @Index(name = "idx_chat_room_member_user", columnList = "user_id") + } +) +public class ChatRoomMember { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "chat_room_id", nullable = false) + private Long chatRoomId; + + @Column(name = "user_id", nullable = false) + private String userId; + + @CreatedDate + @Column(name = "joined_at", nullable = false, updatable = false) + private LocalDateTime joinedAt; +} diff --git a/capstone-domain/src/main/java/backend/chat/repository/ChatRoomMemberRepository.java b/capstone-domain/src/main/java/backend/chat/repository/ChatRoomMemberRepository.java new file mode 100644 index 0000000..102fcc1 --- /dev/null +++ b/capstone-domain/src/main/java/backend/chat/repository/ChatRoomMemberRepository.java @@ -0,0 +1,51 @@ +package backend.chat.repository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import backend.chat.entity.ChatRoomMember; + +public interface ChatRoomMemberRepository extends JpaRepository { + + boolean existsByChatRoomIdAndUserId(Long chatRoomId, String userId); + + Optional findByChatRoomIdAndUserId(Long chatRoomId, String userId); + + List findByChatRoomId(Long chatRoomId); + + List findByUserId(String userId); + + @Query("select m.chatRoomId from ChatRoomMember m where m.userId = :userId") + List findChatRoomIdsByUserId(@Param("userId") String userId); + + @Query("select m.userId from ChatRoomMember m where m.chatRoomId = :chatRoomId") + List findUserIdsByChatRoomId(@Param("chatRoomId") Long chatRoomId); + + /** + * 두 유저가 정확히 둘만 멤버로 있는 PERSONAL 방 ID 를 조회. + * PERSONAL 재사용 정책 (A↔B 채팅 다시 시작 시 기존 방 반환) 의 핵심 쿼리. + */ + @Query(""" + select m.chatRoomId + from ChatRoomMember m + where m.chatRoomId in ( + select m2.chatRoomId from ChatRoomMember m2 + where m2.userId in (:userA, :userB) + group by m2.chatRoomId + having count(distinct m2.userId) = 2 + ) + group by m.chatRoomId + having count(m.userId) = 2 + """) + List findPersonalRoomIdsBetween(@Param("userA") String userA, @Param("userB") String userB); + + long countByChatRoomId(Long chatRoomId); + + @Query("select m.chatRoomId, count(m) from ChatRoomMember m where m.chatRoomId in :roomIds group by m.chatRoomId") + List countMembersGroupedByRoomIds(@Param("roomIds") Collection roomIds); +} diff --git a/capstone-domain/src/main/java/backend/chat/repository/ChatRoomRepository.java b/capstone-domain/src/main/java/backend/chat/repository/ChatRoomRepository.java index 9cffb04..10eec40 100644 --- a/capstone-domain/src/main/java/backend/chat/repository/ChatRoomRepository.java +++ b/capstone-domain/src/main/java/backend/chat/repository/ChatRoomRepository.java @@ -1,6 +1,7 @@ package backend.chat.repository; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -12,4 +13,10 @@ public interface ChatRoomRepository extends JpaRepository { List findBySpotId(String spotId); List findBySpotIdAndType(String spotId, ChatRoomType type); + + /** + * 스팟 기반 GROUP 채팅방 중 유효한(미삭제) 단건. partial unique index 와 의미적으로 짝. + * DB 레벨 제약 (uq_chat_room_group_spot) 으로 1 개 이하가 보장됨. + */ + Optional findFirstBySpotIdAndTypeAndIsDeletedFalse(String spotId, ChatRoomType type); } diff --git a/docs/migrations/2026-05-15_chat_room_member.sql b/docs/migrations/2026-05-15_chat_room_member.sql new file mode 100644 index 0000000..b25fd56 --- /dev/null +++ b/docs/migrations/2026-05-15_chat_room_member.sql @@ -0,0 +1,81 @@ +-- ============================================================================ +-- Migration: ChatRoomMember 도입 + ChatRoom 컬럼 확장 + GROUP partial unique +-- Date : 2026-05-15 +-- Author : 김동현 +-- PR : PR A — ChatRoomMember introduction +-- Target : PostgreSQL (운영/로컬 동일 적용) +-- ============================================================================ +-- +-- 변경 사항: +-- 1) chat_room_members 테이블 신규 생성 (멤버십 모델) +-- - UNIQUE (chat_room_id, user_id) +-- - INDEX (user_id) -- "내가 속한 방" 조회용 +-- 2) chat_rooms 에 name / image_url / post_id / is_deleted 컬럼 추가 +-- 3) chat_rooms 에 (spot_id) WHERE type='GROUP' AND is_deleted=false partial +-- unique index 추가 — "스팟당 GROUP 방 1 개" DB 수준 보장 +-- 4) 백필: 기존 chat_messages 의 sender 들을 해당 방의 멤버로 등록 +-- +-- ⚠️ ddl-auto=update 는 신규 테이블/컬럼 자동 생성 가능하지만 partial unique +-- index 와 백필 INSERT 는 자동 처리 안 됨 → 본 스크립트 수동 실행 필요. +-- +-- 실행 절차 (로컬): +-- 1. 서버 중지 +-- 2. psql -d backend_db -f docs/migrations/2026-05-15_chat_room_member.sql +-- 3. 서버 재기동 — Hibernate 가 잔여 컬럼/제약을 idempotent 하게 맞춤 +-- +-- 롤백: +-- DROP INDEX IF EXISTS uq_chat_room_group_spot; +-- DROP TABLE IF EXISTS chat_room_members; +-- ALTER TABLE chat_rooms DROP COLUMN IF EXISTS name; +-- ALTER TABLE chat_rooms DROP COLUMN IF EXISTS image_url; +-- ALTER TABLE chat_rooms DROP COLUMN IF EXISTS post_id; +-- ALTER TABLE chat_rooms DROP COLUMN IF EXISTS is_deleted; +-- ============================================================================ + +BEGIN; + +-- 1) ChatRoomMember 테이블 +CREATE TABLE IF NOT EXISTS chat_room_members ( + id BIGSERIAL PRIMARY KEY, + chat_room_id BIGINT NOT NULL REFERENCES chat_rooms(id) ON DELETE CASCADE, + user_id VARCHAR(255) NOT NULL, + joined_at TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT uq_chat_room_member UNIQUE (chat_room_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_chat_room_member_user + ON chat_room_members(user_id); + +-- 2) ChatRoom 컬럼 추가 +ALTER TABLE chat_rooms ADD COLUMN IF NOT EXISTS name VARCHAR(255); +ALTER TABLE chat_rooms ADD COLUMN IF NOT EXISTS image_url VARCHAR(1024); +ALTER TABLE chat_rooms ADD COLUMN IF NOT EXISTS post_id VARCHAR(36); +ALTER TABLE chat_rooms ADD COLUMN IF NOT EXISTS is_deleted BOOLEAN NOT NULL DEFAULT false; + +-- 3) GROUP 방은 (spot_id) 당 활성 1 개 — partial unique index +CREATE UNIQUE INDEX IF NOT EXISTS uq_chat_room_group_spot + ON chat_rooms (spot_id) + WHERE type = 'GROUP' AND is_deleted = false AND spot_id IS NOT NULL; + +-- 4) 백필: 기존 chat_messages 의 sender_id 를 해당 방 멤버로 등록 +-- (방장/단일 참여자 등 메타데이터가 없어도 sender 는 사실상 멤버였음) +INSERT INTO chat_room_members (chat_room_id, user_id, joined_at) +SELECT m.room_id, m.sender_id, MIN(m.created_at) + FROM chat_messages m + WHERE m.sender_id IS NOT NULL + AND m.sender_id <> '' + AND NOT EXISTS ( + SELECT 1 FROM chat_room_members crm + WHERE crm.chat_room_id = m.room_id + AND crm.user_id = m.sender_id + ) + GROUP BY m.room_id, m.sender_id +ON CONFLICT (chat_room_id, user_id) DO NOTHING; + +COMMIT; + +-- 검증 쿼리: +-- SELECT count(*) FROM chat_room_members; +-- \d chat_rooms +-- \d chat_room_members +-- SELECT indexdef FROM pg_indexes WHERE indexname = 'uq_chat_room_group_spot'; From 4adfd7523746ccf79b34b55bb40d917733eafeff Mon Sep 17 00:00:00 2001 From: donghyunkim Date: Sat, 16 May 2026 22:16:35 +0900 Subject: [PATCH 3/4] fix(auth): resolve real userId when JWTFilter sets only email as principal The existing JWTFilter (capstone-common) populates the SecurityContext with a UsernamePasswordAuthenticationToken whose principal is the raw email String, not a CustomUserDetails. Controllers that ask for @AuthenticationPrincipal CustomUserDetails therefore receive null, and controllers that fall back to `instanceof String userId` end up storing the email where the userId should go (e.g. authorId == email). Until JWTFilter itself is refactored to seat a CustomUserDetails, work around the bug at the edge: - ChatController#currentUserId now accepts Object and dispatches: CustomUserDetails -> getUserId(); String email -> UserRepository .findByEmail -> id; otherwise null. Injects UserRepository for the lookup. - SpotController#resolveCurrentUserId applies the same dispatch so createSpot stops persisting email as authorId. Also harden SpotService.matchSpot so the chat-room backfill survives the existing data gap where createSpot does not insert the author into spot_participants: we now union the spot's authorId with the participant list before calling ChatService.ensureGroupRoomForSpot, guaranteeing the author (and any future real participants) become chat members the moment a spot transitions to MATCHED. TODO (separate PR): refactor JWTFilter to seat CustomUserDetails and register the author as an OWNER participant inside createSpot, then remove these workarounds. Co-Authored-By: Claude Opus 4.7 --- .../chat/controller/ChatController.java | 65 +++++++++++++------ .../spot/controller/SpotController.java | 11 +++- .../backend/spot/service/SpotService.java | 16 +++-- 3 files changed, 65 insertions(+), 27 deletions(-) diff --git a/capstone-api/src/main/java/backend/chat/controller/ChatController.java b/capstone-api/src/main/java/backend/chat/controller/ChatController.java index c312774..98e69ab 100644 --- a/capstone-api/src/main/java/backend/chat/controller/ChatController.java +++ b/capstone-api/src/main/java/backend/chat/controller/ChatController.java @@ -24,6 +24,8 @@ 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; @@ -38,6 +40,7 @@ public class ChatController { private final ChatService chatService; private final SseEmitterService sseEmitterService; + private final UserRepository userRepository; // ─── SSE 연결 ───────────────────────────────── @@ -58,9 +61,9 @@ public SseEmitter connect( @Operation(summary = "채팅방 목록 조회") @GetMapping("/rooms") public ResponseEntity>> 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( @@ -71,10 +74,10 @@ public ResponseEntity>> getRooms( @PostMapping("/rooms") public ResponseEntity> createRoom( @Valid @RequestBody CreateChatRoomRequest request, - @AuthenticationPrincipal CustomUserDetails userDetails + @AuthenticationPrincipal Object principal ) { return ResponseEntity.ok(ApiResponse.success( - chatService.createRoom(request, currentUserId(userDetails)) + chatService.createRoom(request, currentUserId(principal)) )); } @@ -85,10 +88,10 @@ public ResponseEntity> createRoom( @PostMapping("/rooms/personal") public ResponseEntity> createPersonalRoom( @Valid @RequestBody CreatePersonalChatRoomRequest request, - @AuthenticationPrincipal CustomUserDetails userDetails + @AuthenticationPrincipal Object principal ) { return ResponseEntity.ok(ApiResponse.success( - chatService.createPersonalRoom(request, currentUserId(userDetails)) + chatService.createPersonalRoom(request, currentUserId(principal)) )); } @@ -96,27 +99,27 @@ public ResponseEntity> createPersonalRoom( @GetMapping("/rooms/{roomId}") public ResponseEntity> 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>> 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>> 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) ───────────────────────── @@ -131,10 +134,10 @@ public ResponseEntity> getMessages( @Parameter(description = "커서 (마지막 메시지 ID, 최초 조회 시 생략)") @RequestParam(required = false) Long cursor, @RequestParam(defaultValue = "30") int size, - @AuthenticationPrincipal CustomUserDetails userDetails + @AuthenticationPrincipal Object principal ) { return ResponseEntity.ok(ApiResponse.success( - chatService.getMessages(roomId, cursor, size, currentUserId(userDetails)) + chatService.getMessages(roomId, cursor, size, currentUserId(principal)) )); } @@ -146,10 +149,10 @@ public ResponseEntity> getMessages( public ResponseEntity> 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)) )); } @@ -157,13 +160,35 @@ public ResponseEntity> sendMessage( @PostMapping("/rooms/{roomId}/read") public ResponseEntity> 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 로 변환. + * + *

JWTFilter 가 현재 principal 에 email (String) 만 셋팅하므로 (createSpot/Chat 등에서 + * @AuthenticationPrincipal CustomUserDetails 가 null 로 들어오는 원인) 여기서 한 번 더 + * UserRepository.findByEmail 을 거쳐 진짜 userId 로 풀어준다. CustomUserDetails 가 들어오는 + * 경로 (Login 직후 등) 도 함께 처리하기 위해 Object 로 받아 type 분기. + * + *

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; } } diff --git a/capstone-api/src/main/java/backend/spot/controller/SpotController.java b/capstone-api/src/main/java/backend/spot/controller/SpotController.java index e776392..589fb82 100644 --- a/capstone-api/src/main/java/backend/spot/controller/SpotController.java +++ b/capstone-api/src/main/java/backend/spot/controller/SpotController.java @@ -19,6 +19,8 @@ import backend.global.common.response.ApiResponse; import backend.global.security.CustomUserDetails; +import backend.user.entity.UserEntity; +import backend.user.repository.UserRepository; import backend.spot.dto.CastVoteRequest; import backend.spot.dto.CreateChecklistRequest; import backend.spot.dto.CreateNoteRequest; @@ -48,6 +50,7 @@ public class SpotController { private final SpotService spotService; + private final UserRepository userRepository; // ─── Spot 기본 CRUD ─────────────────────────── @@ -275,8 +278,12 @@ private String resolveCurrentUserId(Object principal, Authentication authenticat if (principal instanceof CustomUserDetails userDetails) { return userDetails.getUserId(); } - if (principal instanceof String userId) { - return userId; + // JWTFilter 가 principal 에 email (String) 만 셋팅하는 현 상태를 우회. + // TODO: JWTFilter 자체가 CustomUserDetails 를 셋팅하도록 리팩터링되면 본 분기 제거. + if (principal instanceof String emailOrId) { + return userRepository.findByEmail(emailOrId) + .map(UserEntity::getId) + .orElse(emailOrId); } return null; diff --git a/capstone-api/src/main/java/backend/spot/service/SpotService.java b/capstone-api/src/main/java/backend/spot/service/SpotService.java index 08eb7d3..f472cb3 100644 --- a/capstone-api/src/main/java/backend/spot/service/SpotService.java +++ b/capstone-api/src/main/java/backend/spot/service/SpotService.java @@ -148,13 +148,19 @@ public SpotResponse matchSpot(String spotId) { Spot spot = findSpotOrThrow(spotId); spot.match(); - // 매칭 시 GROUP 채팅방을 보장하고 참가자를 자동 가입 - List participantUserIds = spotParticipantRepository.findBySpotId(spotId).stream() + // 매칭 시 GROUP 채팅방을 보장하고 author + 참가자를 자동 가입. + // (현재 createSpot 이 author 를 spot_participants 에 자동 등록하지 않으므로 + // author 를 별도로 합쳐서 누락을 막는다. 향후 별도 PR 에서 + // createSpot 단계에서 author 를 OWNER 로 등록하도록 정리.) + Set memberUserIds = new HashSet<>(); + if (spot.getAuthorId() != null && !spot.getAuthorId().isBlank()) { + memberUserIds.add(spot.getAuthorId()); + } + spotParticipantRepository.findBySpotId(spotId).stream() .map(p -> p.getUserId()) .filter(uid -> uid != null && !uid.isBlank()) - .distinct() - .toList(); - chatService.ensureGroupRoomForSpot(spotId, participantUserIds); + .forEach(memberUserIds::add); + chatService.ensureGroupRoomForSpot(spotId, memberUserIds); return toSpotResponse(spot); } From d7a675da5206d8ee5ad1d4b8bd7e5060a5cc7caf Mon Sep 17 00:00:00 2001 From: donghyunkim Date: Sat, 16 May 2026 22:30:57 +0900 Subject: [PATCH 4/4] fix(chat): address CodeRabbit feedback on PR #21 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes 8 Major findings from CodeRabbit review. ## Race safety - ChatService now wraps both GROUP-room creation and ChatRoomMember inserts in DataIntegrityViolationException handlers, leveraging the partial unique index and (chat_room_id, user_id) UNIQUE constraint as the source of truth. Concurrent idempotent calls converge on the same row instead of 500-ing. - createPersonalRoom does a second lookup after insert to detect a racing partner room and soft-deletes the newer duplicate, keeping the older room as the canonical one. Full canonical-pair uniqueness is left as a follow-up (requires a new column + index). - ensureGroupRoomForSpot now goes through the same findOrCreate helper so SpotService.matchSpot inherits the race-safe path. ## Soft-delete leak - getRooms / getRoomsBySpot / getRoomsByUser filter out isDeleted rooms locally, matching findRoomOrThrow's behaviour and preventing deleted-room re-emergence when stale membership rows still exist. ## Personal-room lookup correctness - ChatRoomMemberRepository.findPersonalRoomIdsBetween now joins ChatRoom and requires type=PERSONAL AND isDeleted=false. Previously a (group room + same two users) edge case could be reused as if it were a PERSONAL room. ## Spot integration - SpotService.matchSpot only joins ACTIVE participants into the chat room. LEFT / EXPELLED participants no longer receive auto-membership. ## Auth fallback - SpotController.resolveCurrentUserId no longer falls through to emailOrId when findByEmail misses — it now returns null, matching ChatController policy. Prevents email strings from leaking into authorId / participant.userId. ## Migration - Added explicit note about the backfill's scope limitation (silent members not represented in chat_messages) and the upgrade path for environments that have alternative membership sources. Co-Authored-By: Claude Opus 4.7 --- .../backend/chat/service/ChatService.java | 137 ++++++++++++------ .../spot/controller/SpotController.java | 9 +- .../backend/spot/service/SpotService.java | 1 + .../repository/ChatRoomMemberRepository.java | 13 +- .../2026-05-15_chat_room_member.sql | 8 +- 5 files changed, 117 insertions(+), 51 deletions(-) diff --git a/capstone-api/src/main/java/backend/chat/service/ChatService.java b/capstone-api/src/main/java/backend/chat/service/ChatService.java index 2da64c2..48910d7 100644 --- a/capstone-api/src/main/java/backend/chat/service/ChatService.java +++ b/capstone-api/src/main/java/backend/chat/service/ChatService.java @@ -9,6 +9,7 @@ import java.util.function.Function; import java.util.stream.Collectors; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -71,7 +72,11 @@ public List getRooms(String currentUserId) { rooms = List.of(); } else { List roomIds = chatRoomMemberRepository.findChatRoomIdsByUserId(currentUser.getId()); - rooms = roomIds.isEmpty() ? List.of() : chatRoomRepository.findAllById(roomIds); + rooms = roomIds.isEmpty() + ? List.of() + : chatRoomRepository.findAllById(roomIds).stream() + .filter(r -> !r.isDeleted()) + .toList(); } Map enrichments = buildEnrichments(rooms, currentUser); return rooms.stream() @@ -95,16 +100,8 @@ public ChatRoomResponse createRoom(CreateChatRoomRequest request, String current throw new BusinessException(ErrorCode.GROUP_CHAT_REQUIRES_SPOT); } - // 동일 spot 의 활성 GROUP 방 idempotent 재사용 - ChatRoom room = chatRoomRepository - .findFirstBySpotIdAndTypeAndIsDeletedFalse(request.getSpotId(), ChatRoomType.GROUP) - .orElseGet(() -> chatRoomRepository.save( - ChatRoom.builder() - .spotId(request.getSpotId()) - .type(ChatRoomType.GROUP) - .isDeleted(false) - .build() - )); + // 동일 spot 의 활성 GROUP 방 idempotent 재사용 (concurrent-safe) + ChatRoom room = findOrCreateGroupRoomForSpot(request.getSpotId()); // 호출 유저를 멤버로 등록 (이미 있으면 no-op) if (currentUserId != null && !currentUserId.isBlank()) { @@ -135,28 +132,49 @@ public ChatRoomResponse createPersonalRoom(CreatePersonalChatRoomRequest request UserEntity partner = userRepository.findById(partnerId) .orElseThrow(() -> new BusinessException(ErrorCode.CHAT_PARTNER_NOT_FOUND)); - // 기존 PERSONAL 방 재사용 - List existing = chatRoomMemberRepository.findPersonalRoomIdsBetween(me.getId(), partner.getId()); - ChatRoom room = existing.stream() - .flatMap(id -> chatRoomRepository.findById(id).stream()) - .filter(r -> r.getType() == ChatRoomType.PERSONAL && !r.isDeleted()) - .min(Comparator.comparing(ChatRoom::getId)) - .orElse(null); - + ChatRoom room = lookupPersonalRoom(me.getId(), partner.getId()); if (room == null) { - room = chatRoomRepository.save( - ChatRoom.builder() - .type(ChatRoomType.PERSONAL) - .isDeleted(false) - .build() - ); - ensureMember(room.getId(), me.getId()); - ensureMember(room.getId(), partner.getId()); + try { + room = chatRoomRepository.save( + ChatRoom.builder() + .type(ChatRoomType.PERSONAL) + .isDeleted(false) + .build() + ); + ensureMember(room.getId(), me.getId()); + ensureMember(room.getId(), partner.getId()); + } catch (DataIntegrityViolationException race) { + // 멤버 INSERT 시 (room_id, user_id) UNIQUE 가 충돌하는 경우는 ensureMember 가 + // 내부에서 흡수하므로 여기까지 오기 어렵다. 안전망으로 재조회. + room = lookupPersonalRoom(me.getId(), partner.getId()); + if (room == null) { + throw race; + } + } + // PERSONAL 은 (userA, userB) canonical pair 의 partial unique index 가 없는 한 + // 동시 요청에서 두 방이 만들어질 수 있다 (CodeRabbit 지적, heavy lift). 한 번 더 재조회하여 + // 그 사이 다른 트랜잭션이 만든 방이 있으면 가장 오래된 것을 채택하고 본 트랜잭션이 만든 방은 soft-delete. + ChatRoom existingNow = lookupPersonalRoom(me.getId(), partner.getId()); + if (existingNow != null && !existingNow.getId().equals(room.getId())) { + ChatRoom older = existingNow.getId() < room.getId() ? existingNow : room; + ChatRoom newer = existingNow.getId() < room.getId() ? room : existingNow; + newer.markDeleted(); + chatRoomRepository.save(newer); + room = older; + } } return ChatRoomResponse.from(room, buildEnrichment(room, me)); } + private ChatRoom lookupPersonalRoom(String userA, String userB) { + return chatRoomMemberRepository.findPersonalRoomIdsBetween(userA, userB).stream() + .flatMap(id -> chatRoomRepository.findById(id).stream()) + .filter(r -> r.getType() == ChatRoomType.PERSONAL && !r.isDeleted()) + .min(Comparator.comparing(ChatRoom::getId)) + .orElse(null); + } + /** * 채팅방 단건 상세 조회. 멤버만 접근 가능. */ @@ -187,7 +205,10 @@ public List getRoomsBySpot(String spotId, String currentUserId List rooms = chatRoomRepository.findBySpotId(spotId); if (currentUser != null) { Set myRoomIds = Set.copyOf(chatRoomMemberRepository.findChatRoomIdsByUserId(currentUser.getId())); - rooms = rooms.stream().filter(r -> myRoomIds.contains(r.getId())).toList(); + rooms = rooms.stream() + .filter(r -> !r.isDeleted()) + .filter(r -> myRoomIds.contains(r.getId())) + .toList(); } else { rooms = List.of(); } @@ -215,7 +236,11 @@ public List getRoomsByUser(String userId, String currentUserId } else { targetRoomIds = List.of(); } - List rooms = targetRoomIds.isEmpty() ? List.of() : chatRoomRepository.findAllById(targetRoomIds); + List rooms = targetRoomIds.isEmpty() + ? List.of() + : chatRoomRepository.findAllById(targetRoomIds).stream() + .filter(r -> !r.isDeleted()) + .toList(); Map enrichments = buildEnrichments(rooms, currentUser); return rooms.stream() .map(room -> ChatRoomResponse.from(room, enrichments.getOrDefault(room.getId(), ChatRoomEnrichment.empty()))) @@ -237,12 +262,17 @@ public void ensureMember(Long roomId, String userId) { if (chatRoomMemberRepository.existsByChatRoomIdAndUserId(roomId, userId)) { return; } - chatRoomMemberRepository.save( - ChatRoomMember.builder() - .chatRoomId(roomId) - .userId(userId) - .build() - ); + try { + chatRoomMemberRepository.save( + ChatRoomMember.builder() + .chatRoomId(roomId) + .userId(userId) + .build() + ); + } catch (DataIntegrityViolationException race) { + // (room_id, user_id) UNIQUE 위반 — 다른 트랜잭션이 동일 멤버를 먼저 등록함. + // idempotent 한 작업이므로 무시. + } } /** @@ -250,15 +280,7 @@ public void ensureMember(Long roomId, String userId) { * SpotService 측에서 매칭 완료 시 호출. */ public ChatRoom ensureGroupRoomForSpot(String spotId, Collection participantUserIds) { - ChatRoom room = chatRoomRepository - .findFirstBySpotIdAndTypeAndIsDeletedFalse(spotId, ChatRoomType.GROUP) - .orElseGet(() -> chatRoomRepository.save( - ChatRoom.builder() - .spotId(spotId) - .type(ChatRoomType.GROUP) - .isDeleted(false) - .build() - )); + ChatRoom room = findOrCreateGroupRoomForSpot(spotId); if (participantUserIds != null) { for (String userId : participantUserIds) { ensureMember(room.getId(), userId); @@ -267,6 +289,33 @@ public ChatRoom ensureGroupRoomForSpot(String spotId, Collection partici return room; } + /** + * spot 의 GROUP 방을 조회 또는 생성. partial unique index + * (spot_id WHERE type=GROUP AND is_deleted=false) 와 짝이 되어 동시 요청에서도 + * 정확히 1 개의 방만 살아남도록 보장한다. + */ + private ChatRoom findOrCreateGroupRoomForSpot(String spotId) { + return chatRoomRepository + .findFirstBySpotIdAndTypeAndIsDeletedFalse(spotId, ChatRoomType.GROUP) + .orElseGet(() -> { + try { + return chatRoomRepository.save( + ChatRoom.builder() + .spotId(spotId) + .type(ChatRoomType.GROUP) + .isDeleted(false) + .build() + ); + } catch (DataIntegrityViolationException race) { + // 다른 트랜잭션이 먼저 만들었음 — partial unique index 가 두 번째 insert 를 막음. + // 재조회하여 idempotent 한 결과를 돌려준다. + return chatRoomRepository + .findFirstBySpotIdAndTypeAndIsDeletedFalse(spotId, ChatRoomType.GROUP) + .orElseThrow(() -> race); + } + }); + } + // ───────────────────────────────────────────── // 메시지 (Message) // ───────────────────────────────────────────── diff --git a/capstone-api/src/main/java/backend/spot/controller/SpotController.java b/capstone-api/src/main/java/backend/spot/controller/SpotController.java index 589fb82..93979ca 100644 --- a/capstone-api/src/main/java/backend/spot/controller/SpotController.java +++ b/capstone-api/src/main/java/backend/spot/controller/SpotController.java @@ -280,10 +280,13 @@ private String resolveCurrentUserId(Object principal, Authentication authenticat } // JWTFilter 가 principal 에 email (String) 만 셋팅하는 현 상태를 우회. // TODO: JWTFilter 자체가 CustomUserDetails 를 셋팅하도록 리팩터링되면 본 분기 제거. - if (principal instanceof String emailOrId) { - return userRepository.findByEmail(emailOrId) + if (principal instanceof String email) { + // email 을 userId 로 그대로 흘려보내면 author/sender 등에 email 이 박혀 + // 멤버십 조회와 join 이 모두 깨진다. 매칭되는 user 가 없으면 null 반환하여 + // 비인증 흐름을 타도록 한다 (ChatController#currentUserId 와 동일 정책). + return userRepository.findByEmail(email) .map(UserEntity::getId) - .orElse(emailOrId); + .orElse(null); } return null; diff --git a/capstone-api/src/main/java/backend/spot/service/SpotService.java b/capstone-api/src/main/java/backend/spot/service/SpotService.java index f472cb3..a4bdfc2 100644 --- a/capstone-api/src/main/java/backend/spot/service/SpotService.java +++ b/capstone-api/src/main/java/backend/spot/service/SpotService.java @@ -157,6 +157,7 @@ public SpotResponse matchSpot(String spotId) { memberUserIds.add(spot.getAuthorId()); } spotParticipantRepository.findBySpotId(spotId).stream() + .filter(p -> p.getState() == ParticipantState.ACTIVE) .map(p -> p.getUserId()) .filter(uid -> uid != null && !uid.isBlank()) .forEach(memberUserIds::add); diff --git a/capstone-domain/src/main/java/backend/chat/repository/ChatRoomMemberRepository.java b/capstone-domain/src/main/java/backend/chat/repository/ChatRoomMemberRepository.java index 102fcc1..531e381 100644 --- a/capstone-domain/src/main/java/backend/chat/repository/ChatRoomMemberRepository.java +++ b/capstone-domain/src/main/java/backend/chat/repository/ChatRoomMemberRepository.java @@ -27,13 +27,22 @@ public interface ChatRoomMemberRepository extends JpaRepository findUserIdsByChatRoomId(@Param("chatRoomId") Long chatRoomId); /** - * 두 유저가 정확히 둘만 멤버로 있는 PERSONAL 방 ID 를 조회. + * 두 유저가 정확히 둘만 멤버로 있는 PERSONAL 활성 방 ID 를 조회. * PERSONAL 재사용 정책 (A↔B 채팅 다시 시작 시 기존 방 반환) 의 핵심 쿼리. + * + *

ChatRoom 의 type/isDeleted 도 함께 필터하여, GROUP 방이나 soft-deleted 방이 + * 우연히 동일한 두 유저만 멤버로 가지더라도 잘못 재사용되지 않도록 한다. */ @Query(""" select m.chatRoomId from ChatRoomMember m - where m.chatRoomId in ( + where exists ( + select 1 from ChatRoom r + where r.id = m.chatRoomId + and r.type = backend.chat.entity.ChatRoomType.PERSONAL + and r.isDeleted = false + ) + and m.chatRoomId in ( select m2.chatRoomId from ChatRoomMember m2 where m2.userId in (:userA, :userB) group by m2.chatRoomId diff --git a/docs/migrations/2026-05-15_chat_room_member.sql b/docs/migrations/2026-05-15_chat_room_member.sql index b25fd56..8deb458 100644 --- a/docs/migrations/2026-05-15_chat_room_member.sql +++ b/docs/migrations/2026-05-15_chat_room_member.sql @@ -57,8 +57,12 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_chat_room_group_spot ON chat_rooms (spot_id) WHERE type = 'GROUP' AND is_deleted = false AND spot_id IS NOT NULL; --- 4) 백필: 기존 chat_messages 의 sender_id 를 해당 방 멤버로 등록 --- (방장/단일 참여자 등 메타데이터가 없어도 sender 는 사실상 멤버였음) +-- 4) 백필: 기존 chat_messages 의 sender_id 를 해당 방 멤버로 등록. +-- 한계: 메시지를 보낸 적이 없는 과거 멤버 (예: 입장만 하고 침묵한 유저) 는 본 백필에서 +-- 누락된다. 본 PR 이전 단계에는 멤버십 메타데이터가 존재하지 않았기 때문에 다른 +-- 백필 소스를 활용할 수 없다. 운영 환경에서 침묵 유저 명단이 다른 곳 (예: SpotParticipant) +-- 에 남아 있다면 본 스크립트 외에 보강 백필을 별도 수행해야 한다. +-- 현재 컴포지션 (chat 도메인 단독, 메시지 보낸 자만 사실상 참여자) 에서는 충분. INSERT INTO chat_room_members (chat_room_id, user_id, joined_at) SELECT m.room_id, m.sender_id, MIN(m.created_at) FROM chat_messages m