Skip to content
Merged

Test #725

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 @@ -21,6 +21,7 @@
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

Expand Down Expand Up @@ -82,4 +83,14 @@ public ResponseEntity<Void> cancelProgress(
chatRoomService.cancelProgress(customOAuth2User.getMember(), chatRoomId);
return ResponseEntity.ok().build();
}

@Override
@PostMapping("/{chat-room-id}/leave")
@LogMonitoring
public ResponseEntity<Void> leaveChatRoom(
@AuthenticationPrincipal CustomOAuth2User customOAuth2User,
@PathVariable("chat-room-id") String chatRoomId) {
chatRoomService.leaveChatRoom(customOAuth2User.getMember(), chatRoomId);
return ResponseEntity.ok().build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -287,4 +287,42 @@ ResponseEntity<ApplicationFormInfoResponse> chatRoomApplicationFormInfo(
"""
)
ResponseEntity<Void> cancelProgress(CustomOAuth2User customOAuth2User, String chatRoomId);

@ApiChangeLogs({
@ApiChangeLog(
date = "2026-01-28",
author = "mr6208",
description = "채팅방 나가기 API 설계",
issueUrl = ""
)
})
@Operation(
summary = "채팅방 나가기 기능",
description = """

이 API는 인증이 필요합니다.

### 요청 파라미터
- **chat-room-id (String)** : 채팅방 고유 ID [필수]

### 반환 데이터
- 상태코드만을 반환합니다.

### 변경된 중요한 부분들
- 카카오톡 오픈채팅을 레퍼런스로 참고해서 최대한 비슷하게 설계했습니다.
- 1:1 채팅도중 상대방이 나갔을 시 내 기준에서는 과거 채팅목록 조회가 가능하지만 채팅은 불가합니다.
- 상대방 기준에서는(나간사람) 채팅방 목록에 나간 채팅방이 보이지 않고 채팅방 내부 조회 자체가 불가능합니다.
- 이런 요구사항을 반영하기 위해 기존 응답데이터에 대한 변경이 이루어졌습니다.

### 아래는 채팅방 리스트업 및 채팅방 내부 조회시 추가된 데이터입니다.
```json
{
"chatEnabled(현재 채팅이 가능한지 알려주는 플래그)": "true/false",
"opponentLeft(현재 채팅방에서 상대방이 나갔는지를 알려주는 플래그)": true/false
}
```

"""
)
ResponseEntity<Void> leaveChatRoom(CustomOAuth2User customOAuth2User, String chatRoomId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,13 @@ public class ChatRoomContextResponse {
private List<TicketOpenDateInfoResponse> ticketOpenDateInfoResponseList; // 티켓 오픈일 List
private TicketReservationSite ticketReservationSite; // 예매처
private ConcertType concertType; // 공연 카테고리
private boolean chatEnabled; // 현재 채팅이 가능한지
private boolean opponentLeft; // 상대방이 나갔는지

@Builder
public ChatRoomContextResponse(String chatRoomId, UUID opponentMemberId, UUID fulfillmentFormId, String opponentMemberNickName, String concertName, String concertThumbnailUrl,
TicketOpenType ticketOpenType, List<TicketOpenDateInfoResponse> ticketOpenDateInfoResponseList, TicketReservationSite ticketReservationSite, ConcertType concertType) {
TicketOpenType ticketOpenType, List<TicketOpenDateInfoResponse> ticketOpenDateInfoResponseList, TicketReservationSite ticketReservationSite, ConcertType concertType, boolean chatEnabled,
boolean opponentLeft) {
this.chatRoomId = chatRoomId;
this.opponentMemberId = opponentMemberId;
this.fulfillmentFormId = fulfillmentFormId;
Expand All @@ -38,5 +41,7 @@ public ChatRoomContextResponse(String chatRoomId, UUID opponentMemberId, UUID fu
this.ticketOpenDateInfoResponseList = ticketOpenDateInfoResponseList;
this.ticketReservationSite = ticketReservationSite;
this.concertType = concertType;
this.chatEnabled = chatEnabled;
this.opponentLeft = opponentLeft;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
@Setter
@NoArgsConstructor
public class ChatRoomResponse {

private String chatRoomId; // 채팅방 PK
private String chatRoomName; // 상대방 닉네임 출력
private String lastChatMessage; // 마지막 채팅 메시지
Expand All @@ -19,10 +20,12 @@ public class ChatRoomResponse {
private String concertThumbnailUrl; // 콘서트 썸네일 사진
private TicketOpenType ticketOpenType; // 선예매/일예 구분
private int unReadMessageCount; // 읽지 않은 메시지 개수
private boolean chatEnabled; // 메시지 전송 가능 여부
private boolean opponentLeft; // 상대가 나갔는지(전송불가)

@Builder
public ChatRoomResponse(String chatRoomId, String chatRoomName, String lastChatMessage, LocalDateTime lastChatSendTime, String profileUrl, String concertThumbnailUrl,
TicketOpenType ticketOpenType, int unReadMessageCount) {
public ChatRoomResponse(String chatRoomId, String chatRoomName, String lastChatMessage, LocalDateTime lastChatSendTime, String profileUrl, String concertThumbnailUrl, TicketOpenType ticketOpenType,
int unReadMessageCount, boolean chatEnabled, boolean opponentLeft) {
this.chatRoomId = chatRoomId;
this.chatRoomName = chatRoomName;
this.lastChatMessage = lastChatMessage;
Expand All @@ -31,5 +34,7 @@ public ChatRoomResponse(String chatRoomId, String chatRoomName, String lastChatM
this.concertThumbnailUrl = concertThumbnailUrl;
this.ticketOpenType = ticketOpenType;
this.unReadMessageCount = unReadMessageCount;
this.chatEnabled = chatEnabled;
this.opponentLeft = opponentLeft;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ public ChatRoomResponse toChatRoomResponse(ChatRoom chatRoom, Member member, Map
String concertThumbnailStoredPath = applicationForm.getConcert().getConcertThumbnailStoredPath();
String profileImgStoredPath = other.getProfileImgStoredPath();

boolean chatEnabled = chatRoom.canChat();
boolean opponentLeft = chatRoom.isLeft(opponentId);

return ChatRoomResponse.builder()
.unReadMessageCount(unRead)
.chatRoomId(chatRoom.getChatRoomId())
Expand All @@ -65,12 +68,19 @@ public ChatRoomResponse toChatRoomResponse(ChatRoom chatRoom, Member member, Map
.concertThumbnailUrl(storageService.generatePublicUrl(concertThumbnailStoredPath))
.lastChatSendTime(TimeUtil.toLocalDateTime(chatRoom.getLastMessageTime()))
.profileUrl(storageService.generatePublicUrl(profileImgStoredPath))
.chatEnabled(chatEnabled)
.opponentLeft(opponentLeft)
.build();
}

@Override
public ChatRoomContextResponse toChatRoomContextResponse(ChatRoom chatRoom, UUID currentMemberId,
UUID fulfillmentFormId, TicketOpenType ticketOpenType, String opponentMemberNickname, ConcertInfoResponse response) {

UUID opponentId = chatRoom.getOpponentId(currentMemberId);
boolean chatEnabled = chatRoom.canChat();
boolean opponentLeft = chatRoom.isLeft(opponentId);

return ChatRoomContextResponse.builder()
.concertName(response.concertName())
.concertType(response.concertType())
Expand All @@ -82,6 +92,8 @@ public ChatRoomContextResponse toChatRoomContextResponse(ChatRoom chatRoom, UUID
.ticketOpenDateInfoResponseList(response.ticketOpenDateInfoResponseList())
.chatRoomId(chatRoom.getChatRoomId())
.ticketOpenType(ticketOpenType)
.chatEnabled(chatEnabled)
.opponentLeft(opponentLeft)
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,8 @@ private String formattingSendDate(LocalDateTime dateTime) {
* 채팅 메시지 저장 + Redis를 이용해 읽지않은 메시지 count, 및 브로드캐스팅을 관리하는 메서드
*/
private ChatMessage handleNewChatMessage(Member sender, ChatMessageRequest request, ChatRoom chatRoom) {
// 현재 채팅을 진행할 수 있는지 검증
validateCanChat(chatRoom);

ChatMessage message = saveChatMessage(sender, request, chatRoom);

Expand Down Expand Up @@ -466,4 +468,11 @@ private String toPreview(ChatMessage chatMessage) {
case UPDATE_FULFILLMENT_FORM -> ChatMessageType.UPDATE_FULFILLMENT_FORM.getDescription();
};
}

// 현재 채팅을 진행 수 있는 상태인지(한명이라도 나가면 채팅 불가)
private void validateCanChat(ChatRoom room) {
if (!room.canChat()) {
throw new CustomException(ErrorCode.CHAT_DISABLED);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import com.ticketmate.backend.concert.infrastructure.entity.Concert;
import com.ticketmate.backend.member.infrastructure.entity.Member;
import com.ticketmate.backend.member.infrastructure.repository.MemberRepository;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
Expand Down Expand Up @@ -169,8 +170,8 @@ public Slice<ChatMessageResponse> getChatMessage(Member member, String chatRoomI
ChatRoom chatRoom = chatRoomRepository.findById(chatRoomId)
.orElseThrow(() -> new CustomException(ErrorCode.CHAT_ROOM_NOT_FOUND));

// 채팅방 내부 참가자 유효성 검증
validateRoomMember(chatRoom, member);
// 채팅방 내부 참가자 유효성 검증 및 채팅방 유효성 검증
validateActiveParticipant(chatRoom, member);

// 채팅메시지 전용 페이지네이션 객체 생성
Pageable pageable = request.toPageable();
Expand Down Expand Up @@ -224,6 +225,26 @@ public void cancelProgress(Member member, String chatRoomId) {
applicationForm.setApplicationFormStatus(ApplicationFormStatus.CANCELED_IN_PROCESS);
}

// 채팅방 나가기 기능
@Transactional
public void leaveChatRoom(Member member, String chatRoomId) {
ChatRoom chatRoom = findChatRoomById(chatRoomId);
validateRoomMember(chatRoom, member);

UUID memberId = member.getMemberId();
if (chatRoom.isLeft(memberId)) {
return;
}

chatRoom.leave(memberId, Instant.now());
chatRoomRepository.save(chatRoom);

// unread 키 삭제
String key = (UN_READ_MESSAGE_COUNTER_KEY).formatted(chatRoomId, memberId);
redisTemplate.delete(key);
log.debug("채팅방 나가기 완료. 채팅방 상태 : {}, 나간 시간: {}", chatRoom.getRoomStatus(), chatRoom.getClosedDate());
}

/**
* 방 참가자 검증
*/
Expand All @@ -235,6 +256,14 @@ public void validateRoomMember(ChatRoom room, Member member) {
}
}

private void validateActiveParticipant(ChatRoom room, Member member) {
validateRoomMember(room, member);

if (room.isLeft(member.getMemberId())) {
throw new CustomException(ErrorCode.CHAT_ROOM_LEFT);
}
}

/**
* 채팅방 조회 공용 메서드
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.ticketmate.backend.chat.core.constant;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public enum ChatRoomStatus {
ACTIVE("열린 채팅방"),

CLOSED("닫힌 채팅방");

private final String description;

}
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package com.ticketmate.backend.chat.infrastructure.entity;

import com.ticketmate.backend.chat.core.constant.ChatMessageType;
import com.ticketmate.backend.chat.core.constant.ChatRoomStatus;
import com.ticketmate.backend.common.application.exception.CustomException;
import com.ticketmate.backend.common.application.exception.ErrorCode;
import com.ticketmate.backend.common.infrastructure.persistence.BaseMongoDocument;
import com.ticketmate.backend.concert.core.constant.TicketOpenType;
import java.time.Instant;
import java.util.UUID;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
Expand Down Expand Up @@ -68,6 +72,19 @@ public class ChatRoom extends BaseMongoDocument {

private TicketOpenType ticketOpenType; // 신청폼의 선예매, 일반예매인지

@Indexed
private Instant agentLeftDate; // 대리인 퇴장시간

@Indexed
private Instant clientLeftDate; // 의뢰인 퇴장시간

@Indexed
@Builder.Default
private ChatRoomStatus roomStatus = ChatRoomStatus.ACTIVE; // 한명이라도 나가면 CLOSED 상태

@Indexed
private Instant closedDate;

public void updateLastMessage(String message) {
this.lastMessage = message;
}
Expand All @@ -88,4 +105,40 @@ public void updateLastMessageType(ChatMessageType chatMessageType) {
public UUID getOpponentId(UUID currentMemberId) {
return currentMemberId.equals(agentMemberId) ? clientMemberId : agentMemberId;
}

public boolean isLeft(UUID memberId) {
if (memberId.equals(agentMemberId)) {
return agentLeftDate != null;
}
if (memberId.equals(clientMemberId)) {
return clientLeftDate != null;
}
return false;
}

public boolean isParticipant(UUID memberId) {
return memberId.equals(agentMemberId) || memberId.equals(clientMemberId);
}

public boolean canChat() {
// 상대가 나갔을시 남아있는 사람도 채팅 불가
return roomStatus == ChatRoomStatus.ACTIVE && agentLeftDate == null && clientLeftDate == null;
}

// 채팅방을 나갈 시 동작하는 메서드
public void leave(UUID memberId, Instant now) {
if (memberId.equals(agentMemberId)) {
agentLeftDate = now;
} else if (memberId.equals(clientMemberId)) {
clientLeftDate = now;
} else {
throw new CustomException(ErrorCode.NO_AUTH_TO_ROOM);
}

// 한 명이라도 나가면 방은 CLOSED
if (roomStatus != ChatRoomStatus.CLOSED) {
roomStatus = ChatRoomStatus.CLOSED;
closedDate = now;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,17 +34,18 @@ public Page<ChatRoom> search(TicketOpenType ticketOpenType, String keyword, Memb

log.debug("선예매 일반예매 구분 : {}", ticketOpenType);

// 1. 내가 ‘대리인’으로 들어가 있는 방 조건
Criteria asAgent = Criteria.where("agentMemberId").is(memberId);
// 1. 내가 ‘대리인’으로 들어가 있는 방 조건 + 나가지 않은 채팅방(나간시점의 기록이 없는)
Criteria asAgent = Criteria.where("agentMemberId").is(memberId).and("agentLeftAt").is(null);

if (ticketOpenType != null) {
asAgent = asAgent.and("ticketOpenType").is(ticketOpenType);
}
if (!keyword.isEmpty()) {
asAgent = asAgent.and("clientMemberNickname").regex(keyword, "i");
}

// 2. 내가 ‘의뢰인’으로 들어가 있는 방 조건
Criteria asClient = Criteria.where("clientMemberId").is(memberId);
// 2. 내가 ‘의뢰인’으로 들어가 있는 방 조건 + 위와 동일
Criteria asClient = Criteria.where("clientMemberId").is(memberId).and("clientLeftAt").is(null);
if (ticketOpenType != null) {
asClient = asClient.and("ticketOpenType").is(ticketOpenType);
}
Expand All @@ -57,9 +58,9 @@ public Page<ChatRoom> search(TicketOpenType ticketOpenType, String keyword, Memb

// 마지막 채팅 메시지 시간 기준 내림차순 정렬 (6개씩)
Query query = Query.query(criteria)
.with(Sort.by(Sort.Direction.DESC, "lastMessageTime"))
.skip((long) pageNumber * PageableConstants.DEFAULT_PAGE_SIZE)
.limit(PageableConstants.DEFAULT_PAGE_SIZE);
.with(Sort.by(Sort.Direction.DESC, "lastMessageTime"))
.skip((long) pageNumber * PageableConstants.DEFAULT_PAGE_SIZE)
.limit(PageableConstants.DEFAULT_PAGE_SIZE);

List<ChatRoom> chatRoomList = mongoTemplate.find(query, ChatRoom.class);
log.debug("조회된 채팅방 객체 개수 : {}", chatRoomList.size());
Expand All @@ -68,7 +69,7 @@ public Page<ChatRoom> search(TicketOpenType ticketOpenType, String keyword, Memb
log.debug("Count로 조회된 채팅방 개수 : {}", totalCount);

return new PageImpl<>(chatRoomList,
PageRequest.of(pageNumber, PageableConstants.DEFAULT_PAGE_SIZE),
totalCount);
PageRequest.of(pageNumber, PageableConstants.DEFAULT_PAGE_SIZE),
totalCount);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,10 @@ public enum ErrorCode {

CHAT_MESSAGE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "채팅 메시지 전송중 오류가 발생했습니다."),

CHAT_ROOM_LEFT(HttpStatus.NOT_FOUND, "채팅방을 나가서 존재하지 않는 채팅방입니다."),

CHAT_DISABLED(HttpStatus.BAD_REQUEST, "해당 채팅방은 상대방이 채팅방을 나가 채팅을 진행할 수 없습니다."),

// EMBEDDING

EMBEDDING_API_ERROR(HttpStatus.BAD_REQUEST, "Vertex AI API 호출에 실패했습니다."),
Expand Down
Loading