diff --git a/.gitignore b/.gitignore index d6709aa..db44e26 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ +### Project Files ### HELP.md + +### Gradle ### .gradle build/ !gradle/wrapper/gradle-wrapper.jar @@ -28,12 +31,26 @@ out/ ### NetBeans ### /nbproject/private/ -/nbbuild/ -/dist/ -/nbdist/ -/.nb-gradle/ +nbbuild/ +dist/ +nbdist/ +.nb-gradle/ ### VS Code ### .vscode/ -*.yml \ No newline at end of file +### OS Files ### +.DS_Store +Thumbs.db + +### Logs ### +*.log +logs/ + +### Environment & Config Files ### +.env +application.properties +application.yml + +### Others ### +*.class \ No newline at end of file diff --git a/build.gradle b/build.gradle index 144ae94..1d0a2b5 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ plugins { id 'java' - id 'org.springframework.boot' version '3.5.5' + id 'org.springframework.boot' version '3.5.7' id 'io.spring.dependency-management' version '1.1.7' } @@ -22,6 +22,12 @@ configurations { repositories { mavenCentral() + maven { url 'https://repo.spring.io/milestone' } + maven { url 'https://repo.spring.io/snapshot' } + maven { + name = 'Central Portal Snapshots' + url = 'https://central.sonatype.com/repository/maven-snapshots/' + } } ext { @@ -29,13 +35,33 @@ ext { } dependencies { + //공통 implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + //스웨거 + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0' + + //상렬이거 + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'io.jsonwebtoken:jjwt:0.9.1' + implementation 'javax.xml.bind:jaxb-api:2.3.1' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + + //내거 + implementation 'org.springframework.boot:spring-boot-starter-webflux' + implementation platform("org.springframework.ai:spring-ai-bom:1.0.0-SNAPSHOT") + implementation 'org.springframework.ai:spring-ai-starter-model-openai' + implementation 'org.springframework.boot:spring-boot-starter-websocket' + implementation 'org.springframework.boot:spring-boot-starter-cache' + implementation 'com.github.ben-manes.caffeine:caffeine' + } dependencyManagement { diff --git a/src/main/java/project/backend/BackendApplication.java b/src/main/java/project/backend/BackendApplication.java index ceb02f5..7019853 100644 --- a/src/main/java/project/backend/BackendApplication.java +++ b/src/main/java/project/backend/BackendApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; @SpringBootApplication +@EnableCaching public class BackendApplication { public static void main(String[] args) { diff --git a/src/main/java/project/backend/chat/ChatController.java b/src/main/java/project/backend/chat/ChatController.java new file mode 100644 index 0000000..4a3a1af --- /dev/null +++ b/src/main/java/project/backend/chat/ChatController.java @@ -0,0 +1,29 @@ +package project.backend.chat; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.stereotype.Controller; +import project.backend.chat.dto.SendChatMessageRequest; + +import java.security.Principal; + +@Slf4j +@Controller +@RequiredArgsConstructor +public class ChatController { + + private final ChatMessageService chatMessageService; + + @MessageMapping("/chat/message") + public void handleMessage(SendChatMessageRequest messageRequest, Principal principal) { + // StompAuthChannelInterceptor가 'User.id' (String)를 넣어줌 + String senderUserIdStr = principal.getName(); + Long senderUserId = Long.parseLong(senderUserIdStr); + + log.info("Message received from User.id {}: roomId={}, content={}", + senderUserId, messageRequest.getRoomId(), messageRequest.getContent()); + + chatMessageService.sendMessage(senderUserId, messageRequest); + } +} \ No newline at end of file diff --git a/src/main/java/project/backend/chat/ChatMessageService.java b/src/main/java/project/backend/chat/ChatMessageService.java new file mode 100644 index 0000000..fdec99d --- /dev/null +++ b/src/main/java/project/backend/chat/ChatMessageService.java @@ -0,0 +1,97 @@ +package project.backend.chat; + +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.simp.SimpMessageSendingOperations; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import project.backend.chat.dto.ChatMessageDTO; +import project.backend.chat.dto.SendChatMessageRequest; +import project.backend.chat.entity.ChatMessage; +import project.backend.chat.entity.ChatRoom; +import project.backend.chat.repository.ChatMessageRepository; +import project.backend.chat.repository.ChatRoomRepository; +import project.backend.user.UserRepository; // KakaoUserRepository 제거 +import project.backend.user.entity.User; + +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ChatMessageService { + + private final ChatMessageRepository chatMessageRepository; + private final ChatRoomRepository chatRoomRepository; + private final UserRepository userRepository; // User 리포지토리 사용 + private final SimpMessageSendingOperations messagingTemplate; + + /** + * 메시지 전송 및 저장 + * (이제 KakaoUser 관련 로직이 완전히 제거되었습니다) + */ + @Transactional + public void sendMessage(Long senderUserId, SendChatMessageRequest request) { + + // 1. 발신자(Sender) 조회 (User.id로) + User senderUser = userRepository.findById(senderUserId) + .orElseThrow(() -> new EntityNotFoundException("Sender User not found: " + senderUserId)); + + // 2. 채팅방 조회 + ChatRoom room = chatRoomRepository.findById(request.getRoomId()) + .orElseThrow(() -> new EntityNotFoundException("ChatRoom not found: " + request.getRoomId())); + + // 3. 수신자(Receiver) 조회 + User receiverUser = room.getOtherParticipant(senderUser); + if (receiverUser == null) { + // 이 경우는 채팅방에 참여자가 1명이거나 잘못된 경우 + log.error("Receiver not found in room: {}", request.getRoomId()); + throw new EntityNotFoundException("Receiver not found in room"); + } + + // 4. 메시지 엔티티 생성 및 저장 + ChatMessage message = new ChatMessage(room, senderUser, request.getContent()); + chatMessageRepository.save(message); + + // 5. 채팅방 마지막 메시지 업데이트 (목록 정렬용) + room.setLastMessage(message.getContent(), message.getTimestamp()); + // (트랜잭션 종료 시 자동 저장됨) + + // 6. DTO 변환 + ChatMessageDTO messageDTO = ChatMessageDTO.fromEntity(message); + + // 7. WebSocket으로 메시지 전송 + // StompAuthChannelInterceptor에서 User.id를 String으로 Principal에 저장했으므로, + // .convertAndSendToUser의 첫 번째 인자(user)는 User.id의 String 값이어야 합니다. + + // 수신자에게 전송 + messagingTemplate.convertAndSendToUser( + String.valueOf(receiverUser.getId()), // 수신자의 User.id (String) + "/queue/chat", // 구독 주소 + messageDTO // 전송할 메시지 + ); + + // 발신자에게도 전송 (본인 화면 업데이트용) + messagingTemplate.convertAndSendToUser( + String.valueOf(senderUser.getId()), // 발신자의 User.id (String) + "/queue/chat", + messageDTO + ); + + log.info("Message sent from User {} to User {}", senderUser.getId(), receiverUser.getId()); + } + + /** + * 특정 채팅방의 메시지 내역 조회 + */ + @Transactional(readOnly = true) + public List getChatMessages(Long roomId) { + // TODO: (선택) roomId에 현재 로그인한 유저가 포함되어 있는지 확인하는 인가 로직 추가 + return chatMessageRepository.findByChatRoomIdOrderByTimestampAsc(roomId) + .stream() + .map(ChatMessageDTO::fromEntity) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/src/main/java/project/backend/chat/ChatRoomController.java b/src/main/java/project/backend/chat/ChatRoomController.java new file mode 100644 index 0000000..2651c8c --- /dev/null +++ b/src/main/java/project/backend/chat/ChatRoomController.java @@ -0,0 +1,103 @@ +package project.backend.chat; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import project.backend.chat.dto.ChatMessageDTO; +import project.backend.chat.dto.ChatRoomDTO; +import project.backend.chat.dto.CreateChatRoomRequest; +import project.backend.kakaoLogin.KakaoUser; +import project.backend.pythonapi.dto.SajuResponse; + +import java.util.List; + +@RestController +@RequestMapping("/api/chat") +@RequiredArgsConstructor +@Tag(name = "채팅 API (REST)", description = "채팅방 생성, 목록 조회, 메시지 내역 조회") +@SecurityRequirement(name = "bearerAuth") +public class ChatRoomController { + + private final ChatRoomService chatRoomService; + private final ChatMessageService chatMessageService; + + @Operation(summary = "1:1 채팅방 생성 또는 조회") + @PostMapping("/room") + public ResponseEntity createOrGetRoom( + @AuthenticationPrincipal KakaoUser kakaoUser, + @RequestBody CreateChatRoomRequest request) { + + if (kakaoUser.getUser() == null) { + return ResponseEntity.status(403).build(); // 회원가입 미완료 + } + Long currentUserId = kakaoUser.getUser().getId(); + + ChatRoomDTO room = chatRoomService.createOrGetRoom(currentUserId, request.getMatchedUserId()); + return ResponseEntity.ok(room); + } + + @Operation(summary = "내 채팅방 목록 조회") + @GetMapping("/rooms") + public ResponseEntity> getMyChatRooms(@AuthenticationPrincipal KakaoUser kakaoUser) { + + Long currentUserId = kakaoUser.getUser().getId(); + List rooms = chatRoomService.getUserChatRooms(currentUserId); + return ResponseEntity.ok(rooms); + } + + @Operation(summary = "특정 채팅방 메시지 내역 조회") + @GetMapping("/room/{roomId}/messages") + public ResponseEntity> getChatMessages( + @PathVariable Long roomId, + @AuthenticationPrincipal KakaoUser kakaoUser) { + + // TODO: (선택) kakaoUser.getUser().getId()가 이 roomId에 접근 권한이 있는지 확인 + + List messages = chatMessageService.getChatMessages(roomId); + return ResponseEntity.ok(messages); + } + + @Operation(summary = "채팅방 나가기 (채팅방 및 대화 내역 삭제)", + description = "채팅방을 나갑니다. 1:1 채팅이므로 방 자체가 삭제되며, 상대방에게도 목록에서 사라집니다.") + @DeleteMapping("/room/{roomId}") + public ResponseEntity leaveChatRoom( + @AuthenticationPrincipal KakaoUser kakaoUser, + @PathVariable Long roomId) { + + if (kakaoUser.getUser() == null) { + return ResponseEntity.status(403).build(); // Forbidden + } + Long currentUserId = kakaoUser.getUser().getId(); + + try { + chatRoomService.deleteChatRoom(currentUserId, roomId); + return ResponseEntity.ok().build(); // 성공 (200 OK) + } catch (EntityNotFoundException e) { + return ResponseEntity.notFound().build(); // 방이 없음 (404 Not Found) + } catch (AccessDeniedException e) { + return ResponseEntity.status(403).build(); // 권한 없음 (403 Forbidden) + } + } + + @Operation(summary = "채팅방 궁합 점수 조회", description = "채팅방 ID를 통해 저장된 두 사람의 사주 궁합 결과를 조회") + @GetMapping("/room/{roomId}/saju") + public ResponseEntity getSajuInfo( + @PathVariable Long roomId, + @AuthenticationPrincipal KakaoUser kakaoUser) { + + if (kakaoUser.getUser() == null) { + return ResponseEntity.status(403).build(); + } + Long currentUserId = kakaoUser.getUser().getId(); + + SajuResponse response = chatRoomService.getSajuInfoInRoom(roomId, currentUserId); + + return ResponseEntity.ok(response); + } +} \ No newline at end of file diff --git a/src/main/java/project/backend/chat/ChatRoomService.java b/src/main/java/project/backend/chat/ChatRoomService.java new file mode 100644 index 0000000..e982bf5 --- /dev/null +++ b/src/main/java/project/backend/chat/ChatRoomService.java @@ -0,0 +1,107 @@ +package project.backend.chat; + +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import project.backend.chat.dto.ChatRoomDTO; +import project.backend.chat.entity.ChatRoom; +import project.backend.chat.repository.ChatRoomRepository; +import project.backend.matching.MatchSajuInfoRepository; +import project.backend.matching.entity.MatchSajuInfo; +import project.backend.pythonapi.dto.SajuResponse; +import project.backend.user.UserRepository; +import project.backend.user.entity.User; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ChatRoomService { + + private final ChatRoomRepository chatRoomRepository; + private final UserRepository userRepository; + private final MatchSajuInfoRepository matchSajuInfoRepository; + + /** + * 1:1 채팅방 생성 또는 조회 + */ + @Transactional + public ChatRoomDTO createOrGetRoom(Long currentUserId, Long matchedUserId) { + User user1 = userRepository.findById(currentUserId) + .orElseThrow(() -> new EntityNotFoundException("Current user not found: " + currentUserId)); + User user2 = userRepository.findById(matchedUserId) + .orElseThrow(() -> new EntityNotFoundException("Matched user not found: " + matchedUserId)); + + ChatRoom room = chatRoomRepository.findByParticipants(user1, user2) + .orElseGet(() -> { + ChatRoom newRoom = new ChatRoom(user1, user2); + return chatRoomRepository.save(newRoom); + }); + + return ChatRoomDTO.fromEntity(room, user1); + } + + /** + * 내 채팅방 목록 조회 + */ + public List getUserChatRooms(Long currentUserId) { + User user = userRepository.findById(currentUserId) + .orElseThrow(() -> new EntityNotFoundException("Current user not found: " + currentUserId)); + + List rooms = chatRoomRepository.findAllByUser(user); + + return rooms.stream() + .map(room -> ChatRoomDTO.fromEntity(room, user)) + .collect(Collectors.toList()); + } + + /** + * 채팅방 나가기 (채팅방 및 메시지 삭제) + */ + @Transactional + public void deleteChatRoom(Long currentUserId, Long roomId) { + User currentUser = userRepository.findById(currentUserId) + .orElseThrow(() -> new EntityNotFoundException("Current user not found: " + currentUserId)); + + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> new EntityNotFoundException("ChatRoom not found: " + roomId)); + + // 요청한 유저가 해당 채팅방의 참여자인지 확인 (인가) + if (!room.getParticipants().contains(currentUser)) { + throw new AccessDeniedException("User is not a participant of this chat room."); + } + + chatRoomRepository.delete(room); + } + + //채팅방 내에서 상대방과 내 궁합점수 조회 + public SajuResponse getSajuInfoInRoom(Long roomId, Long currentUserId) { + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> new EntityNotFoundException("ChatRoom not found: " + roomId)); + + User me = userRepository.findById(currentUserId) + .orElseThrow(() -> new EntityNotFoundException("Current user not found")); + + User partner = room.getOtherParticipant(me); + if (partner == null) { + throw new EntityNotFoundException("Partner not found in this room"); + } + + MatchSajuInfo info = matchSajuInfoRepository.findByUsers(me, partner) + .orElseThrow(() -> new IllegalArgumentException("두 유저 사이의 궁합 정보가 없습니다.")); + + return new SajuResponse( + info.getOriginalScore(), + info.getFinalScore(), + info.getStressScore(), + info.getPerson1SalAnalysis(), + info.getPerson2SalAnalysis(), + info.getMatchAnalysis(), + null + ); + } +} \ No newline at end of file diff --git a/src/main/java/project/backend/chat/StompAuthChannelInterceptor.java b/src/main/java/project/backend/chat/StompAuthChannelInterceptor.java new file mode 100644 index 0000000..765bc2a --- /dev/null +++ b/src/main/java/project/backend/chat/StompAuthChannelInterceptor.java @@ -0,0 +1,76 @@ +package project.backend.chat; + +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.stereotype.Component; +import project.backend.kakaoLogin.JwtTokenProvider; +import project.backend.kakaoLogin.KakaoUser; +import project.backend.kakaoLogin.KakaoUserRepository; +import project.backend.user.entity.User; + +@Slf4j +@Component +@RequiredArgsConstructor +public class StompAuthChannelInterceptor implements ChannelInterceptor { + + private final JwtTokenProvider jwtTokenProvider; + private final KakaoUserRepository kakaoUserRepository; // User.id를 찾기 위해 필요 + + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + + if (StompCommand.CONNECT.equals(accessor.getCommand())) { + String authToken = accessor.getFirstNativeHeader("Authorization"); + + if (authToken != null && authToken.startsWith("Bearer ")) { + String token = authToken.substring(7); + + if (jwtTokenProvider.validateToken(token)) { + // 1. JWT에서 'kakaoId' 또는 'email' (String) 추출 + String principalIdentifier = jwtTokenProvider.getUserIdFromToken(token); + + if (principalIdentifier != null) { + try { + // 2. DB에서 KakaoUser 조회 + KakaoUser kakaoUser = kakaoUserRepository.findByEmail(principalIdentifier) + .or(() -> kakaoUserRepository.findByKakaoId(principalIdentifier)) + .orElseThrow(() -> new EntityNotFoundException("KakaoUser not found: " + principalIdentifier)); + + // 3. KakaoUser에 연결된 'User' 엔티티 조회 + User user = kakaoUser.getUser(); + if (user == null) { + throw new EntityNotFoundException("User entity not linked for KakaoUser: " + principalIdentifier + ". (Signup not completed)"); + } + + // 4. 'User.id' (Long)를 String으로 변환 + String userId = String.valueOf(user.getId()); + + // 5. WebSocket 세션의 Principal로 'User.id' (String)를 설정 + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userId, null, null); + accessor.setUser(authentication); + log.info("STOMP user authenticated. Principal name set to User.id: {}", userId); + + } catch (EntityNotFoundException e) { + log.warn("STOMP connection failed: {}", e.getMessage()); + } + } + } else { + log.warn("STOMP connection failed: Invalid JWT token"); + } + } else { + log.warn("STOMP connection failed: Missing or invalid Authorization header"); + } + } + return message; + } +} \ No newline at end of file diff --git a/src/main/java/project/backend/chat/WebSocketConfig.java b/src/main/java/project/backend/chat/WebSocketConfig.java new file mode 100644 index 0000000..aa8b638 --- /dev/null +++ b/src/main/java/project/backend/chat/WebSocketConfig.java @@ -0,0 +1,48 @@ +package project.backend.chat; + +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; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@Configuration +@EnableWebSocketMessageBroker +@RequiredArgsConstructor +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + private final StompAuthChannelInterceptor stompAuthChannelInterceptor; + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + // 1. 메시지 브로커가 처리할 prefix 설정 + // "/queue" -> 1:1 메시징 + // "/topic" -> 브로드캐스팅 (공지 등) + registry.enableSimpleBroker("/queue", "/topic"); + + // 2. 클라이언트가 서버로 메시지를 보낼 때 사용할 prefix + // (예: /app/chat/message) + registry.setApplicationDestinationPrefixes("/app"); + + // 3. 1:1 메시징을 위한 prefix + // (컨트롤러에서 @SendToUser 또는 SimpMessagingTemplate.convertAndSendToUser 사용 시) + // 클라이언트는 /user/queue/chat 와 같은 주소를 구독합니다. + registry.setUserDestinationPrefix("/user"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + // WebSocket 연결을 위한 엔드포인트 + registry.addEndpoint("/ws/chat") + .setAllowedOriginPatterns("*") // CORS 허용 + .withSockJS(); // SockJS 지원 + } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + // STOMP CONNECT 시 JWT 인증을 처리할 인터셉터 등록 + registration.interceptors(stompAuthChannelInterceptor); + } +} \ No newline at end of file diff --git a/src/main/java/project/backend/chat/dto/ChatMessageDTO.java b/src/main/java/project/backend/chat/dto/ChatMessageDTO.java new file mode 100644 index 0000000..548ee21 --- /dev/null +++ b/src/main/java/project/backend/chat/dto/ChatMessageDTO.java @@ -0,0 +1,30 @@ +package project.backend.chat.dto; + +import lombok.Builder; +import lombok.Getter; +import project.backend.chat.entity.ChatMessage; + +import java.time.LocalDateTime; + +// 메시지 조회 및 실시간 전송용 DTO +@Getter +@Builder +public class ChatMessageDTO { + private Long messageId; + private Long roomId; + private Long senderId; + private String senderName; + private String content; + private LocalDateTime timestamp; + + public static ChatMessageDTO fromEntity(ChatMessage message) { + return ChatMessageDTO.builder() + .messageId(message.getId()) + .roomId(message.getChatRoom().getId()) + .senderId(message.getSender().getId()) + .senderName(message.getSender().getName()) + .content(message.getContent()) + .timestamp(message.getTimestamp()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/project/backend/chat/dto/ChatRoomDTO.java b/src/main/java/project/backend/chat/dto/ChatRoomDTO.java new file mode 100644 index 0000000..78bfecf --- /dev/null +++ b/src/main/java/project/backend/chat/dto/ChatRoomDTO.java @@ -0,0 +1,34 @@ +package project.backend.chat.dto; + +import lombok.Builder; +import lombok.Getter; +import project.backend.chat.entity.ChatRoom; +import project.backend.user.entity.User; + +import java.time.LocalDateTime; + +// 채팅방 목록 조회용 DTO +@Getter +@Builder +public class ChatRoomDTO { + private Long roomId; + private Long otherUserId; + private String otherUserName; + private String otherUserProfileImage; + private String lastMessage; + private LocalDateTime lastMessageTimestamp; + + public static ChatRoomDTO fromEntity(ChatRoom room, User currentUser) { + User otherUser = room.getOtherParticipant(currentUser); + String profileImage = (otherUser.getUserProfile() != null) ? otherUser.getUserProfile().getProfileImagePath() : null; + + return ChatRoomDTO.builder() + .roomId(room.getId()) + .otherUserId(otherUser.getId()) + .otherUserName(otherUser.getName()) + .otherUserProfileImage(profileImage) + .lastMessage(room.getLastMessage()) + .lastMessageTimestamp(room.getLastMessageTimestamp()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/project/backend/chat/dto/CreateChatRoomRequest.java b/src/main/java/project/backend/chat/dto/CreateChatRoomRequest.java new file mode 100644 index 0000000..84e2116 --- /dev/null +++ b/src/main/java/project/backend/chat/dto/CreateChatRoomRequest.java @@ -0,0 +1,11 @@ +package project.backend.chat.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class CreateChatRoomRequest { + // 매칭된 상대방의 User ID + private Long matchedUserId; +} \ No newline at end of file diff --git a/src/main/java/project/backend/chat/dto/SendChatMessageRequest.java b/src/main/java/project/backend/chat/dto/SendChatMessageRequest.java new file mode 100644 index 0000000..4f1565d --- /dev/null +++ b/src/main/java/project/backend/chat/dto/SendChatMessageRequest.java @@ -0,0 +1,11 @@ +package project.backend.chat.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class SendChatMessageRequest { + private Long roomId; + private String content; +} \ No newline at end of file diff --git a/src/main/java/project/backend/chat/entity/ChatMessage.java b/src/main/java/project/backend/chat/entity/ChatMessage.java new file mode 100644 index 0000000..ce7e9c3 --- /dev/null +++ b/src/main/java/project/backend/chat/entity/ChatMessage.java @@ -0,0 +1,39 @@ +package project.backend.chat.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import project.backend.user.entity.User; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor +public class ChatMessage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "room_id", nullable = false) + private ChatRoom chatRoom; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "sender_id", nullable = false) + private User sender; + + @Column(nullable = false, length = 1000) + private String content; + + @Column(nullable = false) + private LocalDateTime timestamp; + + public ChatMessage(ChatRoom chatRoom, User sender, String content) { + this.chatRoom = chatRoom; + this.sender = sender; + this.content = content; + this.timestamp = LocalDateTime.now(); + } +} \ No newline at end of file diff --git a/src/main/java/project/backend/chat/entity/ChatRoom.java b/src/main/java/project/backend/chat/entity/ChatRoom.java new file mode 100644 index 0000000..cc81429 --- /dev/null +++ b/src/main/java/project/backend/chat/entity/ChatRoom.java @@ -0,0 +1,56 @@ +package project.backend.chat.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import project.backend.user.entity.User; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Entity +@Getter +@NoArgsConstructor +public class ChatRoom { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // 두 사용자를 저장. ManyToMany를 사용해 유연하게 관리 + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable( + name = "chatroom_participants", + joinColumns = @JoinColumn(name = "room_id"), + inverseJoinColumns = @JoinColumn(name = "user_id") + ) + private Set participants = new HashSet<>(); + + // 가장 마지막 메시지 (채팅방 목록 정렬용) + private String lastMessage; + private LocalDateTime lastMessageTimestamp; + + @OneToMany(mappedBy = "chatRoom", cascade = CascadeType.ALL, orphanRemoval = true) + private List messages; + + public ChatRoom(User user1, User user2) { + this.participants.add(user1); + this.participants.add(user2); + this.lastMessageTimestamp = LocalDateTime.now(); + } + + public void setLastMessage(String lastMessage, LocalDateTime timestamp) { + this.lastMessage = lastMessage; + this.lastMessageTimestamp = timestamp; + } + + // 채팅방에서 상대방 유저 찾기 + public User getOtherParticipant(User currentUser) { + return participants.stream() + .filter(user -> !user.getId().equals(currentUser.getId())) + .findFirst() + .orElse(null); + } +} \ No newline at end of file diff --git a/src/main/java/project/backend/chat/repository/ChatMessageRepository.java b/src/main/java/project/backend/chat/repository/ChatMessageRepository.java new file mode 100644 index 0000000..2b0ebc3 --- /dev/null +++ b/src/main/java/project/backend/chat/repository/ChatMessageRepository.java @@ -0,0 +1,12 @@ +package project.backend.chat.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import project.backend.chat.entity.ChatMessage; + +import java.util.List; + +public interface ChatMessageRepository extends JpaRepository { + + // 특정 채팅방의 모든 메시지 조회 (시간순) + List findByChatRoomIdOrderByTimestampAsc(Long chatRoomId); +} \ No newline at end of file diff --git a/src/main/java/project/backend/chat/repository/ChatRoomRepository.java b/src/main/java/project/backend/chat/repository/ChatRoomRepository.java new file mode 100644 index 0000000..3458345 --- /dev/null +++ b/src/main/java/project/backend/chat/repository/ChatRoomRepository.java @@ -0,0 +1,21 @@ +package project.backend.chat.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import project.backend.chat.entity.ChatRoom; +import project.backend.user.entity.User; + +import java.util.List; +import java.util.Optional; + +public interface ChatRoomRepository extends JpaRepository { + + // 특정 유저가 참여하고 있는 모든 채팅방 목록 조회 (최신 메시지 순 정렬) + @Query("SELECT r FROM ChatRoom r WHERE :user MEMBER OF r.participants ORDER BY r.lastMessageTimestamp DESC") + List findAllByUser(@Param("user") User user); + + // 두 명의 유저로 채팅방 찾기 + @Query("SELECT r FROM ChatRoom r WHERE :user1 MEMBER OF r.participants AND :user2 MEMBER OF r.participants") + Optional findByParticipants(@Param("user1") User user1, @Param("user2") User user2); +} \ No newline at end of file diff --git a/src/main/java/project/backend/config/CacheConfig.java b/src/main/java/project/backend/config/CacheConfig.java new file mode 100644 index 0000000..af36f8a --- /dev/null +++ b/src/main/java/project/backend/config/CacheConfig.java @@ -0,0 +1,21 @@ +package project.backend.config; + +import com.github.benmanes.caffeine.cache.Caffeine; +import org.springframework.cache.CacheManager; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class CacheConfig { + + @Bean + public CacheManager cacheManager() { + CaffeineCacheManager cacheManager = new CaffeineCacheManager(); + + cacheManager.setCaffeine(Caffeine.newBuilder().maximumSize(100)); + cacheManager.setCacheNames(java.util.Collections.singletonList("fortunes")); + + return cacheManager; + } +} diff --git a/src/main/java/project/backend/config/SwaggerConfig.java b/src/main/java/project/backend/config/SwaggerConfig.java new file mode 100644 index 0000000..ed0a1ae --- /dev/null +++ b/src/main/java/project/backend/config/SwaggerConfig.java @@ -0,0 +1,34 @@ +package project.backend.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import java.util.Collections; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .components(new Components().addSecuritySchemes("bearerAuth", securityScheme())) + .security(Collections.singletonList(new SecurityRequirement().addList("bearerAuth"))) + .info(new Info() + .title("Dating App API") + .description("팀프 2조의 API 문서입니다.") + .version("1.0.0")); + } + + private SecurityScheme securityScheme() { + return new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .in(SecurityScheme.In.HEADER) + .name("Authorization"); + } +} diff --git a/src/main/java/project/backend/config/WebConfig.java b/src/main/java/project/backend/config/WebConfig.java new file mode 100644 index 0000000..2d568c1 --- /dev/null +++ b/src/main/java/project/backend/config/WebConfig.java @@ -0,0 +1,19 @@ +package project.backend.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Value("${file.upload-dir:uploads/profile}") + private String uploadDir; + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + registry.addResourceHandler("/uploads/profile/**") + .addResourceLocations("file:" + uploadDir + "/"); + } +} \ No newline at end of file diff --git a/src/main/java/project/backend/config/WebSecurityConfig.java b/src/main/java/project/backend/config/WebSecurityConfig.java new file mode 100644 index 0000000..b92947d --- /dev/null +++ b/src/main/java/project/backend/config/WebSecurityConfig.java @@ -0,0 +1,55 @@ +package project.backend.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +public class WebSecurityConfig { + + private static final String[] SWAGGER_URLS = { + "/swagger-ui.html", + "/swagger-ui/**", + "/v3/api-docs/**", + "/swagger-resources/**", + "/webjars/**" + }; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + // csrf 비활성화 + .csrf(csrf -> csrf.disable()) + // CORS 설정 허용 + .cors(Customizer.withDefaults()) + // 요청별 인가 규칙 + .authorizeHttpRequests(auth -> auth + // 테스트를 위해 "/api/**"를 추가함, 추후 보안 고려 시 세밀하게 관리하도록 수정하는 게 좋음 + .requestMatchers("/auth/**", "/login/**", "/api/**", "/oauth2/**", "/ws/chat/**").permitAll() + .requestMatchers(SWAGGER_URLS).permitAll() + .anyRequest().authenticated() + ) + // OAuth2 로그인 설정 + .oauth2Login(oauth -> oauth + .defaultSuccessUrl("/auth/kakao/callback", true) + ) + // 세션 비활성화 (JWT 사용 시) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} \ No newline at end of file diff --git a/src/main/java/project/backend/fortune/FortuneController.java b/src/main/java/project/backend/fortune/FortuneController.java new file mode 100644 index 0000000..3a57c3e --- /dev/null +++ b/src/main/java/project/backend/fortune/FortuneController.java @@ -0,0 +1,25 @@ +package project.backend.fortune; + +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import project.backend.fortune.dto.FortuneDTO; + +@RestController +@RequestMapping("/fortune") +@RequiredArgsConstructor +@Tag(name = "오늘의 운세",description = "오늘의 운세 기능(포춘쿠키)") +public class FortuneController { + + private final FortuneService fortuneService; + + @GetMapping + public ResponseEntity getTodayFortune() { + FortuneDTO fortune = fortuneService.getTodayFortune(); + return ResponseEntity.ok(fortune); + } +} + diff --git a/src/main/java/project/backend/fortune/FortuneService.java b/src/main/java/project/backend/fortune/FortuneService.java new file mode 100644 index 0000000..0ff0f1e --- /dev/null +++ b/src/main/java/project/backend/fortune/FortuneService.java @@ -0,0 +1,20 @@ +package project.backend.fortune; + +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import project.backend.fortune.dto.FortuneDTO; +import project.backend.openai.OpenAiService; + +@Service +@RequiredArgsConstructor +public class FortuneService { + + private final OpenAiService openAiService; + + @Cacheable(value = "fortunes" ,key = "T(java.time.LocalDate).now().toString()") + public FortuneDTO getTodayFortune() { + return openAiService.getTodayFortune(); + } +} + diff --git a/src/main/java/project/backend/fortune/dto/FortuneDTO.java b/src/main/java/project/backend/fortune/dto/FortuneDTO.java new file mode 100644 index 0000000..873a483 --- /dev/null +++ b/src/main/java/project/backend/fortune/dto/FortuneDTO.java @@ -0,0 +1,15 @@ +package project.backend.fortune.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class FortuneDTO { + + private final String overallFortune; // 총운 + private final String loveFortune; // 애정운 + private final String moneyFortune; // 금전운 + private final String careerFortune; // 직장운 +} + diff --git a/src/main/java/project/backend/kakaoLogin/AuthController.java b/src/main/java/project/backend/kakaoLogin/AuthController.java new file mode 100644 index 0000000..10fb5ff --- /dev/null +++ b/src/main/java/project/backend/kakaoLogin/AuthController.java @@ -0,0 +1,52 @@ +package project.backend.kakaoLogin; + +import org.springframework.web.bind.annotation.GetMapping; +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.servlet.view.RedirectView; + +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +@Tag(name = "auth-controller", description = "카카오 로그인") +public class AuthController { + + private final KakaoOAuthService kakaoOAuthService; + + @GetMapping("/kakao/callback") + public RedirectView kakaoCallback(@RequestParam("code") String code) { + + JwtTokenResponse tokens = kakaoOAuthService.loginWithKakao(code); + + // 딥링크 URL 구성 (앱으로 반환) + String redirectUrl = String.format( + "divineapp://auth/kakao/callback?accessToken=%s&refreshToken=%s&newUser=%s", + tokens.getAccessToken(), + tokens.getRefreshToken(), + tokens.isNewUser() + ); + + return new RedirectView(redirectUrl); + } +} + +/* +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +@Tag(name = "auth-controller" ,description = "카카오 로그인") +public class AuthController { + + private final KakaoOAuthService kakaoOAuthService; + + @GetMapping("/kakao/callback") + public ResponseEntity kakaoCallback(@RequestParam("code") String code) { + JwtTokenResponse tokens = kakaoOAuthService.loginWithKakao(code); + return ResponseEntity.ok(tokens); + } +} +*/ \ No newline at end of file diff --git a/src/main/java/project/backend/kakaoLogin/JwtAuthenticationFilter.java b/src/main/java/project/backend/kakaoLogin/JwtAuthenticationFilter.java new file mode 100644 index 0000000..16c4826 --- /dev/null +++ b/src/main/java/project/backend/kakaoLogin/JwtAuthenticationFilter.java @@ -0,0 +1,72 @@ +package project.backend.kakaoLogin; + +import java.io.IOException; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import io.jsonwebtoken.Claims; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + private final KakaoUserRepository userRepository; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) + throws ServletException, IOException { + + // Authorization 헤더에서 JWT 추출 + String header = request.getHeader("Authorization"); + if (header == null || !header.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + String token = header.substring(7); + + // 토큰 검증 + if (!jwtTokenProvider.validateToken(token)) { + filterChain.doFilter(request, response); + return; + } + + // 토큰에서 사용자 식별 정보(email 또는 kakaoId) 추출 + Claims claims = jwtTokenProvider.getClaims(token); + String subject = claims.getSubject(); + + if (subject == null) { + filterChain.doFilter(request, response); + return; + } + + // DB에서 사용자 조회 + KakaoUser user = userRepository.findByEmail(subject) + .orElseGet(() -> userRepository.findByKakaoId(subject).orElse(null)); + + if (user == null) { + filterChain.doFilter(request, response); + return; + } + + // 인증 객체 생성 및 SecurityContext에 등록 + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(user, null, null); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + SecurityContextHolder.getContext().setAuthentication(authentication); + filterChain.doFilter(request, response); + } +} \ No newline at end of file diff --git a/src/main/java/project/backend/kakaoLogin/JwtProperties.java b/src/main/java/project/backend/kakaoLogin/JwtProperties.java new file mode 100644 index 0000000..5d3d3ef --- /dev/null +++ b/src/main/java/project/backend/kakaoLogin/JwtProperties.java @@ -0,0 +1,16 @@ +package project.backend.kakaoLogin; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import lombok.Getter; +import lombok.Setter; + +@Setter +@Getter +@Component +@ConfigurationProperties("jwt") +public class JwtProperties { + private String issuer; + private String secretKey; +} \ No newline at end of file diff --git a/src/main/java/project/backend/kakaoLogin/JwtTokenProvider.java b/src/main/java/project/backend/kakaoLogin/JwtTokenProvider.java new file mode 100644 index 0000000..0e8b58e --- /dev/null +++ b/src/main/java/project/backend/kakaoLogin/JwtTokenProvider.java @@ -0,0 +1,79 @@ +package project.backend.kakaoLogin; + +import java.util.Date; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.impl.Base64Codec; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class JwtTokenProvider { + + @Value("${jwt.secret-key}") + private String secretKey; + + // 유효기간 설정 + private final long accessTokenValidity = 60 * 60 * 1000L; // 1시간 + private final long refreshTokenValidity = 7 * 24 * 60 * 60 * 1000L; // 7일 + + // Access Token 생성 + public String createAccessToken(String userId, String role) { + return createToken(userId, role, accessTokenValidity); + } + + // Refresh Token 생성 + public String createRefreshToken(String userId, String role) { + return createToken(userId, role, refreshTokenValidity); + } + + // 공통 토큰 생성 로직 + private String createToken(String userId, String role, long validity) { + Claims claims = Jwts.claims().setSubject(userId); + claims.put("role", role); + + Date now = new Date(); + Date expiration = new Date(now.getTime() + validity); + + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(expiration) + .signWith(SignatureAlgorithm.HS256, secretKey.getBytes()) + .compact(); + } + + // 토큰에서 사용자 ID 추출 + public String getUserIdFromToken(String token) { + return Jwts.parser() + .setSigningKey(secretKey.getBytes()) + .parseClaimsJws(token) + .getBody() + .getSubject(); + } + + // 토큰 유효성 검증 + public boolean validateToken(String token) { + try { + Jwts.parser().setSigningKey(secretKey.getBytes()).parseClaimsJws(token); + return true; + } catch (JwtException | IllegalArgumentException e) { + log.warn("JWT 검증 실패: {}", e.getMessage()); + return false; + } + } + + // Claims 추출 + public Claims getClaims(String token) { + return Jwts.parser() + .setSigningKey(Base64Codec.BASE64.encode(secretKey)) + .parseClaimsJws(token) + .getBody(); + } +} \ No newline at end of file diff --git a/src/main/java/project/backend/kakaoLogin/JwtTokenResponse.java b/src/main/java/project/backend/kakaoLogin/JwtTokenResponse.java new file mode 100644 index 0000000..07ac27c --- /dev/null +++ b/src/main/java/project/backend/kakaoLogin/JwtTokenResponse.java @@ -0,0 +1,16 @@ +package project.backend.kakaoLogin; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class JwtTokenResponse { + private String accessToken; + private String refreshToken; + private boolean isNewUser; // 기본 정보 없음 -> 신규 가입자 +} \ No newline at end of file diff --git a/src/main/java/project/backend/kakaoLogin/KakaoOAuthService.java b/src/main/java/project/backend/kakaoLogin/KakaoOAuthService.java new file mode 100644 index 0000000..4abfe6c --- /dev/null +++ b/src/main/java/project/backend/kakaoLogin/KakaoOAuthService.java @@ -0,0 +1,133 @@ +package project.backend.kakaoLogin; + +import java.util.Map; +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class KakaoOAuthService { + + private final KakaoUserRepository userRepository; + private final RefreshTokenRepository refreshTokenRepository; + private final JwtTokenProvider jwtTokenProvider; + + private final RestTemplate restTemplate = new RestTemplate(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Value("${spring.security.oauth2.client.registration.kakao.client-id}") + private String clientId; + + @Value("${spring.security.oauth2.client.registration.kakao.redirect-uri}") + private String redirectUri; + + @Value("${spring.security.oauth2.client.provider.kakao.token-uri}") + private String tokenUri; + + @Value("${spring.security.oauth2.client.provider.kakao.user-info-uri}") + private String userInfoUri; + + public JwtTokenResponse loginWithKakao(String code) { + // access token 요청 + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("grant_type", "authorization_code"); + body.add("client_id", clientId); + body.add("redirect_uri", redirectUri); + body.add("code", code); + + HttpEntity> tokenRequest = new HttpEntity<>(body, headers); + ResponseEntity tokenResponse = restTemplate.exchange(tokenUri, HttpMethod.POST, tokenRequest, String.class); + + Map tokenMap; + try { + tokenMap = objectMapper.readValue(tokenResponse.getBody(), new TypeReference>() {}); + } catch (Exception e) { + throw new RuntimeException("카카오 토큰 요청 실패", e); + } + + String kakaoAccessToken = (String) tokenMap.get("access_token"); + + // 사용자 정보 요청 + HttpHeaders userHeaders = new HttpHeaders(); + userHeaders.setBearerAuth(kakaoAccessToken); + HttpEntity userInfoReq = new HttpEntity<>(userHeaders); + + ResponseEntity userInfoResp = restTemplate.exchange(userInfoUri, HttpMethod.GET, userInfoReq, String.class); + + Map userMap; + try { + userMap = objectMapper.readValue(userInfoResp.getBody(), new TypeReference>() {}); + } catch (Exception e) { + throw new RuntimeException("카카오 사용자 정보 조회 실패", e); + } + + // 사용자 정보 파싱 + String kakaoId = String.valueOf(userMap.get("id")); + Map kakaoAccount = (Map) userMap.get("kakao_account"); + String email = kakaoAccount != null ? (String) kakaoAccount.get("email") : null; + + // DB 사용자 확인/저장 + Optional existingUser = userRepository.findByKakaoId(kakaoId); + boolean isNewUser = existingUser.isEmpty(); + + KakaoUser user = existingUser.orElseGet(() -> { + KakaoUser newUser = KakaoUser.builder() + .kakaoId(kakaoId) + .email(email) + .role(Role.USER) + .build(); + return userRepository.save(newUser); + }); + + // JWT 발급 + String accessToken = jwtTokenProvider.createAccessToken( + (user.getEmail() != null) ? user.getEmail() : user.getKakaoId(), + user.getRole().name() + ); + + String refreshToken = jwtTokenProvider.createRefreshToken( + (user.getEmail() != null) ? user.getEmail() : user.getKakaoId(), + user.getRole().name() + ); + + // RefreshToken 저장 (기존 있으면 갱신) + refreshTokenRepository.findByUserId(user.getKakaoId()) + .ifPresentOrElse(existing -> { + existing.setToken(refreshToken); + refreshTokenRepository.save(existing); + }, () -> { + refreshTokenRepository.save( + RefreshToken.builder() + .userId(user.getKakaoId()) + .token(refreshToken) + .build() + ); + }); + + // 반환 + JwtTokenResponse resp = new JwtTokenResponse(); + resp.setAccessToken(accessToken); + resp.setRefreshToken(refreshToken); + resp.setNewUser(isNewUser); + + return resp; + } +} \ No newline at end of file diff --git a/src/main/java/project/backend/kakaoLogin/KakaoUser.java b/src/main/java/project/backend/kakaoLogin/KakaoUser.java new file mode 100644 index 0000000..9ac3061 --- /dev/null +++ b/src/main/java/project/backend/kakaoLogin/KakaoUser.java @@ -0,0 +1,42 @@ +package project.backend.kakaoLogin; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToOne; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import project.backend.user.entity.User; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class KakaoUser { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true) + private String kakaoId; + + @Column(unique = true) + private String email; + + @Enumerated(EnumType.STRING) + @Builder.Default + private Role role = Role.USER; + + @OneToOne(mappedBy = "kakaoUser", cascade = CascadeType.ALL) + private User user; +} diff --git a/src/main/java/project/backend/kakaoLogin/KakaoUserRepository.java b/src/main/java/project/backend/kakaoLogin/KakaoUserRepository.java new file mode 100644 index 0000000..1db877a --- /dev/null +++ b/src/main/java/project/backend/kakaoLogin/KakaoUserRepository.java @@ -0,0 +1,12 @@ +package project.backend.kakaoLogin; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface KakaoUserRepository extends JpaRepository { + Optional findByKakaoId(String kakaoId); + Optional findByEmail(String email); +} \ No newline at end of file diff --git a/src/main/java/project/backend/kakaoLogin/RefreshToken.java b/src/main/java/project/backend/kakaoLogin/RefreshToken.java new file mode 100644 index 0000000..9c2604e --- /dev/null +++ b/src/main/java/project/backend/kakaoLogin/RefreshToken.java @@ -0,0 +1,36 @@ +package project.backend.kakaoLogin; + +import java.time.LocalDateTime; + +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; +import lombok.Setter; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class RefreshToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String userId; // user의 kakaoId 또는 email + + @Column(nullable = false, length = 500) + private String token; + + @Builder.Default + private LocalDateTime createdAt = LocalDateTime.now(); +} \ No newline at end of file diff --git a/src/main/java/project/backend/kakaoLogin/RefreshTokenRepository.java b/src/main/java/project/backend/kakaoLogin/RefreshTokenRepository.java new file mode 100644 index 0000000..906a7cf --- /dev/null +++ b/src/main/java/project/backend/kakaoLogin/RefreshTokenRepository.java @@ -0,0 +1,9 @@ +package project.backend.kakaoLogin; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RefreshTokenRepository extends JpaRepository { + Optional findByUserId(String userId); +} \ No newline at end of file diff --git a/src/main/java/project/backend/kakaoLogin/Role.java b/src/main/java/project/backend/kakaoLogin/Role.java new file mode 100644 index 0000000..8d5e6b6 --- /dev/null +++ b/src/main/java/project/backend/kakaoLogin/Role.java @@ -0,0 +1,6 @@ +package project.backend.kakaoLogin; + +public enum Role { + USER, + ADMIN +} \ No newline at end of file diff --git a/src/main/java/project/backend/matching/MatchSajuInfoRepository.java b/src/main/java/project/backend/matching/MatchSajuInfoRepository.java new file mode 100644 index 0000000..6c36e8f --- /dev/null +++ b/src/main/java/project/backend/matching/MatchSajuInfoRepository.java @@ -0,0 +1,16 @@ +package project.backend.matching; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import project.backend.matching.entity.MatchSajuInfo; +import project.backend.user.entity.User; +import java.util.Optional; + +public interface MatchSajuInfoRepository extends JpaRepository { + + @Query("SELECT m FROM MatchSajuInfo m WHERE " + + "(m.user = :user AND m.matchedUser = :partner) OR " + + "(m.user = :partner AND m.matchedUser = :user)") + Optional findByUsers(@Param("user") User user, @Param("partner") User partner); +} \ No newline at end of file diff --git a/src/main/java/project/backend/matching/MatchingController.java b/src/main/java/project/backend/matching/MatchingController.java new file mode 100644 index 0000000..6500c46 --- /dev/null +++ b/src/main/java/project/backend/matching/MatchingController.java @@ -0,0 +1,20 @@ +package project.backend.matching; + +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; +import project.backend.matching.dto.MatchingResultDTO; + +@RestController +@RequestMapping("/matching") +@RequiredArgsConstructor +@Tag(name = "매칭하기" , description = "매칭하기 기능(자신의 id(유저 id)만 넣으면 됨)") +public class MatchingController { + + private final MatchingService matchingService; + + @GetMapping("/{userId}") + public MatchingResultDTO getMatchingResult(@PathVariable("userId") Long userId) throws Exception { + return matchingService.getMatchingResult(userId); + } +} diff --git a/src/main/java/project/backend/matching/MatchingService.java b/src/main/java/project/backend/matching/MatchingService.java new file mode 100644 index 0000000..ab7979a --- /dev/null +++ b/src/main/java/project/backend/matching/MatchingService.java @@ -0,0 +1,123 @@ +package project.backend.matching; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import project.backend.matching.dto.MatchingResultDTO; +import project.backend.matching.entity.MatchSajuInfo; +import project.backend.mypage.dto.MyPageDisplayDTO; +import project.backend.openai.OpenAiService; +import project.backend.pythonapi.dto.PersonInfo; +import project.backend.pythonapi.dto.SajuRequest; +import project.backend.pythonapi.dto.SajuResponse; +import project.backend.pythonapi.SajuService; +import project.backend.user.UserRepository; +import project.backend.user.dto.UserEnums; +import project.backend.user.entity.User; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Optional; +import java.util.Random; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MatchingService { + + private final SajuService sajuService; + private final UserRepository userRepository; + private final MatchSajuInfoRepository matchSajuInfoRepository; + + //전체 매칭하기 기능 반환 + public MatchingResultDTO getMatchingResult(Long userId) throws Exception { + + Optional userOptional = userRepository.findById(userId); + if (userOptional.isEmpty()) { + throw new Exception("User not found"); + } + User user = userOptional.get(); + int userGender = 0; + if (user.getGender() == UserEnums.Gender.MALE) { + userGender = 1; + } + + User randomUser = randomUser(user); + int randomUserGender = 0; + if (randomUser.getGender() == UserEnums.Gender.MALE) { + randomUserGender = 1; + } + + SajuRequest sajuRequest = new SajuRequest( + new PersonInfo( + user.getBirthDate().getYear(), + user.getBirthDate().getMonthValue(), + user.getBirthDate().getDayOfMonth(), + userGender), + new PersonInfo( + randomUser.getBirthDate().getYear(), + randomUser.getBirthDate().getMonthValue(), + randomUser.getBirthDate().getDayOfMonth(), + randomUserGender) + ); + Mono sajuResponseMono = getSajuResponse(sajuRequest); + SajuResponse sajuResponse = sajuResponseMono.block(); + + MatchSajuInfo matchInfo = MatchSajuInfo.builder() + .user(user) + .matchedUser(randomUser) + .originalScore(sajuResponse.originalScore()) + .finalScore(sajuResponse.finalScore()) + .stressScore(sajuResponse.stressScore()) + .person1SalAnalysis(sajuResponse.person1SalAnalysis()) + .person2SalAnalysis(sajuResponse.person2SalAnalysis()) + .matchAnalysis(sajuResponse.matchAnalysis()) + .build(); + + matchSajuInfoRepository.save(matchInfo); + + MyPageDisplayDTO myPageDisplayDTO = new MyPageDisplayDTO(randomUser, randomUser.getUserProfile()); + + return MatchingResultDTO.builder().sajuResponse(sajuResponse).personInfo(myPageDisplayDTO).build(); + } + + //랜덤으로 상대방 불러오기(지역 + 씹게이 판별) + private User randomUser(User myUser) { + UserEnums.region region = myUser.getUserProfile().getRegion(); + UserEnums.SexualOrientation sexualOrientation = myUser.getUserProfile().getSexualOrientation(); + UserEnums.Gender myGender = myUser.getGender(); + + List matchingUsers; + + // STRAIGHT인 경우 성별이 반대인 사용자들만 조회 + if (sexualOrientation == UserEnums.SexualOrientation.STRAIGHT) { + matchingUsers = userRepository.findMatchingUsersForStraight( + region, + sexualOrientation, + myUser.getId(), + myGender + ); + } else { + // HOMOSEXUAL인 경우 같은 sexualOrientation을 가진 모든 사용자 조회 + matchingUsers = userRepository.findMatchingUsersByRegionAndOrientation( + region, + sexualOrientation, + myUser.getId() + ); + } + + if (matchingUsers.isEmpty()) { + throw new RuntimeException("매칭 가능한 사용자가 없습니다."); + } + + // 랜덤으로 하나 선택 + Random random = new Random(); + int randomIndex = random.nextInt(matchingUsers.size()); + return matchingUsers.get(randomIndex); + } + + //pythonApi 에서 궁합점수 반환 + private Mono getSajuResponse(SajuRequest sajuRequest) { + return sajuService.getMatchResult(sajuRequest); + } +} diff --git a/src/main/java/project/backend/matching/dto/MatchingResultDTO.java b/src/main/java/project/backend/matching/dto/MatchingResultDTO.java new file mode 100644 index 0000000..30a2ee6 --- /dev/null +++ b/src/main/java/project/backend/matching/dto/MatchingResultDTO.java @@ -0,0 +1,15 @@ +package project.backend.matching.dto; + +import lombok.Builder; +import lombok.Getter; +import project.backend.mypage.dto.MyPageDisplayDTO; +import project.backend.pythonapi.dto.SajuResponse; + +@Getter +@Builder +public class MatchingResultDTO { + + private final SajuResponse sajuResponse; + + private final MyPageDisplayDTO personInfo; +} diff --git a/src/main/java/project/backend/matching/entity/MatchSajuInfo.java b/src/main/java/project/backend/matching/entity/MatchSajuInfo.java new file mode 100644 index 0000000..dc19e55 --- /dev/null +++ b/src/main/java/project/backend/matching/entity/MatchSajuInfo.java @@ -0,0 +1,44 @@ +package project.backend.matching.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import project.backend.user.entity.User; + +@Entity +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "match_saju_info") +public class MatchSajuInfo { + + @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 = "matched_user_id") + private User matchedUser; + + // SajuResponse 데이터 필드들 + private double originalScore; + + private double finalScore; + + private double stressScore; + + private String person1SalAnalysis; + + private String person2SalAnalysis; + + private String matchAnalysis; +} \ No newline at end of file diff --git a/src/main/java/project/backend/mypage/MyPageController.java b/src/main/java/project/backend/mypage/MyPageController.java new file mode 100644 index 0000000..20c1f0b --- /dev/null +++ b/src/main/java/project/backend/mypage/MyPageController.java @@ -0,0 +1,62 @@ +package project.backend.mypage; + +import java.io.IOException; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import lombok.RequiredArgsConstructor; +import project.backend.mypage.dto.MyPageDisplayDTO; +import project.backend.user.dto.UserProfileDTO; + +@RestController +@RequestMapping("/my-page") +@RequiredArgsConstructor +@Tag(name = "마이페이지", description = "마이페이지 관련 컨트롤러(정보 조회, 프로필 수정, 프로필 사진 수정, 회원탈퇴") +public class MyPageController { + + private final MyPageService myPageService; + + // 마이페이지 정보 조회 + @GetMapping("/{userId}") + @Operation(summary = "내 모든 정보 조회" , description = "기본,상세 정보 + 프로필 사진까지 받는 메서드") + public ResponseEntity getMyPageInfo(@PathVariable("userId") Long userId) { + MyPageDisplayDTO myPageInfo = myPageService.getMyPageInfo(userId); + + return ResponseEntity.ok(myPageInfo); + } + + // 마이페이지 프로필 정보 수정 + @PatchMapping("/{userId}/profile") + @Operation(summary = "프로필 정보 수정" ,description = "상세 정보 + 자기소개 글만 수정 가능(프로필 사진은 수정 X)") + public ResponseEntity updateProfile( + @PathVariable("userId") Long userId, + @RequestBody UserProfileDTO userProfileDTO) { + myPageService.editProfile(userId, userProfileDTO); + + return ResponseEntity.ok().build(); + } + + // 마이페이지 프로필 이미지 수정 + @PatchMapping(value = "/{userId}/profile-image", consumes = "multipart/form-data") + @Operation(summary = "프로필 이미지 수정") + public ResponseEntity updateProfileImage( + @PathVariable("userId") Long userId, + @RequestPart("profileImage") MultipartFile profileImage) throws IOException { + String imagePath = myPageService.updateProfileImage(userId, profileImage); + + return ResponseEntity.ok(imagePath); + } + + // 회원 탈퇴 + @DeleteMapping("/{userId}") + @Operation(summary = "회원 탈퇴") + public ResponseEntity deleteUser(@PathVariable("userId") Long userId) { + myPageService.deleteUser(userId); + + return ResponseEntity.ok(userId); + } +} \ No newline at end of file diff --git a/src/main/java/project/backend/mypage/MyPageService.java b/src/main/java/project/backend/mypage/MyPageService.java new file mode 100644 index 0000000..bbeed3c --- /dev/null +++ b/src/main/java/project/backend/mypage/MyPageService.java @@ -0,0 +1,45 @@ +package project.backend.mypage; + +import java.io.IOException; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import org.springframework.web.multipart.MultipartFile; + +import lombok.RequiredArgsConstructor; +import project.backend.mypage.dto.MyPageDisplayDTO; +import project.backend.user.UserService; +import project.backend.user.dto.UserProfileDTO; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MyPageService { + + private final UserService userService; + + // 마이페이지 조회 + public MyPageDisplayDTO getMyPageInfo(Long userId) { + return userService.getUserInfo(userId); + } + + // user 정보 수정 + @Transactional + public void editProfile(Long userId, UserProfileDTO userProfileDTO) { + userService.updateUserProfileInfo(userId, userProfileDTO); + } + + // user 프로필 사진 수정 + @Transactional + public String updateProfileImage(Long userId, MultipartFile profileImage) throws IOException { + return userService.updateUserProfileImage(userId, profileImage); + } + + // 회원 탈퇴 + @Transactional + public void deleteUser(Long userId) { + userService.deleteUser(userId); + } + +} \ No newline at end of file diff --git a/src/main/java/project/backend/mypage/dto/MyPageDisplayDTO.java b/src/main/java/project/backend/mypage/dto/MyPageDisplayDTO.java new file mode 100644 index 0000000..ea892b4 --- /dev/null +++ b/src/main/java/project/backend/mypage/dto/MyPageDisplayDTO.java @@ -0,0 +1,61 @@ +package project.backend.mypage.dto; + +import lombok.Builder; +import lombok.Getter; +import project.backend.user.dto.UserEnums; +import project.backend.user.entity.User; +import project.backend.user.entity.UserProfile; + +import java.time.LocalDate; + +@Getter +public class MyPageDisplayDTO { + + // User 정보 + private Long userId; + private String name; + private UserEnums.Gender gender; + private LocalDate birthDate; + + // UserProfile 정보 + private UserEnums.SexualOrientation sexualOrientation; + private UserEnums.Job job; + private UserEnums.region region; + private UserEnums.DrinkingFrequency drinkingFrequency; + private UserEnums.SmokingStatus smokingStatus; + private Integer height; + private UserEnums.PetPreference petPreference; + private UserEnums.Religion religion; + private UserEnums.ContactFrequency contactFrequency; + private UserEnums.Mbti mbti; + private String introduction; + private String profileImagePath; + + @Builder + public MyPageDisplayDTO(User user, UserProfile profile) { + this.userId = user.getId(); + this.name = user.getName(); + this.gender = user.getGender(); + this.birthDate = user.getBirthDate(); + + if (profile != null) { + this.sexualOrientation = profile.getSexualOrientation(); + this.job = profile.getJob(); + this.region = profile.getRegion(); + this.drinkingFrequency = profile.getDrinkingFrequency(); + this.smokingStatus = profile.getSmokingStatus(); + this.height = profile.getHeight(); + this.petPreference = profile.getPetPreference(); + this.religion = profile.getReligion(); + this.contactFrequency = profile.getContactFrequency(); + this.mbti = profile.getMbti(); + this.introduction = profile.getIntroduction(); + this.profileImagePath = profile.getProfileImagePath(); + } + } + + public static MyPageDisplayDTO fromEntity(User user) { + return new MyPageDisplayDTO(user, user.getUserProfile()); + } + +} diff --git a/src/main/java/project/backend/openai/OpenAiController.java b/src/main/java/project/backend/openai/OpenAiController.java new file mode 100644 index 0000000..91c03a9 --- /dev/null +++ b/src/main/java/project/backend/openai/OpenAiController.java @@ -0,0 +1,95 @@ +package project.backend.openai; + +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import project.backend.openai.dto.ConversationTopicDTO; +import project.backend.openai.dto.DatingCourseDTO; +import project.backend.openai.dto.PromptRequest; +import project.backend.openai.dto.RecommendationRequest; +import project.backend.user.UserRepository; +import project.backend.user.entity.User; + +import java.util.Optional; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/ai") +@Tag(name = "spring-ai-controller", description = "프런트에서 사용할 필요 X") +public class OpenAiController { + + private final OpenAiService openAiService; + private final UserRepository userRepository; + + @PostMapping + public String getGptResponse(@RequestBody PromptRequest request) { + return openAiService.getGptResponse(request.prompt()); + } + + //대화주제 추천 + @PostMapping("/conversation-topics") + public ResponseEntity getConversationTopics( + @RequestBody RecommendationRequest request) throws Exception { + + Long myUserId = request.myUserId(); + Long matchedUserId = request.matchedUserId(); + + Optional myUserOptional = userRepository.findByIdWithProfile(myUserId); + if (myUserOptional.isEmpty()) { + throw new Exception("내 사용자 정보를 찾을 수 없습니다. userId: " + myUserId); + } + + Optional matchedUserOptional = userRepository.findByIdWithProfile(matchedUserId); + if (matchedUserOptional.isEmpty()) { + throw new Exception("매칭된 사용자 정보를 찾을 수 없습니다. userId: " + matchedUserId); + } + + User myUser = myUserOptional.get(); + User matchedUser = matchedUserOptional.get(); + + // UserProfile이 없는 경우 예외 처리 + if (myUser.getUserProfile() == null) { + throw new Exception("내 사용자 프로필 정보가 없습니다. userId: " + myUserId); + } + if (matchedUser.getUserProfile() == null) { + throw new Exception("매칭된 사용자 프로필 정보가 없습니다. userId: " + matchedUserId); + } + + ConversationTopicDTO conversationTopics = openAiService.getConversationTopics(myUser, matchedUser); + return ResponseEntity.ok(conversationTopics); + } + + //데이트 코스 추천 + @PostMapping("/dating-courses") + public ResponseEntity getDatingCourses( + @RequestBody RecommendationRequest request) throws Exception { + + Long myUserId = request.myUserId(); + Long matchedUserId = request.matchedUserId(); + + Optional myUserOptional = userRepository.findByIdWithProfile(myUserId); + if (myUserOptional.isEmpty()) { + throw new Exception("내 사용자 정보를 찾을 수 없습니다. userId: " + myUserId); + } + + Optional matchedUserOptional = userRepository.findByIdWithProfile(matchedUserId); + if (matchedUserOptional.isEmpty()) { + throw new Exception("매칭된 사용자 정보를 찾을 수 없습니다. userId: " + matchedUserId); + } + + User myUser = myUserOptional.get(); + User matchedUser = matchedUserOptional.get(); + + // UserProfile이 없는 경우 예외 처리 + if (myUser.getUserProfile() == null) { + throw new Exception("내 사용자 프로필 정보가 없습니다. userId: " + myUserId); + } + if (matchedUser.getUserProfile() == null) { + throw new Exception("매칭된 사용자 프로필 정보가 없습니다. userId: " + matchedUserId); + } + + DatingCourseDTO datingCourses = openAiService.getDatingCourseRecommendation(myUser, matchedUser); + return ResponseEntity.ok(datingCourses); + } +} diff --git a/src/main/java/project/backend/openai/OpenAiService.java b/src/main/java/project/backend/openai/OpenAiService.java new file mode 100644 index 0000000..1d2cf3b --- /dev/null +++ b/src/main/java/project/backend/openai/OpenAiService.java @@ -0,0 +1,252 @@ +package project.backend.openai; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.stereotype.Service; +import project.backend.fortune.dto.FortuneDTO; +import project.backend.openai.dto.ConversationTopicDTO; +import project.backend.openai.dto.DatingCourseDTO; +import project.backend.user.entity.User; + +import java.util.ArrayList; +import java.util.List; + +@Service +public class OpenAiService { + + private final ChatClient chatClient; + private final ObjectMapper objectMapper; + + public OpenAiService(ChatClient.Builder chatClientBuilder, ObjectMapper objectMapper) { + this.chatClient = chatClientBuilder.build(); + this.objectMapper = objectMapper; + } + + public String getGptResponse(String prompt) { + + return chatClient.prompt() + .user(prompt) + .call() + .content(); + } + + //오늘의 운세(4가지) + public FortuneDTO getTodayFortune() { + String prompt = """ + 오늘의 운세를 다음 JSON 형식으로 반환해주세요. + 각 운세는 한 문단으로 작성해주세요 (100자 이내). + + { + "overallFortune": "총운 설명", + "loveFortune": "애정운 설명", + "moneyFortune": "금전운 설명", + "careerFortune": "직장운 설명" + } + + 반환 형식은 반드시 JSON만 반환하고, 다른 텍스트는 포함하지 마세요. + """; + + try { + String response = chatClient.prompt() + .user(prompt) + .call() + .content(); + + String jsonResponse = extractJsonFromResponse(response); + JsonNode jsonNode = objectMapper.readTree(jsonResponse); + + return FortuneDTO.builder() + .overallFortune(jsonNode.get("overallFortune").asText()) + .loveFortune(jsonNode.get("loveFortune").asText()) + .moneyFortune(jsonNode.get("moneyFortune").asText()) + .careerFortune(jsonNode.get("careerFortune").asText()) + .build(); + + } catch (Exception e) { + throw new RuntimeException("운세를 가져오는 중 오류가 발생했습니다: " + e.getMessage(), e); + } + } + + //대화주제 추천 + public ConversationTopicDTO getConversationTopics(User myUser, User matchedUser) { + String userInfo = buildUserInfoString(myUser); + String matchedUserInfo = buildUserInfoString(matchedUser); + + String prompt = String.format(""" + 다음 두 사람의 정보를 바탕으로 대화주제를 추천해주세요. + + [내 정보] + %s + + [상대방 정보] + %s + + 두 사람의 공통 관심사, 성격, 취미 등을 고려하여 대화하기 좋은 주제 5개를 추천해주세요. + 각 주제는 간단명료하게 한 문장으로 작성해주세요. + 각 주제 앞에 내용과 어울리는 이모지를 하나씩 붙여주세요. + + 다음 JSON 형식으로 반환해주세요: + { + "topics": ["😊 주제1", "💡 주제2", "💬 주제3", "🤔 주제4", "💖 주제5"] + } + + 이모지 예시: 😊 💡 💬 🤔 💖 🎯 🌟 🎨 🎵 🎮 📚 🎬 🍔 🎪 🎭 + 반환 형식은 반드시 JSON만 반환하고, 다른 텍스트는 포함하지 마세요. + """, userInfo, matchedUserInfo); + + try { + String response = chatClient.prompt() + .user(prompt) + .call() + .content(); + + String jsonResponse = extractJsonFromResponse(response); + JsonNode jsonNode = objectMapper.readTree(jsonResponse); + + List topics = new ArrayList<>(); + JsonNode topicsArray = jsonNode.get("topics"); + if (topicsArray != null && topicsArray.isArray()) { + for (JsonNode topic : topicsArray) { + topics.add(topic.asText()); + } + } + + return ConversationTopicDTO.builder() + .topics(topics) + .build(); + + } catch (Exception e) { + throw new RuntimeException("대화주제를 가져오는 중 오류가 발생했습니다: " + e.getMessage(), e); + } + } + + //데이트 코스 추천 + public DatingCourseDTO getDatingCourseRecommendation(User myUser, User matchedUser) { + String userInfo = buildUserInfoString(myUser); + String matchedUserInfo = buildUserInfoString(matchedUser); + String region = myUser.getUserProfile().getRegion() != null + ? myUser.getUserProfile().getRegion().name() + : "서울"; + + String prompt = String.format(""" + 다음 두 사람의 정보를 바탕으로 데이트 코스를 추천해주세요. + + [내 정보] + %s + + [상대방 정보] + %s + + [지역] + %s + + 두 사람의 성격, 취미, 지역을 고려하여 데이트하기 좋은 코스 5개를 추천해주세요. + 각 코스는 다음 형식으로 작성해주세요: "이모지 장소명 - 활동 설명" + - 각 코스 앞에 내용과 어울리는 이모지를 하나씩 붙여주세요 + - 구체적인 장소명을 반드시 포함해주세요 (예: "한강 공원", "CGV 강남", "이태원 맛집 거리") + - 지역에 맞는 실제 존재하는 장소를 추천해주세요 + - 활동 설명도 함께 작성해주세요 + + 다음 JSON 형식으로 반환해주세요: + { + "courses": ["📍 한강 공원 - 저녁 산책과 야경 감상", "🎬 CGV 강남 - 영화 관람 후 카페 투어", "🍽️ 이태원 맛집 거리 - 다양한 음식 체험", "🎨 삼청동 갤러리 투어 - 예술적인 데이트", "🌳 남산타워 - 야경 감상"] + } + + 이모지 예시: 📍 🎬 🍽️ 🎨 🌳 🎯 🎪 🎭 🏛️ 🎵 🎮 📚 🍔 ☕ 🌸 🏖️ + 반환 형식은 반드시 JSON만 반환하고, 다른 텍스트는 포함하지 마세요. + """, userInfo, matchedUserInfo, region); + + try { + String response = chatClient.prompt() + .user(prompt) + .call() + .content(); + + String jsonResponse = extractJsonFromResponse(response); + JsonNode jsonNode = objectMapper.readTree(jsonResponse); + + List courses = new ArrayList<>(); + JsonNode coursesArray = jsonNode.get("courses"); + if (coursesArray != null && coursesArray.isArray()) { + for (JsonNode course : coursesArray) { + courses.add(course.asText()); + } + } + + return DatingCourseDTO.builder() + .courses(courses) + .build(); + + } catch (Exception e) { + throw new RuntimeException("데이트 코스를 가져오는 중 오류가 발생했습니다: " + e.getMessage(), e); + } + } + + //사용자 정보를 문자열로 변환하는 헬퍼 메서드 + private String buildUserInfoString(User user) { + StringBuilder sb = new StringBuilder(); + sb.append("이름: ").append(user.getName()).append("\n"); + sb.append("성별: ").append(user.getGender()).append("\n"); + + if (user.getUserProfile() != null) { + var profile = user.getUserProfile(); + if (profile.getJob() != null) { + sb.append("직업: ").append(profile.getJob()).append("\n"); + } + if (profile.getMbti() != null) { + sb.append("MBTI: ").append(profile.getMbti()).append("\n"); + } + if (profile.getRegion() != null) { + sb.append("지역: ").append(profile.getRegion()).append("\n"); + } + if (profile.getPetPreference() != null) { + sb.append("반려동물 선호도: ").append(profile.getPetPreference()).append("\n"); + } + if (profile.getDrinkingFrequency() != null) { + sb.append("음주 빈도: ").append(profile.getDrinkingFrequency()).append("\n"); + } + if (profile.getSmokingStatus() != null) { + sb.append("흡연 여부: ").append(profile.getSmokingStatus()).append("\n"); + } + if (profile.getReligion() != null) { + sb.append("종교: ").append(profile.getReligion()).append("\n"); + } + if (profile.getIntroduction() != null && !profile.getIntroduction().isEmpty()) { + sb.append("자기소개: ").append(profile.getIntroduction()).append("\n"); + } + } + + return sb.toString(); + } + + //ai 응답에서 json 만 추출 + private String extractJsonFromResponse(String response) { + String trimmed = response.trim(); + + if (trimmed.startsWith("```json")) { + int start = trimmed.indexOf("{"); + int end = trimmed.lastIndexOf("}") + 1; + return trimmed.substring(start, end); + } + + if (trimmed.startsWith("```")) { + int start = trimmed.indexOf("{"); + int end = trimmed.lastIndexOf("}") + 1; + return trimmed.substring(start, end); + } + + if (trimmed.startsWith("{")) { + int end = trimmed.lastIndexOf("}") + 1; + return trimmed.substring(0, end); + } + + int startIndex = trimmed.indexOf("{"); + int endIndex = trimmed.lastIndexOf("}"); + if (startIndex != -1 && endIndex != -1 && endIndex > startIndex) { + return trimmed.substring(startIndex, endIndex + 1); + } + + return trimmed; + } +} \ No newline at end of file diff --git a/src/main/java/project/backend/openai/dto/ConversationTopicDTO.java b/src/main/java/project/backend/openai/dto/ConversationTopicDTO.java new file mode 100644 index 0000000..29ee9d4 --- /dev/null +++ b/src/main/java/project/backend/openai/dto/ConversationTopicDTO.java @@ -0,0 +1,14 @@ +package project.backend.openai.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +public class ConversationTopicDTO { + + private final List topics; // 대화주제 리스트 (3-5개) +} + diff --git a/src/main/java/project/backend/openai/dto/DatingCourseDTO.java b/src/main/java/project/backend/openai/dto/DatingCourseDTO.java new file mode 100644 index 0000000..41ac79f --- /dev/null +++ b/src/main/java/project/backend/openai/dto/DatingCourseDTO.java @@ -0,0 +1,14 @@ +package project.backend.openai.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +public class DatingCourseDTO { + + private final List courses; // 데이트 코스 리스트 (3-5개) +} + diff --git a/src/main/java/project/backend/openai/dto/PromptRequest.java b/src/main/java/project/backend/openai/dto/PromptRequest.java new file mode 100644 index 0000000..eed60fa --- /dev/null +++ b/src/main/java/project/backend/openai/dto/PromptRequest.java @@ -0,0 +1,4 @@ +package project.backend.openai.dto; + +public record PromptRequest(String prompt) { +} \ No newline at end of file diff --git a/src/main/java/project/backend/openai/dto/RecommendationRequest.java b/src/main/java/project/backend/openai/dto/RecommendationRequest.java new file mode 100644 index 0000000..a27a95f --- /dev/null +++ b/src/main/java/project/backend/openai/dto/RecommendationRequest.java @@ -0,0 +1,8 @@ +package project.backend.openai.dto; + +public record RecommendationRequest( + Long myUserId, + Long matchedUserId +) { +} + diff --git a/src/main/java/project/backend/pythonapi/SajuController.java b/src/main/java/project/backend/pythonapi/SajuController.java new file mode 100644 index 0000000..f3e0897 --- /dev/null +++ b/src/main/java/project/backend/pythonapi/SajuController.java @@ -0,0 +1,25 @@ +package project.backend.pythonapi; + +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import project.backend.pythonapi.dto.SajuRequest; +import project.backend.pythonapi.dto.SajuResponse; +import reactor.core.publisher.Mono; + +@RestController +@RequestMapping("/api/match") +@RequiredArgsConstructor +@Tag(name = "사주팔자 궁합점수 받기",description = "프런트에서 사용할 필요 X") +public class SajuController { + + private final SajuService matchService; + + @PostMapping + public Mono getMatchScore(@RequestBody SajuRequest matchRequest) { + return matchService.getMatchResult(matchRequest); + } +} diff --git a/src/main/java/project/backend/pythonapi/SajuService.java b/src/main/java/project/backend/pythonapi/SajuService.java new file mode 100644 index 0000000..7729fbe --- /dev/null +++ b/src/main/java/project/backend/pythonapi/SajuService.java @@ -0,0 +1,25 @@ +package project.backend.pythonapi; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import project.backend.pythonapi.dto.SajuRequest; +import project.backend.pythonapi.dto.SajuResponse; +import reactor.core.publisher.Mono; + +@Service +@RequiredArgsConstructor +public class SajuService { + + private final WebClient webClient; + + public Mono getMatchResult(SajuRequest request) { + + return webClient.post() + .uri("/match") + .bodyValue(request) + .retrieve() + .bodyToMono(SajuResponse.class); + + } +} diff --git a/src/main/java/project/backend/pythonapi/WebClientConfig.java b/src/main/java/project/backend/pythonapi/WebClientConfig.java new file mode 100644 index 0000000..994490f --- /dev/null +++ b/src/main/java/project/backend/pythonapi/WebClientConfig.java @@ -0,0 +1,23 @@ +package project.backend.pythonapi; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +public class WebClientConfig { + + @Value("${api.python.baseUrl}") + private String baseUrl; + + @Bean + public WebClient webClient() { + return WebClient.builder() + .baseUrl(baseUrl) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .build(); + } +} diff --git a/src/main/java/project/backend/pythonapi/dto/PersonInfo.java b/src/main/java/project/backend/pythonapi/dto/PersonInfo.java new file mode 100644 index 0000000..0fb388d --- /dev/null +++ b/src/main/java/project/backend/pythonapi/dto/PersonInfo.java @@ -0,0 +1,5 @@ +package project.backend.pythonapi.dto; + +public record PersonInfo(int year, int month, int day, int gender) { + +} \ No newline at end of file diff --git a/src/main/java/project/backend/pythonapi/dto/SajuRequest.java b/src/main/java/project/backend/pythonapi/dto/SajuRequest.java new file mode 100644 index 0000000..a14695c --- /dev/null +++ b/src/main/java/project/backend/pythonapi/dto/SajuRequest.java @@ -0,0 +1,13 @@ +package project.backend.pythonapi.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class SajuRequest { + + private PersonInfo person1; + + private PersonInfo person2; +} diff --git a/src/main/java/project/backend/pythonapi/dto/SajuResponse.java b/src/main/java/project/backend/pythonapi/dto/SajuResponse.java new file mode 100644 index 0000000..fd5080b --- /dev/null +++ b/src/main/java/project/backend/pythonapi/dto/SajuResponse.java @@ -0,0 +1,26 @@ +package project.backend.pythonapi.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record SajuResponse( + @JsonProperty("original_score") + double originalScore, + + @JsonProperty("final_score") + double finalScore, + + @JsonProperty("stress_score") + double stressScore, + + @JsonProperty("person1_sal_analysis") + String person1SalAnalysis, + + @JsonProperty("person2_sal_analysis") + String person2SalAnalysis, + + @JsonProperty("match_analysis") + String matchAnalysis, + + String error +) { +} \ No newline at end of file diff --git a/src/main/java/project/backend/user/UserInfoController.java b/src/main/java/project/backend/user/UserInfoController.java new file mode 100644 index 0000000..9ce310c --- /dev/null +++ b/src/main/java/project/backend/user/UserInfoController.java @@ -0,0 +1,33 @@ +package project.backend.user; + +import java.io.IOException; + +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import lombok.RequiredArgsConstructor; +import project.backend.user.dto.SignUpRequestDTO; +import project.backend.user.dto.UserResponseDTO; + +@RestController +@RequestMapping("/users") +@RequiredArgsConstructor +@Tag(name = "회원가입", description = "json 유저 정보 + 사진파일 필요") +public class UserInfoController { + + private final UserService userService; + + // 회원가입 (사진파일 + json 상세 정보) + @PostMapping(value = "/signup", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity signUp( + @ModelAttribute SignUpRequestDTO requestDTO, + @RequestPart(value = "profileImage", required = false) MultipartFile profileImage) throws IOException { + UserResponseDTO response = userService.registerNewUser(requestDTO, profileImage); + + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } +} \ No newline at end of file diff --git a/src/main/java/project/backend/user/UserRepository.java b/src/main/java/project/backend/user/UserRepository.java new file mode 100644 index 0000000..920efcc --- /dev/null +++ b/src/main/java/project/backend/user/UserRepository.java @@ -0,0 +1,41 @@ +package project.backend.user; + +import java.util.List; +import java.util.Optional; + +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 project.backend.user.dto.UserEnums; +import project.backend.user.entity.User; + +@Repository +public interface UserRepository extends JpaRepository { + + @Query("SELECT u FROM User u JOIN FETCH u.userProfile WHERE u.id = :id") + Optional findByIdWithProfile(@Param("id") Long id); + + @Query("SELECT u FROM User u JOIN FETCH u.userProfile " + + "WHERE u.userProfile.region = :region " + + "AND u.userProfile.sexualOrientation = :sexualOrientation " + + "AND u.id != :excludeUserId") + List findMatchingUsersByRegionAndOrientation( + @Param("region") UserEnums.region region, + @Param("sexualOrientation") UserEnums.SexualOrientation sexualOrientation, + @Param("excludeUserId") Long excludeUserId + ); + + @Query("SELECT u FROM User u JOIN FETCH u.userProfile " + + "WHERE u.userProfile.region = :region " + + "AND u.userProfile.sexualOrientation = :sexualOrientation " + + "AND u.id != :excludeUserId " + + "AND u.gender != :myGender") + List findMatchingUsersForStraight( + @Param("region") UserEnums.region region, + @Param("sexualOrientation") UserEnums.SexualOrientation sexualOrientation, + @Param("excludeUserId") Long excludeUserId, + @Param("myGender") UserEnums.Gender myGender + ); +} \ No newline at end of file diff --git a/src/main/java/project/backend/user/UserService.java b/src/main/java/project/backend/user/UserService.java new file mode 100644 index 0000000..25f1a75 --- /dev/null +++ b/src/main/java/project/backend/user/UserService.java @@ -0,0 +1,157 @@ +package project.backend.user; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.UUID; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; +import project.backend.mypage.dto.MyPageDisplayDTO; +import project.backend.user.dto.SignUpRequestDTO; +import project.backend.user.dto.UserProfileDTO; +import project.backend.user.dto.UserResponseDTO; +import project.backend.user.entity.User; +import project.backend.user.entity.UserProfile; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserService { + + private final UserRepository repository; + + @Value("${file.upload-dir:uploads/profile}") + private String uploadDir; + + //회원가입 + @Transactional + public UserResponseDTO registerNewUser(SignUpRequestDTO requestDTO, MultipartFile profileImage) throws IOException { + UserProfile userProfile = UserProfile.builder() + .sexualOrientation(requestDTO.getSexualOrientation()) + .job(requestDTO.getJob()) + .region(requestDTO.getRegion()) + .drinkingFrequency(requestDTO.getDrinkingFrequency()) + .smokingStatus(requestDTO.getSmokingStatus()) + .height(requestDTO.getHeight()) + .petPreference(requestDTO.getPetPreference()) + .religion(requestDTO.getReligion()) + .contactFrequency(requestDTO.getContactFrequency()) + .mbti(requestDTO.getMbti()) + .introduction(requestDTO.getIntroduction()) + .build(); + + // 프로필 이미지 저장 + if (profileImage != null && !profileImage.isEmpty()) { + String imagePath = saveProfileImage(profileImage); + userProfile.setProfileImagePath(imagePath); + } + + User user = User.builder() + .name(requestDTO.getName()) + .gender(requestDTO.getGender()) + .birthDate(requestDTO.getBirthdate()) + .userProfile(userProfile) + .build(); + + repository.save(user); + + return new UserResponseDTO(user.getId(), user.getName()); //과연 필요할까..??일단 넣음 + } + + + // 전체 사용자 정보 조회 + public MyPageDisplayDTO getUserInfo(Long userId) { + User user = repository.findByIdWithProfile(userId) + .orElseThrow(() -> new EntityNotFoundException("User with id " + userId + " not found")); + + return MyPageDisplayDTO.fromEntity(user); + } + + //상세 정보 수정 + @Transactional + public void updateUserProfileInfo(Long userId, UserProfileDTO userProfileDTO) { + User user = repository.findByIdWithProfile(userId) + .orElseThrow(() -> new EntityNotFoundException("User with id " + userId + " not found")); + + UserProfile userProfile = user.getUserProfile(); + userProfile.updateUserProfile(userProfileDTO); + + } + + //회원 탈퇴 + @Transactional + public void deleteUser(Long userId) { + User user = repository.findByIdWithProfile(userId) + .orElseThrow(() -> new EntityNotFoundException("User with id " + userId + " not found")); + + UserProfile userProfile = user.getUserProfile(); + if (userProfile != null && userProfile.getProfileImagePath() != null) { + deleteProfileImage(userProfile.getProfileImagePath()); + } + + repository.delete(user); + } + + //프로필 이미지 수정 + @Transactional + public String updateUserProfileImage(Long userId, MultipartFile newImage) throws IOException { + if (newImage == null || newImage.isEmpty()) { + throw new IllegalArgumentException("Profile image file must not be empty"); + } + + User user = repository.findByIdWithProfile(userId) + .orElseThrow(() -> new EntityNotFoundException("User with id " + userId + " not found")); + + UserProfile userProfile = user.getUserProfile(); + if (userProfile == null) { + throw new EntityNotFoundException("User profile not found for user id " + userId); + } + + if (userProfile.getProfileImagePath() != null) { + deleteProfileImage(userProfile.getProfileImagePath()); + } + + String newPath = saveProfileImage(newImage); + userProfile.setProfileImagePath(newPath); + + return newPath; + } + + // 프로필 이미지 저장 + private String saveProfileImage(MultipartFile file) throws IOException { + Path uploadPath = Paths.get(uploadDir); + if (!Files.exists(uploadPath)) { + Files.createDirectories(uploadPath); + } + + String originalFilename = file.getOriginalFilename(); + String extension = ""; + if (originalFilename != null && originalFilename.lastIndexOf('.') != -1) { + extension = originalFilename.substring(originalFilename.lastIndexOf('.')); + } + String fileName = UUID.randomUUID().toString() + extension; + + Path filePath = uploadPath.resolve(fileName); + Files.copy(file.getInputStream(), filePath); + + return "/uploads/profile/" + fileName; + } + + // 프로필 이미지 삭제 + private void deleteProfileImage(String imagePath) { + try { + String fileName = imagePath.substring(imagePath.lastIndexOf('/') + 1); + Path filePath = Paths.get(uploadDir).resolve(fileName); + Files.deleteIfExists(filePath); + } catch (Exception e) { + e.printStackTrace(); + } + } +} \ No newline at end of file diff --git a/src/main/java/project/backend/user/dto/SignUpRequestDTO.java b/src/main/java/project/backend/user/dto/SignUpRequestDTO.java new file mode 100644 index 0000000..5441aa8 --- /dev/null +++ b/src/main/java/project/backend/user/dto/SignUpRequestDTO.java @@ -0,0 +1,34 @@ +package project.backend.user.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDate; + +@Getter +@AllArgsConstructor +public class SignUpRequestDTO { + + // 기본정보 + private String name; + private LocalDate birthdate; + private UserEnums.Gender gender; + + // 상세정보 + private UserEnums.SexualOrientation sexualOrientation; + private UserEnums.Job job; + private UserEnums.region region; + private UserEnums.DrinkingFrequency drinkingFrequency; + private UserEnums.SmokingStatus smokingStatus; + private Integer height; + private UserEnums.PetPreference petPreference; + private UserEnums.Religion religion; + private UserEnums.ContactFrequency contactFrequency; + private UserEnums.Mbti mbti; + + //자기소개서 + private String introduction; + + //이미지 url...? + //private String profileImage; +} diff --git a/src/main/java/project/backend/user/dto/UserEnums.java b/src/main/java/project/backend/user/dto/UserEnums.java new file mode 100644 index 0000000..ed82604 --- /dev/null +++ b/src/main/java/project/backend/user/dto/UserEnums.java @@ -0,0 +1,77 @@ +package project.backend.user.dto; + +public class UserEnums { + + public enum Gender { + MALE, + FEMALE + } + + public enum SexualOrientation { + STRAIGHT, HOMOSEXUAL + } + + public enum Job { + UNEMPLOYED, + STUDENT, + EMPLOYEE + } + + public enum region { + SEOUL, + GYEONGGI_DO, + INCHEON, + BUSAN, + DAEGU, + GWANGJU, + DAEJEON, + ULSAN, + SEJONG, + GANGWON_DO, + CHUNGCHEONGBUK_DO, + CHUNGCHEONGNAM_DO, + JEOLLABUK_DO, + JEOLLANAM_DO, + GYEONGSANGBUK_DO, + GYEONGSANGNAM_DO, + JEJU_DO, + } + + public enum DrinkingFrequency { + NONE, + ONCE_OR_TWICE_PER_WEEK, + THREE_TIMES_OR_MORE_PER_WEEK + } + + public enum SmokingStatus { + NON_SMOKER, + SMOKER + } + + public enum PetPreference { + NONE, + DOG, + CAT, + OTHER + } + + public enum Religion { + NONE, // 무교 + CATHOLIC, // 천주교 + CHRISTIAN, // 기독교 + BUDDHIST, // 불교 + OTHER // 기타 + } + + public enum ContactFrequency { + IMPORTANT, + NOT_IMPORTANT + } + + public enum Mbti { + INTJ, INTP, ENTJ, ENTP, + INFJ, INFP, ENFJ, ENFP, + ISTJ, ISFJ, ESTJ, ESFJ, + ISTP, ISFP, ESTP, ESFP + } +} diff --git a/src/main/java/project/backend/user/dto/UserProfileDTO.java b/src/main/java/project/backend/user/dto/UserProfileDTO.java new file mode 100644 index 0000000..7dc88e7 --- /dev/null +++ b/src/main/java/project/backend/user/dto/UserProfileDTO.java @@ -0,0 +1,19 @@ +package project.backend.user.dto; + +import lombok.Getter; + +@Getter +public class UserProfileDTO { + + private UserEnums.SexualOrientation sexualOrientation; + private UserEnums.Job job; + private UserEnums.region region; + private UserEnums.DrinkingFrequency drinkingFrequency; + private UserEnums.SmokingStatus smokingStatus; + private Integer height; + private UserEnums.PetPreference petPreference; + private UserEnums.Religion religion; + private UserEnums.ContactFrequency contactFrequency; + private UserEnums.Mbti mbti; + private String introduction; +} diff --git a/src/main/java/project/backend/user/dto/UserResponseDTO.java b/src/main/java/project/backend/user/dto/UserResponseDTO.java new file mode 100644 index 0000000..4d1f60d --- /dev/null +++ b/src/main/java/project/backend/user/dto/UserResponseDTO.java @@ -0,0 +1,12 @@ +package project.backend.user.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class UserResponseDTO { + + private Long id; + private String name; +} diff --git a/src/main/java/project/backend/user/entity/User.java b/src/main/java/project/backend/user/entity/User.java new file mode 100644 index 0000000..375bdb2 --- /dev/null +++ b/src/main/java/project/backend/user/entity/User.java @@ -0,0 +1,50 @@ +package project.backend.user.entity; + +import java.time.LocalDate; + +import jakarta.persistence.*; +import lombok.*; +import project.backend.kakaoLogin.KakaoUser; +import project.backend.user.dto.UserEnums; + +@Entity +@Table(name = "users") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + @Enumerated(EnumType.STRING) + private UserEnums.Gender gender; + + private LocalDate birthDate; + + @OneToOne(mappedBy = "user", cascade = CascadeType.ALL) + private UserProfile userProfile; + + @OneToOne + @JoinColumn(name = "kakao_user_id") + private KakaoUser kakaoUser; + + @Builder + public User(String name, UserEnums.Gender gender, LocalDate birthDate, UserProfile userProfile) { + this.name = name; + this.gender = gender; + this.birthDate = birthDate; + + this.setUserProfile(userProfile); + } + + private void setUserProfile(UserProfile userProfile) { + this.userProfile = userProfile; + + if (userProfile != null) { + userProfile.setUser(this); + } + } +} \ No newline at end of file diff --git a/src/main/java/project/backend/user/entity/UserProfile.java b/src/main/java/project/backend/user/entity/UserProfile.java new file mode 100644 index 0000000..9cc2da0 --- /dev/null +++ b/src/main/java/project/backend/user/entity/UserProfile.java @@ -0,0 +1,95 @@ +package project.backend.user.entity; + +import jakarta.persistence.*; +import lombok.*; +import project.backend.user.dto.UserEnums; +import project.backend.user.dto.UserProfileDTO; + +@Entity +@Table(name = "user_profiles") +@Getter +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class UserProfile { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Setter + @OneToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Setter + private String profileImagePath; + + @Enumerated(EnumType.STRING) + private UserEnums.SexualOrientation sexualOrientation; + + @Enumerated(EnumType.STRING) + private UserEnums.Job job; + + @Enumerated(EnumType.STRING) + private UserEnums.region region; + + @Enumerated(EnumType.STRING) + private UserEnums.DrinkingFrequency drinkingFrequency; + + @Enumerated(EnumType.STRING) + private UserEnums.SmokingStatus smokingStatus; + + private Integer height; + + @Enumerated(EnumType.STRING) + private UserEnums.PetPreference petPreference; + + @Setter + @Enumerated(EnumType.STRING) + private UserEnums.Religion religion; + + @Enumerated(EnumType.STRING) + private UserEnums.ContactFrequency contactFrequency; + + @Enumerated(EnumType.STRING) + private UserEnums.Mbti mbti; + + private String introduction; + + public void updateUserProfile(UserProfileDTO userProfileDTO) { + if (userProfileDTO.getSexualOrientation() != null) { + this.sexualOrientation = userProfileDTO.getSexualOrientation(); + } + if (userProfileDTO.getJob() != null) { + this.job = userProfileDTO.getJob(); + } + if (userProfileDTO.getRegion() != null) { + this.region = userProfileDTO.getRegion(); + } + if (userProfileDTO.getDrinkingFrequency() != null) { + this.drinkingFrequency = userProfileDTO.getDrinkingFrequency(); + } + if (userProfileDTO.getSmokingStatus() != null) { + this.smokingStatus = userProfileDTO.getSmokingStatus(); + } + if (userProfileDTO.getHeight() != null) { + this.height = userProfileDTO.getHeight(); + } + if (userProfileDTO.getPetPreference() != null) { + this.petPreference = userProfileDTO.getPetPreference(); + } + if (userProfileDTO.getReligion() != null) { + this.religion = userProfileDTO.getReligion(); + } + if (userProfileDTO.getContactFrequency() != null) { + this.contactFrequency = userProfileDTO.getContactFrequency(); + } + if (userProfileDTO.getMbti() != null) { + this.mbti = userProfileDTO.getMbti(); + } + if (userProfileDTO.getIntroduction() != null) { + this.introduction = userProfileDTO.getIntroduction(); + } + } +} \ No newline at end of file