From 2b552b5cb3907ab1fe4eeb50d5c340749576bf0b Mon Sep 17 00:00:00 2001 From: uni-j-uni Date: Mon, 15 Dec 2025 15:17:00 +0900 Subject: [PATCH 01/11] =?UTF-8?q?:recycle:Refactor:=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EA=B8=80=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/post/controller/PostController.java | 11 ++++++++++- .../domain/post/repository/PostRepository.java | 6 +++--- .../refit/domain/post/service/PostServiceImpl.java | 13 +++++++++---- 3 files changed, 22 insertions(+), 8 deletions(-) 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 af0b2e6..578b719 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,12 +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); + Page findByPostCategoryAndIdLessThan(PostCategory category, Long id, 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..eeb65e7 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,12 +155,19 @@ 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 - .findByPostCategoryContainingAndIdLessThan(category, lastPostId, pageable) + .findByPostCategoryAndIdLessThan(postCategory, lastPostId, pageable) .getContent(); } @@ -217,8 +224,6 @@ public PostDetailResponse getPostById(Long id) { post.increaseViews(); - post.increaseViews(); - log.info( "[POST DETAIL] postId={}, userId={}, views={}", post.getId(), From 35dc1b9ea36a91c4c1d70c785fc380650814d037 Mon Sep 17 00:00:00 2001 From: uni-j-uni Date: Mon, 15 Dec 2025 15:17:40 +0900 Subject: [PATCH 02/11] =?UTF-8?q?:heavy=5Fplus=5Fsign:Dependency:=20WebSoc?= =?UTF-8?q?ket=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 +++ 1 file changed, 3 insertions(+) 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') { From 920a8dbf07183c30d033c257ee7b2bc87c0ecc6a Mon Sep 17 00:00:00 2001 From: uni-j-uni Date: Mon, 15 Dec 2025 15:18:00 +0900 Subject: [PATCH 03/11] =?UTF-8?q?:recycle:Refactor:=20=EA=B5=90=ED=99=98?= =?UTF-8?q?=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=8B=9D=EB=B3=84=EC=9E=90=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ExchangeController.java | 14 ++++++++- .../controller/ExchangeControllerImpl.java | 8 +++-- .../response/ExchangePostCardResponse.java | 3 ++ .../exchange/mapper/ExchangeMapper.java | 1 + .../repository/ExchangeRepository.java | 20 +++++++++++++ .../exchange/service/ExchangeService.java | 2 +- .../exchange/service/ExchangeServiceImpl.java | 30 ++++++++++++++++--- 7 files changed, 70 insertions(+), 8 deletions(-) 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..1df0292 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(defaultValue = "OUTER", 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); } From 255265e4b5eb46bbc77d372861b57ba46950004b Mon Sep 17 00:00:00 2001 From: uni-j-uni Date: Mon, 15 Dec 2025 15:19:07 +0900 Subject: [PATCH 04/11] =?UTF-8?q?:sparkles:Feat:=20=EC=9B=B9=EC=86=8C?= =?UTF-8?q?=EC=BC=93=20=EA=B8=B0=EB=B0=98=20=EC=B1=84=ED=8C=85=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/controller/ChatController.java | 57 ++++++ .../chat/controller/ChatControllerImpl.java | 52 ++++++ .../controller/ChatMessageController.java | 33 ++++ .../chat/dto/request/ChatMessageRequest.java | 17 ++ .../dto/response/ChatMessageResponse.java | 31 ++++ .../chat/dto/response/ChatRoomResponse.java | 31 ++++ .../refit/domain/chat/entity/ChatMessage.java | 55 ++++++ .../refit/domain/chat/entity/ChatRoom.java | 74 ++++++++ .../domain/chat/exception/ChatErrorCode.java | 22 +++ .../refit/domain/chat/mapper/ChatMapper.java | 41 +++++ .../repository/ChatMessageRepository.java | 28 +++ .../chat/repository/ChatRoomRepository.java | 49 +++++ .../domain/chat/service/ChatService.java | 24 +++ .../domain/chat/service/ChatServiceImpl.java | 171 ++++++++++++++++++ .../refit/global/config/WebSocketConfig.java | 45 +++++ .../StompJwtChannelInterceptor.java | 55 ++++++ .../interceptor/WebSocketAuthInterceptor.java | 46 +++++ src/main/resources | 2 +- 18 files changed, 832 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/sku/refit/domain/chat/controller/ChatController.java create mode 100644 src/main/java/com/sku/refit/domain/chat/controller/ChatControllerImpl.java create mode 100644 src/main/java/com/sku/refit/domain/chat/controller/ChatMessageController.java create mode 100644 src/main/java/com/sku/refit/domain/chat/dto/request/ChatMessageRequest.java create mode 100644 src/main/java/com/sku/refit/domain/chat/dto/response/ChatMessageResponse.java create mode 100644 src/main/java/com/sku/refit/domain/chat/dto/response/ChatRoomResponse.java create mode 100644 src/main/java/com/sku/refit/domain/chat/entity/ChatMessage.java create mode 100644 src/main/java/com/sku/refit/domain/chat/entity/ChatRoom.java create mode 100644 src/main/java/com/sku/refit/domain/chat/exception/ChatErrorCode.java create mode 100644 src/main/java/com/sku/refit/domain/chat/mapper/ChatMapper.java create mode 100644 src/main/java/com/sku/refit/domain/chat/repository/ChatMessageRepository.java create mode 100644 src/main/java/com/sku/refit/domain/chat/repository/ChatRoomRepository.java create mode 100644 src/main/java/com/sku/refit/domain/chat/service/ChatService.java create mode 100644 src/main/java/com/sku/refit/domain/chat/service/ChatServiceImpl.java create mode 100644 src/main/java/com/sku/refit/global/config/WebSocketConfig.java create mode 100644 src/main/java/com/sku/refit/global/interceptor/StompJwtChannelInterceptor.java create mode 100644 src/main/java/com/sku/refit/global/interceptor/WebSocketAuthInterceptor.java 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..d61b440 --- /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().build(); + } +} 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..1fd8b3e --- /dev/null +++ b/src/main/java/com/sku/refit/domain/chat/controller/ChatMessageController.java @@ -0,0 +1,33 @@ +/* + * 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"); + 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..780132a --- /dev/null +++ b/src/main/java/com/sku/refit/domain/chat/entity/ChatRoom.java @@ -0,0 +1,74 @@ +/* + * 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.exchange.entity.ExchangePost; +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_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 { + this.receiverLastReadAt = time; + } + } +} 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/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) {} +} diff --git a/src/main/resources b/src/main/resources index d23b7c2..e9738e5 160000 --- a/src/main/resources +++ b/src/main/resources @@ -1 +1 @@ -Subproject commit d23b7c2b63504e84ea800b139b7e9235d4bf045f +Subproject commit e9738e527bba9befe3cf28fad31cbd31949854ab From 59f12f03c8f7ab45090952a8abebd8136f1682d4 Mon Sep 17 00:00:00 2001 From: uni-j-uni Date: Mon, 15 Dec 2025 15:48:16 +0900 Subject: [PATCH 05/11] =?UTF-8?q?:recycle:Refactor:=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EA=B8=80=20=EC=BF=BC=EB=A6=AC=20=EB=A9=94=EC=86=8C=EB=93=9C=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/sku/refit/domain/post/service/PostServiceImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 eeb65e7..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 @@ -167,7 +167,7 @@ public InfiniteResponse getPostsByCategory( } else { posts = postRepository - .findByPostCategoryAndIdLessThan(postCategory, lastPostId, pageable) + .findByPostCategoryContainingAndIdLessThan(category, lastPostId, pageable) .getContent(); } From e1b1a7290bcb01ce1bef1b06374b4d2bf808a4d8 Mon Sep 17 00:00:00 2001 From: uni-j-uni Date: Mon, 15 Dec 2025 15:53:10 +0900 Subject: [PATCH 06/11] =?UTF-8?q?:recycle:Refactor:=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EB=A6=AC=EB=B7=B0=20=EA=B8=B0=EB=B0=98=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=EC=82=AC=ED=95=AD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/chat/controller/ChatControllerImpl.java | 2 +- .../chat/controller/ChatMessageController.java | 6 ++++++ .../com/sku/refit/domain/chat/entity/ChatRoom.java | 12 ++++++++---- .../exchange/controller/ExchangeController.java | 2 +- 4 files changed, 16 insertions(+), 6 deletions(-) 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 index d61b440..8c476a8 100644 --- a/src/main/java/com/sku/refit/domain/chat/controller/ChatControllerImpl.java +++ b/src/main/java/com/sku/refit/domain/chat/controller/ChatControllerImpl.java @@ -47,6 +47,6 @@ public ResponseEntity>> getMe public ResponseEntity> readMessages(Long roomId) { chatService.readMessages(roomId); - return ResponseEntity.ok().build(); + 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 index 1fd8b3e..22f03ca 100644 --- a/src/main/java/com/sku/refit/domain/chat/controller/ChatMessageController.java +++ b/src/main/java/com/sku/refit/domain/chat/controller/ChatMessageController.java @@ -28,6 +28,12 @@ public void sendMessage(ChatMessageRequest request, Principal principal) { "[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/entity/ChatRoom.java b/src/main/java/com/sku/refit/domain/chat/entity/ChatRoom.java index 780132a..ca30aca 100644 --- a/src/main/java/com/sku/refit/domain/chat/entity/ChatRoom.java +++ b/src/main/java/com/sku/refit/domain/chat/entity/ChatRoom.java @@ -3,6 +3,8 @@ */ package com.sku.refit.domain.chat.entity; +import com.sku.refit.domain.chat.exception.ChatErrorCode; +import com.sku.refit.global.exception.CustomException; import java.time.LocalDateTime; import jakarta.persistence.Column; @@ -66,9 +68,11 @@ public class ChatRoom extends BaseTimeEntity { public void markAsRead(Long userId, LocalDateTime time) { if (sender.getId().equals(userId)) { - this.senderLastReadAt = time; - } else { - this.receiverLastReadAt = time; - } +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/exchange/controller/ExchangeController.java b/src/main/java/com/sku/refit/domain/exchange/controller/ExchangeController.java index 1df0292..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 @@ -64,7 +64,7 @@ ResponseEntity>> getExchange type = "string", allowableValues = {"OUTER", "SHIRTS", "PANTS", "SHOES", "ACCESSORY"}, example = "OUTER")) - @RequestParam(defaultValue = "OUTER", required = false) + @RequestParam(required = false) String exchangeCategory, @Parameter(description = "위도", example = "37.544018") @RequestParam(defaultValue = "37.544018") From 93e6dafe560b70c6ce7b41cd34c92858d3cc9728 Mon Sep 17 00:00:00 2001 From: uni-j-uni Date: Mon, 15 Dec 2025 15:54:24 +0900 Subject: [PATCH 07/11] =?UTF-8?q?:recycle:Refactor:=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EB=A6=AC=EB=B7=B0=20=EA=B8=B0=EB=B0=98=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=EC=82=AC=ED=95=AD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sku/refit/domain/chat/entity/ChatRoom.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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 index ca30aca..12f4a99 100644 --- a/src/main/java/com/sku/refit/domain/chat/entity/ChatRoom.java +++ b/src/main/java/com/sku/refit/domain/chat/entity/ChatRoom.java @@ -3,8 +3,6 @@ */ package com.sku.refit.domain.chat.entity; -import com.sku.refit.domain.chat.exception.ChatErrorCode; -import com.sku.refit.global.exception.CustomException; import java.time.LocalDateTime; import jakarta.persistence.Column; @@ -18,9 +16,11 @@ 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; @@ -68,11 +68,11 @@ public class ChatRoom extends BaseTimeEntity { 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); - } + this.senderLastReadAt = time; + } else if (receiver.getId().equals(userId)) { + this.receiverLastReadAt = time; + } else { + throw new CustomException(ChatErrorCode.CHAT_NOT_FOUND); + } } } From 1d06f3cf289e21a5ea7a41985b41cb6e12b8d310 Mon Sep 17 00:00:00 2001 From: uni-j-uni Date: Mon, 15 Dec 2025 21:09:17 +0900 Subject: [PATCH 08/11] =?UTF-8?q?:recycle:Refactor:=20=EC=BB=A4=EB=AE=A4?= =?UTF-8?q?=EB=8B=88=ED=8B=B0=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=EC=A1=B0=ED=9A=8C=20=EA=B0=80=EB=8A=A5=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/controller/PostController.java | 4 +-- .../post/repository/PostRepository.java | 7 ++-- .../domain/post/service/PostServiceImpl.java | 36 +++++++++++-------- 3 files changed, 27 insertions(+), 20 deletions(-) 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 179482c..432cae7 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 @@ -68,9 +68,9 @@ ResponseEntity>> getPostByCate type = "string", allowableValues = {"FREE", "REPAIR", "INFO"}, example = "FREE")) - @RequestParam + @RequestParam(required = false) String category, - @Parameter(description = "마지막으로 조회한 게시글 식별자(첫 조회 시 생략)", example = "3") + @Parameter(description = "마지막으로 조회한 게시글 식별자(첫 조회 시 생략, 최신순이라서 식별자 값 감소)", example = "3") @RequestParam(required = false) Long lastPostId, @Parameter(description = "한 번에 조회할 게시글 개수", example = "3") @RequestParam(defaultValue = "3") 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 29de413..d83302e 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 @@ -16,10 +16,9 @@ public interface PostRepository extends JpaRepository { Page findByPostCategory(PostCategory category, Pageable pageable); - Page findByPostCategoryContainingAndIdLessThan( - String category, Long lastPostId, Pageable pageable); - Page findAllByUser_Id(Long userId, Pageable pageable); - Page findAllByUser_IdAndIdLessThan(Long userId, Long lastPostId, Pageable pageable); + Page findByIdLessThan(Long id, Pageable pageable); + + Page findByPostCategoryAndIdLessThan(PostCategory category, Long id, 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 7bf3668..8e28a5e 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,20 +155,28 @@ 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.findByPostCategory(postCategory, pageable).getContent(); + if (category == null || category.isBlank()) { + if (lastPostId == null) { + posts = postRepository.findAll(pageable).getContent(); + } else { + posts = postRepository.findByIdLessThan(lastPostId, pageable).getContent(); + } } else { - posts = - postRepository - .findByPostCategoryContainingAndIdLessThan(category, lastPostId, pageable) - .getContent(); + PostCategory postCategory; + try { + postCategory = PostCategory.valueOf(category); + } catch (IllegalArgumentException e) { + throw new CustomException(PostErrorCode.INVALID_CATEGORY); + } + + if (lastPostId == null) { + posts = postRepository.findByPostCategory(postCategory, pageable).getContent(); + } else { + posts = + postRepository + .findByPostCategoryAndIdLessThan(postCategory, lastPostId, pageable) + .getContent(); + } } boolean hasNext = posts.size() > size; @@ -204,7 +212,7 @@ public InfiniteResponse getPostsByCategory( user)) .toList(); - Long newLastCursor = posts.isEmpty() ? null : posts.getLast().getId(); + Long newLastCursor = posts.isEmpty() ? null : posts.get(posts.size() - 1).getId(); log.info( "[POST CATEGORY LIST] category={}, lastPostId={}, size={}", category, lastPostId, size); From 5ab42e42232cf2320afbb430a85cabc34923c6c8 Mon Sep 17 00:00:00 2001 From: uni-j-uni Date: Mon, 15 Dec 2025 23:37:45 +0900 Subject: [PATCH 09/11] =?UTF-8?q?:wrench:Settings:=20=ED=94=84=EB=A1=A0?= =?UTF-8?q?=ED=8A=B8=20=EB=B0=B0=ED=8F=AC=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?CORS=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sku/refit/global/security/OAuth2LoginSuccessHandler.java | 2 +- src/main/resources | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/sku/refit/global/security/OAuth2LoginSuccessHandler.java b/src/main/java/com/sku/refit/global/security/OAuth2LoginSuccessHandler.java index b593fda..62f4f19 100644 --- a/src/main/java/com/sku/refit/global/security/OAuth2LoginSuccessHandler.java +++ b/src/main/java/com/sku/refit/global/security/OAuth2LoginSuccessHandler.java @@ -68,6 +68,6 @@ public void onAuthenticationSuccess( log.info("카카오 로그인 성공: {}", user.getUsername()); response.addHeader("Authorization", "Bearer " + tokenResponse.getAccessToken()); - response.sendRedirect("http://localhost:3000"); + response.sendRedirect("https://refit-lab.vercel.app"); } } diff --git a/src/main/resources b/src/main/resources index e9738e5..6202fc3 160000 --- a/src/main/resources +++ b/src/main/resources @@ -1 +1 @@ -Subproject commit e9738e527bba9befe3cf28fad31cbd31949854ab +Subproject commit 6202fc35eaaf4218bfeaaa64dbbb3d7fd833f0ed From 284bc71f35c4708ea778465d47a9a9e6fa40cc9b Mon Sep 17 00:00:00 2001 From: uni-j-uni Date: Mon, 15 Dec 2025 23:39:14 +0900 Subject: [PATCH 10/11] =?UTF-8?q?:wrench:Settings:=20=ED=94=84=EB=A1=A0?= =?UTF-8?q?=ED=8A=B8=20=EB=B0=B0=ED=8F=AC=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?CORS=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sku/refit/global/security/OAuth2LoginSuccessHandler.java | 2 +- src/main/resources | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/sku/refit/global/security/OAuth2LoginSuccessHandler.java b/src/main/java/com/sku/refit/global/security/OAuth2LoginSuccessHandler.java index 62f4f19..f39b935 100644 --- a/src/main/java/com/sku/refit/global/security/OAuth2LoginSuccessHandler.java +++ b/src/main/java/com/sku/refit/global/security/OAuth2LoginSuccessHandler.java @@ -68,6 +68,6 @@ public void onAuthenticationSuccess( log.info("카카오 로그인 성공: {}", user.getUsername()); response.addHeader("Authorization", "Bearer " + tokenResponse.getAccessToken()); - response.sendRedirect("https://refit-lab.vercel.app"); + response.sendRedirect("https://refitlab.site"); } } diff --git a/src/main/resources b/src/main/resources index 6202fc3..b18f530 160000 --- a/src/main/resources +++ b/src/main/resources @@ -1 +1 @@ -Subproject commit 6202fc35eaaf4218bfeaaa64dbbb3d7fd833f0ed +Subproject commit b18f5301115686c5608bd6285bc6d02f24202840 From 7f8dd3370095e8cf14311a412c53f8d502f28d35 Mon Sep 17 00:00:00 2001 From: uni-j-uni Date: Mon, 15 Dec 2025 23:45:17 +0900 Subject: [PATCH 11/11] =?UTF-8?q?:wrench:Settings:=20=ED=94=84=EB=A1=A0?= =?UTF-8?q?=ED=8A=B8=20=EB=B0=B0=ED=8F=AC=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?CORS=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sku/refit/global/security/OAuth2LoginSuccessHandler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/sku/refit/global/security/OAuth2LoginSuccessHandler.java b/src/main/java/com/sku/refit/global/security/OAuth2LoginSuccessHandler.java index f39b935..62f4f19 100644 --- a/src/main/java/com/sku/refit/global/security/OAuth2LoginSuccessHandler.java +++ b/src/main/java/com/sku/refit/global/security/OAuth2LoginSuccessHandler.java @@ -68,6 +68,6 @@ public void onAuthenticationSuccess( log.info("카카오 로그인 성공: {}", user.getUsername()); response.addHeader("Authorization", "Bearer " + tokenResponse.getAccessToken()); - response.sendRedirect("https://refitlab.site"); + response.sendRedirect("https://refit-lab.vercel.app"); } }