Skip to content
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ dependencies {
// Image
implementation("com.sksamuel.scrimage:scrimage-core:4.3.5")
implementation("com.sksamuel.scrimage:scrimage-webp:4.3.5")

// WebSocket + STOMP
implementation 'org.springframework.boot:spring-boot-starter-websocket'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
* Copyright (c) SKU 다시입을Lab
*/
package com.sku.refit.domain.chat.controller;

/*
* Copyright (c) SKU 다시입을Lab
*/

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;

import com.sku.refit.domain.chat.dto.response.ChatMessageResponse;
import com.sku.refit.domain.chat.dto.response.ChatRoomResponse;
import com.sku.refit.global.page.response.InfiniteResponse;
import com.sku.refit.global.response.BaseResponse;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;

@Tag(name = "채팅", description = "채팅 관련 API")
@RequestMapping("/api/chats")
public interface ChatController {

@PostMapping("/exchange/{postId}")
@Operation(summary = "새 채팅방 생성", description = "특정 교환 게시글의 채팅방을 생성합니다.")
ResponseEntity<BaseResponse<ChatRoomResponse>> createChatRoom(
@Parameter(description = "채팅방을 생성할 교환글 식별자", example = "1") @PathVariable Long postId);

@GetMapping("/rooms")
@Operation(summary = "채팅방 조회", description = "사용자의 채팅방 내역을 조회합니다.")
ResponseEntity<BaseResponse<InfiniteResponse<ChatRoomResponse>>> getMyChatRooms(
@Parameter(description = "마지막으로 조회한 채팅방 식별자(첫 조회 시 생략)", example = "5")
@RequestParam(required = false)
Long lastChatRoomId,
@Parameter(description = "한 번에 조회할 채팅방 개수", example = "5") @RequestParam(defaultValue = "5")
Integer size);

@GetMapping("/rooms/{roomId}/messages")
ResponseEntity<BaseResponse<InfiniteResponse<ChatMessageResponse>>> getMessages(
@Parameter(description = "채팅방 식별자", example = "1") @PathVariable Long roomId,
@Parameter(description = "마지막으로 조회한 채팅 식별자(첫 조회 시 생략)", example = "10")
@RequestParam(required = false)
Long lastChatId,
@Parameter(description = "한 번에 조회할 채팅 개수", example = "10") @RequestParam(defaultValue = "10")
Integer size);

@PutMapping("/rooms/{roomId}/read")
ResponseEntity<BaseResponse<Void>> readMessages(
@Parameter(description = "채팅방 식별자", example = "1") @PathVariable Long roomId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* Copyright (c) SKU 다시입을Lab
*/
package com.sku.refit.domain.chat.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;

import com.sku.refit.domain.chat.dto.response.ChatMessageResponse;
import com.sku.refit.domain.chat.dto.response.ChatRoomResponse;
import com.sku.refit.domain.chat.service.ChatService;
import com.sku.refit.global.page.response.InfiniteResponse;
import com.sku.refit.global.response.BaseResponse;

import lombok.RequiredArgsConstructor;

@RestController
@RequiredArgsConstructor
public class ChatControllerImpl implements ChatController {

private final ChatService chatService;

@Override
public ResponseEntity<BaseResponse<ChatRoomResponse>> createChatRoom(Long postId) {

ChatRoomResponse response = chatService.createChatRoom(postId);
return ResponseEntity.ok(BaseResponse.success(response));
}

@Override
public ResponseEntity<BaseResponse<InfiniteResponse<ChatRoomResponse>>> getMyChatRooms(
Long lastChatRoomId, Integer size) {

return ResponseEntity.ok(
BaseResponse.success(chatService.getMyChatRooms(lastChatRoomId, size)));
}

@Override
public ResponseEntity<BaseResponse<InfiniteResponse<ChatMessageResponse>>> getMessages(
Long roomId, Long lastChatId, Integer size) {

return ResponseEntity.ok(
BaseResponse.success(chatService.getMessages(roomId, lastChatId, size)));
}

@Override
public ResponseEntity<BaseResponse<Void>> readMessages(Long roomId) {

chatService.readMessages(roomId);
return ResponseEntity.ok(BaseResponse.success(null));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* Copyright (c) SKU 다시입을Lab
*/
package com.sku.refit.domain.chat.controller;

import java.security.Principal;

import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.stereotype.Controller;

import com.sku.refit.domain.chat.dto.request.ChatMessageRequest;
import com.sku.refit.domain.chat.service.ChatService;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Controller
@RequiredArgsConstructor
@Slf4j
public class ChatMessageController {

private final ChatService chatService;

@MessageMapping("/chat/send")
public void sendMessage(ChatMessageRequest request, Principal principal) {

log.info(
"[WS CONTROLLER] sendMessage 호출됨 roomId={}, principal={}",
request.getRoomId(),
principal != null ? principal.getName() : "null");

if (principal == null) {
log.warn("[WS CONTROLLER] 인증되지 않은 사용자의 메시지 전송 시도");
return;
}

chatService.sendMessage(request, principal);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/*
* Copyright (c) SKU 다시입을Lab
*/
package com.sku.refit.domain.chat.dto.request;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class ChatMessageRequest {

private Long roomId;
private String content;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* Copyright (c) SKU 다시입을Lab
*/
package com.sku.refit.domain.chat.dto.response;

import java.time.LocalDateTime;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Getter;

@Getter
@Builder
@Schema(title = "ChatMessageResponse DTO", description = "채팅 메세지 응답 반환")
public class ChatMessageResponse {

@Schema(description = "채팅 메세지 식별자", example = "1")
private Long messageId;

@Schema(description = "채팅방 식별자", example = "1")
private Long roomId;

@Schema(description = "채팅 발신자", example = "김다입")
private String senderNickname;

@Schema(description = "채팅 내용", example = "안녕하세요. 교환 원하시나요?")
private String content;

@Schema(description = "메세지 작성 시간", example = "20250101T120000")
private LocalDateTime createdAt;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* Copyright (c) SKU 다시입을Lab
*/
package com.sku.refit.domain.chat.dto.response;

import java.time.LocalDateTime;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Getter;

@Getter
@Builder
@Schema(title = "ChatRoomResponse DTO", description = "채팅방 응답 반환")
public class ChatRoomResponse {

@Schema(description = "채팅방 식별자", example = "1")
private Long roomId;

@Schema(description = "교환글 식별자", example = "1")
private Long exchangePostId;

@Schema(description = "수신자 닉네임", example = "김재생")
private String receiverNickname;

@Schema(description = "마지막 메세지", example = "내일 오후 1시에 가능합니다!")
private String lastMessage;

@Schema(description = "마지막 메세지 작성 시간", example = "20250101T120000")
private LocalDateTime lastMessageAt;
}
55 changes: 55 additions & 0 deletions src/main/java/com/sku/refit/domain/chat/entity/ChatMessage.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* Copyright (c) SKU 다시입을Lab
*/
package com.sku.refit.domain.chat.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;

import com.sku.refit.domain.user.entity.User;
import com.sku.refit.global.common.BaseTimeEntity;

import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Table(name = "chat_message")
public class ChatMessage extends BaseTimeEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "chat_room_id", nullable = false)
private ChatRoom chatRoom;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "sender_id", nullable = false)
private User sender;

@Column(nullable = false)
private String content;

@Column(nullable = false)
@Builder.Default
private Boolean isRead = false;

@Column(nullable = false)
@Builder.Default
private Boolean isDeleted = false;
}
78 changes: 78 additions & 0 deletions src/main/java/com/sku/refit/domain/chat/entity/ChatRoom.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/*
* Copyright (c) SKU 다시입을Lab
*/
package com.sku.refit.domain.chat.entity;

import java.time.LocalDateTime;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;

import com.sku.refit.domain.chat.exception.ChatErrorCode;
import com.sku.refit.domain.exchange.entity.ExchangePost;
import com.sku.refit.domain.user.entity.User;
import com.sku.refit.global.common.BaseTimeEntity;
import com.sku.refit.global.exception.CustomException;

import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Table(
name = "chat_room",
uniqueConstraints = {
@UniqueConstraint(columnNames = {"exchange_post_id", "sender_id", "receiver_id"})
})
public class ChatRoom extends BaseTimeEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "exchange_post_id", nullable = false)
private ExchangePost exchangePost;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "sender_id", nullable = false)
private User sender;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "receiver_id", nullable = false)
private User receiver;

@Column(columnDefinition = "TEXT")
@Builder.Default
private String lastMessage = null;

@Column @Builder.Default private LocalDateTime lastMessageAt = LocalDateTime.now();

@Column @Builder.Default private LocalDateTime senderLastReadAt = LocalDateTime.now();

@Column @Builder.Default private LocalDateTime receiverLastReadAt = LocalDateTime.now();

public void markAsRead(Long userId, LocalDateTime time) {
if (sender.getId().equals(userId)) {
this.senderLastReadAt = time;
} else if (receiver.getId().equals(userId)) {
this.receiverLastReadAt = time;
} else {
throw new CustomException(ChatErrorCode.CHAT_NOT_FOUND);
}
}
}
Loading