From 0b1469f2a501513db9edf2b2067f91dcb645eb53 Mon Sep 17 00:00:00 2001 From: dbrkdgus00 Date: Mon, 14 Jul 2025 15:53:54 +0900 Subject: [PATCH 01/34] =?UTF-8?q?[EA3-111]=20chore=20:=20=EC=9B=B9?= =?UTF-8?q?=EC=86=8C=EC=BC=93=20=EA=B4=80=EB=A0=A8=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=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 880c5d0a..6881ef15 100644 --- a/build.gradle +++ b/build.gradle @@ -83,6 +83,9 @@ dependencies { // swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' + + // WebSocket + STOMP 통신용 + implementation 'org.springframework.boot:spring-boot-starter-websocket' } dependencyManagement { From c0d19eab8cf5bace83588c549d32757ef99614e3 Mon Sep 17 00:00:00 2001 From: dbrkdgus00 Date: Mon, 14 Jul 2025 15:54:48 +0900 Subject: [PATCH 02/34] =?UTF-8?q?[EA3-111]=20refactor=20:=20swagger?= =?UTF-8?q?=EC=9A=A9=20=ED=8C=8C=EC=9D=BC=20=EC=A3=BC=EC=84=9D=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20=EC=8B=A4=EC=A0=9C=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20?= =?UTF-8?q?=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GroupChatSwaggerController.java | 66 ++++---- .../GroupChatSwaggerSpecification.java | 38 ++--- .../GroupChatWebSocketController.java | 68 ++++----- .../requset/GroupChatMessageRequestDto.java | 69 ++++----- .../dto/requset/GroupChatSwaggerRequest.java | 50 +++--- .../response/GroupChatMessageResponseDto.java | 143 ++++++++---------- .../response/GroupChatSwaggerResponse.java | 66 ++++---- .../groupchat/entity/GroupChatMessage.java | 16 ++ .../GroupChatMessageRepository.java | 16 +- .../repository/GroupChatRoomRepository.java | 36 ++--- .../groupchat/service/GroupChatService.java | 108 ++++++------- .../global/config/WebSocketConfig.java | 54 +++---- 12 files changed, 355 insertions(+), 375 deletions(-) diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatSwaggerController.java b/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatSwaggerController.java index 5ee362a9..24e9a757 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatSwaggerController.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatSwaggerController.java @@ -1,33 +1,33 @@ -package grep.neogul_coder.domain.groupchat.controller; - -import grep.neogul_coder.domain.groupchat.controller.dto.requset.GroupChatSwaggerRequest; -import grep.neogul_coder.domain.groupchat.controller.dto.response.GroupChatSwaggerResponse; -import grep.neogul_coder.global.response.ApiResponse; -import org.springframework.web.bind.annotation.*; - -import java.time.LocalDateTime; -import java.util.List; - -@RestController -@RequestMapping("/ws-stomp") -public class GroupChatSwaggerController implements GroupChatSwaggerSpecification { - - - @PostMapping("/pub/chat/message") - @Override - public ApiResponse sendMessage( - @RequestBody GroupChatSwaggerRequest request) { - return ApiResponse.success(new GroupChatSwaggerResponse()); - } - - - @GetMapping("/sub/chat/room/{roomId}") - @Override - public ApiResponse> getMessages(@PathVariable Long roomId) { - List messages = List.of( - new GroupChatSwaggerResponse(), - new GroupChatSwaggerResponse() - ); - return ApiResponse.success(messages); - } -} +//package grep.neogul_coder.domain.groupchat.controller; +// +//import grep.neogul_coder.domain.groupchat.controller.dto.requset.GroupChatSwaggerRequest; +//import grep.neogul_coder.domain.groupchat.controller.dto.response.GroupChatSwaggerResponse; +//import grep.neogul_coder.global.response.ApiResponse; +//import org.springframework.web.bind.annotation.*; +// +//import java.time.LocalDateTime; +//import java.util.List; +// +//@RestController +//@RequestMapping("/ws-stomp") +//public class GroupChatSwaggerController implements GroupChatSwaggerSpecification { +// +// +// @PostMapping("/pub/chat/message") +// @Override +// public ApiResponse sendMessage( +// @RequestBody GroupChatSwaggerRequest request) { +// return ApiResponse.success(new GroupChatSwaggerResponse()); +// } +// +// +// @GetMapping("/sub/chat/room/{roomId}") +// @Override +// public ApiResponse> getMessages(@PathVariable Long roomId) { +// List messages = List.of( +// new GroupChatSwaggerResponse(), +// new GroupChatSwaggerResponse() +// ); +// return ApiResponse.success(messages); +// } +//} diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatSwaggerSpecification.java b/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatSwaggerSpecification.java index f668694d..3e068782 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatSwaggerSpecification.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatSwaggerSpecification.java @@ -1,19 +1,19 @@ -package grep.neogul_coder.domain.groupchat.controller; - -import grep.neogul_coder.domain.groupchat.controller.dto.requset.GroupChatSwaggerRequest; -import grep.neogul_coder.domain.groupchat.controller.dto.response.GroupChatSwaggerResponse; -import grep.neogul_coder.global.response.ApiResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; - -import java.util.List; - -@Tag(name = "GroupChat-Swagger", description = "WebSocket 구조 설명용 Swagger 문서") -public interface GroupChatSwaggerSpecification { - - @Operation(summary = "Pub 채팅 메시지 전송 구조 예시", description = "실제 채팅 전송은 WebSocket을 통해 `/pub/chat/message`로 전송됩니다.") - ApiResponse sendMessage(GroupChatSwaggerRequest request); - - @Operation(summary = "Sub 채팅 메시지 수신 구조 예시", description = "실제 메시지 수신은 WebSocket을 통해 `/sub/chat/room/{roomId}`로 수신됩니다.") - ApiResponse> getMessages(Long roomId); -} +//package grep.neogul_coder.domain.groupchat.controller; +// +//import grep.neogul_coder.domain.groupchat.controller.dto.requset.GroupChatSwaggerRequest; +//import grep.neogul_coder.domain.groupchat.controller.dto.response.GroupChatSwaggerResponse; +//import grep.neogul_coder.global.response.ApiResponse; +//import io.swagger.v3.oas.annotations.Operation; +//import io.swagger.v3.oas.annotations.tags.Tag; +// +//import java.util.List; +// +//@Tag(name = "GroupChat-Swagger", description = "WebSocket 구조 설명용 Swagger 문서") +//public interface GroupChatSwaggerSpecification { +// +// @Operation(summary = "Pub 채팅 메시지 전송 구조 예시", description = "실제 채팅 전송은 WebSocket을 통해 `/pub/chat/message`로 전송됩니다.") +// ApiResponse sendMessage(GroupChatSwaggerRequest request); +// +// @Operation(summary = "Sub 채팅 메시지 수신 구조 예시", description = "실제 메시지 수신은 WebSocket을 통해 `/sub/chat/room/{roomId}`로 수신됩니다.") +// ApiResponse> getMessages(Long roomId); +//} diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatWebSocketController.java b/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatWebSocketController.java index 0b7c1363..9f8e54b1 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatWebSocketController.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatWebSocketController.java @@ -1,34 +1,34 @@ -//package grep.neogul_coder.domain.groupchat.controller; -// -//import grep.neogul_coder.domain.groupchat.dto.GroupChatMessageRequestDto; -//import grep.neogul_coder.domain.groupchat.dto.GroupChatMessageResponseDto; -//import grep.neogul_coder.domain.groupchat.service.GroupChatService; -//import org.springframework.messaging.handler.annotation.MessageMapping; -//import org.springframework.messaging.handler.annotation.SendTo; -//import org.springframework.messaging.simp.SimpMessagingTemplate; -//import org.springframework.stereotype.Controller; -// -//@Controller -//public class GroupChatWebSocketController { -// -// private final GroupChatService groupChatService; -// private final SimpMessagingTemplate messagingTemplate; -// -// public GroupChatWebSocketController(GroupChatService groupChatService, -// SimpMessagingTemplate messagingTemplate) { -// this.groupChatService = groupChatService; -// this.messagingTemplate = messagingTemplate; -// } -// -// // 클라이언트가 /pub/chat/message 로 보낼 때 처리됨 -// @MessageMapping("/chat/message") -// public void handleMessage(GroupChatMessageRequestDto requestDto) { -// // 메시지를 DB에 저장하고, 응답 DTO 생성 -// GroupChatMessageResponseDto responseDto = groupChatService.saveMessage(requestDto); -// -// // 구독 중인 클라이언트에게 메시지 전송 (채팅방 구분) -// messagingTemplate.convertAndSend( -// "/sub/chat/room/" + requestDto.getRoomId(), responseDto -// ); -// } -//} +package grep.neogul_coder.domain.groupchat.controller; + +import grep.neogul_coder.domain.groupchat.controller.dto.requset.GroupChatMessageRequestDto; +import grep.neogul_coder.domain.groupchat.controller.dto.response.GroupChatMessageResponseDto; +import grep.neogul_coder.domain.groupchat.service.GroupChatService; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Controller; + +@Controller +public class GroupChatWebSocketController { + + private final GroupChatService groupChatService; + private final SimpMessagingTemplate messagingTemplate; + + public GroupChatWebSocketController(GroupChatService groupChatService, + SimpMessagingTemplate messagingTemplate) { + this.groupChatService = groupChatService; + this.messagingTemplate = messagingTemplate; + } + + // 클라이언트가 /pub/chat/message 로 보낼 때 처리됨 + @MessageMapping("/chat/message") + public void handleMessage(GroupChatMessageRequestDto requestDto) { + // 메시지를 DB에 저장하고, 응답 DTO 생성 + GroupChatMessageResponseDto responseDto = groupChatService.saveMessage(requestDto); + + // 구독 중인 클라이언트에게 메시지 전송 (채팅방 구분) + messagingTemplate.convertAndSend( + "/sub/chat/room/" + requestDto.getRoomId(), responseDto + ); + } +} diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/requset/GroupChatMessageRequestDto.java b/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/requset/GroupChatMessageRequestDto.java index 41d589e2..ff75c1e9 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/requset/GroupChatMessageRequestDto.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/requset/GroupChatMessageRequestDto.java @@ -1,39 +1,30 @@ -//package grep.neogul_coder.domain.groupchat.controller.dto; -// -//public class GroupChatMessageRequestDto { -// private Long roomId; -// private Long senderId; -// private String message; -// -// public GroupChatMessageRequestDto() {} -// -// public GroupChatMessageRequestDto(Long roomId, Long senderId, String message) { -// this.roomId = roomId; -// this.senderId = senderId; -// this.message = message; -// } -// -// public Long getRoomId() { -// return roomId; -// } -// -// public void setRoomId(Long roomId) { -// this.roomId = roomId; -// } -// -// public Long getSenderId() { -// return senderId; -// } -// -// public void setSenderId(Long senderId) { -// this.senderId = senderId; -// } -// -// public String getMessage() { -// return message; -// } -// -// public void setMessage(String message) { -// this.message = message; -// } -//} +package grep.neogul_coder.domain.groupchat.controller.dto.requset; + +import lombok.Getter; + +@Getter +public class GroupChatMessageRequestDto { + private Long roomId; + private Long senderId; + private String message; + + public GroupChatMessageRequestDto() {} + + public GroupChatMessageRequestDto(Long roomId, Long senderId, String message) { + this.roomId = roomId; + this.senderId = senderId; + this.message = message; + } + + public void setRoomId(Long roomId) { + this.roomId = roomId; + } + + public void setSenderId(Long senderId) { + this.senderId = senderId; + } + + public void setMessage(String message) { + this.message = message; + } +} diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/requset/GroupChatSwaggerRequest.java b/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/requset/GroupChatSwaggerRequest.java index bf5f1f1c..092713e8 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/requset/GroupChatSwaggerRequest.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/requset/GroupChatSwaggerRequest.java @@ -1,25 +1,25 @@ -package grep.neogul_coder.domain.groupchat.controller.dto.requset; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Getter; - -@Getter -@Schema(description = "Swagger용 채팅 메시지 전송 요청 DTO") -public class GroupChatSwaggerRequest { - - @Schema(description = "보낸 사람 ID", example = "456") - private Long senderId; - - @Schema(description = "채팅방 ID", example = "100") - private Long roomId; - - @Schema(description = "보낼 메시지", example = "안녕하세요!") - private String message; - - - public void setSenderId(Long senderId) { this.senderId = senderId; } - - public void setRoomId(Long roomId) { this.roomId = roomId; } - - public void setMessage(String message) { this.message = message; } -} +//package grep.neogul_coder.domain.groupchat.controller.dto.requset; +// +//import io.swagger.v3.oas.annotations.media.Schema; +//import lombok.Getter; +// +//@Getter +//@Schema(description = "Swagger용 채팅 메시지 전송 요청 DTO") +//public class GroupChatSwaggerRequest { +// +// @Schema(description = "보낸 사람 ID", example = "456") +// private Long senderId; +// +// @Schema(description = "채팅방 ID", example = "100") +// private Long roomId; +// +// @Schema(description = "보낼 메시지", example = "안녕하세요!") +// private String message; +// +// +// public void setSenderId(Long senderId) { this.senderId = senderId; } +// +// public void setRoomId(Long roomId) { this.roomId = roomId; } +// +// public void setMessage(String message) { this.message = message; } +//} diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/response/GroupChatMessageResponseDto.java b/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/response/GroupChatMessageResponseDto.java index 6414acb0..46ba596e 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/response/GroupChatMessageResponseDto.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/response/GroupChatMessageResponseDto.java @@ -1,84 +1,59 @@ -//package grep.neogul_coder.domain.groupchat.controller.dto; -// -//import java.time.LocalDateTime; -// -//public class GroupChatMessageResponseDto { -// -// private Long id; // 메시지 고유 ID -// private Long roomId; // (선택) 프론트 팀에서 필요시 -// private Long senderId; // user 추적 -// private String content; // 메시지 내용 -// private String senderName; // 메시지 보낸 사람(닉네임) -// private String senderProfileUrl; // 메시지 보낸 사람의 프로필 사진 URL -// private LocalDateTime sentAt; // 메시지 보낸 시각 -// -// public GroupChatMessageResponseDto() { -// } -// -// public GroupChatMessageResponseDto(Long id, Long roomId, Long senderId, String content, -// String senderName,String senderProfileUrl, LocalDateTime sentAt) { -// this.id = id; -// this.roomId = roomId; -// this.senderId = senderId; -// this.content = content; -// this.senderName = senderName; -// this.senderProfileUrl = senderProfileUrl; -// this.sentAt = sentAt; -// } -// -// public Long getId() { -// return id; -// } -// -// public void setId(Long id) { -// this.id = id; -// } -// -// public Long getRoomId() { -// return roomId; -// } -// -// public void setRoomId(Long roomId) { -// this.roomId = roomId; -// } -// -// public Long getSenderId() { -// return senderId; -// } -// -// public void setSenderId(Long senderId) { -// this.senderId = senderId; -// } -// -// public String getContent() { -// return content; -// } -// -// public void setContent(String content) { -// this.content = content; -// } -// -// public String getSenderName() { -// return senderName; -// } -// -// public void setSenderName(String senderName) { -// this.senderName = senderName; -// } -// -// public String getSenderProfileUrl() { -// return senderProfileUrl; -// } -// -// public void setSenderProfileUrl(String senderProfileUrl) { -// this.senderProfileUrl = senderProfileUrl; -// } -// -// public LocalDateTime getSentAt() { -// return sentAt; -// } -// -// public void setSentAt(LocalDateTime sentAt) { -// this.sentAt = sentAt; -// } -//} +package grep.neogul_coder.domain.groupchat.controller.dto.response; + +import java.time.LocalDateTime; +import lombok.Getter; + +@Getter +public class GroupChatMessageResponseDto { + + private Long id; // 메시지 고유 ID + private Long roomId; // 채팅방 ID + private Long senderId; // 보낸 사람 ID + private String senderNickname; // 보낸 사람 닉네임 + private String profileImageUrl; // 프로필 이미지 URL + private String message; // 메시지 내용 + private LocalDateTime sentAt; // 보낸 시간 + + public GroupChatMessageResponseDto() { + } + + public GroupChatMessageResponseDto(Long id, Long roomId, Long senderId, + String senderNickname, String profileImageUrl, + String message, LocalDateTime sentAt) { + this.id = id; + this.roomId = roomId; + this.senderId = senderId; + this.senderNickname = senderNickname; + this.profileImageUrl = profileImageUrl; + this.message = message; + this.sentAt = sentAt; + } + + public void setId(Long id) { + this.id = id; + } + + public void setRoomId(Long roomId) { + this.roomId = roomId; + } + + public void setSenderId(Long senderId) { + this.senderId = senderId; + } + + public void setSenderNickname(String senderNickname) { + this.senderNickname = senderNickname; + } + + public void setProfileImageUrl(String profileImageUrl) { + this.profileImageUrl = profileImageUrl; + } + + public void setMessage(String message) { + this.message = message; + } + + public void setSentAt(LocalDateTime sentAt) { + this.sentAt = sentAt; + } +} diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/response/GroupChatSwaggerResponse.java b/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/response/GroupChatSwaggerResponse.java index 845160ab..466ba921 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/response/GroupChatSwaggerResponse.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/response/GroupChatSwaggerResponse.java @@ -1,34 +1,32 @@ -package grep.neogul_coder.domain.groupchat.controller.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; -import java.time.LocalDateTime; -import lombok.Builder; -import lombok.Getter; - -@Getter -@Schema(description = "Swagger용 채팅 메시지 응답 DTO") -public class GroupChatSwaggerResponse { - - @Schema(description = "메시지 고유 ID", example = "101") - private Long id; - - @Schema(description = "보낸 사람 ID", example = "456") - private Long senderId; - - @Schema(description = "보낸 사람 닉네임", example = "강현") - private String senderNickname; - - @Schema(description = "프로필 이미지 URL", example = "https://ganghyeon.jpg") - private String profileImageUrl; - - @Schema(description = "채팅방 ID", example = "100") - private Long roomId; - - @Schema(description = "보낸 메시지", example = "안녕하세요!") - private String message; - - @Schema(description = "보낸 시간", example = "2025-07-07T17:45:00") - private LocalDateTime sentAt; - - -} +//package grep.neogul_coder.domain.groupchat.controller.dto.response; +// +//import io.swagger.v3.oas.annotations.media.Schema; +//import java.time.LocalDateTime; +//import lombok.Builder; +//import lombok.Getter; +// +//@Getter +//@Schema(description = "Swagger용 채팅 메시지 응답 DTO") +//public class GroupChatSwaggerResponse { +// +// @Schema(description = "메시지 고유 ID", example = "101") +// private Long id; +// +// @Schema(description = "보낸 사람 ID", example = "456") +// private Long senderId; +// +// @Schema(description = "보낸 사람 닉네임", example = "강현") +// private String senderNickname; +// +// @Schema(description = "프로필 이미지 URL", example = "https://ganghyeon.jpg") +// private String profileImageUrl; +// +// @Schema(description = "채팅방 ID", example = "100") +// private Long roomId; +// +// @Schema(description = "보낸 메시지", example = "안녕하세요!") +// private String message; +// +// @Schema(description = "보낸 시간", example = "2025-07-07T17:45:00") +// private LocalDateTime sentAt; +//} diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/entity/GroupChatMessage.java b/src/main/java/grep/neogul_coder/domain/groupchat/entity/GroupChatMessage.java index 3e9b87ba..d1350108 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/entity/GroupChatMessage.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/entity/GroupChatMessage.java @@ -23,4 +23,20 @@ public class GroupChatMessage extends BaseEntity { @Column(nullable = false) private String content; + + public void setMessageId(Long messageId) { + this.messageId = messageId; + } + + public void setGroupChatRoom(GroupChatRoom groupChatRoom) { + this.groupChatRoom = groupChatRoom; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public void setContent(String content) { + this.content = content; + } } diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/repository/GroupChatMessageRepository.java b/src/main/java/grep/neogul_coder/domain/groupchat/repository/GroupChatMessageRepository.java index e68a8673..6fd9ac52 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/repository/GroupChatMessageRepository.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/repository/GroupChatMessageRepository.java @@ -1,8 +1,8 @@ -//package grep.neogul_coder.domain.groupchat.repository; -// -//import grep.neogul_coder.domain.groupchat.domain.GroupChatMessage; -//import org.springframework.data.jpa.repository.JpaRepository; -// -//public interface GroupChatMessageRepository extends JpaRepository { -// -//} +package grep.neogul_coder.domain.groupchat.repository; + +import grep.neogul_coder.domain.groupchat.entity.GroupChatMessage; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface GroupChatMessageRepository extends JpaRepository { + +} diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/repository/GroupChatRoomRepository.java b/src/main/java/grep/neogul_coder/domain/groupchat/repository/GroupChatRoomRepository.java index 7fa8618f..a9ee2e29 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/repository/GroupChatRoomRepository.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/repository/GroupChatRoomRepository.java @@ -1,18 +1,18 @@ -//package grep.neogul_coder.domain.groupchat.repository; -// -//import grep.neogul_coder.domain.groupchat.domain.GroupChatRoom; -//import java.util.Optional; -//import org.springframework.data.jpa.repository.JpaRepository; -// -//// JpaRepository : 기본적인 CRUD 자동제공 -//public interface GroupChatRoomRepository extends JpaRepository { -// -// // 스터디 ID로 채팅방을 찾는 메서드 -// // 스터디는 1개당 채팅방 1개가 연결되어 있으므로, -// // 이 메서드는 service 단에서 studyId를 기반으로 채팅방을 찾아올 때 사용됨! -// -// -// // Optional을 쓰는 이유 : studyId에 해당하는 채팅방이 없을 수 도 있기 때문 -// // Optional.empty()로 감싸 null 에러 방지 -// Optional findByStudy(Long studyId); -//} +package grep.neogul_coder.domain.groupchat.repository; + +import grep.neogul_coder.domain.groupchat.entity.GroupChatRoom; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +// JpaRepository : 기본적인 CRUD 자동제공 +public interface GroupChatRoomRepository extends JpaRepository { + + // 스터디 ID로 채팅방을 찾는 메서드 + // 스터디는 1개당 채팅방 1개가 연결되어 있으므로, + // 이 메서드는 service 단에서 studyId를 기반으로 채팅방을 찾아올 때 사용됨! + + + // Optional을 쓰는 이유 : studyId에 해당하는 채팅방이 없을 수 도 있기 때문 + // Optional.empty()로 감싸 null 에러 방지 + Optional findByStudy(Long studyId); +} diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/service/GroupChatService.java b/src/main/java/grep/neogul_coder/domain/groupchat/service/GroupChatService.java index 615f4ce0..9d2a9562 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/service/GroupChatService.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/service/GroupChatService.java @@ -1,54 +1,54 @@ -//package grep.neogul_coder.domain.groupchat.service; -// -//import grep.neogul_coder.domain.groupchat.domain.GroupChatMessage; -//import grep.neogul_coder.domain.groupchat.domain.GroupChatRoom; -//import grep.neogul_coder.domain.groupchat.dto.GroupChatMessageRequestDto; -//import grep.neogul_coder.domain.groupchat.dto.GroupChatMessageResponseDto; -//import grep.neogul_coder.domain.groupchat.repository.GroupChatMessageRepository; -//import grep.neogul_coder.domain.groupchat.repository.GroupChatRoomRepository; -//import grep.neogul_coder.domain.user.domain.User; -//import grep.neogul_coder.domain.user.repository.UserRepository; -//import org.springframework.stereotype.Service; -//import java.time.LocalDateTime; -// -//@Service -//public class GroupChatService { -// -// private final GroupChatMessageRepository messageRepository; -// private final GroupChatRoomRepository roomRepository; -// private final UserRepository userRepository; -// -// public GroupChatService(GroupChatMessageRepository messageRepository, -// GroupChatRoomRepository roomRepository, -// UserRepository userRepository) { -// this.messageRepository = messageRepository; -// this.roomRepository = roomRepository; -// this.userRepository = userRepository; -// } -// -// public GroupChatMessageResponseDto saveMessage(GroupChatMessageRequestDto requestDto) { -// // 필요한 도메인 객체 조회 -// GroupChatRoom room = roomRepository.findById(requestDto.getRoomId()) -// .orElseThrow(() -> new IllegalArgumentException("채팅방이 존재하지 않습니다.")); -// User sender = userRepository.findById(requestDto.getSenderId()) -// .orElseThrow(() -> new IllegalArgumentException("사용자가 존재하지 않습니다.")); -// -// // 메시지 생성 및 저장 -// GroupChatMessage message = new GroupChatMessage(); -// message.setRoom(room); -// message.setSender(sender); -// message.setContent(requestDto.getContent()); -// message.setSentAt(LocalDateTime.now()); -// -// messageRepository.save(message); -// -// // 응답 DTO 구성 -// return new GroupChatMessageResponseDto( -// message.getMessageId(), -// message.getContent(), -// sender.getNickname(), -// sender.getProfileImageUrl(), // 프론트에서 필요 -// message.getSentAt() -// ); -// } -//} +package grep.neogul_coder.domain.groupchat.service; + +import grep.neogul_coder.domain.groupchat.entity.GroupChatMessage; +import grep.neogul_coder.domain.groupchat.entity.GroupChatRoom; +import grep.neogul_coder.domain.groupchat.controller.dto.requset.GroupChatMessageRequestDto; +import grep.neogul_coder.domain.groupchat.controller.dto.response.GroupChatMessageResponseDto; +import grep.neogul_coder.domain.groupchat.repository.GroupChatMessageRepository; +import grep.neogul_coder.domain.groupchat.repository.GroupChatRoomRepository; +import grep.neogul_coder.domain.users.entity.User; +import grep.neogul_coder.domain.users.repository.UserRepository; +import org.springframework.stereotype.Service; +import java.time.LocalDateTime; + +@Service +public class GroupChatService { + + private final GroupChatMessageRepository messageRepository; + private final GroupChatRoomRepository roomRepository; + private final UserRepository userRepository; + + public GroupChatService(GroupChatMessageRepository messageRepository, + GroupChatRoomRepository roomRepository, + UserRepository userRepository) { + this.messageRepository = messageRepository; + this.roomRepository = roomRepository; + this.userRepository = userRepository; + } + + public GroupChatMessageResponseDto saveMessage(GroupChatMessageRequestDto requestDto) { + // 필요한 도메인 객체 조회 + GroupChatRoom room = roomRepository.findById(requestDto.getRoomId()) + .orElseThrow(() -> new IllegalArgumentException("채팅방이 존재하지 않습니다.")); + User sender = userRepository.findById(requestDto.getSenderId()) + .orElseThrow(() -> new IllegalArgumentException("사용자가 존재하지 않습니다.")); + + // 메시지 생성 및 저장 + GroupChatMessage message = new GroupChatMessage(); + message.setRoom(room); + message.setSender(sender); + message.setContent(requestDto.getContent()); + message.setSentAt(LocalDateTime.now()); + + messageRepository.save(message); + + // 응답 DTO 구성 + return new GroupChatMessageResponseDto( + message.getMessageId(), + message.getContent(), + sender.getNickname(), + sender.getProfileImageUrl(), // 프론트에서 필요 + message.getSentAt() + ); + } +} diff --git a/src/main/java/grep/neogul_coder/global/config/WebSocketConfig.java b/src/main/java/grep/neogul_coder/global/config/WebSocketConfig.java index 3e5650f2..54d16ee2 100644 --- a/src/main/java/grep/neogul_coder/global/config/WebSocketConfig.java +++ b/src/main/java/grep/neogul_coder/global/config/WebSocketConfig.java @@ -1,27 +1,27 @@ -//package grep.neogul_coder.global; -// -//import org.springframework.context.annotation.Configuration; -//import org.springframework.messaging.simp.config.MessageBrokerRegistry; -//import org.springframework.web.socket.config.annotation.*; -// -//@Configuration -//@EnableWebSocketMessageBroker -//public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { -// -// @Override -// public void configureMessageBroker(MessageBrokerRegistry registry) { -// // 클라이언트가 메시지를 받을 수 있는 경로(prefix) -// registry.enableSimpleBroker("/sub"); -// -// // 클라이언트가 메시지를 보낼 때 사용할 prefix -// registry.setApplicationDestinationPrefixes("/pub"); -// } -// -// @Override -// public void registerStompEndpoints(StompEndpointRegistry registry) { -// // 클라이언트가 WebSocket에 연결할 endpoint -// registry.addEndpoint("/ws-stomp") -// .setAllowedOriginPatterns("*") // CORS 허용 -// .withSockJS(); // SockJS 지원 -// } -//} +package grep.neogul_coder.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.*; + +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + // 클라이언트가 메시지를 받을 수 있는 경로(prefix) + registry.enableSimpleBroker("/sub"); + + // 클라이언트가 메시지를 보낼 때 사용할 prefix + registry.setApplicationDestinationPrefixes("/pub"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + // 클라이언트가 WebSocket에 연결할 endpoint + registry.addEndpoint("/ws-stomp") + .setAllowedOriginPatterns("*") // CORS 허용 + .withSockJS(); // SockJS 지원 + } +} From 776198a3bfb6d62d6dfd3b2e3fd20d54671903eb Mon Sep 17 00:00:00 2001 From: dbrkdgus00 Date: Mon, 14 Jul 2025 22:02:27 +0900 Subject: [PATCH 03/34] =?UTF-8?q?[EA3-111]=20refactor=20:=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=ED=95=84=EB=93=9C=EB=AA=85=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 --- .../domain/groupchat/entity/GroupChatMessage.java | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/entity/GroupChatMessage.java b/src/main/java/grep/neogul_coder/domain/groupchat/entity/GroupChatMessage.java index d1350108..c7c7558b 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/entity/GroupChatMessage.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/entity/GroupChatMessage.java @@ -3,6 +3,7 @@ import grep.neogul_coder.domain.users.entity.User; import grep.neogul_coder.global.entity.BaseEntity; import jakarta.persistence.*; +import java.time.LocalDateTime; import lombok.Getter; import lombok.NoArgsConstructor; @@ -22,7 +23,9 @@ public class GroupChatMessage extends BaseEntity { private Long userId; @Column(nullable = false) - private String content; + private String message; + + private LocalDateTime sentAt; public void setMessageId(Long messageId) { this.messageId = messageId; @@ -36,7 +39,11 @@ public void setUserId(Long userId) { this.userId = userId; } - public void setContent(String content) { - this.content = content; + public void setMessage(String message) { + this.message = message; + } + + public void setSentAt(LocalDateTime sentAt) { + this.sentAt = sentAt; } } From 872a736198941a7bbcb7e9e0f8ee3ec465257319 Mon Sep 17 00:00:00 2001 From: dbrkdgus00 Date: Mon, 14 Jul 2025 22:03:58 +0900 Subject: [PATCH 04/34] =?UTF-8?q?[EA3-111]=20refactor=20:=20=EB=B0=94?= =?UTF-8?q?=EB=80=90=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=EB=AA=85=20=ED=86=A0=EB=8C=80=EB=A1=9C=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/groupchat/service/GroupChatService.java | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/service/GroupChatService.java b/src/main/java/grep/neogul_coder/domain/groupchat/service/GroupChatService.java index 9d2a9562..7e82a653 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/service/GroupChatService.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/service/GroupChatService.java @@ -35,9 +35,9 @@ public GroupChatMessageResponseDto saveMessage(GroupChatMessageRequestDto reques // 메시지 생성 및 저장 GroupChatMessage message = new GroupChatMessage(); - message.setRoom(room); - message.setSender(sender); - message.setContent(requestDto.getContent()); + message.setGroupChatRoom(room); // 엔티티에 있는 필드명 맞게 사용 + message.setUserId(sender.getId()); // 연관관계 없이 userId만 저장 + message.setMessage(requestDto.getMessage()); message.setSentAt(LocalDateTime.now()); messageRepository.save(message); @@ -45,9 +45,11 @@ public GroupChatMessageResponseDto saveMessage(GroupChatMessageRequestDto reques // 응답 DTO 구성 return new GroupChatMessageResponseDto( message.getMessageId(), - message.getContent(), + message.getGroupChatRoom().getRoomId(), // ← roomId 필드가 필요하면 여기서 꺼내야 함 + sender.getId(), sender.getNickname(), - sender.getProfileImageUrl(), // 프론트에서 필요 + sender.getProfileImageUrl(), + message.getMessage(), message.getSentAt() ); } From 325ea1f893cfaaccc76e7e28129f948b145b3a11 Mon Sep 17 00:00:00 2001 From: dbrkdgus00 Date: Tue, 15 Jul 2025 09:51:27 +0900 Subject: [PATCH 05/34] =?UTF-8?q?[EA3-111]=20feature=20:=20=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=EB=94=94=20=EC=B1=84=ED=8C=85=EB=B0=A9=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/groupchat/service/GroupChatService.java | 13 ++++++++++++- .../study/repository/StudyMemberRepository.java | 2 ++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/service/GroupChatService.java b/src/main/java/grep/neogul_coder/domain/groupchat/service/GroupChatService.java index 7e82a653..b19bf90e 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/service/GroupChatService.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/service/GroupChatService.java @@ -6,6 +6,7 @@ import grep.neogul_coder.domain.groupchat.controller.dto.response.GroupChatMessageResponseDto; import grep.neogul_coder.domain.groupchat.repository.GroupChatMessageRepository; import grep.neogul_coder.domain.groupchat.repository.GroupChatRoomRepository; +import grep.neogul_coder.domain.study.repository.StudyMemberRepository; import grep.neogul_coder.domain.users.entity.User; import grep.neogul_coder.domain.users.repository.UserRepository; import org.springframework.stereotype.Service; @@ -17,13 +18,16 @@ public class GroupChatService { private final GroupChatMessageRepository messageRepository; private final GroupChatRoomRepository roomRepository; private final UserRepository userRepository; + private final StudyMemberRepository studyMemberRepository; public GroupChatService(GroupChatMessageRepository messageRepository, GroupChatRoomRepository roomRepository, - UserRepository userRepository) { + UserRepository userRepository, + StudyMemberRepository studyMemberRepository) { this.messageRepository = messageRepository; this.roomRepository = roomRepository; this.userRepository = userRepository; + this.studyMemberRepository = studyMemberRepository; } public GroupChatMessageResponseDto saveMessage(GroupChatMessageRequestDto requestDto) { @@ -33,6 +37,13 @@ public GroupChatMessageResponseDto saveMessage(GroupChatMessageRequestDto reques User sender = userRepository.findById(requestDto.getSenderId()) .orElseThrow(() -> new IllegalArgumentException("사용자가 존재하지 않습니다.")); + // 스터디 참가자 검증 로직 추가 + Long studyId = room.getStudyId(); + boolean isParticipant = studyMemberRepository.existsByStudyIdAndUserId(studyId, sender.getId()); + if (!isParticipant) { + throw new IllegalArgumentException("해당 스터디에 참가한 사용자만 채팅할 수 있습니다."); + } + // 메시지 생성 및 저장 GroupChatMessage message = new GroupChatMessage(); message.setGroupChatRoom(room); // 엔티티에 있는 필드명 맞게 사용 diff --git a/src/main/java/grep/neogul_coder/domain/study/repository/StudyMemberRepository.java b/src/main/java/grep/neogul_coder/domain/study/repository/StudyMemberRepository.java index 4b436267..45e9a074 100644 --- a/src/main/java/grep/neogul_coder/domain/study/repository/StudyMemberRepository.java +++ b/src/main/java/grep/neogul_coder/domain/study/repository/StudyMemberRepository.java @@ -16,4 +16,6 @@ public interface StudyMemberRepository extends JpaRepository @Query("select sm from StudyMember sm join fetch sm.study where sm.study.id = :studyId") List findByStudyIdFetchStudy(@Param("studyId") long studyId); + + boolean existsByStudyIdAndUserId(Long studyId, Long id); } From 104fe64642aaa234ece3210d498cfbe3e893a02b Mon Sep 17 00:00:00 2001 From: dbrkdgus00 Date: Tue, 15 Jul 2025 15:20:30 +0900 Subject: [PATCH 06/34] =?UTF-8?q?[EA3-111]=20docs=20:=20=EA=B7=B8=EB=A3=B9?= =?UTF-8?q?=EC=B1=84=ED=8C=85=20Swagger=20=EC=A3=BC=EC=84=9D=20=ED=95=B4?= =?UTF-8?q?=EC=A0=9C=20=EB=B0=8F=20=EC=8B=A4=EC=A0=9C=20=EC=BB=A8=ED=8A=B8?= =?UTF-8?q?=EB=A1=A4=EB=9F=AC=20=ED=9E=88=EB=93=A0=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GroupChatSwaggerController.java | 66 +++++++++---------- .../GroupChatSwaggerSpecification.java | 38 +++++------ .../GroupChatWebSocketController.java | 2 + .../requset/GroupChatMessageRequestDto.java | 2 + .../dto/requset/GroupChatSwaggerRequest.java | 50 +++++++------- .../response/GroupChatMessageResponseDto.java | 2 + .../response/GroupChatSwaggerResponse.java | 64 +++++++++--------- .../repository/GroupChatRoomRepository.java | 2 +- 8 files changed, 116 insertions(+), 110 deletions(-) diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatSwaggerController.java b/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatSwaggerController.java index 24e9a757..5ee362a9 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatSwaggerController.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatSwaggerController.java @@ -1,33 +1,33 @@ -//package grep.neogul_coder.domain.groupchat.controller; -// -//import grep.neogul_coder.domain.groupchat.controller.dto.requset.GroupChatSwaggerRequest; -//import grep.neogul_coder.domain.groupchat.controller.dto.response.GroupChatSwaggerResponse; -//import grep.neogul_coder.global.response.ApiResponse; -//import org.springframework.web.bind.annotation.*; -// -//import java.time.LocalDateTime; -//import java.util.List; -// -//@RestController -//@RequestMapping("/ws-stomp") -//public class GroupChatSwaggerController implements GroupChatSwaggerSpecification { -// -// -// @PostMapping("/pub/chat/message") -// @Override -// public ApiResponse sendMessage( -// @RequestBody GroupChatSwaggerRequest request) { -// return ApiResponse.success(new GroupChatSwaggerResponse()); -// } -// -// -// @GetMapping("/sub/chat/room/{roomId}") -// @Override -// public ApiResponse> getMessages(@PathVariable Long roomId) { -// List messages = List.of( -// new GroupChatSwaggerResponse(), -// new GroupChatSwaggerResponse() -// ); -// return ApiResponse.success(messages); -// } -//} +package grep.neogul_coder.domain.groupchat.controller; + +import grep.neogul_coder.domain.groupchat.controller.dto.requset.GroupChatSwaggerRequest; +import grep.neogul_coder.domain.groupchat.controller.dto.response.GroupChatSwaggerResponse; +import grep.neogul_coder.global.response.ApiResponse; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.List; + +@RestController +@RequestMapping("/ws-stomp") +public class GroupChatSwaggerController implements GroupChatSwaggerSpecification { + + + @PostMapping("/pub/chat/message") + @Override + public ApiResponse sendMessage( + @RequestBody GroupChatSwaggerRequest request) { + return ApiResponse.success(new GroupChatSwaggerResponse()); + } + + + @GetMapping("/sub/chat/room/{roomId}") + @Override + public ApiResponse> getMessages(@PathVariable Long roomId) { + List messages = List.of( + new GroupChatSwaggerResponse(), + new GroupChatSwaggerResponse() + ); + return ApiResponse.success(messages); + } +} diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatSwaggerSpecification.java b/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatSwaggerSpecification.java index 3e068782..f668694d 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatSwaggerSpecification.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatSwaggerSpecification.java @@ -1,19 +1,19 @@ -//package grep.neogul_coder.domain.groupchat.controller; -// -//import grep.neogul_coder.domain.groupchat.controller.dto.requset.GroupChatSwaggerRequest; -//import grep.neogul_coder.domain.groupchat.controller.dto.response.GroupChatSwaggerResponse; -//import grep.neogul_coder.global.response.ApiResponse; -//import io.swagger.v3.oas.annotations.Operation; -//import io.swagger.v3.oas.annotations.tags.Tag; -// -//import java.util.List; -// -//@Tag(name = "GroupChat-Swagger", description = "WebSocket 구조 설명용 Swagger 문서") -//public interface GroupChatSwaggerSpecification { -// -// @Operation(summary = "Pub 채팅 메시지 전송 구조 예시", description = "실제 채팅 전송은 WebSocket을 통해 `/pub/chat/message`로 전송됩니다.") -// ApiResponse sendMessage(GroupChatSwaggerRequest request); -// -// @Operation(summary = "Sub 채팅 메시지 수신 구조 예시", description = "실제 메시지 수신은 WebSocket을 통해 `/sub/chat/room/{roomId}`로 수신됩니다.") -// ApiResponse> getMessages(Long roomId); -//} +package grep.neogul_coder.domain.groupchat.controller; + +import grep.neogul_coder.domain.groupchat.controller.dto.requset.GroupChatSwaggerRequest; +import grep.neogul_coder.domain.groupchat.controller.dto.response.GroupChatSwaggerResponse; +import grep.neogul_coder.global.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +import java.util.List; + +@Tag(name = "GroupChat-Swagger", description = "WebSocket 구조 설명용 Swagger 문서") +public interface GroupChatSwaggerSpecification { + + @Operation(summary = "Pub 채팅 메시지 전송 구조 예시", description = "실제 채팅 전송은 WebSocket을 통해 `/pub/chat/message`로 전송됩니다.") + ApiResponse sendMessage(GroupChatSwaggerRequest request); + + @Operation(summary = "Sub 채팅 메시지 수신 구조 예시", description = "실제 메시지 수신은 WebSocket을 통해 `/sub/chat/room/{roomId}`로 수신됩니다.") + ApiResponse> getMessages(Long roomId); +} diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatWebSocketController.java b/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatWebSocketController.java index 9f8e54b1..69c84e84 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatWebSocketController.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatWebSocketController.java @@ -3,11 +3,13 @@ import grep.neogul_coder.domain.groupchat.controller.dto.requset.GroupChatMessageRequestDto; import grep.neogul_coder.domain.groupchat.controller.dto.response.GroupChatMessageResponseDto; import grep.neogul_coder.domain.groupchat.service.GroupChatService; +import io.swagger.v3.oas.annotations.Hidden; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.SendTo; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Controller; +@Hidden @Controller public class GroupChatWebSocketController { diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/requset/GroupChatMessageRequestDto.java b/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/requset/GroupChatMessageRequestDto.java index ff75c1e9..a2bdabfc 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/requset/GroupChatMessageRequestDto.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/requset/GroupChatMessageRequestDto.java @@ -1,7 +1,9 @@ package grep.neogul_coder.domain.groupchat.controller.dto.requset; +import io.swagger.v3.oas.annotations.Hidden; import lombok.Getter; +@Hidden @Getter public class GroupChatMessageRequestDto { private Long roomId; diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/requset/GroupChatSwaggerRequest.java b/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/requset/GroupChatSwaggerRequest.java index 092713e8..bf5f1f1c 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/requset/GroupChatSwaggerRequest.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/requset/GroupChatSwaggerRequest.java @@ -1,25 +1,25 @@ -//package grep.neogul_coder.domain.groupchat.controller.dto.requset; -// -//import io.swagger.v3.oas.annotations.media.Schema; -//import lombok.Getter; -// -//@Getter -//@Schema(description = "Swagger용 채팅 메시지 전송 요청 DTO") -//public class GroupChatSwaggerRequest { -// -// @Schema(description = "보낸 사람 ID", example = "456") -// private Long senderId; -// -// @Schema(description = "채팅방 ID", example = "100") -// private Long roomId; -// -// @Schema(description = "보낼 메시지", example = "안녕하세요!") -// private String message; -// -// -// public void setSenderId(Long senderId) { this.senderId = senderId; } -// -// public void setRoomId(Long roomId) { this.roomId = roomId; } -// -// public void setMessage(String message) { this.message = message; } -//} +package grep.neogul_coder.domain.groupchat.controller.dto.requset; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Getter +@Schema(description = "Swagger용 채팅 메시지 전송 요청 DTO") +public class GroupChatSwaggerRequest { + + @Schema(description = "보낸 사람 ID", example = "456") + private Long senderId; + + @Schema(description = "채팅방 ID", example = "100") + private Long roomId; + + @Schema(description = "보낼 메시지", example = "안녕하세요!") + private String message; + + + public void setSenderId(Long senderId) { this.senderId = senderId; } + + public void setRoomId(Long roomId) { this.roomId = roomId; } + + public void setMessage(String message) { this.message = message; } +} diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/response/GroupChatMessageResponseDto.java b/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/response/GroupChatMessageResponseDto.java index 46ba596e..35e8a843 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/response/GroupChatMessageResponseDto.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/response/GroupChatMessageResponseDto.java @@ -1,8 +1,10 @@ package grep.neogul_coder.domain.groupchat.controller.dto.response; +import io.swagger.v3.oas.annotations.Hidden; import java.time.LocalDateTime; import lombok.Getter; +@Hidden @Getter public class GroupChatMessageResponseDto { diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/response/GroupChatSwaggerResponse.java b/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/response/GroupChatSwaggerResponse.java index 466ba921..b4fadd44 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/response/GroupChatSwaggerResponse.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/response/GroupChatSwaggerResponse.java @@ -1,32 +1,32 @@ -//package grep.neogul_coder.domain.groupchat.controller.dto.response; -// -//import io.swagger.v3.oas.annotations.media.Schema; -//import java.time.LocalDateTime; -//import lombok.Builder; -//import lombok.Getter; -// -//@Getter -//@Schema(description = "Swagger용 채팅 메시지 응답 DTO") -//public class GroupChatSwaggerResponse { -// -// @Schema(description = "메시지 고유 ID", example = "101") -// private Long id; -// -// @Schema(description = "보낸 사람 ID", example = "456") -// private Long senderId; -// -// @Schema(description = "보낸 사람 닉네임", example = "강현") -// private String senderNickname; -// -// @Schema(description = "프로필 이미지 URL", example = "https://ganghyeon.jpg") -// private String profileImageUrl; -// -// @Schema(description = "채팅방 ID", example = "100") -// private Long roomId; -// -// @Schema(description = "보낸 메시지", example = "안녕하세요!") -// private String message; -// -// @Schema(description = "보낸 시간", example = "2025-07-07T17:45:00") -// private LocalDateTime sentAt; -//} +package grep.neogul_coder.domain.groupchat.controller.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Schema(description = "Swagger용 채팅 메시지 응답 DTO") +public class GroupChatSwaggerResponse { + + @Schema(description = "메시지 고유 ID", example = "101") + private Long id; + + @Schema(description = "보낸 사람 ID", example = "456") + private Long senderId; + + @Schema(description = "보낸 사람 닉네임", example = "강현") + private String senderNickname; + + @Schema(description = "프로필 이미지 URL", example = "https://ganghyeon.jpg") + private String profileImageUrl; + + @Schema(description = "채팅방 ID", example = "100") + private Long roomId; + + @Schema(description = "보낸 메시지", example = "안녕하세요!") + private String message; + + @Schema(description = "보낸 시간", example = "2025-07-07T17:45:00") + private LocalDateTime sentAt; +} diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/repository/GroupChatRoomRepository.java b/src/main/java/grep/neogul_coder/domain/groupchat/repository/GroupChatRoomRepository.java index a9ee2e29..14127a4b 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/repository/GroupChatRoomRepository.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/repository/GroupChatRoomRepository.java @@ -14,5 +14,5 @@ public interface GroupChatRoomRepository extends JpaRepository findByStudy(Long studyId); + Optional findByStudyId(Long studyId); } From d2251638ddd4fc9e6ea406c942a29cd4010a423b Mon Sep 17 00:00:00 2001 From: dbrkdgus00 Date: Wed, 16 Jul 2025 06:16:05 +0900 Subject: [PATCH 07/34] [EA3-111] --- src/main/resources/import.sql | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/main/resources/import.sql b/src/main/resources/import.sql index f534a9b4..c973d0ea 100644 --- a/src/main/resources/import.sql +++ b/src/main/resources/import.sql @@ -120,16 +120,16 @@ INSERT INTO attendance (study_id, user_id, attendance_date) VALUES (3, 2, '2025- INSERT INTO group_chat_room (study_id) VALUES (301); INSERT INTO group_chat_room (study_id) VALUES (302); -INSERT INTO group_chat_message (room_id, user_id, content) VALUES (1, 101, '안녕하세요! 스터디 언제 시작하나요?'); -INSERT INTO group_chat_message (room_id, user_id, content) VALUES (1, 102, '오늘 저녁 8시에 시작해요!'); -INSERT INTO group_chat_message (room_id, user_id, content) VALUES (2, 103, '파일 올렸어요. 확인 부탁드려요.'); -INSERT INTO group_chat_message (room_id, user_id, content) VALUES (2, 101, '네 확인했어요. 감사합니다!'); - -INSERT INTO review (study_id, write_user_id, target_user_id, content) VALUES (1, 1, 1, '열심히 참여하셨어요.'); -INSERT INTO review (study_id, write_user_id, target_user_id, content) VALUES (1, 2, 2, '피드백이 빠르고 정확했어요. 하지만 지각을 자주하십니다'); -INSERT INTO review (study_id, write_user_id, target_user_id, content) VALUES (1, 3, 2, '커뮤니케이션이 좋았어요'); -INSERT INTO review (study_id, write_user_id, target_user_id, content) VALUES (2, 1, 2, '책임감이 느껴졌어요.'); -INSERT INTO review (study_id, write_user_id, target_user_id, content) VALUES (2, 2, 1, '팀워크가 훌륭했어요.'); +INSERT INTO group_chat_message (room_id, user_id, message) VALUES (1, 101, '안녕하세요! 스터디 언제 시작하나요?'); +INSERT INTO group_chat_message (room_id, user_id, message) VALUES (1, 102, '오늘 저녁 8시에 시작해요!'); +INSERT INTO group_chat_message (room_id, user_id, message) VALUES (2, 103, '파일 올렸어요. 확인 부탁드려요.'); +INSERT INTO group_chat_message (room_id, user_id, message) VALUES (2, 101, '네 확인했어요. 감사합니다!'); + +INSERT INTO review (study_id, write_user_id, target_user_id, message) VALUES (1, 1, 1, '열심히 참여하셨어요.'); +INSERT INTO review (study_id, write_user_id, target_user_id, message) VALUES (1, 2, 2, '피드백이 빠르고 정확했어요. 하지만 지각을 자주하십니다'); +INSERT INTO review (study_id, write_user_id, target_user_id, message) VALUES (1, 3, 2, '커뮤니케이션이 좋았어요'); +INSERT INTO review (study_id, write_user_id, target_user_id, message) VALUES (2, 1, 2, '책임감이 느껴졌어요.'); +INSERT INTO review (study_id, write_user_id, target_user_id, message) VALUES (2, 2, 1, '팀워크가 훌륭했어요.'); INSERT INTO review_tag (review_type, review_tag) VALUES ('GOOD', '항상 밝고 긍정적으로 참여 하십니다'); INSERT INTO review_tag (review_type, review_tag) VALUES ('BAD', '자주 지각을 하십니다'); From 4cac8e3f66c9b0996a79c64b78b5649bd120194e Mon Sep 17 00:00:00 2001 From: dbrkdgus00 Date: Wed, 16 Jul 2025 06:58:00 +0900 Subject: [PATCH 08/34] =?UTF-8?q?[EA3-111]=20refactor=20:=20sql=EB=AC=B8?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/import.sql | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/resources/import.sql b/src/main/resources/import.sql index c973d0ea..a5b8104f 100644 --- a/src/main/resources/import.sql +++ b/src/main/resources/import.sql @@ -125,11 +125,11 @@ INSERT INTO group_chat_message (room_id, user_id, message) VALUES (1, 102, '오 INSERT INTO group_chat_message (room_id, user_id, message) VALUES (2, 103, '파일 올렸어요. 확인 부탁드려요.'); INSERT INTO group_chat_message (room_id, user_id, message) VALUES (2, 101, '네 확인했어요. 감사합니다!'); -INSERT INTO review (study_id, write_user_id, target_user_id, message) VALUES (1, 1, 1, '열심히 참여하셨어요.'); -INSERT INTO review (study_id, write_user_id, target_user_id, message) VALUES (1, 2, 2, '피드백이 빠르고 정확했어요. 하지만 지각을 자주하십니다'); -INSERT INTO review (study_id, write_user_id, target_user_id, message) VALUES (1, 3, 2, '커뮤니케이션이 좋았어요'); -INSERT INTO review (study_id, write_user_id, target_user_id, message) VALUES (2, 1, 2, '책임감이 느껴졌어요.'); -INSERT INTO review (study_id, write_user_id, target_user_id, message) VALUES (2, 2, 1, '팀워크가 훌륭했어요.'); +INSERT INTO review (study_id, write_user_id, target_user_id, content) VALUES (1, 1, 1, '열심히 참여하셨어요.'); +INSERT INTO review (study_id, write_user_id, target_user_id, content) VALUES (1, 2, 2, '피드백이 빠르고 정확했어요. 하지만 지각을 자주하십니다'); +INSERT INTO review (study_id, write_user_id, target_user_id, content) VALUES (1, 3, 2, '커뮤니케이션이 좋았어요'); +INSERT INTO review (study_id, write_user_id, target_user_id, content) VALUES (2, 1, 2, '책임감이 느껴졌어요.'); +INSERT INTO review (study_id, write_user_id, target_user_id, content) VALUES (2, 2, 1, '팀워크가 훌륭했어요.'); INSERT INTO review_tag (review_type, review_tag) VALUES ('GOOD', '항상 밝고 긍정적으로 참여 하십니다'); INSERT INTO review_tag (review_type, review_tag) VALUES ('BAD', '자주 지각을 하십니다'); From 1aa4764a5229f2dadf22e38adb1af31113825c75 Mon Sep 17 00:00:00 2001 From: dbrkdgus00 Date: Wed, 16 Jul 2025 19:09:04 +0900 Subject: [PATCH 09/34] =?UTF-8?q?[EA3-111]=20rename=20:=20=EC=BA=98?= =?UTF-8?q?=EB=A6=B0=EB=8D=94=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=B0=8F=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EB=AA=85=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entity/{Calender.java => Calendar.java} | 2 +- ...nalCalender.java => PersonalCalendar.java} | 5 ++- .../{TeamCalender.java => TeamCalendar.java} | 5 ++- src/main/resources/import.sql | 34 +++++++++---------- 4 files changed, 22 insertions(+), 24 deletions(-) rename src/main/java/grep/neogul_coder/domain/calender/entity/{Calender.java => Calendar.java} (90%) rename src/main/java/grep/neogul_coder/domain/calender/entity/{PersonalCalender.java => PersonalCalendar.java} (72%) rename src/main/java/grep/neogul_coder/domain/calender/entity/{TeamCalender.java => TeamCalendar.java} (74%) diff --git a/src/main/java/grep/neogul_coder/domain/calender/entity/Calender.java b/src/main/java/grep/neogul_coder/domain/calender/entity/Calendar.java similarity index 90% rename from src/main/java/grep/neogul_coder/domain/calender/entity/Calender.java rename to src/main/java/grep/neogul_coder/domain/calender/entity/Calendar.java index 1687820d..d4fc0a98 100644 --- a/src/main/java/grep/neogul_coder/domain/calender/entity/Calender.java +++ b/src/main/java/grep/neogul_coder/domain/calender/entity/Calendar.java @@ -7,7 +7,7 @@ @Entity @Getter -public class Calender extends BaseEntity { +public class Calendar extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/grep/neogul_coder/domain/calender/entity/PersonalCalender.java b/src/main/java/grep/neogul_coder/domain/calender/entity/PersonalCalendar.java similarity index 72% rename from src/main/java/grep/neogul_coder/domain/calender/entity/PersonalCalender.java rename to src/main/java/grep/neogul_coder/domain/calender/entity/PersonalCalendar.java index e17aa6ad..692b197c 100644 --- a/src/main/java/grep/neogul_coder/domain/calender/entity/PersonalCalender.java +++ b/src/main/java/grep/neogul_coder/domain/calender/entity/PersonalCalendar.java @@ -1,20 +1,19 @@ package grep.neogul_coder.domain.calender.entity; -import grep.neogul_coder.domain.users.entity.User; import grep.neogul_coder.global.entity.BaseEntity; import jakarta.persistence.*; import lombok.Getter; @Entity @Getter -public class PersonalCalender extends BaseEntity { +public class PersonalCalendar extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToOne(fetch = FetchType.LAZY) - private Calender calendar; + private Calendar calendar; @Column(name = "user_id") private Long userId; diff --git a/src/main/java/grep/neogul_coder/domain/calender/entity/TeamCalender.java b/src/main/java/grep/neogul_coder/domain/calender/entity/TeamCalendar.java similarity index 74% rename from src/main/java/grep/neogul_coder/domain/calender/entity/TeamCalender.java rename to src/main/java/grep/neogul_coder/domain/calender/entity/TeamCalendar.java index 13ba1185..b8794563 100644 --- a/src/main/java/grep/neogul_coder/domain/calender/entity/TeamCalender.java +++ b/src/main/java/grep/neogul_coder/domain/calender/entity/TeamCalendar.java @@ -2,20 +2,19 @@ import grep.neogul_coder.global.entity.BaseEntity; -import grep.neogul_coder.domain.study.Study; import jakarta.persistence.*; import lombok.Getter; @Entity @Getter -public class TeamCalender extends BaseEntity { +public class TeamCalendar extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToOne(fetch = FetchType.LAZY) - private Calender calendar; + private Calendar calendar; @Column(name = "study_id") private Long studyId; diff --git a/src/main/resources/import.sql b/src/main/resources/import.sql index a5b8104f..53e0fbe3 100644 --- a/src/main/resources/import.sql +++ b/src/main/resources/import.sql @@ -40,23 +40,23 @@ INSERT INTO study_application ( recruitment_post_id, application_reason, is_read INSERT INTO study_application ( recruitment_post_id, application_reason, is_read, status) VALUES (2, '시간이 맞아 지원합니다.', FALSE, 'REJECTED'); INSERT INTO study_application ( recruitment_post_id, application_reason, is_read, status) VALUES (3, '프로젝트 경험 쌓고 싶습니다.', TRUE, 'APPROVED'); -INSERT INTO calender (scheduled_start, scheduled_end, title, content) VALUES ('2025-07-20 10:00:00', '2025-07-20 12:00:00', '스터디 회의', '진행 상황 공유'); -INSERT INTO calender (scheduled_start, scheduled_end, title, content) VALUES ('2025-07-21 09:00:00', '2025-07-21 10:30:00', '모각코', '혼자 코딩하기'); -INSERT INTO calender (scheduled_start, scheduled_end, title, content) VALUES ('2025-07-22 15:00:00', '2025-07-22 16:00:00', '운동', '헬스장 가기'); -INSERT INTO calender (scheduled_start, scheduled_end, title, content) VALUES ('2025-07-23 13:00:00', '2025-07-23 14:30:00', '리팩토링', '코드 개선'); -INSERT INTO calender (scheduled_start, scheduled_end, title, content) VALUES ('2025-07-24 11:00:00', '2025-07-24 12:00:00', '디자인 회의', 'UI 피드백'); - -INSERT INTO personal_calender (user_id, calendar_id) VALUES (101, 1); -INSERT INTO personal_calender (user_id, calendar_id) VALUES (101, 2); -INSERT INTO personal_calender (user_id, calendar_id) VALUES (102, 3); -INSERT INTO personal_calender (user_id, calendar_id) VALUES (103, 4); -INSERT INTO personal_calender (user_id, calendar_id) VALUES (104, 5); - -INSERT INTO team_calender (study_id, calendar_id) VALUES (201, 1); -INSERT INTO team_calender (study_id, calendar_id) VALUES (202, 2); -INSERT INTO team_calender (study_id, calendar_id) VALUES (201, 3); -INSERT INTO team_calender (study_id, calendar_id) VALUES (203, 4); -INSERT INTO team_calender (study_id, calendar_id) VALUES (204, 5); +INSERT INTO calendar (scheduled_start, scheduled_end, title, content) VALUES ('2025-07-20 10:00:00', '2025-07-20 12:00:00', '스터디 회의', '진행 상황 공유'); +INSERT INTO calendar (scheduled_start, scheduled_end, title, content) VALUES ('2025-07-21 09:00:00', '2025-07-21 10:30:00', '모각코', '혼자 코딩하기'); +INSERT INTO calendar (scheduled_start, scheduled_end, title, content) VALUES ('2025-07-22 15:00:00', '2025-07-22 16:00:00', '운동', '헬스장 가기'); +INSERT INTO calendar (scheduled_start, scheduled_end, title, content) VALUES ('2025-07-23 13:00:00', '2025-07-23 14:30:00', '리팩토링', '코드 개선'); +INSERT INTO calendar (scheduled_start, scheduled_end, title, content) VALUES ('2025-07-24 11:00:00', '2025-07-24 12:00:00', '디자인 회의', 'UI 피드백'); + +INSERT INTO personal_calendar (user_id, calendar_id) VALUES (101, 1); +INSERT INTO personal_calendar (user_id, calendar_id) VALUES (101, 2); +INSERT INTO personal_calendar (user_id, calendar_id) VALUES (102, 3); +INSERT INTO personal_calendar (user_id, calendar_id) VALUES (103, 4); +INSERT INTO personal_calendar (user_id, calendar_id) VALUES (104, 5); + +INSERT INTO team_calendar (study_id, calendar_id) VALUES (201, 1); +INSERT INTO team_calendar (study_id, calendar_id) VALUES (202, 2); +INSERT INTO team_calendar (study_id, calendar_id) VALUES (201, 3); +INSERT INTO team_calendar (study_id, calendar_id) VALUES (203, 4); +INSERT INTO team_calendar (study_id, calendar_id) VALUES (204, 5); INSERT INTO time_vote_period (study_id, start_date, end_date) VALUES (2, '2025-07-15 00:00:00', '2025-07-22 00:00:00'); From 0938bc45af984002fd483ddd4684d1db494252d8 Mon Sep 17 00:00:00 2001 From: dbrkdgus00 Date: Wed, 16 Jul 2025 19:52:20 +0900 Subject: [PATCH 10/34] =?UTF-8?q?[EA3-111]=20refactor=20:=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=ED=95=84=EB=93=9C=EB=AA=85=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/data.sql | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index cab4c07a..20722e1a 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -120,10 +120,10 @@ INSERT INTO attendance (study_id, user_id, attendance_date) VALUES (3, 2, '2025- INSERT INTO group_chat_room (study_id) VALUES (1); INSERT INTO group_chat_room (study_id) VALUES (2); -INSERT INTO group_chat_message (room_id, user_id, content) VALUES (1, 1, '안녕하세요! 스터디 언제 시작하나요?'); -INSERT INTO group_chat_message (room_id, user_id, content) VALUES (1, 2, '오늘 저녁 8시에 시작해요!'); -INSERT INTO group_chat_message (room_id, user_id, content) VALUES (2, 3, '파일 올렸어요. 확인 부탁드려요.'); -INSERT INTO group_chat_message (room_id, user_id, content) VALUES (2, 1, '네 확인했어요. 감사합니다!'); +INSERT INTO group_chat_message (room_id, user_id, message) VALUES (1, 1, '안녕하세요! 스터디 언제 시작하나요?'); +INSERT INTO group_chat_message (room_id, user_id, message) VALUES (1, 2, '오늘 저녁 8시에 시작해요!'); +INSERT INTO group_chat_message (room_id, user_id, message) VALUES (2, 3, '파일 올렸어요. 확인 부탁드려요.'); +INSERT INTO group_chat_message (room_id, user_id, message) VALUES (2, 1, '네 확인했어요. 감사합니다!'); INSERT INTO review (study_id, write_user_id, target_user_id, content, created_date) VALUES (1, 1, 1, '열심히 참여하셨어요.', '2025-07-10 10:00:00'); INSERT INTO review (study_id, write_user_id, target_user_id, content, created_date) VALUES (1, 2, 2, '피드백이 빠르고 정확했어요. 하지만 지각을 자주하십니다', '2025-07-11 09:30:00'); From 2aa6d16d8155d0e3eab35b3164c3e2171bf6845a Mon Sep 17 00:00:00 2001 From: dbrkdgus00 Date: Wed, 16 Jul 2025 20:38:58 +0900 Subject: [PATCH 11/34] =?UTF-8?q?[EA3-111]=20refactor=20:=20sql=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EB=82=B4=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=EB=AA=85=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/data.sql | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index 20722e1a..43ba63f5 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -46,17 +46,17 @@ INSERT INTO calendar (scheduled_start, scheduled_end, title, content) VALUES ('2 INSERT INTO calendar (scheduled_start, scheduled_end, title, content) VALUES ('2025-07-23 13:00:00', '2025-07-23 14:30:00', '리팩토링', '코드 개선'); INSERT INTO calendar (scheduled_start, scheduled_end, title, content) VALUES ('2025-07-24 11:00:00', '2025-07-24 12:00:00', '디자인 회의', 'UI 피드백'); -INSERT INTO personal_calendar (user_id, calendar_id) VALUES (101, 1); -INSERT INTO personal_calendar (user_id, calendar_id) VALUES (101, 2); -INSERT INTO personal_calendar (user_id, calendar_id) VALUES (102, 3); -INSERT INTO personal_calendar (user_id, calendar_id) VALUES (103, 4); -INSERT INTO personal_calendar (user_id, calendar_id) VALUES (104, 5); - -INSERT INTO team_calendar (study_id, calendar_id) VALUES (201, 1); -INSERT INTO team_calendar (study_id, calendar_id) VALUES (202, 2); -INSERT INTO team_calendar (study_id, calendar_id) VALUES (201, 3); -INSERT INTO team_calendar (study_id, calendar_id) VALUES (203, 4); -INSERT INTO team_calendar (study_id, calendar_id) VALUES (204, 5); +INSERT INTO personal_calendar (user_id, calendar_id) VALUES (1, 1); +INSERT INTO personal_calendar (user_id, calendar_id) VALUES (1, 2); +INSERT INTO personal_calendar (user_id, calendar_id) VALUES (2, 3); +INSERT INTO personal_calendar (user_id, calendar_id) VALUES (3, 4); +INSERT INTO personal_calendar (user_id, calendar_id) VALUES (4, 5); + +INSERT INTO team_calendar (study_id, calendar_id) VALUES (1, 1); +INSERT INTO team_calendar (study_id, calendar_id) VALUES (2, 2); +INSERT INTO team_calendar (study_id, calendar_id) VALUES (1, 3); +INSERT INTO team_calendar (study_id, calendar_id) VALUES (3, 4); +INSERT INTO team_calendar (study_id, calendar_id) VALUES (4, 5); INSERT INTO time_vote_period (study_id, start_date, end_date) VALUES (2, '2025-07-15 00:00:00', '2025-07-22 00:00:00'); @@ -120,7 +120,7 @@ INSERT INTO attendance (study_id, user_id, attendance_date) VALUES (3, 2, '2025- INSERT INTO group_chat_room (study_id) VALUES (1); INSERT INTO group_chat_room (study_id) VALUES (2); -INSERT INTO group_chat_message (room_id, user_id, message) VALUES (1, 1, '안녕하세요! 스터디 언제 시작하나요?'); +INSERT INTO group_chat_message (room_id, user_id, meesage) VALUES (1, 1, '안녕하세요! 스터디 언제 시작하나요?'); INSERT INTO group_chat_message (room_id, user_id, message) VALUES (1, 2, '오늘 저녁 8시에 시작해요!'); INSERT INTO group_chat_message (room_id, user_id, message) VALUES (2, 3, '파일 올렸어요. 확인 부탁드려요.'); INSERT INTO group_chat_message (room_id, user_id, message) VALUES (2, 1, '네 확인했어요. 감사합니다!'); @@ -141,4 +141,4 @@ INSERT INTO my_review_tag (review_tag_id, review_id) VALUES (1, 1); INSERT INTO my_review_tag (review_tag_id, review_id) VALUES (3, 1); INSERT INTO my_review_tag (review_tag_id, review_id) VALUES (2, 2); INSERT INTO my_review_tag (review_tag_id, review_id) VALUES (5, 2); -INSERT INTO my_review_tag (review_tag_id, review_id) VALUES (4, 3); +INSERT INTO my_review_tag (review_tag_id, review_id) VALUES (4, 3); \ No newline at end of file From 5a678a78b1d3d98d1ad3d3163b98eb5766e0120d Mon Sep 17 00:00:00 2001 From: dbrkdgus00 Date: Thu, 17 Jul 2025 00:42:09 +0900 Subject: [PATCH 12/34] =?UTF-8?q?[EA3-111]=20feature=20:=20=EA=B3=BC?= =?UTF-8?q?=EA=B1=B0=20=EC=B1=84=ED=8C=85=20=ED=99=95=EC=9D=B8=20=EB=B0=8F?= =?UTF-8?q?=20=EB=AC=B4=ED=95=9C=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=95=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/GroupChatRestController.java | 32 +++++++++++ .../GroupChatRestSpecification.java | 57 +++++++++++++++++++ .../GroupChatWebSocketController.java | 6 +- .../GroupChatMessageRepository.java | 11 ++++ .../groupchat/service/GroupChatService.java | 46 ++++++++++++++- 5 files changed, 149 insertions(+), 3 deletions(-) create mode 100644 src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatRestController.java create mode 100644 src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatRestSpecification.java diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatRestController.java b/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatRestController.java new file mode 100644 index 00000000..7ecdd7b4 --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatRestController.java @@ -0,0 +1,32 @@ +package grep.neogul_coder.domain.groupchat.controller; + +import grep.neogul_coder.domain.groupchat.controller.dto.response.GroupChatMessageResponseDto; +import grep.neogul_coder.domain.groupchat.service.GroupChatService; +import grep.neogul_coder.global.response.ApiResponse; +import grep.neogul_coder.global.response.PageResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/chat") +public class GroupChatRestController implements GroupChatRestSpecification { + + private final GroupChatService groupChatService; + + // 과거 채팅 메시지 페이징 조회 (무한 스크롤용) + @Override + @GetMapping("/room/{roomId}/messages") + public ApiResponse> getMessages( + @PathVariable("roomId") Long roomId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + // 서비스에서 페이징된 메시지 조회 + PageResponse pageResponse = + groupChatService.getMessages(roomId, page, size); + + // 공통 응답 형식에 맞게 반환 + return ApiResponse.success(pageResponse); + } +} diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatRestSpecification.java b/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatRestSpecification.java new file mode 100644 index 00000000..d7114251 --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatRestSpecification.java @@ -0,0 +1,57 @@ +package grep.neogul_coder.domain.groupchat.controller; + +import grep.neogul_coder.domain.groupchat.controller.dto.response.GroupChatMessageResponseDto; +import grep.neogul_coder.global.response.ApiResponse; +import grep.neogul_coder.global.response.PageResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "GroupChat-REST", description = "채팅 메시지 조회 API (무한 스크롤용)") +public interface GroupChatRestSpecification { + + @Operation( + summary = "채팅 메시지 페이징 조회", + description = """ + 채팅방의 과거 메시지를 페이지 단위로 조회합니다.
+ 프론트는 무한 스크롤 방식으로 이 API를 반복 호출하여 이전 메시지를 계속 불러올 수 있습니다.

+ + 이 API는 WebSocket의 `/sub/chat/room/{roomId}` 실시간 수신과는 별개입니다.
+ 사용자는 채팅방 입장 시 이 API로 과거 메시지를 먼저 가져오고
+ 이후 WebSocket을 연결해 실시간 메시지를 받는 방식으로 연동합니다.

+ + 전반적인 프론트 흐름은 다음과 같습니다:
+ 1. 채팅방에 처음 입장하면 → 이 API로 `page=0`부터 메시지를 조회
+ 2. 이후 스크롤을 올릴 때마다 → `page=1`, `page=2`… 순차 호출
+ 3. 동시에 WebSocket `/sub/chat/room/{roomId}` 구독 시작

+ + 파라미터 설명:
+ - `roomId`: 채팅방 식별자입니다.
+ - `page`: 0부터 시작하는 페이지 번호입니다. (0번이 가장 오래된 메시지입니다.)
+ - `size`: 한 페이지에 포함시킬 메시지 개수입니다.
+ - 메시지는 **오래된 순(오름차순)** 으로 정렬되어 반환됩니다.
+ - 프론트는 최신 메시지를 아래에 배치하고, 스크롤을 위로 올릴 때마다 과거 메시지를 불러오도록 구현합니다.

+ + + 응답 구조:
+ - `ApiResponse>` 형태로 감싸져 있습니다.
+ - 실제 메시지는 `content()` 메서드를 통해 꺼낼 수 있습니다.
+ - 페이지네이션 정보는 `currentNumber()`, `prevPage()`, `nextPage()` 등을 통해 확인할 수 있습니다.

+ + 예시 요청 URL: `/api/chat/room/1/messages?page=0&size=20` + """ + ) + + ApiResponse> getMessages( + @Parameter(description = "채팅방 ID", example = "1") + @PathVariable("roomId") Long roomId, + + @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") + @RequestParam(defaultValue = "0") int page, + + @Parameter(description = "한 페이지당 메시지 수", example = "20") + @RequestParam(defaultValue = "20") int size + ); +} diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatWebSocketController.java b/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatWebSocketController.java index 69c84e84..c7feb85c 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatWebSocketController.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatWebSocketController.java @@ -9,6 +9,7 @@ import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Controller; +// Swagger 문서에 노출되지 않도록 설정 @Hidden @Controller public class GroupChatWebSocketController { @@ -16,6 +17,7 @@ public class GroupChatWebSocketController { private final GroupChatService groupChatService; private final SimpMessagingTemplate messagingTemplate; + // 생성자 주입을 통해 필요한 서비스와 템플릿 객체 주입 public GroupChatWebSocketController(GroupChatService groupChatService, SimpMessagingTemplate messagingTemplate) { this.groupChatService = groupChatService; @@ -29,8 +31,10 @@ public void handleMessage(GroupChatMessageRequestDto requestDto) { GroupChatMessageResponseDto responseDto = groupChatService.saveMessage(requestDto); // 구독 중인 클라이언트에게 메시지 전송 (채팅방 구분) + // 클라이언트는 /sub/chat/room/{roomId} 구독 중이어야 실시간으로 수신 가능 messagingTemplate.convertAndSend( - "/sub/chat/room/" + requestDto.getRoomId(), responseDto + "/sub/chat/room/" + requestDto.getRoomId(), // 메시지를 받을 대상 + responseDto // 클라이언트에 전달할 응답 메시지 DTO ); } } diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/repository/GroupChatMessageRepository.java b/src/main/java/grep/neogul_coder/domain/groupchat/repository/GroupChatMessageRepository.java index 6fd9ac52..c5437d47 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/repository/GroupChatMessageRepository.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/repository/GroupChatMessageRepository.java @@ -1,8 +1,19 @@ package grep.neogul_coder.domain.groupchat.repository; import grep.neogul_coder.domain.groupchat.entity.GroupChatMessage; +import org.springframework.data.domain.Page; +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; public interface GroupChatMessageRepository extends JpaRepository { + + // 채팅방(roomId)에 속한 메시지를 전송 시간 내림차순으로 페이징 조회 + @Query("SELECT m FROM GroupChatMessage m " + + "WHERE m.groupChatRoom.roomId = :roomId " + + "ORDER BY m.sentAt ASC") + Page findMessagesByRoomIdAsc(@Param("roomId") Long roomId, Pageable pageable); + } diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/service/GroupChatService.java b/src/main/java/grep/neogul_coder/domain/groupchat/service/GroupChatService.java index b19bf90e..ddce78be 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/service/GroupChatService.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/service/GroupChatService.java @@ -9,6 +9,11 @@ import grep.neogul_coder.domain.study.repository.StudyMemberRepository; import grep.neogul_coder.domain.users.entity.User; import grep.neogul_coder.domain.users.repository.UserRepository; +import grep.neogul_coder.global.response.PageResponse; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import java.time.LocalDateTime; @@ -20,6 +25,7 @@ public class GroupChatService { private final UserRepository userRepository; private final StudyMemberRepository studyMemberRepository; + // 생성자 주입을 통한 의존성 주입 public GroupChatService(GroupChatMessageRepository messageRepository, GroupChatRoomRepository roomRepository, UserRepository userRepository, @@ -31,9 +37,10 @@ public GroupChatService(GroupChatMessageRepository messageRepository, } public GroupChatMessageResponseDto saveMessage(GroupChatMessageRequestDto requestDto) { - // 필요한 도메인 객체 조회 + // 채팅방 존재 여부 확인 GroupChatRoom room = roomRepository.findById(requestDto.getRoomId()) .orElseThrow(() -> new IllegalArgumentException("채팅방이 존재하지 않습니다.")); + // 메시지 발신자(사용자) 정보 조회 User sender = userRepository.findById(requestDto.getSenderId()) .orElseThrow(() -> new IllegalArgumentException("사용자가 존재하지 않습니다.")); @@ -51,9 +58,10 @@ public GroupChatMessageResponseDto saveMessage(GroupChatMessageRequestDto reques message.setMessage(requestDto.getMessage()); message.setSentAt(LocalDateTime.now()); + // 메시지 저장 messageRepository.save(message); - // 응답 DTO 구성 + // 저장된 메시지를 dto로 변환 return new GroupChatMessageResponseDto( message.getMessageId(), message.getGroupChatRoom().getRoomId(), // ← roomId 필드가 필요하면 여기서 꺼내야 함 @@ -64,4 +72,38 @@ public GroupChatMessageResponseDto saveMessage(GroupChatMessageRequestDto reques message.getSentAt() ); } + + // 과거 채팅 메시지 페이징 조회 (무한 스크롤용) + public PageResponse getMessages(Long roomId, int page, int size) { + // 오래된 메시지부터 조회되도록 정렬 기준을 ASC로 설정 + Pageable pageable = PageRequest.of(page, size, Sort.by("sentAt").ascending()); + + // JPQL 쿼리 메서드 기반 조회 + Page messages = messageRepository.findMessagesByRoomIdAsc(roomId, pageable); + + // 응답 DTO로 변환 + Page messagePage = messages.map(message -> { + User sender = userRepository.findById(message.getUserId()) + .orElseThrow(() -> new IllegalArgumentException("사용자가 존재하지 않습니다.")); + + return new GroupChatMessageResponseDto( + message.getMessageId(), + message.getGroupChatRoom().getRoomId(), + sender.getId(), + sender.getNickname(), + sender.getProfileImageUrl(), + message.getMessage(), + message.getSentAt() + ); + }); + + // PageResponse로 감싸서 반환 + return new PageResponse<>( + "/api/chat/room/" + roomId + "/messages", + messagePage, + 5 // 페이지 버튼 개수 + ); + } + + } From 6ac683788a0606206cc209e4e594dd7ca3391d28 Mon Sep 17 00:00:00 2001 From: dbrkdgus00 Date: Fri, 18 Jul 2025 16:06:49 +0900 Subject: [PATCH 13/34] =?UTF-8?q?[EA3-111]=20refactor=20:=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EB=AA=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/groupchat/controller/GroupChatRestSpecification.java | 2 +- .../groupchat/controller/GroupChatSwaggerSpecification.java | 2 +- src/main/resources/data.sql | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatRestSpecification.java b/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatRestSpecification.java index d7114251..dd865f5d 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatRestSpecification.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatRestSpecification.java @@ -9,7 +9,7 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestParam; -@Tag(name = "GroupChat-REST", description = "채팅 메시지 조회 API (무한 스크롤용)") +@Tag(name = "GroupChat", description = "채팅 메시지 조회 API (무한 스크롤용)") public interface GroupChatRestSpecification { @Operation( diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatSwaggerSpecification.java b/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatSwaggerSpecification.java index f668694d..34f95f51 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatSwaggerSpecification.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatSwaggerSpecification.java @@ -8,7 +8,7 @@ import java.util.List; -@Tag(name = "GroupChat-Swagger", description = "WebSocket 구조 설명용 Swagger 문서") +@Tag(name = "GroupChat", description = "WebSocket 구조 설명용 Swagger 문서") public interface GroupChatSwaggerSpecification { @Operation(summary = "Pub 채팅 메시지 전송 구조 예시", description = "실제 채팅 전송은 WebSocket을 통해 `/pub/chat/message`로 전송됩니다.") diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index 3537e0fb..eaf9a0a9 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -120,7 +120,7 @@ INSERT INTO attendance (study_id, user_id, attendance_date) VALUES (3, 2, '2025- INSERT INTO group_chat_room (study_id) VALUES (1); INSERT INTO group_chat_room (study_id) VALUES (2); -INSERT INTO group_chat_message (room_id, user_id, meesage) VALUES (1, 1, '안녕하세요! 스터디 언제 시작하나요?'); +INSERT INTO group_chat_message (room_id, user_id, message) VALUES (1, 1, '안녕하세요! 스터디 언제 시작하나요?'); INSERT INTO group_chat_message (room_id, user_id, message) VALUES (1, 2, '오늘 저녁 8시에 시작해요!'); INSERT INTO group_chat_message (room_id, user_id, message) VALUES (2, 3, '파일 올렸어요. 확인 부탁드려요.'); INSERT INTO group_chat_message (room_id, user_id, message) VALUES (2, 1, '네 확인했어요. 감사합니다!'); From 82e2bd4def43131c5fce6b5b528bf9453d67edf4 Mon Sep 17 00:00:00 2001 From: pia01190 Date: Sun, 20 Jul 2025 20:54:15 +0900 Subject: [PATCH 14/34] =?UTF-8?q?[EA3-162]=20feature:=20=EC=B6=9C=EC=84=9D?= =?UTF-8?q?=20=EA=B8=B0=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 --- .../domain/attendance/Attendance.java | 26 ++++++++-- .../controller/AttendanceController.java | 21 ++++++--- .../controller/AttendanceSpecification.java | 3 +- .../exception/code/AttendanceErrorCode.java | 21 +++++++++ .../repository/AttendanceRepository.java | 16 +++++++ .../attendance/service/AttendanceService.java | 47 +++++++++++++++++++ 6 files changed, 123 insertions(+), 11 deletions(-) create mode 100644 src/main/java/grep/neogul_coder/domain/attendance/exception/code/AttendanceErrorCode.java create mode 100644 src/main/java/grep/neogul_coder/domain/attendance/repository/AttendanceRepository.java create mode 100644 src/main/java/grep/neogul_coder/domain/attendance/service/AttendanceService.java diff --git a/src/main/java/grep/neogul_coder/domain/attendance/Attendance.java b/src/main/java/grep/neogul_coder/domain/attendance/Attendance.java index 0ea9ef53..9b56c225 100644 --- a/src/main/java/grep/neogul_coder/domain/attendance/Attendance.java +++ b/src/main/java/grep/neogul_coder/domain/attendance/Attendance.java @@ -2,15 +2,18 @@ import grep.neogul_coder.global.entity.BaseEntity; import jakarta.persistence.*; +import lombok.Builder; +import lombok.Getter; -import java.time.LocalDate; +import java.time.LocalDateTime; +@Getter @Entity public class Attendance extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long attendanceId; + private Long id; @Column(nullable = false) private Long studyId; @@ -19,5 +22,22 @@ public class Attendance extends BaseEntity { private Long userId; @Column(nullable = false) - private LocalDate attendanceDate; + private LocalDateTime attendanceDate; + + protected Attendance() {} + + @Builder + private Attendance(Long studyId, Long userId, LocalDateTime attendanceDate) { + this.studyId = studyId; + this.userId = userId; + this.attendanceDate = attendanceDate; + } + + public static Attendance create(Long studyId, Long userId) { + return Attendance.builder() + .studyId(studyId) + .userId(userId) + .attendanceDate(LocalDateTime.now()) + .build(); + } } diff --git a/src/main/java/grep/neogul_coder/domain/attendance/controller/AttendanceController.java b/src/main/java/grep/neogul_coder/domain/attendance/controller/AttendanceController.java index b4293f13..fcc9127d 100644 --- a/src/main/java/grep/neogul_coder/domain/attendance/controller/AttendanceController.java +++ b/src/main/java/grep/neogul_coder/domain/attendance/controller/AttendanceController.java @@ -1,25 +1,32 @@ package grep.neogul_coder.domain.attendance.controller; import grep.neogul_coder.domain.attendance.controller.dto.response.AttendanceResponse; +import grep.neogul_coder.domain.attendance.service.AttendanceService; +import grep.neogul_coder.global.auth.Principal; import grep.neogul_coder.global.response.ApiResponse; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; import java.util.List; -@RequestMapping("/api/attendances") +@RequestMapping("/api/studies/{studyId}/attendances") +@RequiredArgsConstructor @RestController public class AttendanceController implements AttendanceSpecification { + private final AttendanceService attendanceService; + @GetMapping public ApiResponse> getAttendances() { return ApiResponse.success(List.of(new AttendanceResponse())); } @PostMapping - public ApiResponse createAttendance() { - return ApiResponse.noContent(); + public ApiResponse createAttendance(@PathVariable("studyId") Long studyId, + @AuthenticationPrincipal Principal userDetails) { + Long userId = userDetails.getUserId(); + Long id = attendanceService.createAttendance(studyId, userId); + return ApiResponse.success(id); } } diff --git a/src/main/java/grep/neogul_coder/domain/attendance/controller/AttendanceSpecification.java b/src/main/java/grep/neogul_coder/domain/attendance/controller/AttendanceSpecification.java index c3ae8439..d0d21148 100644 --- a/src/main/java/grep/neogul_coder/domain/attendance/controller/AttendanceSpecification.java +++ b/src/main/java/grep/neogul_coder/domain/attendance/controller/AttendanceSpecification.java @@ -1,6 +1,7 @@ package grep.neogul_coder.domain.attendance.controller; import grep.neogul_coder.domain.attendance.controller.dto.response.AttendanceResponse; +import grep.neogul_coder.global.auth.Principal; import grep.neogul_coder.global.response.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -14,5 +15,5 @@ public interface AttendanceSpecification { ApiResponse> getAttendances(); @Operation(summary = "출석 체크", description = "스터디에 출석을 합니다.") - ApiResponse createAttendance(); + ApiResponse createAttendance(Long studyId, Principal userDetails); } diff --git a/src/main/java/grep/neogul_coder/domain/attendance/exception/code/AttendanceErrorCode.java b/src/main/java/grep/neogul_coder/domain/attendance/exception/code/AttendanceErrorCode.java new file mode 100644 index 00000000..69500110 --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/attendance/exception/code/AttendanceErrorCode.java @@ -0,0 +1,21 @@ +package grep.neogul_coder.domain.attendance.exception.code; + +import grep.neogul_coder.global.response.code.ErrorCode; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum AttendanceErrorCode implements ErrorCode { + + ATTENDANCE_ALREADY_CHECKED("A001", HttpStatus.BAD_REQUEST, "출석은 하루에 한 번만 가능합니다."); + + private final String code; + private final HttpStatus status; + private final String message; + + AttendanceErrorCode(String code, HttpStatus status, String message) { + this.code = code; + this.status = status; + this.message = message; + } +} diff --git a/src/main/java/grep/neogul_coder/domain/attendance/repository/AttendanceRepository.java b/src/main/java/grep/neogul_coder/domain/attendance/repository/AttendanceRepository.java new file mode 100644 index 00000000..78b03217 --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/attendance/repository/AttendanceRepository.java @@ -0,0 +1,16 @@ +package grep.neogul_coder.domain.attendance.repository; + +import grep.neogul_coder.domain.attendance.Attendance; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; + +public interface AttendanceRepository extends JpaRepository { + @Query("select count(a) > 0 from Attendance a where a.studyId = :studyId and a.userId = :userId and a.attendanceDate between :startOfDay and :endOfDay") + boolean existsTodayAttendance(@Param("studyId") Long studyId, + @Param("userId") Long userId, + @Param("startOfDay") LocalDateTime startOfDay, + @Param("endOfDay") LocalDateTime endOfDay); +} diff --git a/src/main/java/grep/neogul_coder/domain/attendance/service/AttendanceService.java b/src/main/java/grep/neogul_coder/domain/attendance/service/AttendanceService.java new file mode 100644 index 00000000..4df1d4cb --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/attendance/service/AttendanceService.java @@ -0,0 +1,47 @@ +package grep.neogul_coder.domain.attendance.service; + +import grep.neogul_coder.domain.attendance.Attendance; +import grep.neogul_coder.domain.attendance.repository.AttendanceRepository; +import grep.neogul_coder.domain.study.Study; +import grep.neogul_coder.domain.study.repository.StudyRepository; +import grep.neogul_coder.global.exception.business.BusinessException; +import grep.neogul_coder.global.exception.business.NotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; + +import static grep.neogul_coder.domain.attendance.exception.code.AttendanceErrorCode.*; +import static grep.neogul_coder.domain.study.exception.code.StudyErrorCode.*; + +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Service +public class AttendanceService { + + private final AttendanceRepository attendanceRepository; + private final StudyRepository studyRepository; + + @Transactional + public Long createAttendance(Long studyId, Long userId) { + Study study = studyRepository.findByIdAndActivatedTrue(studyId) + .orElseThrow(() -> new NotFoundException(STUDY_NOT_FOUND)); + + validateNotAlreadyChecked(studyId, userId); + + Attendance attendance = Attendance.create(studyId, userId); + attendanceRepository.save(attendance); + return attendance.getId(); + } + + private void validateNotAlreadyChecked(Long studyId, Long userId) { + LocalDateTime startOfDay = LocalDate.now().atStartOfDay(); + LocalDateTime endOfDay = LocalDate.now().atTime(LocalTime.MAX); + if (attendanceRepository.existsTodayAttendance(studyId, userId, startOfDay, endOfDay)) { + throw new BusinessException(ATTENDANCE_ALREADY_CHECKED); + } + } +} From 6382e80ef7dc35aa5549607ad1df5c9e5bb7d686 Mon Sep 17 00:00:00 2001 From: pia01190 Date: Sun, 20 Jul 2025 23:27:00 +0900 Subject: [PATCH 15/34] =?UTF-8?q?[EA3-162]=20feature:=20=EC=B6=9C=EC=84=9D?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=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 --- .../controller/AttendanceController.java | 10 ++--- .../controller/AttendanceSpecification.java | 3 +- .../dto/response/AttendanceInfoResponse.java | 30 +++++++++++++++ .../dto/response/AttendanceResponse.java | 17 +++++++++ .../repository/AttendanceRepository.java | 9 +++-- .../attendance/service/AttendanceService.java | 38 ++++++++++++++++++- .../repository/StudyMemberRepository.java | 4 ++ 7 files changed, 100 insertions(+), 11 deletions(-) create mode 100644 src/main/java/grep/neogul_coder/domain/attendance/controller/dto/response/AttendanceInfoResponse.java diff --git a/src/main/java/grep/neogul_coder/domain/attendance/controller/AttendanceController.java b/src/main/java/grep/neogul_coder/domain/attendance/controller/AttendanceController.java index fcc9127d..3994a76e 100644 --- a/src/main/java/grep/neogul_coder/domain/attendance/controller/AttendanceController.java +++ b/src/main/java/grep/neogul_coder/domain/attendance/controller/AttendanceController.java @@ -1,6 +1,6 @@ package grep.neogul_coder.domain.attendance.controller; -import grep.neogul_coder.domain.attendance.controller.dto.response.AttendanceResponse; +import grep.neogul_coder.domain.attendance.controller.dto.response.AttendanceInfoResponse; import grep.neogul_coder.domain.attendance.service.AttendanceService; import grep.neogul_coder.global.auth.Principal; import grep.neogul_coder.global.response.ApiResponse; @@ -8,8 +8,6 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -import java.util.List; - @RequestMapping("/api/studies/{studyId}/attendances") @RequiredArgsConstructor @RestController @@ -18,8 +16,10 @@ public class AttendanceController implements AttendanceSpecification { private final AttendanceService attendanceService; @GetMapping - public ApiResponse> getAttendances() { - return ApiResponse.success(List.of(new AttendanceResponse())); + public ApiResponse getAttendances(@PathVariable("studyId") Long studyId, + @AuthenticationPrincipal Principal userDetails) { + AttendanceInfoResponse attendances = attendanceService.getAttendances(studyId, userDetails.getUserId()); + return ApiResponse.success(attendances); } @PostMapping diff --git a/src/main/java/grep/neogul_coder/domain/attendance/controller/AttendanceSpecification.java b/src/main/java/grep/neogul_coder/domain/attendance/controller/AttendanceSpecification.java index d0d21148..41144e1f 100644 --- a/src/main/java/grep/neogul_coder/domain/attendance/controller/AttendanceSpecification.java +++ b/src/main/java/grep/neogul_coder/domain/attendance/controller/AttendanceSpecification.java @@ -1,5 +1,6 @@ package grep.neogul_coder.domain.attendance.controller; +import grep.neogul_coder.domain.attendance.controller.dto.response.AttendanceInfoResponse; import grep.neogul_coder.domain.attendance.controller.dto.response.AttendanceResponse; import grep.neogul_coder.global.auth.Principal; import grep.neogul_coder.global.response.ApiResponse; @@ -12,7 +13,7 @@ public interface AttendanceSpecification { @Operation(summary = "출석 조회", description = "일주일 단위로 출석을 조회합니다.") - ApiResponse> getAttendances(); + ApiResponse getAttendances(Long studyId, Principal userDetails); @Operation(summary = "출석 체크", description = "스터디에 출석을 합니다.") ApiResponse createAttendance(Long studyId, Principal userDetails); diff --git a/src/main/java/grep/neogul_coder/domain/attendance/controller/dto/response/AttendanceInfoResponse.java b/src/main/java/grep/neogul_coder/domain/attendance/controller/dto/response/AttendanceInfoResponse.java new file mode 100644 index 00000000..fb9f3838 --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/attendance/controller/dto/response/AttendanceInfoResponse.java @@ -0,0 +1,30 @@ +package grep.neogul_coder.domain.attendance.controller.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +public class AttendanceInfoResponse { + + @Schema(description = "출석일 목록") + private List attendances; + + @Schema(description = "출석률", example = "50") + private int attendanceRate; + + @Builder + private AttendanceInfoResponse(List attendances, int attendanceRate) { + this.attendances = attendances; + this.attendanceRate = attendanceRate; + } + + public static AttendanceInfoResponse of(List responses, int attendanceRate) { + return AttendanceInfoResponse.builder() + .attendances(responses) + .attendanceRate(attendanceRate) + .build(); + } +} diff --git a/src/main/java/grep/neogul_coder/domain/attendance/controller/dto/response/AttendanceResponse.java b/src/main/java/grep/neogul_coder/domain/attendance/controller/dto/response/AttendanceResponse.java index 38e818bf..e43f5fbd 100644 --- a/src/main/java/grep/neogul_coder/domain/attendance/controller/dto/response/AttendanceResponse.java +++ b/src/main/java/grep/neogul_coder/domain/attendance/controller/dto/response/AttendanceResponse.java @@ -1,6 +1,8 @@ package grep.neogul_coder.domain.attendance.controller.dto.response; +import grep.neogul_coder.domain.attendance.Attendance; import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; import lombok.Getter; import java.time.LocalDate; @@ -16,4 +18,19 @@ public class AttendanceResponse { @Schema(description = "출석일", example = "2025-07-10") private LocalDate attendanceDate; + + @Builder + private AttendanceResponse(Long studyId, Long userId, LocalDate attendanceDate) { + this.studyId = studyId; + this.userId = userId; + this.attendanceDate = attendanceDate; + } + + public static AttendanceResponse from(Attendance attendance) { + return AttendanceResponse.builder() + .studyId(attendance.getStudyId()) + .userId(attendance.getUserId()) + .attendanceDate(attendance.getAttendanceDate().toLocalDate()) + .build(); + } } diff --git a/src/main/java/grep/neogul_coder/domain/attendance/repository/AttendanceRepository.java b/src/main/java/grep/neogul_coder/domain/attendance/repository/AttendanceRepository.java index 78b03217..a199c0ce 100644 --- a/src/main/java/grep/neogul_coder/domain/attendance/repository/AttendanceRepository.java +++ b/src/main/java/grep/neogul_coder/domain/attendance/repository/AttendanceRepository.java @@ -6,11 +6,14 @@ import org.springframework.data.repository.query.Param; import java.time.LocalDateTime; +import java.util.List; public interface AttendanceRepository extends JpaRepository { @Query("select count(a) > 0 from Attendance a where a.studyId = :studyId and a.userId = :userId and a.attendanceDate between :startOfDay and :endOfDay") boolean existsTodayAttendance(@Param("studyId") Long studyId, - @Param("userId") Long userId, - @Param("startOfDay") LocalDateTime startOfDay, - @Param("endOfDay") LocalDateTime endOfDay); + @Param("userId") Long userId, + @Param("startOfDay") LocalDateTime startOfDay, + @Param("endOfDay") LocalDateTime endOfDay); + + List findByStudyIdAndUserId(Long studyId, Long userId); } diff --git a/src/main/java/grep/neogul_coder/domain/attendance/service/AttendanceService.java b/src/main/java/grep/neogul_coder/domain/attendance/service/AttendanceService.java index 4df1d4cb..1738dad5 100644 --- a/src/main/java/grep/neogul_coder/domain/attendance/service/AttendanceService.java +++ b/src/main/java/grep/neogul_coder/domain/attendance/service/AttendanceService.java @@ -1,8 +1,11 @@ package grep.neogul_coder.domain.attendance.service; import grep.neogul_coder.domain.attendance.Attendance; +import grep.neogul_coder.domain.attendance.controller.dto.response.AttendanceInfoResponse; +import grep.neogul_coder.domain.attendance.controller.dto.response.AttendanceResponse; import grep.neogul_coder.domain.attendance.repository.AttendanceRepository; import grep.neogul_coder.domain.study.Study; +import grep.neogul_coder.domain.study.repository.StudyMemberRepository; import grep.neogul_coder.domain.study.repository.StudyRepository; import grep.neogul_coder.global.exception.business.BusinessException; import grep.neogul_coder.global.exception.business.NotFoundException; @@ -13,9 +16,11 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; +import java.time.temporal.ChronoUnit; +import java.util.List; -import static grep.neogul_coder.domain.attendance.exception.code.AttendanceErrorCode.*; -import static grep.neogul_coder.domain.study.exception.code.StudyErrorCode.*; +import static grep.neogul_coder.domain.attendance.exception.code.AttendanceErrorCode.ATTENDANCE_ALREADY_CHECKED; +import static grep.neogul_coder.domain.study.exception.code.StudyErrorCode.STUDY_NOT_FOUND; @Transactional(readOnly = true) @RequiredArgsConstructor @@ -24,6 +29,21 @@ public class AttendanceService { private final AttendanceRepository attendanceRepository; private final StudyRepository studyRepository; + private final StudyMemberRepository studyMemberRepository; + + public AttendanceInfoResponse getAttendances(Long studyId, Long userId) { + Study study = studyRepository.findByIdAndActivatedTrue(studyId) + .orElseThrow(() -> new NotFoundException(STUDY_NOT_FOUND)); + + List attendances = attendanceRepository.findByStudyIdAndUserId(studyId, userId); + List responses = attendances.stream() + .map(AttendanceResponse::from) + .toList(); + + int attendanceRate = getAttendanceRate(studyId, userId, study, responses); + + return AttendanceInfoResponse.of(responses, attendanceRate); + } @Transactional public Long createAttendance(Long studyId, Long userId) { @@ -37,9 +57,23 @@ public Long createAttendance(Long studyId, Long userId) { return attendance.getId(); } + private int getAttendanceRate(Long studyId, Long userId, Study study, List responses) { + LocalDate start = study.getStartDate().toLocalDate(); + LocalDate participated = studyMemberRepository.findCreatedDateByStudyIdAndUserId(studyId, userId).toLocalDate(); + LocalDate attendanceStart = start.isAfter(participated) ? start : participated; + LocalDate end = study.getEndDate().toLocalDate(); + + int totalDays = (int) ChronoUnit.DAYS.between(attendanceStart, end) + 1; + int attendDays = responses.size(); + int attendanceRate = totalDays == 0 ? 0 : Math.round(((float) attendDays / totalDays) * 100); + + return attendanceRate; + } + private void validateNotAlreadyChecked(Long studyId, Long userId) { LocalDateTime startOfDay = LocalDate.now().atStartOfDay(); LocalDateTime endOfDay = LocalDate.now().atTime(LocalTime.MAX); + if (attendanceRepository.existsTodayAttendance(studyId, userId, startOfDay, endOfDay)) { throw new BusinessException(ATTENDANCE_ALREADY_CHECKED); } diff --git a/src/main/java/grep/neogul_coder/domain/study/repository/StudyMemberRepository.java b/src/main/java/grep/neogul_coder/domain/study/repository/StudyMemberRepository.java index e508d69d..2ef3df4a 100644 --- a/src/main/java/grep/neogul_coder/domain/study/repository/StudyMemberRepository.java +++ b/src/main/java/grep/neogul_coder/domain/study/repository/StudyMemberRepository.java @@ -7,6 +7,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -31,4 +32,7 @@ public interface StudyMemberRepository extends JpaRepository @Query("select m from StudyMember m where m.study.id = :studyId and m.role = 'MEMBER' and m.activated = true") List findAvailableNewLeaders(@Param("studyId") Long studyId); + + @Query("select m.createdDate from StudyMember m where m.study.id = :studyId and m.userId = :userId and m.activated = true") + LocalDateTime findCreatedDateByStudyIdAndUserId(@Param("studyId") Long studyId, @Param("userId") Long userId); } From f5ff6c4777b6310d23b99943b909c599fe9e2933 Mon Sep 17 00:00:00 2001 From: Tokwasp Date: Mon, 21 Jul 2025 13:07:56 +0900 Subject: [PATCH 16/34] =?UTF-8?q?[EA3-163]=20feature:=20=EC=8A=A4=ED=84=B0?= =?UTF-8?q?=EB=94=94=20=EA=B2=8C=EC=8B=9C=ED=8C=90=20CUD=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/StudyResponse.java | 2 +- .../domain/studypost/StudyPost.java | 66 +++++++------ .../domain/studypost/StudyPostErrorCode.java | 22 +++++ .../controller/StudyPostController.java | 94 ++++++++++--------- .../controller/StudyPostSpecification.java | 63 ++++++------- .../dto/request/StudyPostSaveRequest.java | 45 +++++++++ .../dto/request/StudyPostUpdateRequest.java | 26 +++++ .../studypost/dto/StudyPostRequest.java | 22 ----- .../repository/StudyPostQueryRepository.java | 33 +++++++ .../studypost/service/StudyPostService.java | 62 ++++++++++-- ...tStudyPostCommentQueryRepositoryTest.java} | 0 11 files changed, 300 insertions(+), 135 deletions(-) create mode 100644 src/main/java/grep/neogul_coder/domain/studypost/StudyPostErrorCode.java create mode 100644 src/main/java/grep/neogul_coder/domain/studypost/controller/dto/request/StudyPostSaveRequest.java create mode 100644 src/main/java/grep/neogul_coder/domain/studypost/controller/dto/request/StudyPostUpdateRequest.java delete mode 100644 src/main/java/grep/neogul_coder/domain/studypost/dto/StudyPostRequest.java create mode 100644 src/main/java/grep/neogul_coder/domain/studypost/repository/StudyPostQueryRepository.java rename src/test/java/grep/neogul_coder/domain/recruitment/post/repository/{RecruitmentPostCommentQueryRepositoryTest.java => RecruitmentPostStudyPostCommentQueryRepositoryTest.java} (100%) diff --git a/src/main/java/grep/neogul_coder/domain/study/controller/dto/response/StudyResponse.java b/src/main/java/grep/neogul_coder/domain/study/controller/dto/response/StudyResponse.java index 49102dd5..f661ae00 100644 --- a/src/main/java/grep/neogul_coder/domain/study/controller/dto/response/StudyResponse.java +++ b/src/main/java/grep/neogul_coder/domain/study/controller/dto/response/StudyResponse.java @@ -2,7 +2,7 @@ import grep.neogul_coder.domain.attendance.controller.dto.response.AttendanceResponse; import grep.neogul_coder.domain.calender.controller.dto.response.TeamCalendarResponse; -import grep.neogul_coder.domain.studypost.dto.StudyPostListResponse; +import grep.neogul_coder.domain.studypost.controller.dto.StudyPostListResponse; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; diff --git a/src/main/java/grep/neogul_coder/domain/studypost/StudyPost.java b/src/main/java/grep/neogul_coder/domain/studypost/StudyPost.java index bcd5e230..b4f83105 100644 --- a/src/main/java/grep/neogul_coder/domain/studypost/StudyPost.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/StudyPost.java @@ -2,44 +2,54 @@ import grep.neogul_coder.domain.study.Study; import grep.neogul_coder.global.entity.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -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.validation.constraints.NotBlank; +import jakarta.persistence.*; +import lombok.Builder; import lombok.Getter; @Getter @Entity public class StudyPost extends BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "study_id", nullable = false) - private Study study; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "study_id", nullable = false) + private Study study; - @Column(nullable = false) - private Long userId; + @Column(nullable = false) + private Long userId; - @NotBlank(message = "제목은 필수입니다.") - @Column(nullable = false) - private String title; + @Column(nullable = false) + private String title; - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private Category category; + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Category category; - @NotBlank(message = "내용은 필수입니다.") - @Column(nullable = false) - private String content; + @Column(nullable = false) + private String content; + @Builder + private StudyPost(Long userId, String title, Category category, String content) { + this.userId = userId; + this.title = title; + this.category = category; + this.content = content; + } + + public void connectStudy(Study study) { + this.study = study; + } + + public void update(Category category, String title, String content) { + this.category = category; + this.title = title; + this.content = content; + } + + public void delete() { + this.activated = false; + } } diff --git a/src/main/java/grep/neogul_coder/domain/studypost/StudyPostErrorCode.java b/src/main/java/grep/neogul_coder/domain/studypost/StudyPostErrorCode.java new file mode 100644 index 00000000..54a236a6 --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/studypost/StudyPostErrorCode.java @@ -0,0 +1,22 @@ +package grep.neogul_coder.domain.studypost; + +import grep.neogul_coder.global.response.code.ErrorCode; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum StudyPostErrorCode implements ErrorCode { + NOT_FOUND_STUDY(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.name(), "해당 스터디를 찾지 못했습니다."), + NOT_FOUND_POST(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.name(), "해당 스터디 게시글을 찾지 못했습니다."); + + private static final String BASIC_ERROR_NAME = "STUDY_POST"; + private final HttpStatus status; + private final String code; + private final String message; + + StudyPostErrorCode(HttpStatus status, String code, String message) { + this.status = status; + this.code = BASIC_ERROR_NAME + ": " + code; + this.message = message; + } +} diff --git a/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostController.java b/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostController.java index 979e8c84..66be8b5f 100644 --- a/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostController.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostController.java @@ -1,55 +1,59 @@ package grep.neogul_coder.domain.studypost.controller; -import grep.neogul_coder.domain.studypost.dto.StudyPostDetailResponse; -import grep.neogul_coder.domain.studypost.dto.StudyPostListResponse; -import grep.neogul_coder.domain.studypost.dto.StudyPostRequest; +import grep.neogul_coder.domain.studypost.controller.dto.StudyPostDetailResponse; +import grep.neogul_coder.domain.studypost.controller.dto.StudyPostListResponse; +import grep.neogul_coder.domain.studypost.controller.dto.request.StudyPostSaveRequest; +import grep.neogul_coder.domain.studypost.controller.dto.request.StudyPostUpdateRequest; +import grep.neogul_coder.domain.studypost.service.StudyPostService; +import grep.neogul_coder.global.auth.Principal; import grep.neogul_coder.global.response.ApiResponse; import jakarta.validation.Valid; -import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import java.util.List; + +@RequiredArgsConstructor +@RequestMapping("/api/posts") @RestController -@RequestMapping("/api/studies/{studyId}/posts") public class StudyPostController implements StudyPostSpecification { - @PostMapping - public ApiResponse create( - @PathVariable("studyId") Long studyId, - @RequestBody @Valid StudyPostRequest request - ) { - return ApiResponse.noContent(); - } - - @GetMapping("/all") - public ApiResponse> findAllWithoutPagination( - @PathVariable("studyId") Long studyId - ) { - List content = List.of(new StudyPostListResponse()); - return ApiResponse.success(content); - } - - @GetMapping("/{postId}") - public ApiResponse findOne( - @PathVariable("studyId") Long studyId, - @PathVariable("postId") Long postId - ) { - return ApiResponse.success(new StudyPostDetailResponse()); - } - - @PutMapping("/{postId}") - public ApiResponse update( - @PathVariable("studyId") Long studyId, - @PathVariable("postId") Long postId, - @RequestBody @Valid StudyPostRequest request - ) { - return ApiResponse.noContent(); - } - - @DeleteMapping("/{postId}") - public ApiResponse delete( - @PathVariable("studyId") Long studyId, - @PathVariable("postId") Long postId - ) { - return ApiResponse.noContent(); - } + private final StudyPostService studyPostService; + + @PostMapping + public ApiResponse create(@RequestBody @Valid StudyPostSaveRequest request, + @AuthenticationPrincipal Principal userDetails) { + long postId = studyPostService.create(request, userDetails.getUserId()); + return ApiResponse.success(postId); + } + + @GetMapping("/{postId}") + public ApiResponse findOne(@PathVariable("postId") Long postId) { + studyPostService.findOne(postId); + return ApiResponse.success(new StudyPostDetailResponse()); + } + + @GetMapping("/all") + public ApiResponse> findAllWithoutPagination( + @PathVariable("studyId") Long studyId + ) { + List content = List.of(new StudyPostListResponse()); + return ApiResponse.success(content); + } + + @PutMapping("/{postId}") + public ApiResponse update(@PathVariable("postId") Long postId, + @RequestBody @Valid StudyPostUpdateRequest request, + @AuthenticationPrincipal Principal userDetails) { + studyPostService.update(request, postId, userDetails.getUserId()); + return ApiResponse.noContent(); + } + + @DeleteMapping("/{postId}") + public ApiResponse delete(@PathVariable("postId") Long postId, + @AuthenticationPrincipal Principal userDetails) { + studyPostService.delete(postId, userDetails.getUserId()); + return ApiResponse.noContent(); + } } diff --git a/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostSpecification.java b/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostSpecification.java index f6a77b02..983bb6c0 100644 --- a/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostSpecification.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostSpecification.java @@ -1,45 +1,42 @@ package grep.neogul_coder.domain.studypost.controller; -import grep.neogul_coder.domain.studypost.dto.StudyPostDetailResponse; -import grep.neogul_coder.domain.studypost.dto.StudyPostListResponse; -import grep.neogul_coder.domain.studypost.dto.StudyPostRequest; +import grep.neogul_coder.domain.studypost.controller.dto.StudyPostDetailResponse; +import grep.neogul_coder.domain.studypost.controller.dto.StudyPostListResponse; +import grep.neogul_coder.domain.studypost.controller.dto.request.StudyPostSaveRequest; +import grep.neogul_coder.domain.studypost.controller.dto.request.StudyPostUpdateRequest; +import grep.neogul_coder.global.auth.Principal; import grep.neogul_coder.global.response.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; +import org.springframework.security.core.annotation.AuthenticationPrincipal; + import java.util.List; @Tag(name = "Study-Post", description = "스터디 게시판 API") public interface StudyPostSpecification { - @Operation(summary = "게시글 생성", description = "스터디에 새로운 게시글을 작성합니다.") - ApiResponse create( - @Parameter(description = "스터디 ID", example = "1") Long studyId, - @Valid StudyPostRequest request - ); - - @Operation(summary = "게시글 목록 전체 조회", description = "스터디의 게시글 전체 목록을 조회합니다.") - ApiResponse> findAllWithoutPagination( - @Parameter(description = "스터디 ID", example = "1") Long studyId - ); - - @Operation(summary = "게시글 상세 조회", description = "특정 게시글의 상세 정보를 조회합니다.") - ApiResponse findOne( - @Parameter(description = "스터디 ID", example = "1") Long studyId, - @Parameter(description = "게시글 ID", example = "15") Long postId - ); - - @Operation(summary = "게시글 수정", description = "기존 게시글을 수정합니다.") - ApiResponse update( - @Parameter(description = "스터디 ID", example = "1") Long studyId, - @Parameter(description = "게시글 ID", example = "15") Long postId, - @Valid StudyPostRequest request - ); - - @Operation(summary = "게시글 삭제", description = "특정 게시글을 삭제합니다.") - ApiResponse delete( - @Parameter(description = "스터디 ID", example = "1") Long studyId, - @Parameter(description = "게시글 ID", example = "15") Long postId - ); + @Operation(summary = "게시글 생성", description = "스터디에 새로운 게시글을 작성합니다.") + ApiResponse create(StudyPostSaveRequest request, Principal userDetails); + + @Operation(summary = "게시글 목록 전체 조회", description = "스터디의 게시글 전체 목록을 조회합니다.") + ApiResponse> findAllWithoutPagination( + @Parameter(description = "스터디 ID", example = "1") Long studyId + ); + + @Operation(summary = "게시글 상세 조회", description = "특정 게시글의 상세 정보를 조회합니다.") + ApiResponse findOne( + @Parameter(description = "게시글 ID", example = "15") Long postId + ); + + @Operation(summary = "게시글 수정", description = "기존 게시글을 수정합니다.") + ApiResponse update( + @Parameter(description = "게시글 ID", example = "15") Long postId, + StudyPostUpdateRequest request, + Principal userDetails + ); + + @Operation(summary = "게시글 삭제", description = "특정 게시글을 삭제합니다.") + ApiResponse delete(@Parameter(description = "게시글 ID", example = "15") Long postId, + @AuthenticationPrincipal Principal userDetails); } diff --git a/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/request/StudyPostSaveRequest.java b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/request/StudyPostSaveRequest.java new file mode 100644 index 00000000..9c05f9f1 --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/request/StudyPostSaveRequest.java @@ -0,0 +1,45 @@ +package grep.neogul_coder.domain.studypost.controller.dto.request; + +import grep.neogul_coder.domain.study.Study; +import grep.neogul_coder.domain.studypost.Category; +import grep.neogul_coder.domain.studypost.StudyPost; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +@Getter +@Schema(description = "스터디 게시글 저장 요청 DTO") +public class StudyPostSaveRequest { + + @Schema(description = "3", example = "스터디 ID") + @NotNull + private long studyId; + + @Schema(description = "제목", example = "스터디 공지") + @NotBlank + private String title; + + @Schema(description = "카테고리: NOTICE(공지), FREE(자유)", example = "NOTICE") + @NotBlank + private Category category; + + @Schema(description = "내용", example = "오늘은 각자 공부한 내용에 대해 발표가 있는 날 입니다!") + @NotBlank + private String content; + + private StudyPostSaveRequest() { + } + + public StudyPost toEntity(Study study, long userId) { + StudyPost studyPost = StudyPost.builder() + .userId(userId) + .title(this.title) + .category(this.category) + .content(this.content) + .build(); + + studyPost.connectStudy(study); + return studyPost; + } +} diff --git a/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/request/StudyPostUpdateRequest.java b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/request/StudyPostUpdateRequest.java new file mode 100644 index 00000000..5bb9291c --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/request/StudyPostUpdateRequest.java @@ -0,0 +1,26 @@ +package grep.neogul_coder.domain.studypost.controller.dto.request; + +import grep.neogul_coder.domain.studypost.Category; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; + +@Getter +@Schema(description = "스터디 게시글 수정 요청 DTO") +public class StudyPostUpdateRequest { + + @Schema(description = "제목", example = "스터디 공지") + @NotBlank + private String title; + + @Schema(description = "카테고리: NOTICE(공지), FREE(자유)", example = "NOTICE") + @NotBlank + private Category category; + + @Schema(description = "내용", example = "오늘은 각자 공부한 내용에 대해 발표가 있는 날 입니다!") + @NotBlank + private String content; + + private StudyPostUpdateRequest() { + } +} diff --git a/src/main/java/grep/neogul_coder/domain/studypost/dto/StudyPostRequest.java b/src/main/java/grep/neogul_coder/domain/studypost/dto/StudyPostRequest.java deleted file mode 100644 index ac3ec449..00000000 --- a/src/main/java/grep/neogul_coder/domain/studypost/dto/StudyPostRequest.java +++ /dev/null @@ -1,22 +0,0 @@ -package grep.neogul_coder.domain.studypost.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotBlank; -import lombok.Getter; - -@Getter -@Schema(description = "스터디 게시글 저장/수정 요청 DTO") -public class StudyPostRequest { - - @Schema(description = "제목", example = "스터디 공지") - @NotBlank - private String title; - - @Schema(description = "카테고리: NOTICE(공지), FREE(자유)", example = "NOTICE") - @NotBlank - private String category; - - @Schema(description = "내용", example = "오늘은 각자 공부한 내용에 대해 발표가 있는 날 입니다!") - @NotBlank - private String content; -} diff --git a/src/main/java/grep/neogul_coder/domain/studypost/repository/StudyPostQueryRepository.java b/src/main/java/grep/neogul_coder/domain/studypost/repository/StudyPostQueryRepository.java new file mode 100644 index 00000000..dee5566b --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/studypost/repository/StudyPostQueryRepository.java @@ -0,0 +1,33 @@ +package grep.neogul_coder.domain.studypost.repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import grep.neogul_coder.domain.studypost.QStudyPost; +import grep.neogul_coder.domain.studypost.StudyPost; +import jakarta.persistence.EntityManager; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public class StudyPostQueryRepository { + + private final EntityManager em; + private final JPAQueryFactory queryFactory; + + public StudyPostQueryRepository(EntityManager em) { + this.em = em; + this.queryFactory = new JPAQueryFactory(em); + } + + public Optional findByIdAndUserId(Long postId, long userId) { + StudyPost studyPost = queryFactory.selectFrom(QStudyPost.studyPost) + .where( + QStudyPost.studyPost.id.eq(postId), + QStudyPost.studyPost.userId.eq(userId), + QStudyPost.studyPost.activated.isTrue() + ) + .fetchOne(); + + return Optional.ofNullable(studyPost); + } +} diff --git a/src/main/java/grep/neogul_coder/domain/studypost/service/StudyPostService.java b/src/main/java/grep/neogul_coder/domain/studypost/service/StudyPostService.java index 2c250a83..d6a14d88 100644 --- a/src/main/java/grep/neogul_coder/domain/studypost/service/StudyPostService.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/service/StudyPostService.java @@ -1,21 +1,71 @@ package grep.neogul_coder.domain.studypost.service; +import grep.neogul_coder.domain.study.Study; +import grep.neogul_coder.domain.study.StudyMember; +import grep.neogul_coder.domain.study.repository.StudyMemberQueryRepository; import grep.neogul_coder.domain.studypost.StudyPost; -import grep.neogul_coder.domain.studypost.exception.PostNotFoundException; -import grep.neogul_coder.domain.studypost.exception.code.PostErrorCode; +import grep.neogul_coder.domain.studypost.comment.repository.StudyCommentQueryRepository; +import grep.neogul_coder.domain.studypost.controller.dto.request.StudyPostSaveRequest; +import grep.neogul_coder.domain.studypost.controller.dto.request.StudyPostUpdateRequest; +import grep.neogul_coder.domain.studypost.repository.StudyPostQueryRepository; import grep.neogul_coder.domain.studypost.repository.StudyPostRepository; +import grep.neogul_coder.global.exception.business.NotFoundException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.List; + +import static grep.neogul_coder.domain.studypost.StudyPostErrorCode.*; + +@Transactional(readOnly = true) @Service @RequiredArgsConstructor public class StudyPostService { - private final StudyPostRepository StudyPostRepository; + private final StudyMemberQueryRepository studyQueryRepository; + private final StudyPostRepository studyPostRepository; + private final StudyPostQueryRepository studyPostQueryRepository; + + private final StudyCommentQueryRepository commentQueryRepository; + + public void findOne(Long postId) { + commentQueryRepository.findAllFetchPostByPostId(postId); + + + } + + @Transactional + public long create(StudyPostSaveRequest request, long userId) { + List myStudies = studyQueryRepository.findAllFetchStudyByUserId(userId); + Study study = extractTargetStudyById(myStudies, request.getStudyId()); + return studyPostRepository.save(request.toEntity(study, userId)).getId(); + } + + @Transactional + public void update(StudyPostUpdateRequest request, Long postId, long userId) { + StudyPost studyPost = studyPostQueryRepository.findByIdAndUserId(postId, userId) + .orElseThrow(() -> new NotFoundException(NOT_FOUND_POST)); - public StudyPost findById(Long id) { - return StudyPostRepository.findById(id).orElseThrow(() -> new PostNotFoundException( - PostErrorCode.POST_NOT_FOUND)); + studyPost.update( + request.getCategory(), + request.getTitle(), + request.getContent() + ); } + public void delete(Long postId, long userId) { + StudyPost studyPost = studyPostQueryRepository.findByIdAndUserId(postId, userId) + .orElseThrow(() -> new NotFoundException(NOT_FOUND_POST)); + + studyPost.delete(); + } + + private Study extractTargetStudyById(List studyMembers, long studyId) { + return studyMembers.stream() + .map(StudyMember::getStudy) + .filter(study -> studyId == study.getId()) + .findFirst() + .orElseThrow(() -> new NotFoundException(NOT_FOUND_STUDY)); + } } diff --git a/src/test/java/grep/neogul_coder/domain/recruitment/post/repository/RecruitmentPostCommentQueryRepositoryTest.java b/src/test/java/grep/neogul_coder/domain/recruitment/post/repository/RecruitmentPostStudyPostCommentQueryRepositoryTest.java similarity index 100% rename from src/test/java/grep/neogul_coder/domain/recruitment/post/repository/RecruitmentPostCommentQueryRepositoryTest.java rename to src/test/java/grep/neogul_coder/domain/recruitment/post/repository/RecruitmentPostStudyPostCommentQueryRepositoryTest.java From 30627c354c6a06661612d1f53eb1b9af27f7c54a Mon Sep 17 00:00:00 2001 From: Tokwasp Date: Mon, 21 Jul 2025 13:08:36 +0900 Subject: [PATCH 17/34] =?UTF-8?q?[EA3-163]=20chore:=20dto=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 컨트롤러 하위로 --- .../controller/dto/response/StudyMyContentResponse.java | 2 +- .../comment/StudyPostComment.java} | 4 ++-- .../comment/controller/CommentController.java | 6 +++--- .../comment/controller/CommentSpecification.java | 6 +++--- .../domain/{ => studypost}/comment/dto/CommentRequest.java | 2 +- .../domain/{ => studypost}/comment/dto/CommentResponse.java | 2 +- .../{ => controller}/dto/StudyPostDetailResponse.java | 4 ++-- .../{ => controller}/dto/StudyPostListResponse.java | 2 +- 8 files changed, 14 insertions(+), 14 deletions(-) rename src/main/java/grep/neogul_coder/domain/{comment/Comment.java => studypost/comment/StudyPostComment.java} (86%) rename src/main/java/grep/neogul_coder/domain/{ => studypost}/comment/controller/CommentController.java (86%) rename src/main/java/grep/neogul_coder/domain/{ => studypost}/comment/controller/CommentSpecification.java (89%) rename src/main/java/grep/neogul_coder/domain/{ => studypost}/comment/dto/CommentRequest.java (86%) rename src/main/java/grep/neogul_coder/domain/{ => studypost}/comment/dto/CommentResponse.java (93%) rename src/main/java/grep/neogul_coder/domain/studypost/{ => controller}/dto/StudyPostDetailResponse.java (90%) rename src/main/java/grep/neogul_coder/domain/studypost/{ => controller}/dto/StudyPostListResponse.java (94%) diff --git a/src/main/java/grep/neogul_coder/domain/study/controller/dto/response/StudyMyContentResponse.java b/src/main/java/grep/neogul_coder/domain/study/controller/dto/response/StudyMyContentResponse.java index 0fdc6796..b6459d21 100644 --- a/src/main/java/grep/neogul_coder/domain/study/controller/dto/response/StudyMyContentResponse.java +++ b/src/main/java/grep/neogul_coder/domain/study/controller/dto/response/StudyMyContentResponse.java @@ -1,6 +1,6 @@ package grep.neogul_coder.domain.study.controller.dto.response; -import grep.neogul_coder.domain.studypost.dto.StudyPostListResponse; +import grep.neogul_coder.domain.studypost.controller.dto.StudyPostListResponse; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; diff --git a/src/main/java/grep/neogul_coder/domain/comment/Comment.java b/src/main/java/grep/neogul_coder/domain/studypost/comment/StudyPostComment.java similarity index 86% rename from src/main/java/grep/neogul_coder/domain/comment/Comment.java rename to src/main/java/grep/neogul_coder/domain/studypost/comment/StudyPostComment.java index a355cf2b..d0434d58 100644 --- a/src/main/java/grep/neogul_coder/domain/comment/Comment.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/comment/StudyPostComment.java @@ -1,4 +1,4 @@ -package grep.neogul_coder.domain.comment; +package grep.neogul_coder.domain.studypost.comment; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -8,7 +8,7 @@ import jakarta.validation.constraints.NotBlank; @Entity -public class Comment { +public class StudyPostComment { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/grep/neogul_coder/domain/comment/controller/CommentController.java b/src/main/java/grep/neogul_coder/domain/studypost/comment/controller/CommentController.java similarity index 86% rename from src/main/java/grep/neogul_coder/domain/comment/controller/CommentController.java rename to src/main/java/grep/neogul_coder/domain/studypost/comment/controller/CommentController.java index f24a93b2..230c039e 100644 --- a/src/main/java/grep/neogul_coder/domain/comment/controller/CommentController.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/comment/controller/CommentController.java @@ -1,7 +1,7 @@ -package grep.neogul_coder.domain.comment.controller; +package grep.neogul_coder.domain.studypost.comment.controller; -import grep.neogul_coder.domain.comment.dto.CommentRequest; -import grep.neogul_coder.domain.comment.dto.CommentResponse; +import grep.neogul_coder.domain.studypost.comment.dto.CommentRequest; +import grep.neogul_coder.domain.studypost.comment.dto.CommentResponse; import grep.neogul_coder.global.response.ApiResponse; import jakarta.validation.Valid; import java.util.List; diff --git a/src/main/java/grep/neogul_coder/domain/comment/controller/CommentSpecification.java b/src/main/java/grep/neogul_coder/domain/studypost/comment/controller/CommentSpecification.java similarity index 89% rename from src/main/java/grep/neogul_coder/domain/comment/controller/CommentSpecification.java rename to src/main/java/grep/neogul_coder/domain/studypost/comment/controller/CommentSpecification.java index 34f54956..fa009632 100644 --- a/src/main/java/grep/neogul_coder/domain/comment/controller/CommentSpecification.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/comment/controller/CommentSpecification.java @@ -1,7 +1,7 @@ -package grep.neogul_coder.domain.comment.controller; +package grep.neogul_coder.domain.studypost.comment.controller; -import grep.neogul_coder.domain.comment.dto.CommentRequest; -import grep.neogul_coder.domain.comment.dto.CommentResponse; +import grep.neogul_coder.domain.studypost.comment.dto.CommentRequest; +import grep.neogul_coder.domain.studypost.comment.dto.CommentResponse; import grep.neogul_coder.global.response.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; diff --git a/src/main/java/grep/neogul_coder/domain/comment/dto/CommentRequest.java b/src/main/java/grep/neogul_coder/domain/studypost/comment/dto/CommentRequest.java similarity index 86% rename from src/main/java/grep/neogul_coder/domain/comment/dto/CommentRequest.java rename to src/main/java/grep/neogul_coder/domain/studypost/comment/dto/CommentRequest.java index 881a8b03..5985b326 100644 --- a/src/main/java/grep/neogul_coder/domain/comment/dto/CommentRequest.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/comment/dto/CommentRequest.java @@ -1,4 +1,4 @@ -package grep.neogul_coder.domain.comment.dto; +package grep.neogul_coder.domain.studypost.comment.dto; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/grep/neogul_coder/domain/comment/dto/CommentResponse.java b/src/main/java/grep/neogul_coder/domain/studypost/comment/dto/CommentResponse.java similarity index 93% rename from src/main/java/grep/neogul_coder/domain/comment/dto/CommentResponse.java rename to src/main/java/grep/neogul_coder/domain/studypost/comment/dto/CommentResponse.java index fa56ee40..5371d3f6 100644 --- a/src/main/java/grep/neogul_coder/domain/comment/dto/CommentResponse.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/comment/dto/CommentResponse.java @@ -1,4 +1,4 @@ -package grep.neogul_coder.domain.comment.dto; +package grep.neogul_coder.domain.studypost.comment.dto; import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDateTime; diff --git a/src/main/java/grep/neogul_coder/domain/studypost/dto/StudyPostDetailResponse.java b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/StudyPostDetailResponse.java similarity index 90% rename from src/main/java/grep/neogul_coder/domain/studypost/dto/StudyPostDetailResponse.java rename to src/main/java/grep/neogul_coder/domain/studypost/controller/dto/StudyPostDetailResponse.java index 621cd160..d1f3d57e 100644 --- a/src/main/java/grep/neogul_coder/domain/studypost/dto/StudyPostDetailResponse.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/StudyPostDetailResponse.java @@ -1,6 +1,6 @@ -package grep.neogul_coder.domain.studypost.dto; +package grep.neogul_coder.domain.studypost.controller.dto; -import grep.neogul_coder.domain.comment.dto.CommentResponse; +import grep.neogul_coder.domain.studypost.comment.dto.CommentResponse; import grep.neogul_coder.domain.studypost.Category; import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDateTime; diff --git a/src/main/java/grep/neogul_coder/domain/studypost/dto/StudyPostListResponse.java b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/StudyPostListResponse.java similarity index 94% rename from src/main/java/grep/neogul_coder/domain/studypost/dto/StudyPostListResponse.java rename to src/main/java/grep/neogul_coder/domain/studypost/controller/dto/StudyPostListResponse.java index 2b79dbea..cfc3dfeb 100644 --- a/src/main/java/grep/neogul_coder/domain/studypost/dto/StudyPostListResponse.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/StudyPostListResponse.java @@ -1,4 +1,4 @@ -package grep.neogul_coder.domain.studypost.dto; +package grep.neogul_coder.domain.studypost.controller.dto; import grep.neogul_coder.domain.studypost.Category; import io.swagger.v3.oas.annotations.media.Schema; From 5598f8e1ec41114b699bbd5721d4d575f9d91391 Mon Sep 17 00:00:00 2001 From: Tokwasp Date: Mon, 21 Jul 2025 13:09:14 +0900 Subject: [PATCH 18/34] =?UTF-8?q?[EA3-163]=20refactor:=20=ED=80=B4?= =?UTF-8?q?=EC=A6=88=20=EC=8A=A4=ED=84=B0=EB=94=94=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EA=B8=80=20=EC=A1=B0=ED=9A=8C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - service to repository --- .../domain/quiz/controller/AiQuizController.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/main/java/grep/neogul_coder/domain/quiz/controller/AiQuizController.java b/src/main/java/grep/neogul_coder/domain/quiz/controller/AiQuizController.java index 9bc44541..c75e7f1d 100644 --- a/src/main/java/grep/neogul_coder/domain/quiz/controller/AiQuizController.java +++ b/src/main/java/grep/neogul_coder/domain/quiz/controller/AiQuizController.java @@ -8,29 +8,30 @@ import grep.neogul_coder.domain.quiz.service.AiQuizServiceImpl; import grep.neogul_coder.domain.studypost.Category; import grep.neogul_coder.domain.studypost.StudyPost; -import grep.neogul_coder.domain.studypost.service.StudyPostService; +import grep.neogul_coder.domain.studypost.repository.StudyPostRepository; import grep.neogul_coder.global.response.ApiResponse; -import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.Optional; + @RestController @RequiredArgsConstructor @RequestMapping("/api/post/ai") public class AiQuizController implements AiQuizSpecification { - private final StudyPostService studyPostService; + private final StudyPostRepository studyPostRepository; private final AiQuizServiceImpl aiQuizServiceImpl; private final AiQuizRepository aiQuizRepository; @GetMapping("/{postId}") public ApiResponse get(@PathVariable("postId") Long postId) { - StudyPost post = studyPostService.findById(postId); + StudyPost post = studyPostRepository.findById(postId).orElseThrow(); if (!Category.FREE.equals((post.getCategory()))) { throw new PostNotFreeException(QuizErrorCode.POST_NOT_FREE_ERROR); From e86a7f4cb84e8988789dc203f2bcf21bf4a4ec6c Mon Sep 17 00:00:00 2001 From: Tokwasp Date: Mon, 21 Jul 2025 14:54:40 +0900 Subject: [PATCH 19/34] =?UTF-8?q?[EA3-163]=20feature:=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EA=B8=80=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=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/study/StudyMember.java | 5 +- .../domain/studypost/StudyPost.java | 3 + .../domain/studypost/StudyPostErrorCode.java | 2 +- .../studypost/comment/StudyPostComment.java | 42 +-- .../comment/dto/CommentResponse.java | 5 +- .../StudyCommentQueryRepository.java | 56 ++++ .../repository/StudyCommentRepository.java | 7 + .../controller/StudyPostController.java | 6 +- .../controller/StudyPostSpecification.java | 2 +- .../dto/StudyPostDetailResponse.java | 40 --- .../dto/request/StudyPostSaveRequest.java | 9 + .../dto/request/StudyPostUpdateRequest.java | 8 + .../controller/dto/response/CommentInfo.java | 42 +++ .../controller/dto/response/PostInfo.java | 51 ++++ .../dto/response/StudyPostDetailResponse.java | 28 ++ .../repository/StudyPostQueryRepository.java | 38 ++- .../studypost/service/StudyPostService.java | 18 +- .../service/StudyPostServiceTest.java | 242 ++++++++++++++++++ 18 files changed, 529 insertions(+), 75 deletions(-) create mode 100644 src/main/java/grep/neogul_coder/domain/studypost/comment/repository/StudyCommentQueryRepository.java create mode 100644 src/main/java/grep/neogul_coder/domain/studypost/comment/repository/StudyCommentRepository.java delete mode 100644 src/main/java/grep/neogul_coder/domain/studypost/controller/dto/StudyPostDetailResponse.java create mode 100644 src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/CommentInfo.java create mode 100644 src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/PostInfo.java create mode 100644 src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/StudyPostDetailResponse.java create mode 100644 src/test/java/grep/neogul_coder/domain/studypost/service/StudyPostServiceTest.java diff --git a/src/main/java/grep/neogul_coder/domain/study/StudyMember.java b/src/main/java/grep/neogul_coder/domain/study/StudyMember.java index 40e1005c..8a785e31 100644 --- a/src/main/java/grep/neogul_coder/domain/study/StudyMember.java +++ b/src/main/java/grep/neogul_coder/domain/study/StudyMember.java @@ -25,7 +25,8 @@ public class StudyMember extends BaseEntity { private Boolean isParticipated; - protected StudyMember() {} + protected StudyMember() { + } @Builder public StudyMember(Study study, Long userId, StudyMemberRole role, Boolean isParticipated) { @@ -43,7 +44,7 @@ public boolean isLeader() { return this.role == StudyMemberRole.LEADER; } - public boolean hasNotRoleLeader(){ + public boolean hasNotRoleLeader() { return this.role != StudyMemberRole.LEADER; } diff --git a/src/main/java/grep/neogul_coder/domain/studypost/StudyPost.java b/src/main/java/grep/neogul_coder/domain/studypost/StudyPost.java index b4f83105..7aa2d18c 100644 --- a/src/main/java/grep/neogul_coder/domain/studypost/StudyPost.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/StudyPost.java @@ -31,6 +31,9 @@ public class StudyPost extends BaseEntity { @Column(nullable = false) private String content; + protected StudyPost() { + } + @Builder private StudyPost(Long userId, String title, Category category, String content) { this.userId = userId; diff --git a/src/main/java/grep/neogul_coder/domain/studypost/StudyPostErrorCode.java b/src/main/java/grep/neogul_coder/domain/studypost/StudyPostErrorCode.java index 54a236a6..3aa472f4 100644 --- a/src/main/java/grep/neogul_coder/domain/studypost/StudyPostErrorCode.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/StudyPostErrorCode.java @@ -6,7 +6,7 @@ @Getter public enum StudyPostErrorCode implements ErrorCode { - NOT_FOUND_STUDY(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.name(), "해당 스터디를 찾지 못했습니다."), + NOT_JOINED_STUDY_USER(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.name(), "해당 스터디에 참여 하고 있지 않은 회원 입니다."), NOT_FOUND_POST(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.name(), "해당 스터디 게시글을 찾지 못했습니다."); private static final String BASIC_ERROR_NAME = "STUDY_POST"; diff --git a/src/main/java/grep/neogul_coder/domain/studypost/comment/StudyPostComment.java b/src/main/java/grep/neogul_coder/domain/studypost/comment/StudyPostComment.java index d0434d58..6d0df59f 100644 --- a/src/main/java/grep/neogul_coder/domain/studypost/comment/StudyPostComment.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/comment/StudyPostComment.java @@ -1,26 +1,36 @@ package grep.neogul_coder.domain.studypost.comment; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; +import grep.neogul_coder.global.entity.BaseEntity; +import jakarta.persistence.*; import jakarta.validation.constraints.NotBlank; +import lombok.Builder; +import lombok.Getter; +@Getter @Entity -public class StudyPostComment { +public class StudyPostComment extends BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @Column(nullable = false) - private Long postId; + @Column(nullable = false) + private Long postId; - @Column(nullable = false) - private Long userId; + @Column(nullable = false) + private Long userId; - @Column(nullable = false, length = 100) - @NotBlank(message = "내용은 필수입니다.") - private String content; + @Column(nullable = false, length = 100) + @NotBlank(message = "내용은 필수입니다.") + private String content; + + protected StudyPostComment() { + } + + @Builder + private StudyPostComment(Long postId, Long userId, String content) { + this.postId = postId; + this.userId = userId; + this.content = content; + } } diff --git a/src/main/java/grep/neogul_coder/domain/studypost/comment/dto/CommentResponse.java b/src/main/java/grep/neogul_coder/domain/studypost/comment/dto/CommentResponse.java index 5371d3f6..85381e62 100644 --- a/src/main/java/grep/neogul_coder/domain/studypost/comment/dto/CommentResponse.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/comment/dto/CommentResponse.java @@ -8,9 +8,12 @@ @Schema(description = "스터디 게시글 댓글 응답 DTO") public class CommentResponse { - @Schema(description = "댓글 ID", example = "100") + @Schema(description = "댓글 ID", example = "3") private Long id; + @Schema(description = "회원 ID", example = "3") + private Long userId; + @Schema(description = "작성자 닉네임", example = "너굴코더") private String nickname; diff --git a/src/main/java/grep/neogul_coder/domain/studypost/comment/repository/StudyCommentQueryRepository.java b/src/main/java/grep/neogul_coder/domain/studypost/comment/repository/StudyCommentQueryRepository.java new file mode 100644 index 00000000..4c2c6963 --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/studypost/comment/repository/StudyCommentQueryRepository.java @@ -0,0 +1,56 @@ +package grep.neogul_coder.domain.studypost.comment.repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import grep.neogul_coder.domain.studypost.comment.StudyPostComment; +import grep.neogul_coder.domain.studypost.controller.dto.response.CommentInfo; +import grep.neogul_coder.domain.studypost.controller.dto.response.QCommentInfo; +import jakarta.persistence.EntityManager; +import org.springframework.stereotype.Repository; + +import java.util.List; + +import static grep.neogul_coder.domain.studypost.QStudyPost.studyPost; +import static grep.neogul_coder.domain.studypost.comment.QStudyPostComment.studyPostComment; +import static grep.neogul_coder.domain.users.entity.QUser.*; + +@Repository +public class StudyCommentQueryRepository { + + private final EntityManager em; + private final JPAQueryFactory queryFactory; + + public StudyCommentQueryRepository(EntityManager em) { + this.em = em; + this.queryFactory = new JPAQueryFactory(em); + } + + public List findAllByPostId(Long postId) { + return queryFactory.selectFrom(studyPostComment) + .join(studyPost).on(studyPost.id.eq(studyPostComment.postId)) + .where( + studyPostComment.activated.isTrue(), + studyPostComment.postId.eq(postId) + ) + .fetch(); + } + + public List findWriterInfosByPostId(long postId) { + return queryFactory.select( + new QCommentInfo( + user.id, + user.nickname, + user.profileImageUrl, + studyPostComment.id, + studyPostComment.content, + studyPostComment.createdDate + ) + ) + .from(studyPostComment) + .join(user).on(studyPostComment.userId.eq(user.id)) + .where( + studyPostComment.activated.isTrue(), + studyPostComment.postId.eq(postId) + ) + .fetch(); + } +} diff --git a/src/main/java/grep/neogul_coder/domain/studypost/comment/repository/StudyCommentRepository.java b/src/main/java/grep/neogul_coder/domain/studypost/comment/repository/StudyCommentRepository.java new file mode 100644 index 00000000..f756ef00 --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/studypost/comment/repository/StudyCommentRepository.java @@ -0,0 +1,7 @@ +package grep.neogul_coder.domain.studypost.comment.repository; + +import grep.neogul_coder.domain.studypost.comment.StudyPostComment; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface StudyCommentRepository extends JpaRepository { +} diff --git a/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostController.java b/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostController.java index 66be8b5f..297a513f 100644 --- a/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostController.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostController.java @@ -1,9 +1,9 @@ package grep.neogul_coder.domain.studypost.controller; -import grep.neogul_coder.domain.studypost.controller.dto.StudyPostDetailResponse; import grep.neogul_coder.domain.studypost.controller.dto.StudyPostListResponse; import grep.neogul_coder.domain.studypost.controller.dto.request.StudyPostSaveRequest; import grep.neogul_coder.domain.studypost.controller.dto.request.StudyPostUpdateRequest; +import grep.neogul_coder.domain.studypost.controller.dto.response.StudyPostDetailResponse; import grep.neogul_coder.domain.studypost.service.StudyPostService; import grep.neogul_coder.global.auth.Principal; import grep.neogul_coder.global.response.ApiResponse; @@ -30,8 +30,8 @@ public ApiResponse create(@RequestBody @Valid StudyPostSaveRequest request @GetMapping("/{postId}") public ApiResponse findOne(@PathVariable("postId") Long postId) { - studyPostService.findOne(postId); - return ApiResponse.success(new StudyPostDetailResponse()); + StudyPostDetailResponse response = studyPostService.findOne(postId); + return ApiResponse.success(response); } @GetMapping("/all") diff --git a/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostSpecification.java b/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostSpecification.java index 983bb6c0..11a50108 100644 --- a/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostSpecification.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostSpecification.java @@ -1,6 +1,6 @@ package grep.neogul_coder.domain.studypost.controller; -import grep.neogul_coder.domain.studypost.controller.dto.StudyPostDetailResponse; +import grep.neogul_coder.domain.studypost.controller.dto.response.StudyPostDetailResponse; import grep.neogul_coder.domain.studypost.controller.dto.StudyPostListResponse; import grep.neogul_coder.domain.studypost.controller.dto.request.StudyPostSaveRequest; import grep.neogul_coder.domain.studypost.controller.dto.request.StudyPostUpdateRequest; diff --git a/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/StudyPostDetailResponse.java b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/StudyPostDetailResponse.java deleted file mode 100644 index d1f3d57e..00000000 --- a/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/StudyPostDetailResponse.java +++ /dev/null @@ -1,40 +0,0 @@ -package grep.neogul_coder.domain.studypost.controller.dto; - -import grep.neogul_coder.domain.studypost.comment.dto.CommentResponse; -import grep.neogul_coder.domain.studypost.Category; -import io.swagger.v3.oas.annotations.media.Schema; -import java.time.LocalDateTime; -import java.util.List; -import lombok.Getter; - -@Getter -@Schema(description = "스터디 게시글 상세 응답 DTO") -public class StudyPostDetailResponse { - - @Schema(description = "게시글 ID", example = "10") - private Long id; - - @Schema(description = "제목", example = "모든 국민은 직업선택의 자유를 가진다.") - private String title; - - @Schema(description = "카테고리: NOTICE(공지), FREE(자유)", example = "NOTICE") - private Category category; - - @Schema(description = "본문", example = "국회는 의원의 자격을 심사하며, 의원을 징계할 있다.") - private String content; - - @Schema(description = "작성일", example = "2025-07-10T14:00:00") - private LocalDateTime createdDate; - - @Schema(description = "작성자 닉네임", example = "너굴코더") - private String nickname; - - @Schema(description = "작성자 프로필 이미지 URL", example = "https://cdn.example.com/profile.jpg") - private String profileImageUrl; - - @Schema(description = "댓글 수", example = "3") - private int commentCount; - - @Schema(description = "댓글 목록") - private List comments; -} diff --git a/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/request/StudyPostSaveRequest.java b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/request/StudyPostSaveRequest.java index 9c05f9f1..194fd67c 100644 --- a/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/request/StudyPostSaveRequest.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/request/StudyPostSaveRequest.java @@ -6,6 +6,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import lombok.Builder; import lombok.Getter; @Getter @@ -31,6 +32,14 @@ public class StudyPostSaveRequest { private StudyPostSaveRequest() { } + @Builder + private StudyPostSaveRequest(long studyId, String title, Category category, String content) { + this.studyId = studyId; + this.title = title; + this.category = category; + this.content = content; + } + public StudyPost toEntity(Study study, long userId) { StudyPost studyPost = StudyPost.builder() .userId(userId) diff --git a/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/request/StudyPostUpdateRequest.java b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/request/StudyPostUpdateRequest.java index 5bb9291c..3759d3ce 100644 --- a/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/request/StudyPostUpdateRequest.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/request/StudyPostUpdateRequest.java @@ -3,6 +3,7 @@ import grep.neogul_coder.domain.studypost.Category; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; +import lombok.Builder; import lombok.Getter; @Getter @@ -23,4 +24,11 @@ public class StudyPostUpdateRequest { private StudyPostUpdateRequest() { } + + @Builder + private StudyPostUpdateRequest(String title, Category category, String content) { + this.title = title; + this.category = category; + this.content = content; + } } diff --git a/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/CommentInfo.java b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/CommentInfo.java new file mode 100644 index 00000000..47ea8a56 --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/CommentInfo.java @@ -0,0 +1,42 @@ +package grep.neogul_coder.domain.studypost.controller.dto.response; + +import com.querydsl.core.annotations.QueryProjection; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.ToString; + +import java.time.LocalDateTime; + +@ToString +@Getter +public class CommentInfo { + + @Schema(description = "댓글 ID", example = "3") + private long userId; + + @Schema(description = "작성자 닉네임", example = "너굴코더") + private String nickname; + + @Schema(description = "작성자 프로필 이미지 URL", example = "https://cdn.example.com/profile.jpg") + private String profileImageUrl; + + @Schema(description = "댓글 ID", example = "100") + private long id; + + @Schema(description = "댓글 내용", example = "정말 좋은 정보 감사합니다!") + private String content; + + @Schema(description = "작성일", example = "2025-07-10T14:45:00") + private LocalDateTime createdAt; + + @QueryProjection + public CommentInfo(long userId, String nickname, String profileImageUrl, long id, + String content, LocalDateTime createdAt) { + this.userId = userId; + this.nickname = nickname; + this.profileImageUrl = profileImageUrl; + this.id = id; + this.content = content; + this.createdAt = createdAt; + } +} diff --git a/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/PostInfo.java b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/PostInfo.java new file mode 100644 index 00000000..01a9c0be --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/PostInfo.java @@ -0,0 +1,51 @@ +package grep.neogul_coder.domain.studypost.controller.dto.response; + +import com.querydsl.core.annotations.QueryProjection; +import grep.neogul_coder.domain.studypost.Category; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.ToString; + +import java.time.LocalDateTime; + +@ToString +@Getter +public class PostInfo { + + @Schema(example = "3", description = "작성자 식별자") + private long userId; + + @Schema(description = "작성자 닉네임", example = "너굴코더") + private String nickname; + + @Schema(description = "작성자 프로필 이미지 URL", example = "https://cdn.example.com/profile.jpg") + private String profileImageUrl; + + @Schema(description = "게시글 ID", example = "10") + private Long postId; + + @Schema(description = "제목", example = "모든 국민은 직업선택의 자유를 가진다.") + private String title; + + @Schema(description = "카테고리: NOTICE(공지), FREE(자유)", example = "NOTICE") + private Category category; + + @Schema(description = "본문", example = "국회는 의원의 자격을 심사하며, 의원을 징계할 있다.") + private String content; + + @Schema(description = "작성일", example = "2025-07-10T14:00:00") + private LocalDateTime createdDate; + + @QueryProjection + public PostInfo(long userId, String nickname, String profileImageUrl, Long postId, + String title, Category category, String content, LocalDateTime createdDate) { + this.userId = userId; + this.nickname = nickname; + this.profileImageUrl = profileImageUrl; + this.postId = postId; + this.title = title; + this.category = category; + this.content = content; + this.createdDate = createdDate; + } +} diff --git a/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/StudyPostDetailResponse.java b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/StudyPostDetailResponse.java new file mode 100644 index 00000000..56957659 --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/StudyPostDetailResponse.java @@ -0,0 +1,28 @@ +package grep.neogul_coder.domain.studypost.controller.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.ToString; + +import java.util.List; + +@ToString +@Getter +@Schema(description = "스터디 게시글 상세 응답 DTO") +public class StudyPostDetailResponse { + + @Schema(description = "게시글 회원 정보") + private PostInfo postInfo; + + @Schema(description = "댓글 목록") + private List comments; + + @Schema(description = "댓글 수", example = "3") + private int commentCount; + + public StudyPostDetailResponse(PostInfo postInfo, List comments, int commentCount) { + this.postInfo = postInfo; + this.comments = comments; + this.commentCount = commentCount; + } +} diff --git a/src/main/java/grep/neogul_coder/domain/studypost/repository/StudyPostQueryRepository.java b/src/main/java/grep/neogul_coder/domain/studypost/repository/StudyPostQueryRepository.java index dee5566b..cefe2da0 100644 --- a/src/main/java/grep/neogul_coder/domain/studypost/repository/StudyPostQueryRepository.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/repository/StudyPostQueryRepository.java @@ -3,31 +3,59 @@ import com.querydsl.jpa.impl.JPAQueryFactory; import grep.neogul_coder.domain.studypost.QStudyPost; import grep.neogul_coder.domain.studypost.StudyPost; +import grep.neogul_coder.domain.studypost.controller.dto.response.PostInfo; +import grep.neogul_coder.domain.studypost.controller.dto.response.QPostInfo; import jakarta.persistence.EntityManager; import org.springframework.stereotype.Repository; import java.util.Optional; +import static grep.neogul_coder.domain.users.entity.QUser.user; + @Repository public class StudyPostQueryRepository { private final EntityManager em; private final JPAQueryFactory queryFactory; + private final QStudyPost studyPost = QStudyPost.studyPost; + public StudyPostQueryRepository(EntityManager em) { this.em = em; this.queryFactory = new JPAQueryFactory(em); } + public PostInfo findPostWriterInfo(Long postId) { + return queryFactory.select( + new QPostInfo( + user.id, + user.nickname, + user.profileImageUrl, + studyPost.id.as("postId"), + studyPost.title, + studyPost.category, + studyPost.content, + studyPost.createdDate + ) + ) + .from(studyPost) + .join(user).on(studyPost.userId.eq(user.id)) + .where( + studyPost.id.eq(postId), + studyPost.activated.isTrue() + ) + .fetchOne(); + } + public Optional findByIdAndUserId(Long postId, long userId) { - StudyPost studyPost = queryFactory.selectFrom(QStudyPost.studyPost) + StudyPost findStudyPost = queryFactory.selectFrom(studyPost) .where( - QStudyPost.studyPost.id.eq(postId), - QStudyPost.studyPost.userId.eq(userId), - QStudyPost.studyPost.activated.isTrue() + studyPost.id.eq(postId), + studyPost.userId.eq(userId), + studyPost.activated.isTrue() ) .fetchOne(); - return Optional.ofNullable(studyPost); + return Optional.ofNullable(findStudyPost); } } diff --git a/src/main/java/grep/neogul_coder/domain/studypost/service/StudyPostService.java b/src/main/java/grep/neogul_coder/domain/studypost/service/StudyPostService.java index d6a14d88..c56f7bee 100644 --- a/src/main/java/grep/neogul_coder/domain/studypost/service/StudyPostService.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/service/StudyPostService.java @@ -7,6 +7,9 @@ import grep.neogul_coder.domain.studypost.comment.repository.StudyCommentQueryRepository; import grep.neogul_coder.domain.studypost.controller.dto.request.StudyPostSaveRequest; import grep.neogul_coder.domain.studypost.controller.dto.request.StudyPostUpdateRequest; +import grep.neogul_coder.domain.studypost.controller.dto.response.CommentInfo; +import grep.neogul_coder.domain.studypost.controller.dto.response.PostInfo; +import grep.neogul_coder.domain.studypost.controller.dto.response.StudyPostDetailResponse; import grep.neogul_coder.domain.studypost.repository.StudyPostQueryRepository; import grep.neogul_coder.domain.studypost.repository.StudyPostRepository; import grep.neogul_coder.global.exception.business.NotFoundException; @@ -16,7 +19,8 @@ import java.util.List; -import static grep.neogul_coder.domain.studypost.StudyPostErrorCode.*; +import static grep.neogul_coder.domain.studypost.StudyPostErrorCode.NOT_FOUND_POST; +import static grep.neogul_coder.domain.studypost.StudyPostErrorCode.NOT_JOINED_STUDY_USER; @Transactional(readOnly = true) @Service @@ -24,15 +28,16 @@ public class StudyPostService { private final StudyMemberQueryRepository studyQueryRepository; + private final StudyPostRepository studyPostRepository; private final StudyPostQueryRepository studyPostQueryRepository; private final StudyCommentQueryRepository commentQueryRepository; - public void findOne(Long postId) { - commentQueryRepository.findAllFetchPostByPostId(postId); - - + public StudyPostDetailResponse findOne(Long postId) { + PostInfo postInfo = studyPostQueryRepository.findPostWriterInfo(postId); + List commentInfos = commentQueryRepository.findWriterInfosByPostId(postId); + return new StudyPostDetailResponse(postInfo, commentInfos, commentInfos.size()); } @Transactional @@ -54,6 +59,7 @@ public void update(StudyPostUpdateRequest request, Long postId, long userId) { ); } + @Transactional public void delete(Long postId, long userId) { StudyPost studyPost = studyPostQueryRepository.findByIdAndUserId(postId, userId) .orElseThrow(() -> new NotFoundException(NOT_FOUND_POST)); @@ -66,6 +72,6 @@ private Study extractTargetStudyById(List studyMembers, long studyI .map(StudyMember::getStudy) .filter(study -> studyId == study.getId()) .findFirst() - .orElseThrow(() -> new NotFoundException(NOT_FOUND_STUDY)); + .orElseThrow(() -> new NotFoundException(NOT_JOINED_STUDY_USER)); } } diff --git a/src/test/java/grep/neogul_coder/domain/studypost/service/StudyPostServiceTest.java b/src/test/java/grep/neogul_coder/domain/studypost/service/StudyPostServiceTest.java new file mode 100644 index 00000000..a4135664 --- /dev/null +++ b/src/test/java/grep/neogul_coder/domain/studypost/service/StudyPostServiceTest.java @@ -0,0 +1,242 @@ +package grep.neogul_coder.domain.studypost.service; + +import grep.neogul_coder.domain.IntegrationTestSupport; +import grep.neogul_coder.domain.study.Study; +import grep.neogul_coder.domain.study.StudyMember; +import grep.neogul_coder.domain.study.repository.StudyMemberRepository; +import grep.neogul_coder.domain.study.repository.StudyRepository; +import grep.neogul_coder.domain.studypost.Category; +import grep.neogul_coder.domain.studypost.StudyPost; +import grep.neogul_coder.domain.studypost.comment.StudyPostComment; +import grep.neogul_coder.domain.studypost.comment.repository.StudyCommentRepository; +import grep.neogul_coder.domain.studypost.controller.dto.request.StudyPostSaveRequest; +import grep.neogul_coder.domain.studypost.controller.dto.request.StudyPostUpdateRequest; +import grep.neogul_coder.domain.studypost.controller.dto.response.StudyPostDetailResponse; +import grep.neogul_coder.domain.studypost.repository.StudyPostRepository; +import grep.neogul_coder.domain.users.entity.User; +import grep.neogul_coder.domain.users.repository.UserRepository; +import grep.neogul_coder.global.exception.business.NotFoundException; +import jakarta.persistence.EntityManager; +import org.assertj.core.groups.Tuple; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.Collection; +import java.util.List; + +import static grep.neogul_coder.domain.studypost.Category.FREE; +import static grep.neogul_coder.domain.studypost.Category.NOTICE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class StudyPostServiceTest extends IntegrationTestSupport { + + @Autowired + private StudyPostService studyPostService; + + @Autowired + private EntityManager em; + + @Autowired + private UserRepository userRepository; + + @Autowired + private StudyRepository studyRepository; + + @Autowired + private StudyMemberRepository studyMemberRepository; + + @Autowired + private StudyPostRepository studyPostRepository; + + @Autowired + private StudyCommentRepository studycommentRepository; + + @DisplayName("게시글을 조회 합니다.") + @Test + void findOne() { + //given + User user = createUser("테스터"); + userRepository.save(user); + + Study study = createStudy("자바 스터디"); + studyRepository.save(study); + + StudyPost post = createStudyPost(user.getId(), "제목", FREE, "내용"); + post.connectStudy(study); + studyPostRepository.save(post); + + List comments = List.of( + createPostComment(post.getId(), user.getId(), "댓글1"), + createPostComment(post.getId(), user.getId(), "댓글2"), + createPostComment(post.getId(), user.getId(), "댓글3") + ); + studycommentRepository.saveAll(comments); + + //when + StudyPostDetailResponse response = studyPostService.findOne(post.getId()); + + //then + assertThat(response.getPostInfo()) + .extracting("nickname", "title") + .containsExactly("테스터", "제목"); + + assertThat(response.getCommentCount()).isEqualTo(3); + + assertThat(response.getComments()) + .extracting("nickname", "content") + .containsExactlyInAnyOrder( + Tuple.tuple("테스터", "댓글1"), + Tuple.tuple("테스터", "댓글2"), + Tuple.tuple("테스터", "댓글3") + ); + } + + @DisplayName("스터디 게시글을 생성 합니다.") + @TestFactory + Collection create() { + User user = createUser("테스터"); + userRepository.save(user); + + Study study = createStudy("자바 스터디"); + studyRepository.save(study); + + return List.of( + DynamicTest.dynamicTest("스터디 게시글은 스터디에 참여한 회원만 작성할 수 있습니다.", () -> { + StudyPostSaveRequest request = createStudyPostSaveRequest(study.getId(), "게시글 제목", FREE, "게시글 내용"); + + //when //then + assertThatThrownBy(() -> studyPostService.create(request, user.getId())) + .isInstanceOf(NotFoundException.class).hasMessage("해당 스터디에 참여 하고 있지 않은 회원 입니다."); + }), + + DynamicTest.dynamicTest("스터디 게시글을 생성 합니다.", () -> { + //given + StudyMember studyMember = createStudyMember(study, user.getId()); + studyMemberRepository.save(studyMember); + + StudyPostSaveRequest request = createStudyPostSaveRequest(study.getId(), "게시글 제목", FREE, "게시글 내용"); + + //when + long postId = studyPostService.create(request, user.getId()); + em.flush(); + em.clear(); + + //then + StudyPost studyPost = studyPostRepository.findById(postId).orElseThrow(); + assertThat(studyPost) + .extracting("title", "content") + .containsExactly("게시글 제목", "게시글 내용"); + }) + ); + } + + @DisplayName("스터디 게시글을 수정 합니다") + @Test + void update() { + //given + User user = createUser("테스터"); + userRepository.save(user); + + Study study = createStudy("자바 스터디"); + studyRepository.save(study); + + StudyPost post = createStudyPost(user.getId(), "제목", FREE, "내용"); + post.connectStudy(study); + studyPostRepository.save(post); + + StudyPostUpdateRequest request = createUpdateRequest("수정된 제목", NOTICE, "수정된 내용"); + + //when + studyPostService.update(request, post.getId(), user.getId()); + em.flush(); + em.clear(); + + //then + StudyPost findPost = studyPostRepository.findById(post.getId()).orElseThrow(); + assertThat(findPost) + .extracting("title", "category", "content") + .containsExactly("수정된 제목", NOTICE, "수정된 내용"); + } + + @DisplayName("게시글을 삭제 합니다.") + @Test + void delete() { + //given + User user = createUser("테스터"); + userRepository.save(user); + + Study study = createStudy("자바 스터디"); + studyRepository.save(study); + + StudyPost post = createStudyPost(user.getId(), "제목", FREE, "내용"); + post.connectStudy(study); + studyPostRepository.save(post); + + //when + studyPostService.delete(post.getId(), user.getId()); + em.flush(); + em.clear(); + + //then + StudyPost findPost = studyPostRepository.findById(post.getId()).orElseThrow(); + assertThat(findPost.getActivated()).isFalse(); + } + + private User createUser(String nickname) { + return User.builder() + .nickname(nickname) + .password("tempPassword") + .build(); + } + + private Study createStudy(String name) { + return Study.builder() + .name(name) + .build(); + } + + private StudyPostSaveRequest createStudyPostSaveRequest(long studyId, String title, Category category, String content) { + return StudyPostSaveRequest.builder() + .studyId(studyId) + .title(title) + .category(category) + .content(content) + .build(); + } + + private StudyMember createStudyMember(Study study, long userId) { + return StudyMember.builder() + .study(study) + .userId(userId) + .build(); + } + + private StudyPost createStudyPost(long userId, String title, Category category, String content) { + return StudyPost.builder() + .userId(userId) + .title(title) + .category(category) + .content(content) + .build(); + } + + private StudyPostUpdateRequest createUpdateRequest(String title, Category category, String content) { + return StudyPostUpdateRequest.builder() + .title(title) + .category(category) + .content(content) + .build(); + } + + private StudyPostComment createPostComment(long postId, long userId, String content){ + return StudyPostComment.builder() + .postId(postId) + .userId(userId) + .content(content) + .build(); + } +} \ No newline at end of file From 0d5bf135d0b91668a2be76dd54421c0b4594e854 Mon Sep 17 00:00:00 2001 From: pia01190 Date: Mon, 21 Jul 2025 15:33:57 +0900 Subject: [PATCH 20/34] =?UTF-8?q?[EA3-162]=20feature:=20=EC=B6=9C=EC=84=9D?= =?UTF-8?q?=20=EC=B2=B4=ED=81=AC,=20=EC=A1=B0=ED=9A=8C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/AttendanceServiceTest.java | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 src/test/java/grep/neogul_coder/domain/attendance/service/AttendanceServiceTest.java diff --git a/src/test/java/grep/neogul_coder/domain/attendance/service/AttendanceServiceTest.java b/src/test/java/grep/neogul_coder/domain/attendance/service/AttendanceServiceTest.java new file mode 100644 index 00000000..425c07e8 --- /dev/null +++ b/src/test/java/grep/neogul_coder/domain/attendance/service/AttendanceServiceTest.java @@ -0,0 +1,120 @@ +package grep.neogul_coder.domain.attendance.service; + +import grep.neogul_coder.domain.IntegrationTestSupport; +import grep.neogul_coder.domain.attendance.Attendance; +import grep.neogul_coder.domain.attendance.controller.dto.response.AttendanceInfoResponse; +import grep.neogul_coder.domain.attendance.repository.AttendanceRepository; +import grep.neogul_coder.domain.study.Study; +import grep.neogul_coder.domain.study.StudyMember; +import grep.neogul_coder.domain.study.repository.StudyMemberRepository; +import grep.neogul_coder.domain.study.repository.StudyRepository; +import grep.neogul_coder.domain.users.entity.User; +import grep.neogul_coder.domain.users.repository.UserRepository; +import grep.neogul_coder.global.exception.business.BusinessException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import static grep.neogul_coder.domain.attendance.exception.code.AttendanceErrorCode.ATTENDANCE_ALREADY_CHECKED; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class AttendanceServiceTest extends IntegrationTestSupport { + + @Autowired + private UserRepository userRepository; + + @Autowired + private StudyRepository studyRepository; + + @Autowired + private StudyMemberRepository studyMemberRepository; + + @Autowired + private AttendanceService attendanceService; + + @Autowired + private AttendanceRepository attendanceRepository; + + private Long userId; + private Long studyId; + + @BeforeEach + void init() { + User user = createUser("test1"); + userRepository.save(user); + userId = user.getId(); + + Study study = createStudy("스터디", LocalDateTime.parse("2025-07-25T20:20:20"), LocalDateTime.parse("2025-07-28T20:20:20")); + studyRepository.save(study); + studyId = study.getId(); + + StudyMember studyMember = createStudyMember(study, userId); + studyMemberRepository.save(studyMember); + } + + @DisplayName("출석을 조회합니다.") + @Test + void getAttendances() { + // given + Attendance attendance = Attendance.create(studyId, userId); + attendanceRepository.save(attendance); + + // when + AttendanceInfoResponse response = attendanceService.getAttendances(studyId, userId); + + // then + assertThat(response.getAttendances().getFirst().getStudyId()).isEqualTo(studyId); + } + + @DisplayName("스터디에 출석 체크를 합니다.") + @Test + void createAttendance() { + // given + Attendance attendance = Attendance.create(studyId, userId); + + // when + attendanceRepository.save(attendance); + + // then + assertThat(attendance.getAttendanceDate().toLocalDate()).isEqualTo(LocalDate.now()); + } + + @DisplayName("스터디에 이미 출석한 경우 예외가 발생합니다.") + @Test + void createAttendanceFail() { + // given + Attendance attendance = Attendance.create(studyId, userId); + attendanceRepository.save(attendance); + + // when then + assertThatThrownBy(() -> + attendanceService.createAttendance(studyId, userId)) + .isInstanceOf(BusinessException.class).hasMessage(ATTENDANCE_ALREADY_CHECKED.getMessage()); + } + + private static User createUser(String nickname) { + return User.builder() + .nickname(nickname) + .build(); + } + + private static Study createStudy(String name, LocalDateTime startDate, LocalDateTime endDate) { + return Study.builder() + .name(name) + .startDate(startDate) + .endDate(endDate) + .build(); + } + + private StudyMember createStudyMember(Study study, long userId) { + return StudyMember.builder() + .study(study) + .userId(userId) + .build(); + } +} \ No newline at end of file From 95f00c9db6a54a220d90b074bc1d83a0d7061e54 Mon Sep 17 00:00:00 2001 From: pia01190 Date: Mon, 21 Jul 2025 17:17:11 +0900 Subject: [PATCH 21/34] =?UTF-8?q?[EA3-161]=20feature:=20=EC=8A=A4=ED=84=B0?= =?UTF-8?q?=EB=94=94=20=EC=83=9D=EC=84=B1=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../study/controller/StudyController.java | 12 +++-- .../study/controller/StudySpecification.java | 4 +- .../dto/request/StudyCreateRequest.java | 5 +- .../domain/study/service/StudyService.java | 47 ++++++++++++++++++- 4 files changed, 58 insertions(+), 10 deletions(-) diff --git a/src/main/java/grep/neogul_coder/domain/study/controller/StudyController.java b/src/main/java/grep/neogul_coder/domain/study/controller/StudyController.java index 0a5c04ec..30f5e157 100644 --- a/src/main/java/grep/neogul_coder/domain/study/controller/StudyController.java +++ b/src/main/java/grep/neogul_coder/domain/study/controller/StudyController.java @@ -10,9 +10,12 @@ import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; +import org.springframework.http.MediaType; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; import java.util.List; @RequestMapping("/api/studies") @@ -65,10 +68,11 @@ public ApiResponse getMyStudyMemberInfo(@PathVariable(" return ApiResponse.success(studyService.getMyStudyMemberInfo(studyId, userId)); } - @PostMapping - public ApiResponse createStudy(@RequestBody @Valid StudyCreateRequest request, - @AuthenticationPrincipal Principal userDetails) { - Long id = studyService.createStudy(request, userDetails.getUserId()); + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse createStudy(@RequestPart("request") @Valid StudyCreateRequest request, + @RequestPart(value = "image", required = false) MultipartFile image, + @AuthenticationPrincipal Principal userDetails) throws IOException { + Long id = studyService.createStudy(request, userDetails.getUserId(), image); return ApiResponse.success(id); } diff --git a/src/main/java/grep/neogul_coder/domain/study/controller/StudySpecification.java b/src/main/java/grep/neogul_coder/domain/study/controller/StudySpecification.java index e97c4767..422dc3bb 100644 --- a/src/main/java/grep/neogul_coder/domain/study/controller/StudySpecification.java +++ b/src/main/java/grep/neogul_coder/domain/study/controller/StudySpecification.java @@ -8,7 +8,9 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.data.domain.Pageable; +import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; import java.util.List; @Tag(name = "Study", description = "스터디 API") @@ -36,7 +38,7 @@ public interface StudySpecification { ApiResponse getMyStudyMemberInfo(Long studyId, Principal userDetails); @Operation(summary = "스터디 생성", description = "새로운 스터디를 생성합니다.") - ApiResponse createStudy(StudyCreateRequest request, Principal userDetails); + ApiResponse createStudy(StudyCreateRequest request, MultipartFile image, Principal userDetails) throws IOException; @Operation(summary = "스터디 수정", description = "스터디를 수정합니다.") ApiResponse updateStudy(Long studyId, StudyUpdateRequest request, Principal userDetails); diff --git a/src/main/java/grep/neogul_coder/domain/study/controller/dto/request/StudyCreateRequest.java b/src/main/java/grep/neogul_coder/domain/study/controller/dto/request/StudyCreateRequest.java index 5c95fc46..9932c223 100644 --- a/src/main/java/grep/neogul_coder/domain/study/controller/dto/request/StudyCreateRequest.java +++ b/src/main/java/grep/neogul_coder/domain/study/controller/dto/request/StudyCreateRequest.java @@ -45,7 +45,6 @@ public class StudyCreateRequest { @Schema(description = "스터디 소개", example = "자바 스터디입니다.") private String introduction; - @NotBlank @Schema(description = "대표 이미지", example = "http://localhost:8083/image.jpg") private String imageUrl; @@ -65,7 +64,7 @@ private StudyCreateRequest(String name, Category category, int capacity, StudyTy this.imageUrl = imageUrl; } - public Study toEntity() { + public Study toEntity(String imageUrl) { return Study.builder() .name(this.name) .category(this.category) @@ -75,7 +74,7 @@ public Study toEntity() { .startDate(this.startDate) .endDate(this.endDate) .introduction(this.introduction) - .imageUrl(this.imageUrl) + .imageUrl(imageUrl) .build(); } } diff --git a/src/main/java/grep/neogul_coder/domain/study/service/StudyService.java b/src/main/java/grep/neogul_coder/domain/study/service/StudyService.java index e02c4985..d5cdaab2 100644 --- a/src/main/java/grep/neogul_coder/domain/study/service/StudyService.java +++ b/src/main/java/grep/neogul_coder/domain/study/service/StudyService.java @@ -17,12 +17,20 @@ import grep.neogul_coder.domain.users.repository.UserRepository; import grep.neogul_coder.global.exception.business.BusinessException; import grep.neogul_coder.global.exception.business.NotFoundException; +import grep.neogul_coder.global.utils.upload.FileUploadResponse; +import grep.neogul_coder.global.utils.upload.FileUsageType; +import grep.neogul_coder.global.utils.upload.uploader.GcpFileUploader; +import grep.neogul_coder.global.utils.upload.uploader.LocalFileUploader; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.env.Environment; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; import java.util.List; import java.util.Optional; @@ -43,6 +51,15 @@ public class StudyService { private final UserRepository userRepository; private final BuddyEnergyService buddyEnergyService; + @Autowired(required = false) + private GcpFileUploader gcpFileUploader; + + @Autowired(required = false) + private LocalFileUploader localFileUploader; + + @Autowired + private Environment environment; + public StudyItemPagingResponse getMyStudiesPaging(Pageable pageable, Long userId) { Page page = studyQueryRepository.findMyStudiesPaging(pageable, userId); return StudyItemPagingResponse.of(page); @@ -90,10 +107,12 @@ public StudyMemberInfoResponse getMyStudyMemberInfo(Long studyId, Long userId) { } @Transactional - public Long createStudy(StudyCreateRequest request, Long userId) { + public Long createStudy(StudyCreateRequest request, Long userId, MultipartFile image) throws IOException { validateLocation(request.getStudyType(), request.getLocation()); - Study study = studyRepository.save(request.toEntity()); + String imageUrl = createImageUrl(userId, image); + + Study study = studyRepository.save(request.toEntity(imageUrl)); StudyMember leader = StudyMember.builder() .study(study) @@ -181,4 +200,28 @@ private void validateStudyDeletable(Long studyId) { throw new BusinessException(STUDY_DELETE_NOT_ALLOWED); } } + + private boolean isProductionEnvironment() { + for (String profile : environment.getActiveProfiles()) { + if ("prod".equals(profile)) { + return true; + } + } + return false; + } + + private String createImageUrl(Long userId, MultipartFile image) throws IOException { + String imageUrl = null; + if (isImgExists(image)) { + FileUploadResponse uploadResult = isProductionEnvironment() + ? gcpFileUploader.upload(image, userId, FileUsageType.STUDY_COVER, userId) + : localFileUploader.upload(image, userId, FileUsageType.STUDY_COVER, userId); + imageUrl = uploadResult.fileUrl(); + } + return imageUrl; + } + + private boolean isImgExists(MultipartFile image) { + return image != null && !image.isEmpty(); + } } From 963b9f7071f7c5e5a4ab1127f4e24cf314c6e694 Mon Sep 17 00:00:00 2001 From: pia01190 Date: Mon, 21 Jul 2025 17:23:27 +0900 Subject: [PATCH 22/34] =?UTF-8?q?[EA3-161]=20feature:=20=EC=8A=A4=ED=84=B0?= =?UTF-8?q?=EB=94=94=20=EC=88=98=EC=A0=95=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/study/controller/StudyController.java | 7 ++++--- .../domain/study/controller/StudySpecification.java | 2 +- .../study/controller/dto/request/StudyUpdateRequest.java | 5 ++--- .../neogul_coder/domain/study/service/StudyService.java | 6 ++++-- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/main/java/grep/neogul_coder/domain/study/controller/StudyController.java b/src/main/java/grep/neogul_coder/domain/study/controller/StudyController.java index 30f5e157..e831131d 100644 --- a/src/main/java/grep/neogul_coder/domain/study/controller/StudyController.java +++ b/src/main/java/grep/neogul_coder/domain/study/controller/StudyController.java @@ -78,9 +78,10 @@ public ApiResponse createStudy(@RequestPart("request") @Valid StudyCreateR @PutMapping("/{studyId}") public ApiResponse updateStudy(@PathVariable("studyId") Long studyId, - @RequestBody @Valid StudyUpdateRequest request, - @AuthenticationPrincipal Principal userDetails) { - studyService.updateStudy(studyId, request, userDetails.getUserId()); + @RequestPart @Valid StudyUpdateRequest request, + @RequestPart(value = "image", required = false) MultipartFile image, + @AuthenticationPrincipal Principal userDetails) throws IOException { + studyService.updateStudy(studyId, request, userDetails.getUserId(), image); return ApiResponse.noContent(); } diff --git a/src/main/java/grep/neogul_coder/domain/study/controller/StudySpecification.java b/src/main/java/grep/neogul_coder/domain/study/controller/StudySpecification.java index 422dc3bb..407173b2 100644 --- a/src/main/java/grep/neogul_coder/domain/study/controller/StudySpecification.java +++ b/src/main/java/grep/neogul_coder/domain/study/controller/StudySpecification.java @@ -41,7 +41,7 @@ public interface StudySpecification { ApiResponse createStudy(StudyCreateRequest request, MultipartFile image, Principal userDetails) throws IOException; @Operation(summary = "스터디 수정", description = "스터디를 수정합니다.") - ApiResponse updateStudy(Long studyId, StudyUpdateRequest request, Principal userDetails); + ApiResponse updateStudy(Long studyId, StudyUpdateRequest request, MultipartFile image, Principal userDetails) throws IOException; @Operation(summary = "스터디 삭제", description = "스터디를 삭제합니다.") ApiResponse deleteStudy(Long studyId, Principal userDetails); diff --git a/src/main/java/grep/neogul_coder/domain/study/controller/dto/request/StudyUpdateRequest.java b/src/main/java/grep/neogul_coder/domain/study/controller/dto/request/StudyUpdateRequest.java index 0480583c..e6a224ef 100644 --- a/src/main/java/grep/neogul_coder/domain/study/controller/dto/request/StudyUpdateRequest.java +++ b/src/main/java/grep/neogul_coder/domain/study/controller/dto/request/StudyUpdateRequest.java @@ -38,7 +38,6 @@ public class StudyUpdateRequest { @Schema(description = "스터디 소개", example = "자바 스터디입니다.") private String introduction; - @NotBlank @Schema(description = "대표 이미지", example = "http://localhost:8083/image.jpg") private String imageUrl; @@ -57,7 +56,7 @@ private StudyUpdateRequest(String name, Category category, int capacity, StudyTy this.imageUrl = imageUrl; } - public Study toEntity() { + public Study toEntity(String imageUrl) { return Study.builder() .name(this.name) .category(this.category) @@ -66,7 +65,7 @@ public Study toEntity() { .location(this.location) .startDate(this.startDate) .introduction(this.introduction) - .imageUrl(this.imageUrl) + .imageUrl(imageUrl) .build(); } } diff --git a/src/main/java/grep/neogul_coder/domain/study/service/StudyService.java b/src/main/java/grep/neogul_coder/domain/study/service/StudyService.java index d5cdaab2..58fcbdaa 100644 --- a/src/main/java/grep/neogul_coder/domain/study/service/StudyService.java +++ b/src/main/java/grep/neogul_coder/domain/study/service/StudyService.java @@ -125,7 +125,7 @@ public Long createStudy(StudyCreateRequest request, Long userId, MultipartFile i } @Transactional - public void updateStudy(Long studyId, StudyUpdateRequest request, Long userId) { + public void updateStudy(Long studyId, StudyUpdateRequest request, Long userId, MultipartFile image) throws IOException { Study study = studyRepository.findById(studyId) .orElseThrow(() -> new NotFoundException(STUDY_NOT_FOUND)); @@ -134,6 +134,8 @@ public void updateStudy(Long studyId, StudyUpdateRequest request, Long userId) { validateStudyLeader(studyId, userId); validateStudyStartDate(request, study); + String imageUrl = createImageUrl(userId, image); + study.update( request.getName(), request.getCategory(), @@ -142,7 +144,7 @@ public void updateStudy(Long studyId, StudyUpdateRequest request, Long userId) { request.getLocation(), request.getStartDate(), request.getIntroduction(), - request.getImageUrl() + imageUrl ); } From 8def746ac2785ba608d1c1bb8858b2cfa84d1be6 Mon Sep 17 00:00:00 2001 From: Tokwasp Date: Mon, 21 Jul 2025 17:37:30 +0900 Subject: [PATCH 23/34] =?UTF-8?q?[EA3-163]=20feature:=20=EC=8A=A4=ED=84=B0?= =?UTF-8?q?=EB=94=94=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=95=20=EC=A1=B0=ED=9A=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/studypost/Category.java | 4 + .../domain/studypost/StudyPostErrorCode.java | 1 + ...a => StudyPostCommentQueryRepository.java} | 32 ++-- .../controller/StudyPostController.java | 16 +- .../controller/StudyPostSpecification.java | 14 +- .../controller/dto/StudyPostListResponse.java | 28 +-- .../dto/request/StudyPostPagingCondition.java | 46 +++++ .../dto/response/NoticePostInfo.java | 33 ++++ .../dto/response/PostPagingInfo.java | 43 +++++ .../dto/response/PostPagingResult.java | 34 ++++ .../repository/StudyPostQueryRepository.java | 119 ++++++++++++- .../studypost/service/StudyPostService.java | 16 +- .../StudyPostQueryRepositoryTest.java | 164 ++++++++++++++++++ 13 files changed, 502 insertions(+), 48 deletions(-) rename src/main/java/grep/neogul_coder/domain/studypost/comment/repository/{StudyCommentQueryRepository.java => StudyPostCommentQueryRepository.java} (64%) create mode 100644 src/main/java/grep/neogul_coder/domain/studypost/controller/dto/request/StudyPostPagingCondition.java create mode 100644 src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/NoticePostInfo.java create mode 100644 src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/PostPagingInfo.java create mode 100644 src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/PostPagingResult.java create mode 100644 src/test/java/grep/neogul_coder/domain/studypost/repository/StudyPostQueryRepositoryTest.java diff --git a/src/main/java/grep/neogul_coder/domain/studypost/Category.java b/src/main/java/grep/neogul_coder/domain/studypost/Category.java index e9551eee..6693f78c 100644 --- a/src/main/java/grep/neogul_coder/domain/studypost/Category.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/Category.java @@ -16,4 +16,8 @@ public enum Category { public String toJson() { return korean; } + + public String getKorean() { + return korean; + } } diff --git a/src/main/java/grep/neogul_coder/domain/studypost/StudyPostErrorCode.java b/src/main/java/grep/neogul_coder/domain/studypost/StudyPostErrorCode.java index 3aa472f4..f66490d5 100644 --- a/src/main/java/grep/neogul_coder/domain/studypost/StudyPostErrorCode.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/StudyPostErrorCode.java @@ -6,6 +6,7 @@ @Getter public enum StudyPostErrorCode implements ErrorCode { + NOT_VALID_CONDITION(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.name(), "잘못된 쿼리 조건 입니다."), NOT_JOINED_STUDY_USER(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.name(), "해당 스터디에 참여 하고 있지 않은 회원 입니다."), NOT_FOUND_POST(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.name(), "해당 스터디 게시글을 찾지 못했습니다."); diff --git a/src/main/java/grep/neogul_coder/domain/studypost/comment/repository/StudyCommentQueryRepository.java b/src/main/java/grep/neogul_coder/domain/studypost/comment/repository/StudyPostCommentQueryRepository.java similarity index 64% rename from src/main/java/grep/neogul_coder/domain/studypost/comment/repository/StudyCommentQueryRepository.java rename to src/main/java/grep/neogul_coder/domain/studypost/comment/repository/StudyPostCommentQueryRepository.java index 4c2c6963..d164895e 100644 --- a/src/main/java/grep/neogul_coder/domain/studypost/comment/repository/StudyCommentQueryRepository.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/comment/repository/StudyPostCommentQueryRepository.java @@ -11,15 +11,15 @@ import static grep.neogul_coder.domain.studypost.QStudyPost.studyPost; import static grep.neogul_coder.domain.studypost.comment.QStudyPostComment.studyPostComment; -import static grep.neogul_coder.domain.users.entity.QUser.*; +import static grep.neogul_coder.domain.users.entity.QUser.user; @Repository -public class StudyCommentQueryRepository { +public class StudyPostCommentQueryRepository { private final EntityManager em; private final JPAQueryFactory queryFactory; - public StudyCommentQueryRepository(EntityManager em) { + public StudyPostCommentQueryRepository(EntityManager em) { this.em = em; this.queryFactory = new JPAQueryFactory(em); } @@ -36,14 +36,14 @@ public List findAllByPostId(Long postId) { public List findWriterInfosByPostId(long postId) { return queryFactory.select( - new QCommentInfo( - user.id, - user.nickname, - user.profileImageUrl, - studyPostComment.id, - studyPostComment.content, - studyPostComment.createdDate - ) + new QCommentInfo( + user.id, + user.nickname, + user.profileImageUrl, + studyPostComment.id, + studyPostComment.content, + studyPostComment.createdDate + ) ) .from(studyPostComment) .join(user).on(studyPostComment.userId.eq(user.id)) @@ -53,4 +53,14 @@ public List findWriterInfosByPostId(long postId) { ) .fetch(); } + + public List findByPostIdIn(List postIds) { + return queryFactory.select(studyPostComment) + .from(studyPost) + .where( + studyPostComment.activated.isTrue(), + studyPostComment.postId.in(postIds) + ) + .fetch(); + } } diff --git a/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostController.java b/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostController.java index 297a513f..406d2758 100644 --- a/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostController.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostController.java @@ -1,8 +1,9 @@ package grep.neogul_coder.domain.studypost.controller; -import grep.neogul_coder.domain.studypost.controller.dto.StudyPostListResponse; +import grep.neogul_coder.domain.studypost.controller.dto.request.StudyPostPagingCondition; import grep.neogul_coder.domain.studypost.controller.dto.request.StudyPostSaveRequest; import grep.neogul_coder.domain.studypost.controller.dto.request.StudyPostUpdateRequest; +import grep.neogul_coder.domain.studypost.controller.dto.response.PostPagingResult; import grep.neogul_coder.domain.studypost.controller.dto.response.StudyPostDetailResponse; import grep.neogul_coder.domain.studypost.service.StudyPostService; import grep.neogul_coder.global.auth.Principal; @@ -12,8 +13,6 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -import java.util.List; - @RequiredArgsConstructor @RequestMapping("/api/posts") @RestController @@ -34,12 +33,11 @@ public ApiResponse findOne(@PathVariable("postId") Long return ApiResponse.success(response); } - @GetMapping("/all") - public ApiResponse> findAllWithoutPagination( - @PathVariable("studyId") Long studyId - ) { - List content = List.of(new StudyPostListResponse()); - return ApiResponse.success(content); + @GetMapping("/studies/{study-id}") + public ApiResponse findPagingInfo(@PathVariable("study-id") Long studyId, + @RequestBody StudyPostPagingCondition condition) { + PostPagingResult response = studyPostService.findPagingInfo(condition, studyId); + return ApiResponse.success(response); } @PutMapping("/{postId}") diff --git a/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostSpecification.java b/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostSpecification.java index 11a50108..744753fd 100644 --- a/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostSpecification.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostSpecification.java @@ -1,9 +1,10 @@ package grep.neogul_coder.domain.studypost.controller; -import grep.neogul_coder.domain.studypost.controller.dto.response.StudyPostDetailResponse; -import grep.neogul_coder.domain.studypost.controller.dto.StudyPostListResponse; +import grep.neogul_coder.domain.studypost.controller.dto.request.StudyPostPagingCondition; import grep.neogul_coder.domain.studypost.controller.dto.request.StudyPostSaveRequest; import grep.neogul_coder.domain.studypost.controller.dto.request.StudyPostUpdateRequest; +import grep.neogul_coder.domain.studypost.controller.dto.response.PostPagingResult; +import grep.neogul_coder.domain.studypost.controller.dto.response.StudyPostDetailResponse; import grep.neogul_coder.global.auth.Principal; import grep.neogul_coder.global.response.ApiResponse; import io.swagger.v3.oas.annotations.Operation; @@ -11,18 +12,15 @@ import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import java.util.List; - @Tag(name = "Study-Post", description = "스터디 게시판 API") public interface StudyPostSpecification { @Operation(summary = "게시글 생성", description = "스터디에 새로운 게시글을 작성합니다.") ApiResponse create(StudyPostSaveRequest request, Principal userDetails); - @Operation(summary = "게시글 목록 전체 조회", description = "스터디의 게시글 전체 목록을 조회합니다.") - ApiResponse> findAllWithoutPagination( - @Parameter(description = "스터디 ID", example = "1") Long studyId - ); + @Operation(summary = "게시글 목록 페이징 조회", description = "스터디의 게시글을 페이징 조회 합니다.") + ApiResponse findPagingInfo(@Parameter(description = "스터디 ID", example = "1") Long studyId, + StudyPostPagingCondition condition); @Operation(summary = "게시글 상세 조회", description = "특정 게시글의 상세 정보를 조회합니다.") ApiResponse findOne( diff --git a/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/StudyPostListResponse.java b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/StudyPostListResponse.java index cfc3dfeb..4bc7fa0c 100644 --- a/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/StudyPostListResponse.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/StudyPostListResponse.java @@ -6,24 +6,26 @@ import lombok.Getter; @Getter -@Schema(description = "스터디 게시글 목록 응답 DTO") +@Schema(description = "스터디 게시글 페이징 조회") public class StudyPostListResponse { - @Schema(description = "게시글 ID", example = "12") - private Long id; + static class PostPagingInfo { + @Schema(description = "게시글 ID", example = "12") + private Long id; - @Schema(description = "제목", example = "모든 국민은 직업선택의 자유를 가진다.") - private String title; + @Schema(description = "제목", example = "모든 국민은 직업선택의 자유를 가진다.") + private String title; - @Schema(description = "카테고리: NOTICE(공지), FREE(자유)", example = "NOTICE") - private Category category; + @Schema(description = "카테고리: NOTICE(공지), FREE(자유)", example = "NOTICE") + private Category category; - @Schema(description = "본문", example = "국회는 의원의 자격을 심사하며, 의원을 징계할 있다.") - private String content; + @Schema(description = "본문", example = "국회는 의원의 자격을 심사하며, 의원을 징계할 있다.") + private String content; - @Schema(description = "작성일", example = "2025-07-10T14:32:00") - private LocalDateTime createdDate; + @Schema(description = "작성일", example = "2025-07-10T14:32:00") + private LocalDateTime createdDate; - @Schema(description = "댓글 수", example = "3") - private int commentCount; + @Schema(description = "댓글 수", example = "3") + private int commentCount; + } } diff --git a/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/request/StudyPostPagingCondition.java b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/request/StudyPostPagingCondition.java new file mode 100644 index 00000000..97913137 --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/request/StudyPostPagingCondition.java @@ -0,0 +1,46 @@ +package grep.neogul_coder.domain.studypost.controller.dto.request; + +import grep.neogul_coder.domain.studypost.Category; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Positive; +import lombok.Getter; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +@Getter +public class StudyPostPagingCondition { + + private int page; + + @Positive + private int pageSize; + + @Schema(example = "NOTICE", description = " 스터디 공지 타입") + private Category category; + + @Schema(example = "내용", description = "자바 내용") + private String content; + + @Schema(example = "commentCount, createDateTime", description = "생성일 정렬") + private String attributeName; + + @Schema(example = "ASC, DESC", description = "댓글순 정렬") + private String sort; + + private StudyPostPagingCondition() { + } + + public StudyPostPagingCondition(int page, int pageSize, Category category, + String content, String attributeName, String sort) { + this.page = page; + this.pageSize = pageSize; + this.category = category; + this.content = content; + this.attributeName = attributeName; + this.sort = sort; + } + + public Pageable toPageable() { + return PageRequest.of(page, pageSize); + } +} diff --git a/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/NoticePostInfo.java b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/NoticePostInfo.java new file mode 100644 index 00000000..a57319a3 --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/NoticePostInfo.java @@ -0,0 +1,33 @@ +package grep.neogul_coder.domain.studypost.controller.dto.response; + +import com.querydsl.core.annotations.QueryProjection; +import grep.neogul_coder.domain.studypost.Category; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Getter +public class NoticePostInfo { + + @Schema(example = "3", description = "게시글 ID") + private long postId; + + @Schema(example = "공지", description = "게시글 타입") + private String category; + + @Schema(example = "제목", description = "공지글 제목") + private String title; + + @Schema(example = "2025-07-21", description = "생성일") + private LocalDate createdAt; + + @QueryProjection + public NoticePostInfo(long postId, Category category, String title, LocalDateTime createdAt) { + this.postId = postId; + this.category = category.getKorean(); + this.title = title; + this.createdAt = createdAt.toLocalDate(); + } +} diff --git a/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/PostPagingInfo.java b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/PostPagingInfo.java new file mode 100644 index 00000000..7b229d22 --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/PostPagingInfo.java @@ -0,0 +1,43 @@ +package grep.neogul_coder.domain.studypost.controller.dto.response; + +import com.querydsl.core.annotations.QueryProjection; +import grep.neogul_coder.domain.studypost.Category; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.ToString; + +import java.time.LocalDateTime; + +@ToString +@Getter +public class PostPagingInfo { + + @Schema(description = "게시글 ID", example = "12") + private Long id; + + @Schema(description = "제목", example = "모든 국민은 직업선택의 자유를 가진다.") + private String title; + + @Schema(description = "카테고리: NOTICE(공지), FREE(자유)", example = "NOTICE") + private Category category; + + @Schema(description = "본문", example = "국회는 의원의 자격을 심사하며, 의원을 징계할 있다.") + private String content; + + @Schema(description = "작성일", example = "2025-07-10T14:32:00") + private LocalDateTime createdDate; + + @Schema(description = "댓글 수", example = "3") + private long commentCount; + + @QueryProjection + public PostPagingInfo(Long id, String title, Category category, + String content, LocalDateTime createdDate, long commentCount) { + this.id = id; + this.title = title; + this.category = category; + this.content = content; + this.createdDate = createdDate; + this.commentCount = commentCount; + } +} diff --git a/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/PostPagingResult.java b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/PostPagingResult.java new file mode 100644 index 00000000..cec63504 --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/PostPagingResult.java @@ -0,0 +1,34 @@ +package grep.neogul_coder.domain.studypost.controller.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import org.springframework.data.domain.Page; + +import java.util.List; + +@Getter +public class PostPagingResult { + + @Schema(description = "최신 공지글") + private List noticePostInfos; + + @Schema(description = "게시글 페이징 리스트") + private List postInfos; + + @Schema(example = "3", description = "총 페이지수") + private long totalPage; + + @Schema(example = "3", description = "총 요소 개수") + private long totalElementCount; + + @Schema(example = "true", description = "다음 페이지 여부") + private boolean hasNext; + + public PostPagingResult(List noticePostInfos, Page page) { + this.noticePostInfos = noticePostInfos; + this.postInfos = page.getContent(); + this.totalPage = page.getTotalPages(); + this.totalElementCount = page.getTotalElements(); + this.hasNext = page.hasNext(); + } +} diff --git a/src/main/java/grep/neogul_coder/domain/studypost/repository/StudyPostQueryRepository.java b/src/main/java/grep/neogul_coder/domain/studypost/repository/StudyPostQueryRepository.java index cefe2da0..6fa76561 100644 --- a/src/main/java/grep/neogul_coder/domain/studypost/repository/StudyPostQueryRepository.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/repository/StudyPostQueryRepository.java @@ -1,15 +1,30 @@ package grep.neogul_coder.domain.studypost.repository; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.NumberExpression; +import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; +import grep.neogul_coder.domain.studypost.Category; import grep.neogul_coder.domain.studypost.QStudyPost; import grep.neogul_coder.domain.studypost.StudyPost; -import grep.neogul_coder.domain.studypost.controller.dto.response.PostInfo; -import grep.neogul_coder.domain.studypost.controller.dto.response.QPostInfo; +import grep.neogul_coder.domain.studypost.controller.dto.request.StudyPostPagingCondition; +import grep.neogul_coder.domain.studypost.controller.dto.response.*; +import grep.neogul_coder.global.exception.validation.ValidationException; import jakarta.persistence.EntityManager; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; +import java.util.function.Supplier; +import static grep.neogul_coder.domain.studypost.Category.NOTICE; +import static grep.neogul_coder.domain.studypost.StudyPostErrorCode.NOT_VALID_CONDITION; +import static grep.neogul_coder.domain.studypost.comment.QStudyPostComment.studyPostComment; import static grep.neogul_coder.domain.users.entity.QUser.user; @Repository @@ -18,6 +33,8 @@ public class StudyPostQueryRepository { private final EntityManager em; private final JPAQueryFactory queryFactory; + public static final int NOTICE_POST_LIMIT = 2; + private final QStudyPost studyPost = QStudyPost.studyPost; public StudyPostQueryRepository(EntityManager em) { @@ -58,4 +75,102 @@ public Optional findByIdAndUserId(Long postId, long userId) { return Optional.ofNullable(findStudyPost); } + + public Page findPagingFilteredBy(StudyPostPagingCondition condition, Long studyId) { + Pageable pageable = condition.toPageable(); + + JPAQuery query = queryFactory.select( + new QPostPagingInfo( + studyPost.id, + studyPost.title, + studyPost.category, + studyPost.content, + studyPost.createdDate, + studyPostComment.countDistinct() + ) + ) + .from(studyPost) + .leftJoin(studyPostComment).on(studyPost.id.eq(studyPostComment.postId)) + .where( + studyPost.activated.isTrue(), + studyPost.study.id.eq(studyId), + likeContent(condition.getContent()), + equalsCategory(condition.getCategory()) + ) + .groupBy(studyPost.id) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()); + + OrderSpecifier orderSpecifier = resolveOrderSpecifier(condition.getAttributeName(), condition.getSort()); + if (orderSpecifier != null) { + query.orderBy(orderSpecifier); + } + + Long count = queryFactory.select(studyPost.count()) + .from(studyPost) + .where( + studyPost.activated.isTrue(), + studyPost.study.id.eq(studyId), + likeContent(condition.getContent()), + equalsCategory(condition.getCategory()) + ) + .fetchOne(); + + return new PageImpl<>(query.fetch(), pageable, count == null ? 0 : count); + } + + public List findLatestNoticeInfoBy(Long studyId) { + return queryFactory.select( + new QNoticePostInfo( + studyPost.id, + studyPost.category, + studyPost.title, + studyPost.createdDate + ) + ) + .from(studyPost) + .where( + studyPost.activated.isTrue(), + studyPost.study.id.eq(studyId), + studyPost.category.eq(NOTICE) + ) + .orderBy(studyPost.createdDate.desc()) + .limit(NOTICE_POST_LIMIT) + .fetch(); + } + + private OrderSpecifier resolveOrderSpecifier(String attributeName, String direction) { + if (attributeName == null || direction == null) { + return null; + } + + boolean isAsc = "ASC".equals(direction); + + if (attributeName.equalsIgnoreCase("commentCount")) { + NumberExpression commentCount = studyPostComment.id.countDistinct(); + return isAsc ? commentCount.asc() : commentCount.desc(); + } + + if (attributeName.equalsIgnoreCase("createDateTime")) { + return isAsc ? studyPost.createdDate.asc() : studyPost.createdDate.desc(); + } + + throw new ValidationException(NOT_VALID_CONDITION); + } + + private BooleanBuilder likeContent(String content) { + return nullSafeBuilder(() -> studyPost.content.contains(content)); + } + + private BooleanBuilder equalsCategory(Category category) { + return nullSafeBuilder(() -> studyPost.category.eq(category)); + } + + private BooleanBuilder nullSafeBuilder(Supplier supplier) { + try { + return new BooleanBuilder(supplier.get()); + } catch (Exception e) { + return new BooleanBuilder(); + } + } } diff --git a/src/main/java/grep/neogul_coder/domain/studypost/service/StudyPostService.java b/src/main/java/grep/neogul_coder/domain/studypost/service/StudyPostService.java index c56f7bee..815dd856 100644 --- a/src/main/java/grep/neogul_coder/domain/studypost/service/StudyPostService.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/service/StudyPostService.java @@ -4,16 +4,16 @@ import grep.neogul_coder.domain.study.StudyMember; import grep.neogul_coder.domain.study.repository.StudyMemberQueryRepository; import grep.neogul_coder.domain.studypost.StudyPost; -import grep.neogul_coder.domain.studypost.comment.repository.StudyCommentQueryRepository; +import grep.neogul_coder.domain.studypost.comment.repository.StudyPostCommentQueryRepository; +import grep.neogul_coder.domain.studypost.controller.dto.request.StudyPostPagingCondition; import grep.neogul_coder.domain.studypost.controller.dto.request.StudyPostSaveRequest; import grep.neogul_coder.domain.studypost.controller.dto.request.StudyPostUpdateRequest; -import grep.neogul_coder.domain.studypost.controller.dto.response.CommentInfo; -import grep.neogul_coder.domain.studypost.controller.dto.response.PostInfo; -import grep.neogul_coder.domain.studypost.controller.dto.response.StudyPostDetailResponse; +import grep.neogul_coder.domain.studypost.controller.dto.response.*; import grep.neogul_coder.domain.studypost.repository.StudyPostQueryRepository; import grep.neogul_coder.domain.studypost.repository.StudyPostRepository; import grep.neogul_coder.global.exception.business.NotFoundException; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -32,7 +32,7 @@ public class StudyPostService { private final StudyPostRepository studyPostRepository; private final StudyPostQueryRepository studyPostQueryRepository; - private final StudyCommentQueryRepository commentQueryRepository; + private final StudyPostCommentQueryRepository commentQueryRepository; public StudyPostDetailResponse findOne(Long postId) { PostInfo postInfo = studyPostQueryRepository.findPostWriterInfo(postId); @@ -40,6 +40,12 @@ public StudyPostDetailResponse findOne(Long postId) { return new StudyPostDetailResponse(postInfo, commentInfos, commentInfos.size()); } + public PostPagingResult findPagingInfo(StudyPostPagingCondition condition, Long studyId) { + Page pages = studyPostQueryRepository.findPagingFilteredBy(condition, studyId); + List noticeInfos = studyPostQueryRepository.findLatestNoticeInfoBy(studyId); + return new PostPagingResult(noticeInfos, pages); + } + @Transactional public long create(StudyPostSaveRequest request, long userId) { List myStudies = studyQueryRepository.findAllFetchStudyByUserId(userId); diff --git a/src/test/java/grep/neogul_coder/domain/studypost/repository/StudyPostQueryRepositoryTest.java b/src/test/java/grep/neogul_coder/domain/studypost/repository/StudyPostQueryRepositoryTest.java new file mode 100644 index 00000000..8394ecb6 --- /dev/null +++ b/src/test/java/grep/neogul_coder/domain/studypost/repository/StudyPostQueryRepositoryTest.java @@ -0,0 +1,164 @@ +package grep.neogul_coder.domain.studypost.repository; + +import grep.neogul_coder.domain.IntegrationTestSupport; +import grep.neogul_coder.domain.study.Study; +import grep.neogul_coder.domain.study.StudyMember; +import grep.neogul_coder.domain.study.repository.StudyMemberRepository; +import grep.neogul_coder.domain.study.repository.StudyRepository; +import grep.neogul_coder.domain.studypost.Category; +import grep.neogul_coder.domain.studypost.StudyPost; +import grep.neogul_coder.domain.studypost.comment.StudyPostComment; +import grep.neogul_coder.domain.studypost.comment.repository.StudyCommentRepository; +import grep.neogul_coder.domain.studypost.controller.dto.request.StudyPostPagingCondition; +import grep.neogul_coder.domain.studypost.controller.dto.response.NoticePostInfo; +import grep.neogul_coder.domain.studypost.controller.dto.response.PostPagingInfo; +import grep.neogul_coder.domain.users.entity.User; +import grep.neogul_coder.domain.users.repository.UserRepository; +import jakarta.persistence.EntityManager; +import org.assertj.core.groups.Tuple; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; + +import java.util.List; + +import static grep.neogul_coder.domain.studypost.Category.FREE; +import static grep.neogul_coder.domain.studypost.Category.NOTICE; +import static org.assertj.core.api.Assertions.assertThat; + +class StudyPostQueryRepositoryTest extends IntegrationTestSupport { + + @Autowired + private EntityManager em; + + @Autowired + private UserRepository userRepository; + + @Autowired + private StudyRepository studyRepository; + + @Autowired + private StudyMemberRepository studyMemberRepository; + + @Autowired + private StudyPostRepository studyPostRepository; + + @Autowired + private StudyPostQueryRepository studyPostQueryRepository; + + @Autowired + private StudyCommentRepository studycommentRepository; + + @DisplayName("스터디 게시글을 페이징 조회 합니다.") + @Test + void findPagingFilteredBy() { + //given + User user = createUser("테스터"); + userRepository.save(user); + + Study study = createStudy("자바 스터디"); + studyRepository.save(study); + + StudyPost post1 = createStudyPost(study, user.getId(), "제목1", NOTICE, "Like 내용1"); + StudyPost post2 = createStudyPost(study, user.getId(), "제목2", FREE, "Like 내용2"); + + List posts = List.of( + post1, post2, + createStudyPost(study, user.getId(), "제목3", FREE, "내용3"), + createStudyPost(study, user.getId(), "제목4", NOTICE, "내용4"), + createStudyPost(study, user.getId(), "제목5", FREE, "내용5") + ); + studyPostRepository.saveAll(posts); + + List comments = List.of( + createPostComment(post1.getId(), user.getId(), "댓글1"), + createPostComment(post2.getId(), user.getId(), "댓글2"), + createPostComment(post2.getId(), user.getId(), "댓글3") + ); + studycommentRepository.saveAll(comments); + + //when + StudyPostPagingCondition condition = new StudyPostPagingCondition(0, 2, FREE, "Like", "createDateTime", "DESC"); + Page page = studyPostQueryRepository.findPagingFilteredBy(condition, study.getId()); + List response = page.getContent(); + // System.out.println("response = " + response); + // System.out.println("page.getTotalPages() = " + page.getTotalPages()); + // System.out.println("page.getTotalElements() = " + page.getTotalElements()); + + //then + assertThat(response).hasSize(1); + assertThat(response) + .extracting("title", "commentCount") + .containsExactly( + Tuple.tuple("제목2", 2L) + ); + } + + @DisplayName("가장 최근 공지글을 조회 합니다.") + @Test + void findLatestNoticeInfoBy() { + //given + User user = createUser("테스터"); + userRepository.save(user); + + Study study = createStudy("자바 스터디"); + studyRepository.save(study); + + List posts = List.of( + createStudyPost(study, user.getId(), "공지글1", NOTICE, "내용1"), + createStudyPost(study, user.getId(), "자유글2", FREE, "내용2"), + createStudyPost(study, user.getId(), "공지글2", NOTICE, "내용3") + ); + studyPostRepository.saveAll(posts); + + //when + List result = studyPostQueryRepository.findLatestNoticeInfoBy(study.getId()); + + //then + assertThat(result).hasSize(2) + .extracting("title") + .containsExactlyInAnyOrder("공지글1", "공지글2"); + } + + private User createUser(String nickname) { + return User.builder() + .nickname(nickname) + .password("tempPassword") + .build(); + } + + private Study createStudy(String name) { + return Study.builder() + .name(name) + .build(); + } + + + private StudyMember createStudyMember(Study study, long userId) { + return StudyMember.builder() + .study(study) + .userId(userId) + .build(); + } + + private StudyPost createStudyPost(Study study, long userId, String title, Category category, String content) { + StudyPost studyPost = StudyPost.builder() + .userId(userId) + .title(title) + .category(category) + .content(content) + .build(); + + studyPost.connectStudy(study); + return studyPost; + } + + private StudyPostComment createPostComment(long postId, long userId, String content) { + return StudyPostComment.builder() + .postId(postId) + .userId(userId) + .content(content) + .build(); + } +} \ No newline at end of file From 313d58a425f561c44e772077092a2b1081c1eb5d Mon Sep 17 00:00:00 2001 From: Tokwasp Date: Mon, 21 Jul 2025 18:01:37 +0900 Subject: [PATCH 24/34] =?UTF-8?q?[EA3-163]=20chore:=20=EC=8A=A4=ED=84=B0?= =?UTF-8?q?=EB=94=94=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=8A=A4=EC=9B=A8?= =?UTF-8?q?=EA=B1=B0=20=EC=83=81=EC=84=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/StudyPostController.java | 4 +- .../controller/StudyPostSpecification.java | 97 ++++++++++++++++++- .../global/auth/entity/RefreshToken.java | 3 + 3 files changed, 100 insertions(+), 4 deletions(-) diff --git a/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostController.java b/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostController.java index 406d2758..1763e5ef 100644 --- a/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostController.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostController.java @@ -33,9 +33,9 @@ public ApiResponse findOne(@PathVariable("postId") Long return ApiResponse.success(response); } - @GetMapping("/studies/{study-id}") + @PostMapping("/studies/{study-id}") public ApiResponse findPagingInfo(@PathVariable("study-id") Long studyId, - @RequestBody StudyPostPagingCondition condition) { + @RequestBody @Valid StudyPostPagingCondition condition) { PostPagingResult response = studyPostService.findPagingInfo(condition, studyId); return ApiResponse.success(response); } diff --git a/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostSpecification.java b/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostSpecification.java index 744753fd..6409e9a9 100644 --- a/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostSpecification.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostSpecification.java @@ -18,11 +18,104 @@ public interface StudyPostSpecification { @Operation(summary = "게시글 생성", description = "스터디에 새로운 게시글을 작성합니다.") ApiResponse create(StudyPostSaveRequest request, Principal userDetails); - @Operation(summary = "게시글 목록 페이징 조회", description = "스터디의 게시글을 페이징 조회 합니다.") + @Operation( + summary = "게시글 목록 페이징 조회", + description = """ + 스터디의 게시글을 조건에 따라 페이징하여 조회합니다. + + ✅ 요청 예시: + `GET /api/posts/studies/{study-id} + + ✅ condition 설명: + - `page`: 조회할 페이지 번호 (0부터 시작) + + - `pageSize`: 한 페이지에 표시할 게시글 수 + + - `category`: 게시글 카테고리 (예: NOTICE, FREE) + + - `content`: 게시글 내용 검색 키워드 + + - `attributeName`: 정렬 대상 속성 (예: commentCount, createDateTime) + + - `sort`: 정렬 방향 (ASC 또는 DESC) + + ✅ 응답 예시: + ```json + { + "data": { + "noticePostInfos": [ + { + "postId": 3, + "category": "공지", + "title": "스터디 일정 공지", + "createdAt": "2025-07-21" + } + ], + "postInfos": [ + { + "id": 12, + "title": "모든 국민은 직업선택의 자유를 가진다.", + "category": "NOTICE", + "content": "국회는 의원의 자격을 심사하며, 의원을 징계할 있다.", + "createdDate": "2025-07-10T14:32:00", + "commentCount": 3 + } + ], + "totalPage": 3, + "totalElementCount": 12, + "hasNext": true + } + } + ``` + """ + ) ApiResponse findPagingInfo(@Parameter(description = "스터디 ID", example = "1") Long studyId, StudyPostPagingCondition condition); - @Operation(summary = "게시글 상세 조회", description = "특정 게시글의 상세 정보를 조회합니다.") + @Operation( + summary = "게시글 상세 조회", + description = """ + 특정 게시글의 상세 정보를 조회합니다. + + ✅ 요청 예시: + `GET /api/posts/{post-id}` + + ✅ 응답 예시: + ```json + { + "data": { + "postInfo": { + "postId": 15, + "title": "스터디에 참여해주세요", + "category": "NOTICE", + "content": "매주 월요일 정기모임 진행합니다.", + "createdDate": "2025-07-21T15:32:00", + "commentCount": 3 + }, + "comments": [ + { + "userId": 3, + "nickname": "너굴코더", + "profileImageUrl": "https://cdn.example.com/profile.jpg", + "id": 100, + "content": "정말 좋은 정보 감사합니다!", + "createdAt": "2025-07-10T14:45:00" + }, + { + "userId": 4, + "nickname": "코딩곰", + "profileImageUrl": "https://cdn.example.com/codingbear.png", + "id": 101, + "content": "참석하겠습니다!", + "createdAt": "2025-07-10T15:12:00" + } + ], + "commentCount": 3 + } + } + ``` + """ + ) ApiResponse findOne( @Parameter(description = "게시글 ID", example = "15") Long postId ); diff --git a/src/main/java/grep/neogul_coder/global/auth/entity/RefreshToken.java b/src/main/java/grep/neogul_coder/global/auth/entity/RefreshToken.java index 0d36c8d0..600e5090 100644 --- a/src/main/java/grep/neogul_coder/global/auth/entity/RefreshToken.java +++ b/src/main/java/grep/neogul_coder/global/auth/entity/RefreshToken.java @@ -12,6 +12,9 @@ public class RefreshToken { private String token = UUID.randomUUID().toString(); private Long ttl = 3600 * 24 * 7L; + public RefreshToken() { + } + public RefreshToken(String atId){ this.atId = atId; } From 4e55484a65cc4420d5998bf9bb44959d5ec7b34a Mon Sep 17 00:00:00 2001 From: hyeunS-P Date: Mon, 21 Jul 2025 18:05:27 +0900 Subject: [PATCH 25/34] =?UTF-8?q?[EA3-166]=20feature:PR=20=ED=85=9C?= =?UTF-8?q?=ED=94=8C=EB=A6=BF=20=EC=A1=B0=ED=9A=8C=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EB=A6=AC=ED=8C=A9=ED=84=B0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/PrPageResponse.java | 47 ++++-- .../domain/prtemplate/entity/Link.java | 2 +- .../repository/PrTemplateQueryRepository.java | 41 +++++ .../prtemplate/service/PrTemplateService.java | 146 ++++++++++-------- 4 files changed, 151 insertions(+), 85 deletions(-) create mode 100644 src/main/java/grep/neogul_coder/domain/prtemplate/repository/PrTemplateQueryRepository.java diff --git a/src/main/java/grep/neogul_coder/domain/prtemplate/controller/dto/response/PrPageResponse.java b/src/main/java/grep/neogul_coder/domain/prtemplate/controller/dto/response/PrPageResponse.java index c5232ea8..3f6f852a 100644 --- a/src/main/java/grep/neogul_coder/domain/prtemplate/controller/dto/response/PrPageResponse.java +++ b/src/main/java/grep/neogul_coder/domain/prtemplate/controller/dto/response/PrPageResponse.java @@ -1,12 +1,12 @@ package grep.neogul_coder.domain.prtemplate.controller.dto.response; -import grep.neogul_coder.domain.buddy.entity.BuddyEnergy; -import grep.neogul_coder.domain.prtemplate.entity.PrTemplate; +import grep.neogul_coder.domain.buddy.controller.dto.response.BuddyEnergyResponse; +import grep.neogul_coder.domain.review.ReviewType; import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDate; import java.util.List; -import lombok.Builder; -import lombok.Data; + +import lombok.*; @Data @@ -21,10 +21,10 @@ public class PrPageResponse { private List userLocationAndLinks; @Schema(description = "버디 에너지 수치", example = "85") - private int buddyEnergy; + private BuddyEnergyResponse buddyEnergy; - @Schema(description = "리뷰 태그 목록") - private List reviewTags; + @Schema(description = "리뷰 타입 목록") + private List reviewTypes; @Schema(description = "리뷰 내용 목록") private List reviewContents; @@ -40,13 +40,22 @@ public static class UserLocationAndLink { @Schema(description = "위치", example = "서울") private String location; - @Schema(description = "링크 이름", example = "인스타그램") - private String linkName; + @Schema(description = "링크 목록") + private List links; + + @Data + @Builder + @Schema(description = "링크 정보") + public static class LinkInfo { + @Schema(description = "링크 이름", example = "인스타그램") + private String linkName; - @Schema(description = "링크 URL", example = "https://instagram.com/example") - private String link; + @Schema(description = "링크 URL", example = "https://instagram.com/example") + private String link; + } } + @Data @Builder @Schema(description = "유저 프로필 정보") @@ -59,16 +68,19 @@ public static class UserProfileDto { private String profileImgUrl; } - @Data + @Getter @Builder - @Schema(description = "리뷰 태그 정보") - public static class ReviewTagDto { + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "리뷰 타입 정보") + public static class ReviewTypeDto { @Schema(description = "리뷰 유형", example = "친절함") - private String reviewType; + private ReviewType reviewType; @Schema(description = "리뷰 개수", example = "12") private int reviewCount; + } @Data @@ -76,9 +88,12 @@ public static class ReviewTagDto { @Schema(description = "리뷰 내용 정보") public static class ReviewContentDto { - @Schema(description = "리뷰한 사용자 ID", example = "101") + @Schema(description = "리뷰한 사용자 ID", example = "4") private Long reviewUserId; + @Schema(description = "리뷰한 사용자 닉네임", example = "홍길동") + private String reviewUserNickname; + @Schema(description = "리뷰한 사용자 프로필 이미지 URL", example = "https://example.com/reviewer.jpg") private String reviewUserImgUrl; diff --git a/src/main/java/grep/neogul_coder/domain/prtemplate/entity/Link.java b/src/main/java/grep/neogul_coder/domain/prtemplate/entity/Link.java index e4635abc..38f89ca1 100644 --- a/src/main/java/grep/neogul_coder/domain/prtemplate/entity/Link.java +++ b/src/main/java/grep/neogul_coder/domain/prtemplate/entity/Link.java @@ -37,7 +37,7 @@ public static Link LinkInit(Long prId, String prUrl, String urlName) { .prId(prId) .prUrl(prUrl) .urlName(urlName) - .activated(true) + .activated(false) .build(); } diff --git a/src/main/java/grep/neogul_coder/domain/prtemplate/repository/PrTemplateQueryRepository.java b/src/main/java/grep/neogul_coder/domain/prtemplate/repository/PrTemplateQueryRepository.java new file mode 100644 index 00000000..85802a63 --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/prtemplate/repository/PrTemplateQueryRepository.java @@ -0,0 +1,41 @@ +package grep.neogul_coder.domain.prtemplate.repository; + +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; +import grep.neogul_coder.domain.prtemplate.controller.dto.response.PrPageResponse; +import grep.neogul_coder.domain.review.entity.QMyReviewTagEntity; +import grep.neogul_coder.domain.review.entity.QReviewEntity; +import grep.neogul_coder.domain.review.entity.QReviewTagEntity; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class PrTemplateQueryRepository { + + private final JPAQueryFactory queryFactory; + + public List findReviewTypeCountsByTargetUser(Long targetUserId) { + QReviewEntity review = QReviewEntity.reviewEntity; + QMyReviewTagEntity myReviewTag = QMyReviewTagEntity.myReviewTagEntity; + QReviewTagEntity reviewTag = QReviewTagEntity.reviewTagEntity; + + return queryFactory + .select(Projections.constructor( + PrPageResponse.ReviewTypeDto.class, + reviewTag.reviewType, + myReviewTag.count().intValue() + )) + .from(myReviewTag) + .join(myReviewTag.reviewEntity, review) + .join(myReviewTag.reviewTag, reviewTag) + .where(review.targetUserId.eq(targetUserId)) + .groupBy(reviewTag.reviewType) + .fetch(); + } + + + +} diff --git a/src/main/java/grep/neogul_coder/domain/prtemplate/service/PrTemplateService.java b/src/main/java/grep/neogul_coder/domain/prtemplate/service/PrTemplateService.java index 5e2f6cfc..a88e11ae 100644 --- a/src/main/java/grep/neogul_coder/domain/prtemplate/service/PrTemplateService.java +++ b/src/main/java/grep/neogul_coder/domain/prtemplate/service/PrTemplateService.java @@ -1,13 +1,13 @@ package grep.neogul_coder.domain.prtemplate.service; -import grep.neogul_coder.domain.buddy.entity.BuddyEnergy; -import grep.neogul_coder.domain.buddy.repository.BuddyEnergyRepository; +import grep.neogul_coder.domain.buddy.controller.dto.response.BuddyEnergyResponse; +import grep.neogul_coder.domain.buddy.service.BuddyEnergyService; import grep.neogul_coder.domain.prtemplate.controller.dto.response.PrPageResponse; import grep.neogul_coder.domain.prtemplate.entity.Link; import grep.neogul_coder.domain.prtemplate.entity.PrTemplate; -import grep.neogul_coder.domain.prtemplate.exception.TemplateNotFoundException; import grep.neogul_coder.domain.prtemplate.exception.code.PrTemplateErrorCode; import grep.neogul_coder.domain.prtemplate.repository.LinkRepository; +import grep.neogul_coder.domain.prtemplate.repository.PrTemplateQueryRepository; import grep.neogul_coder.domain.prtemplate.repository.PrTemplateRepository; import grep.neogul_coder.domain.review.entity.ReviewEntity; import grep.neogul_coder.domain.review.repository.ReviewRepository; @@ -16,107 +16,117 @@ import grep.neogul_coder.domain.users.exception.code.UserErrorCode; import grep.neogul_coder.domain.users.repository.UserRepository; import grep.neogul_coder.global.exception.business.NotFoundException; -import jakarta.transaction.Transactional; -import java.util.Comparator; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Comparator; +import java.util.List; @Service @RequiredArgsConstructor -@Transactional public class PrTemplateService { private final PrTemplateRepository prTemplateRepository; + private final PrTemplateQueryRepository prTemplateQueryRepository; private final LinkRepository linkRepository; private final ReviewRepository reviewRepository; - private final BuddyEnergyRepository buddyEnergyRepository; + private final BuddyEnergyService buddyEnergyService; private final UserRepository userRepository; + @Transactional public void deleteByUserId(Long userId) { PrTemplate prTemplate = getPrTemplateByUserId(userId); prTemplate.delete(); } + @Transactional public void update(Long userId, String location) { PrTemplate prTemplate = getPrTemplateByUserId(userId); prTemplate.update(location); } + @Transactional public void updateIntroduction(Long userId, String introduction) { PrTemplate prTemplate = getPrTemplateByUserId(userId); prTemplate.updateIntroduction(introduction); } + @Transactional(readOnly = true) public PrPageResponse toResponse(Long userId) { - User user = userRepository.findById(userId).orElseThrow(() -> new NotFoundException( - UserErrorCode.USER_NOT_FOUND)); PrTemplate prTemplate = getPrTemplateByUserId(userId); List links = linkRepository.findAllByUserIdAndActivatedTrue(userId); List reviews = reviewRepository.findAllByTargetUserId(userId); - List userProfiles = List.of( - PrPageResponse.UserProfileDto.builder() - .nickname(user.getNickname()) - .profileImgUrl(user.getProfileImageUrl()) - .build() - ); - - List userLocationAndLinks = links.stream() - .filter(Link::getActivated) - .map(link -> PrPageResponse.UserLocationAndLink.builder() - .location(prTemplate.getLocation()) - .linkName(link.getUrlName()) - .link(link.getPrUrl()) - .build()) - .toList(); - - int buddyEnergy = buddyEnergyRepository.findByUserId(userId) - .map(BuddyEnergy::getLevel) - .orElse(50); - - Map tagMap = reviews.stream() - .flatMap(review -> review.getReviewTags().stream()) - .map(myTag -> myTag.getReviewTag().getReviewTag()) - .collect(Collectors.groupingBy(tag -> tag, Collectors.counting())); - - List reviewTags = tagMap.entrySet().stream() - .map(entry -> PrPageResponse.ReviewTagDto.builder() - .reviewType(entry.getKey()) - .reviewCount(entry.getValue().intValue()) - .build()) - .toList(); - - List reviewContents = reviews.stream() - .sorted(Comparator.comparing(ReviewEntity::getCreatedDate).reversed()) - .limit(5) - .map(review -> { - User writer = userRepository.findById(review.getWriteUserId()) - .orElseThrow(() -> new UserNotFoundException(UserErrorCode.USER_NOT_FOUND)); - return PrPageResponse.ReviewContentDto.builder() - .reviewUserId(writer.getId()) - .reviewUserImgUrl(writer.getProfileImageUrl()) - .reviewComment(review.getContent()) - .reviewDate(review.getCreatedDate().toLocalDate()) - .build(); - }) - .toList(); + List prUser = getPrUser(userId); + List userLocationAndLinks = getUserLocationAndLinks(links, prTemplate); + BuddyEnergyResponse buddyEnergy = buddyEnergyService.getBuddyEnergy(userId); + List reviewTypeDto = getReviewTypeDto(userId); + List reviewContents = getReviewContents(reviews); return PrPageResponse.builder() - .userProfiles(userProfiles) - .userLocationAndLinks(userLocationAndLinks) - .buddyEnergy(buddyEnergy) - .reviewTags(reviewTags) - .reviewContents(reviewContents) - .introduction(prTemplate.getIntroduction()) - .build(); + .userProfiles(prUser) + .userLocationAndLinks(userLocationAndLinks) + .buddyEnergy(buddyEnergy) + .reviewTypes(reviewTypeDto) + .reviewContents(reviewContents) + .introduction(prTemplate.getIntroduction()) + .build(); } - public PrTemplate getPrTemplateByUserId(Long userId) { + private PrTemplate getPrTemplateByUserId(Long userId) { return prTemplateRepository.findByUserId(userId).orElseThrow( - () -> new NotFoundException(PrTemplateErrorCode.TEMPLATE_NOT_FOUND)); + () -> new NotFoundException(PrTemplateErrorCode.TEMPLATE_NOT_FOUND)); + } + + private List getPrUser(Long userId) { + User user = userRepository.findById(userId).orElseThrow(() -> new UserNotFoundException(UserErrorCode.USER_NOT_FOUND)); + return List.of( + PrPageResponse.UserProfileDto.builder() + .nickname(user.getNickname()) + .profileImgUrl(user.getProfileImageUrl()) + .build() + ); + } + + private List getUserLocationAndLinks(List links, PrTemplate prTemplate) { + return links.stream() + .map(link -> PrPageResponse.UserLocationAndLink.builder() + .location(prTemplate.getLocation()) + .links(LinkInfo(links)) + .build()) + .toList(); + } + + private List LinkInfo(List links) { + return links.stream() + .map(link -> PrPageResponse.UserLocationAndLink.LinkInfo.builder() + .linkName(link.getUrlName()) + .link(link.getPrUrl()) + .build()) + .toList(); + } + + private List getReviewTypeDto(Long targetUserId) { + return prTemplateQueryRepository.findReviewTypeCountsByTargetUser(targetUserId); + } + + private List getReviewContents(List reviews) { + return reviews.stream() + .sorted(Comparator.comparing(ReviewEntity::getCreatedDate).reversed()) + .limit(5) + .map(review -> { + User writer = userRepository.findById(review.getWriteUserId()).orElseThrow(() -> new UserNotFoundException(UserErrorCode.USER_NOT_FOUND)); + return PrPageResponse.ReviewContentDto.builder() + .reviewUserId(writer.getId()) + .reviewUserNickname(writer.getNickname()) + .reviewUserImgUrl(writer.getProfileImageUrl()) + .reviewComment(review.getContent()) + .reviewDate(review.getCreatedDate().toLocalDate()) + .build(); + }) + .toList(); + } -} \ No newline at end of file +} From 7912fbd4c75ae0b796c3521f25032c086be528d2 Mon Sep 17 00:00:00 2001 From: dbrkdgus00 Date: Mon, 21 Jul 2025 18:16:24 +0900 Subject: [PATCH 26/34] =?UTF-8?q?[EA3-111]=20feature=20:=20=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9=EC=B1=84=ED=8C=85=20=EA=B3=BC=EA=B1=B0=20=EC=B1=84?= =?UTF-8?q?=ED=8C=85=20=ED=8E=98=EC=9D=B4=EC=A7=95=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20Swagger=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/GroupChatRestController.java | 1 - .../GroupChatRestSpecification.java | 81 +++++++++++++------ .../GroupChatSwaggerSpecification.java | 53 +++++++++++- 3 files changed, 106 insertions(+), 29 deletions(-) diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatRestController.java b/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatRestController.java index 7ecdd7b4..32efb300 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatRestController.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatRestController.java @@ -26,7 +26,6 @@ public ApiResponse> getMessages( PageResponse pageResponse = groupChatService.getMessages(roomId, page, size); - // 공통 응답 형식에 맞게 반환 return ApiResponse.success(pageResponse); } } diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatRestSpecification.java b/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatRestSpecification.java index dd865f5d..4ec795df 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatRestSpecification.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatRestSpecification.java @@ -15,33 +15,62 @@ public interface GroupChatRestSpecification { @Operation( summary = "채팅 메시지 페이징 조회", description = """ - 채팅방의 과거 메시지를 페이지 단위로 조회합니다.
- 프론트는 무한 스크롤 방식으로 이 API를 반복 호출하여 이전 메시지를 계속 불러올 수 있습니다.

- - 이 API는 WebSocket의 `/sub/chat/room/{roomId}` 실시간 수신과는 별개입니다.
- 사용자는 채팅방 입장 시 이 API로 과거 메시지를 먼저 가져오고
- 이후 WebSocket을 연결해 실시간 메시지를 받는 방식으로 연동합니다.

- - 전반적인 프론트 흐름은 다음과 같습니다:
- 1. 채팅방에 처음 입장하면 → 이 API로 `page=0`부터 메시지를 조회
- 2. 이후 스크롤을 올릴 때마다 → `page=1`, `page=2`… 순차 호출
- 3. 동시에 WebSocket `/sub/chat/room/{roomId}` 구독 시작

- - 파라미터 설명:
- - `roomId`: 채팅방 식별자입니다.
- - `page`: 0부터 시작하는 페이지 번호입니다. (0번이 가장 오래된 메시지입니다.)
- - `size`: 한 페이지에 포함시킬 메시지 개수입니다.
- - 메시지는 **오래된 순(오름차순)** 으로 정렬되어 반환됩니다.
- - 프론트는 최신 메시지를 아래에 배치하고, 스크롤을 위로 올릴 때마다 과거 메시지를 불러오도록 구현합니다.

+ 이 API는 **채팅방의 과거 메시지**를 페이지 단위로 가져오는 용도입니다. + WebSocket의 실시간 수신(`/sub/chat/room/{roomId}`)과는 별개로, + 채팅방에 입장할 때 이전 대화 기록을 불러오는 데 사용됩니다. - - 응답 구조:
- - `ApiResponse>` 형태로 감싸져 있습니다.
- - 실제 메시지는 `content()` 메서드를 통해 꺼낼 수 있습니다.
- - 페이지네이션 정보는 `currentNumber()`, `prevPage()`, `nextPage()` 등을 통해 확인할 수 있습니다.

- - 예시 요청 URL: `/api/chat/room/1/messages?page=0&size=20` - """ + --- + + **프론트엔드 연동 흐름 (권장 방식)**: + 1. **채팅방 입장 시** → `GET /api/chat/room/{roomId}/messages?page=0&size=20` 호출해 최신 메시지 20개 로드 + 2. **스크롤 위로 올릴 때** → `page=1`, `page=2` ... 순차적으로 과거 메시지를 추가 로딩 (무한 스크롤) + 3. **동시에** → WebSocket(`wss://wibby.cedartodo.uk/ws-stomp`) 연결 후 `/sub/chat/room/{roomId}`를 **구독**해 실시간 메시지 수신 + + --- + + **파라미터 설명**: + - `roomId`: 채팅방 ID + - `page`: 페이지 번호 (0부터 시작, 0 = 최신 메시지 20개) + - `size`: 한 페이지당 메시지 수 (기본값 20) + - 메시지는 **오래된 순(오름차순)**으로 반환됩니다. + + --- + + **응답 구조**: + - `ApiResponse>` 형태 + - `content()`로 메시지 목록 접근 가능 + - 페이지네이션 정보: `currentNumber()`, `prevPage()`, `nextPage()` 등 + + --- + + **예시 요청 URL**: + ``` + /api/chat/room/1/messages?page=0&size=20 + ``` + + **예시 응답**: + ```json + { + "success": true, + "data": { + "content": [ + { + "id": 101, + "roomId": 1, + "senderId": 10, + "senderNickname": "유강현", + "profileImageUrl": "https://example.com/profile.jpg", + "message": "안녕하세요!", + "sentAt": "2025-07-21T14:32:00" + } + ], + "currentNumber": 0, + "nextPage": 1, + "prevPage": null + } + } + ``` + """ ) ApiResponse> getMessages( diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatSwaggerSpecification.java b/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatSwaggerSpecification.java index 34f95f51..7b8adaf0 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatSwaggerSpecification.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatSwaggerSpecification.java @@ -11,9 +11,58 @@ @Tag(name = "GroupChat", description = "WebSocket 구조 설명용 Swagger 문서") public interface GroupChatSwaggerSpecification { - @Operation(summary = "Pub 채팅 메시지 전송 구조 예시", description = "실제 채팅 전송은 WebSocket을 통해 `/pub/chat/message`로 전송됩니다.") + @Operation(summary = "채팅 메시지 전송 (WebSocket Pub)", + description = """ + **실제 채팅 메시지 전송은 WebSocket 연결 후에 이루어집니다.** + + **1. WebSocket 연결** + - 먼저 `wss://wibby.cedartodo.uk/ws-stomp` 엔드포인트로 STOMP 연결을 맺습니다. + + **2. 메시지 전송** + - 연결이 완료된 후 `/pub/chat/message` 경로로 메시지를 보냅니다. + + **예시 Request JSON** + ```json + { + "roomId": 1, + "senderId": 10, + "message": "안녕하세요!" + } + ``` + + ** Swagger에서 이 API를 실행해도 실제 전송은 되지 않으며, + WebSocket 통신 구조를 이해하기 위한 문서 예시입니다.** + """) ApiResponse sendMessage(GroupChatSwaggerRequest request); - @Operation(summary = "Sub 채팅 메시지 수신 구조 예시", description = "실제 메시지 수신은 WebSocket을 통해 `/sub/chat/room/{roomId}`로 수신됩니다.") + @Operation(summary = "채팅 메시지 수신 (WebSocket Sub)", + description = """ + **실제 메시지 수신 또한 WebSocket 연결이 필수입니다.** + + **1. WebSocket 연결** + - 먼저 `wss://wibby.cedartodo.uk/ws-stomp` 엔드포인트로 STOMP 연결을 맺습니다. + + **2. 메시지 구독** + - 연결 후 `/sub/chat/room/{roomId}` 경로를 구독(subscribe)하면 해당 채팅방의 새로운 메시지를 실시간으로 수신할 수 있습니다. + + **예시 Subscribe 경로** + `/sub/chat/room/1` + + **예시 수신 데이터(JSON)** + ```json + { + "id": 101, + "roomId": 1, + "senderId": 10, + "senderNickname": "유강현", + "profileImageUrl": "https://example.com/profile.jpg", + "message": "안녕하세요!", + "sentAt": "2025-07-21T14:32:00" + } + ``` + + ** Swagger에서는 WebSocket 구독을 테스트할 수 없으며, + 이 문서는 프론트엔드 구현 참고용입니다.** + """) ApiResponse> getMessages(Long roomId); } From 0b726bce712c0e0045c7d33ad7be530b9a54477e Mon Sep 17 00:00:00 2001 From: hyeunS-P Date: Mon, 21 Jul 2025 18:21:51 +0900 Subject: [PATCH 27/34] =?UTF-8?q?[EA3-166]=20fix:=EB=A7=81=ED=81=AC?= =?UTF-8?q?=EC=97=90=20activated=20=EC=BB=AC=EB=9F=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=EC=A1=B0=ED=9A=8C=EA=B0=80=20=EC=A0=95?= =?UTF-8?q?=EC=83=81=EC=A0=81=EC=9C=BC=EB=A1=9C=20=EB=8F=99=EC=9E=91?= =?UTF-8?q?=ED=95=98=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 --- .../domain/prtemplate/service/PrTemplateService.java | 12 +++++------- src/main/resources/data.sql | 10 +++++----- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/main/java/grep/neogul_coder/domain/prtemplate/service/PrTemplateService.java b/src/main/java/grep/neogul_coder/domain/prtemplate/service/PrTemplateService.java index a88e11ae..fcd3022d 100644 --- a/src/main/java/grep/neogul_coder/domain/prtemplate/service/PrTemplateService.java +++ b/src/main/java/grep/neogul_coder/domain/prtemplate/service/PrTemplateService.java @@ -91,15 +91,13 @@ private List getPrUser(Long userId) { } private List getUserLocationAndLinks(List links, PrTemplate prTemplate) { - return links.stream() - .map(link -> PrPageResponse.UserLocationAndLink.builder() - .location(prTemplate.getLocation()) - .links(LinkInfo(links)) - .build()) - .toList(); + return List.of(PrPageResponse.UserLocationAndLink.builder() + .location(prTemplate.getLocation()) + .links(toLinkInfoList(links)) + .build()); } - private List LinkInfo(List links) { + private List toLinkInfoList(List links) { return links.stream() .map(link -> PrPageResponse.UserLocationAndLink.LinkInfo.builder() .linkName(link.getUrlName()) diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index 2a2695cf..3f581d96 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -105,11 +105,11 @@ INSERT INTO pr_template (user_id, introduction, location) VALUES (2, '실용적 INSERT INTO pr_template (user_id, introduction, location) VALUES (4, '초심을 잃지 않는 프론트엔드 개발자입니다. Vue, React 기반 프로젝트 경험이 있으며, UI/UX에 대한 관심도 많습니다.', '대전시 유성구'); INSERT INTO pr_template (user_id, introduction, location) VALUES (5, '문제를 해결하는 것이 즐거운 백엔드 개발자입니다. JPA, QueryDSL 기반의 안정적인 데이터 처리와 아키텍처 설계에 관심이 있습니다.', '인천시 연수구'); -INSERT INTO link (user_id, pr_url, url_name) VALUES (1, 'https://github.com/yeongho', 'GitHub 포트폴리오'); -INSERT INTO link (user_id, pr_url, url_name) VALUES (2, 'https://velog.io/@jiweon01', '기술 블로그 (Velog)'); -INSERT INTO link (user_id, pr_url, url_name) VALUES (5, 'https://notion.so/dev-profile', '기술 이력서 (Notion)'); -INSERT INTO link (user_id, pr_url, url_name) VALUES (5, 'https://toss.im/team/gimgim', '팀 프로젝트 소개'); -INSERT INTO link (user_id, pr_url, url_name) VALUES (1, 'https://linkedin.com/in/eungyeong', 'LinkedIn 프로필'); +INSERT INTO link (user_id, pr_url, url_name, activated) VALUES (1, 'https://github.com/yeongho', 'GitHub 포트폴리오', true); +INSERT INTO link (user_id, pr_url, url_name, activated) VALUES (2, 'https://velog.io/@jiweon01', '기술 블로그 (Velog)', true); +INSERT INTO link (user_id, pr_url, url_name, activated) VALUES (5, 'https://notion.so/dev-profile', '기술 이력서 (Notion)', true); +INSERT INTO link (user_id, pr_url, url_name, activated) VALUES (5, 'https://toss.im/team/gimgim', '팀 프로젝트 소개', true); +INSERT INTO link (user_id, pr_url, url_name, activated) VALUES (1, 'https://linkedin.com/in/eungyeong', 'LinkedIn 프로필', true); INSERT INTO attendance (study_id, user_id, attendance_date) VALUES (1, 3, '2025-07-01'); INSERT INTO attendance (study_id, user_id, attendance_date) VALUES (1, 3, '2025-07-02'); From d5d27d6f3d545c037e4756bca452c051cc91012d Mon Sep 17 00:00:00 2001 From: pia01190 Date: Mon, 21 Jul 2025 18:19:58 +0900 Subject: [PATCH 28/34] =?UTF-8?q?[EA3-161]=20refactor:=20=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=EB=94=94=20=EC=88=98=EC=A0=95=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/study/service/StudyService.java | 13 ++++++++++++- .../global/utils/upload/AbstractFileManager.java | 2 +- .../domain/study/service/StudyServiceTest.java | 8 +++++++- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/main/java/grep/neogul_coder/domain/study/service/StudyService.java b/src/main/java/grep/neogul_coder/domain/study/service/StudyService.java index 58fcbdaa..76812a85 100644 --- a/src/main/java/grep/neogul_coder/domain/study/service/StudyService.java +++ b/src/main/java/grep/neogul_coder/domain/study/service/StudyService.java @@ -134,7 +134,7 @@ public void updateStudy(Long studyId, StudyUpdateRequest request, Long userId, M validateStudyLeader(studyId, userId); validateStudyStartDate(request, study); - String imageUrl = createImageUrl(userId, image); + String imageUrl = updateImageUrl(userId, image, study.getImageUrl()); study.update( request.getName(), @@ -223,6 +223,17 @@ private String createImageUrl(Long userId, MultipartFile image) throws IOExcepti return imageUrl; } + private String updateImageUrl(Long userId, MultipartFile image, String originalImageUrl) throws IOException { + if (isImgExists(image)) { + FileUploadResponse uploadResult = isProductionEnvironment() + ? gcpFileUploader.upload(image, userId, FileUsageType.STUDY_COVER, userId) + : localFileUploader.upload(image, userId, FileUsageType.STUDY_COVER, userId); + return uploadResult.fileUrl(); + } + return originalImageUrl; + } + + private boolean isImgExists(MultipartFile image) { return image != null && !image.isEmpty(); } diff --git a/src/main/java/grep/neogul_coder/global/utils/upload/AbstractFileManager.java b/src/main/java/grep/neogul_coder/global/utils/upload/AbstractFileManager.java index 88dbf8f4..81a03ca1 100644 --- a/src/main/java/grep/neogul_coder/global/utils/upload/AbstractFileManager.java +++ b/src/main/java/grep/neogul_coder/global/utils/upload/AbstractFileManager.java @@ -52,8 +52,8 @@ public FileUploadResponse upload(MultipartFile file, Long uploaderId, FileUsageT originFileName, renameFileName, usageType, - fileUrl, savePath, + fileUrl, uploaderId, usageRefId ); diff --git a/src/test/java/grep/neogul_coder/domain/study/service/StudyServiceTest.java b/src/test/java/grep/neogul_coder/domain/study/service/StudyServiceTest.java index 168f0c32..684ac659 100644 --- a/src/test/java/grep/neogul_coder/domain/study/service/StudyServiceTest.java +++ b/src/test/java/grep/neogul_coder/domain/study/service/StudyServiceTest.java @@ -20,6 +20,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; import java.time.LocalDateTime; import java.util.List; @@ -70,8 +72,12 @@ void createStudy() { .imageUrl("http://localhost:8083/image.url") .build(); + MultipartFile image = new MockMultipartFile( + + ) + // when - Long id = studyService.createStudy(request, userId); + Long id = studyService.createStudy(request, userId, image); // then Study findStudy = studyRepository.findByIdAndActivatedTrue(id).orElseThrow(); From 63780b6cfc619105ae3be207487ee4c3cfe731db Mon Sep 17 00:00:00 2001 From: pia01190 Date: Mon, 21 Jul 2025 18:27:58 +0900 Subject: [PATCH 29/34] =?UTF-8?q?[EA3-161]=20refactor:=20FileUploadRespons?= =?UTF-8?q?e=20=EC=83=9D=EC=84=B1=EC=9E=90=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/study/service/StudyService.java | 4 +-- .../domain/users/service/UserService.java | 2 +- .../utils/upload/AbstractFileManager.java | 18 +++++------ .../utils/upload/FileUploadResponse.java | 31 +++++++++++++------ .../study/service/StudyServiceTest.java | 6 +--- 5 files changed, 35 insertions(+), 26 deletions(-) diff --git a/src/main/java/grep/neogul_coder/domain/study/service/StudyService.java b/src/main/java/grep/neogul_coder/domain/study/service/StudyService.java index 76812a85..5b6cc333 100644 --- a/src/main/java/grep/neogul_coder/domain/study/service/StudyService.java +++ b/src/main/java/grep/neogul_coder/domain/study/service/StudyService.java @@ -218,7 +218,7 @@ private String createImageUrl(Long userId, MultipartFile image) throws IOExcepti FileUploadResponse uploadResult = isProductionEnvironment() ? gcpFileUploader.upload(image, userId, FileUsageType.STUDY_COVER, userId) : localFileUploader.upload(image, userId, FileUsageType.STUDY_COVER, userId); - imageUrl = uploadResult.fileUrl(); + imageUrl = uploadResult.getFileUrl(); } return imageUrl; } @@ -228,7 +228,7 @@ private String updateImageUrl(Long userId, MultipartFile image, String originalI FileUploadResponse uploadResult = isProductionEnvironment() ? gcpFileUploader.upload(image, userId, FileUsageType.STUDY_COVER, userId) : localFileUploader.upload(image, userId, FileUsageType.STUDY_COVER, userId); - return uploadResult.fileUrl(); + return uploadResult.getFileUrl(); } return originalImageUrl; } diff --git a/src/main/java/grep/neogul_coder/domain/users/service/UserService.java b/src/main/java/grep/neogul_coder/domain/users/service/UserService.java index 53f049ca..644e0b77 100644 --- a/src/main/java/grep/neogul_coder/domain/users/service/UserService.java +++ b/src/main/java/grep/neogul_coder/domain/users/service/UserService.java @@ -100,7 +100,7 @@ public void updateProfile(Long userId, String nickname, MultipartFile profileIma FileUploadResponse response = isProductionEnvironment() ? gcpFileUploader.upload(profileImage, userId, FileUsageType.PROFILE, userId) : localFileUploader.upload(profileImage, userId, FileUsageType.PROFILE, userId); - uploadedImageUrl = response.fileUrl(); + uploadedImageUrl = response.getFileUrl(); } else { uploadedImageUrl = user.getProfileImageUrl(); } diff --git a/src/main/java/grep/neogul_coder/global/utils/upload/AbstractFileManager.java b/src/main/java/grep/neogul_coder/global/utils/upload/AbstractFileManager.java index 81a03ca1..a648a494 100644 --- a/src/main/java/grep/neogul_coder/global/utils/upload/AbstractFileManager.java +++ b/src/main/java/grep/neogul_coder/global/utils/upload/AbstractFileManager.java @@ -48,15 +48,15 @@ public FileUploadResponse upload(MultipartFile file, Long uploaderId, FileUsageT uploadFile(file, buildFullPath(savePath, renameFileName)); // 실제 파일 업로드(구현체에서 구현) - return new FileUploadResponse( - originFileName, - renameFileName, - usageType, - savePath, - fileUrl, - uploaderId, - usageRefId - ); + return FileUploadResponse.builder() + .originFileName(originFileName) + .renameFileName(renameFileName) + .usageType(usageType) + .savePath(savePath) + .fileUrl(fileUrl) + .uploaderId(uploaderId) + .usageRefId(usageRefId) + .build(); } // 실제 파일 업로드를 수행 diff --git a/src/main/java/grep/neogul_coder/global/utils/upload/FileUploadResponse.java b/src/main/java/grep/neogul_coder/global/utils/upload/FileUploadResponse.java index 76e10463..82f0674f 100644 --- a/src/main/java/grep/neogul_coder/global/utils/upload/FileUploadResponse.java +++ b/src/main/java/grep/neogul_coder/global/utils/upload/FileUploadResponse.java @@ -1,13 +1,26 @@ package grep.neogul_coder.global.utils.upload; -public record FileUploadResponse( - String originFileName, // 원본 파일명 - String renameFileName, // UUID로 변경된 파일명 - FileUsageType usageType, // 파일 사용 목적 - String savePath, // 저장 경로 - String fileUrl, // 전체 URL - Long uploaderId, // 업로더 ID - Long usageRefId // 파일이 참조되는 도메인 ID -) { +import lombok.Builder; +import lombok.Getter; +@Getter +public class FileUploadResponse { + private String originFileName; // 원본 파일명 + private String renameFileName; // UUID로 변경된 파일명 + private FileUsageType usageType; // 파일 사용 목적 + private String savePath; // 저장 경로 + private String fileUrl; // 전체 URL + private Long uploaderId; // 업로더 ID + private Long usageRefId; // 파일이 참조되는 도메인 ID + + @Builder + public FileUploadResponse(String originFileName, String renameFileName, FileUsageType usageType, String savePath, String fileUrl, Long uploaderId, Long usageRefId) { + this.originFileName = originFileName; + this.renameFileName = renameFileName; + this.usageType = usageType; + this.savePath = savePath; + this.fileUrl = fileUrl; + this.uploaderId = uploaderId; + this.usageRefId = usageRefId; + } } diff --git a/src/test/java/grep/neogul_coder/domain/study/service/StudyServiceTest.java b/src/test/java/grep/neogul_coder/domain/study/service/StudyServiceTest.java index 684ac659..2476006c 100644 --- a/src/test/java/grep/neogul_coder/domain/study/service/StudyServiceTest.java +++ b/src/test/java/grep/neogul_coder/domain/study/service/StudyServiceTest.java @@ -72,12 +72,8 @@ void createStudy() { .imageUrl("http://localhost:8083/image.url") .build(); - MultipartFile image = new MockMultipartFile( - - ) - // when - Long id = studyService.createStudy(request, userId, image); + Long id = studyService.createStudy(request, userId); // then Study findStudy = studyRepository.findByIdAndActivatedTrue(id).orElseThrow(); From 6484bae9e386efcbd313e1c03e74567c7cedcfa3 Mon Sep 17 00:00:00 2001 From: pia01190 Date: Mon, 21 Jul 2025 18:43:45 +0900 Subject: [PATCH 30/34] =?UTF-8?q?[EA3-161]=20refactor:=20=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=EB=94=94=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../study/service/StudyServiceTest.java | 73 +++++++++++-------- 1 file changed, 43 insertions(+), 30 deletions(-) diff --git a/src/test/java/grep/neogul_coder/domain/study/service/StudyServiceTest.java b/src/test/java/grep/neogul_coder/domain/study/service/StudyServiceTest.java index 2476006c..5b843cc8 100644 --- a/src/test/java/grep/neogul_coder/domain/study/service/StudyServiceTest.java +++ b/src/test/java/grep/neogul_coder/domain/study/service/StudyServiceTest.java @@ -14,6 +14,7 @@ import grep.neogul_coder.domain.users.entity.User; import grep.neogul_coder.domain.users.repository.UserRepository; import grep.neogul_coder.global.exception.business.BusinessException; +import jakarta.persistence.EntityManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -23,6 +24,7 @@ import org.springframework.mock.web.MockMultipartFile; import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; import java.time.LocalDateTime; import java.util.List; @@ -34,6 +36,9 @@ class StudyServiceTest extends IntegrationTestSupport { + @Autowired + private EntityManager em; + @Autowired private UserRepository userRepository; @@ -56,9 +61,30 @@ void init() { userId = user1.getId(); } + @DisplayName("가입한 스터디 목록을 페이징 조회합니다.") + @Test + void getStudies() { + // given + Pageable pageable = PageRequest.of(0, 12); + + Study study = createStudy("스터디", Category.IT, 3, StudyType.OFFLINE, "서울", LocalDateTime.of(2025, 7, 18, 0, 0, 0), + LocalDateTime.of(2025, 7, 28, 0, 0, 0), "스터디입니다.", "http://localhost:8083/image.url"); + studyRepository.save(study); + Long studyId = study.getId(); + + StudyMember studyMember = createStudyMember(study, userId, LEADER); + studyMemberRepository.save(studyMember); + + // when + StudyItemPagingResponse response = studyService.getMyStudiesPaging(pageable, userId); + + // then + assertThat(response.getStudies().getFirst().getName()).isEqualTo("스터디"); + } + @DisplayName("스터디를 생성합니다.") @Test - void createStudy() { + void createStudy() throws IOException { // given StudyCreateRequest request = StudyCreateRequest.builder() .name("스터디") @@ -71,41 +97,21 @@ void createStudy() { .introduction("스터디입니다.") .imageUrl("http://localhost:8083/image.url") .build(); + MultipartFile image = null; // when - Long id = studyService.createStudy(request, userId); + Long id = studyService.createStudy(request, userId, image); // then Study findStudy = studyRepository.findByIdAndActivatedTrue(id).orElseThrow(); assertThat(findStudy.getName()).isEqualTo("스터디"); } - @DisplayName("가입한 스터디 목록을 페이징 조회합니다.") - @Test - void getStudies() { - // given - Pageable pageable = PageRequest.of(0, 12); - - Study study = createStudy("스터디", Category.IT, 3, StudyType.OFFLINE, "서울", LocalDateTime.of(2025, 7, 18, 0, 0, 0), - LocalDateTime.of(2025, 7, 28, 0, 0, 0), "스터디입니다.", "http://localhost:8083/image.url"); - studyRepository.save(study); - Long studyId = study.getId(); - - StudyMember studyMember = createStudyMember(study, userId, LEADER); - studyMemberRepository.save(studyMember); - - // when - StudyItemPagingResponse response = studyService.getMyStudiesPaging(pageable, userId); - - // then - assertThat(response.getStudies().getFirst().getName()).isEqualTo("스터디"); - } - @DisplayName("스터디장이 스터디를 수정합니다.") @Test - void updateStudy() { + void updateStudy() throws IOException { // given - Study study = createStudy("스터디", Category.IT, 3, StudyType.OFFLINE, "서울", LocalDateTime.of(2025, 7, 18, 0, 0, 0), + Study study = createStudy("스터디", Category.IT, 3, StudyType.OFFLINE, "서울", LocalDateTime.of(2026, 7, 18, 0, 0, 0), LocalDateTime.of(2025, 7, 28, 0, 0, 0), "스터디입니다.", "http://localhost:8083/image.url"); studyRepository.save(study); Long studyId = study.getId(); @@ -119,13 +125,15 @@ void updateStudy() { .capacity(8) .studyType(StudyType.OFFLINE) .location("서울") - .startDate(LocalDateTime.now()) + .startDate(LocalDateTime.of(2026, 7, 20, 0, 0, 0)) .introduction("Updated") - .imageUrl("http://localhost:8083/image.url") .build(); + MultipartFile image = null; // when - studyService.updateStudy(studyId, request, userId); + studyService.updateStudy(studyId, request, userId, image); + em.flush(); + em.clear(); // then Study findStudy = studyRepository.findByIdAndActivatedTrue(studyId).orElseThrow(); @@ -154,10 +162,11 @@ void updateStudyFail() { .introduction("Updated") .imageUrl("http://localhost:8083/image.url") .build(); + MultipartFile image = null; // when then assertThatThrownBy(() -> - studyService.updateStudy(studyId, request, userId)) + studyService.updateStudy(studyId, request, userId, image)) .isInstanceOf(BusinessException.class).hasMessage(NOT_STUDY_LEADER.getMessage()); } @@ -175,9 +184,13 @@ void deleteStudy() { // when studyService.deleteStudy(studyId, userId); + em.flush(); + em.clear(); + + Study deletedStudy = studyRepository.findById(studyId).orElseThrow(); // then - assertThat(study.getActivated()).isFalse(); + assertThat(deletedStudy.getActivated()).isFalse(); } private static User createUser(String nickname) { From ecca1b899b26015a2ca9960cd574b7be89d96591 Mon Sep 17 00:00:00 2001 From: pia01190 Date: Mon, 21 Jul 2025 19:03:42 +0900 Subject: [PATCH 31/34] =?UTF-8?q?[EA3-161]=20refactor:=20=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=EB=94=94=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../neogul_coder/domain/study/service/StudyServiceTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/test/java/grep/neogul_coder/domain/study/service/StudyServiceTest.java b/src/test/java/grep/neogul_coder/domain/study/service/StudyServiceTest.java index 5b843cc8..9684bcf3 100644 --- a/src/test/java/grep/neogul_coder/domain/study/service/StudyServiceTest.java +++ b/src/test/java/grep/neogul_coder/domain/study/service/StudyServiceTest.java @@ -101,6 +101,8 @@ void createStudy() throws IOException { // when Long id = studyService.createStudy(request, userId, image); + em.flush(); + em.clear(); // then Study findStudy = studyRepository.findByIdAndActivatedTrue(id).orElseThrow(); From 2aff1860a0ff95ad6d2d56cfde625c9d43b6f474 Mon Sep 17 00:00:00 2001 From: Tokwasp Date: Mon, 21 Jul 2025 19:31:24 +0900 Subject: [PATCH 32/34] =?UTF-8?q?[EA3-138]=20feature:=20=EB=AA=A8=EC=A7=91?= =?UTF-8?q?=EA=B8=80=20=EC=88=98=EC=A0=95=EC=8B=9C=20=EB=AA=A8=EC=A7=91?= =?UTF-8?q?=EB=A7=88=EA=B0=90=20=EA=B8=B0=EA=B0=84=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/recruitment/post/RecruitmentPost.java | 3 ++- .../dto/request/RecruitmentPostUpdateRequest.java | 11 ++++++++++- .../post/service/RecruitmentPostService.java | 3 ++- .../request/RecruitmentPostUpdateServiceRequest.java | 6 +++++- 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/main/java/grep/neogul_coder/domain/recruitment/post/RecruitmentPost.java b/src/main/java/grep/neogul_coder/domain/recruitment/post/RecruitmentPost.java index e954a066..020ef8d7 100644 --- a/src/main/java/grep/neogul_coder/domain/recruitment/post/RecruitmentPost.java +++ b/src/main/java/grep/neogul_coder/domain/recruitment/post/RecruitmentPost.java @@ -45,10 +45,11 @@ private RecruitmentPost(long studyId, String subject, String content, long userI protected RecruitmentPost() { } - public void update(String subject, String content, int recruitmentCount) { + public void update(String subject, String content, int recruitmentCount, LocalDateTime expiredDateTime) { this.subject = subject; this.content = content; this.recruitmentCount = recruitmentCount; + this.expiredDate = expiredDateTime; } public boolean isNotOwnedBy(long userId) { diff --git a/src/main/java/grep/neogul_coder/domain/recruitment/post/controller/dto/request/RecruitmentPostUpdateRequest.java b/src/main/java/grep/neogul_coder/domain/recruitment/post/controller/dto/request/RecruitmentPostUpdateRequest.java index 5dd27c33..f0bbf822 100644 --- a/src/main/java/grep/neogul_coder/domain/recruitment/post/controller/dto/request/RecruitmentPostUpdateRequest.java +++ b/src/main/java/grep/neogul_coder/domain/recruitment/post/controller/dto/request/RecruitmentPostUpdateRequest.java @@ -2,11 +2,14 @@ import grep.neogul_coder.domain.recruitment.post.service.request.RecruitmentPostUpdateServiceRequest; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Future; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Positive; import lombok.Builder; import lombok.Getter; +import java.time.LocalDateTime; + @Getter public class RecruitmentPostUpdateRequest { @@ -22,11 +25,16 @@ public class RecruitmentPostUpdateRequest { @NotBlank(message = "내용은 필수값 입니다.") private String content; + @Future + @Schema(example = "2025-07-21T23:59:59", description = "모집글 마감 기간") + private LocalDateTime expiredDateTime; + @Builder - private RecruitmentPostUpdateRequest(String subject, String content, int recruitmentCount) { + private RecruitmentPostUpdateRequest(String subject, String content, int recruitmentCount, LocalDateTime expiredDateTime) { this.subject = subject; this.content = content; this.recruitmentCount = recruitmentCount; + this.expiredDateTime = expiredDateTime; } public RecruitmentPostUpdateServiceRequest toServiceRequest() { @@ -34,6 +42,7 @@ public RecruitmentPostUpdateServiceRequest toServiceRequest() { .subject(this.subject) .content(this.content) .recruitmentCount(this.recruitmentCount) + .expiredDateTime(this.expiredDateTime) .build(); } diff --git a/src/main/java/grep/neogul_coder/domain/recruitment/post/service/RecruitmentPostService.java b/src/main/java/grep/neogul_coder/domain/recruitment/post/service/RecruitmentPostService.java index fbbb117e..af49d8b8 100644 --- a/src/main/java/grep/neogul_coder/domain/recruitment/post/service/RecruitmentPostService.java +++ b/src/main/java/grep/neogul_coder/domain/recruitment/post/service/RecruitmentPostService.java @@ -78,7 +78,8 @@ public long update(RecruitmentPostUpdateServiceRequest request, long recruitment recruitmentPost.update( request.getSubject(), request.getContent(), - request.getRecruitmentCount() + request.getRecruitmentCount(), + request.getExpiredDateTime() ); return recruitmentPost.getId(); } diff --git a/src/main/java/grep/neogul_coder/domain/recruitment/post/service/request/RecruitmentPostUpdateServiceRequest.java b/src/main/java/grep/neogul_coder/domain/recruitment/post/service/request/RecruitmentPostUpdateServiceRequest.java index 8760ab7e..4aa617ea 100644 --- a/src/main/java/grep/neogul_coder/domain/recruitment/post/service/request/RecruitmentPostUpdateServiceRequest.java +++ b/src/main/java/grep/neogul_coder/domain/recruitment/post/service/request/RecruitmentPostUpdateServiceRequest.java @@ -3,16 +3,20 @@ import lombok.Builder; import lombok.Getter; +import java.time.LocalDateTime; + @Getter public class RecruitmentPostUpdateServiceRequest { private String subject; private String content; private int recruitmentCount; + private LocalDateTime expiredDateTime; @Builder - private RecruitmentPostUpdateServiceRequest(String subject, String content, int recruitmentCount) { + private RecruitmentPostUpdateServiceRequest(String subject, String content, int recruitmentCount, LocalDateTime expiredDateTime) { this.subject = subject; this.content = content; this.recruitmentCount = recruitmentCount; + this.expiredDateTime = expiredDateTime; } } From 6088c7582f010bfde60c3c75d99c00065bf595f5 Mon Sep 17 00:00:00 2001 From: Tokwasp Date: Mon, 21 Jul 2025 19:54:19 +0900 Subject: [PATCH 33/34] =?UTF-8?q?[EA3-138]=20feature:=20=EB=AA=A8=EC=A7=91?= =?UTF-8?q?=EA=B8=80=20=EB=8C=93=EA=B8=80=20CUD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../recruitment/RecruitmentErrorCode.java | 1 + .../comment/RecruitmentPostComment.java | 8 +++ .../RecruitmentPostCommentController.java | 13 ++++- .../RecruitmentPostCommentSpecification.java | 2 +- .../RecruitmentCommentSaveRequest.java | 14 ++++++ .../RecruitmentCommentUpdateRequest.java | 3 -- ...RecruitmentPostCommentQueryRepository.java | 12 +++++ .../RecruitmentPostCommentService.java | 50 +++++++++++++++++++ .../RecruitmentPostQueryRepository.java | 13 +++++ 9 files changed, 110 insertions(+), 6 deletions(-) create mode 100644 src/main/java/grep/neogul_coder/domain/recruitment/comment/service/RecruitmentPostCommentService.java diff --git a/src/main/java/grep/neogul_coder/domain/recruitment/RecruitmentErrorCode.java b/src/main/java/grep/neogul_coder/domain/recruitment/RecruitmentErrorCode.java index 9be863d7..ef18fade 100644 --- a/src/main/java/grep/neogul_coder/domain/recruitment/RecruitmentErrorCode.java +++ b/src/main/java/grep/neogul_coder/domain/recruitment/RecruitmentErrorCode.java @@ -9,6 +9,7 @@ public enum RecruitmentErrorCode implements ErrorCode { NOT_STUDY_LEADER(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.name(), "스터디의 리더가 아닙니다."), NOT_FOUND_STUDY_MEMBER(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.name(), "스터디에 참여하고 있지 않은 회원 입니다."), NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.name(), "모집글을 찾지 못했습니다."), + NOT_FOUND_COMMENT(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.name(), "댓글을 찾지 못했습니다"), NOT_OWNER(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.name(), "모집글을 등록한 당사자가 아닙니다."); private static final String BASIC_MESSAGE = "RECRUITMENT"; diff --git a/src/main/java/grep/neogul_coder/domain/recruitment/comment/RecruitmentPostComment.java b/src/main/java/grep/neogul_coder/domain/recruitment/comment/RecruitmentPostComment.java index d832d8a0..721db049 100644 --- a/src/main/java/grep/neogul_coder/domain/recruitment/comment/RecruitmentPostComment.java +++ b/src/main/java/grep/neogul_coder/domain/recruitment/comment/RecruitmentPostComment.java @@ -30,4 +30,12 @@ private RecruitmentPostComment(RecruitmentPost recruitmentPost, long userId, Str this.userId = userId; this.content = content; } + + public void update(String content) { + this.content = content; + } + + public void delete() { + this.activated = false; + } } diff --git a/src/main/java/grep/neogul_coder/domain/recruitment/comment/controller/RecruitmentPostCommentController.java b/src/main/java/grep/neogul_coder/domain/recruitment/comment/controller/RecruitmentPostCommentController.java index f50ab3ab..b41ddb94 100644 --- a/src/main/java/grep/neogul_coder/domain/recruitment/comment/controller/RecruitmentPostCommentController.java +++ b/src/main/java/grep/neogul_coder/domain/recruitment/comment/controller/RecruitmentPostCommentController.java @@ -2,31 +2,40 @@ import grep.neogul_coder.domain.recruitment.comment.controller.dto.request.RecruitmentCommentSaveRequest; import grep.neogul_coder.domain.recruitment.comment.controller.dto.request.RecruitmentCommentUpdateRequest; +import grep.neogul_coder.domain.recruitment.comment.service.RecruitmentPostCommentService; import grep.neogul_coder.global.auth.Principal; import grep.neogul_coder.global.response.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @RequestMapping("/recruitment-posts/comments") +@RequiredArgsConstructor @RestController public class RecruitmentPostCommentController implements RecruitmentPostCommentSpecification { + private final RecruitmentPostCommentService commentService; + @PostMapping - public ApiResponse save(@RequestBody RecruitmentCommentSaveRequest request, + public ApiResponse save(@RequestBody @Valid RecruitmentCommentSaveRequest request, @AuthenticationPrincipal Principal userDetails) { - return ApiResponse.noContent(); + long commentId = commentService.save(request, userDetails.getUserId()); + return ApiResponse.success(commentId); } @PutMapping("/{comment-id}") public ApiResponse update(@PathVariable("comment-id") long commentId, @RequestBody RecruitmentCommentUpdateRequest request, @AuthenticationPrincipal Principal userDetails) { + commentService.update(request, commentId, userDetails.getUserId()); return ApiResponse.noContent(); } @DeleteMapping("/{comment-id}") public ApiResponse delete(@PathVariable("comment-id") long commentId, @AuthenticationPrincipal Principal userDetails) { + commentService.delete(commentId, userDetails.getUserId()); return ApiResponse.noContent(); } } diff --git a/src/main/java/grep/neogul_coder/domain/recruitment/comment/controller/RecruitmentPostCommentSpecification.java b/src/main/java/grep/neogul_coder/domain/recruitment/comment/controller/RecruitmentPostCommentSpecification.java index 036ec150..71da396b 100644 --- a/src/main/java/grep/neogul_coder/domain/recruitment/comment/controller/RecruitmentPostCommentSpecification.java +++ b/src/main/java/grep/neogul_coder/domain/recruitment/comment/controller/RecruitmentPostCommentSpecification.java @@ -11,7 +11,7 @@ public interface RecruitmentPostCommentSpecification { @Operation(summary = "모집글 댓글 작성", description = "모집글에 대한 댓글을 작성 합니다.") - ApiResponse save(RecruitmentCommentSaveRequest request, Principal userDetails); + ApiResponse save(RecruitmentCommentSaveRequest request, Principal userDetails); @Operation(summary = "모집글 댓글 수정", description = "모집글에 대한 댓글을 수정 합니다.") ApiResponse update(long commentId, RecruitmentCommentUpdateRequest request, Principal userDetails); diff --git a/src/main/java/grep/neogul_coder/domain/recruitment/comment/controller/dto/request/RecruitmentCommentSaveRequest.java b/src/main/java/grep/neogul_coder/domain/recruitment/comment/controller/dto/request/RecruitmentCommentSaveRequest.java index 34de215c..6b282d9c 100644 --- a/src/main/java/grep/neogul_coder/domain/recruitment/comment/controller/dto/request/RecruitmentCommentSaveRequest.java +++ b/src/main/java/grep/neogul_coder/domain/recruitment/comment/controller/dto/request/RecruitmentCommentSaveRequest.java @@ -1,5 +1,7 @@ package grep.neogul_coder.domain.recruitment.comment.controller.dto.request; +import grep.neogul_coder.domain.recruitment.comment.RecruitmentPostComment; +import grep.neogul_coder.domain.recruitment.post.RecruitmentPost; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; @@ -11,4 +13,16 @@ public class RecruitmentCommentSaveRequest { @Schema(example = "저도 참여 할래요!", description = "모집글 내용") private String content; + + private RecruitmentCommentSaveRequest() { + } + + public RecruitmentPostComment toEntity(RecruitmentPost post, long userId){ + return RecruitmentPostComment.builder() + .recruitmentPost(post) + .userId(userId) + .content(this.content) + .build(); + } + } diff --git a/src/main/java/grep/neogul_coder/domain/recruitment/comment/controller/dto/request/RecruitmentCommentUpdateRequest.java b/src/main/java/grep/neogul_coder/domain/recruitment/comment/controller/dto/request/RecruitmentCommentUpdateRequest.java index 31cf4d65..7747e5d3 100644 --- a/src/main/java/grep/neogul_coder/domain/recruitment/comment/controller/dto/request/RecruitmentCommentUpdateRequest.java +++ b/src/main/java/grep/neogul_coder/domain/recruitment/comment/controller/dto/request/RecruitmentCommentUpdateRequest.java @@ -6,9 +6,6 @@ @Getter public class RecruitmentCommentUpdateRequest { - @Schema(example = "2", description = "모집글 ID") - private long postId; - @Schema(example = "저도 참여 할래요!", description = "모집글 내용") private String content; } diff --git a/src/main/java/grep/neogul_coder/domain/recruitment/comment/repository/RecruitmentPostCommentQueryRepository.java b/src/main/java/grep/neogul_coder/domain/recruitment/comment/repository/RecruitmentPostCommentQueryRepository.java index a2945b35..4e86733e 100644 --- a/src/main/java/grep/neogul_coder/domain/recruitment/comment/repository/RecruitmentPostCommentQueryRepository.java +++ b/src/main/java/grep/neogul_coder/domain/recruitment/comment/repository/RecruitmentPostCommentQueryRepository.java @@ -8,6 +8,7 @@ import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; import static grep.neogul_coder.domain.recruitment.comment.QRecruitmentPostComment.recruitmentPostComment; import static grep.neogul_coder.domain.users.entity.QUser.user; @@ -51,4 +52,15 @@ public List findCommentsWithWriterInfo(Long recruitmentP ) .fetch(); } + + public Optional findMyCommentBy(long commentId, long userId) { + RecruitmentPostComment comment = queryFactory.selectFrom(recruitmentPostComment) + .where( + recruitmentPostComment.activated.isTrue(), + recruitmentPostComment.id.eq(commentId), + recruitmentPostComment.userId.eq(userId) + ) + .fetchOne(); + return Optional.ofNullable(comment); + } } diff --git a/src/main/java/grep/neogul_coder/domain/recruitment/comment/service/RecruitmentPostCommentService.java b/src/main/java/grep/neogul_coder/domain/recruitment/comment/service/RecruitmentPostCommentService.java new file mode 100644 index 00000000..62a3bc37 --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/recruitment/comment/service/RecruitmentPostCommentService.java @@ -0,0 +1,50 @@ +package grep.neogul_coder.domain.recruitment.comment.service; + +import grep.neogul_coder.domain.recruitment.RecruitmentErrorCode; +import grep.neogul_coder.domain.recruitment.comment.RecruitmentPostComment; +import grep.neogul_coder.domain.recruitment.comment.controller.dto.request.RecruitmentCommentSaveRequest; +import grep.neogul_coder.domain.recruitment.comment.controller.dto.request.RecruitmentCommentUpdateRequest; +import grep.neogul_coder.domain.recruitment.comment.repository.RecruitmentPostCommentQueryRepository; +import grep.neogul_coder.domain.recruitment.comment.repository.RecruitmentPostCommentRepository; +import grep.neogul_coder.domain.recruitment.post.RecruitmentPost; +import grep.neogul_coder.domain.recruitment.post.repository.RecruitmentPostQueryRepository; +import grep.neogul_coder.global.exception.business.NotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import static grep.neogul_coder.domain.recruitment.RecruitmentErrorCode.*; + +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Service +public class RecruitmentPostCommentService { + + private final RecruitmentPostQueryRepository postRepository; + private final RecruitmentPostCommentRepository commentRepository; + private final RecruitmentPostCommentQueryRepository commentQueryRepository; + + @Transactional + public long save(RecruitmentCommentSaveRequest request, long userId) { + RecruitmentPost myPost = postRepository.findMyPostBy(request.getPostId(), userId) + .orElseThrow(() -> new NotFoundException(NOT_FOUND)); + + return commentRepository.save(request.toEntity(myPost, userId)).getId(); + } + + @Transactional + public void update(RecruitmentCommentUpdateRequest request, long commentId, long userId) { + RecruitmentPostComment comment = commentQueryRepository.findMyCommentBy(commentId, userId) + .orElseThrow(() -> new NotFoundException(NOT_FOUND_COMMENT)); + + comment.update(request.getContent()); + } + + @Transactional + public void delete(long commentId, long userId) { + RecruitmentPostComment comment = commentQueryRepository.findMyCommentBy(commentId, userId) + .orElseThrow(() -> new NotFoundException(NOT_FOUND_COMMENT)); + + comment.delete(); + } +} diff --git a/src/main/java/grep/neogul_coder/domain/recruitment/post/repository/RecruitmentPostQueryRepository.java b/src/main/java/grep/neogul_coder/domain/recruitment/post/repository/RecruitmentPostQueryRepository.java index a62d3f80..c62155b9 100644 --- a/src/main/java/grep/neogul_coder/domain/recruitment/post/repository/RecruitmentPostQueryRepository.java +++ b/src/main/java/grep/neogul_coder/domain/recruitment/post/repository/RecruitmentPostQueryRepository.java @@ -3,6 +3,7 @@ import com.querydsl.core.BooleanBuilder; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQueryFactory; +import grep.neogul_coder.domain.recruitment.post.QRecruitmentPost; import grep.neogul_coder.domain.recruitment.post.RecruitmentPost; import grep.neogul_coder.domain.recruitment.post.controller.dto.request.PagingCondition; import grep.neogul_coder.domain.recruitment.post.controller.dto.response.QRecruitmentPostWithStudyInfo; @@ -15,6 +16,7 @@ import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; import java.util.function.Supplier; import static grep.neogul_coder.domain.recruitment.post.QRecruitmentPost.recruitmentPost; @@ -123,6 +125,17 @@ public Page findAllByFilter(PagingCondition condition, Long use return new PageImpl<>(content, condition.toPageable(), count == null ? 0 : count); } + public Optional findMyPostBy(long postId, long userId) { + RecruitmentPost findRecruitmentPost = queryFactory.selectFrom(recruitmentPost) + .where( + recruitmentPost.activated.isTrue(), + recruitmentPost.userId.eq(userId), + recruitmentPost.id.eq(postId) + ) + .fetchOne(); + return Optional.ofNullable(findRecruitmentPost); + } + private BooleanBuilder equalsStudyType(StudyType studyType) { return nullSafeBuilder(() -> study.studyType.eq(studyType)); } From 835f9ecbe90a0a1ace1846ebcf439de190dce3ac Mon Sep 17 00:00:00 2001 From: dbrkdgus00 Date: Mon, 21 Jul 2025 20:08:29 +0900 Subject: [PATCH 34/34] =?UTF-8?q?[EA3-111]=20docs=20:=20=EC=A4=91=EB=B3=B5?= =?UTF-8?q?=EB=90=9C=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 --- 1 file changed, 3 deletions(-) diff --git a/build.gradle b/build.gradle index 25a7f492..02b3789e 100644 --- a/build.gradle +++ b/build.gradle @@ -86,11 +86,8 @@ dependencies { // 배포 관련 의존성 // runtimeOnly 'org.postgresql:postgresql' implementation 'org.springframework.boot:spring-boot-devtools' - implementation 'com.google.cloud:spring-cloud-gcp-starter-secretmanager:4.9.1' implementation 'com.google.cloud:google-cloud-storage:2.38.0' - // implementation 'com.google.cloud:spring-cloud-gcp-starter-secretmanager:4.9.1' - // implementation 'com.google.cloud:google-cloud-storage:2.38.0' // WebSocket + STOMP 통신용 implementation 'org.springframework.boot:spring-boot-starter-websocket'