diff --git a/build.gradle b/build.gradle index 3a450f4..364ef40 100644 --- a/build.gradle +++ b/build.gradle @@ -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') { diff --git a/src/main/java/com/sku/refit/domain/chat/controller/ChatController.java b/src/main/java/com/sku/refit/domain/chat/controller/ChatController.java new file mode 100644 index 0000000..bdbfc59 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/chat/controller/ChatController.java @@ -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> createChatRoom( + @Parameter(description = "채팅방을 생성할 교환글 식별자", example = "1") @PathVariable Long postId); + + @GetMapping("/rooms") + @Operation(summary = "채팅방 조회", description = "사용자의 채팅방 내역을 조회합니다.") + ResponseEntity>> getMyChatRooms( + @Parameter(description = "마지막으로 조회한 채팅방 식별자(첫 조회 시 생략)", example = "5") + @RequestParam(required = false) + Long lastChatRoomId, + @Parameter(description = "한 번에 조회할 채팅방 개수", example = "5") @RequestParam(defaultValue = "5") + Integer size); + + @GetMapping("/rooms/{roomId}/messages") + ResponseEntity>> 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> readMessages( + @Parameter(description = "채팅방 식별자", example = "1") @PathVariable Long roomId); +} diff --git a/src/main/java/com/sku/refit/domain/chat/controller/ChatControllerImpl.java b/src/main/java/com/sku/refit/domain/chat/controller/ChatControllerImpl.java new file mode 100644 index 0000000..8c476a8 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/chat/controller/ChatControllerImpl.java @@ -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> createChatRoom(Long postId) { + + ChatRoomResponse response = chatService.createChatRoom(postId); + return ResponseEntity.ok(BaseResponse.success(response)); + } + + @Override + public ResponseEntity>> getMyChatRooms( + Long lastChatRoomId, Integer size) { + + return ResponseEntity.ok( + BaseResponse.success(chatService.getMyChatRooms(lastChatRoomId, size))); + } + + @Override + public ResponseEntity>> getMessages( + Long roomId, Long lastChatId, Integer size) { + + return ResponseEntity.ok( + BaseResponse.success(chatService.getMessages(roomId, lastChatId, size))); + } + + @Override + public ResponseEntity> readMessages(Long roomId) { + + chatService.readMessages(roomId); + return ResponseEntity.ok(BaseResponse.success(null)); + } +} diff --git a/src/main/java/com/sku/refit/domain/chat/controller/ChatMessageController.java b/src/main/java/com/sku/refit/domain/chat/controller/ChatMessageController.java new file mode 100644 index 0000000..22f03ca --- /dev/null +++ b/src/main/java/com/sku/refit/domain/chat/controller/ChatMessageController.java @@ -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); + } +} diff --git a/src/main/java/com/sku/refit/domain/chat/dto/request/ChatMessageRequest.java b/src/main/java/com/sku/refit/domain/chat/dto/request/ChatMessageRequest.java new file mode 100644 index 0000000..818d9ec --- /dev/null +++ b/src/main/java/com/sku/refit/domain/chat/dto/request/ChatMessageRequest.java @@ -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; +} diff --git a/src/main/java/com/sku/refit/domain/chat/dto/response/ChatMessageResponse.java b/src/main/java/com/sku/refit/domain/chat/dto/response/ChatMessageResponse.java new file mode 100644 index 0000000..184d7f2 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/chat/dto/response/ChatMessageResponse.java @@ -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; +} diff --git a/src/main/java/com/sku/refit/domain/chat/dto/response/ChatRoomResponse.java b/src/main/java/com/sku/refit/domain/chat/dto/response/ChatRoomResponse.java new file mode 100644 index 0000000..3eff351 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/chat/dto/response/ChatRoomResponse.java @@ -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; +} diff --git a/src/main/java/com/sku/refit/domain/chat/entity/ChatMessage.java b/src/main/java/com/sku/refit/domain/chat/entity/ChatMessage.java new file mode 100644 index 0000000..5031c26 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/chat/entity/ChatMessage.java @@ -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; +} diff --git a/src/main/java/com/sku/refit/domain/chat/entity/ChatRoom.java b/src/main/java/com/sku/refit/domain/chat/entity/ChatRoom.java new file mode 100644 index 0000000..12f4a99 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/chat/entity/ChatRoom.java @@ -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); + } + } +} diff --git a/src/main/java/com/sku/refit/domain/chat/exception/ChatErrorCode.java b/src/main/java/com/sku/refit/domain/chat/exception/ChatErrorCode.java new file mode 100644 index 0000000..07e757d --- /dev/null +++ b/src/main/java/com/sku/refit/domain/chat/exception/ChatErrorCode.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.chat.exception; + +import org.springframework.http.HttpStatus; + +import com.sku.refit.global.exception.model.BaseErrorCode; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ChatErrorCode implements BaseErrorCode { + CHAT_NOT_FOUND("CHAT001", "채팅이 존재하지 않습니다.", HttpStatus.NOT_FOUND), + ; + + private final String code; + private final String message; + private final HttpStatus status; +} diff --git a/src/main/java/com/sku/refit/domain/chat/mapper/ChatMapper.java b/src/main/java/com/sku/refit/domain/chat/mapper/ChatMapper.java new file mode 100644 index 0000000..eed5e76 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/chat/mapper/ChatMapper.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.chat.mapper; + +import org.springframework.stereotype.Component; + +import com.sku.refit.domain.chat.dto.response.ChatMessageResponse; +import com.sku.refit.domain.chat.dto.response.ChatRoomResponse; +import com.sku.refit.domain.chat.entity.ChatMessage; +import com.sku.refit.domain.chat.entity.ChatRoom; +import com.sku.refit.domain.exchange.entity.ExchangePost; +import com.sku.refit.domain.user.entity.User; + +@Component +public class ChatMapper { + + public ChatRoom toChatRoom(ExchangePost exchangePost, User sender, User receiver) { + return ChatRoom.builder().exchangePost(exchangePost).sender(sender).receiver(receiver).build(); + } + + public ChatRoomResponse toChatRoomResponse(ChatRoom chatRoom) { + return ChatRoomResponse.builder() + .roomId(chatRoom.getId()) + .exchangePostId(chatRoom.getExchangePost().getId()) + .receiverNickname(chatRoom.getReceiver().getNickname()) + .lastMessage(chatRoom.getLastMessage()) + .lastMessageAt(chatRoom.getLastMessageAt()) + .build(); + } + + public ChatMessageResponse toChatMessageResponse(ChatMessage chatMessage) { + return ChatMessageResponse.builder() + .messageId(chatMessage.getId()) + .roomId(chatMessage.getChatRoom().getId()) + .senderNickname(chatMessage.getSender().getNickname()) + .content(chatMessage.getContent()) + .createdAt(chatMessage.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/com/sku/refit/domain/chat/repository/ChatMessageRepository.java b/src/main/java/com/sku/refit/domain/chat/repository/ChatMessageRepository.java new file mode 100644 index 0000000..1d954e0 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/chat/repository/ChatMessageRepository.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.chat.repository; + +import java.util.List; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import com.sku.refit.domain.chat.entity.ChatMessage; + +@Repository +public interface ChatMessageRepository extends JpaRepository { + + @Query( + """ +select m from ChatMessage m +where m.chatRoom.id = :roomId +and (:lastId is null or m.id < :lastId) +order by m.id desc +""") + List findMessages( + @Param("roomId") Long roomId, @Param("lastId") Long lastId, Pageable pageable); +} diff --git a/src/main/java/com/sku/refit/domain/chat/repository/ChatRoomRepository.java b/src/main/java/com/sku/refit/domain/chat/repository/ChatRoomRepository.java new file mode 100644 index 0000000..3bb9f21 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/chat/repository/ChatRoomRepository.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.chat.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import com.sku.refit.domain.chat.entity.ChatRoom; + +@Repository +public interface ChatRoomRepository extends JpaRepository { + + /** 교환 게시글 기준 채팅방 존재 여부 확인 (중복 생성 방지) */ + Optional findByExchangePostIdAndSenderIdAndReceiverId( + Long exchangePostId, Long senderId, Long receiverId); + + /** 내가 참여한 채팅방 목록 조회 - sender 이거나 receiver 인 경우 - 최신 메시지 기준 정렬 */ + @Query( + """ +select c from ChatRoom c +where (c.sender.id = :userId or c.receiver.id = :userId) +and (:lastId is null or c.id < :lastId) +order by c.lastMessageAt desc nulls last, c.id desc +""") + List findMyChatRooms( + @Param("userId") Long userId, @Param("lastId") Long lastId, Pageable pageable); + + /** 게시글 + 두 유저 기준 채팅방 조회 (sender/receiver 순서 상관없이) */ + @Query( + """ + select cr + from ChatRoom cr + where cr.exchangePost.id = :postId + and ( + (cr.sender.id = :userA and cr.receiver.id = :userB) + or + (cr.sender.id = :userB and cr.receiver.id = :userA) + ) + """) + Optional findByExchangePostIdAndUsers( + @Param("postId") Long postId, @Param("userA") Long userA, @Param("userB") Long userB); +} diff --git a/src/main/java/com/sku/refit/domain/chat/service/ChatService.java b/src/main/java/com/sku/refit/domain/chat/service/ChatService.java new file mode 100644 index 0000000..71175ae --- /dev/null +++ b/src/main/java/com/sku/refit/domain/chat/service/ChatService.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.chat.service; + +import java.security.Principal; + +import com.sku.refit.domain.chat.dto.request.ChatMessageRequest; +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; + +public interface ChatService { + + ChatRoomResponse createChatRoom(Long postId); + + void sendMessage(ChatMessageRequest request, Principal principal); + + InfiniteResponse getMyChatRooms(Long lastChatRoomId, Integer size); + + InfiniteResponse getMessages(Long roomId, Long lastChatId, Integer size); + + void readMessages(Long roomId); +} diff --git a/src/main/java/com/sku/refit/domain/chat/service/ChatServiceImpl.java b/src/main/java/com/sku/refit/domain/chat/service/ChatServiceImpl.java new file mode 100644 index 0000000..35be46f --- /dev/null +++ b/src/main/java/com/sku/refit/domain/chat/service/ChatServiceImpl.java @@ -0,0 +1,171 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.chat.service; + +import java.security.Principal; +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.sku.refit.domain.chat.dto.request.ChatMessageRequest; +import com.sku.refit.domain.chat.dto.response.ChatMessageResponse; +import com.sku.refit.domain.chat.dto.response.ChatRoomResponse; +import com.sku.refit.domain.chat.entity.ChatMessage; +import com.sku.refit.domain.chat.entity.ChatRoom; +import com.sku.refit.domain.chat.exception.ChatErrorCode; +import com.sku.refit.domain.chat.mapper.ChatMapper; +import com.sku.refit.domain.chat.repository.ChatMessageRepository; +import com.sku.refit.domain.chat.repository.ChatRoomRepository; +import com.sku.refit.domain.exchange.entity.ExchangePost; +import com.sku.refit.domain.exchange.exception.ExchangeErrorCode; +import com.sku.refit.domain.exchange.repository.ExchangeRepository; +import com.sku.refit.domain.user.entity.User; +import com.sku.refit.domain.user.exception.UserErrorCode; +import com.sku.refit.domain.user.repository.UserRepository; +import com.sku.refit.domain.user.service.UserService; +import com.sku.refit.global.exception.CustomException; +import com.sku.refit.global.page.mapper.InfiniteMapper; +import com.sku.refit.global.page.response.InfiniteResponse; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ChatServiceImpl implements ChatService { + + private final SimpMessagingTemplate messagingTemplate; + private final ChatRoomRepository chatRoomRepository; + private final ExchangeRepository exchangeRepository; + private final UserRepository userRepository; + private final ChatMessageRepository chatMessageRepository; + private final UserService userService; + private final ChatMapper chatMapper; + private final InfiniteMapper infiniteMapper; + + @Override + @Transactional + public ChatRoomResponse createChatRoom(Long postId) { + + User user = userService.getCurrentUser(); + + ExchangePost post = + exchangeRepository + .findById(postId) + .orElseThrow(() -> new CustomException(ExchangeErrorCode.EXCHANGE_NOT_FOUND)); + + User receiver = post.getUser(); + + if (receiver == null) { + throw new CustomException(ChatErrorCode.CHAT_NOT_FOUND); + } + + ChatRoom room = + chatRoomRepository + .findByExchangePostIdAndUsers(postId, user.getId(), receiver.getId()) + .orElseGet(() -> chatRoomRepository.save(chatMapper.toChatRoom(post, user, receiver))); + + return chatMapper.toChatRoomResponse(room); + } + + @Transactional + public void sendMessage(ChatMessageRequest request, Principal principal) { + + if (principal == null) { + throw new CustomException(UserErrorCode.USER_NOT_FOUND); + } + + String username = principal.getName(); + User sender = + userRepository + .findByUsername(username) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + + ChatRoom chatRoom = + chatRoomRepository + .findById(request.getRoomId()) + .orElseThrow(() -> new CustomException(ChatErrorCode.CHAT_NOT_FOUND)); + + ChatMessage message = + chatMessageRepository.save( + ChatMessage.builder() + .chatRoom(chatRoom) + .sender(sender) + .content(request.getContent()) + .build()); + + ChatMessageResponse response = chatMapper.toChatMessageResponse(message); + + log.info( + "[CHAT] 메시지 수신 roomId={}, sender={}, content={}", + request.getRoomId(), + sender.getNickname(), + request.getContent()); + + messagingTemplate.convertAndSend("/sub/chat/rooms/" + chatRoom.getId(), response); + } + + @Override + @Transactional(readOnly = true) + public InfiniteResponse getMyChatRooms(Long lastChatRoomId, Integer size) { + + User user = userService.getCurrentUser(); + + Pageable pageable = PageRequest.of(0, size + 1); + + List rooms = + chatRoomRepository.findMyChatRooms(user.getId(), lastChatRoomId, pageable); + + boolean hasNext = rooms.size() > size; + + if (hasNext) { + rooms.remove(size); + } + + List content = rooms.stream().map(chatMapper::toChatRoomResponse).toList(); + + return infiniteMapper.toInfiniteResponse(content, lastChatRoomId, hasNext, size); + } + + @Override + @Transactional(readOnly = true) + public InfiniteResponse getMessages( + Long roomId, Long lastChatId, Integer size) { + + Pageable pageable = PageRequest.of(0, size + 1); + + List messages = chatMessageRepository.findMessages(roomId, lastChatId, pageable); + + boolean hasNext = messages.size() > size; + + if (hasNext) { + messages.remove(size); + } + + List content = + messages.stream().map(chatMapper::toChatMessageResponse).toList(); + + return infiniteMapper.toInfiniteResponse(content, lastChatId, hasNext, size); + } + + @Transactional + @Override + public void readMessages(Long roomId) { + + User user = userService.getCurrentUser(); + + ChatRoom room = + chatRoomRepository + .findById(roomId) + .orElseThrow(() -> new CustomException(ChatErrorCode.CHAT_NOT_FOUND)); + + room.markAsRead(user.getId(), LocalDateTime.now()); + } +} diff --git a/src/main/java/com/sku/refit/domain/exchange/controller/ExchangeController.java b/src/main/java/com/sku/refit/domain/exchange/controller/ExchangeController.java index 396ad92..d968130 100644 --- a/src/main/java/com/sku/refit/domain/exchange/controller/ExchangeController.java +++ b/src/main/java/com/sku/refit/domain/exchange/controller/ExchangeController.java @@ -28,6 +28,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; @Tag(name = "교환 게시글", description = "교환 게시글 관련 API") @@ -50,10 +51,21 @@ ResponseEntity> createExchangePost( ExchangePostRequest request); @GetMapping - @Operation(summary = "교환 게시글 목록(페이지) 조회 (위치 기반)") + @Operation( + summary = "교환 게시글 목록(페이지) 조회", + description = "교환 게시글 목록을 페이지로 조회합니다. 위치 기반과 카테고리의 선택 적용이 가능합니다.") ResponseEntity>> getExchangePostsByLocation( @Parameter(description = "페이지 번호", example = "1") @RequestParam Integer pageNum, @Parameter(description = "페이지 크기", example = "4") @RequestParam Integer pageSize, + @Parameter( + description = "게시글 카테고리", + schema = + @Schema( + type = "string", + allowableValues = {"OUTER", "SHIRTS", "PANTS", "SHOES", "ACCESSORY"}, + example = "OUTER")) + @RequestParam(required = false) + String exchangeCategory, @Parameter(description = "위도", example = "37.544018") @RequestParam(defaultValue = "37.544018") Double latitude, diff --git a/src/main/java/com/sku/refit/domain/exchange/controller/ExchangeControllerImpl.java b/src/main/java/com/sku/refit/domain/exchange/controller/ExchangeControllerImpl.java index 8d66def..65a28f4 100644 --- a/src/main/java/com/sku/refit/domain/exchange/controller/ExchangeControllerImpl.java +++ b/src/main/java/com/sku/refit/domain/exchange/controller/ExchangeControllerImpl.java @@ -42,7 +42,11 @@ public ResponseEntity> createExchangePo @Override public ResponseEntity>> getExchangePostsByLocation( - Integer pageNum, Integer pageSize, Double latitude, Double longitude) { + Integer pageNum, + Integer pageSize, + String exchangeCategory, + Double latitude, + Double longitude) { if (pageNum < 1) { throw new CustomException(PageErrorStatus.PAGE_NOT_FOUND); @@ -55,7 +59,7 @@ public ResponseEntity> createExchangePo PageResponse exchangePostCardResponsePageResponse; exchangePostCardResponsePageResponse = - exchangeService.getExchangePostsByLocation(pageable, latitude, longitude); + exchangeService.getExchangePostsByLocation(pageable, exchangeCategory, latitude, longitude); return ResponseEntity.ok(BaseResponse.success(exchangePostCardResponsePageResponse)); } diff --git a/src/main/java/com/sku/refit/domain/exchange/dto/response/ExchangePostCardResponse.java b/src/main/java/com/sku/refit/domain/exchange/dto/response/ExchangePostCardResponse.java index bed1ad1..e0be8b7 100644 --- a/src/main/java/com/sku/refit/domain/exchange/dto/response/ExchangePostCardResponse.java +++ b/src/main/java/com/sku/refit/domain/exchange/dto/response/ExchangePostCardResponse.java @@ -14,6 +14,9 @@ @Schema(title = "ExchangePostCardResponse DTO", description = "교환글 카드 형식 응답 반환") public class ExchangePostCardResponse { + @Schema(description = "교환 게시글 식별자", example = "1") + private Long exchangePostId; + @Schema(description = "썸네일 이미지 URL") private String thumbnailImageUrl; diff --git a/src/main/java/com/sku/refit/domain/exchange/mapper/ExchangeMapper.java b/src/main/java/com/sku/refit/domain/exchange/mapper/ExchangeMapper.java index 6a4de72..f936a8b 100644 --- a/src/main/java/com/sku/refit/domain/exchange/mapper/ExchangeMapper.java +++ b/src/main/java/com/sku/refit/domain/exchange/mapper/ExchangeMapper.java @@ -65,6 +65,7 @@ public ExchangePostDetailResponse toDetailResponse(ExchangePost exchangePost, Us public ExchangePostCardResponse toCardResponse(ExchangePost exchangePost) { return ExchangePostCardResponse.builder() + .exchangePostId(exchangePost.getId()) .thumbnailImageUrl(exchangePost.getImageUrlList().getFirst()) .category(exchangePost.getExchangeCategory()) .title(exchangePost.getTitle()) diff --git a/src/main/java/com/sku/refit/domain/exchange/repository/ExchangeRepository.java b/src/main/java/com/sku/refit/domain/exchange/repository/ExchangeRepository.java index 1210175..c0f51d0 100644 --- a/src/main/java/com/sku/refit/domain/exchange/repository/ExchangeRepository.java +++ b/src/main/java/com/sku/refit/domain/exchange/repository/ExchangeRepository.java @@ -12,6 +12,7 @@ import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import com.sku.refit.domain.exchange.entity.ExchangeCategory; import com.sku.refit.domain.exchange.entity.ExchangePost; import com.sku.refit.domain.exchange.entity.ExchangeStatus; @@ -36,4 +37,23 @@ Page findByDistanceAndStatus( @Param("longitude") Double longitude, @Param("status") ExchangeStatus status, Pageable pageable); + + @Query( + """ + SELECT e + FROM ExchangePost e + WHERE e.exchangeStatus = :status + AND e.exchangeCategory = :exchangeCategory + ORDER BY + function('ST_Distance_Sphere', + point(e.spotLongitude, e.spotLatitude), + point(:longitude, :latitude) + ) + """) + Page findByDistanceAndStatusAndExchangeCategory( + @Param("latitude") Double latitude, + @Param("longitude") Double longitude, + @Param("status") ExchangeStatus status, + @Param("exchangeCategory") ExchangeCategory exchangeCategory, + Pageable pageable); } diff --git a/src/main/java/com/sku/refit/domain/exchange/service/ExchangeService.java b/src/main/java/com/sku/refit/domain/exchange/service/ExchangeService.java index a0a8531..012d2b6 100644 --- a/src/main/java/com/sku/refit/domain/exchange/service/ExchangeService.java +++ b/src/main/java/com/sku/refit/domain/exchange/service/ExchangeService.java @@ -34,7 +34,7 @@ ExchangePostDetailResponse createExchangePost( * @return 교환 게시글 카드 페이지 응답 */ PageResponse getExchangePostsByLocation( - Pageable pageable, Double latitude, Double longitude); + Pageable pageable, String exchangeCategory, Double latitude, Double longitude); ExchangePostDetailResponse getExchangePost(Long exchangePostId); diff --git a/src/main/java/com/sku/refit/domain/exchange/service/ExchangeServiceImpl.java b/src/main/java/com/sku/refit/domain/exchange/service/ExchangeServiceImpl.java index aff53a1..ca802af 100644 --- a/src/main/java/com/sku/refit/domain/exchange/service/ExchangeServiceImpl.java +++ b/src/main/java/com/sku/refit/domain/exchange/service/ExchangeServiceImpl.java @@ -81,14 +81,34 @@ public ExchangePostDetailResponse createExchangePost( @Override @Transactional(readOnly = true) public PageResponse getExchangePostsByLocation( - Pageable pageable, Double latitude, Double longitude) { + Pageable pageable, String exchangeCategory, Double latitude, Double longitude) { + + Page page; + + if (exchangeCategory == null || exchangeCategory.isBlank()) { + page = + exchangeRepository.findByDistanceAndStatus( + latitude, longitude, ExchangeStatus.BEFORE, pageable); + } else { + ExchangeCategory category; + try { + category = ExchangeCategory.valueOf(exchangeCategory); + } catch (IllegalArgumentException e) { + throw new CustomException(ExchangeErrorCode.EXCHANGE_CATEGORY_INVALID); + } - Page page = - exchangeRepository.findByDistanceAndStatus( - latitude, longitude, ExchangeStatus.BEFORE, pageable); + page = + exchangeRepository.findByDistanceAndStatusAndExchangeCategory( + latitude, longitude, ExchangeStatus.BEFORE, category, pageable); + } Page mappedPage = page.map(exchangeMapper::toCardResponse); + log.info( + "[ExchangePost READ] pageSize={}, pageNum={}", + mappedPage.getSize(), + mappedPage.getNumber()); + return pageMapper.toPageResponse(mappedPage); } @@ -102,6 +122,8 @@ public ExchangePostDetailResponse getExchangePost(Long exchangePostId) { .findByIdAndExchangeStatus(exchangePostId, ExchangeStatus.BEFORE) .orElseThrow(() -> new CustomException(ExchangeErrorCode.EXCHANGE_NOT_FOUND)); + log.info("[ExchangePost READ] postId={}, userId={}", exchangePostId, user.getId()); + return exchangeMapper.toDetailResponse(exchangePost, user); } diff --git a/src/main/java/com/sku/refit/domain/post/controller/PostController.java b/src/main/java/com/sku/refit/domain/post/controller/PostController.java index c076b20..179482c 100644 --- a/src/main/java/com/sku/refit/domain/post/controller/PostController.java +++ b/src/main/java/com/sku/refit/domain/post/controller/PostController.java @@ -27,6 +27,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; @Tag(name = "커뮤니티 게시글", description = "커뮤니티 게시글 관련 API") @@ -60,7 +61,15 @@ ResponseEntity> togglePostLike( @GetMapping @Operation(summary = "카테고리별 게시글 전체 조회", description = "특정 카테고리의 게시글 리스트를 조회합니다.") ResponseEntity>> getPostByCategory( - @RequestParam String category, + @Parameter( + description = "게시글 카테고리", + schema = + @Schema( + type = "string", + allowableValues = {"FREE", "REPAIR", "INFO"}, + example = "FREE")) + @RequestParam + String category, @Parameter(description = "마지막으로 조회한 게시글 식별자(첫 조회 시 생략)", example = "3") @RequestParam(required = false) Long lastPostId, diff --git a/src/main/java/com/sku/refit/domain/post/repository/PostRepository.java b/src/main/java/com/sku/refit/domain/post/repository/PostRepository.java index 1190285..29de413 100644 --- a/src/main/java/com/sku/refit/domain/post/repository/PostRepository.java +++ b/src/main/java/com/sku/refit/domain/post/repository/PostRepository.java @@ -9,11 +9,12 @@ import org.springframework.stereotype.Repository; import com.sku.refit.domain.post.entity.Post; +import com.sku.refit.domain.post.entity.PostCategory; @Repository public interface PostRepository extends JpaRepository { - Page findByPostCategoryContaining(String category, Pageable pageable); + Page findByPostCategory(PostCategory category, Pageable pageable); Page findByPostCategoryContainingAndIdLessThan( String category, Long lastPostId, Pageable pageable); diff --git a/src/main/java/com/sku/refit/domain/post/service/PostServiceImpl.java b/src/main/java/com/sku/refit/domain/post/service/PostServiceImpl.java index 3f034db..7bf3668 100644 --- a/src/main/java/com/sku/refit/domain/post/service/PostServiceImpl.java +++ b/src/main/java/com/sku/refit/domain/post/service/PostServiceImpl.java @@ -155,8 +155,15 @@ public InfiniteResponse getPostsByCategory( Pageable pageable = PageRequest.of(0, size + 1, Sort.by(Sort.Direction.DESC, "id")); List posts; + PostCategory postCategory; + try { + postCategory = PostCategory.valueOf(category); + } catch (IllegalArgumentException e) { + throw new CustomException(PostErrorCode.INVALID_CATEGORY); + } + if (lastPostId == null) { - posts = postRepository.findByPostCategoryContaining(category, pageable).getContent(); + posts = postRepository.findByPostCategory(postCategory, pageable).getContent(); } else { posts = postRepository @@ -217,8 +224,6 @@ public PostDetailResponse getPostById(Long id) { post.increaseViews(); - post.increaseViews(); - log.info( "[POST DETAIL] postId={}, userId={}, views={}", post.getId(), diff --git a/src/main/java/com/sku/refit/global/config/WebSocketConfig.java b/src/main/java/com/sku/refit/global/config/WebSocketConfig.java new file mode 100644 index 0000000..3d6e9bc --- /dev/null +++ b/src/main/java/com/sku/refit/global/config/WebSocketConfig.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +import com.sku.refit.global.interceptor.StompJwtChannelInterceptor; +import com.sku.refit.global.interceptor.WebSocketAuthInterceptor; + +import lombok.RequiredArgsConstructor; + +@Configuration +@EnableWebSocketMessageBroker +@RequiredArgsConstructor +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + private final WebSocketAuthInterceptor webSocketAuthInterceptor; + private final StompJwtChannelInterceptor stompJwtChannelInterceptor; + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry + .addEndpoint("/ws-chat-sockjs") + .addInterceptors(webSocketAuthInterceptor) + .setAllowedOriginPatterns("*") + .withSockJS(); + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.enableSimpleBroker("/sub"); + registry.setApplicationDestinationPrefixes("/pub"); + } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(stompJwtChannelInterceptor); + } +} diff --git a/src/main/java/com/sku/refit/global/interceptor/StompJwtChannelInterceptor.java b/src/main/java/com/sku/refit/global/interceptor/StompJwtChannelInterceptor.java new file mode 100644 index 0000000..b20f0e0 --- /dev/null +++ b/src/main/java/com/sku/refit/global/interceptor/StompJwtChannelInterceptor.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.global.interceptor; + +import java.util.Objects; + +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.stereotype.Component; + +import com.sku.refit.global.jwt.JwtProvider; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class StompJwtChannelInterceptor implements ChannelInterceptor { + + private final JwtProvider jwtProvider; + private final UserDetailsService userDetailsService; + + @Override + public Message preSend(Message message, MessageChannel channel) { + + StompHeaderAccessor accessor = + MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + + if (StompCommand.CONNECT.equals(Objects.requireNonNull(accessor).getCommand())) { + + String authHeader = accessor.getFirstNativeHeader("Authorization"); + + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String token = authHeader.substring(7); + + String username = jwtProvider.getUsernameFromToken(token); + UserDetails user = userDetailsService.loadUserByUsername(username); + + Authentication authentication = + new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities()); + + accessor.setUser(authentication); // ★ 핵심 + } + } + return message; + } +} diff --git a/src/main/java/com/sku/refit/global/interceptor/WebSocketAuthInterceptor.java b/src/main/java/com/sku/refit/global/interceptor/WebSocketAuthInterceptor.java new file mode 100644 index 0000000..3954f9a --- /dev/null +++ b/src/main/java/com/sku/refit/global/interceptor/WebSocketAuthInterceptor.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.global.interceptor; + +import java.util.Map; + +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.HandshakeInterceptor; + +@Component +public class WebSocketAuthInterceptor implements HandshakeInterceptor { + + @Override + public boolean beforeHandshake( + ServerHttpRequest request, + ServerHttpResponse response, + WebSocketHandler wsHandler, + Map attributes) { + + // SockJS 내부 요청은 인증 처리 안 함 + if (!(request instanceof ServletServerHttpRequest servletRequest)) { + return true; + } + + String token = servletRequest.getServletRequest().getHeader("Authorization"); + + // ✅ 토큰이 있을 때만 attributes에 저장 + if (token != null && !token.isBlank()) { + attributes.put("token", token); + } + + return true; // ❗ 절대 false 반환하지 말 것 + } + + @Override + public void afterHandshake( + ServerHttpRequest request, + ServerHttpResponse response, + WebSocketHandler wsHandler, + Exception exception) {} +}