From 20a5ccc6a5788bedad22c2e43e3056805468f533 Mon Sep 17 00:00:00 2001 From: Jae Date: Mon, 8 Dec 2025 14:47:47 +0900 Subject: [PATCH 01/57] =?UTF-8?q?[Feat]=20WebSocket,=20STOMP=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=ED=8C=8C=EC=9D=BC=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 + .../websocket/StompChannelInterceptor.java | 79 +++++++++++++++++++ .../config/websocket/StompEventListener.java | 37 +++++++++ .../websocket/StompWebSocketConfig.java | 50 ++++++++++++ 4 files changed, 169 insertions(+) create mode 100644 src/main/java/com/ureka/techpost/global/config/websocket/StompChannelInterceptor.java create mode 100644 src/main/java/com/ureka/techpost/global/config/websocket/StompEventListener.java create mode 100644 src/main/java/com/ureka/techpost/global/config/websocket/StompWebSocketConfig.java diff --git a/build.gradle b/build.gradle index ef09125..5a95200 100644 --- a/build.gradle +++ b/build.gradle @@ -31,6 +31,9 @@ dependencies { //implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' + //WebSocket + implementation 'org.springframework.boot:spring-boot-starter-websocket' + // DB & Redis implementation 'com.mysql:mysql-connector-j' implementation 'org.springframework.boot:spring-boot-starter-data-redis' diff --git a/src/main/java/com/ureka/techpost/global/config/websocket/StompChannelInterceptor.java b/src/main/java/com/ureka/techpost/global/config/websocket/StompChannelInterceptor.java new file mode 100644 index 0000000..614e2f2 --- /dev/null +++ b/src/main/java/com/ureka/techpost/global/config/websocket/StompChannelInterceptor.java @@ -0,0 +1,79 @@ +//package com.ureka.techpost.global.config.websocket; +// +//import com.goojakgyo.goojakgyo.chat.service.ChatService; +//import io.jsonwebtoken.Claims; +//import io.jsonwebtoken.Jwts; +//import org.springframework.beans.factory.annotation.Value; +//import org.springframework.messaging.Message; +//import org.springframework.messaging.MessageChannel; +//import org.springframework.messaging.simp.stomp.StompCommand; +//import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +//import org.springframework.messaging.support.ChannelInterceptor; +//import org.springframework.security.authentication.AuthenticationServiceException; +//import org.springframework.stereotype.Component; +// +//// STOMP jwt 인증 처리 +//// STOMP에서 클라이언트가 연결 요청시 JWT의 유효성을 검증하는 역할 +//// 웹소켓으로 채팅 서버에 접속하려는 클라이언트가 보내는 연결메시지 (CONNECT)를 가로채서 메시지에 포함된 JWT 토큰이 유효한지 확인하는 보안설정 코드 +//@Component +//public class StompChannelInterceptor implements ChannelInterceptor { +// +// // application.yaml에 정의된 jwt.secretKey 값을 가져와 필드에 주입 +// @Value("${jwt.secretKey}") +// private String secretKey; +// +// private final ChatService chatService; +// +// public StompChannelInterceptor(ChatService chatService) { +// this.chatService = chatService; +// } +// +// // connect, subscribe, disconnet 하기 전에 preSend()를 무조건 거친다 +// // 클라이언트로부터 메시지가 채널로 전송되기 직전에 이 메서드 호출됨 +// @Override +// public Message preSend(Message message, MessageChannel channel) { +// +// final StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); +// +// if(StompCommand.CONNECT == accessor.getCommand()) { +// System.out.println("connect 요청 시 토큰 유효성 검증"); +// +// String bearerToken = accessor.getFirstNativeHeader("Authorization"); +// +// // Bearer [토큰 값] 형태이므로 substring을 이용해 순수 토큰 값만 남기기 +// String token = bearerToken.substring(7); +// +// // 토큰 검증 +// Claims claims = Jwts.parserBuilder() +// .setSigningKey(secretKey) +// .build() +// .parseClaimsJws(token) +// .getBody(); +// +// System.out.println("토큰 검증 완료"); +// } +// +// // 사용자가 채팅방의 참여자인지 검증 +// if(StompCommand.SUBSCRIBE == accessor.getCommand()) { +// System.out.println("subscribe 검증"); +// String bearerToken = accessor.getFirstNativeHeader("Authorization"); +// String token = bearerToken.substring(7); +// +// Claims claims = Jwts.parserBuilder() +// .setSigningKey(secretKey) +// .build() +// .parseClaimsJws(token) +// .getBody(); +// +// String email = claims.getSubject(); +// String roomId = accessor.getDestination().split("/")[2]; +// if(!chatService.isRoomParticipant(email, Long.parseLong(roomId))) { +// throw new AuthenticationServiceException("해당 room에 권한이 없습니다."); +// } +// } +// return message; +// } +// +// +// +//} diff --git a/src/main/java/com/ureka/techpost/global/config/websocket/StompEventListener.java b/src/main/java/com/ureka/techpost/global/config/websocket/StompEventListener.java new file mode 100644 index 0000000..4b389fc --- /dev/null +++ b/src/main/java/com/ureka/techpost/global/config/websocket/StompEventListener.java @@ -0,0 +1,37 @@ +package com.ureka.techpost.global.config.websocket; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.context.event.EventListener; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.messaging.SessionConnectEvent; + +// connection 객체 관리 +// 실시간 서버에서 문제 => 연결 객체 많아져서 서버 과부화되는 것 => 적절한 제거 필요함 + +// 스프링과 STOMP는 세션 관리를 자동 (내부적)으로 처리 +// 연결 / 해제 이벤트 기록, 연결된 세션 수를 실시간으로 확인할 목적으로 EventListener 생성 +// 로그 & 디버깅 목적 +@Component +public class StompEventListener { + private final Set sessions = ConcurrentHashMap.newKeySet(); + + @EventListener + public void connectHandle(SessionConnectEvent event) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + sessions.add(accessor.getSessionId()); // 세션 생성 + + System.out.println("connect session ID " + accessor.getSessionId()); + System.out.println("total session : " + sessions.size()); + } + + @EventListener + public void disconnectHandle(SessionConnectEvent event) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + sessions.remove(accessor.getSessionId()); // 세션 삭제 + + System.out.println("disconnect session ID " + accessor.getSessionId()); + System.out.println("total session : " + sessions.size()); + } +} diff --git a/src/main/java/com/ureka/techpost/global/config/websocket/StompWebSocketConfig.java b/src/main/java/com/ureka/techpost/global/config/websocket/StompWebSocketConfig.java new file mode 100644 index 0000000..9cd01fc --- /dev/null +++ b/src/main/java/com/ureka/techpost/global/config/websocket/StompWebSocketConfig.java @@ -0,0 +1,50 @@ +// STOMP WebSocket Config 파일 + +package com.ureka.techpost.global.config.websocket; + +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +// STOMP 사용해 메시지 브로커 설정 +@Configuration +@EnableWebSocketMessageBroker // STOMP 전용 +public class StompWebSocketConfig implements WebSocketMessageBrokerConfigurer { +// private final StompChannelInterceptor stompHandler; +// +// public StompWebSocketConfig(StompChannelInterceptor stompHandler) { +// this.stompHandler = stompHandler; +// } + + // WebSocket 엔드포인트 등록 : 클라이언트가 연결할 수 있는 WebSocket 엔드포인트 정의 + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + // 클라이언트가 WebSocket에 연결하기 위한 엔드포인트를 "/connect"로 설정 + registry.addEndpoint("/connect") + // 클라이언트의 origin을 명시적으로 지정 + .setAllowedOrigins("http://localhost:3000") +// // WebSocket을 지원하지 않는 브라우저에서도 SockJS를 통해 WebSocket 기능 사용 가능하도록 + .withSockJS(); + } + + // 메시지 브로커 구성 : 클라 - 서버 간의 메시지 라우팅 관리 + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + // 발행 (publish) : /publish/1 형태로 메시지 발행해야 함을 설정 + // /publish로 시작하는 url 패턴으로 메시지 발행되면 @Controller 객체의 @MessageMapping 메서드로 라우팅 + registry.setApplicationDestinationPrefixes("/publish"); + + // 수신 (subscribe) : /topic/1 형태로 메시지 수신해야 함을 설정 + registry.enableSimpleBroker("/topic"); + } + +// // 웹소켓 요청 (Connect, subscribe, disconnect) 등의 요청 시에는 http reader 등 http 메시지를 넣어올 수 있고, +// // 이를 interceptor를 통해 가로채 토큰 등을 검증할 수 있음 +// @Override +// public void configureClientInboundChannel(ChannelRegistration registration) { +// registration.interceptors(stompHandler); +// } +} From 384613bdc75a2c22be92707e1acdcc9e6268d98f Mon Sep 17 00:00:00 2001 From: Jae Date: Mon, 8 Dec 2025 14:51:59 +0900 Subject: [PATCH 02/57] =?UTF-8?q?[Docs]=20=ED=81=B4=EB=9E=98=EC=8A=A4=20?= =?UTF-8?q?=EC=84=A4=EB=AA=85=20=EB=AC=B8=EA=B5=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/config/websocket/StompChannelInterceptor.java | 8 ++++++++ .../global/config/websocket/StompEventListener.java | 8 ++++++++ .../global/config/websocket/StompWebSocketConfig.java | 8 +++++++- 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/ureka/techpost/global/config/websocket/StompChannelInterceptor.java b/src/main/java/com/ureka/techpost/global/config/websocket/StompChannelInterceptor.java index 614e2f2..8ab1798 100644 --- a/src/main/java/com/ureka/techpost/global/config/websocket/StompChannelInterceptor.java +++ b/src/main/java/com/ureka/techpost/global/config/websocket/StompChannelInterceptor.java @@ -1,3 +1,11 @@ +/** + * @file StompChannelInterceptor.java + @author 이재, 강승우 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 WebSocket 사용시 사용자 인증하는 클래스입니다. + */ + //package com.ureka.techpost.global.config.websocket; // //import com.goojakgyo.goojakgyo.chat.service.ChatService; diff --git a/src/main/java/com/ureka/techpost/global/config/websocket/StompEventListener.java b/src/main/java/com/ureka/techpost/global/config/websocket/StompEventListener.java index 4b389fc..da4828e 100644 --- a/src/main/java/com/ureka/techpost/global/config/websocket/StompEventListener.java +++ b/src/main/java/com/ureka/techpost/global/config/websocket/StompEventListener.java @@ -1,3 +1,11 @@ +/** + * @file StompEventLister.java + @author 이재, 강승우 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 WebSocket 세션 관리 클래스입니다. + */ + package com.ureka.techpost.global.config.websocket; import java.util.Set; diff --git a/src/main/java/com/ureka/techpost/global/config/websocket/StompWebSocketConfig.java b/src/main/java/com/ureka/techpost/global/config/websocket/StompWebSocketConfig.java index 9cd01fc..31acf4e 100644 --- a/src/main/java/com/ureka/techpost/global/config/websocket/StompWebSocketConfig.java +++ b/src/main/java/com/ureka/techpost/global/config/websocket/StompWebSocketConfig.java @@ -1,4 +1,10 @@ -// STOMP WebSocket Config 파일 +/** + * @file StompWebSocketConfig.java + @author 이재, 강승우 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 WebSocket 사용 설정 클래스입니다. + */ package com.ureka.techpost.global.config.websocket; From a71356132ebf16a19dd93d3ef98eff0313c4258b Mon Sep 17 00:00:00 2001 From: Jae Date: Mon, 8 Dec 2025 16:26:06 +0900 Subject: [PATCH 03/57] =?UTF-8?q?[Feat]=20=EC=B1=84=ED=8C=85=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/chat/entity/ChatMessage.java | 36 +++++++++++++++++++ .../domain/chat/entity/ChatParticipant.java | 32 +++++++++++++++++ .../techpost/domain/chat/entity/ChatRoom.java | 25 +++++++++++++ .../websocket/StompWebSocketConfig.java | 1 - 4 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/ureka/techpost/domain/chat/entity/ChatMessage.java create mode 100644 src/main/java/com/ureka/techpost/domain/chat/entity/ChatParticipant.java create mode 100644 src/main/java/com/ureka/techpost/domain/chat/entity/ChatRoom.java diff --git a/src/main/java/com/ureka/techpost/domain/chat/entity/ChatMessage.java b/src/main/java/com/ureka/techpost/domain/chat/entity/ChatMessage.java new file mode 100644 index 0000000..779bbcb --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/chat/entity/ChatMessage.java @@ -0,0 +1,36 @@ +package com.ureka.techpost.domain.chat.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Getter +@Entity +public class ChatMessage { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "chat_room_id", nullable = false) + private ChatRoom chatRoom; + +// @ManyToOne(fetch = FetchType.LAZY) +// @JoinColumn(name = "member_id", nullable = false) +// private Member member; + + @Column(nullable = false, length = 500) + private String content; +} diff --git a/src/main/java/com/ureka/techpost/domain/chat/entity/ChatParticipant.java b/src/main/java/com/ureka/techpost/domain/chat/entity/ChatParticipant.java new file mode 100644 index 0000000..0d3eedd --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/chat/entity/ChatParticipant.java @@ -0,0 +1,32 @@ +package com.ureka.techpost.domain.chat.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Getter +@Entity +public class ChatParticipant { + @Id + @GeneratedValue(strategy= GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "chat_room_id", nullable = false) + private ChatRoom chatRoom; + +// @ManyToOne(fetch = FetchType.LAZY) +// @JoinColumn(name = "member_id", nullable = false) +// private Member member; +} diff --git a/src/main/java/com/ureka/techpost/domain/chat/entity/ChatRoom.java b/src/main/java/com/ureka/techpost/domain/chat/entity/ChatRoom.java new file mode 100644 index 0000000..10b82bd --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/chat/entity/ChatRoom.java @@ -0,0 +1,25 @@ +package com.ureka.techpost.domain.chat.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Getter +@Entity +public class ChatRoom { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String roomName; +} diff --git a/src/main/java/com/ureka/techpost/global/config/websocket/StompWebSocketConfig.java b/src/main/java/com/ureka/techpost/global/config/websocket/StompWebSocketConfig.java index 31acf4e..ad3bb48 100644 --- a/src/main/java/com/ureka/techpost/global/config/websocket/StompWebSocketConfig.java +++ b/src/main/java/com/ureka/techpost/global/config/websocket/StompWebSocketConfig.java @@ -9,7 +9,6 @@ package com.ureka.techpost.global.config.websocket; import org.springframework.context.annotation.Configuration; -import org.springframework.messaging.simp.config.ChannelRegistration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; From 7a60fde88ebb143f6b990acab91b4aa7d157473a Mon Sep 17 00:00:00 2001 From: Jae Date: Mon, 8 Dec 2025 16:36:49 +0900 Subject: [PATCH 04/57] =?UTF-8?q?[Feat]=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20API=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 --- .../chat/controller/ChatController.java | 22 ++++++++++++++++ .../domain/chat/dto/response/ChatRoomRes.java | 23 ++++++++++++++++ .../chat/repository/ChatRoomRepository.java | 9 +++++++ .../domain/chat/service/ChatService.java | 26 +++++++++++++++++++ 4 files changed, 80 insertions(+) create mode 100644 src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java create mode 100644 src/main/java/com/ureka/techpost/domain/chat/dto/response/ChatRoomRes.java create mode 100644 src/main/java/com/ureka/techpost/domain/chat/repository/ChatRoomRepository.java create mode 100644 src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java diff --git a/src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java b/src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java new file mode 100644 index 0000000..6178f22 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java @@ -0,0 +1,22 @@ +package com.ureka.techpost.domain.chat.controller; + +import com.ureka.techpost.domain.chat.dto.response.ChatRoomRes; +import com.ureka.techpost.domain.chat.service.ChatService; +import com.ureka.techpost.global.apiPayload.ApiResponse; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/chats") +public class ChatController { + private final ChatService chatService; + + @GetMapping + public ApiResponse> getChatRoomList() { + return ApiResponse.onSuccess(chatService.getChatRoomList()); + } +} diff --git a/src/main/java/com/ureka/techpost/domain/chat/dto/response/ChatRoomRes.java b/src/main/java/com/ureka/techpost/domain/chat/dto/response/ChatRoomRes.java new file mode 100644 index 0000000..09a15b6 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/chat/dto/response/ChatRoomRes.java @@ -0,0 +1,23 @@ +package com.ureka.techpost.domain.chat.dto.response; + +import com.ureka.techpost.domain.chat.entity.ChatRoom; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@AllArgsConstructor +@Builder +@Getter +public class ChatRoomRes { + + private Long roomId; + + private String roomName; + + public static ChatRoomRes from(ChatRoom chatRoom) { + return ChatRoomRes.builder() + .roomId(chatRoom.getId()) + .roomName(chatRoom.getRoomName()) + .build(); + } +} diff --git a/src/main/java/com/ureka/techpost/domain/chat/repository/ChatRoomRepository.java b/src/main/java/com/ureka/techpost/domain/chat/repository/ChatRoomRepository.java new file mode 100644 index 0000000..fbd60d8 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/chat/repository/ChatRoomRepository.java @@ -0,0 +1,9 @@ +package com.ureka.techpost.domain.chat.repository; + +import com.ureka.techpost.domain.chat.entity.ChatRoom; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ChatRoomRepository extends JpaRepository { +} diff --git a/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java b/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java new file mode 100644 index 0000000..82b1615 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java @@ -0,0 +1,26 @@ +package com.ureka.techpost.domain.chat.service; + +import com.ureka.techpost.domain.chat.dto.response.ChatRoomRes; +import com.ureka.techpost.domain.chat.entity.ChatRoom; +import com.ureka.techpost.domain.chat.repository.ChatRoomRepository; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class ChatService { + + private final ChatRoomRepository chatRoomRepository; + + public List getChatRoomList() { + List chatRoomList = chatRoomRepository.findAll(); + List chatRoomResList = new ArrayList<>(); + + for (ChatRoom chatRoom : chatRoomList) + chatRoomResList.add(ChatRoomRes.from(chatRoom)); + + return chatRoomResList; + } +} \ No newline at end of file From 29bdd7b304bda053976143aa7bbee7b850bc0a0f Mon Sep 17 00:00:00 2001 From: k0081915 Date: Mon, 8 Dec 2025 16:37:45 +0900 Subject: [PATCH 05/57] =?UTF-8?q?[Feat]=20=EC=9C=A0=EC=A0=80=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4,=20SecurityConfig=20=EC=84=A4=EC=A0=95=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 --- build.gradle | 8 +- .../ureka/techpost/TechpostApplication.java | 2 + .../techpost/domain/user/entity/User.java | 48 +++++++++ .../techpost/domain/user/enums/Role.java | 12 +++ .../user/repository/UserRepository.java | 22 +++++ .../techpost/global/config/AppConfig.java | 22 +++++ .../global/config/SecurityConfig.java | 97 +++++++++++++++---- .../techpost/global/entity/BaseEntity.java | 32 ++++++ 8 files changed, 220 insertions(+), 23 deletions(-) create mode 100644 src/main/java/com/ureka/techpost/domain/user/entity/User.java create mode 100644 src/main/java/com/ureka/techpost/domain/user/enums/Role.java create mode 100644 src/main/java/com/ureka/techpost/domain/user/repository/UserRepository.java create mode 100644 src/main/java/com/ureka/techpost/global/config/AppConfig.java create mode 100644 src/main/java/com/ureka/techpost/global/entity/BaseEntity.java diff --git a/build.gradle b/build.gradle index 5a95200..3782f79 100644 --- a/build.gradle +++ b/build.gradle @@ -28,7 +28,7 @@ dependencies { // Spring 기본 implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - //implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' //WebSocket @@ -45,9 +45,9 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' // JWT - // implementation 'io.jsonwebtoken:jjwt-api:0.11.5' - // runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' - // runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + implementation 'io.jsonwebtoken:jjwt-api:0.13.0' + implementation 'io.jsonwebtoken:jjwt-impl:0.13.0' + implementation 'io.jsonwebtoken:jjwt-jackson:0.13.0' // JSON 처리 라이브러리 // Lombok compileOnly 'org.projectlombok:lombok' diff --git a/src/main/java/com/ureka/techpost/TechpostApplication.java b/src/main/java/com/ureka/techpost/TechpostApplication.java index edcfb3d..ab2a957 100644 --- a/src/main/java/com/ureka/techpost/TechpostApplication.java +++ b/src/main/java/com/ureka/techpost/TechpostApplication.java @@ -2,7 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +@EnableJpaAuditing @SpringBootApplication public class TechpostApplication { diff --git a/src/main/java/com/ureka/techpost/domain/user/entity/User.java b/src/main/java/com/ureka/techpost/domain/user/entity/User.java new file mode 100644 index 0000000..997d955 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/user/entity/User.java @@ -0,0 +1,48 @@ +package com.ureka.techpost.domain.user.entity; + +import com.ureka.techpost.domain.user.enums.Role; +import com.ureka.techpost.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +/** + * @file User.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 사용자 정보를 담는 Entity 클래스입니다. + */ +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class User extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_id") + private Long userId; + + @Column(unique = true) + private String username; + + private String password; // 소셜 로그인은 null 가능 + + @Column(nullable = false) + private String name; + + // 일반 로그인은 provider="NONE", providerId=null 로 설정 + private String provider; // google, kakao, naver + + @Column(name = "provider_id") + private String providerId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Role role; + + public String getRoleName() { + return this.role.name(); + } +} diff --git a/src/main/java/com/ureka/techpost/domain/user/enums/Role.java b/src/main/java/com/ureka/techpost/domain/user/enums/Role.java new file mode 100644 index 0000000..23d80c7 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/user/enums/Role.java @@ -0,0 +1,12 @@ +package com.ureka.techpost.domain.user.enums; + +/** + * @file Role.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 사용자 권한을 정의하는 Enum 클래스입니다. + */ +public enum Role { + ROLE_USER, ROLE_ADMIN +} diff --git a/src/main/java/com/ureka/techpost/domain/user/repository/UserRepository.java b/src/main/java/com/ureka/techpost/domain/user/repository/UserRepository.java new file mode 100644 index 0000000..c8ab3cd --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/user/repository/UserRepository.java @@ -0,0 +1,22 @@ +package com.ureka.techpost.domain.user.repository; + +import com.ureka.techpost.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * @file UserRepository.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 User Entity를 위한 Repository 클래스입니다. + */ +@Repository +public interface UserRepository extends JpaRepository { + + Optional findByUsername(String username); + + boolean existsByUsername(String username); +} diff --git a/src/main/java/com/ureka/techpost/global/config/AppConfig.java b/src/main/java/com/ureka/techpost/global/config/AppConfig.java new file mode 100644 index 0000000..ca87cc8 --- /dev/null +++ b/src/main/java/com/ureka/techpost/global/config/AppConfig.java @@ -0,0 +1,22 @@ +package com.ureka.techpost.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +/** + * @file AppConfig.java + @author 김동혁 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 비밀번호 암호화를 위한 클래스입니다. + */ +@Configuration +public class AppConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java b/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java index 2335346..a3b35c6 100644 --- a/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java +++ b/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java @@ -1,31 +1,90 @@ package com.ureka.techpost.global.config; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; +import java.util.Collections; @Configuration +@EnableWebSecurity +@RequiredArgsConstructor public class SecurityConfig { - @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - - http - .csrf(csrf -> csrf.disable()) - .authorizeHttpRequests(auth -> auth - .requestMatchers( - "/swagger-ui/**", - "/v3/api-docs/**", - "/swagger-resources/**", - "/health" - ).permitAll() - .anyRequest().permitAll() - ) - .httpBasic(Customizer.withDefaults()) - .formLogin(login -> login.disable()); - - return http.build(); - } +// private final JwtUtil jwtUtil; +// private final UserRepository userRepository; +// private final TokenService tokenService; +// private final CustomLogoutHandler customLogoutHandler; +// private final CustomOAuth2UserService customOAuth2UserService; +// private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; + + static final String[] WHITE_LIST = {"/swagger-ui/**", + "/v3/api-docs/**", + "/swagger-resources/**", + "/health", + "/", "/login", "/signup", "/css/**", "/js/**", "/oauth2/**", + "/api/auth/**" + }; + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { + return configuration.getAuthenticationManager(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(Collections.singletonList("http://localhost:5173")); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(Collections.singletonList("*")); + configuration.setAllowCredentials(true); + configuration.setExposedHeaders(Collections.singletonList("Authorization")); + configuration.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + + http + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + + .authorizeHttpRequests(auth -> auth + .requestMatchers(WHITE_LIST).permitAll() + .anyRequest().permitAll() + ); +// .logout(logout -> logout +// .logoutUrl("/api/auth/logout") +// .addLogoutHandler(customLogoutHandler) +// .logoutSuccessHandler((request, response, authentication) -> { +// response.setStatus(HttpServletResponse.SC_OK); +// })) +// +// .addFilterBefore(new JwtAuthenticationFilter(jwtUtil, userRepository, tokenService), UsernamePasswordAuthenticationFilter.class); + + + return http.build(); + } } diff --git a/src/main/java/com/ureka/techpost/global/entity/BaseEntity.java b/src/main/java/com/ureka/techpost/global/entity/BaseEntity.java new file mode 100644 index 0000000..0a6f91b --- /dev/null +++ b/src/main/java/com/ureka/techpost/global/entity/BaseEntity.java @@ -0,0 +1,32 @@ +package com.ureka.techpost.global.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +/** + * @file BaseEntity.java + @author 김동혁 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 생성/수정 시간을 자동으로 관리하는 Base Entity 클래스입니다. + */ +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public class BaseEntity { + + @CreatedDate + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; +} From fd9716b073d3659a428ca27a34b24458145927fb Mon Sep 17 00:00:00 2001 From: k0081915 Date: Mon, 8 Dec 2025 16:58:29 +0900 Subject: [PATCH 06/57] =?UTF-8?q?[Feat]=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 30 ++++++++++++++ .../techpost/domain/auth/dto/SignupDto.java | 30 ++++++++++++++ .../domain/auth/service/AuthService.java | 41 +++++++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 src/main/java/com/ureka/techpost/domain/auth/controller/AuthController.java create mode 100644 src/main/java/com/ureka/techpost/domain/auth/dto/SignupDto.java create mode 100644 src/main/java/com/ureka/techpost/domain/auth/service/AuthService.java diff --git a/src/main/java/com/ureka/techpost/domain/auth/controller/AuthController.java b/src/main/java/com/ureka/techpost/domain/auth/controller/AuthController.java new file mode 100644 index 0000000..9584983 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/controller/AuthController.java @@ -0,0 +1,30 @@ +package com.ureka.techpost.domain.auth.controller; + +import com.ureka.techpost.domain.auth.dto.SignupDto; +import com.ureka.techpost.domain.auth.service.AuthService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * @file AuthController.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 사용자 인증과 관련된 HTTP 요청을 받아 처리하는 REST 컨트롤러 클래스입니다. + */@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + + @PostMapping("/signup") + public ResponseEntity signup(@RequestBody SignupDto signupDto) { + authService.signup(signupDto); + return ResponseEntity.ok("회원가입 성공"); + } + +} diff --git a/src/main/java/com/ureka/techpost/domain/auth/dto/SignupDto.java b/src/main/java/com/ureka/techpost/domain/auth/dto/SignupDto.java new file mode 100644 index 0000000..cb3f64b --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/dto/SignupDto.java @@ -0,0 +1,30 @@ +package com.ureka.techpost.domain.auth.dto; + +import com.ureka.techpost.domain.user.entity.User; +import com.ureka.techpost.domain.user.enums.Role; +import lombok.Data; + +/** + * @file SignupDto.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 회원가입 요청에 사용되는 DTO 클래스입니다. + */ +@Data +public class SignupDto { + + private String username; + private String password; + private String name; + + public User toEntity(String encodedPassword) { + return User.builder() + .username(username) + .password(encodedPassword) + .name(name) + .provider("NONE") + .role(Role.ROLE_USER) + .build(); + } +} diff --git a/src/main/java/com/ureka/techpost/domain/auth/service/AuthService.java b/src/main/java/com/ureka/techpost/domain/auth/service/AuthService.java new file mode 100644 index 0000000..e1d7b4c --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/service/AuthService.java @@ -0,0 +1,41 @@ +package com.ureka.techpost.domain.auth.service; + +import com.ureka.techpost.domain.auth.dto.SignupDto; +import com.ureka.techpost.domain.user.entity.User; +import com.ureka.techpost.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * @file AuthController.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 사용자 인증 관련 로직을 수행하는 클래스입니다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + // 회원가입 + @Transactional + public void signup(SignupDto signupDto) { + // DB에 입력한 username이 존재하는지 확인 + if (userRepository.existsByUsername(signupDto.getUsername())) { + throw new RuntimeException("이미 가입되어 있는 회원입니다."); + } + + // 없으면 DB에 회원 저장 + User user = signupDto.toEntity(passwordEncoder.encode(signupDto.getPassword())); + userRepository.save(user); + } + + +} \ No newline at end of file From a5fb72385431e7fbc92319f96c7d489163cff0a9 Mon Sep 17 00:00:00 2001 From: Jae Date: Mon, 8 Dec 2025 16:59:40 +0900 Subject: [PATCH 07/57] =?UTF-8?q?[Feat]=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=A0=84=EC=86=A1=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/controller/StompController.java | 24 +++++++++++++++++++ .../chat/dto/request/ChatMessageReq.java | 16 +++++++++++++ .../repository/ChatMessageRepository.java | 8 +++++++ .../domain/chat/service/ChatService.java | 23 ++++++++++++++++++ 4 files changed, 71 insertions(+) create mode 100644 src/main/java/com/ureka/techpost/domain/chat/controller/StompController.java create mode 100644 src/main/java/com/ureka/techpost/domain/chat/dto/request/ChatMessageReq.java create mode 100644 src/main/java/com/ureka/techpost/domain/chat/repository/ChatMessageRepository.java diff --git a/src/main/java/com/ureka/techpost/domain/chat/controller/StompController.java b/src/main/java/com/ureka/techpost/domain/chat/controller/StompController.java new file mode 100644 index 0000000..b69e7ad --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/chat/controller/StompController.java @@ -0,0 +1,24 @@ +package com.ureka.techpost.domain.chat.controller; + +import com.ureka.techpost.domain.chat.dto.request.ChatMessageReq; +import com.ureka.techpost.domain.chat.service.ChatService; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.simp.SimpMessageSendingOperations; +import org.springframework.stereotype.Controller; + +@RequiredArgsConstructor +@Controller +public class StompController { + + private final SimpMessageSendingOperations messageTemplate; + private final ChatService chatService; + + @MessageMapping("/{roomId}") + public void sendMessage(@DestinationVariable Long roomId, Long userid, ChatMessageReq chatMessageReq) { + chatService.saveMessage(roomId, userid, chatMessageReq); + chatService.saveMessage(roomId, userid, chatMessageReq); + messageTemplate.convertAndSend("/topic/" + roomId, chatMessageReq); + } +} \ No newline at end of file diff --git a/src/main/java/com/ureka/techpost/domain/chat/dto/request/ChatMessageReq.java b/src/main/java/com/ureka/techpost/domain/chat/dto/request/ChatMessageReq.java new file mode 100644 index 0000000..35f7ad5 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/chat/dto/request/ChatMessageReq.java @@ -0,0 +1,16 @@ +package com.ureka.techpost.domain.chat.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Getter +public class ChatMessageReq { + + private String message; + private String senderName; +} \ No newline at end of file diff --git a/src/main/java/com/ureka/techpost/domain/chat/repository/ChatMessageRepository.java b/src/main/java/com/ureka/techpost/domain/chat/repository/ChatMessageRepository.java new file mode 100644 index 0000000..9da1a4c --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/chat/repository/ChatMessageRepository.java @@ -0,0 +1,8 @@ +package com.ureka.techpost.domain.chat.repository; + +import com.ureka.techpost.domain.chat.entity.ChatMessage; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ChatMessageRepository extends JpaRepository { + +} diff --git a/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java b/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java index 82b1615..b892c1b 100644 --- a/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java +++ b/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java @@ -1,8 +1,13 @@ package com.ureka.techpost.domain.chat.service; +import com.ureka.techpost.domain.chat.dto.request.ChatMessageReq; import com.ureka.techpost.domain.chat.dto.response.ChatRoomRes; +import com.ureka.techpost.domain.chat.entity.ChatMessage; import com.ureka.techpost.domain.chat.entity.ChatRoom; +import com.ureka.techpost.domain.chat.repository.ChatMessageRepository; import com.ureka.techpost.domain.chat.repository.ChatRoomRepository; +import jakarta.persistence.EntityNotFoundException; +import jakarta.transaction.Transactional; import java.util.ArrayList; import java.util.List; import lombok.RequiredArgsConstructor; @@ -13,6 +18,7 @@ public class ChatService { private final ChatRoomRepository chatRoomRepository; + private final ChatMessageRepository chatMessageRepository; public List getChatRoomList() { List chatRoomList = chatRoomRepository.findAll(); @@ -23,4 +29,21 @@ public List getChatRoomList() { return chatRoomResList; } + + @Transactional + public void saveMessage(Long roomId, Long userid, ChatMessageReq chatMessageReq) { + ChatRoom chatRoom = chatRoomRepository.findById(roomId) + .orElseThrow(() -> new EntityNotFoundException("room cannot be found")); + +// User sender = memberRepository.findById(userid) +// .orElseThrow(() -> new EntityNotFoundException("member cannot be found")); + + ChatMessage chatMessage = ChatMessage.builder() + .chatRoom(chatRoom) +// .member(sender) + .content(chatMessageReq.getMessage()) + .build(); + + chatMessageRepository.save(chatMessage); + } } \ No newline at end of file From 30be2de3b6d28c76d35ea6b01e8b0ab576029335 Mon Sep 17 00:00:00 2001 From: Jae Date: Mon, 8 Dec 2025 17:08:55 +0900 Subject: [PATCH 08/57] =?UTF-8?q?[Docs]=20=ED=81=B4=EB=9E=98=EC=8A=A4=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=A0=95=EB=B3=B4=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../techpost/domain/chat/controller/ChatController.java | 8 ++++++++ .../techpost/domain/chat/controller/StompController.java | 8 ++++++++ .../techpost/domain/chat/dto/request/ChatMessageReq.java | 8 ++++++++ .../techpost/domain/chat/dto/response/ChatRoomRes.java | 9 +++++++++ .../ureka/techpost/domain/chat/entity/ChatMessage.java | 8 ++++++++ .../techpost/domain/chat/entity/ChatParticipant.java | 8 ++++++++ .../com/ureka/techpost/domain/chat/entity/ChatRoom.java | 8 ++++++++ .../domain/chat/repository/ChatMessageRepository.java | 8 ++++++++ .../domain/chat/repository/ChatRoomRepository.java | 8 ++++++++ .../ureka/techpost/domain/chat/service/ChatService.java | 8 ++++++++ 10 files changed, 81 insertions(+) diff --git a/src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java b/src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java index 6178f22..153c408 100644 --- a/src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java +++ b/src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java @@ -1,3 +1,11 @@ +/** + * @file ChatController.java + @author 이재, 강승우 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 채팅 관련 컨트롤러 클래스입니다. + */ + package com.ureka.techpost.domain.chat.controller; import com.ureka.techpost.domain.chat.dto.response.ChatRoomRes; diff --git a/src/main/java/com/ureka/techpost/domain/chat/controller/StompController.java b/src/main/java/com/ureka/techpost/domain/chat/controller/StompController.java index b69e7ad..b324b06 100644 --- a/src/main/java/com/ureka/techpost/domain/chat/controller/StompController.java +++ b/src/main/java/com/ureka/techpost/domain/chat/controller/StompController.java @@ -1,3 +1,11 @@ +/** + * @file StompController.java + @author 이재, 강승우 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 메시지 전송을 위한 컨트롤러 클래스입니다. + */ + package com.ureka.techpost.domain.chat.controller; import com.ureka.techpost.domain.chat.dto.request.ChatMessageReq; diff --git a/src/main/java/com/ureka/techpost/domain/chat/dto/request/ChatMessageReq.java b/src/main/java/com/ureka/techpost/domain/chat/dto/request/ChatMessageReq.java index 35f7ad5..4697d80 100644 --- a/src/main/java/com/ureka/techpost/domain/chat/dto/request/ChatMessageReq.java +++ b/src/main/java/com/ureka/techpost/domain/chat/dto/request/ChatMessageReq.java @@ -1,3 +1,11 @@ +/** + * @file ChatMessageReq.java + @author 이재, 강승우 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 채팅 메시지 전송 시 사용되는 Request Dto 클래스입니다. + */ + package com.ureka.techpost.domain.chat.dto.request; import lombok.AllArgsConstructor; diff --git a/src/main/java/com/ureka/techpost/domain/chat/dto/response/ChatRoomRes.java b/src/main/java/com/ureka/techpost/domain/chat/dto/response/ChatRoomRes.java index 09a15b6..3de401e 100644 --- a/src/main/java/com/ureka/techpost/domain/chat/dto/response/ChatRoomRes.java +++ b/src/main/java/com/ureka/techpost/domain/chat/dto/response/ChatRoomRes.java @@ -1,3 +1,12 @@ +/** + * @file ChatRoomRes.java + @author 이재, 강승우 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 채팅방 목록 조회 시 사용되는 Response Dto 클래스입니다. + */ + + package com.ureka.techpost.domain.chat.dto.response; import com.ureka.techpost.domain.chat.entity.ChatRoom; diff --git a/src/main/java/com/ureka/techpost/domain/chat/entity/ChatMessage.java b/src/main/java/com/ureka/techpost/domain/chat/entity/ChatMessage.java index 779bbcb..b9e8e38 100644 --- a/src/main/java/com/ureka/techpost/domain/chat/entity/ChatMessage.java +++ b/src/main/java/com/ureka/techpost/domain/chat/entity/ChatMessage.java @@ -1,3 +1,11 @@ +/** + * @file ChatMessage.java + @author 이재, 강승우 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 채팅 메시지 Jpa Entity 클래스입니다. + */ + package com.ureka.techpost.domain.chat.entity; import jakarta.persistence.Column; diff --git a/src/main/java/com/ureka/techpost/domain/chat/entity/ChatParticipant.java b/src/main/java/com/ureka/techpost/domain/chat/entity/ChatParticipant.java index 0d3eedd..92891d0 100644 --- a/src/main/java/com/ureka/techpost/domain/chat/entity/ChatParticipant.java +++ b/src/main/java/com/ureka/techpost/domain/chat/entity/ChatParticipant.java @@ -1,3 +1,11 @@ +/** + * @file ChatParticipant.java + @author 이재, 강승우 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 채팅 - 회원 중간 테이블 Jpa Entity 클래스입니다. + */ + package com.ureka.techpost.domain.chat.entity; import jakarta.persistence.Entity; diff --git a/src/main/java/com/ureka/techpost/domain/chat/entity/ChatRoom.java b/src/main/java/com/ureka/techpost/domain/chat/entity/ChatRoom.java index 10b82bd..1e64c3e 100644 --- a/src/main/java/com/ureka/techpost/domain/chat/entity/ChatRoom.java +++ b/src/main/java/com/ureka/techpost/domain/chat/entity/ChatRoom.java @@ -1,3 +1,11 @@ +/** + * @file ChatRoom.java + @author 이재, 강승우 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 채팅방 Jpa Entity 클래스입니다. + */ + package com.ureka.techpost.domain.chat.entity; import jakarta.persistence.Column; diff --git a/src/main/java/com/ureka/techpost/domain/chat/repository/ChatMessageRepository.java b/src/main/java/com/ureka/techpost/domain/chat/repository/ChatMessageRepository.java index 9da1a4c..ab36346 100644 --- a/src/main/java/com/ureka/techpost/domain/chat/repository/ChatMessageRepository.java +++ b/src/main/java/com/ureka/techpost/domain/chat/repository/ChatMessageRepository.java @@ -1,3 +1,11 @@ +/** + * @file ChatMessageRepository.java + @author 이재, 강승우 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 채팅 메시지 Repository 클래스입니다. + */ + package com.ureka.techpost.domain.chat.repository; import com.ureka.techpost.domain.chat.entity.ChatMessage; diff --git a/src/main/java/com/ureka/techpost/domain/chat/repository/ChatRoomRepository.java b/src/main/java/com/ureka/techpost/domain/chat/repository/ChatRoomRepository.java index fbd60d8..7039438 100644 --- a/src/main/java/com/ureka/techpost/domain/chat/repository/ChatRoomRepository.java +++ b/src/main/java/com/ureka/techpost/domain/chat/repository/ChatRoomRepository.java @@ -1,3 +1,11 @@ +/** + * @file ChatRoomRepository.java + @author 이재, 강승우 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 채팅방 Repository 클래스입니다. + */ + package com.ureka.techpost.domain.chat.repository; import com.ureka.techpost.domain.chat.entity.ChatRoom; diff --git a/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java b/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java index b892c1b..910ec8f 100644 --- a/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java +++ b/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java @@ -1,3 +1,11 @@ +/** + * @file ChatService.java + @author 이재, 강승우 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 채팅 관련 서비스 클래스입니다. + */ + package com.ureka.techpost.domain.chat.service; import com.ureka.techpost.domain.chat.dto.request.ChatMessageReq; From 21281ad9bec32538574a1fcd93e035d2e7385d9d Mon Sep 17 00:00:00 2001 From: k0081915 Date: Mon, 8 Dec 2025 17:29:01 +0900 Subject: [PATCH 09/57] =?UTF-8?q?[Feat]=20=EB=A1=9C=EA=B7=B8=EC=9D=B8,=20J?= =?UTF-8?q?WT=20=EB=B0=9C=EA=B8=89=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 9 +- .../domain/auth/dto/CustomUserDetails.java | 73 ++++++++++ .../domain/auth/dto/ErrorResponseDto.java | 35 +++++ .../techpost/domain/auth/dto/LoginDto.java | 17 +++ .../domain/auth/entity/RefreshToken.java | 34 +++++ .../auth/exception/InvalidTokenException.java | 14 ++ .../exception/JwtGlobalExceptionHandler.java | 39 +++++ .../auth/jwt/JwtAuthenticationFilter.java | 97 +++++++++++++ .../techpost/domain/auth/jwt/JwtUtil.java | 101 +++++++++++++ .../repository/RefreshTokenRepository.java | 27 ++++ .../domain/auth/service/AuthService.java | 55 +++++++ .../service/CustomUserDetailsService.java | 33 +++++ .../domain/auth/service/TokenService.java | 136 ++++++++++++++++++ .../global/config/SecurityConfig.java | 14 +- 14 files changed, 678 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/ureka/techpost/domain/auth/dto/CustomUserDetails.java create mode 100644 src/main/java/com/ureka/techpost/domain/auth/dto/ErrorResponseDto.java create mode 100644 src/main/java/com/ureka/techpost/domain/auth/dto/LoginDto.java create mode 100644 src/main/java/com/ureka/techpost/domain/auth/entity/RefreshToken.java create mode 100644 src/main/java/com/ureka/techpost/domain/auth/exception/InvalidTokenException.java create mode 100644 src/main/java/com/ureka/techpost/domain/auth/exception/JwtGlobalExceptionHandler.java create mode 100644 src/main/java/com/ureka/techpost/domain/auth/jwt/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/ureka/techpost/domain/auth/jwt/JwtUtil.java create mode 100644 src/main/java/com/ureka/techpost/domain/auth/repository/RefreshTokenRepository.java create mode 100644 src/main/java/com/ureka/techpost/domain/auth/service/CustomUserDetailsService.java create mode 100644 src/main/java/com/ureka/techpost/domain/auth/service/TokenService.java diff --git a/src/main/java/com/ureka/techpost/domain/auth/controller/AuthController.java b/src/main/java/com/ureka/techpost/domain/auth/controller/AuthController.java index 9584983..a0ad02f 100644 --- a/src/main/java/com/ureka/techpost/domain/auth/controller/AuthController.java +++ b/src/main/java/com/ureka/techpost/domain/auth/controller/AuthController.java @@ -14,7 +14,8 @@ @version 1.0 @since 2025-12-08 @description 이 파일은 사용자 인증과 관련된 HTTP 요청을 받아 처리하는 REST 컨트롤러 클래스입니다. - */@RestController + */ +@RestController @RequestMapping("/api/auth") @RequiredArgsConstructor public class AuthController { @@ -27,4 +28,10 @@ public ResponseEntity signup(@RequestBody SignupDto signupDto) { return ResponseEntity.ok("회원가입 성공"); } + @PostMapping("/login") + public ResponseEntity login(@RequestBody LoginDto loginDto, HttpServletResponse response) { + authService.login(loginDto, response); + return ResponseEntity.ok("로그인 성공"); + } + } diff --git a/src/main/java/com/ureka/techpost/domain/auth/dto/CustomUserDetails.java b/src/main/java/com/ureka/techpost/domain/auth/dto/CustomUserDetails.java new file mode 100644 index 0000000..14f1a2c --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/dto/CustomUserDetails.java @@ -0,0 +1,73 @@ +package com.ureka.techpost.domain.auth.dto; + +import com.ureka.techpost.domain.user.entity.User; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; + +/** + * @file CustomUserDetails.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 Spring Security의 UserDetails와 OAuth2User 인터페이스를 구현한 커스텀 클래스입니다. + */ +@Getter +public class CustomUserDetails implements UserDetails, OAuth2User { + + private final User user; + private Map attributes; + + // 일반 로그인 생성자 + public CustomUserDetails(User user) { + this.user = user; + } + + // OAuth2 로그인 생성자 + public CustomUserDetails(User user, Map attributes) { + this.user = user; + this.attributes = attributes; + } + + + // === UserDetails 구현 === + @Override + public Collection getAuthorities() { + + Collection collection = new ArrayList<>(); + collection.add(new GrantedAuthority() { + @Override + public String getAuthority() { + return user.getRoleName(); + } + }); + + return collection; + } + + @Override + public String getPassword() { + return user.getPassword(); + } + + @Override + public String getUsername() { + return user.getUsername(); + } + + // === OAuth2User 구현 === + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public String getName() { + return user.getProviderId(); + } +} \ No newline at end of file diff --git a/src/main/java/com/ureka/techpost/domain/auth/dto/ErrorResponseDto.java b/src/main/java/com/ureka/techpost/domain/auth/dto/ErrorResponseDto.java new file mode 100644 index 0000000..4993400 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/dto/ErrorResponseDto.java @@ -0,0 +1,35 @@ +package com.ureka.techpost.domain.auth.dto; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.http.ResponseEntity; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +/** + * @file ErrorResponseDto.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 예외 발생 시 클라이언트에게 반환되는 표준 오류 응답 DTO 클래스입니다. + */ +@Getter +@Builder +public class ErrorResponseDto { + + private final String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + private final int status; + private final String error; + private final String message; + + public static ResponseEntity toResponseEntity(int status, String error, String message) { + return ResponseEntity + .status(status) + .body(ErrorResponseDto.builder() + .status(status) + .error(error) + .message(message) + .build()); + } +} diff --git a/src/main/java/com/ureka/techpost/domain/auth/dto/LoginDto.java b/src/main/java/com/ureka/techpost/domain/auth/dto/LoginDto.java new file mode 100644 index 0000000..19d1452 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/dto/LoginDto.java @@ -0,0 +1,17 @@ +package com.ureka.techpost.domain.auth.dto; + +import lombok.Data; + +/** + * @file LoginDto.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 로그인 요청에 사용되는 DTO 클래스입니다. + */ +@Data +public class LoginDto { + + private String username; + private String password; +} diff --git a/src/main/java/com/ureka/techpost/domain/auth/entity/RefreshToken.java b/src/main/java/com/ureka/techpost/domain/auth/entity/RefreshToken.java new file mode 100644 index 0000000..5e9042a --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/entity/RefreshToken.java @@ -0,0 +1,34 @@ +package com.ureka.techpost.domain.auth.entity; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.index.Indexed; + +/** + * @file RefreshToken.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 리프레시 토큰 정보를 담는 Redis Entity 클래스입니다. + */ +@Getter +@Builder +@AllArgsConstructor +@RedisHash(value = "refreshToken", timeToLive = 60) +public class RefreshToken { + + @Id + private String id; // Redis Key (일반적으로 username이나 userId 사용) + + @Indexed + private String tokenValue; // 리프레시 토큰 값 (조회용 인덱스) + + private String username; // 사용자 식별자 + + public void updateToken(String tokenValue) { + this.tokenValue = tokenValue; + } +} \ No newline at end of file diff --git a/src/main/java/com/ureka/techpost/domain/auth/exception/InvalidTokenException.java b/src/main/java/com/ureka/techpost/domain/auth/exception/InvalidTokenException.java new file mode 100644 index 0000000..c29eb18 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/exception/InvalidTokenException.java @@ -0,0 +1,14 @@ +package com.ureka.techpost.domain.auth.exception; + +/** + * @file InvalidTokenException.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 유효하지 않은 토큰과 관련된 오류 상황에서 발생하는 사용자 정의 런타임 예외(Custom Exception) 클래스입니다. + */ +public class InvalidTokenException extends RuntimeException { + public InvalidTokenException(String message) { + super(message); + } +} diff --git a/src/main/java/com/ureka/techpost/domain/auth/exception/JwtGlobalExceptionHandler.java b/src/main/java/com/ureka/techpost/domain/auth/exception/JwtGlobalExceptionHandler.java new file mode 100644 index 0000000..5de0c1a --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/exception/JwtGlobalExceptionHandler.java @@ -0,0 +1,39 @@ +package com.ureka.techpost.domain.auth.exception; + +import com.ureka.techpost.domain.auth.dto.ErrorResponseDto; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * @file JwtGlobalExceptionHandler.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 애플리케이션 전역에서 발생하는 예외(토큰 오류, 로그인 실패 등)를 감지하여 표준화된 에러 응답(JSON)으로 변환해주는 글로벌 예외 처리 핸들러입니다. + */ +@RestControllerAdvice +public class JwtGlobalExceptionHandler { + + @ExceptionHandler(InvalidTokenException.class) + public ResponseEntity handleInvalidTokenException(InvalidTokenException ex) { + return ErrorResponseDto.toResponseEntity(HttpStatus.UNAUTHORIZED.value(), "토큰 오류", ex.getMessage()); + } + + @ExceptionHandler(BadCredentialsException.class) + public ResponseEntity handleBadCredentialsException(BadCredentialsException ex) { + return ErrorResponseDto.toResponseEntity(HttpStatus.UNAUTHORIZED.value(), "로그인 실패", "비밀번호가 일치하지 않습니다."); + } + + @ExceptionHandler(RuntimeException.class) + public ResponseEntity handleRuntimeException(RuntimeException ex) { + // "이미 가입되어 있는 회원입니다." 와 같은 회원가입 시의 예외를 처리 + if ("이미 가입되어 있는 회원입니다.".equals(ex.getMessage())) { + return ErrorResponseDto.toResponseEntity(HttpStatus.CONFLICT.value(), "회원가입 오류", ex.getMessage()); + } + // 그 외 다른 런타임 예외는 일반적인 서버 오류로 처리 + return ErrorResponseDto.toResponseEntity(HttpStatus.INTERNAL_SERVER_ERROR.value(), "서버 오류", "알 수 없는 런타임 오류가 발생했습니다."); + } +} \ No newline at end of file diff --git a/src/main/java/com/ureka/techpost/domain/auth/jwt/JwtAuthenticationFilter.java b/src/main/java/com/ureka/techpost/domain/auth/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..ce8435f --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,97 @@ +package com.ureka.techpost.domain.auth.jwt; + +import com.ureka.techpost.domain.auth.dto.CustomUserDetails; +import com.ureka.techpost.domain.user.entity.User; +import com.ureka.techpost.domain.user.repository.UserRepository; +import com.ureka.techpost.domain.auth.service.TokenService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * @file JwtAuthenticationFilter.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 모든 API 요청이 올 때마다 가장 먼저 실행되어 토큰 검사하는 클래스입니다. + */ +@Slf4j +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + private final UserRepository userRepository; + private final TokenService tokenService; + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String requestURI = request.getRequestURI(); + // reissue 요청은 헤더에 access 토큰이 아닌 refresh 토큰이 필요하기 때문에, + // JwtAuthenticationFilter의 검증 로직을 건너뛰어야 함 + return requestURI.equals("/api/auth/reissue"); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + log.info("[JwtAuthFilter] doFilterInternal"); + + // 요청 헤더에서 Authorization 키의 값(토큰) 추출 + String authorization = request.getHeader("Authorization"); + + // 토큰이 없거나, Bearer 타입이 아니면 필터 통과 (인증 실패 처리됨) + if (authorization == null || !authorization.startsWith("Bearer ")) { + log.warn("JWT 토큰 없음"); + filterChain.doFilter(request, response); + return; + } + + // "Bearer " 접두사를 제거하고 순수 토큰 값만 추출 + String accessToken = authorization.split(" ")[1]; + + // 토큰 유효성 검증 (만료 여부, 위조 여부 등 확인) + // 유효하지 않으면 예외가 발생하여 GlobalExceptionHandler가 처리 + tokenService.validateAccessToken(accessToken); + + // 토큰에서 사용자 이름(username) 추출 + String username = jwtUtil.getUsernameFromToken(accessToken); + + // 추출한 username으로 DB에서 실제 사용자 정보 조회 + // (토큰에는 비밀번호 같은 민감한 정보가 없으므로 DB 조회가 필요할 수 있음) + User foundUser = userRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("회원이 존재하지 않습니다.")); + + // 인증 객체(Authentication) 생성을 위한 임시 User 객체 생성 + // 비밀번호는 이미 토큰 검증을 통과했으므로 임의의 값으로 설정 + User user = User.builder() + .username(username) + .password("temppassword") + .name(foundUser.getName()) + .role(foundUser.getRole()) + .provider("NONE") + .providerId(null) + .build(); + + // UserDetails 객체 생성 (Spring Security가 사용하는 사용자 정보 객체) + CustomUserDetails customUserDetails = new CustomUserDetails(user); + + // 스프링 시큐리티 인증 토큰 생성 + Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities()); + + // 세션(Security Context)에 인증 정보 등록 + // 이 요청이 끝날 때까지만 인증된 상태로 유지됨 (Stateless) + SecurityContextHolder.getContext().setAuthentication(authToken); + + // 다음 필터로 진행 + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/ureka/techpost/domain/auth/jwt/JwtUtil.java b/src/main/java/com/ureka/techpost/domain/auth/jwt/JwtUtil.java new file mode 100644 index 0000000..1fa875d --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/jwt/JwtUtil.java @@ -0,0 +1,101 @@ +package com.ureka.techpost.domain.auth.jwt; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Date; + +/** + * @file JwtUtil.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 JWT의 생성, 검증, 만료 확인, 정보 추출(파싱) 등을 담당하는 유틸리티 컴포넌트입니다. + */ +@Slf4j +@Component +public class JwtUtil { + + @Value("${jwt.secret}") + private String secretKey; + + @Value("${jwt.access-token-expiration-time}") + private long ACCESS_TOKEN_EXPIRE_TIME; + + @Value("${jwt.refresh-token-expiration-time}") + private long REFRESH_TOKEN_EXPIRE_TIME; + + // 토큰 암호화 키 + private SecretKey key; + + // application.yml에서 jwt.secret 값을 가져와서 비밀 키로 세팅 + @PostConstruct + public void init() { + this.key = Keys.hmacShaKeyFor(secretKey.getBytes()); + } + + // Access 토큰 생성 메소드 + // username, role, category 담겨있음 + public String generateAccessToken(String category, String username, String role) { + return Jwts.builder() + .subject(username) + .claim("role", role) + .claim("category", category) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_EXPIRE_TIME)) + .signWith(key) + .compact(); + } + + // Refresh 토큰 생성 메소드 + // category만 담겨있음 + public String generateRefreshToken(String category) { + return Jwts.builder() + .claim("category", category) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + REFRESH_TOKEN_EXPIRE_TIME)) + .signWith(key) + .compact(); + } + + // JWT로부터 subject를 꺼내서 username 확인 + public String getUsernameFromToken(String token) { + return Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload().getSubject(); + } + + // JWT로부터 role claim 추출 + public String getRoleFromToken(String token) { + return Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload().get("role", String.class); + } + + // JWT로부터 category 추출 (access, refresh 구분) + public String getCategory(String token) { + return Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload().get("category", String.class); + } + + // 토큰이 만료되었으면 true, 아니면 false + public Boolean isExpired(String token) { + return Jwts.parser().verifyWith(key).build().parseSignedClaims(token) + .getPayload().getExpiration().before(new Date()); + } + + // 만료된 토큰에서 username 추출 + public String getUsernameFromExpirationToken(String token) { + try { + return Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .getPayload() + .getSubject(); + } catch (ExpiredJwtException e) { + // 만료된 토큰이어도 일단 내부 정보 반환(재발급 시 사용자 정보가 필요할 수 있음) + return e.getClaims().getSubject(); + } + } +} diff --git a/src/main/java/com/ureka/techpost/domain/auth/repository/RefreshTokenRepository.java b/src/main/java/com/ureka/techpost/domain/auth/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..a30fb0a --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/repository/RefreshTokenRepository.java @@ -0,0 +1,27 @@ +package com.ureka.techpost.domain.auth.repository; + +import com.ureka.techpost.domain.auth.entity.RefreshToken; +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * @file RefreshTokenRepository.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 RefreshToken Entity를 위한 Redis Repository 클래스 입니다. + */ +@Repository +public interface RefreshTokenRepository extends CrudRepository { + + // @Indexed로 지정된 필드는 findBy 구문으로 조회 가능 + Optional findByTokenValue(String tokenValue); + + // CrudRepository는 기본적으로 Key(@Id) 기반 조회만 빠르고, Indexed 필드 조회는 보조 인덱스를 사용함. + + Optional findByUsername(String username); + + void deleteByTokenValue(String tokenValue); +} \ No newline at end of file diff --git a/src/main/java/com/ureka/techpost/domain/auth/service/AuthService.java b/src/main/java/com/ureka/techpost/domain/auth/service/AuthService.java index e1d7b4c..4f5c2fe 100644 --- a/src/main/java/com/ureka/techpost/domain/auth/service/AuthService.java +++ b/src/main/java/com/ureka/techpost/domain/auth/service/AuthService.java @@ -1,14 +1,26 @@ package com.ureka.techpost.domain.auth.service; +import com.ureka.techpost.domain.auth.dto.LoginDto; import com.ureka.techpost.domain.auth.dto.SignupDto; +import com.ureka.techpost.domain.auth.jwt.JwtUtil; import com.ureka.techpost.domain.user.entity.User; import com.ureka.techpost.domain.user.repository.UserRepository; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Collection; +import java.util.Iterator; + /** * @file AuthController.java @author 김동혁, 구본문 @@ -23,6 +35,9 @@ public class AuthService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; + private final TokenService tokenService; + private final AuthenticationManager authenticationManager; // 회원가입 @Transactional @@ -37,5 +52,45 @@ public void signup(SignupDto signupDto) { userRepository.save(user); } + public void login(LoginDto loginDto, HttpServletResponse response) { + // 입력 데이터에서 username, password 꺼냄 + String username = loginDto.getUsername(); + String password = loginDto.getPassword(); + + // 로그인을 위한 Spring Security 인증 토큰 생성 + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, password, null); + + // AuthenticationManager를 통해 사용자 인증 시도 + // 인증 성공 시, 사용자 정보(Principal)와 권한(Authorities)을 포함한 Authentication 객체 반환 + Authentication authentication = authenticationManager.authenticate(authToken); + + // 인증된 사용자 이름 추출 + String authenticatedUsername = authentication.getName(); + + // 인증된 사용자의 권한(Role) 추출 + Collection authorities = authentication.getAuthorities(); + Iterator iterator = authorities.iterator(); + GrantedAuthority auth = iterator.next(); + String role = auth.getAuthority(); + + // JWT 액세스 토큰 및 리프레시 토큰 생성 + String access = jwtUtil.generateAccessToken("access", authenticatedUsername, role); + String refresh = jwtUtil.generateRefreshToken("refresh"); + + // DB에서 사용자 정보 조회 (리프레시 토큰 저장을 위함) + User user = userRepository.findByUsername(authenticatedUsername) + .orElseThrow(() -> new UsernameNotFoundException("해당하는 회원을 찾을 수 없습니다.")); + + // 새로 발급된 리프레시 토큰을 DB에 저장 (기존 토큰이 있다면 업데이트) + tokenService.addRefreshToken(user, refresh); + + // 클라이언트 응답 헤더에 액세스 토큰 추가 (Bearer 타입) + response.setHeader("Authorization", "Bearer " + access); + // 클라이언트 응답 쿠키에 HttpOnly 리프레시 토큰 추가 + response.addCookie(tokenService.createCookie("refresh", refresh)); + // HTTP 응답 상태를 OK(200)로 설정 + response.setStatus(HttpStatus.OK.value()); + } + } \ No newline at end of file diff --git a/src/main/java/com/ureka/techpost/domain/auth/service/CustomUserDetailsService.java b/src/main/java/com/ureka/techpost/domain/auth/service/CustomUserDetailsService.java new file mode 100644 index 0000000..60cf599 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/service/CustomUserDetailsService.java @@ -0,0 +1,33 @@ +package com.ureka.techpost.domain.auth.service; + +import com.ureka.techpost.domain.auth.dto.CustomUserDetails; +import com.ureka.techpost.domain.user.entity.User; +import com.ureka.techpost.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +/** + * @file CustomUserDetailsService.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 스프링 시큐리티 로그인 시 DB에서 사용자 정보를 조회하여 인증 객체(UserDetails)를 생성 및 반환하는 서비스 클래스입니다. + */ +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("회원이 존재하지 않습니다.")); + + return new CustomUserDetails(user); + } +} diff --git a/src/main/java/com/ureka/techpost/domain/auth/service/TokenService.java b/src/main/java/com/ureka/techpost/domain/auth/service/TokenService.java new file mode 100644 index 0000000..d6a2704 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/service/TokenService.java @@ -0,0 +1,136 @@ +package com.ureka.techpost.domain.auth.service; + +import com.ureka.techpost.domain.auth.entity.RefreshToken; +import com.ureka.techpost.domain.auth.exception.InvalidTokenException; +import com.ureka.techpost.domain.auth.jwt.JwtUtil; +import com.ureka.techpost.domain.auth.repository.RefreshTokenRepository; +import com.ureka.techpost.domain.user.entity.User; +import io.jsonwebtoken.ExpiredJwtException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +/** + * @file TokenService.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 Refresh 토큰의 저장·삭제(Redis) 및 로그아웃, 토큰 유효성 검증 등 토큰의 전반적인 생명주기를 관리하는 서비스 클래스입니다. + */ +@Service +@RequiredArgsConstructor +public class TokenService { + + private final RefreshTokenRepository refreshTokenRepository; + private final JwtUtil jwtUtil; + + // 로그아웃 처리 + public void logout(HttpServletRequest request, HttpServletResponse response) { + String refresh = null; + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (Cookie cookie : cookies) { + if (cookie.getName().equals("refresh")) { + refresh = cookie.getValue(); + break; + } + } + } + + // 토큰이 존재하면 검증 및 DB 삭제 시도 + if (refresh != null) { + try { + // 토큰 검증 (만료, 위조, DB 존재 여부 확인) + validateRefreshToken(refresh); + // DB에서 Refresh 토큰 제거 + deleteByTokenValue(refresh); + } catch (InvalidTokenException e) { + // 토큰이 유효하지 않거나(만료 등), 이미 DB에 없는 경우 + // 로그아웃 과정이므로 무시하고 쿠키 삭제로 넘어감 + } + } + + // response에서 쿠키 제거 (항상 수행하여 클라이언트 상태 정리) + Cookie cookie = new Cookie("refresh", null); + cookie.setMaxAge(0); + cookie.setPath("/"); + response.addCookie(cookie); + } + + // DB에 Refresh 토큰 저장 (Redis) + public void addRefreshToken(User user, String refresh) { + // Redis에 저장할 객체 생성 + // @Id 필드(id)에 user.getUsername()을 사용하여, 사용자별로 하나의 리프레시 토큰만 유지하도록 할 수 있음 + // 또는 refresh 값을 id로 사용하여 다중 로그인을 허용할 수도 있음. 여기서는 username을 키로 사용. + RefreshToken refreshToken = RefreshToken.builder() + .id(user.getUsername()) // Key: username + .username(user.getUsername()) + .tokenValue(refresh) + .build(); + + refreshTokenRepository.save(refreshToken); + } + + // 쿠키 생성 + public Cookie createCookie(String key, String value) { + Cookie cookie = new Cookie(key, value); + cookie.setMaxAge(24 * 60 * 60); + cookie.setHttpOnly(true); + return cookie; + } + + // DB에 Refresh 토큰이 존재하는지 확인 (Redis) + public Boolean existsByTokenValue(String tokenValue) { + // @Indexed 된 필드로 조회 + return refreshTokenRepository.findByTokenValue(tokenValue).isPresent(); + } + + // DB에서 Refresh 토큰을 삭제 (Redis) + public void deleteByTokenValue(String tokenValue) { + refreshTokenRepository.deleteByTokenValue(tokenValue); + } + + // 리프레시 토큰 검증 + public void validateRefreshToken(String token) { + if (token == null) { + throw new InvalidTokenException("리프레시 토큰이 없습니다."); + } + + try { + jwtUtil.isExpired(token); + } catch (ExpiredJwtException e) { + throw new InvalidTokenException("만료된 리프레시 토큰입니다."); + } + + String category = jwtUtil.getCategory(token); + if (!category.equals("refresh")) { + throw new InvalidTokenException("유효하지 않은 카테고리의 토큰입니다."); + } + + if (!existsByTokenValue(token)) { + throw new InvalidTokenException("DB에 존재하지 않는 리프레시 토큰입니다."); + } + } + + // 액세스 토큰 검증 + public void validateAccessToken(String token) { + if (token == null) { + throw new InvalidTokenException("액세스 토큰이 없습니다."); + } + + try { + jwtUtil.isExpired(token); + } catch (ExpiredJwtException e) { + throw new InvalidTokenException("만료된 액세스 토큰입니다."); + } + + String category = jwtUtil.getCategory(token); + if (!category.equals("access")) { + throw new InvalidTokenException("유효하지 않은 카테고리의 토큰입니다."); + } + + } + +} \ No newline at end of file diff --git a/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java b/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java index a3b35c6..2610c25 100644 --- a/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java +++ b/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java @@ -1,5 +1,9 @@ package com.ureka.techpost.global.config; +import com.ureka.techpost.domain.auth.jwt.JwtAuthenticationFilter; +import com.ureka.techpost.domain.auth.jwt.JwtUtil; +import com.ureka.techpost.domain.auth.service.TokenService; +import com.ureka.techpost.domain.user.repository.UserRepository; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; @@ -25,9 +29,9 @@ @RequiredArgsConstructor public class SecurityConfig { -// private final JwtUtil jwtUtil; -// private final UserRepository userRepository; -// private final TokenService tokenService; + private final JwtUtil jwtUtil; + private final UserRepository userRepository; + private final TokenService tokenService; // private final CustomLogoutHandler customLogoutHandler; // private final CustomOAuth2UserService customOAuth2UserService; // private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; @@ -74,7 +78,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .authorizeHttpRequests(auth -> auth .requestMatchers(WHITE_LIST).permitAll() .anyRequest().permitAll() - ); + ) // .logout(logout -> logout // .logoutUrl("/api/auth/logout") // .addLogoutHandler(customLogoutHandler) @@ -82,7 +86,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // response.setStatus(HttpServletResponse.SC_OK); // })) // -// .addFilterBefore(new JwtAuthenticationFilter(jwtUtil, userRepository, tokenService), UsernamePasswordAuthenticationFilter.class); + .addFilterBefore(new JwtAuthenticationFilter(jwtUtil, userRepository, tokenService), UsernamePasswordAuthenticationFilter.class); return http.build(); From 1634e3c5c1a73276c88920eb093c741d84c8131b Mon Sep 17 00:00:00 2001 From: k0081915 Date: Mon, 8 Dec 2025 17:32:43 +0900 Subject: [PATCH 10/57] =?UTF-8?q?[Feat]=20=ED=86=A0=ED=81=B0=20=EC=9E=AC?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 6 +++ .../domain/auth/service/AuthService.java | 50 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/src/main/java/com/ureka/techpost/domain/auth/controller/AuthController.java b/src/main/java/com/ureka/techpost/domain/auth/controller/AuthController.java index a0ad02f..7f82a54 100644 --- a/src/main/java/com/ureka/techpost/domain/auth/controller/AuthController.java +++ b/src/main/java/com/ureka/techpost/domain/auth/controller/AuthController.java @@ -1,5 +1,6 @@ package com.ureka.techpost.domain.auth.controller; +import com.ureka.techpost.domain.auth.dto.LoginDto; import com.ureka.techpost.domain.auth.dto.SignupDto; import com.ureka.techpost.domain.auth.service.AuthService; import jakarta.servlet.http.HttpServletRequest; @@ -28,6 +29,11 @@ public ResponseEntity signup(@RequestBody SignupDto signupDto) { return ResponseEntity.ok("회원가입 성공"); } + @PostMapping("/reissue") + public ResponseEntity reissue(HttpServletRequest request, HttpServletResponse response) { + return authService.reissue(request, response); + } + @PostMapping("/login") public ResponseEntity login(@RequestBody LoginDto loginDto, HttpServletResponse response) { authService.login(loginDto, response); diff --git a/src/main/java/com/ureka/techpost/domain/auth/service/AuthService.java b/src/main/java/com/ureka/techpost/domain/auth/service/AuthService.java index 4f5c2fe..9e13bc8 100644 --- a/src/main/java/com/ureka/techpost/domain/auth/service/AuthService.java +++ b/src/main/java/com/ureka/techpost/domain/auth/service/AuthService.java @@ -2,13 +2,17 @@ import com.ureka.techpost.domain.auth.dto.LoginDto; import com.ureka.techpost.domain.auth.dto.SignupDto; +import com.ureka.techpost.domain.auth.exception.InvalidTokenException; import com.ureka.techpost.domain.auth.jwt.JwtUtil; import com.ureka.techpost.domain.user.entity.User; import com.ureka.techpost.domain.user.repository.UserRepository; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; @@ -92,5 +96,51 @@ public void login(LoginDto loginDto, HttpServletResponse response) { response.setStatus(HttpStatus.OK.value()); } + // 토큰 재발급 + public ResponseEntity reissue(HttpServletRequest request, HttpServletResponse response) { + + String authorization = request.getHeader("Authorization"); + // Access Token 검증 + if (authorization == null || !authorization.startsWith("Bearer ")) { + throw new InvalidTokenException("액세스 토큰이 없습니다."); + } + String accessToken = authorization.split(" ")[1]; + + // Refresh 토큰 검증 + String refresh = null; + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (Cookie cookie : cookies) { + if (cookie.getName().equals("refresh")) { + refresh = cookie.getValue(); + break; + } + } + } + + tokenService.validateRefreshToken(refresh); + + // --- 검증 통과 --- // + + // 기존 토큰에서 username 꺼냄 + String username = jwtUtil.getUsernameFromExpirationToken(accessToken); + + User foundUser = userRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("회원을 찾을 수 없습니다.")); + + // 새로운 access/refresh 토큰 생성 + String newAccess = jwtUtil.generateAccessToken("access", username, foundUser.getRoleName()); + String newRefresh = jwtUtil.generateRefreshToken("refresh"); + + // 기존 Refresh 토큰 DB에서 삭제 후 새 Refresh 토큰 저장 + tokenService.deleteByTokenValue(refresh); + tokenService.addRefreshToken(foundUser, newRefresh); + + // 응답 설정 + response.setHeader("Authorization", "Bearer " + newAccess); + response.addCookie(tokenService.createCookie("refresh", newRefresh)); + + return new ResponseEntity<>(HttpStatus.OK); + } } \ No newline at end of file From 9f42c67c5a99795ae5f1320345f28255eb4382ba Mon Sep 17 00:00:00 2001 From: k0081915 Date: Mon, 8 Dec 2025 17:38:39 +0900 Subject: [PATCH 11/57] =?UTF-8?q?[Feat]=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/handler/CustomLogoutHandler.java | 29 +++++++++++++++++++ .../JwtGlobalExceptionHandler.java | 3 +- .../global/config/SecurityConfig.java | 18 +++++++----- 3 files changed, 41 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/ureka/techpost/domain/auth/handler/CustomLogoutHandler.java rename src/main/java/com/ureka/techpost/domain/auth/{exception => handler}/JwtGlobalExceptionHandler.java (94%) diff --git a/src/main/java/com/ureka/techpost/domain/auth/handler/CustomLogoutHandler.java b/src/main/java/com/ureka/techpost/domain/auth/handler/CustomLogoutHandler.java new file mode 100644 index 0000000..e9cfa57 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/handler/CustomLogoutHandler.java @@ -0,0 +1,29 @@ +package com.ureka.techpost.domain.auth.handler; + +import com.ureka.techpost.domain.auth.service.TokenService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.stereotype.Component; + +/** + * @file CustomLogoutHandler.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 실제 로그아웃 로직(토큰 삭제, 쿠키 정리)을 TokenService에 위임하여 실행하는 커스텀 로그아웃 핸들러 클래스입니다. + */ +@Component +@RequiredArgsConstructor +public class CustomLogoutHandler implements LogoutHandler { + + private final TokenService tokenService; + + @Override + public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { + // 로그아웃 로직을 TokenService에 위임 + tokenService.logout(request, response); + } +} diff --git a/src/main/java/com/ureka/techpost/domain/auth/exception/JwtGlobalExceptionHandler.java b/src/main/java/com/ureka/techpost/domain/auth/handler/JwtGlobalExceptionHandler.java similarity index 94% rename from src/main/java/com/ureka/techpost/domain/auth/exception/JwtGlobalExceptionHandler.java rename to src/main/java/com/ureka/techpost/domain/auth/handler/JwtGlobalExceptionHandler.java index 5de0c1a..cf6b890 100644 --- a/src/main/java/com/ureka/techpost/domain/auth/exception/JwtGlobalExceptionHandler.java +++ b/src/main/java/com/ureka/techpost/domain/auth/handler/JwtGlobalExceptionHandler.java @@ -1,6 +1,7 @@ -package com.ureka.techpost.domain.auth.exception; +package com.ureka.techpost.domain.auth.handler; import com.ureka.techpost.domain.auth.dto.ErrorResponseDto; +import com.ureka.techpost.domain.auth.exception.InvalidTokenException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.BadCredentialsException; diff --git a/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java b/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java index 2610c25..4e5c3bd 100644 --- a/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java +++ b/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java @@ -1,5 +1,6 @@ package com.ureka.techpost.global.config; +import com.ureka.techpost.domain.auth.handler.CustomLogoutHandler; import com.ureka.techpost.domain.auth.jwt.JwtAuthenticationFilter; import com.ureka.techpost.domain.auth.jwt.JwtUtil; import com.ureka.techpost.domain.auth.service.TokenService; @@ -32,7 +33,7 @@ public class SecurityConfig { private final JwtUtil jwtUtil; private final UserRepository userRepository; private final TokenService tokenService; -// private final CustomLogoutHandler customLogoutHandler; + private final CustomLogoutHandler customLogoutHandler; // private final CustomOAuth2UserService customOAuth2UserService; // private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; @@ -79,13 +80,14 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers(WHITE_LIST).permitAll() .anyRequest().permitAll() ) -// .logout(logout -> logout -// .logoutUrl("/api/auth/logout") -// .addLogoutHandler(customLogoutHandler) -// .logoutSuccessHandler((request, response, authentication) -> { -// response.setStatus(HttpServletResponse.SC_OK); -// })) -// + + .logout(logout -> logout + .logoutUrl("/api/auth/logout") + .addLogoutHandler(customLogoutHandler) + .logoutSuccessHandler((request, response, authentication) -> { + response.setStatus(HttpServletResponse.SC_OK); + })) + .addFilterBefore(new JwtAuthenticationFilter(jwtUtil, userRepository, tokenService), UsernamePasswordAuthenticationFilter.class); From cf541ec7a4990d068eb122ed5b986f040f47203c Mon Sep 17 00:00:00 2001 From: k0081915 Date: Tue, 9 Dec 2025 09:36:46 +0900 Subject: [PATCH 12/57] =?UTF-8?q?[Feat]=20AuthController=20=EC=9E=AC?= =?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/auth/controller/AuthController.java | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/ureka/techpost/domain/auth/controller/AuthController.java b/src/main/java/com/ureka/techpost/domain/auth/controller/AuthController.java index 7f82a54..830c413 100644 --- a/src/main/java/com/ureka/techpost/domain/auth/controller/AuthController.java +++ b/src/main/java/com/ureka/techpost/domain/auth/controller/AuthController.java @@ -3,6 +3,7 @@ import com.ureka.techpost.domain.auth.dto.LoginDto; import com.ureka.techpost.domain.auth.dto.SignupDto; import com.ureka.techpost.domain.auth.service.AuthService; +import com.ureka.techpost.global.apiPayload.ApiResponse; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -24,20 +25,20 @@ public class AuthController { private final AuthService authService; @PostMapping("/signup") - public ResponseEntity signup(@RequestBody SignupDto signupDto) { + public ApiResponse signup(@RequestBody SignupDto signupDto) { authService.signup(signupDto); - return ResponseEntity.ok("회원가입 성공"); + return ApiResponse.onSuccess("회원가입 성공"); } @PostMapping("/reissue") - public ResponseEntity reissue(HttpServletRequest request, HttpServletResponse response) { - return authService.reissue(request, response); + public ApiResponse reissue(HttpServletRequest request, HttpServletResponse response) { + return ApiResponse.onSuccess(authService.reissue(request, response)); } @PostMapping("/login") - public ResponseEntity login(@RequestBody LoginDto loginDto, HttpServletResponse response) { + public ApiResponse login(@RequestBody LoginDto loginDto, HttpServletResponse response) { authService.login(loginDto, response); - return ResponseEntity.ok("로그인 성공"); + return ApiResponse.onSuccess("로그인 성공"); } } From 656d5f537fd33447468cce78436648b0f740ed3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=8A=B9=EC=96=B8?= Date: Tue, 9 Dec 2025 10:00:01 +0900 Subject: [PATCH 13/57] =?UTF-8?q?[Feat]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20En?= =?UTF-8?q?tity=20=EB=B0=8F=20DTO=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/post/dto/PostRequestDTO.java | 32 ++++++++++ .../domain/post/dto/PostResponseDTO.java | 35 +++++++++++ .../techpost/domain/post/entity/Post.java | 61 +++++++++++++++++++ 3 files changed, 128 insertions(+) create mode 100644 src/main/java/com/ureka/techpost/domain/post/dto/PostRequestDTO.java create mode 100644 src/main/java/com/ureka/techpost/domain/post/dto/PostResponseDTO.java create mode 100644 src/main/java/com/ureka/techpost/domain/post/entity/Post.java diff --git a/src/main/java/com/ureka/techpost/domain/post/dto/PostRequestDTO.java b/src/main/java/com/ureka/techpost/domain/post/dto/PostRequestDTO.java new file mode 100644 index 0000000..943d02e --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/post/dto/PostRequestDTO.java @@ -0,0 +1,32 @@ +package com.ureka.techpost.domain.post.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** + * @file PostRequestDTO.java + * @author 최승언 + * @version 1.0 + * @since 2025-12-09 + * @description 클라이언트로부터 게시글 등록 요청 시 전달받는 데이터를 담는 DTO 클래스입니다. + */ + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class PostRequestDTO { + + private String title; + private String summary; + private String originalUrl; + private String thumbnailUrl; + private String publisher; + private LocalDateTime publishedAt; + private String sourceName; + +} diff --git a/src/main/java/com/ureka/techpost/domain/post/dto/PostResponseDTO.java b/src/main/java/com/ureka/techpost/domain/post/dto/PostResponseDTO.java new file mode 100644 index 0000000..8e0d53e --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/post/dto/PostResponseDTO.java @@ -0,0 +1,35 @@ +package com.ureka.techpost.domain.post.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** + * @file PostResponseDTO.java + * @author 최승언 + * @version 1.0 + * @since 2025-12-09 + * @description 클라이언트에게 게시글 정보를 응답(Response)할 때 사용하는 데이터를 담는 DTO 클래스입니다. + */ + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class PostResponseDTO { + private Long id; + private String title; + private String summary; + private String originalUrl; + private String thumbnailUrl; + private String publisher; + private LocalDateTime publishedAt; + private String sourceName; + private LocalDateTime createdAt; + + private Long likeCount; + private Long commentCount; +} diff --git a/src/main/java/com/ureka/techpost/domain/post/entity/Post.java b/src/main/java/com/ureka/techpost/domain/post/entity/Post.java new file mode 100644 index 0000000..1d97495 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/post/entity/Post.java @@ -0,0 +1,61 @@ +package com.ureka.techpost.domain.post.entity; + +import jakarta.persistence.*; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +/** + * @file Post.java + * @author 최승언 + * @version 1.0 + * @since 2025-12-09 + * @description 게시글의 핵심 데이터(제목, 내용, URL 등)를 관리하는 엔티티(Entity) 클래스입니다. + */ + +@Entity +@Table(name = "post") +@Getter +@NoArgsConstructor +@EntityListeners(AuditingEntityListener.class) +public class Post { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String title; + + @Column(columnDefinition = "TEXT", nullable = false) + private String summary; + + @Column(name = "original_url", nullable = false, unique = true) + private String originalUrl; + + @Column(nullable = false) + private String publisher; + + @Column(name = "published_at") + private LocalDateTime publishedAt; + + @Column(name = "source_name") + private String sourceName; + + @Column(name = "thumbnail_url", nullable = false) + private String thumbnailUrl; + + @Builder + public Post(String title, String summary, String originalUrl, String publisher, LocalDateTime publishedAt, String sourceName, String thumbnailUrl) { + this.title = title; + this.summary = summary; + this.originalUrl = originalUrl; + this.publisher = publisher; + this.publishedAt = publishedAt; + this.sourceName = sourceName; + this.thumbnailUrl = thumbnailUrl; + } +} From 8a49aaee0e11e7e88cb21049769a55febb457257 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=8A=B9=EC=96=B8?= Date: Tue, 9 Dec 2025 10:00:23 +0900 Subject: [PATCH 14/57] =?UTF-8?q?[Feat]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20Re?= =?UTF-8?q?pository=20=EB=B0=8F=20QueryDSL=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=BF=BC=EB=A6=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/repository/PostRepository.java | 50 +++++++++ .../post/repository/PostRepositoryCustom.java | 17 +++ .../post/repository/PostRepositoryImpl.java | 101 ++++++++++++++++++ 3 files changed, 168 insertions(+) create mode 100644 src/main/java/com/ureka/techpost/domain/post/repository/PostRepository.java create mode 100644 src/main/java/com/ureka/techpost/domain/post/repository/PostRepositoryCustom.java create mode 100644 src/main/java/com/ureka/techpost/domain/post/repository/PostRepositoryImpl.java diff --git a/src/main/java/com/ureka/techpost/domain/post/repository/PostRepository.java b/src/main/java/com/ureka/techpost/domain/post/repository/PostRepository.java new file mode 100644 index 0000000..3ea019e --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/post/repository/PostRepository.java @@ -0,0 +1,50 @@ +package com.ureka.techpost.domain.post.repository; + +import com.ureka.techpost.domain.post.dto.PostResponseDTO; +import com.ureka.techpost.domain.post.entity.Post; +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; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * @file PostRepository.java + * @author 최승언 + * @version 1.0 + * @since 2025-12-09 + * @description 게시글(Post) 엔티티의 기본적인 CRUD 및 JPQL 쿼리를 담당하는 JPA Repository 인터페이스입니다. + */ + +@Repository +public interface PostRepository extends JpaRepository, PostRepositoryCustom { + + boolean existsByOriginalUrl(String originalUrl); + + @Query("SELECT new com.ureka.techpost.domain.post.dto.PostResponseDTO(" + + "p.id, p.title, p.summary, p.originalUrl, p.thumbnailUrl, " + + "p.publisher, p.publishedAt, p.sourceName, p.createdAt, " + +// [수정] 좋아요 수: 아직 없으므로 0으로 대체 +// "(SELECT count(l) FROM Likes l WHERE l.post.id = p.id), " + + "0L, " + +// "(SELECT count(c) FROM Comment c WHERE c.post.id = p.id)) " + + "0L) " + + "FROM Post p") + Page findPostList(Pageable pageable); + + @Query("SELECT new com.ureka.techpost.domain.post.dto.PostResponseDTO(" + + "p.id, p.title, p.summary, p.originalUrl, p.thumbnailUrl, " + + "p.publisher, p.publishedAt, p.sourceName, p.createdAt, " + +// [수정] 댓글 수: 아직 없으므로 0으로 대체 +// "(SELECT count(l) FROM Likes l WHERE l.post.id = p.id), " + + "0L, " + +// "(SELECT count(c) FROM Comment c WHERE c.post.id = p.id)) " + + "0L) " + + "FROM Post p " + + "WHERE p.id = :postId") + Optional findPostById(@Param("postId") Long postId); + +} diff --git a/src/main/java/com/ureka/techpost/domain/post/repository/PostRepositoryCustom.java b/src/main/java/com/ureka/techpost/domain/post/repository/PostRepositoryCustom.java new file mode 100644 index 0000000..6d31cff --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/post/repository/PostRepositoryCustom.java @@ -0,0 +1,17 @@ +package com.ureka.techpost.domain.post.repository; + +import com.ureka.techpost.domain.post.dto.PostResponseDTO; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +/** + * @file PostRepositoryCustom.java + * @author 최승언 + * @version 1.0 + * @since 2025-12-09 + * @description QueryDSL을 사용한 동적 쿼리 및 검색 기능을 정의하기 위한 커스텀 Repository 인터페이스입니다. + */ + +public interface PostRepositoryCustom { + Page search(String keyword, String publisher, Pageable pageable); +} diff --git a/src/main/java/com/ureka/techpost/domain/post/repository/PostRepositoryImpl.java b/src/main/java/com/ureka/techpost/domain/post/repository/PostRepositoryImpl.java new file mode 100644 index 0000000..aaa1213 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/post/repository/PostRepositoryImpl.java @@ -0,0 +1,101 @@ +package com.ureka.techpost.domain.post.repository; + +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.ureka.techpost.domain.post.dto.PostResponseDTO; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; +import org.springframework.util.StringUtils; + +import java.util.List; + +import static com.ureka.techpost.domain.post.entity.QPost.post; +//import static com.ureka.techpost.domain.post.entity.QComment.comment; +//import static com.ureka.techpost.domain.post.entity.QLikes.likes; + +/** + * @file PostRepositoryImpl.java + * @author 최승언 + * @version 1.0 + * @since 2025-01-01 + * @description QueryDSL을 활용하여 게시글 검색, 필터링 등 복잡한 조회 로직을 실제로 구현한 클래스입니다. + */ + +@RequiredArgsConstructor +public class PostRepositoryImpl implements PostRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public Page search(String keyword, String publisher, Pageable pageable) { + List content = queryFactory + .select(Projections.constructor(PostResponseDTO.class, + post.id, + post.title, + post.summary, + post.originalUrl, + post.thumbnailUrl, + post.publisher, + post.publishedAt, + post.sourceName, +// post.createdAt, + // 좋아요 수 + // [수정] 좋아요 수: 아직 없으므로 0으로 대체 + // ExpressionUtils.as( + // JPAExpressions.select(likes.count()) + // .from(likes) + // .where(likes.post.eq(post)), + // "likeCount"), + com.querydsl.core.types.dsl.Expressions.asNumber(0L), + + // [수정] 댓글 수: 아직 없으므로 0으로 대체 + // ExpressionUtils.as( + // JPAExpressions.select(comment.count()) + // .from(comment) + // .where(comment.post.eq(post)), + // "commentCount") + com.querydsl.core.types.dsl.Expressions.asNumber(0L) + )) + .from(post) + .where( + titleOrSummaryContains(keyword), // 제목 or 요약 + providerContains(publisher) // 출처 + ) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .orderBy(post.id.desc()) // 정렬 + .fetch(); + + // 카운트 쿼리 + JPAQuery countQuery = queryFactory + .select(post.count()) + .from(post) + .where( + titleOrSummaryContains(keyword), + providerContains(publisher) + ); + + return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); + } + + // 제목 or 요약 키워드 검색 + private BooleanExpression titleOrSummaryContains(String keyword) { + if (!StringUtils.hasText(keyword)) { + return null; + } + return post.title.contains(keyword) + .or(post.summary.contains(keyword)); + } + + // 출처 검색 + private BooleanExpression providerContains(String provider) { + if (!StringUtils.hasText(provider)) { + return null; + } + return post.publisher.contains(provider); + } +} From f6af9583da650a0fc7c1c772113763eb6cc08f72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=8A=B9=EC=96=B8?= Date: Tue, 9 Dec 2025 10:00:39 +0900 Subject: [PATCH 15/57] =?UTF-8?q?[Feat]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20CR?= =?UTF-8?q?UD=20=EB=B9=84=EC=A6=88=EB=8B=88=EC=8A=A4=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/post/service/PostService.java | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 src/main/java/com/ureka/techpost/domain/post/service/PostService.java diff --git a/src/main/java/com/ureka/techpost/domain/post/service/PostService.java b/src/main/java/com/ureka/techpost/domain/post/service/PostService.java new file mode 100644 index 0000000..84420f2 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/post/service/PostService.java @@ -0,0 +1,64 @@ +package com.ureka.techpost.domain.post.service; + +import com.ureka.techpost.domain.post.dto.PostResponseDTO; +import com.ureka.techpost.domain.post.dto.PostRequestDTO; +import com.ureka.techpost.domain.post.entity.Post; +import com.ureka.techpost.domain.post.repository.PostRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +/** + * @file PostService.java + * @author 최승언 + * @version 1.0 + * @since 2025-12-09 + * @description 게시글 등록, 조회, 검색, 삭제 등 게시글 도메인의 핵심 비즈니스 로직을 처리하는 서비스 클래스입니다. + */ + +@Service +@RequiredArgsConstructor +public class PostService { + + private final PostRepository postRepository; + + public PostResponseDTO findById(Long id) { + + return postRepository.findPostById(id) + .orElseThrow(() -> new IllegalArgumentException("해당 게시글 없음")); + } + + public void save(PostRequestDTO postRequestDTO) { + + if(postRepository.existsByOriginalUrl(postRequestDTO.getOriginalUrl())){ + throw new IllegalArgumentException("이미 존재하는 게시글"); + } + + // TODO : 유저 권한 검사 + + postRepository.save(Post.builder() + .title(postRequestDTO.getTitle()) + .summary(postRequestDTO.getSummary()) + .originalUrl(postRequestDTO.getOriginalUrl()) + .publisher(postRequestDTO.getPublisher()) + .publishedAt(postRequestDTO.getPublishedAt()) + .sourceName(postRequestDTO.getSourceName()) + .thumbnailUrl(postRequestDTO.getThumbnailUrl()) + .build()); + } + + public Page search(String keyword, String publisher, Pageable pageable){ + return postRepository.search(keyword, publisher, pageable); + } + + public void deletePost(Long postId) { + + Post post = postRepository.findById(postId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 게시물")); + + // TODO : 유저 권한 검사 추가 + + postRepository.delete(post); + } +} From db8434e118f622a0c9acba8805fe1521435aac43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=8A=B9=EC=96=B8?= Date: Tue, 9 Dec 2025 10:00:59 +0900 Subject: [PATCH 16/57] =?UTF-8?q?[Feat]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20CR?= =?UTF-8?q?UD=20API=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/controller/PostController.java | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 src/main/java/com/ureka/techpost/domain/post/controller/PostController.java diff --git a/src/main/java/com/ureka/techpost/domain/post/controller/PostController.java b/src/main/java/com/ureka/techpost/domain/post/controller/PostController.java new file mode 100644 index 0000000..fd60004 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/post/controller/PostController.java @@ -0,0 +1,61 @@ +package com.ureka.techpost.domain.post.controller; + +import com.ureka.techpost.domain.post.dto.PostResponseDTO; +import com.ureka.techpost.domain.post.dto.PostRequestDTO; +import com.ureka.techpost.domain.post.service.PostService; +import com.ureka.techpost.global.apiPayload.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.*; + +/** + * @file PostController.java + @author 최승언 + @version 1.0 + @since 2025-12-09 + @description 게시글 관련 API 요청(생성, 조회, 검색, 삭제)을 받아 서비스 계층으로 전달하고 응답을 반환하는 컨트롤러 클래스입니다. + */ + +@RestController +@RequiredArgsConstructor +@RequestMapping("/posts") +public class PostController { + + private final PostService postService; + + @PostMapping("") + public ApiResponse createPost(@RequestBody PostRequestDTO postRequestDTO){ + + postService.save(postRequestDTO); + + return ApiResponse.onSuccess("게시글 등록 성공"); + } + + // 게시물 목록 가져오기 & 게시물 검색해서 목록 가져오기 + @GetMapping("") + public ApiResponse> searchPosts( + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String publisher, + @PageableDefault(size = 10, sort = "id", direction = Sort.Direction.DESC)Pageable pageable + ){ + return ApiResponse.onSuccess(postService.search(keyword, publisher, pageable)); + } + + @GetMapping("/{postId}") + public ApiResponse getPost(@PathVariable Long postId){ + + return ApiResponse.onSuccess(postService.findById(postId)); + } + + @DeleteMapping("/{postId}") + public ApiResponse deletePost(@PathVariable Long postId){ + + postService.deletePost(postId); + + return ApiResponse.onSuccess("게시물 삭제 성공"); + } + +} From 463d302d8461b6aea914486f5678d2ad12e19f41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=8A=B9=EC=96=B8?= Date: Tue, 9 Dec 2025 10:41:40 +0900 Subject: [PATCH 17/57] =?UTF-8?q?[Feat]=20QueryDSL=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EB=B0=8F=20=EB=B9=8C=EB=93=9C=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/build.gradle b/build.gradle index 3782f79..0ff105d 100644 --- a/build.gradle +++ b/build.gradle @@ -58,6 +58,11 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testImplementation 'com.h2database:h2' + // QueryDSL + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" } tasks.named('test') { From 17b84322e376ee9b373694b100155eceb2a5ee6b Mon Sep 17 00:00:00 2001 From: Jae Date: Tue, 9 Dec 2025 13:23:40 +0900 Subject: [PATCH 18/57] =?UTF-8?q?[Fix]=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=B6=94=EA=B0=80=EC=97=90=20?= =?UTF-8?q?=EB=94=B0=EB=A5=B8=20=EC=84=A4=EC=A0=95=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/controller/ChatController.java | 10 +- .../chat/controller/StompController.java | 12 +- .../domain/chat/entity/ChatMessage.java | 7 +- .../domain/chat/service/ChatService.java | 14 +- .../websocket/StompChannelInterceptor.java | 168 ++++++++++-------- .../websocket/StompWebSocketConfig.java | 23 ++- 6 files changed, 132 insertions(+), 102 deletions(-) diff --git a/src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java b/src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java index 153c408..3f31dcb 100644 --- a/src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java +++ b/src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java @@ -14,7 +14,9 @@ import java.util.List; import lombok.RequiredArgsConstructor; 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.RequestParam; import org.springframework.web.bind.annotation.RestController; @RequiredArgsConstructor @@ -25,6 +27,12 @@ public class ChatController { @GetMapping public ApiResponse> getChatRoomList() { - return ApiResponse.onSuccess(chatService.getChatRoomList()); + return ApiResponse.onSuccess(chatService.getChatRoomList()); + } + + @PostMapping("/room/group/create") + public ApiResponse createGroupChatRoom(@RequestParam String roomName){ + chatService.createGroupChatRoom(roomName); + return ApiResponse.onSuccess(null); } } diff --git a/src/main/java/com/ureka/techpost/domain/chat/controller/StompController.java b/src/main/java/com/ureka/techpost/domain/chat/controller/StompController.java index b324b06..cb24c0f 100644 --- a/src/main/java/com/ureka/techpost/domain/chat/controller/StompController.java +++ b/src/main/java/com/ureka/techpost/domain/chat/controller/StompController.java @@ -8,12 +8,17 @@ package com.ureka.techpost.domain.chat.controller; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.ureka.techpost.domain.auth.dto.CustomUserDetails; import com.ureka.techpost.domain.chat.dto.request.ChatMessageReq; import com.ureka.techpost.domain.chat.service.ChatService; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.messaging.handler.annotation.DestinationVariable; import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; import org.springframework.messaging.simp.SimpMessageSendingOperations; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.stereotype.Controller; @RequiredArgsConstructor @@ -24,9 +29,10 @@ public class StompController { private final ChatService chatService; @MessageMapping("/{roomId}") - public void sendMessage(@DestinationVariable Long roomId, Long userid, ChatMessageReq chatMessageReq) { - chatService.saveMessage(roomId, userid, chatMessageReq); - chatService.saveMessage(roomId, userid, chatMessageReq); + public void sendMessage(@DestinationVariable Long roomId, @AuthenticationPrincipal CustomUserDetails userDetails, @Payload @Valid ChatMessageReq chatMessageReq) throws JsonProcessingException { + Long userId = userDetails.getUser().getUserId(); + + chatService.saveMessage(roomId, userId, chatMessageReq); messageTemplate.convertAndSend("/topic/" + roomId, chatMessageReq); } } \ No newline at end of file diff --git a/src/main/java/com/ureka/techpost/domain/chat/entity/ChatMessage.java b/src/main/java/com/ureka/techpost/domain/chat/entity/ChatMessage.java index b9e8e38..422d83c 100644 --- a/src/main/java/com/ureka/techpost/domain/chat/entity/ChatMessage.java +++ b/src/main/java/com/ureka/techpost/domain/chat/entity/ChatMessage.java @@ -8,6 +8,7 @@ package com.ureka.techpost.domain.chat.entity; +import com.ureka.techpost.domain.user.entity.User; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -35,9 +36,9 @@ public class ChatMessage { @JoinColumn(name = "chat_room_id", nullable = false) private ChatRoom chatRoom; -// @ManyToOne(fetch = FetchType.LAZY) -// @JoinColumn(name = "member_id", nullable = false) -// private Member member; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private User user; @Column(nullable = false, length = 500) private String content; diff --git a/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java b/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java index 910ec8f..eceb16a 100644 --- a/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java +++ b/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java @@ -8,17 +8,22 @@ package com.ureka.techpost.domain.chat.service; +import com.ureka.techpost.domain.auth.dto.CustomUserDetails; import com.ureka.techpost.domain.chat.dto.request.ChatMessageReq; import com.ureka.techpost.domain.chat.dto.response.ChatRoomRes; import com.ureka.techpost.domain.chat.entity.ChatMessage; import com.ureka.techpost.domain.chat.entity.ChatRoom; import com.ureka.techpost.domain.chat.repository.ChatMessageRepository; import com.ureka.techpost.domain.chat.repository.ChatRoomRepository; +import com.ureka.techpost.domain.user.entity.User; +import com.ureka.techpost.domain.user.repository.UserRepository; import jakarta.persistence.EntityNotFoundException; import jakarta.transaction.Transactional; import java.util.ArrayList; import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; @RequiredArgsConstructor @@ -27,6 +32,7 @@ public class ChatService { private final ChatRoomRepository chatRoomRepository; private final ChatMessageRepository chatMessageRepository; + private final UserRepository userRepository; public List getChatRoomList() { List chatRoomList = chatRoomRepository.findAll(); @@ -39,16 +45,16 @@ public List getChatRoomList() { } @Transactional - public void saveMessage(Long roomId, Long userid, ChatMessageReq chatMessageReq) { + public void saveMessage(Long roomId, Long userId, ChatMessageReq chatMessageReq) { ChatRoom chatRoom = chatRoomRepository.findById(roomId) .orElseThrow(() -> new EntityNotFoundException("room cannot be found")); -// User sender = memberRepository.findById(userid) -// .orElseThrow(() -> new EntityNotFoundException("member cannot be found")); + User sender = userRepository.findById(userId) + .orElseThrow(() -> new EntityNotFoundException("member cannot be found")); ChatMessage chatMessage = ChatMessage.builder() .chatRoom(chatRoom) -// .member(sender) + .user(sender) .content(chatMessageReq.getMessage()) .build(); diff --git a/src/main/java/com/ureka/techpost/global/config/websocket/StompChannelInterceptor.java b/src/main/java/com/ureka/techpost/global/config/websocket/StompChannelInterceptor.java index 8ab1798..649fe05 100644 --- a/src/main/java/com/ureka/techpost/global/config/websocket/StompChannelInterceptor.java +++ b/src/main/java/com/ureka/techpost/global/config/websocket/StompChannelInterceptor.java @@ -6,82 +6,92 @@ @description 이 파일은 WebSocket 사용시 사용자 인증하는 클래스입니다. */ -//package com.ureka.techpost.global.config.websocket; -// -//import com.goojakgyo.goojakgyo.chat.service.ChatService; -//import io.jsonwebtoken.Claims; -//import io.jsonwebtoken.Jwts; -//import org.springframework.beans.factory.annotation.Value; -//import org.springframework.messaging.Message; -//import org.springframework.messaging.MessageChannel; -//import org.springframework.messaging.simp.stomp.StompCommand; -//import org.springframework.messaging.simp.stomp.StompHeaderAccessor; -//import org.springframework.messaging.support.ChannelInterceptor; -//import org.springframework.security.authentication.AuthenticationServiceException; -//import org.springframework.stereotype.Component; -// -//// STOMP jwt 인증 처리 -//// STOMP에서 클라이언트가 연결 요청시 JWT의 유효성을 검증하는 역할 -//// 웹소켓으로 채팅 서버에 접속하려는 클라이언트가 보내는 연결메시지 (CONNECT)를 가로채서 메시지에 포함된 JWT 토큰이 유효한지 확인하는 보안설정 코드 -//@Component -//public class StompChannelInterceptor implements ChannelInterceptor { -// -// // application.yaml에 정의된 jwt.secretKey 값을 가져와 필드에 주입 -// @Value("${jwt.secretKey}") -// private String secretKey; -// -// private final ChatService chatService; -// -// public StompChannelInterceptor(ChatService chatService) { -// this.chatService = chatService; -// } -// -// // connect, subscribe, disconnet 하기 전에 preSend()를 무조건 거친다 -// // 클라이언트로부터 메시지가 채널로 전송되기 직전에 이 메서드 호출됨 -// @Override -// public Message preSend(Message message, MessageChannel channel) { -// -// final StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); -// -// if(StompCommand.CONNECT == accessor.getCommand()) { -// System.out.println("connect 요청 시 토큰 유효성 검증"); -// -// String bearerToken = accessor.getFirstNativeHeader("Authorization"); -// -// // Bearer [토큰 값] 형태이므로 substring을 이용해 순수 토큰 값만 남기기 -// String token = bearerToken.substring(7); -// -// // 토큰 검증 -// Claims claims = Jwts.parserBuilder() -// .setSigningKey(secretKey) -// .build() -// .parseClaimsJws(token) -// .getBody(); -// -// System.out.println("토큰 검증 완료"); -// } -// -// // 사용자가 채팅방의 참여자인지 검증 -// if(StompCommand.SUBSCRIBE == accessor.getCommand()) { -// System.out.println("subscribe 검증"); -// String bearerToken = accessor.getFirstNativeHeader("Authorization"); -// String token = bearerToken.substring(7); -// -// Claims claims = Jwts.parserBuilder() -// .setSigningKey(secretKey) -// .build() -// .parseClaimsJws(token) -// .getBody(); -// -// String email = claims.getSubject(); -// String roomId = accessor.getDestination().split("/")[2]; -// if(!chatService.isRoomParticipant(email, Long.parseLong(roomId))) { -// throw new AuthenticationServiceException("해당 room에 권한이 없습니다."); -// } -// } -// return message; -// } -// -// -// -//} +package com.ureka.techpost.global.config.websocket; + +import com.ureka.techpost.domain.auth.dto.CustomUserDetails; +import com.ureka.techpost.domain.auth.service.CustomUserDetailsService; +import com.ureka.techpost.domain.chat.service.ChatService; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.nio.charset.StandardCharsets; +import javax.crypto.SecretKey; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +// STOMP jwt 인증 처리 +// STOMP에서 클라이언트가 연결 요청시 JWT의 유효성을 검증하는 역할 +// 웹소켓으로 채팅 서버에 접속하려는 클라이언트가 보내는 연결메시지 (CONNECT)를 가로채서 메시지에 포함된 JWT 토큰이 유효한지 확인하는 보안설정 코드 +@Component +@RequiredArgsConstructor +public class StompChannelInterceptor implements ChannelInterceptor { + + // application.yaml에 정의된 jwt.secretKey 값을 가져와 필드에 주입 + @Value("${jwt.secretKey}") + private String secretKey; + + private SecretKey getSigningKey() { + return Keys.hmacShaKeyFor(secretKey.getBytes()); + } + + private final ChatService chatService; + private final CustomUserDetailsService userDetailsService; + + // connect, subscribe, disconnet 하기 전에 preSend()를 무조건 거친다 + // 클라이언트로부터 메시지가 채널로 전송되기 직전에 이 메서드 호출됨 + @Override + public Message preSend(Message message, MessageChannel channel) { + + final StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); + + if(StompCommand.CONNECT == accessor.getCommand()) { + String bearerToken = accessor.getFirstNativeHeader("Authorization"); + String token = bearerToken.substring(7); + + // 토큰 검증 + Claims claims = Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + + String username = claims.getSubject(); + + CustomUserDetails userDetails = (CustomUserDetails) userDetailsService.loadUserByUsername(username); + + Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + + accessor.setUser(authentication); + + } + + // 사용자가 채팅방의 참여자인지 검증 + if(StompCommand.SUBSCRIBE == accessor.getCommand()) { + System.out.println("subscribe 검증"); + String bearerToken = accessor.getFirstNativeHeader("Authorization"); + String token = bearerToken.substring(7); + + Claims claims = Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + + String email = claims.getSubject(); + String roomId = accessor.getDestination().split("/")[2]; + } + return message; + } + + + +} diff --git a/src/main/java/com/ureka/techpost/global/config/websocket/StompWebSocketConfig.java b/src/main/java/com/ureka/techpost/global/config/websocket/StompWebSocketConfig.java index ad3bb48..1700c77 100644 --- a/src/main/java/com/ureka/techpost/global/config/websocket/StompWebSocketConfig.java +++ b/src/main/java/com/ureka/techpost/global/config/websocket/StompWebSocketConfig.java @@ -8,7 +8,9 @@ package com.ureka.techpost.global.config.websocket; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.ChannelRegistration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; @@ -16,13 +18,10 @@ // STOMP 사용해 메시지 브로커 설정 @Configuration +@RequiredArgsConstructor @EnableWebSocketMessageBroker // STOMP 전용 public class StompWebSocketConfig implements WebSocketMessageBrokerConfigurer { -// private final StompChannelInterceptor stompHandler; -// -// public StompWebSocketConfig(StompChannelInterceptor stompHandler) { -// this.stompHandler = stompHandler; -// } + private final StompChannelInterceptor stompChannelInterceptor; // WebSocket 엔드포인트 등록 : 클라이언트가 연결할 수 있는 WebSocket 엔드포인트 정의 @Override @@ -31,7 +30,7 @@ public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/connect") // 클라이언트의 origin을 명시적으로 지정 .setAllowedOrigins("http://localhost:3000") -// // WebSocket을 지원하지 않는 브라우저에서도 SockJS를 통해 WebSocket 기능 사용 가능하도록 + // WebSocket을 지원하지 않는 브라우저에서도 SockJS를 통해 WebSocket 기능 사용 가능하도록 .withSockJS(); } @@ -46,10 +45,10 @@ public void configureMessageBroker(MessageBrokerRegistry registry) { registry.enableSimpleBroker("/topic"); } -// // 웹소켓 요청 (Connect, subscribe, disconnect) 등의 요청 시에는 http reader 등 http 메시지를 넣어올 수 있고, -// // 이를 interceptor를 통해 가로채 토큰 등을 검증할 수 있음 -// @Override -// public void configureClientInboundChannel(ChannelRegistration registration) { -// registration.interceptors(stompHandler); -// } + // 웹소켓 요청 (Connect, subscribe, disconnect) 등의 요청 시에는 http reader 등 http 메시지를 넣어올 수 있고, + // 이를 interceptor를 통해 가로채 토큰 등을 검증할 수 있음 + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(stompChannelInterceptor); + } } From f778871b7fa965bf2c661f0aab21dcdc65d0aa0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=8A=B9=EC=96=B8?= Date: Tue, 9 Dec 2025 15:05:59 +0900 Subject: [PATCH 19/57] =?UTF-8?q?[Feat]=20=EA=B4=80=EB=A6=AC=EC=9E=90(ADMI?= =?UTF-8?q?N)=20=EA=B6=8C=ED=95=9C=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C?= =?UTF-8?q?=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/post/controller/PostController.java | 12 ++++++++---- .../ureka/techpost/domain/post/entity/Post.java | 3 ++- .../post/repository/PostRepositoryImpl.java | 12 ++++++------ .../domain/post/service/PostService.java | 17 +++++++++++------ 4 files changed, 27 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/ureka/techpost/domain/post/controller/PostController.java b/src/main/java/com/ureka/techpost/domain/post/controller/PostController.java index fd60004..857f204 100644 --- a/src/main/java/com/ureka/techpost/domain/post/controller/PostController.java +++ b/src/main/java/com/ureka/techpost/domain/post/controller/PostController.java @@ -1,5 +1,6 @@ package com.ureka.techpost.domain.post.controller; +import com.ureka.techpost.domain.auth.dto.CustomUserDetails; import com.ureka.techpost.domain.post.dto.PostResponseDTO; import com.ureka.techpost.domain.post.dto.PostRequestDTO; import com.ureka.techpost.domain.post.service.PostService; @@ -9,6 +10,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.web.PageableDefault; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; /** @@ -27,9 +29,10 @@ public class PostController { private final PostService postService; @PostMapping("") - public ApiResponse createPost(@RequestBody PostRequestDTO postRequestDTO){ + public ApiResponse createPost(@RequestBody PostRequestDTO postRequestDTO, + @AuthenticationPrincipal CustomUserDetails userDetails){ - postService.save(postRequestDTO); + postService.save(postRequestDTO, userDetails); return ApiResponse.onSuccess("게시글 등록 성공"); } @@ -51,9 +54,10 @@ public ApiResponse getPost(@PathVariable Long postId){ } @DeleteMapping("/{postId}") - public ApiResponse deletePost(@PathVariable Long postId){ + public ApiResponse deletePost(@PathVariable Long postId, + @AuthenticationPrincipal CustomUserDetails userDetails){ - postService.deletePost(postId); + postService.deletePost(postId, userDetails); return ApiResponse.onSuccess("게시물 삭제 성공"); } diff --git a/src/main/java/com/ureka/techpost/domain/post/entity/Post.java b/src/main/java/com/ureka/techpost/domain/post/entity/Post.java index 1d97495..e23d519 100644 --- a/src/main/java/com/ureka/techpost/domain/post/entity/Post.java +++ b/src/main/java/com/ureka/techpost/domain/post/entity/Post.java @@ -1,5 +1,6 @@ package com.ureka.techpost.domain.post.entity; +import com.ureka.techpost.global.entity.BaseEntity; import jakarta.persistence.*; import lombok.Builder; import lombok.Getter; @@ -21,7 +22,7 @@ @Getter @NoArgsConstructor @EntityListeners(AuditingEntityListener.class) -public class Post { +public class Post extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/com/ureka/techpost/domain/post/repository/PostRepositoryImpl.java b/src/main/java/com/ureka/techpost/domain/post/repository/PostRepositoryImpl.java index aaa1213..c7e2ce1 100644 --- a/src/main/java/com/ureka/techpost/domain/post/repository/PostRepositoryImpl.java +++ b/src/main/java/com/ureka/techpost/domain/post/repository/PostRepositoryImpl.java @@ -31,7 +31,7 @@ public class PostRepositoryImpl implements PostRepositoryCustom { private final JPAQueryFactory queryFactory; @Override - public Page search(String keyword, String publisher, Pageable pageable) { + public Page search(String keyword, String sourceName, Pageable pageable) { List content = queryFactory .select(Projections.constructor(PostResponseDTO.class, post.id, @@ -42,7 +42,7 @@ public Page search(String keyword, String publisher, Pageable p post.publisher, post.publishedAt, post.sourceName, -// post.createdAt, + post.createdAt, // 좋아요 수 // [수정] 좋아요 수: 아직 없으므로 0으로 대체 // ExpressionUtils.as( @@ -63,7 +63,7 @@ public Page search(String keyword, String publisher, Pageable p .from(post) .where( titleOrSummaryContains(keyword), // 제목 or 요약 - providerContains(publisher) // 출처 + sourceNameContains(sourceName) // 출처 ) .offset(pageable.getOffset()) .limit(pageable.getPageSize()) @@ -76,7 +76,7 @@ public Page search(String keyword, String publisher, Pageable p .from(post) .where( titleOrSummaryContains(keyword), - providerContains(publisher) + sourceNameContains(sourceName) ); return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); @@ -92,10 +92,10 @@ private BooleanExpression titleOrSummaryContains(String keyword) { } // 출처 검색 - private BooleanExpression providerContains(String provider) { + private BooleanExpression sourceNameContains(String provider) { if (!StringUtils.hasText(provider)) { return null; } - return post.publisher.contains(provider); + return post.sourceName.contains(provider); } } diff --git a/src/main/java/com/ureka/techpost/domain/post/service/PostService.java b/src/main/java/com/ureka/techpost/domain/post/service/PostService.java index 84420f2..0477d5b 100644 --- a/src/main/java/com/ureka/techpost/domain/post/service/PostService.java +++ b/src/main/java/com/ureka/techpost/domain/post/service/PostService.java @@ -1,5 +1,6 @@ package com.ureka.techpost.domain.post.service; +import com.ureka.techpost.domain.auth.dto.CustomUserDetails; import com.ureka.techpost.domain.post.dto.PostResponseDTO; import com.ureka.techpost.domain.post.dto.PostRequestDTO; import com.ureka.techpost.domain.post.entity.Post; @@ -29,13 +30,15 @@ public PostResponseDTO findById(Long id) { .orElseThrow(() -> new IllegalArgumentException("해당 게시글 없음")); } - public void save(PostRequestDTO postRequestDTO) { + public void save(PostRequestDTO postRequestDTO, CustomUserDetails userDetails) { + + if(!userDetails.getUser().getRoleName().equals("ROLE_ADMIN")){ + throw new IllegalArgumentException("권한이 없음"); + } if(postRepository.existsByOriginalUrl(postRequestDTO.getOriginalUrl())){ throw new IllegalArgumentException("이미 존재하는 게시글"); } - - // TODO : 유저 권한 검사 postRepository.save(Post.builder() .title(postRequestDTO.getTitle()) @@ -52,13 +55,15 @@ public Page search(String keyword, String publisher, Pageable p return postRepository.search(keyword, publisher, pageable); } - public void deletePost(Long postId) { + public void deletePost(Long postId, CustomUserDetails userDetails) { + + if(!userDetails.getUser().getRoleName().equals("ROLE_ADMIN")){ + throw new IllegalArgumentException("권한이 없음"); + } Post post = postRepository.findById(postId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 게시물")); - // TODO : 유저 권한 검사 추가 - postRepository.delete(post); } } From 7c8b48cd937801333231a1cce45b30666efcb54e Mon Sep 17 00:00:00 2001 From: k0081915 Date: Tue, 9 Dec 2025 15:09:38 +0900 Subject: [PATCH 20/57] =?UTF-8?q?[Feat]=20=EC=86=8C=EC=85=9C=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=20=EC=A0=95=EB=B3=B4=20=ED=91=9C=EC=A4=80=ED=99=94,?= =?UTF-8?q?=20=ED=9A=8C=EC=9B=90=20=EC=A0=80=EC=9E=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/entity/RefreshToken.java | 2 +- .../domain/auth/info/GoogleUserInfo.java | 44 +++++++++ .../domain/auth/info/KakaoUserInfo.java | 49 ++++++++++ .../domain/auth/info/NaverUserInfo.java | 48 ++++++++++ .../domain/auth/info/OAuth2UserInfo.java | 23 +++++ .../auth/service/CustomOAuth2UserService.java | 90 +++++++++++++++++++ 6 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/ureka/techpost/domain/auth/info/GoogleUserInfo.java create mode 100644 src/main/java/com/ureka/techpost/domain/auth/info/KakaoUserInfo.java create mode 100644 src/main/java/com/ureka/techpost/domain/auth/info/NaverUserInfo.java create mode 100644 src/main/java/com/ureka/techpost/domain/auth/info/OAuth2UserInfo.java create mode 100644 src/main/java/com/ureka/techpost/domain/auth/service/CustomOAuth2UserService.java diff --git a/src/main/java/com/ureka/techpost/domain/auth/entity/RefreshToken.java b/src/main/java/com/ureka/techpost/domain/auth/entity/RefreshToken.java index 5e9042a..c81d563 100644 --- a/src/main/java/com/ureka/techpost/domain/auth/entity/RefreshToken.java +++ b/src/main/java/com/ureka/techpost/domain/auth/entity/RefreshToken.java @@ -17,7 +17,7 @@ @Getter @Builder @AllArgsConstructor -@RedisHash(value = "refreshToken", timeToLive = 60) +@RedisHash(value = "refreshToken", timeToLive = 1209600) public class RefreshToken { @Id diff --git a/src/main/java/com/ureka/techpost/domain/auth/info/GoogleUserInfo.java b/src/main/java/com/ureka/techpost/domain/auth/info/GoogleUserInfo.java new file mode 100644 index 0000000..e7a6620 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/info/GoogleUserInfo.java @@ -0,0 +1,44 @@ +package com.ureka.techpost.domain.auth.info; + +import java.util.Map; + +/** + * @file GoogleUserInfo.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-09 + @description 이 파일은 구글 사용자 정보를 추출하기 위한 구현체 클래스입니다. + */ +public class GoogleUserInfo implements OAuth2UserInfo { + + private final Map attributes; + + public GoogleUserInfo(Map attributes) { + this.attributes = attributes; + } + + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public String getProviderId() { + return (String) attributes.get("sub"); + } + + @Override + public String getProvider() { + return "google"; + } + + @Override + public String getEmail() { + return (String) attributes.get("email"); + } + + @Override + public String getName() { + return (String) attributes.get("name"); + } +} diff --git a/src/main/java/com/ureka/techpost/domain/auth/info/KakaoUserInfo.java b/src/main/java/com/ureka/techpost/domain/auth/info/KakaoUserInfo.java new file mode 100644 index 0000000..674769a --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/info/KakaoUserInfo.java @@ -0,0 +1,49 @@ +package com.ureka.techpost.domain.auth.info; + +import java.util.Map; + +/** + * @file KakaoUserInfo.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-09 + @description 이 파일은 카카오 사용자 정보를 추출하기 위한 구현체 클래스입니다. + */ +public class KakaoUserInfo implements OAuth2UserInfo { + + private final Map attributes; + private final Map kakaoAccountAttributes; + private final Map profileAttributes; + + @SuppressWarnings("unchecked") + public KakaoUserInfo(Map attributes) { + this.attributes = attributes; + this.kakaoAccountAttributes = (Map) attributes.get("kakao_account"); + this.profileAttributes = (Map) kakaoAccountAttributes.get("profile"); + } + + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public String getProviderId() { + return attributes.get("id").toString(); + } + + @Override + public String getProvider() { + return "kakao"; + } + + @Override + public String getEmail() { + return (String) kakaoAccountAttributes.get("email"); + } + + @Override + public String getName() { + return (String) profileAttributes.get("nickname"); + } +} diff --git a/src/main/java/com/ureka/techpost/domain/auth/info/NaverUserInfo.java b/src/main/java/com/ureka/techpost/domain/auth/info/NaverUserInfo.java new file mode 100644 index 0000000..9783e9b --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/info/NaverUserInfo.java @@ -0,0 +1,48 @@ +package com.ureka.techpost.domain.auth.info; + +import java.util.Map; + +/** + * @file NaverUserInfo.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-09 + @description 이 파일은 네이버 사용자 정보를 추출하기 위한 구현체 클래스입니다. + */ +public class NaverUserInfo implements OAuth2UserInfo { + + private final Map attributes; + private final Map responseAttributes; + + @SuppressWarnings("unchecked") + public NaverUserInfo(Map attributes) { + this.attributes = attributes; + // Naver 응답의 실제 사용자 정보는 "response" 키 값에 Map 형태로 들어있음 + this.responseAttributes = (Map) attributes.get("response"); + } + + @Override + public Map getAttributes() { + return responseAttributes; // 실제 속성 맵 반환 + } + + @Override + public String getProviderId() { + return (String) responseAttributes.get("id"); + } + + @Override + public String getProvider() { + return "naver"; + } + + @Override + public String getEmail() { + return (String) responseAttributes.get("email"); + } + + @Override + public String getName() { + return (String) responseAttributes.get("name"); + } +} diff --git a/src/main/java/com/ureka/techpost/domain/auth/info/OAuth2UserInfo.java b/src/main/java/com/ureka/techpost/domain/auth/info/OAuth2UserInfo.java new file mode 100644 index 0000000..81d778c --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/info/OAuth2UserInfo.java @@ -0,0 +1,23 @@ +package com.ureka.techpost.domain.auth.info; + +import java.util.Map; + +/** + * @file OAuth2UserInfo.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-09 + @description 이 파일은 소셜 사용자 정보를 공통된 형식으로 사용하기 위한 클래스입니다. + */ +public interface OAuth2UserInfo { + // 제공자로부터 받은 원본 사용자 정보 + Map getAttributes(); + // 제공자의 고유 식별 ID + String getProviderId(); + // 제공자 이름 (google, naver, kakao) + String getProvider(); + // 사용자 이메일 + String getEmail(); + // 사용자 이름 + String getName(); +} diff --git a/src/main/java/com/ureka/techpost/domain/auth/service/CustomOAuth2UserService.java b/src/main/java/com/ureka/techpost/domain/auth/service/CustomOAuth2UserService.java new file mode 100644 index 0000000..9d8e274 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/service/CustomOAuth2UserService.java @@ -0,0 +1,90 @@ +package com.ureka.techpost.domain.auth.service; + +import com.ureka.techpost.domain.auth.dto.CustomUserDetails; +import com.ureka.techpost.domain.auth.info.GoogleUserInfo; +import com.ureka.techpost.domain.auth.info.KakaoUserInfo; +import com.ureka.techpost.domain.auth.info.NaverUserInfo; +import com.ureka.techpost.domain.auth.info.OAuth2UserInfo; +import com.ureka.techpost.domain.user.entity.User; +import com.ureka.techpost.domain.user.enums.Role; +import com.ureka.techpost.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +/** + * @file CustomOAuth2UserService.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-09 + @description 이 파일은 소셜 사용자 정보를 DB에 위한 클래스입니다. + */ +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + + // 기본 OAuth2UserService를 통해 사용자 정보 가져오기 + OAuth2User oAuth2User = super.loadUser(userRequest); + + // 사용자의 소셜 서비스 제공자(provider) 이름 가져오기 (google, naver, kakao 등) + String provider = userRequest.getClientRegistration().getRegistrationId(); + + // 제공자에 따라 적절한 OAuth2UserInfo 구현체 선택 + OAuth2UserInfo oAuth2UserInfo = getOAuth2UserInfo(provider, oAuth2User); + + // 소셜 사용자의 고유 ID와 제공자 이름을 조합하여 유니크한 username 생성 + // 예: google_112233445566 + String username = provider + "_" + oAuth2UserInfo.getProviderId(); + + // DB에서 해당 사용자를 조회 + User existingUser = userRepository.findByUsername(username).orElse(null); + + User user; + if (existingUser != null) { + // 이미 가입된 사용자인 경우, 기존 정보를 그대로 사용 + user = existingUser; + } else { + // 신규 사용자인 경우, 자동 회원가입 진행 + String randomPassword = passwordEncoder.encode(UUID.randomUUID().toString()); + user = User.builder() + .username(username) + .name(oAuth2UserInfo.getName()) + .password(randomPassword) // 소셜 로그인이므로 비밀번호는 임의의 값으로 설정 + .role(Role.ROLE_USER) + .provider(oAuth2UserInfo.getProvider()) + .providerId(oAuth2UserInfo.getProviderId()) + .build(); + userRepository.save(user); + } + + // 우리 시스템에서 사용할 CustomUserDetails 객체로 변환하여 반환 + return new CustomUserDetails(user, oAuth2User.getAttributes()); + } + + private static OAuth2UserInfo getOAuth2UserInfo(String provider, OAuth2User oAuth2User) { + OAuth2UserInfo oAuth2UserInfo; + if (provider.equals("google")) { + oAuth2UserInfo = new GoogleUserInfo(oAuth2User.getAttributes()); + } else if (provider.equals("naver")) { + oAuth2UserInfo = new NaverUserInfo(oAuth2User.getAttributes()); + } else if (provider.equals("kakao")) { + oAuth2UserInfo = new KakaoUserInfo(oAuth2User.getAttributes()); + } else { + // 지원하지 않는 제공자일 경우 예외 처리 + throw new OAuth2AuthenticationException("지원하지 않는 소셜 로그인입니다."); + } + return oAuth2UserInfo; + } +} From 2b5ea5ec77ba757ee6a6f0d031502e28e8b6dece Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=8A=B9=EC=96=B8?= Date: Tue, 9 Dec 2025 15:12:51 +0900 Subject: [PATCH 21/57] =?UTF-8?q?[Feat]=20QueryDSL=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../techpost/global/config/QueryDSLConfig.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 src/main/java/com/ureka/techpost/global/config/QueryDSLConfig.java diff --git a/src/main/java/com/ureka/techpost/global/config/QueryDSLConfig.java b/src/main/java/com/ureka/techpost/global/config/QueryDSLConfig.java new file mode 100644 index 0000000..41d21a7 --- /dev/null +++ b/src/main/java/com/ureka/techpost/global/config/QueryDSLConfig.java @@ -0,0 +1,18 @@ +package com.ureka.techpost.global.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QueryDSLConfig { + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} From 76202bec4fdf42c5f0abb487a916b73c5cda7869 Mon Sep 17 00:00:00 2001 From: k0081915 Date: Tue, 9 Dec 2025 15:28:39 +0900 Subject: [PATCH 22/57] =?UTF-8?q?[Feat]=20=EC=86=8C=EC=85=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=84=B1=EA=B3=B5=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/OAuth2LoginSuccessHandler.java | 60 +++++++++++++++++++ .../global/config/SecurityConfig.java | 12 +++- 2 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/ureka/techpost/domain/auth/handler/OAuth2LoginSuccessHandler.java diff --git a/src/main/java/com/ureka/techpost/domain/auth/handler/OAuth2LoginSuccessHandler.java b/src/main/java/com/ureka/techpost/domain/auth/handler/OAuth2LoginSuccessHandler.java new file mode 100644 index 0000000..7c2f51c --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/handler/OAuth2LoginSuccessHandler.java @@ -0,0 +1,60 @@ +package com.ureka.techpost.domain.auth.handler; + + +import com.ureka.techpost.domain.auth.dto.CustomUserDetails; +import com.ureka.techpost.domain.auth.jwt.JwtUtil; +import com.ureka.techpost.domain.auth.service.TokenService; +import com.ureka.techpost.domain.user.entity.User; +import com.ureka.techpost.domain.user.repository.UserRepository; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; +import java.util.Collection; +import java.util.Iterator; + +@Component +@RequiredArgsConstructor +public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final JwtUtil jwtUtil; + private final TokenService tokenService; + private final UserRepository userRepository; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { + CustomUserDetails oAuth2User = (CustomUserDetails) authentication.getPrincipal(); + + String username = oAuth2User.getUsername(); + Collection authorities = oAuth2User.getAuthorities(); + Iterator iterator = authorities.iterator(); + GrantedAuthority auth = iterator.next(); + String role = auth.getAuthority(); + + // 우리 시스템의 JWT 토큰 생성 + String access = jwtUtil.generateAccessToken("access", username, role); + String refresh = jwtUtil.generateRefreshToken("refresh"); + + // 리프레시 토큰 저장 및 쿠키에 추가 + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("해당하는 유저를 찾을 수 없습니다.")); + tokenService.addRefreshToken(user, refresh); + response.addCookie(tokenService.createCookie("refresh", refresh)); + + // 액세스 토큰을 쿼리 파라미터에 담아 프론트엔드 URL로 리다이렉트 + // vue.js 에서 지원하는 포트 번호로 변경해야 함 + String targetUrl = UriComponentsBuilder.fromUriString("http://localhost:5173/") + .queryParam("accessToken", access) + .build().toUriString(); + + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } +} \ No newline at end of file diff --git a/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java b/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java index 4e5c3bd..b96f04e 100644 --- a/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java +++ b/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java @@ -1,8 +1,10 @@ package com.ureka.techpost.global.config; import com.ureka.techpost.domain.auth.handler.CustomLogoutHandler; +import com.ureka.techpost.domain.auth.handler.OAuth2LoginSuccessHandler; import com.ureka.techpost.domain.auth.jwt.JwtAuthenticationFilter; import com.ureka.techpost.domain.auth.jwt.JwtUtil; +import com.ureka.techpost.domain.auth.service.CustomOAuth2UserService; import com.ureka.techpost.domain.auth.service.TokenService; import com.ureka.techpost.domain.user.repository.UserRepository; import jakarta.servlet.http.HttpServletResponse; @@ -34,8 +36,8 @@ public class SecurityConfig { private final UserRepository userRepository; private final TokenService tokenService; private final CustomLogoutHandler customLogoutHandler; -// private final CustomOAuth2UserService customOAuth2UserService; -// private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; + private final CustomOAuth2UserService customOAuth2UserService; + private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; static final String[] WHITE_LIST = {"/swagger-ui/**", "/v3/api-docs/**", @@ -81,6 +83,12 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .anyRequest().permitAll() ) + .oauth2Login(oauth2 -> oauth2 + .userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig + .userService(customOAuth2UserService)) + .successHandler(oAuth2LoginSuccessHandler) + ) + .logout(logout -> logout .logoutUrl("/api/auth/logout") .addLogoutHandler(customLogoutHandler) From 5bbd531a4be2844bc5066aff25393ddbcc083c9a Mon Sep 17 00:00:00 2001 From: k0081915 Date: Tue, 9 Dec 2025 15:35:21 +0900 Subject: [PATCH 23/57] =?UTF-8?q?[Style]=20=ED=81=B4=EB=9E=98=EC=8A=A4=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=A3=BC=EC=84=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/handler/OAuth2LoginSuccessHandler.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/java/com/ureka/techpost/domain/auth/handler/OAuth2LoginSuccessHandler.java b/src/main/java/com/ureka/techpost/domain/auth/handler/OAuth2LoginSuccessHandler.java index 7c2f51c..1b66ca8 100644 --- a/src/main/java/com/ureka/techpost/domain/auth/handler/OAuth2LoginSuccessHandler.java +++ b/src/main/java/com/ureka/techpost/domain/auth/handler/OAuth2LoginSuccessHandler.java @@ -21,6 +21,13 @@ import java.util.Collection; import java.util.Iterator; +/** + * @file OAuth2LoginSuccessHandler.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-09 + @description 이 파일은 소셜 로그인 성공 로직을 수행하는 클래스입니다. + */ @Component @RequiredArgsConstructor public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { From dad03837b56346bd44a6fed5567b0a354e66ccec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=8A=B9=EC=96=B8?= Date: Tue, 9 Dec 2025 15:37:17 +0900 Subject: [PATCH 24/57] =?UTF-8?q?[Docs]=20=EA=B2=8C=EC=8B=9C=EA=B8=80(Post?= =?UTF-8?q?)=20API=20=EC=8A=A4=EC=9B=A8=EA=B1=B0(Swagger)=20=EB=AA=85?= =?UTF-8?q?=EC=84=B8=20=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/controller/PostController.java | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/ureka/techpost/domain/post/controller/PostController.java b/src/main/java/com/ureka/techpost/domain/post/controller/PostController.java index 857f204..b53d0d0 100644 --- a/src/main/java/com/ureka/techpost/domain/post/controller/PostController.java +++ b/src/main/java/com/ureka/techpost/domain/post/controller/PostController.java @@ -5,7 +5,10 @@ import com.ureka.techpost.domain.post.dto.PostRequestDTO; import com.ureka.techpost.domain.post.service.PostService; import com.ureka.techpost.global.apiPayload.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; @@ -28,38 +31,41 @@ public class PostController { private final PostService postService; + @Operation(summary = "게시글 등록", description = "제목, 내용, 링크 등을 받아 게시글을 등록합니다.") @PostMapping("") public ApiResponse createPost(@RequestBody PostRequestDTO postRequestDTO, - @AuthenticationPrincipal CustomUserDetails userDetails){ + @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails){ postService.save(postRequestDTO, userDetails); return ApiResponse.onSuccess("게시글 등록 성공"); } - // 게시물 목록 가져오기 & 게시물 검색해서 목록 가져오기 + @Operation(summary = "게시글 목록 조회/검색", description = "키워드와 출처로 검색하거나 전체 목록을 페이징하여 조회합니다.") @GetMapping("") public ApiResponse> searchPosts( - @RequestParam(required = false) String keyword, - @RequestParam(required = false) String publisher, - @PageableDefault(size = 10, sort = "id", direction = Sort.Direction.DESC)Pageable pageable + @Parameter(description = "검색할 키워드 (제목/요약)") @RequestParam(required = false) String keyword, + @Parameter(description = "출처 필터링 (예: Velog)") @RequestParam(required = false) String publisher, + @ParameterObject @PageableDefault(size = 10, sort = "id", direction = Sort.Direction.DESC)Pageable pageable ){ return ApiResponse.onSuccess(postService.search(keyword, publisher, pageable)); } + @Operation(summary = "게시글 상세 조회", description = "게시글 ID(PK)를 이용하여 특정 게시글의 상세 정보를 조회합니다.") @GetMapping("/{postId}") - public ApiResponse getPost(@PathVariable Long postId){ + public ApiResponse getPost(@Parameter(description = "조회할 게시글의 ID") @PathVariable Long postId){ return ApiResponse.onSuccess(postService.findById(postId)); } + @Operation(summary = "게시글 삭제", description = "게시글 ID와 로그인한 유저 정보를 비교하여 본인의 게시글을 삭제합니다.") @DeleteMapping("/{postId}") - public ApiResponse deletePost(@PathVariable Long postId, - @AuthenticationPrincipal CustomUserDetails userDetails){ + public ApiResponse deletePost(@Parameter(description = "삭제할 게시글의 ID") @PathVariable Long postId, + @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails){ postService.deletePost(postId, userDetails); return ApiResponse.onSuccess("게시물 삭제 성공"); } -} +} \ No newline at end of file From 51a11f3d6e33c0b70a22430c1f525a18d8e18d16 Mon Sep 17 00:00:00 2001 From: Jae Date: Tue, 9 Dec 2025 16:14:47 +0900 Subject: [PATCH 25/57] =?UTF-8?q?[Feat]=20=EA=B7=B8=EB=A3=B9=20=EC=B1=84?= =?UTF-8?q?=ED=8C=85=EB=B0=A9=20=EC=83=9D=EC=84=B1,=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=EB=B0=A9=20=EB=A9=94=EC=84=B8=EC=A7=80=20=EB=82=B4=EC=97=AD=20?= =?UTF-8?q?=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/controller/ChatController.java | 13 ++++- .../chat/dto/response/ChatMessageRes.java | 24 ++++++++++ .../domain/chat/entity/ChatMessage.java | 3 +- .../domain/chat/entity/ChatParticipant.java | 10 ++-- .../techpost/domain/chat/entity/ChatRoom.java | 3 +- .../repository/ChatMessageRepository.java | 6 ++- .../repository/ChatParticipantRepository.java | 10 ++++ .../domain/chat/service/ChatService.java | 47 +++++++++++++++++-- .../websocket/StompChannelInterceptor.java | 2 +- 9 files changed, 105 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/ureka/techpost/domain/chat/dto/response/ChatMessageRes.java create mode 100644 src/main/java/com/ureka/techpost/domain/chat/repository/ChatParticipantRepository.java diff --git a/src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java b/src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java index 3f31dcb..8cb964a 100644 --- a/src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java +++ b/src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java @@ -8,12 +8,16 @@ package com.ureka.techpost.domain.chat.controller; +import com.ureka.techpost.domain.auth.dto.CustomUserDetails; +import com.ureka.techpost.domain.chat.dto.response.ChatMessageRes; import com.ureka.techpost.domain.chat.dto.response.ChatRoomRes; import com.ureka.techpost.domain.chat.service.ChatService; import com.ureka.techpost.global.apiPayload.ApiResponse; import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -31,8 +35,13 @@ public ApiResponse> getChatRoomList() { } @PostMapping("/room/group/create") - public ApiResponse createGroupChatRoom(@RequestParam String roomName){ - chatService.createGroupChatRoom(roomName); + public ApiResponse createGroupChatRoom(@RequestParam String roomName, @AuthenticationPrincipal CustomUserDetails userDetails){ + chatService.createGroupChatRoom(roomName, userDetails); return ApiResponse.onSuccess(null); } + + @GetMapping("/history/{roomId}") + public ApiResponse> getChatHistory(@PathVariable Long roomId, @AuthenticationPrincipal CustomUserDetails userDetails) { + return ApiResponse.onSuccess(chatService.getChatHistory(roomId, userDetails)); + } } diff --git a/src/main/java/com/ureka/techpost/domain/chat/dto/response/ChatMessageRes.java b/src/main/java/com/ureka/techpost/domain/chat/dto/response/ChatMessageRes.java new file mode 100644 index 0000000..2b6a716 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/chat/dto/response/ChatMessageRes.java @@ -0,0 +1,24 @@ +/** + * @file ChatMessageRes.java + @author 이재, 강승우 + @version 1.0 + @since 2025-12-09 + @description 이 파일은 채팅 내역 불러오기 시 사용되는 Response Dto 클래스입니다. + */ + +package com.ureka.techpost.domain.chat.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Getter +public class ChatMessageRes { + + private String message; + private String senderName; +} \ No newline at end of file diff --git a/src/main/java/com/ureka/techpost/domain/chat/entity/ChatMessage.java b/src/main/java/com/ureka/techpost/domain/chat/entity/ChatMessage.java index 422d83c..bfad5b8 100644 --- a/src/main/java/com/ureka/techpost/domain/chat/entity/ChatMessage.java +++ b/src/main/java/com/ureka/techpost/domain/chat/entity/ChatMessage.java @@ -9,6 +9,7 @@ package com.ureka.techpost.domain.chat.entity; import com.ureka.techpost.domain.user.entity.User; +import com.ureka.techpost.global.entity.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -27,7 +28,7 @@ @Builder @Getter @Entity -public class ChatMessage { +public class ChatMessage extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; diff --git a/src/main/java/com/ureka/techpost/domain/chat/entity/ChatParticipant.java b/src/main/java/com/ureka/techpost/domain/chat/entity/ChatParticipant.java index 92891d0..423f529 100644 --- a/src/main/java/com/ureka/techpost/domain/chat/entity/ChatParticipant.java +++ b/src/main/java/com/ureka/techpost/domain/chat/entity/ChatParticipant.java @@ -8,6 +8,8 @@ package com.ureka.techpost.domain.chat.entity; +import com.ureka.techpost.domain.user.entity.User; +import com.ureka.techpost.global.entity.BaseEntity; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; @@ -25,7 +27,7 @@ @Builder @Getter @Entity -public class ChatParticipant { +public class ChatParticipant extends BaseEntity { @Id @GeneratedValue(strategy= GenerationType.IDENTITY) private Long id; @@ -34,7 +36,7 @@ public class ChatParticipant { @JoinColumn(name = "chat_room_id", nullable = false) private ChatRoom chatRoom; -// @ManyToOne(fetch = FetchType.LAZY) -// @JoinColumn(name = "member_id", nullable = false) -// private Member member; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; } diff --git a/src/main/java/com/ureka/techpost/domain/chat/entity/ChatRoom.java b/src/main/java/com/ureka/techpost/domain/chat/entity/ChatRoom.java index 1e64c3e..646b372 100644 --- a/src/main/java/com/ureka/techpost/domain/chat/entity/ChatRoom.java +++ b/src/main/java/com/ureka/techpost/domain/chat/entity/ChatRoom.java @@ -8,6 +8,7 @@ package com.ureka.techpost.domain.chat.entity; +import com.ureka.techpost.global.entity.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -23,7 +24,7 @@ @Builder @Getter @Entity -public class ChatRoom { +public class ChatRoom extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; diff --git a/src/main/java/com/ureka/techpost/domain/chat/repository/ChatMessageRepository.java b/src/main/java/com/ureka/techpost/domain/chat/repository/ChatMessageRepository.java index ab36346..ccaf63d 100644 --- a/src/main/java/com/ureka/techpost/domain/chat/repository/ChatMessageRepository.java +++ b/src/main/java/com/ureka/techpost/domain/chat/repository/ChatMessageRepository.java @@ -9,8 +9,12 @@ package com.ureka.techpost.domain.chat.repository; import com.ureka.techpost.domain.chat.entity.ChatMessage; +import com.ureka.techpost.domain.chat.entity.ChatRoom; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +@Repository public interface ChatMessageRepository extends JpaRepository { - + List findByChatRoomOrderByCreatedAtAsc(ChatRoom chatRoom); // 생성 시간 오름차순으로 정렬 } diff --git a/src/main/java/com/ureka/techpost/domain/chat/repository/ChatParticipantRepository.java b/src/main/java/com/ureka/techpost/domain/chat/repository/ChatParticipantRepository.java new file mode 100644 index 0000000..8106035 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/chat/repository/ChatParticipantRepository.java @@ -0,0 +1,10 @@ +package com.ureka.techpost.domain.chat.repository; + +import com.ureka.techpost.domain.chat.entity.ChatParticipant; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ChatParticipantRepository extends JpaRepository { + boolean existsByChatRoom_IdAndUser_UserId(Long chatRoomId, Long userId); +} diff --git a/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java b/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java index eceb16a..40203b8 100644 --- a/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java +++ b/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java @@ -10,10 +10,13 @@ import com.ureka.techpost.domain.auth.dto.CustomUserDetails; import com.ureka.techpost.domain.chat.dto.request.ChatMessageReq; +import com.ureka.techpost.domain.chat.dto.response.ChatMessageRes; import com.ureka.techpost.domain.chat.dto.response.ChatRoomRes; import com.ureka.techpost.domain.chat.entity.ChatMessage; +import com.ureka.techpost.domain.chat.entity.ChatParticipant; import com.ureka.techpost.domain.chat.entity.ChatRoom; import com.ureka.techpost.domain.chat.repository.ChatMessageRepository; +import com.ureka.techpost.domain.chat.repository.ChatParticipantRepository; import com.ureka.techpost.domain.chat.repository.ChatRoomRepository; import com.ureka.techpost.domain.user.entity.User; import com.ureka.techpost.domain.user.repository.UserRepository; @@ -22,8 +25,6 @@ import java.util.ArrayList; import java.util.List; import lombok.RequiredArgsConstructor; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; @RequiredArgsConstructor @@ -32,6 +33,7 @@ public class ChatService { private final ChatRoomRepository chatRoomRepository; private final ChatMessageRepository chatMessageRepository; + private final ChatParticipantRepository chatParticipantRepository; private final UserRepository userRepository; public List getChatRoomList() { @@ -50,7 +52,7 @@ public void saveMessage(Long roomId, Long userId, ChatMessageReq chatMessageReq) .orElseThrow(() -> new EntityNotFoundException("room cannot be found")); User sender = userRepository.findById(userId) - .orElseThrow(() -> new EntityNotFoundException("member cannot be found")); + .orElseThrow(() -> new EntityNotFoundException("user cannot be found")); ChatMessage chatMessage = ChatMessage.builder() .chatRoom(chatRoom) @@ -60,4 +62,43 @@ public void saveMessage(Long roomId, Long userId, ChatMessageReq chatMessageReq) chatMessageRepository.save(chatMessage); } + + public void createGroupChatRoom(String roomName, CustomUserDetails userDetails) { + Long userId = userDetails.getUser().getUserId(); + User user = userRepository.findById(userId).orElseThrow(() -> new EntityNotFoundException("user cannot be found")); + + ChatRoom chatRoom = ChatRoom.builder() + .roomName(roomName) + .build(); + chatRoomRepository.save(chatRoom); + + ChatParticipant chatParticipant = ChatParticipant.builder() + .chatRoom(chatRoom) + .user(user) + .build(); + chatParticipantRepository.save(chatParticipant); + } + + public List getChatHistory(Long roomId, CustomUserDetails userDetails) { + ChatRoom chatRoom = chatRoomRepository.findById(roomId) + .orElseThrow(() -> new EntityNotFoundException("room cannot be found")); + + Long userId = userDetails.getUser().getUserId(); + + boolean isParticipant = chatParticipantRepository.existsByChatRoom_IdAndUser_UserId(roomId, userId); + if(!isParticipant) throw new IllegalArgumentException("속하지 않은 채팅방입니다."); + + List chatMessages = chatMessageRepository.findByChatRoomOrderByCreatedAtAsc(chatRoom); + List chatMessageResList = new ArrayList<>(); + + for (ChatMessage chatMessage : chatMessages) { + ChatMessageRes chatMessageRes = ChatMessageRes.builder() + .message(chatMessage.getContent()) + .senderName(chatMessage.getUser().getName()) + .build(); + chatMessageResList.add(chatMessageRes); + } + + return chatMessageResList; + } } \ No newline at end of file diff --git a/src/main/java/com/ureka/techpost/global/config/websocket/StompChannelInterceptor.java b/src/main/java/com/ureka/techpost/global/config/websocket/StompChannelInterceptor.java index 649fe05..12cd00d 100644 --- a/src/main/java/com/ureka/techpost/global/config/websocket/StompChannelInterceptor.java +++ b/src/main/java/com/ureka/techpost/global/config/websocket/StompChannelInterceptor.java @@ -36,7 +36,7 @@ public class StompChannelInterceptor implements ChannelInterceptor { // application.yaml에 정의된 jwt.secretKey 값을 가져와 필드에 주입 - @Value("${jwt.secretKey}") + @Value("${jwt.secret}") private String secretKey; private SecretKey getSigningKey() { From 2aa3fe9989d6ef5c176a6740e81fe7cf3307023a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=8A=B9=EC=96=B8?= Date: Tue, 9 Dec 2025 16:37:31 +0900 Subject: [PATCH 26/57] =?UTF-8?q?[Docs]=20=EB=AC=B8=EC=84=9C=20=EB=82=A0?= =?UTF-8?q?=EC=A7=9C=20=EC=98=A4=ED=83=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../techpost/domain/post/repository/PostRepositoryImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/ureka/techpost/domain/post/repository/PostRepositoryImpl.java b/src/main/java/com/ureka/techpost/domain/post/repository/PostRepositoryImpl.java index c7e2ce1..4cae8f4 100644 --- a/src/main/java/com/ureka/techpost/domain/post/repository/PostRepositoryImpl.java +++ b/src/main/java/com/ureka/techpost/domain/post/repository/PostRepositoryImpl.java @@ -21,7 +21,7 @@ * @file PostRepositoryImpl.java * @author 최승언 * @version 1.0 - * @since 2025-01-01 + * @since 2025-12-09 * @description QueryDSL을 활용하여 게시글 검색, 필터링 등 복잡한 조회 로직을 실제로 구현한 클래스입니다. */ From 2a5d8ecd455fa7c2d9a583468ef707f6488b25d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=8A=B9=EC=96=B8?= Date: Tue, 9 Dec 2025 16:39:37 +0900 Subject: [PATCH 27/57] =?UTF-8?q?[Style]=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/ureka/techpost/domain/post/entity/Post.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/ureka/techpost/domain/post/entity/Post.java b/src/main/java/com/ureka/techpost/domain/post/entity/Post.java index e23d519..9cacfcb 100644 --- a/src/main/java/com/ureka/techpost/domain/post/entity/Post.java +++ b/src/main/java/com/ureka/techpost/domain/post/entity/Post.java @@ -21,7 +21,6 @@ @Table(name = "post") @Getter @NoArgsConstructor -@EntityListeners(AuditingEntityListener.class) public class Post extends BaseEntity { @Id From cdba495d06ac5a07b92ff727250caa84bde07aad Mon Sep 17 00:00:00 2001 From: bon0512 Date: Tue, 9 Dec 2025 16:54:48 +0900 Subject: [PATCH 28/57] =?UTF-8?q?[Feat]=20Validation=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=20#13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../techpost/domain/auth/dto/SignupDtoValidationTest.java | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 src/test/java/com/ureka/techpost/domain/auth/dto/SignupDtoValidationTest.java diff --git a/src/test/java/com/ureka/techpost/domain/auth/dto/SignupDtoValidationTest.java b/src/test/java/com/ureka/techpost/domain/auth/dto/SignupDtoValidationTest.java new file mode 100644 index 0000000..7f939bf --- /dev/null +++ b/src/test/java/com/ureka/techpost/domain/auth/dto/SignupDtoValidationTest.java @@ -0,0 +1,4 @@ +package com.ureka.techpost.domain.auth.dto; + +public class SignupDtoValidationTest { +} From 0b2fa32f8926c65f66ca79246aaed9941009dc57 Mon Sep 17 00:00:00 2001 From: bon0512 Date: Tue, 9 Dec 2025 17:03:40 +0900 Subject: [PATCH 29/57] =?UTF-8?q?[Feat]=20Validation=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=20#13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 5 +- .../techpost/domain/auth/dto/LoginDto.java | 3 + .../techpost/domain/auth/dto/SignupDto.java | 14 +++++ .../auth/dto/SignupDtoValidationTest.java | 63 +++++++++++++++++++ 4 files changed, 83 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/ureka/techpost/domain/auth/controller/AuthController.java b/src/main/java/com/ureka/techpost/domain/auth/controller/AuthController.java index 830c413..de922ef 100644 --- a/src/main/java/com/ureka/techpost/domain/auth/controller/AuthController.java +++ b/src/main/java/com/ureka/techpost/domain/auth/controller/AuthController.java @@ -6,6 +6,7 @@ import com.ureka.techpost.global.apiPayload.ApiResponse; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -25,7 +26,7 @@ public class AuthController { private final AuthService authService; @PostMapping("/signup") - public ApiResponse signup(@RequestBody SignupDto signupDto) { + public ApiResponse signup(@Valid @RequestBody SignupDto signupDto) { authService.signup(signupDto); return ApiResponse.onSuccess("회원가입 성공"); } @@ -36,7 +37,7 @@ public ApiResponse reissue(HttpServletRequest request, HttpServletResponse re } @PostMapping("/login") - public ApiResponse login(@RequestBody LoginDto loginDto, HttpServletResponse response) { + public ApiResponse login(@Valid @RequestBody LoginDto loginDto, HttpServletResponse response) { authService.login(loginDto, response); return ApiResponse.onSuccess("로그인 성공"); } diff --git a/src/main/java/com/ureka/techpost/domain/auth/dto/LoginDto.java b/src/main/java/com/ureka/techpost/domain/auth/dto/LoginDto.java index 19d1452..821d9dd 100644 --- a/src/main/java/com/ureka/techpost/domain/auth/dto/LoginDto.java +++ b/src/main/java/com/ureka/techpost/domain/auth/dto/LoginDto.java @@ -1,5 +1,6 @@ package com.ureka.techpost.domain.auth.dto; +import jakarta.validation.constraints.NotBlank; import lombok.Data; /** @@ -12,6 +13,8 @@ @Data public class LoginDto { + @NotBlank(message = "아이디는 필수입니다.") private String username; + @NotBlank(message = "비밀번호는 필수입니다.") private String password; } diff --git a/src/main/java/com/ureka/techpost/domain/auth/dto/SignupDto.java b/src/main/java/com/ureka/techpost/domain/auth/dto/SignupDto.java index cb3f64b..6d1d33a 100644 --- a/src/main/java/com/ureka/techpost/domain/auth/dto/SignupDto.java +++ b/src/main/java/com/ureka/techpost/domain/auth/dto/SignupDto.java @@ -2,6 +2,9 @@ import com.ureka.techpost.domain.user.entity.User; import com.ureka.techpost.domain.user.enums.Role; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; import lombok.Data; /** @@ -14,8 +17,19 @@ @Data public class SignupDto { + + @NotBlank(message = "아이디는 필수입니다.") + @Size(min = 4, max = 20, message = "아이디는 4~20자여야 합니다.") + @Pattern( regexp = "^[a-zA-Z0-9]+$", message = "아이디는 영문과 숫자만 사용할 수 있습니다." ) private String username; + + @NotBlank(message = "비밀번호는 필수입니다.") + @Size(min = 8, max = 20, message = "비밀번호는 8~20자여야 합니다.") + @Pattern( regexp = "^(?=.*[A-Za-z])(?=.*\\d).*$", message = "비밀번호는 영문과 숫자를 포함해야 합니다." ) private String password; + + @NotBlank(message = "이름은 필수입니다.") + @Size(max = 30, message = "이름은 30자를 초과할 수 없습니다.") private String name; public User toEntity(String encodedPassword) { diff --git a/src/test/java/com/ureka/techpost/domain/auth/dto/SignupDtoValidationTest.java b/src/test/java/com/ureka/techpost/domain/auth/dto/SignupDtoValidationTest.java index 7f939bf..13e65fb 100644 --- a/src/test/java/com/ureka/techpost/domain/auth/dto/SignupDtoValidationTest.java +++ b/src/test/java/com/ureka/techpost/domain/auth/dto/SignupDtoValidationTest.java @@ -1,4 +1,67 @@ package com.ureka.techpost.domain.auth.dto; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import org.junit.jupiter.api.Test; +import jakarta.validation.Validator; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + public class SignupDtoValidationTest { + private final Validator validator = + Validation.buildDefaultValidatorFactory().getValidator(); + + @Test + void signup_username_blank_then_violation() { + SignupDto dto = new SignupDto(); + dto.setUsername(""); + dto.setPassword("password1"); + dto.setName("홍길동"); + + Set> violations = + validator.validate(dto); + + assertFalse(violations.isEmpty()); + } + + @Test + void signup_valid_then_no_violation() { + SignupDto dto = new SignupDto(); + dto.setUsername("testuser"); + dto.setPassword("password1"); + dto.setName("홍길동"); + + Set> violations = + validator.validate(dto); + + assertTrue(violations.isEmpty()); + } + @Test + void signup_shortPassword() { + SignupDto dto = new SignupDto(); + dto.setUsername("testuser"); + dto.setPassword("pass"); + dto.setName("홍길동"); + + Set> violations = + validator.validate(dto); + + assertFalse(violations.isEmpty()); + } + + @Test + void signup_shortUsername() { + SignupDto dto = new SignupDto(); + dto.setUsername("tes"); + dto.setPassword("pass"); + dto.setName("홍길동"); + + Set> violations = + validator.validate(dto); + + assertFalse(violations.isEmpty()); + } } From 55640c5958d5a81a2f2e35d7d481fe6f8aae6b9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=8A=B9=EC=96=B8?= Date: Tue, 9 Dec 2025 17:04:57 +0900 Subject: [PATCH 30/57] =?UTF-8?q?[Refactor]=20=EA=B2=8C=EC=8B=9C=EA=B8=80?= =?UTF-8?q?=20=EB=93=B1=EB=A1=9D=20=EC=9D=91=EB=8B=B5=20=EA=B0=9C=EC=84=A0?= =?UTF-8?q?=20=EB=B0=8F=20=EC=83=81=ED=83=9C=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../techpost/domain/post/controller/PostController.java | 9 +++++---- .../ureka/techpost/global/apiPayload/ApiResponse.java | 6 +++++- .../global/apiPayload/code/status/SuccessStatus.java | 4 +++- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/ureka/techpost/domain/post/controller/PostController.java b/src/main/java/com/ureka/techpost/domain/post/controller/PostController.java index b53d0d0..7804265 100644 --- a/src/main/java/com/ureka/techpost/domain/post/controller/PostController.java +++ b/src/main/java/com/ureka/techpost/domain/post/controller/PostController.java @@ -5,6 +5,7 @@ import com.ureka.techpost.domain.post.dto.PostRequestDTO; import com.ureka.techpost.domain.post.service.PostService; import com.ureka.techpost.global.apiPayload.ApiResponse; +import com.ureka.techpost.global.apiPayload.code.status.SuccessStatus; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import lombok.RequiredArgsConstructor; @@ -33,12 +34,12 @@ public class PostController { @Operation(summary = "게시글 등록", description = "제목, 내용, 링크 등을 받아 게시글을 등록합니다.") @PostMapping("") - public ApiResponse createPost(@RequestBody PostRequestDTO postRequestDTO, + public ApiResponse createPost(@RequestBody PostRequestDTO postRequestDTO, @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails){ postService.save(postRequestDTO, userDetails); - return ApiResponse.onSuccess("게시글 등록 성공"); + return ApiResponse.of(SuccessStatus._CREATED, null); } @Operation(summary = "게시글 목록 조회/검색", description = "키워드와 출처로 검색하거나 전체 목록을 페이징하여 조회합니다.") @@ -60,12 +61,12 @@ public ApiResponse getPost(@Parameter(description = "조회할 @Operation(summary = "게시글 삭제", description = "게시글 ID와 로그인한 유저 정보를 비교하여 본인의 게시글을 삭제합니다.") @DeleteMapping("/{postId}") - public ApiResponse deletePost(@Parameter(description = "삭제할 게시글의 ID") @PathVariable Long postId, + public ApiResponse deletePost(@Parameter(description = "삭제할 게시글의 ID") @PathVariable Long postId, @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails){ postService.deletePost(postId, userDetails); - return ApiResponse.onSuccess("게시물 삭제 성공"); + return ApiResponse.of(SuccessStatus._NO_CONTENT, null); } } \ No newline at end of file diff --git a/src/main/java/com/ureka/techpost/global/apiPayload/ApiResponse.java b/src/main/java/com/ureka/techpost/global/apiPayload/ApiResponse.java index 6c274a5..93d748d 100644 --- a/src/main/java/com/ureka/techpost/global/apiPayload/ApiResponse.java +++ b/src/main/java/com/ureka/techpost/global/apiPayload/ApiResponse.java @@ -32,12 +32,16 @@ public static ApiResponse onSuccess(T result){ return new ApiResponse<>(true, SuccessStatus._OK.getCode() , SuccessStatus._OK.getMessage(), result); } + // Created 응답 생성 -> 201 + public static ApiResponse onCreated(T result) { + return of(SuccessStatus._CREATED, null); + } + // 커스텀 성공 응답 -> ex) 201 created, 202 Accepted, 204 No content public static ApiResponse of(BaseCode code, T result){ return new ApiResponse<>(true, code.getReasonHttpStatus().getCode() , code.getReasonHttpStatus().getMessage(), result); } - // 실패한 경우 응답 생성 public static ApiResponse onFailure(String code, String message, T data){ return new ApiResponse<>(false, code, message, data); diff --git a/src/main/java/com/ureka/techpost/global/apiPayload/code/status/SuccessStatus.java b/src/main/java/com/ureka/techpost/global/apiPayload/code/status/SuccessStatus.java index 74453c3..4659f64 100644 --- a/src/main/java/com/ureka/techpost/global/apiPayload/code/status/SuccessStatus.java +++ b/src/main/java/com/ureka/techpost/global/apiPayload/code/status/SuccessStatus.java @@ -11,7 +11,9 @@ public enum SuccessStatus implements BaseCode { // 일반적인 응답 - _OK(HttpStatus.OK, "COMMON200", "성공입니다."); + _OK(HttpStatus.OK, "COMMON200", "성공입니다."), + _CREATED(HttpStatus.CREATED, "COMMON201", "자원이 성공적으로 생성되었습니다."), + _NO_CONTENT(HttpStatus.NO_CONTENT, "COMMON204", "삭제 성공");; private final HttpStatus httpStatus; // http 상태코드 private final String code; // 상태 코드 설명 From 5e2c190026002b3a4dfbab0a7849b59bb0f95c8c Mon Sep 17 00:00:00 2001 From: Jae Date: Tue, 9 Dec 2025 17:22:03 +0900 Subject: [PATCH 31/57] =?UTF-8?q?[Fix]=20=EB=A6=AC=EB=B7=B0=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=82=AC=ED=95=AD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ureka/techpost/domain/chat/controller/ChatController.java | 2 +- .../techpost/domain/chat/controller/StompController.java | 2 +- .../techpost/domain/chat/dto/response/ChatMessageRes.java | 2 -- .../global/config/websocket/StompChannelInterceptor.java | 4 ---- 4 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java b/src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java index 8cb964a..7e6f353 100644 --- a/src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java +++ b/src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java @@ -34,7 +34,7 @@ public ApiResponse> getChatRoomList() { return ApiResponse.onSuccess(chatService.getChatRoomList()); } - @PostMapping("/room/group/create") + @PostMapping public ApiResponse createGroupChatRoom(@RequestParam String roomName, @AuthenticationPrincipal CustomUserDetails userDetails){ chatService.createGroupChatRoom(roomName, userDetails); return ApiResponse.onSuccess(null); diff --git a/src/main/java/com/ureka/techpost/domain/chat/controller/StompController.java b/src/main/java/com/ureka/techpost/domain/chat/controller/StompController.java index cb24c0f..8534674 100644 --- a/src/main/java/com/ureka/techpost/domain/chat/controller/StompController.java +++ b/src/main/java/com/ureka/techpost/domain/chat/controller/StompController.java @@ -29,7 +29,7 @@ public class StompController { private final ChatService chatService; @MessageMapping("/{roomId}") - public void sendMessage(@DestinationVariable Long roomId, @AuthenticationPrincipal CustomUserDetails userDetails, @Payload @Valid ChatMessageReq chatMessageReq) throws JsonProcessingException { + public void sendMessage(@DestinationVariable Long roomId, @AuthenticationPrincipal CustomUserDetails userDetails, @Payload @Valid ChatMessageReq chatMessageReq) { Long userId = userDetails.getUser().getUserId(); chatService.saveMessage(roomId, userId, chatMessageReq); diff --git a/src/main/java/com/ureka/techpost/domain/chat/dto/response/ChatMessageRes.java b/src/main/java/com/ureka/techpost/domain/chat/dto/response/ChatMessageRes.java index 2b6a716..80d7b0a 100644 --- a/src/main/java/com/ureka/techpost/domain/chat/dto/response/ChatMessageRes.java +++ b/src/main/java/com/ureka/techpost/domain/chat/dto/response/ChatMessageRes.java @@ -11,9 +11,7 @@ import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; -import lombok.NoArgsConstructor; -@NoArgsConstructor @AllArgsConstructor @Builder @Getter diff --git a/src/main/java/com/ureka/techpost/global/config/websocket/StompChannelInterceptor.java b/src/main/java/com/ureka/techpost/global/config/websocket/StompChannelInterceptor.java index 12cd00d..d349da1 100644 --- a/src/main/java/com/ureka/techpost/global/config/websocket/StompChannelInterceptor.java +++ b/src/main/java/com/ureka/techpost/global/config/websocket/StompChannelInterceptor.java @@ -10,11 +10,9 @@ import com.ureka.techpost.domain.auth.dto.CustomUserDetails; import com.ureka.techpost.domain.auth.service.CustomUserDetailsService; -import com.ureka.techpost.domain.chat.service.ChatService; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; -import java.nio.charset.StandardCharsets; import javax.crypto.SecretKey; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; @@ -23,7 +21,6 @@ import org.springframework.messaging.simp.stomp.StompCommand; import org.springframework.messaging.simp.stomp.StompHeaderAccessor; import org.springframework.messaging.support.ChannelInterceptor; -import org.springframework.security.authentication.AuthenticationServiceException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Component; @@ -43,7 +40,6 @@ private SecretKey getSigningKey() { return Keys.hmacShaKeyFor(secretKey.getBytes()); } - private final ChatService chatService; private final CustomUserDetailsService userDetailsService; // connect, subscribe, disconnet 하기 전에 preSend()를 무조건 거친다 From 08b52a486adddf435fe386efd27bb8774548b005 Mon Sep 17 00:00:00 2001 From: Jae Date: Tue, 9 Dec 2025 17:24:19 +0900 Subject: [PATCH 32/57] =?UTF-8?q?[Fix]=20=EC=9B=B9=EC=86=8C=EC=BC=93=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=EA=B2=BD=EB=A1=9C=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=A0=9C=EC=99=B8=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/ureka/techpost/global/config/SecurityConfig.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java b/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java index 4e5c3bd..9feca34 100644 --- a/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java +++ b/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java @@ -42,7 +42,8 @@ public class SecurityConfig { "/swagger-resources/**", "/health", "/", "/login", "/signup", "/css/**", "/js/**", "/oauth2/**", - "/api/auth/**" + "/api/auth/**", + "/connect/**" }; @Bean From 99cce50b1a3434877bd76c93c4162983dd9d5543 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Tue, 9 Dec 2025 18:39:47 +0900 Subject: [PATCH 33/57] =?UTF-8?q?[Feat]=20=EB=82=B4=EA=B0=80=20=EC=B0=B8?= =?UTF-8?q?=EC=97=AC=ED=95=9C=20=EC=B1=84=ED=8C=85=EB=B0=A9=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + .../techpost/domain/chat/controller/ChatController.java | 5 +++++ .../chat/repository/ChatParticipantRepository.java | 9 +++++++++ .../ureka/techpost/domain/chat/service/ChatService.java | 7 +++++++ 4 files changed, 22 insertions(+) diff --git a/build.gradle b/build.gradle index 3782f79..ccfe2ac 100644 --- a/build.gradle +++ b/build.gradle @@ -35,6 +35,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-websocket' // DB & Redis + runtimeOnly 'com.h2database:h2' implementation 'com.mysql:mysql-connector-j' implementation 'org.springframework.boot:spring-boot-starter-data-redis' diff --git a/src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java b/src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java index 7e6f353..85079fa 100644 --- a/src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java +++ b/src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java @@ -44,4 +44,9 @@ public ApiResponse createGroupChatRoom(@RequestParam String roomName, @Aut public ApiResponse> getChatHistory(@PathVariable Long roomId, @AuthenticationPrincipal CustomUserDetails userDetails) { return ApiResponse.onSuccess(chatService.getChatHistory(roomId, userDetails)); } + + @GetMapping("/my") + public ApiResponse> getMyChatRoomList(@AuthenticationPrincipal CustomUserDetails UserDetail) { + return ApiResponse.onSuccess(chatService.getMyChatRoomList(UserDetail)); + } } diff --git a/src/main/java/com/ureka/techpost/domain/chat/repository/ChatParticipantRepository.java b/src/main/java/com/ureka/techpost/domain/chat/repository/ChatParticipantRepository.java index 8106035..e6d1053 100644 --- a/src/main/java/com/ureka/techpost/domain/chat/repository/ChatParticipantRepository.java +++ b/src/main/java/com/ureka/techpost/domain/chat/repository/ChatParticipantRepository.java @@ -2,9 +2,18 @@ import com.ureka.techpost.domain.chat.entity.ChatParticipant; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.List; + @Repository public interface ChatParticipantRepository extends JpaRepository { boolean existsByChatRoom_IdAndUser_UserId(Long chatRoomId, Long userId); + + @Query("SELECT cp FROM ChatParticipant cp " + + "JOIN FETCH cp.chatRoom " + + "WHERE cp.user.userId = :userId") + List findAllWithChatRoomByUserId(@Param("userId") Long userId); } diff --git a/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java b/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java index 40203b8..095cefe 100644 --- a/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java +++ b/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java @@ -101,4 +101,11 @@ public List getChatHistory(Long roomId, CustomUserDetails userDe return chatMessageResList; } + + public List getMyChatRoomList(CustomUserDetails userDetails) { + return chatParticipantRepository.findAllWithChatRoomByUserId(userDetails.getUser().getUserId()) + .stream() + .map(chatParticipant -> ChatRoomRes.from(chatParticipant.getChatRoom())) + .toList(); + } } \ No newline at end of file From 05ed53495e40e570b72003f17994bebbaa1d4684 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Tue, 9 Dec 2025 18:50:06 +0900 Subject: [PATCH 34/57] =?UTF-8?q?[Feat]=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EC=9E=85=EC=9E=A5=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/chat/controller/ChatController.java | 8 ++++++++ .../techpost/domain/chat/service/ChatService.java | 14 ++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java b/src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java index 85079fa..f277084 100644 --- a/src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java +++ b/src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java @@ -14,6 +14,8 @@ import com.ureka.techpost.domain.chat.service.ChatService; import com.ureka.techpost.global.apiPayload.ApiResponse; import java.util.List; + +import com.ureka.techpost.global.apiPayload.code.BaseCode; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; @@ -49,4 +51,10 @@ public ApiResponse> getChatHistory(@PathVariable Long roomI public ApiResponse> getMyChatRoomList(@AuthenticationPrincipal CustomUserDetails UserDetail) { return ApiResponse.onSuccess(chatService.getMyChatRoomList(UserDetail)); } + + @PostMapping("/{roomId}/join") + public ApiResponse joinChatRoom(@AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable Long roomId) { + chatService.joinChatRoom(userDetails, roomId); + return ApiResponse.onSuccess(null); + } } diff --git a/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java b/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java index 095cefe..39344b4 100644 --- a/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java +++ b/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java @@ -108,4 +108,18 @@ public List getMyChatRoomList(CustomUserDetails userDetails) { .map(chatParticipant -> ChatRoomRes.from(chatParticipant.getChatRoom())) .toList(); } + + @Transactional + public void joinChatRoom(CustomUserDetails userDetails, Long roomId) { + User user = userRepository.findById(userDetails.getUser().getUserId()) + .orElseThrow(() -> new EntityNotFoundException("user cannot be found")); + ChatRoom chatRoom = chatRoomRepository.findById(roomId) + .orElseThrow(() -> new EntityNotFoundException("room cannot be found")); + + ChatParticipant chatParticipant = ChatParticipant.builder() + .chatRoom(chatRoom) + .user(user) + .build(); + chatParticipantRepository.save(chatParticipant); + } } \ No newline at end of file From 0b584354193d8a39c8c30a15d1f2ece48424214c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Tue, 9 Dec 2025 19:08:45 +0900 Subject: [PATCH 35/57] =?UTF-8?q?[Feat]=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EB=82=98=EA=B0=80=EA=B8=B0=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/chat/controller/ChatController.java | 13 +++++++------ .../repository/ChatParticipantRepository.java | 7 +++++++ .../techpost/domain/chat/service/ChatService.java | 15 +++++++++++++++ 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java b/src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java index f277084..6fceba8 100644 --- a/src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java +++ b/src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java @@ -18,12 +18,7 @@ import com.ureka.techpost.global.apiPayload.code.BaseCode; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @RequiredArgsConstructor @RestController @@ -57,4 +52,10 @@ public ApiResponse joinChatRoom(@AuthenticationPrincipal CustomUserDetails chatService.joinChatRoom(userDetails, roomId); return ApiResponse.onSuccess(null); } + + @DeleteMapping("/{roomId}/leave") + public ApiResponse leaveChatRoom(@AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable Long roomId) { + chatService.leaveChatRoom(userDetails, roomId); + return ApiResponse.onSuccess(null); + } } diff --git a/src/main/java/com/ureka/techpost/domain/chat/repository/ChatParticipantRepository.java b/src/main/java/com/ureka/techpost/domain/chat/repository/ChatParticipantRepository.java index e6d1053..0d888cd 100644 --- a/src/main/java/com/ureka/techpost/domain/chat/repository/ChatParticipantRepository.java +++ b/src/main/java/com/ureka/techpost/domain/chat/repository/ChatParticipantRepository.java @@ -1,12 +1,15 @@ package com.ureka.techpost.domain.chat.repository; import com.ureka.techpost.domain.chat.entity.ChatParticipant; +import com.ureka.techpost.domain.chat.entity.ChatRoom; +import com.ureka.techpost.domain.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; @Repository public interface ChatParticipantRepository extends JpaRepository { @@ -16,4 +19,8 @@ public interface ChatParticipantRepository extends JpaRepository findAllWithChatRoomByUserId(@Param("userId") Long userId); + + Optional findByUserAndChatRoom(User user, ChatRoom chatRoom); + + boolean existsByUserAndChatRoom(User user, ChatRoom chatRoom); } diff --git a/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java b/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java index 39344b4..6b54ed0 100644 --- a/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java +++ b/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java @@ -116,10 +116,25 @@ public void joinChatRoom(CustomUserDetails userDetails, Long roomId) { ChatRoom chatRoom = chatRoomRepository.findById(roomId) .orElseThrow(() -> new EntityNotFoundException("room cannot be found")); + if (chatParticipantRepository.existsByUserAndChatRoom(user, chatRoom)) + throw new IllegalStateException("이미 참여 중인 채팅방입니다"); + ChatParticipant chatParticipant = ChatParticipant.builder() .chatRoom(chatRoom) .user(user) .build(); chatParticipantRepository.save(chatParticipant); } + + @Transactional + public void leaveChatRoom(CustomUserDetails userDetails, Long roomId) { + User user = userRepository.findById(userDetails.getUser().getUserId()) + .orElseThrow(() -> new EntityNotFoundException("user cannot be found")); + ChatRoom chatRoom = chatRoomRepository.findById(roomId) + .orElseThrow(() -> new EntityNotFoundException("room cannot be found")); + ChatParticipant chatParticipant = chatParticipantRepository.findByUserAndChatRoom(user, chatRoom) + .orElseThrow(() -> new EntityNotFoundException("chat participant cannot be found")); + + chatParticipantRepository.delete(chatParticipant); + } } \ No newline at end of file From f5510a9e804cc8ebe68ca8517b16b1746ef268e0 Mon Sep 17 00:00:00 2001 From: oohyj Date: Wed, 10 Dec 2025 01:33:06 +0900 Subject: [PATCH 36/57] =?UTF-8?q?feat=20:=20=EC=A0=84=EC=97=AD=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/exception/CustomException.java | 22 ++++++++++++++ .../techpost/global/exception/ErrorCode.java | 20 +++++++++++++ .../global/exception/ErrorResponse.java | 29 +++++++++++++++++++ .../exception/GlobalExceptionHandler.java | 26 +++++++++++++++++ 4 files changed, 97 insertions(+) create mode 100644 src/main/java/com/ureka/techpost/global/exception/CustomException.java create mode 100644 src/main/java/com/ureka/techpost/global/exception/ErrorCode.java create mode 100644 src/main/java/com/ureka/techpost/global/exception/ErrorResponse.java create mode 100644 src/main/java/com/ureka/techpost/global/exception/GlobalExceptionHandler.java diff --git a/src/main/java/com/ureka/techpost/global/exception/CustomException.java b/src/main/java/com/ureka/techpost/global/exception/CustomException.java new file mode 100644 index 0000000..4766848 --- /dev/null +++ b/src/main/java/com/ureka/techpost/global/exception/CustomException.java @@ -0,0 +1,22 @@ +package com.ureka.techpost.global.exception; + +import lombok.Getter; + +@Getter +public class CustomException extends RuntimeException { + + private final ErrorCode errorCode; + + private String info; + + public CustomException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public CustomException(ErrorCode errorCode, String info) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + this.info = info; + } +} diff --git a/src/main/java/com/ureka/techpost/global/exception/ErrorCode.java b/src/main/java/com/ureka/techpost/global/exception/ErrorCode.java new file mode 100644 index 0000000..9056463 --- /dev/null +++ b/src/main/java/com/ureka/techpost/global/exception/ErrorCode.java @@ -0,0 +1,20 @@ +package com.ureka.techpost.global.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + + +@Getter +@AllArgsConstructor +public enum ErrorCode { + + // 예시 + //Post + POST_NOT_FOUND(HttpStatus.BAD_REQUEST, "해당 게시글을 찾을 수 없습니다."), + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND , "해당 댓글을 찾을 수 없습니다."); + + + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/com/ureka/techpost/global/exception/ErrorResponse.java b/src/main/java/com/ureka/techpost/global/exception/ErrorResponse.java new file mode 100644 index 0000000..5e98726 --- /dev/null +++ b/src/main/java/com/ureka/techpost/global/exception/ErrorResponse.java @@ -0,0 +1,29 @@ +package com.ureka.techpost.global.exception; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +@Getter +@Builder +public class ErrorResponse { + + private final HttpStatus status; + private final String code; + private final String message; + + public static ResponseEntity fromException(CustomException e) { + String message = e.getErrorCode().getMessage(); + if (e.getInfo() != null) { + message += " " + e.getInfo(); + } + return ResponseEntity + .status(e.getErrorCode().getStatus()) + .body(ErrorResponse.builder() + .status(e.getErrorCode().getStatus()) + .code(e.getErrorCode().name()) + .message(message) + .build()); + } +} diff --git a/src/main/java/com/ureka/techpost/global/exception/GlobalExceptionHandler.java b/src/main/java/com/ureka/techpost/global/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..d1f2558 --- /dev/null +++ b/src/main/java/com/ureka/techpost/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,26 @@ +package com.ureka.techpost.global.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(CustomException.class) + public ResponseEntity handleCustomException(CustomException e) { + return ErrorResponse.fromException(e); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception e) { + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ErrorResponse.builder() + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .code("INTERNAL_SERVER_ERROR") + .message("서버 내부 오류가 발생했습니다.") + .build()); + } +} From b9bda24ae5819d026cd6bbb4d8830c7c66a648c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Wed, 10 Dec 2025 08:48:24 +0900 Subject: [PATCH 37/57] =?UTF-8?q?[Fix]:=20=EB=88=84=EB=9D=BD=EB=90=9C=20?= =?UTF-8?q?=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20userDetails=20=EB=B3=80=EC=88=98?= =?UTF-8?q?=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 --- .../techpost/domain/chat/controller/ChatController.java | 8 ++++---- .../ureka/techpost/domain/chat/service/ChatService.java | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java b/src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java index 6fceba8..a2231f8 100644 --- a/src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java +++ b/src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java @@ -32,19 +32,19 @@ public ApiResponse> getChatRoomList() { } @PostMapping - public ApiResponse createGroupChatRoom(@RequestParam String roomName, @AuthenticationPrincipal CustomUserDetails userDetails){ + public ApiResponse createGroupChatRoom(@AuthenticationPrincipal CustomUserDetails userDetails, @RequestParam String roomName) { chatService.createGroupChatRoom(roomName, userDetails); return ApiResponse.onSuccess(null); } @GetMapping("/history/{roomId}") - public ApiResponse> getChatHistory(@PathVariable Long roomId, @AuthenticationPrincipal CustomUserDetails userDetails) { + public ApiResponse> getChatHistory(@AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable Long roomId) { return ApiResponse.onSuccess(chatService.getChatHistory(roomId, userDetails)); } @GetMapping("/my") - public ApiResponse> getMyChatRoomList(@AuthenticationPrincipal CustomUserDetails UserDetail) { - return ApiResponse.onSuccess(chatService.getMyChatRoomList(UserDetail)); + public ApiResponse> getMyChatRoomList(@AuthenticationPrincipal CustomUserDetails userDetails) { + return ApiResponse.onSuccess(chatService.getMyChatRoomList(userDetails)); } @PostMapping("/{roomId}/join") diff --git a/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java b/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java index 6b54ed0..6c01dc6 100644 --- a/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java +++ b/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java @@ -63,6 +63,7 @@ public void saveMessage(Long roomId, Long userId, ChatMessageReq chatMessageReq) chatMessageRepository.save(chatMessage); } + @Transactional public void createGroupChatRoom(String roomName, CustomUserDetails userDetails) { Long userId = userDetails.getUser().getUserId(); User user = userRepository.findById(userId).orElseThrow(() -> new EntityNotFoundException("user cannot be found")); From 3fc42b9c185aed124c073e7481928d2e471bc010 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=8A=B9=EC=96=B8?= Date: Wed, 10 Dec 2025 09:17:55 +0900 Subject: [PATCH 38/57] =?UTF-8?q?[Refactor]=20204=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=A9=94=EC=84=B8=EC=A7=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../techpost/global/apiPayload/code/status/SuccessStatus.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/ureka/techpost/global/apiPayload/code/status/SuccessStatus.java b/src/main/java/com/ureka/techpost/global/apiPayload/code/status/SuccessStatus.java index 4659f64..0ea68c3 100644 --- a/src/main/java/com/ureka/techpost/global/apiPayload/code/status/SuccessStatus.java +++ b/src/main/java/com/ureka/techpost/global/apiPayload/code/status/SuccessStatus.java @@ -13,7 +13,7 @@ public enum SuccessStatus implements BaseCode { // 일반적인 응답 _OK(HttpStatus.OK, "COMMON200", "성공입니다."), _CREATED(HttpStatus.CREATED, "COMMON201", "자원이 성공적으로 생성되었습니다."), - _NO_CONTENT(HttpStatus.NO_CONTENT, "COMMON204", "삭제 성공");; + _NO_CONTENT(HttpStatus.NO_CONTENT, "COMMON204", "작업 성공");; private final HttpStatus httpStatus; // http 상태코드 private final String code; // 상태 코드 설명 From 2d455225c613e035a7c04ef025887e2d2d1a7867 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=8A=B9=EC=96=B8?= Date: Wed, 10 Dec 2025 12:27:39 +0900 Subject: [PATCH 39/57] =?UTF-8?q?[Feat]=20=EB=8C=93=EA=B8=80=20=EB=B0=8F?= =?UTF-8?q?=20=EC=9C=A0=EC=A0=80=20=EA=B6=8C=ED=95=9C=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20=EC=97=90=EB=9F=AC=20=EC=BD=94=EB=93=9C(ErrorCode)=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 --- .../java/com/ureka/techpost/global/exception/ErrorCode.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/ureka/techpost/global/exception/ErrorCode.java b/src/main/java/com/ureka/techpost/global/exception/ErrorCode.java index 9056463..2bef3f4 100644 --- a/src/main/java/com/ureka/techpost/global/exception/ErrorCode.java +++ b/src/main/java/com/ureka/techpost/global/exception/ErrorCode.java @@ -12,7 +12,9 @@ public enum ErrorCode { // 예시 //Post POST_NOT_FOUND(HttpStatus.BAD_REQUEST, "해당 게시글을 찾을 수 없습니다."), - COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND , "해당 댓글을 찾을 수 없습니다."); + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND , "해당 댓글을 찾을 수 없습니다."), + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 유저를 찾을 수 없습니다."), + USER_NOT_MATCH(HttpStatus.FORBIDDEN, "수정 및 삭제 권한이 없습니다."); private final HttpStatus status; From 3cad065f1ecf189c8db7089ca535e5388250f68b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=8A=B9=EC=96=B8?= Date: Wed, 10 Dec 2025 12:28:22 +0900 Subject: [PATCH 40/57] =?UTF-8?q?[Feat]=20=EB=8C=93=EA=B8=80(Comment)=20En?= =?UTF-8?q?tity=20=EB=B0=8F=20=EC=9A=94=EC=B2=AD/=EC=9D=91=EB=8B=B5=20DTO?= =?UTF-8?q?=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/comment/dto/CommentRequest.java | 22 ++++++++ .../domain/comment/dto/CommentResponse.java | 23 +++++++++ .../domain/comment/entity/Comment.java | 51 +++++++++++++++++++ 3 files changed, 96 insertions(+) create mode 100644 src/main/java/com/ureka/techpost/domain/comment/dto/CommentRequest.java create mode 100644 src/main/java/com/ureka/techpost/domain/comment/dto/CommentResponse.java create mode 100644 src/main/java/com/ureka/techpost/domain/comment/entity/Comment.java diff --git a/src/main/java/com/ureka/techpost/domain/comment/dto/CommentRequest.java b/src/main/java/com/ureka/techpost/domain/comment/dto/CommentRequest.java new file mode 100644 index 0000000..cfc6979 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/comment/dto/CommentRequest.java @@ -0,0 +1,22 @@ +package com.ureka.techpost.domain.comment.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * @file CommentRequestDTO.java + * @author 최승언 + * @version 1.0 + * @since 2025-12-10 + * @description 클라이언트로부터 댓글 작성 및 수정 요청이 들어올 때, 해당 내용을 전달받기 위해 사용하는 요청 DTO 클래스입니다. + */ + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class CommentRequest { + private String content; +} diff --git a/src/main/java/com/ureka/techpost/domain/comment/dto/CommentResponse.java b/src/main/java/com/ureka/techpost/domain/comment/dto/CommentResponse.java new file mode 100644 index 0000000..0a98010 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/comment/dto/CommentResponse.java @@ -0,0 +1,23 @@ +package com.ureka.techpost.domain.comment.dto; + +import lombok.*; + +/** + * @file CommentResponseDTO.java + * @author 최승언 + * @version 1.0 + * @since 2025-12-10 + * @description 댓글 목록 조회 API 호출 시, 댓글 정보(ID, 작성자, 내용 등)를 클라이언트에게 반환하기 위해 사용하는 응답 DTO 클래스입니다. + */ + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class CommentResponse { + private Long id; + private Long userId; + private String userName; + private String content; +} diff --git a/src/main/java/com/ureka/techpost/domain/comment/entity/Comment.java b/src/main/java/com/ureka/techpost/domain/comment/entity/Comment.java new file mode 100644 index 0000000..1e6b93d --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/comment/entity/Comment.java @@ -0,0 +1,51 @@ +package com.ureka.techpost.domain.comment.entity; + +import com.ureka.techpost.domain.post.entity.Post; +import com.ureka.techpost.domain.user.entity.User; +import com.ureka.techpost.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * @file Comment.java + * @author 최승언 + * @version 1.0 + * @since 2025-12-10 + * @description 게시글에 달리는 댓글 정보를 관리하며, 데이터베이스의 'Comment' 테이블과 매핑되는 엔티티 클래스입니다. + */ + +@Entity +@Table(name = "Comment") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Comment extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id") + private Post post; + + @Column + private String content; + + public void updateContent(String content){ + this.content = content; + } + + @Builder + public Comment(User user, Post post, String content){ + this.user = user; + this.post = post; + this.content = content; + } +} From 8a622273dfc0e340b57043ba40a18597fb3fe4e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=8A=B9=EC=96=B8?= Date: Wed, 10 Dec 2025 12:28:42 +0900 Subject: [PATCH 41/57] =?UTF-8?q?[Feat]=20=EB=8C=93=EA=B8=80=20Repository?= =?UTF-8?q?=20=EB=B0=8F=20N+1=20=EB=B0=A9=EC=A7=80(Fetch=20Join)=20?= =?UTF-8?q?=EC=BF=BC=EB=A6=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../comment/repository/CommentRepository.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/main/java/com/ureka/techpost/domain/comment/repository/CommentRepository.java diff --git a/src/main/java/com/ureka/techpost/domain/comment/repository/CommentRepository.java b/src/main/java/com/ureka/techpost/domain/comment/repository/CommentRepository.java new file mode 100644 index 0000000..0458d8e --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/comment/repository/CommentRepository.java @@ -0,0 +1,22 @@ +package com.ureka.techpost.domain.comment.repository; + + +import com.ureka.techpost.domain.comment.entity.Comment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +/** + * @file CommentRepository.java + * @author 최승언 + * @version 1.0 + * @since 2025-12-09 + * @description 댓글(Comment) 엔티티의 데이터베이스 CRUD 작업을 담당하며, N+1 문제 해결을 위한 Fetch Join 쿼리를 포함하는 리포지토리 인터페이스입니다. + */ + +public interface CommentRepository extends JpaRepository { + @Query("select c from Comment c join fetch c.user where c.post.id = :postId") + List findByPostId(@Param("postId") Long postId); +} From 8190ca1a09c2aaae839c1375906159515a9ea16a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=8A=B9=EC=96=B8?= Date: Wed, 10 Dec 2025 12:28:58 +0900 Subject: [PATCH 42/57] =?UTF-8?q?[Feat]=20=EB=8C=93=EA=B8=80=20CRUD=20?= =?UTF-8?q?=EB=B9=84=EC=A6=88=EB=8B=88=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9E=91=EC=84=B1=EC=9E=90=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../comment/service/CommentService.java | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 src/main/java/com/ureka/techpost/domain/comment/service/CommentService.java diff --git a/src/main/java/com/ureka/techpost/domain/comment/service/CommentService.java b/src/main/java/com/ureka/techpost/domain/comment/service/CommentService.java new file mode 100644 index 0000000..7648494 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/comment/service/CommentService.java @@ -0,0 +1,104 @@ +package com.ureka.techpost.domain.comment.service; + + +import com.ureka.techpost.domain.auth.dto.CustomUserDetails; +import com.ureka.techpost.domain.comment.dto.CommentRequest; +import com.ureka.techpost.domain.comment.dto.CommentResponse; +import com.ureka.techpost.domain.comment.entity.Comment; +import com.ureka.techpost.domain.comment.repository.CommentRepository; +import com.ureka.techpost.domain.post.entity.Post; +import com.ureka.techpost.domain.post.repository.PostRepository; +import com.ureka.techpost.domain.user.entity.User; +import com.ureka.techpost.domain.user.repository.UserRepository; +import com.ureka.techpost.global.exception.CustomException; +import com.ureka.techpost.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * @file CommentService.java + * @author 최승언 + * @version 1.0 + * @since 2025-12-10 + * @description 댓글 작성, 수정, 삭제, 조회 등 댓글 도메인의 핵심 비즈니스 로직을 처리하고 트랜잭션을 관리하는 서비스 클래스입니다. + */ + +@Service +@RequiredArgsConstructor +public class CommentService { + + private final CommentRepository commentRepository; + private final UserRepository userRepository; + private final PostRepository postRepository; + + @Transactional + public void addComment(CommentRequest commentRequest, CustomUserDetails userDetails, Long postId){ + + Post post = postRepository.findById(postId) + .orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND)); + + User user = userRepository.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + Comment comment = Comment.builder() + .user(user) + .post(post) + .content(commentRequest.getContent()) + .build(); + + commentRepository.save(comment); + } + + @Transactional(readOnly = true) + public List findByPostId(Long postId) { + + if(!postRepository.existsById(postId)){ + throw new CustomException(ErrorCode.POST_NOT_FOUND); + } + + return commentRepository.findByPostId(postId).stream() + .map(comment -> new CommentResponse( + comment.getId() + , comment.getUser().getUserId() + , comment.getUser().getName() + , comment.getContent() + )) + .collect(Collectors.toList()); + } + + @Transactional + public void patchComment(Long commentId, CustomUserDetails userDetails, CommentRequest commentRequest) { + + User user = userRepository.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new CustomException(ErrorCode.COMMENT_NOT_FOUND)); + + if(!comment.getUser().getUserId().equals(user.getUserId())){ + throw new CustomException(ErrorCode.USER_NOT_MATCH); + } + + comment.updateContent(commentRequest.getContent()); + } + + @Transactional + public void deleteComment(Long commentId, CustomUserDetails userDetails) { + + User user = userRepository.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new CustomException(ErrorCode.COMMENT_NOT_FOUND)); + + if(!comment.getUser().getUserId().equals(user.getUserId())){ + throw new CustomException(ErrorCode.USER_NOT_MATCH); + } + + commentRepository.delete(comment); + } +} From 7a5f74b06bd051a96ef65c436b18e8adb5e8de20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=8A=B9=EC=96=B8?= Date: Wed, 10 Dec 2025 12:29:18 +0900 Subject: [PATCH 43/57] =?UTF-8?q?[Feat]=20=EB=8C=93=EA=B8=80=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20API=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8?= =?UTF-8?q?=20=EB=B0=8F=20Swagger=20=EB=AA=85=EC=84=B8=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 --- .../comment/controller/CommentController.java | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 src/main/java/com/ureka/techpost/domain/comment/controller/CommentController.java diff --git a/src/main/java/com/ureka/techpost/domain/comment/controller/CommentController.java b/src/main/java/com/ureka/techpost/domain/comment/controller/CommentController.java new file mode 100644 index 0000000..8c1277e --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/comment/controller/CommentController.java @@ -0,0 +1,75 @@ +package com.ureka.techpost.domain.comment.controller; + + +import com.ureka.techpost.domain.auth.dto.CustomUserDetails; +import com.ureka.techpost.domain.comment.dto.CommentRequest; +import com.ureka.techpost.domain.comment.dto.CommentResponse; +import com.ureka.techpost.domain.comment.service.CommentService; +import com.ureka.techpost.global.apiPayload.ApiResponse; +import com.ureka.techpost.global.apiPayload.code.status.SuccessStatus; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * @file CommentController.java + * @author 최승언 + * @version 1.0 + * @since 2025-12-10 + * @description 댓글 관련 API 요청(추가, 조회, 수정, 삭제)을 받아 서비스 계층으로 전달하고, 처리 결과를 클라이언트에게 반환하는 컨트롤러 클래스입니다. + */ + +@Tag(name = "댓글(Comment) API", description = "게시글 댓글 관련 API") +@RestController +@RequiredArgsConstructor +public class CommentController { + + private final CommentService commentService; + + @Operation(summary = "댓글 작성", description = "특정 게시글(postId)에 새로운 댓글을 작성합니다.") + @PostMapping("/posts/{postId}/comments") + public ApiResponse addComment(@Parameter(description = "댓글을 달 게시글의 ID") @PathVariable Long postId, + @RequestBody CommentRequest commentRequest, + @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails){ + + commentService.addComment(commentRequest, userDetails, postId); + + return ApiResponse.of(SuccessStatus._CREATED, null); + } + + @Operation(summary = "댓글 목록 조회", description = "특정 게시글(postId)에 달린 모든 댓글 목록을 조회합니다.") + @GetMapping("/posts/{postId}/comments") + public ApiResponse> getComments(@Parameter(description = "조회할 게시글의 ID") @PathVariable Long postId){ + + List commentResponseList = commentService.findByPostId(postId); + + return ApiResponse.onSuccess(commentResponseList); + } + + @Operation(summary = "댓글 삭제", description = "댓글 ID를 이용하여 본인이 작성한 댓글을 삭제합니다.") + @DeleteMapping("/comments/{commentId}") + public ApiResponse deleteComment(@Parameter(description = "삭제할 댓글의 ID") @PathVariable Long commentId, + @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails){ + + commentService.deleteComment(commentId, userDetails); + + return ApiResponse.of(SuccessStatus._NO_CONTENT, null); + } + + @Operation(summary = "댓글 수정", description = "댓글 ID를 이용하여 본인이 작성한 댓글 내용을 수정합니다.") + @PatchMapping("/comments/{commentId}") + public ApiResponse patchComment(@Parameter(description = "수정할 댓글의 ID") @PathVariable Long commentId, + @RequestBody CommentRequest commentRequest, + @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails){ + + commentService.patchComment(commentId, userDetails, commentRequest); + + return ApiResponse.of(SuccessStatus._NO_CONTENT, null); + } + +} From a5397e8cad78d3fa8f52eac40c8c4cc5de18b96a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=8A=B9=EC=96=B8?= Date: Wed, 10 Dec 2025 12:33:34 +0900 Subject: [PATCH 44/57] =?UTF-8?q?[Feat]=20=EA=B2=8C=EC=8B=9C=EB=AC=BC=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=8C=93?= =?UTF-8?q?=EA=B8=80=20=EC=88=98(CommentCount)=20=EC=A7=91=EA=B3=84=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/post/repository/PostRepository.java | 10 ++++------ .../post/repository/PostRepositoryImpl.java | 18 +++++++++--------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/ureka/techpost/domain/post/repository/PostRepository.java b/src/main/java/com/ureka/techpost/domain/post/repository/PostRepository.java index 3ea019e..544a31e 100644 --- a/src/main/java/com/ureka/techpost/domain/post/repository/PostRepository.java +++ b/src/main/java/com/ureka/techpost/domain/post/repository/PostRepository.java @@ -14,7 +14,7 @@ /** * @file PostRepository.java * @author 최승언 - * @version 1.0 + * @version 1.1 * @since 2025-12-09 * @description 게시글(Post) 엔티티의 기본적인 CRUD 및 JPQL 쿼리를 담당하는 JPA Repository 인터페이스입니다. */ @@ -30,19 +30,17 @@ public interface PostRepository extends JpaRepository, PostRepositor // [수정] 좋아요 수: 아직 없으므로 0으로 대체 // "(SELECT count(l) FROM Likes l WHERE l.post.id = p.id), " + "0L, " + -// "(SELECT count(c) FROM Comment c WHERE c.post.id = p.id)) " + - "0L) " + + "(SELECT count(c) FROM Comment c WHERE c.post.id = p.id)) " + "FROM Post p") Page findPostList(Pageable pageable); @Query("SELECT new com.ureka.techpost.domain.post.dto.PostResponseDTO(" + "p.id, p.title, p.summary, p.originalUrl, p.thumbnailUrl, " + "p.publisher, p.publishedAt, p.sourceName, p.createdAt, " + -// [수정] 댓글 수: 아직 없으므로 0으로 대체 +// [수정] 좋아요 수: 아직 없으므로 0으로 대체 // "(SELECT count(l) FROM Likes l WHERE l.post.id = p.id), " + "0L, " + -// "(SELECT count(c) FROM Comment c WHERE c.post.id = p.id)) " + - "0L) " + + "(SELECT count(c) FROM Comment c WHERE c.post.id = p.id)) " + "FROM Post p " + "WHERE p.id = :postId") Optional findPostById(@Param("postId") Long postId); diff --git a/src/main/java/com/ureka/techpost/domain/post/repository/PostRepositoryImpl.java b/src/main/java/com/ureka/techpost/domain/post/repository/PostRepositoryImpl.java index 4cae8f4..62f7553 100644 --- a/src/main/java/com/ureka/techpost/domain/post/repository/PostRepositoryImpl.java +++ b/src/main/java/com/ureka/techpost/domain/post/repository/PostRepositoryImpl.java @@ -1,7 +1,9 @@ package com.ureka.techpost.domain.post.repository; +import com.querydsl.core.types.ExpressionUtils; import com.querydsl.core.types.Projections; import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import com.ureka.techpost.domain.post.dto.PostResponseDTO; @@ -14,13 +16,13 @@ import java.util.List; import static com.ureka.techpost.domain.post.entity.QPost.post; -//import static com.ureka.techpost.domain.post.entity.QComment.comment; +import static com.ureka.techpost.domain.comment.entity.QComment.comment; //import static com.ureka.techpost.domain.post.entity.QLikes.likes; /** * @file PostRepositoryImpl.java * @author 최승언 - * @version 1.0 + * @version 1.1 * @since 2025-12-09 * @description QueryDSL을 활용하여 게시글 검색, 필터링 등 복잡한 조회 로직을 실제로 구현한 클래스입니다. */ @@ -52,13 +54,11 @@ public Page search(String keyword, String sourceName, Pageable // "likeCount"), com.querydsl.core.types.dsl.Expressions.asNumber(0L), - // [수정] 댓글 수: 아직 없으므로 0으로 대체 - // ExpressionUtils.as( - // JPAExpressions.select(comment.count()) - // .from(comment) - // .where(comment.post.eq(post)), - // "commentCount") - com.querydsl.core.types.dsl.Expressions.asNumber(0L) + ExpressionUtils.as( + JPAExpressions.select(comment.count()) + .from(comment) + .where(comment.post.eq(post)), + "commentCount") )) .from(post) .where( From 1e8675b9b2ac72c23f4d7b67df5a02d26a378c1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=8A=B9=EC=96=B8?= Date: Wed, 10 Dec 2025 12:36:59 +0900 Subject: [PATCH 45/57] =?UTF-8?q?[Docs]=20Swagger=20=EC=84=A4=EB=AA=85=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 --- .../ureka/techpost/domain/post/controller/PostController.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/ureka/techpost/domain/post/controller/PostController.java b/src/main/java/com/ureka/techpost/domain/post/controller/PostController.java index 7804265..ecfb3c9 100644 --- a/src/main/java/com/ureka/techpost/domain/post/controller/PostController.java +++ b/src/main/java/com/ureka/techpost/domain/post/controller/PostController.java @@ -8,6 +8,7 @@ import com.ureka.techpost.global.apiPayload.code.status.SuccessStatus; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springdoc.core.annotations.ParameterObject; import org.springframework.data.domain.Page; @@ -25,6 +26,7 @@ @description 게시글 관련 API 요청(생성, 조회, 검색, 삭제)을 받아 서비스 계층으로 전달하고 응답을 반환하는 컨트롤러 클래스입니다. */ +@Tag(name = "게시글(Post) API", description = "게시글 관련 API") @RestController @RequiredArgsConstructor @RequestMapping("/posts") From 3eba5c5aede0c1b111ae061ee08b4cf06e8da303 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=8A=B9=EC=96=B8?= Date: Wed, 10 Dec 2025 13:20:27 +0900 Subject: [PATCH 46/57] =?UTF-8?q?[Refactor]=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=EB=AA=85=20=EB=B3=80=EA=B2=BD=20(findBy=20->=20findAl?= =?UTF-8?q?lBy)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../comment/dto/{CommentRequest.java => CommentRequestDTO.java} | 0 .../dto/{CommentResponse.java => CommentResponseDTO.java} | 0 .../techpost/domain/comment/repository/CommentRepository.java | 2 +- 3 files changed, 1 insertion(+), 1 deletion(-) rename src/main/java/com/ureka/techpost/domain/comment/dto/{CommentRequest.java => CommentRequestDTO.java} (100%) rename src/main/java/com/ureka/techpost/domain/comment/dto/{CommentResponse.java => CommentResponseDTO.java} (100%) diff --git a/src/main/java/com/ureka/techpost/domain/comment/dto/CommentRequest.java b/src/main/java/com/ureka/techpost/domain/comment/dto/CommentRequestDTO.java similarity index 100% rename from src/main/java/com/ureka/techpost/domain/comment/dto/CommentRequest.java rename to src/main/java/com/ureka/techpost/domain/comment/dto/CommentRequestDTO.java diff --git a/src/main/java/com/ureka/techpost/domain/comment/dto/CommentResponse.java b/src/main/java/com/ureka/techpost/domain/comment/dto/CommentResponseDTO.java similarity index 100% rename from src/main/java/com/ureka/techpost/domain/comment/dto/CommentResponse.java rename to src/main/java/com/ureka/techpost/domain/comment/dto/CommentResponseDTO.java diff --git a/src/main/java/com/ureka/techpost/domain/comment/repository/CommentRepository.java b/src/main/java/com/ureka/techpost/domain/comment/repository/CommentRepository.java index 0458d8e..8c7aa30 100644 --- a/src/main/java/com/ureka/techpost/domain/comment/repository/CommentRepository.java +++ b/src/main/java/com/ureka/techpost/domain/comment/repository/CommentRepository.java @@ -18,5 +18,5 @@ public interface CommentRepository extends JpaRepository { @Query("select c from Comment c join fetch c.user where c.post.id = :postId") - List findByPostId(@Param("postId") Long postId); + List findAllByPostId(@Param("postId") Long postId); } From d540c97a6bbaebe6e7687098bf4e02892b010fcc Mon Sep 17 00:00:00 2001 From: yechan <100present@naver.com> Date: Wed, 10 Dec 2025 13:22:35 +0900 Subject: [PATCH 47/57] =?UTF-8?q?[FIX]=20:=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=ED=95=B8=EB=93=A4=EB=9F=AC=20=ED=86=B5=ED=95=A9,=20=EA=B8=B0?= =?UTF-8?q?=ED=83=80=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/controller/PostController.java | 2 +- .../test/controller/TestController.java | 22 --------- .../domain/test/converter/TestConverter.java | 12 ----- .../domain/test/dto/TestResponse.java | 17 ------- .../techpost/domain/test/entity/Test.java | 16 ------- .../test/repository/TestRepository.java | 7 --- .../global/apiPayload/ApiResponse.java | 2 +- .../global/apiPayload/code/BaseErrorCode.java | 8 ---- .../apiPayload/code/ErrorReasonDTO.java | 18 ------- .../code/{status => }/SuccessStatus.java | 4 +- .../apiPayload/code/status/ErrorStatus.java | 47 ------------------- 11 files changed, 3 insertions(+), 152 deletions(-) delete mode 100644 src/main/java/com/ureka/techpost/domain/test/controller/TestController.java delete mode 100644 src/main/java/com/ureka/techpost/domain/test/converter/TestConverter.java delete mode 100644 src/main/java/com/ureka/techpost/domain/test/dto/TestResponse.java delete mode 100644 src/main/java/com/ureka/techpost/domain/test/entity/Test.java delete mode 100644 src/main/java/com/ureka/techpost/domain/test/repository/TestRepository.java delete mode 100644 src/main/java/com/ureka/techpost/global/apiPayload/code/BaseErrorCode.java delete mode 100644 src/main/java/com/ureka/techpost/global/apiPayload/code/ErrorReasonDTO.java rename src/main/java/com/ureka/techpost/global/apiPayload/code/{status => }/SuccessStatus.java (86%) delete mode 100644 src/main/java/com/ureka/techpost/global/apiPayload/code/status/ErrorStatus.java diff --git a/src/main/java/com/ureka/techpost/domain/post/controller/PostController.java b/src/main/java/com/ureka/techpost/domain/post/controller/PostController.java index 7804265..170c56b 100644 --- a/src/main/java/com/ureka/techpost/domain/post/controller/PostController.java +++ b/src/main/java/com/ureka/techpost/domain/post/controller/PostController.java @@ -5,7 +5,7 @@ import com.ureka.techpost.domain.post.dto.PostRequestDTO; import com.ureka.techpost.domain.post.service.PostService; import com.ureka.techpost.global.apiPayload.ApiResponse; -import com.ureka.techpost.global.apiPayload.code.status.SuccessStatus; +import com.ureka.techpost.global.apiPayload.code.SuccessStatus; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/ureka/techpost/domain/test/controller/TestController.java b/src/main/java/com/ureka/techpost/domain/test/controller/TestController.java deleted file mode 100644 index f985adf..0000000 --- a/src/main/java/com/ureka/techpost/domain/test/controller/TestController.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.ureka.techpost.domain.test.controller; - -import com.ureka.techpost.domain.test.converter.TestConverter; -import com.ureka.techpost.domain.test.dto.TestResponse; -import com.ureka.techpost.global.apiPayload.ApiResponse; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -public class TestController { - - @GetMapping("/health") - public ApiResponse health(){ - return ApiResponse.onSuccess("health check"); - } - - @GetMapping("/test") - public ApiResponse testAPI(){ - - return ApiResponse.onSuccess(TestConverter.toTestDTO()); - } -} diff --git a/src/main/java/com/ureka/techpost/domain/test/converter/TestConverter.java b/src/main/java/com/ureka/techpost/domain/test/converter/TestConverter.java deleted file mode 100644 index 7fdd48a..0000000 --- a/src/main/java/com/ureka/techpost/domain/test/converter/TestConverter.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.ureka.techpost.domain.test.converter; - -import com.ureka.techpost.domain.test.dto.TestResponse; - -public class TestConverter { - - public static TestResponse.TestDTO toTestDTO(){ - return TestResponse.TestDTO.builder() - .testString("This is Test!") - .build(); - } -} diff --git a/src/main/java/com/ureka/techpost/domain/test/dto/TestResponse.java b/src/main/java/com/ureka/techpost/domain/test/dto/TestResponse.java deleted file mode 100644 index 4c75c12..0000000 --- a/src/main/java/com/ureka/techpost/domain/test/dto/TestResponse.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.ureka.techpost.domain.test.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -public class TestResponse { - - @Builder - @Getter - @NoArgsConstructor - @AllArgsConstructor - public static class TestDTO{ - String testString; - } -} diff --git a/src/main/java/com/ureka/techpost/domain/test/entity/Test.java b/src/main/java/com/ureka/techpost/domain/test/entity/Test.java deleted file mode 100644 index 6a707aa..0000000 --- a/src/main/java/com/ureka/techpost/domain/test/entity/Test.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.ureka.techpost.domain.test.entity; - -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; - -@Entity -public class Test { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - private String name; -} diff --git a/src/main/java/com/ureka/techpost/domain/test/repository/TestRepository.java b/src/main/java/com/ureka/techpost/domain/test/repository/TestRepository.java deleted file mode 100644 index 843a723..0000000 --- a/src/main/java/com/ureka/techpost/domain/test/repository/TestRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.ureka.techpost.domain.test.repository; - -import com.ureka.techpost.domain.test.entity.Test; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface TestRepository extends JpaRepository { -} diff --git a/src/main/java/com/ureka/techpost/global/apiPayload/ApiResponse.java b/src/main/java/com/ureka/techpost/global/apiPayload/ApiResponse.java index 93d748d..26259f8 100644 --- a/src/main/java/com/ureka/techpost/global/apiPayload/ApiResponse.java +++ b/src/main/java/com/ureka/techpost/global/apiPayload/ApiResponse.java @@ -11,7 +11,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyOrder; import com.ureka.techpost.global.apiPayload.code.BaseCode; -import com.ureka.techpost.global.apiPayload.code.status.SuccessStatus; +import com.ureka.techpost.global.apiPayload.code.SuccessStatus; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/src/main/java/com/ureka/techpost/global/apiPayload/code/BaseErrorCode.java b/src/main/java/com/ureka/techpost/global/apiPayload/code/BaseErrorCode.java deleted file mode 100644 index c719784..0000000 --- a/src/main/java/com/ureka/techpost/global/apiPayload/code/BaseErrorCode.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.ureka.techpost.global.apiPayload.code; - -public interface BaseErrorCode { - - ErrorReasonDTO getReason(); - - ErrorReasonDTO getReasonHttpStatus(); -} \ No newline at end of file diff --git a/src/main/java/com/ureka/techpost/global/apiPayload/code/ErrorReasonDTO.java b/src/main/java/com/ureka/techpost/global/apiPayload/code/ErrorReasonDTO.java deleted file mode 100644 index 68c7475..0000000 --- a/src/main/java/com/ureka/techpost/global/apiPayload/code/ErrorReasonDTO.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.ureka.techpost.global.apiPayload.code; - -import lombok.Builder; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@Builder -public class ErrorReasonDTO { - - private HttpStatus httpStatus; - - private final boolean isSuccess; - private final String code; - private final String message; - - public boolean getIsSuccess(){return isSuccess;} -} diff --git a/src/main/java/com/ureka/techpost/global/apiPayload/code/status/SuccessStatus.java b/src/main/java/com/ureka/techpost/global/apiPayload/code/SuccessStatus.java similarity index 86% rename from src/main/java/com/ureka/techpost/global/apiPayload/code/status/SuccessStatus.java rename to src/main/java/com/ureka/techpost/global/apiPayload/code/SuccessStatus.java index 0ea68c3..3b3a215 100644 --- a/src/main/java/com/ureka/techpost/global/apiPayload/code/status/SuccessStatus.java +++ b/src/main/java/com/ureka/techpost/global/apiPayload/code/SuccessStatus.java @@ -1,7 +1,5 @@ -package com.ureka.techpost.global.apiPayload.code.status; +package com.ureka.techpost.global.apiPayload.code; -import com.ureka.techpost.global.apiPayload.code.BaseCode; -import com.ureka.techpost.global.apiPayload.code.ReasonDTO; import lombok.AllArgsConstructor; import lombok.Getter; import org.springframework.http.HttpStatus; diff --git a/src/main/java/com/ureka/techpost/global/apiPayload/code/status/ErrorStatus.java b/src/main/java/com/ureka/techpost/global/apiPayload/code/status/ErrorStatus.java deleted file mode 100644 index 14ce25f..0000000 --- a/src/main/java/com/ureka/techpost/global/apiPayload/code/status/ErrorStatus.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.ureka.techpost.global.apiPayload.code.status; - -import com.ureka.techpost.global.apiPayload.code.BaseErrorCode; -import com.ureka.techpost.global.apiPayload.code.ErrorReasonDTO; -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@AllArgsConstructor -public enum ErrorStatus implements BaseErrorCode { - - // 가장 일반적인 응답 - _INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON500", "서버 에러, 관리자에게 문의 바랍니다."), - _BAD_REQUEST(HttpStatus.BAD_REQUEST,"COMMON400","잘못된 요청입니다."), - _UNAUTHORIZED(HttpStatus.UNAUTHORIZED,"COMMON401","인증이 필요합니다."), - _FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON403", "금지된 요청입니다."), - - // For test - TEMP_EXCEPTION(HttpStatus.BAD_REQUEST, "TEMP4001", "이거는 테스트"); - - - private final HttpStatus httpStatus; - private final String code; - private final String message; - - @Override - public ErrorReasonDTO getReason() { - return ErrorReasonDTO.builder() - .message(message) - .code(code) - .isSuccess(false) - .build(); - } - - @Override - public ErrorReasonDTO getReasonHttpStatus() { - return ErrorReasonDTO.builder() - .message(message) - .code(code) - .isSuccess(false) - .httpStatus(httpStatus) - .build(); - } - - -} From 9688af8ac55b03162502bb84e4f0c9edc9eb7319 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=8A=B9=EC=96=B8?= Date: Wed, 10 Dec 2025 13:22:36 +0900 Subject: [PATCH 48/57] =?UTF-8?q?[Refactor]=20=EA=B2=8C=EC=8B=9C=EB=AC=BC(?= =?UTF-8?q?Post)=EC=99=80=20=EB=A9=94=EC=84=9C=EB=93=9C=EB=AA=85=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC=20=EB=B0=8F=20DTO=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=EB=AA=85=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../comment/controller/CommentController.java | 20 +++++++++---------- .../domain/comment/dto/CommentRequestDTO.java | 2 +- .../comment/dto/CommentResponseDTO.java | 2 +- .../comment/service/CommentService.java | 18 ++++++++--------- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/main/java/com/ureka/techpost/domain/comment/controller/CommentController.java b/src/main/java/com/ureka/techpost/domain/comment/controller/CommentController.java index 8c1277e..743f4a1 100644 --- a/src/main/java/com/ureka/techpost/domain/comment/controller/CommentController.java +++ b/src/main/java/com/ureka/techpost/domain/comment/controller/CommentController.java @@ -2,8 +2,8 @@ import com.ureka.techpost.domain.auth.dto.CustomUserDetails; -import com.ureka.techpost.domain.comment.dto.CommentRequest; -import com.ureka.techpost.domain.comment.dto.CommentResponse; +import com.ureka.techpost.domain.comment.dto.CommentRequestDTO; +import com.ureka.techpost.domain.comment.dto.CommentResponseDTO; import com.ureka.techpost.domain.comment.service.CommentService; import com.ureka.techpost.global.apiPayload.ApiResponse; import com.ureka.techpost.global.apiPayload.code.status.SuccessStatus; @@ -33,20 +33,20 @@ public class CommentController { @Operation(summary = "댓글 작성", description = "특정 게시글(postId)에 새로운 댓글을 작성합니다.") @PostMapping("/posts/{postId}/comments") - public ApiResponse addComment(@Parameter(description = "댓글을 달 게시글의 ID") @PathVariable Long postId, - @RequestBody CommentRequest commentRequest, - @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails){ + public ApiResponse createComment(@Parameter(description = "댓글을 달 게시글의 ID") @PathVariable Long postId, + @RequestBody CommentRequestDTO commentRequestDTO, + @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails){ - commentService.addComment(commentRequest, userDetails, postId); + commentService.createComment(commentRequestDTO, userDetails, postId); return ApiResponse.of(SuccessStatus._CREATED, null); } @Operation(summary = "댓글 목록 조회", description = "특정 게시글(postId)에 달린 모든 댓글 목록을 조회합니다.") @GetMapping("/posts/{postId}/comments") - public ApiResponse> getComments(@Parameter(description = "조회할 게시글의 ID") @PathVariable Long postId){ + public ApiResponse> getComments(@Parameter(description = "조회할 게시글의 ID") @PathVariable Long postId){ - List commentResponseList = commentService.findByPostId(postId); + List commentResponseList = commentService.findByPostId(postId); return ApiResponse.onSuccess(commentResponseList); } @@ -64,10 +64,10 @@ public ApiResponse deleteComment(@Parameter(description = "삭제할 댓 @Operation(summary = "댓글 수정", description = "댓글 ID를 이용하여 본인이 작성한 댓글 내용을 수정합니다.") @PatchMapping("/comments/{commentId}") public ApiResponse patchComment(@Parameter(description = "수정할 댓글의 ID") @PathVariable Long commentId, - @RequestBody CommentRequest commentRequest, + @RequestBody CommentRequestDTO commentRequestDTO, @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails){ - commentService.patchComment(commentId, userDetails, commentRequest); + commentService.patchComment(commentId, userDetails, commentRequestDTO); return ApiResponse.of(SuccessStatus._NO_CONTENT, null); } diff --git a/src/main/java/com/ureka/techpost/domain/comment/dto/CommentRequestDTO.java b/src/main/java/com/ureka/techpost/domain/comment/dto/CommentRequestDTO.java index cfc6979..af80757 100644 --- a/src/main/java/com/ureka/techpost/domain/comment/dto/CommentRequestDTO.java +++ b/src/main/java/com/ureka/techpost/domain/comment/dto/CommentRequestDTO.java @@ -17,6 +17,6 @@ @Setter @AllArgsConstructor @NoArgsConstructor -public class CommentRequest { +public class CommentRequestDTO { private String content; } diff --git a/src/main/java/com/ureka/techpost/domain/comment/dto/CommentResponseDTO.java b/src/main/java/com/ureka/techpost/domain/comment/dto/CommentResponseDTO.java index 0a98010..bf5e651 100644 --- a/src/main/java/com/ureka/techpost/domain/comment/dto/CommentResponseDTO.java +++ b/src/main/java/com/ureka/techpost/domain/comment/dto/CommentResponseDTO.java @@ -15,7 +15,7 @@ @AllArgsConstructor @NoArgsConstructor @Builder -public class CommentResponse { +public class CommentResponseDTO { private Long id; private Long userId; private String userName; diff --git a/src/main/java/com/ureka/techpost/domain/comment/service/CommentService.java b/src/main/java/com/ureka/techpost/domain/comment/service/CommentService.java index 7648494..b38157d 100644 --- a/src/main/java/com/ureka/techpost/domain/comment/service/CommentService.java +++ b/src/main/java/com/ureka/techpost/domain/comment/service/CommentService.java @@ -2,8 +2,8 @@ import com.ureka.techpost.domain.auth.dto.CustomUserDetails; -import com.ureka.techpost.domain.comment.dto.CommentRequest; -import com.ureka.techpost.domain.comment.dto.CommentResponse; +import com.ureka.techpost.domain.comment.dto.CommentRequestDTO; +import com.ureka.techpost.domain.comment.dto.CommentResponseDTO; import com.ureka.techpost.domain.comment.entity.Comment; import com.ureka.techpost.domain.comment.repository.CommentRepository; import com.ureka.techpost.domain.post.entity.Post; @@ -36,7 +36,7 @@ public class CommentService { private final PostRepository postRepository; @Transactional - public void addComment(CommentRequest commentRequest, CustomUserDetails userDetails, Long postId){ + public void createComment(CommentRequestDTO commentRequestDTO, CustomUserDetails userDetails, Long postId){ Post post = postRepository.findById(postId) .orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND)); @@ -47,21 +47,21 @@ public void addComment(CommentRequest commentRequest, CustomUserDetails userDeta Comment comment = Comment.builder() .user(user) .post(post) - .content(commentRequest.getContent()) + .content(commentRequestDTO.getContent()) .build(); commentRepository.save(comment); } @Transactional(readOnly = true) - public List findByPostId(Long postId) { + public List findByPostId(Long postId) { if(!postRepository.existsById(postId)){ throw new CustomException(ErrorCode.POST_NOT_FOUND); } - return commentRepository.findByPostId(postId).stream() - .map(comment -> new CommentResponse( + return commentRepository.findAllByPostId(postId).stream() + .map(comment -> new CommentResponseDTO( comment.getId() , comment.getUser().getUserId() , comment.getUser().getName() @@ -71,7 +71,7 @@ public List findByPostId(Long postId) { } @Transactional - public void patchComment(Long commentId, CustomUserDetails userDetails, CommentRequest commentRequest) { + public void patchComment(Long commentId, CustomUserDetails userDetails, CommentRequestDTO commentRequestDTO) { User user = userRepository.findByUsername(userDetails.getUsername()) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); @@ -83,7 +83,7 @@ public void patchComment(Long commentId, CustomUserDetails userDetails, CommentR throw new CustomException(ErrorCode.USER_NOT_MATCH); } - comment.updateContent(commentRequest.getContent()); + comment.updateContent(commentRequestDTO.getContent()); } @Transactional From 57f6632b50cd1ec4514dbd40bf68e7386d695f3f Mon Sep 17 00:00:00 2001 From: k0081915 Date: Wed, 10 Dec 2025 13:34:07 +0900 Subject: [PATCH 49/57] =?UTF-8?q?[Fix]=20SecurityConfig,=20JwtAuthenticati?= =?UTF-8?q?onFilter=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ureka/techpost/domain/auth/jwt/JwtAuthenticationFilter.java | 1 + .../java/com/ureka/techpost/global/config/SecurityConfig.java | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/ureka/techpost/domain/auth/jwt/JwtAuthenticationFilter.java b/src/main/java/com/ureka/techpost/domain/auth/jwt/JwtAuthenticationFilter.java index ce8435f..3718865 100644 --- a/src/main/java/com/ureka/techpost/domain/auth/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/ureka/techpost/domain/auth/jwt/JwtAuthenticationFilter.java @@ -73,6 +73,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse // 인증 객체(Authentication) 생성을 위한 임시 User 객체 생성 // 비밀번호는 이미 토큰 검증을 통과했으므로 임의의 값으로 설정 User user = User.builder() + .userId(foundUser.getUserId()) .username(username) .password("temppassword") .name(foundUser.getName()) diff --git a/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java b/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java index 6cbc367..ad6b200 100644 --- a/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java +++ b/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java @@ -81,7 +81,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .authorizeHttpRequests(auth -> auth .requestMatchers(WHITE_LIST).permitAll() - .anyRequest().permitAll() + .anyRequest().authenticated() ) .oauth2Login(oauth2 -> oauth2 From 9fb7c7df11ba760616130a59f3ed67ef9413c2b6 Mon Sep 17 00:00:00 2001 From: k0081915 Date: Wed, 10 Dec 2025 14:04:38 +0900 Subject: [PATCH 50/57] =?UTF-8?q?[Refactor]=20=EC=8A=A4=EC=9B=A8=EA=B1=B0?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80,=20=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EB=B0=8F=20=EA=B8=B0=ED=83=80=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 --- .../auth/controller/AuthController.java | 28 +++++++++-- .../auth/handler/CustomLogoutHandler.java | 29 ----------- .../handler/OAuth2LoginSuccessHandler.java | 10 +--- .../domain/auth/service/AuthService.java | 50 +++++++++++++++---- .../domain/auth/service/TokenService.java | 33 ------------ .../global/config/SecurityConfig.java | 11 ---- 6 files changed, 66 insertions(+), 95 deletions(-) delete mode 100644 src/main/java/com/ureka/techpost/domain/auth/handler/CustomLogoutHandler.java diff --git a/src/main/java/com/ureka/techpost/domain/auth/controller/AuthController.java b/src/main/java/com/ureka/techpost/domain/auth/controller/AuthController.java index de922ef..38a6c74 100644 --- a/src/main/java/com/ureka/techpost/domain/auth/controller/AuthController.java +++ b/src/main/java/com/ureka/techpost/domain/auth/controller/AuthController.java @@ -4,6 +4,9 @@ import com.ureka.techpost.domain.auth.dto.SignupDto; import com.ureka.techpost.domain.auth.service.AuthService; import com.ureka.techpost.global.apiPayload.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.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; @@ -18,6 +21,7 @@ @since 2025-12-08 @description 이 파일은 사용자 인증과 관련된 HTTP 요청을 받아 처리하는 REST 컨트롤러 클래스입니다. */ +@Tag(name = "Authentication", description = "인증 관련 API") @RestController @RequestMapping("/api/auth") @RequiredArgsConstructor @@ -25,21 +29,39 @@ public class AuthController { private final AuthService authService; + @Operation(summary = "회원가입", description = "새로운 사용자를 등록합니다.") @PostMapping("/signup") - public ApiResponse signup(@Valid @RequestBody SignupDto signupDto) { + public ApiResponse signup( + @Parameter(description = "회원가입 요청 데이터 (아이디, 비밀번호, 이름)", required = true) + @Valid @RequestBody SignupDto signupDto) { authService.signup(signupDto); return ApiResponse.onSuccess("회원가입 성공"); } + @Operation(summary = "토큰 재발급", description = "Refresh Token을 사용하여 만료된 Access Token을 재발급합니다. Refresh Token은 쿠키에서, 만료된 Access Token은 헤더에서 가져옵니다.") @PostMapping("/reissue") - public ApiResponse reissue(HttpServletRequest request, HttpServletResponse response) { + public ApiResponse reissue( + @Parameter(hidden = true) HttpServletRequest request, + @Parameter(hidden = true) HttpServletResponse response) { return ApiResponse.onSuccess(authService.reissue(request, response)); } + @Operation(summary = "로그인", description = "사용자 이름과 비밀번호로 로그인하여 Access Token 및 Refresh Token을 발급받습니다.") @PostMapping("/login") - public ApiResponse login(@Valid @RequestBody LoginDto loginDto, HttpServletResponse response) { + public ApiResponse login( + @Parameter(description = "로그인 요청 데이터 (아이디, 비밀번호)", required = true) + @Valid @RequestBody LoginDto loginDto, + @Parameter(hidden = true) HttpServletResponse response) { authService.login(loginDto, response); return ApiResponse.onSuccess("로그인 성공"); } + @Operation(summary = "로그아웃", description = "Refresh Token을 삭제하고 로그아웃 처리합니다.") + @PostMapping("/logout") + public ResponseEntity logout( + @Parameter(hidden = true) HttpServletRequest request, + @Parameter(hidden = true) HttpServletResponse response) { + authService.logout(request, response); + return ResponseEntity.ok("로그아웃 성공"); + } } diff --git a/src/main/java/com/ureka/techpost/domain/auth/handler/CustomLogoutHandler.java b/src/main/java/com/ureka/techpost/domain/auth/handler/CustomLogoutHandler.java deleted file mode 100644 index e9cfa57..0000000 --- a/src/main/java/com/ureka/techpost/domain/auth/handler/CustomLogoutHandler.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.ureka.techpost.domain.auth.handler; - -import com.ureka.techpost.domain.auth.service.TokenService; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.Authentication; -import org.springframework.security.web.authentication.logout.LogoutHandler; -import org.springframework.stereotype.Component; - -/** - * @file CustomLogoutHandler.java - @author 김동혁, 구본문 - @version 1.0 - @since 2025-12-08 - @description 이 파일은 실제 로그아웃 로직(토큰 삭제, 쿠키 정리)을 TokenService에 위임하여 실행하는 커스텀 로그아웃 핸들러 클래스입니다. - */ -@Component -@RequiredArgsConstructor -public class CustomLogoutHandler implements LogoutHandler { - - private final TokenService tokenService; - - @Override - public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { - // 로그아웃 로직을 TokenService에 위임 - tokenService.logout(request, response); - } -} diff --git a/src/main/java/com/ureka/techpost/domain/auth/handler/OAuth2LoginSuccessHandler.java b/src/main/java/com/ureka/techpost/domain/auth/handler/OAuth2LoginSuccessHandler.java index 1b66ca8..7e46c61 100644 --- a/src/main/java/com/ureka/techpost/domain/auth/handler/OAuth2LoginSuccessHandler.java +++ b/src/main/java/com/ureka/techpost/domain/auth/handler/OAuth2LoginSuccessHandler.java @@ -40,18 +40,12 @@ public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHan public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { CustomUserDetails oAuth2User = (CustomUserDetails) authentication.getPrincipal(); - String username = oAuth2User.getUsername(); - Collection authorities = oAuth2User.getAuthorities(); - Iterator iterator = authorities.iterator(); - GrantedAuthority auth = iterator.next(); - String role = auth.getAuthority(); - // 우리 시스템의 JWT 토큰 생성 - String access = jwtUtil.generateAccessToken("access", username, role); + String access = jwtUtil.generateAccessToken("access", oAuth2User.getUsername(), oAuth2User.getUser().getRoleName()); String refresh = jwtUtil.generateRefreshToken("refresh"); // 리프레시 토큰 저장 및 쿠키에 추가 - User user = userRepository.findByUsername(username) + User user = userRepository.findByUsername(oAuth2User.getUsername()) .orElseThrow(() -> new UsernameNotFoundException("해당하는 유저를 찾을 수 없습니다.")); tokenService.addRefreshToken(user, refresh); response.addCookie(tokenService.createCookie("refresh", refresh)); diff --git a/src/main/java/com/ureka/techpost/domain/auth/service/AuthService.java b/src/main/java/com/ureka/techpost/domain/auth/service/AuthService.java index 9e13bc8..daf3fee 100644 --- a/src/main/java/com/ureka/techpost/domain/auth/service/AuthService.java +++ b/src/main/java/com/ureka/techpost/domain/auth/service/AuthService.java @@ -106,17 +106,7 @@ public ResponseEntity reissue(HttpServletRequest request, HttpServletResponse } String accessToken = authorization.split(" ")[1]; - // Refresh 토큰 검증 - String refresh = null; - Cookie[] cookies = request.getCookies(); - if (cookies != null) { - for (Cookie cookie : cookies) { - if (cookie.getName().equals("refresh")) { - refresh = cookie.getValue(); - break; - } - } - } + String refresh = getRefreshTokenFromCookie(request); tokenService.validateRefreshToken(refresh); @@ -143,4 +133,42 @@ public ResponseEntity reissue(HttpServletRequest request, HttpServletResponse return new ResponseEntity<>(HttpStatus.OK); } + // 로그아웃 처리 + public void logout(HttpServletRequest request, HttpServletResponse response) { + String refresh = getRefreshTokenFromCookie(request); + + // 토큰이 존재하면 검증 및 DB 삭제 시도 + if (refresh != null) { + try { + // 토큰 검증 (만료, 위조, DB 존재 여부 확인) + tokenService.validateRefreshToken(refresh); + // DB에서 Refresh 토큰 제거 + tokenService.deleteByTokenValue(refresh); + } catch (InvalidTokenException e) { + // 토큰이 유효하지 않거나(만료 등), 이미 DB에 없는 경우 + // 로그아웃 과정이므로 무시하고 쿠키 삭제로 넘어감 + } + } + + // response에서 쿠키 제거 (항상 수행하여 클라이언트 상태 정리) + Cookie cookie = new Cookie("refresh", null); + cookie.setMaxAge(0); + cookie.setPath("/"); + response.addCookie(cookie); + } + + private static String getRefreshTokenFromCookie(HttpServletRequest request) { + // Refresh 토큰 검증 + String refresh = null; + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (Cookie cookie : cookies) { + if (cookie.getName().equals("refresh")) { + refresh = cookie.getValue(); + break; + } + } + } + return refresh; + } } \ No newline at end of file diff --git a/src/main/java/com/ureka/techpost/domain/auth/service/TokenService.java b/src/main/java/com/ureka/techpost/domain/auth/service/TokenService.java index d6a2704..136f907 100644 --- a/src/main/java/com/ureka/techpost/domain/auth/service/TokenService.java +++ b/src/main/java/com/ureka/techpost/domain/auth/service/TokenService.java @@ -26,39 +26,6 @@ public class TokenService { private final RefreshTokenRepository refreshTokenRepository; private final JwtUtil jwtUtil; - // 로그아웃 처리 - public void logout(HttpServletRequest request, HttpServletResponse response) { - String refresh = null; - Cookie[] cookies = request.getCookies(); - if (cookies != null) { - for (Cookie cookie : cookies) { - if (cookie.getName().equals("refresh")) { - refresh = cookie.getValue(); - break; - } - } - } - - // 토큰이 존재하면 검증 및 DB 삭제 시도 - if (refresh != null) { - try { - // 토큰 검증 (만료, 위조, DB 존재 여부 확인) - validateRefreshToken(refresh); - // DB에서 Refresh 토큰 제거 - deleteByTokenValue(refresh); - } catch (InvalidTokenException e) { - // 토큰이 유효하지 않거나(만료 등), 이미 DB에 없는 경우 - // 로그아웃 과정이므로 무시하고 쿠키 삭제로 넘어감 - } - } - - // response에서 쿠키 제거 (항상 수행하여 클라이언트 상태 정리) - Cookie cookie = new Cookie("refresh", null); - cookie.setMaxAge(0); - cookie.setPath("/"); - response.addCookie(cookie); - } - // DB에 Refresh 토큰 저장 (Redis) public void addRefreshToken(User user, String refresh) { // Redis에 저장할 객체 생성 diff --git a/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java b/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java index ad6b200..1784340 100644 --- a/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java +++ b/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java @@ -1,18 +1,15 @@ package com.ureka.techpost.global.config; -import com.ureka.techpost.domain.auth.handler.CustomLogoutHandler; import com.ureka.techpost.domain.auth.handler.OAuth2LoginSuccessHandler; import com.ureka.techpost.domain.auth.jwt.JwtAuthenticationFilter; import com.ureka.techpost.domain.auth.jwt.JwtUtil; import com.ureka.techpost.domain.auth.service.CustomOAuth2UserService; import com.ureka.techpost.domain.auth.service.TokenService; import com.ureka.techpost.domain.user.repository.UserRepository; -import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -35,7 +32,6 @@ public class SecurityConfig { private final JwtUtil jwtUtil; private final UserRepository userRepository; private final TokenService tokenService; - private final CustomLogoutHandler customLogoutHandler; private final CustomOAuth2UserService customOAuth2UserService; private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; @@ -90,13 +86,6 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .successHandler(oAuth2LoginSuccessHandler) ) - .logout(logout -> logout - .logoutUrl("/api/auth/logout") - .addLogoutHandler(customLogoutHandler) - .logoutSuccessHandler((request, response, authentication) -> { - response.setStatus(HttpServletResponse.SC_OK); - })) - .addFilterBefore(new JwtAuthenticationFilter(jwtUtil, userRepository, tokenService), UsernamePasswordAuthenticationFilter.class); From 19a483203a762e41372194367b7e0df3419bc619 Mon Sep 17 00:00:00 2001 From: bon0512 Date: Wed, 10 Dec 2025 16:05:23 +0900 Subject: [PATCH 51/57] =?UTF-8?q?[Refactor]=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=98=88=EC=99=B8=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20#32?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/service/AuthService.java | 12 ++++++------ .../domain/auth/service/TokenService.java | 19 ++++++++++--------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/ureka/techpost/domain/auth/service/AuthService.java b/src/main/java/com/ureka/techpost/domain/auth/service/AuthService.java index 9e13bc8..70da658 100644 --- a/src/main/java/com/ureka/techpost/domain/auth/service/AuthService.java +++ b/src/main/java/com/ureka/techpost/domain/auth/service/AuthService.java @@ -2,10 +2,11 @@ import com.ureka.techpost.domain.auth.dto.LoginDto; import com.ureka.techpost.domain.auth.dto.SignupDto; -import com.ureka.techpost.domain.auth.exception.InvalidTokenException; import com.ureka.techpost.domain.auth.jwt.JwtUtil; import com.ureka.techpost.domain.user.entity.User; import com.ureka.techpost.domain.user.repository.UserRepository; +import com.ureka.techpost.global.exception.CustomException; +import com.ureka.techpost.global.exception.ErrorCode; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -17,7 +18,6 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -48,7 +48,7 @@ public class AuthService { public void signup(SignupDto signupDto) { // DB에 입력한 username이 존재하는지 확인 if (userRepository.existsByUsername(signupDto.getUsername())) { - throw new RuntimeException("이미 가입되어 있는 회원입니다."); + throw new CustomException(ErrorCode.USER_ALREADY_EXISTS); } // 없으면 DB에 회원 저장 @@ -83,7 +83,7 @@ public void login(LoginDto loginDto, HttpServletResponse response) { // DB에서 사용자 정보 조회 (리프레시 토큰 저장을 위함) User user = userRepository.findByUsername(authenticatedUsername) - .orElseThrow(() -> new UsernameNotFoundException("해당하는 회원을 찾을 수 없습니다.")); + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); // 새로 발급된 리프레시 토큰을 DB에 저장 (기존 토큰이 있다면 업데이트) tokenService.addRefreshToken(user, refresh); @@ -102,7 +102,7 @@ public ResponseEntity reissue(HttpServletRequest request, HttpServletResponse String authorization = request.getHeader("Authorization"); // Access Token 검증 if (authorization == null || !authorization.startsWith("Bearer ")) { - throw new InvalidTokenException("액세스 토큰이 없습니다."); + throw new CustomException(ErrorCode.ACCESS_TOKEN_MISSING); } String accessToken = authorization.split(" ")[1]; @@ -126,7 +126,7 @@ public ResponseEntity reissue(HttpServletRequest request, HttpServletResponse String username = jwtUtil.getUsernameFromExpirationToken(accessToken); User foundUser = userRepository.findByUsername(username) - .orElseThrow(() -> new UsernameNotFoundException("회원을 찾을 수 없습니다.")); + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); // 새로운 access/refresh 토큰 생성 String newAccess = jwtUtil.generateAccessToken("access", username, foundUser.getRoleName()); diff --git a/src/main/java/com/ureka/techpost/domain/auth/service/TokenService.java b/src/main/java/com/ureka/techpost/domain/auth/service/TokenService.java index d6a2704..246e7d1 100644 --- a/src/main/java/com/ureka/techpost/domain/auth/service/TokenService.java +++ b/src/main/java/com/ureka/techpost/domain/auth/service/TokenService.java @@ -1,10 +1,11 @@ package com.ureka.techpost.domain.auth.service; import com.ureka.techpost.domain.auth.entity.RefreshToken; -import com.ureka.techpost.domain.auth.exception.InvalidTokenException; import com.ureka.techpost.domain.auth.jwt.JwtUtil; import com.ureka.techpost.domain.auth.repository.RefreshTokenRepository; import com.ureka.techpost.domain.user.entity.User; +import com.ureka.techpost.global.exception.CustomException; +import com.ureka.techpost.global.exception.ErrorCode; import io.jsonwebtoken.ExpiredJwtException; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; @@ -46,7 +47,7 @@ public void logout(HttpServletRequest request, HttpServletResponse response) { validateRefreshToken(refresh); // DB에서 Refresh 토큰 제거 deleteByTokenValue(refresh); - } catch (InvalidTokenException e) { + } catch (CustomException e) { // 토큰이 유효하지 않거나(만료 등), 이미 DB에 없는 경우 // 로그아웃 과정이므로 무시하고 쿠키 삭제로 넘어감 } @@ -95,40 +96,40 @@ public void deleteByTokenValue(String tokenValue) { // 리프레시 토큰 검증 public void validateRefreshToken(String token) { if (token == null) { - throw new InvalidTokenException("리프레시 토큰이 없습니다."); + throw new CustomException(ErrorCode.REFRESH_TOKEN_MISSING); } try { jwtUtil.isExpired(token); } catch (ExpiredJwtException e) { - throw new InvalidTokenException("만료된 리프레시 토큰입니다."); + throw new CustomException(ErrorCode.REFRESH_TOKEN_EXPIRED); } String category = jwtUtil.getCategory(token); if (!category.equals("refresh")) { - throw new InvalidTokenException("유효하지 않은 카테고리의 토큰입니다."); + throw new CustomException(ErrorCode.INVALID_TOKEN_CATEGORY); } if (!existsByTokenValue(token)) { - throw new InvalidTokenException("DB에 존재하지 않는 리프레시 토큰입니다."); + throw new CustomException(ErrorCode.REFRESH_TOKEN_NOT_FOUND); } } // 액세스 토큰 검증 public void validateAccessToken(String token) { if (token == null) { - throw new InvalidTokenException("액세스 토큰이 없습니다."); + throw new CustomException(ErrorCode.ACCESS_TOKEN_MISSING); } try { jwtUtil.isExpired(token); } catch (ExpiredJwtException e) { - throw new InvalidTokenException("만료된 액세스 토큰입니다."); + throw new CustomException(ErrorCode.ACCESS_TOKEN_EXPIRED); } String category = jwtUtil.getCategory(token); if (!category.equals("access")) { - throw new InvalidTokenException("유효하지 않은 카테고리의 토큰입니다."); + throw new CustomException(ErrorCode.INVALID_TOKEN_CATEGORY); } } From 52ea9b07b303bf7ae0c6d68b3d1ba1c3d16043f6 Mon Sep 17 00:00:00 2001 From: yechan <100present@naver.com> Date: Wed, 10 Dec 2025 16:28:31 +0900 Subject: [PATCH 52/57] =?UTF-8?q?[FIX]=20:=20=EA=B2=BD=EB=A1=9C=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 --- .../techpost/domain/comment/controller/CommentController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/ureka/techpost/domain/comment/controller/CommentController.java b/src/main/java/com/ureka/techpost/domain/comment/controller/CommentController.java index 743f4a1..be7f8d6 100644 --- a/src/main/java/com/ureka/techpost/domain/comment/controller/CommentController.java +++ b/src/main/java/com/ureka/techpost/domain/comment/controller/CommentController.java @@ -6,7 +6,7 @@ import com.ureka.techpost.domain.comment.dto.CommentResponseDTO; import com.ureka.techpost.domain.comment.service.CommentService; import com.ureka.techpost.global.apiPayload.ApiResponse; -import com.ureka.techpost.global.apiPayload.code.status.SuccessStatus; +import com.ureka.techpost.global.apiPayload.code.SuccessStatus; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; From 85260d81ba14d97dbf3d3434d5037265f7cc5a2a Mon Sep 17 00:00:00 2001 From: bon0512 Date: Wed, 10 Dec 2025 17:31:15 +0900 Subject: [PATCH 53/57] =?UTF-8?q?[Refactor]=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=98=88=EC=99=B8=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20#32?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/CustomAccessDeniedHandler.java | 44 +++++++++++++++++++ .../CustomAuthenticationEntryPoint.java | 42 ++++++++++++++++++ .../CustomAuthenticationFailureHandler.java | 44 +++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 src/main/java/com/ureka/techpost/domain/auth/handler/CustomAccessDeniedHandler.java create mode 100644 src/main/java/com/ureka/techpost/domain/auth/handler/CustomAuthenticationEntryPoint.java create mode 100644 src/main/java/com/ureka/techpost/domain/auth/handler/CustomAuthenticationFailureHandler.java diff --git a/src/main/java/com/ureka/techpost/domain/auth/handler/CustomAccessDeniedHandler.java b/src/main/java/com/ureka/techpost/domain/auth/handler/CustomAccessDeniedHandler.java new file mode 100644 index 0000000..b2b6c2b --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/handler/CustomAccessDeniedHandler.java @@ -0,0 +1,44 @@ +package com.ureka.techpost.domain.auth.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ureka.techpost.global.exception.ErrorResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +/** + * @file CustomAuthenticationEntryPoint.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-10 + @description 이 파일은 전역 예외 형식에 맞게 예외를 보내주는 파일입니다. error 403 + */ +@Component +@RequiredArgsConstructor +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException { + + ErrorResponse errorResponse = ErrorResponse.builder() + .status(HttpStatus.FORBIDDEN) + .code("ACCESS_DENIED") + .message("접근 권한이 없습니다.") + .build(); + + response.setStatus(HttpStatus.FORBIDDEN.value()); + response.setContentType("application/json;charset=UTF-8"); + objectMapper.writeValue(response.getWriter(), errorResponse); + } +} diff --git a/src/main/java/com/ureka/techpost/domain/auth/handler/CustomAuthenticationEntryPoint.java b/src/main/java/com/ureka/techpost/domain/auth/handler/CustomAuthenticationEntryPoint.java new file mode 100644 index 0000000..191bb3d --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/handler/CustomAuthenticationEntryPoint.java @@ -0,0 +1,42 @@ +package com.ureka.techpost.domain.auth.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ureka.techpost.global.exception.ErrorResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; +/** + * @file CustomAuthenticationEntryPoint.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-10 + @description 이 파일은 전역 예외 형식에 맞게 예외를 보내주는 파일입니다. error 401 + */ +@Component +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException { + + ErrorResponse errorResponse = ErrorResponse.builder() + .status(HttpStatus.UNAUTHORIZED) + .code("AUTHENTICATION_FAILED") + .message("인증에 실패했습니다.") + .build(); + + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setContentType("application/json;charset=UTF-8"); + objectMapper.writeValue(response.getWriter(), errorResponse); + } +} \ No newline at end of file diff --git a/src/main/java/com/ureka/techpost/domain/auth/handler/CustomAuthenticationFailureHandler.java b/src/main/java/com/ureka/techpost/domain/auth/handler/CustomAuthenticationFailureHandler.java new file mode 100644 index 0000000..ff43b22 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/handler/CustomAuthenticationFailureHandler.java @@ -0,0 +1,44 @@ +package com.ureka.techpost.domain.auth.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ureka.techpost.global.exception.ErrorResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +/** + * @file CustomAuthenticationFailureHandler.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-10 + @description 이 파일은 전역 예외 형식에 맞게 예외를 보내주는 파일입니다. error 401 + */ + +@Component +public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public void onAuthenticationFailure( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException exception + ) throws IOException { + + ErrorResponse errorResponse = ErrorResponse.builder() + .status(HttpStatus.UNAUTHORIZED) + .code("LOGIN_FAILED") + .message("로그인에 실패했습니다.") + .build(); + + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setContentType("application/json;charset=UTF-8"); + objectMapper.writeValue(response.getWriter(), errorResponse); + } +} From 0c47f2ad10543d1ac0e46e8efe4069ef2825f881 Mon Sep 17 00:00:00 2001 From: bon0512 Date: Wed, 10 Dec 2025 17:43:57 +0900 Subject: [PATCH 54/57] =?UTF-8?q?[Refactor]=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=98=88=EC=99=B8=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20#32?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/config/SecurityConfig.java | 135 ++++++++++-------- .../auth/handler/CustomHandlersTest.java | 77 ++++++++++ 2 files changed, 151 insertions(+), 61 deletions(-) create mode 100644 src/test/java/com/ureka/techpost/domain/auth/handler/CustomHandlersTest.java diff --git a/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java b/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java index 1784340..acdf7be 100644 --- a/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java +++ b/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java @@ -1,5 +1,8 @@ package com.ureka.techpost.global.config; +import com.ureka.techpost.domain.auth.handler.CustomAccessDeniedHandler; +import com.ureka.techpost.domain.auth.handler.CustomAuthenticationEntryPoint; +import com.ureka.techpost.domain.auth.handler.CustomAuthenticationFailureHandler; import com.ureka.techpost.domain.auth.handler.OAuth2LoginSuccessHandler; import com.ureka.techpost.domain.auth.jwt.JwtAuthenticationFilter; import com.ureka.techpost.domain.auth.jwt.JwtUtil; @@ -29,66 +32,76 @@ @RequiredArgsConstructor public class SecurityConfig { - private final JwtUtil jwtUtil; - private final UserRepository userRepository; - private final TokenService tokenService; - private final CustomOAuth2UserService customOAuth2UserService; - private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; - - static final String[] WHITE_LIST = {"/swagger-ui/**", - "/v3/api-docs/**", - "/swagger-resources/**", - "/health", - "/", "/login", "/signup", "/css/**", "/js/**", "/oauth2/**", - "/api/auth/**", + private final JwtUtil jwtUtil; + private final UserRepository userRepository; + private final TokenService tokenService; + private final CustomOAuth2UserService customOAuth2UserService; + private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; + + + static final String[] WHITE_LIST = {"/swagger-ui/**", + "/v3/api-docs/**", + "/swagger-resources/**", + "/health", + "/", "/login", "/signup", "/css/**", "/js/**", "/oauth2/**", + "/api/auth/**", "/connect/**" - }; - - @Bean - public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { - return configuration.getAuthenticationManager(); - } - - @Bean - public CorsConfigurationSource corsConfigurationSource() { - CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOrigins(Collections.singletonList("http://localhost:5173")); - configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); - configuration.setAllowedHeaders(Collections.singletonList("*")); - configuration.setAllowCredentials(true); - configuration.setExposedHeaders(Collections.singletonList("Authorization")); - configuration.setMaxAge(3600L); - - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", configuration); - return source; - } - - @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - - http - .cors(cors -> cors.configurationSource(corsConfigurationSource())) - .csrf(AbstractHttpConfigurer::disable) - .formLogin(AbstractHttpConfigurer::disable) - .httpBasic(AbstractHttpConfigurer::disable) - .sessionManagement(session -> session - .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - - .authorizeHttpRequests(auth -> auth - .requestMatchers(WHITE_LIST).permitAll() - .anyRequest().authenticated() - ) - - .oauth2Login(oauth2 -> oauth2 - .userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig - .userService(customOAuth2UserService)) - .successHandler(oAuth2LoginSuccessHandler) - ) - - .addFilterBefore(new JwtAuthenticationFilter(jwtUtil, userRepository, tokenService), UsernamePasswordAuthenticationFilter.class); - - - return http.build(); - } + }; + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { + return configuration.getAuthenticationManager(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(Collections.singletonList("http://localhost:5173")); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(Collections.singletonList("*")); + configuration.setAllowCredentials(true); + configuration.setExposedHeaders(Collections.singletonList("Authorization")); + configuration.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http, + CustomAuthenticationEntryPoint authenticationEntryPoint, + CustomAuthenticationFailureHandler authenticationFailureHandler, + CustomAccessDeniedHandler AccessDeniedHandler) throws Exception { + + http + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + + .authorizeHttpRequests(auth -> auth + .requestMatchers(WHITE_LIST).permitAll() + .anyRequest().authenticated() + ) + + .exceptionHandling(e -> e + .authenticationEntryPoint(authenticationEntryPoint) + .accessDeniedHandler(AccessDeniedHandler) + ) + + .oauth2Login(oauth2 -> oauth2 + .userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig + .userService(customOAuth2UserService)) + .successHandler(oAuth2LoginSuccessHandler) + .failureHandler(authenticationFailureHandler) + ) + + .addFilterBefore(new JwtAuthenticationFilter(jwtUtil, userRepository, tokenService), UsernamePasswordAuthenticationFilter.class); + + + return http.build(); + } } diff --git a/src/test/java/com/ureka/techpost/domain/auth/handler/CustomHandlersTest.java b/src/test/java/com/ureka/techpost/domain/auth/handler/CustomHandlersTest.java new file mode 100644 index 0000000..6a135da --- /dev/null +++ b/src/test/java/com/ureka/techpost/domain/auth/handler/CustomHandlersTest.java @@ -0,0 +1,77 @@ +package com.ureka.techpost.domain.auth.handler; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; + +import static org.junit.jupiter.api.Assertions.*; + +class CustomHandlersTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + @Test + // AccessDeniedHandler: 권한 부족 시 403 코드, JSON 응답 형식(status/code/message)을 반환하는지 검증 + void accessDeniedHandler_returnsForbiddenJson() throws Exception { + var handler = new CustomAccessDeniedHandler(new ObjectMapper()); + var request = new MockHttpServletRequest(); + var response = new MockHttpServletResponse(); + + handler.handle(request, response, new AccessDeniedException("denied")); + + // 기대: 권한 부족 시 HTTP 403 + assertEquals(HttpServletResponse.SC_FORBIDDEN, response.getStatus()); + // 기대: JSON UTF-8 응답 헤더 + assertEquals("application/json;charset=UTF-8", response.getContentType()); + // 기대: 바디의 status/code/message 필드가 존재하고 값이 비어 있지 않음 + JsonNode body = OBJECT_MAPPER.readTree(response.getContentAsString()); + assertEquals("FORBIDDEN", body.get("status").asText()); + assertEquals("ACCESS_DENIED", body.get("code").asText()); + assertFalse(body.get("message").asText().isBlank()); + } + + @Test + // AuthenticationEntryPoint: 인증되지 않은 요청에 401 코드와 JSON 응답을 반환하는지 검증 + void entryPoint_returnsUnauthorizedJson() throws Exception { + var entryPoint = new CustomAuthenticationEntryPoint(); + var request = new MockHttpServletRequest(); + var response = new MockHttpServletResponse(); + + entryPoint.commence(request, response, new AuthenticationException("auth fail") {}); + + // 기대: 인증 실패 시 HTTP 401 + assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus()); + // 기대: JSON UTF-8 응답 헤더 + assertEquals("application/json;charset=UTF-8", response.getContentType()); + // 기대: 바디의 status/code/message 필드가 존재하고 값이 비어 있지 않음 + JsonNode body = OBJECT_MAPPER.readTree(response.getContentAsString()); + assertEquals("UNAUTHORIZED", body.get("status").asText()); + assertEquals("AUTHENTICATION_FAILED", body.get("code").asText()); + assertFalse(body.get("message").asText().isBlank()); + } + + @Test + // AuthenticationFailureHandler: 로그인 실패 시 401 코드와 JSON 응답을 반환하는지 검증 + void failureHandler_returnsUnauthorizedJson() throws Exception { + var failureHandler = new CustomAuthenticationFailureHandler(); + var request = new MockHttpServletRequest(); + var response = new MockHttpServletResponse(); + + failureHandler.onAuthenticationFailure(request, response, new AuthenticationException("login fail") {}); + + // 기대: 로그인 실패 시 HTTP 401 + assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus()); + // 기대: JSON UTF-8 응답 헤더 + assertEquals("application/json;charset=UTF-8", response.getContentType()); + // 기대: 바디의 status/code/message 필드가 존재하고 값이 비어 있지 않음 + JsonNode body = OBJECT_MAPPER.readTree(response.getContentAsString()); + assertEquals("UNAUTHORIZED", body.get("status").asText()); + assertEquals("LOGIN_FAILED", body.get("code").asText()); + assertFalse(body.get("message").asText().isBlank()); + } +} From 6e113cbaeda6b6cfcf04522ca3e0a19d0f753f95 Mon Sep 17 00:00:00 2001 From: yechan <100present@naver.com> Date: Thu, 11 Dec 2025 13:34:25 +0900 Subject: [PATCH 55/57] =?UTF-8?q?[FIX]=20:=20gitignore=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20application.yml=20=ED=8C=8C=EC=9D=BC=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 --- src/main/resources/application.yml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 src/main/resources/application.yml diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..4d57d1b --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,6 @@ +spring: + profiles: + active: dev # 기본은 dev + + application: + name: techpost From 077f7a5bec78b69ab6c5367b300bd69bbba7947f Mon Sep 17 00:00:00 2001 From: yechan <100present@naver.com> Date: Thu, 11 Dec 2025 13:36:09 +0900 Subject: [PATCH 56/57] =?UTF-8?q?[FIX]=20:=20gitignore=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20application.yml=20=ED=8C=8C=EC=9D=BC=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 --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index e2d2bbf..653a0ba 100644 --- a/.gitignore +++ b/.gitignore @@ -44,7 +44,6 @@ out/ .DS_Store ### application ### -/src/main/resources/application.yml /src/main/resources/application-dev.yml /src/main/resources/application-prod.yml From 86b04e7434f59505cdb37a18175d83372b3768b5 Mon Sep 17 00:00:00 2001 From: yechan <100present@naver.com> Date: Thu, 11 Dec 2025 14:17:01 +0900 Subject: [PATCH 57/57] =?UTF-8?q?[FIX]=20:=20application-prod.yml=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 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 48165df..d8cc932 100644 --- a/README.md +++ b/README.md @@ -1 +1 @@ -# Tech-Post_BE \ No newline at end of file +# Tech-Post_BE. \ No newline at end of file