Skip to content
Merged
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,7 @@ out/

.env
src/main/resources/logback.xml

# AI tools
.claude/
.agents/
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.5'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'org.postgresql:postgresql'
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/app/dearobjet/backend/BackendApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableScheduling
public class BackendApplication {

public static void main(String[] args) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package app.dearobjet.backend.domain.chat.controller;

import app.dearobjet.backend.domain.chat.dto.ChatMessageResponse;
import app.dearobjet.backend.domain.chat.dto.ChatMessageCursorResponse;
import app.dearobjet.backend.domain.chat.dto.SendMessageRequest;
import app.dearobjet.backend.domain.chat.service.ChatMessageService;
import app.dearobjet.backend.domain.chat.service.ChatParticipantService;
import app.dearobjet.backend.global.api.ApiResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;

/**
* 채팅 메시지 REST API 컨트롤러
*/
@RestController
@RequestMapping("/api/chat")
@RequiredArgsConstructor
public class ChatMessageController {

private final ChatMessageService chatMessageService;
private final ChatParticipantService chatParticipantService;

/**
* 메시지 전송 (REST API 방식)
* WebSocket을 사용할 수 없는 환경에서 폴백으로 사용
*
* POST /api/chat/rooms/{roomId}/messages
*/
@PostMapping("/rooms/{roomId}/messages")
public ApiResponse<ChatMessageResponse> sendMessage(
@AuthenticationPrincipal(expression = "userId") Long userId,
@PathVariable String roomId,
@Valid @RequestBody SendMessageRequest request) {

ChatMessageResponse message = chatMessageService.sendMessage(userId, roomId, request);
return ApiResponse.of(message);
}

/**
* 최신 메시지 N개 조회 (커서 초기화)
*
* GET /api/chat/rooms/{roomId}/messages/latest?limit=50
*/
@GetMapping("/rooms/{roomId}/messages/latest")
public ApiResponse<ChatMessageCursorResponse> getLatestMessages(
@AuthenticationPrincipal(expression = "userId") Long userId,
@PathVariable String roomId,
@RequestParam(defaultValue = "50") int limit) {

ChatMessageCursorResponse response = chatMessageService.getLatestMessages(roomId, userId, limit);
return ApiResponse.of(response);
}

/**
* 누락 메시지 동기화 (재접속 복구)
*
* GET /api/chat/rooms/{roomId}/messages/sync?afterMessageId=123&limit=200
*/
@GetMapping("/rooms/{roomId}/messages/sync")
public ApiResponse<ChatMessageCursorResponse> syncMessages(
@AuthenticationPrincipal(expression = "userId") Long userId,
@PathVariable String roomId,
@RequestParam(defaultValue = "0") long afterMessageId,
@RequestParam(defaultValue = "200") int limit) {

ChatMessageCursorResponse response = chatMessageService.syncMessages(roomId, userId, afterMessageId, limit);
return ApiResponse.of(response);
}

/**
* 과거 메시지 더보기
*
* GET /api/chat/rooms/{roomId}/messages/before?beforeMessageId=123&limit=50
*/
@GetMapping("/rooms/{roomId}/messages/before")
public ApiResponse<ChatMessageCursorResponse> getMessagesBefore(
@AuthenticationPrincipal(expression = "userId") Long userId,
@PathVariable String roomId,
@RequestParam long beforeMessageId,
@RequestParam(defaultValue = "50") int limit) {

ChatMessageCursorResponse response = chatMessageService.getMessagesBefore(roomId, userId, beforeMessageId, limit);
return ApiResponse.of(response);
}

/**
* 채팅방 메시지 히스토리 조회
*
* GET /api/chat/rooms/{roomId}/messages?page=0&size=20
*/
@GetMapping("/rooms/{roomId}/messages")
public ApiResponse<List<ChatMessageResponse>> getMessages(
@AuthenticationPrincipal(expression = "userId") Long userId,
@PathVariable String roomId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {

List<ChatMessageResponse> messages = chatMessageService.getMessages(roomId, userId, page, size);
return ApiResponse.of(messages);
}

/**
* 읽음 처리
*
* POST /api/chat/rooms/{roomId}/read
*/
@PostMapping("/rooms/{roomId}/read")
public ApiResponse<Void> markAsRead(
@AuthenticationPrincipal(expression = "userId") Long userId,
@PathVariable String roomId) {

chatMessageService.markAsRead(userId, roomId);
return ApiResponse.of(null);
}

/**
* 사용자의 전체 읽지 않은 메시지 수 조회
*
* GET /api/chat/unread/total
*/
@GetMapping("/unread/total")
public ApiResponse<Map<String, Integer>> getTotalUnreadCount(
@AuthenticationPrincipal(expression = "userId") Long userId) {

int totalUnread = chatParticipantService.getTotalUnreadCount(userId);
return ApiResponse.of(Map.of("totalUnread", totalUnread));
}

/**
* 특정 채팅방의 읽지 않은 메시지 수 조회
*
* GET /api/chat/rooms/{roomId}/unread
*/
@GetMapping("/rooms/{roomId}/unread")
public ApiResponse<Map<String, Integer>> getUnreadCount(
@AuthenticationPrincipal(expression = "userId") Long userId,
@PathVariable String roomId) {

int unreadCount = chatParticipantService.getUnreadCount(userId, roomId);
return ApiResponse.of(Map.of("unreadCount", unreadCount));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package app.dearobjet.backend.domain.chat.controller;

import app.dearobjet.backend.domain.chat.dto.ChatRoomResponse;
import app.dearobjet.backend.domain.chat.dto.CreateChatRoomRequest;
import app.dearobjet.backend.domain.chat.service.ChatRoomService;
import app.dearobjet.backend.global.api.ApiResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

import java.util.List;

/**
* 채팅방 REST API 컨트롤러
*/
@RestController
@RequestMapping("/api/chat/rooms")
@RequiredArgsConstructor
public class ChatRoomController {

private final ChatRoomService chatRoomService;

/**
* 채팅방 생성 또는 기존 채팅방 조회
*
* POST /api/chat/rooms
*/
@PostMapping
public ApiResponse<ChatRoomResponse> createOrGetChatRoom(
@AuthenticationPrincipal(expression = "userId") Long userId,
@Valid @RequestBody CreateChatRoomRequest request) {

ChatRoomResponse chatRoom = chatRoomService.createOrGetChatRoom(userId, request);
return ApiResponse.of(chatRoom);
}

/**
* 내 채팅방 목록 조회
*
* GET /api/chat/rooms
*/
@GetMapping
public ApiResponse<List<ChatRoomResponse>> getMyChatRooms(
@AuthenticationPrincipal(expression = "userId") Long userId) {

List<ChatRoomResponse> chatRooms = chatRoomService.getMyChatRooms(userId);
return ApiResponse.of(chatRooms);
}

/**
* 채팅방 상세 조회
*
* GET /api/chat/rooms/{roomId}
*/
@GetMapping("/{roomId}")
public ApiResponse<ChatRoomResponse> getChatRoom(
@AuthenticationPrincipal(expression = "userId") Long userId,
@PathVariable String roomId) {

ChatRoomResponse chatRoom = chatRoomService.getChatRoom(roomId, userId);
return ApiResponse.of(chatRoom);
}

/**
* 1:1 채팅방 생성 (상대방 ID로 바로 생성)
*
* POST /api/chat/rooms/direct/{partnerId}
*/
@PostMapping("/direct/{partnerId}")
public ApiResponse<ChatRoomResponse> createDirectChatRoom(
@AuthenticationPrincipal(expression = "userId") Long userId,
@PathVariable Long partnerId) {

CreateChatRoomRequest request = CreateChatRoomRequest.oneToOne(partnerId);
ChatRoomResponse chatRoom = chatRoomService.createOrGetChatRoom(userId, request);
return ApiResponse.of(chatRoom);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package app.dearobjet.backend.domain.chat.dto;

import lombok.AllArgsConstructor;
import lombok.Getter;

import java.util.List;

/**
* 커서 기반 메시지 조회 응답
* - 재접속/누락 복구(sync), 최초 진입(latest), 과거 더보기(before)에서 공통으로 사용
*/
@Getter
@AllArgsConstructor
public class ChatMessageCursorResponse {
private final String roomId;
private final Long oldestMessageId;
private final Long latestMessageId;
private final List<ChatMessageResponse> messages;
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package app.dearobjet.backend.domain.chat.dto;

import app.dearobjet.backend.domain.chat.entity.ChatMessage;
import app.dearobjet.backend.domain.chat.entity.MessageType;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

/**
* 채팅 메시지 응답 DTO
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ChatMessageResponse {

private Long id;
private String roomId;
private Long senderId;
private String senderName;
private String senderProfileImage;
private String content;
private MessageType messageType;
private LocalDateTime createdAt;
private boolean isMyMessage;

/**
* 엔티티로부터 DTO 생성
*/
public static ChatMessageResponse from(ChatMessage message, Long currentUserId) {
return ChatMessageResponse.builder()
.id(message.getId())
.roomId(message.getRoomId())
.senderId(message.getSender() != null ? message.getSender().getId() : null)
.senderName(message.getSender() != null ? message.getSender().getName() : "시스템")
.senderProfileImage(message.getSender() != null ? message.getSender().getProfileImage() : null)
.content(message.getContent())
.messageType(message.getMessageType())
.createdAt(message.getCreatedAt())
.isMyMessage(message.getSender() != null && message.getSender().getId().equals(currentUserId))
.build();
}

/**
* Redis 메시지로부터 DTO 생성
*/
public static ChatMessageResponse from(RedisChatMessageDto redisMessage, Long currentUserId) {
return ChatMessageResponse.builder()
.id(redisMessage.getMessageId())
.roomId(redisMessage.getRoomId())
.senderId(redisMessage.getSenderId())
.senderName(redisMessage.getSenderName())
.content(redisMessage.getContent())
.messageType(redisMessage.getMessageType())
.createdAt(redisMessage.getTimestamp())
.isMyMessage(redisMessage.getSenderId() != null && redisMessage.getSenderId().equals(currentUserId))
.build();
}
}
Loading