From 0f47731fd0e46b144871f64c9124cf4c7e0b6fcf Mon Sep 17 00:00:00 2001 From: Kim-Yukyung Date: Mon, 16 Jun 2025 21:29:44 +0900 Subject: [PATCH 01/27] =?UTF-8?q?=F0=9F=92=A1=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/sayup/SayUp/aop/Logging.java | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/sayup/SayUp/aop/Logging.java b/src/main/java/com/sayup/SayUp/aop/Logging.java index ccc2a0c..92740b2 100644 --- a/src/main/java/com/sayup/SayUp/aop/Logging.java +++ b/src/main/java/com/sayup/SayUp/aop/Logging.java @@ -21,7 +21,8 @@ public class Logging { private static final Logger logger = LoggerFactory.getLogger(Logging.class); - @Around("@within(org.springframework.web.bind.annotation.RestController)") // @RestController가 붙은 모든 클래스의 메서드가 실행될 때마다 + // @RestController가 붙은 모든 메서드 실행 전후에 이 코드 실행 + @Around("@within(org.springframework.web.bind.annotation.RestController)") public Object logHttpRequests(ProceedingJoinPoint joinPoint) throws Throwable { ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attributes == null) { @@ -29,12 +30,13 @@ public Object logHttpRequests(ProceedingJoinPoint joinPoint) throws Throwable { } HttpServletRequest request = attributes.getRequest(); - MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + String httpMethod = request.getMethod(); + String uri = request.getRequestURI(); + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); String methodName = signature.getMethod().getName(); String className = signature.getDeclaringType().getSimpleName(); - String httpMethod = request.getMethod(); - String uri = request.getRequestURI(); + // Query Parameter StringBuilder queryParams = new StringBuilder(); @@ -60,9 +62,10 @@ public Object logHttpRequests(ProceedingJoinPoint joinPoint) throws Throwable { // @RestController 또는 @Service 가 붙은 클래스만 AOP 적용 @Around("within(@org.springframework.web.bind.annotation.RestController *) || within(@org.springframework.stereotype.Service *)") + // 예외 발생 시 로그 출력 public Object logExceptions(ProceedingJoinPoint joinPoint) throws Throwable { try { - return joinPoint.proceed(); + return joinPoint.proceed(); // 원래 메서드 실행 } catch (Exception e) { MethodSignature signature = (MethodSignature) joinPoint.getSignature(); String methodName = signature.getMethod().getName(); @@ -70,8 +73,7 @@ public Object logExceptions(ProceedingJoinPoint joinPoint) throws Throwable { logger.error("Exception in {}.{}() | Error: {}", className, methodName, e.getMessage()); - throw e; // 예외 다시 던지기 -> Spring이 전역 예외 처리기로 전달 (@ControllerAdvice에서 예외를 핸들링하여 클라이언트에게 적절한 응답을 반환 가능) + throw e; // 예외를 다시 던져서 spring이 처리하게 함 } } } - From a0e1c6b055712185ea66a4453cabb66abd9917f1 Mon Sep 17 00:00:00 2001 From: Kim-Yukyung Date: Mon, 16 Jun 2025 21:31:43 +0900 Subject: [PATCH 02/27] =?UTF-8?q?=F0=9F=92=A1=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/sayup/SayUp/config/AsyncConfig.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/sayup/SayUp/config/AsyncConfig.java b/src/main/java/com/sayup/SayUp/config/AsyncConfig.java index 9a58a7b..b6f244a 100644 --- a/src/main/java/com/sayup/SayUp/config/AsyncConfig.java +++ b/src/main/java/com/sayup/SayUp/config/AsyncConfig.java @@ -21,9 +21,9 @@ public class AsyncConfig { @Bean public Executor taskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); - executor.setCorePoolSize(5); // 기본적으로 유지되는 스레드의 수 - executor.setMaxPoolSize(10); // 최대 스레드의 수 - executor.setQueueCapacity(500); // 스레드가 모두 사용 중일 때 작업을 대기시키기 위한 큐의 용량 + executor.setCorePoolSize(5); // 기본적으로 유지되는 스레드의 수 + executor.setMaxPoolSize(10); // 최대 스레드의 수 + executor.setQueueCapacity(500); // 스레드가 모두 사용 중일 때 작업을 대기시키기 위한 큐의 용량 executor.setThreadNamePrefix("Async-"); executor.initialize(); return executor; From 04bf5f9c3d6eabcbfff5df82d4f8198145fc7a25 Mon Sep 17 00:00:00 2001 From: Kim-Yukyung Date: Mon, 16 Jun 2025 21:33:55 +0900 Subject: [PATCH 03/27] =?UTF-8?q?=E2=9C=A8=20=EB=A0=88=EB=94=94=EC=8A=A4?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/sayup/SayUp/config/RedisConfig.java | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/main/java/com/sayup/SayUp/config/RedisConfig.java diff --git a/src/main/java/com/sayup/SayUp/config/RedisConfig.java b/src/main/java/com/sayup/SayUp/config/RedisConfig.java new file mode 100644 index 0000000..59bad4d --- /dev/null +++ b/src/main/java/com/sayup/SayUp/config/RedisConfig.java @@ -0,0 +1,50 @@ +package com.sayup.SayUp.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Value("${spring.redis.host:localhost}") + private String redisHost; + + @Value("${spring.redis.port:6379}") + private int redisPort; + + @Value("${spring.redis.password:}") + private String redisPassword; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(redisHost, redisPort); + + if (redisPassword != null && !redisPassword.isEmpty()) { + config.setPassword(redisPassword); + } + + return new LettuceConnectionFactory(config); + } + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + // 키와 값의 직렬화 방식 설정 + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + template.setHashValueSerializer(new StringRedisSerializer()); + + template.afterPropertiesSet(); + return template; + } +} From a893b9c69f1b0d795c7bba0285ee6a958162be0c Mon Sep 17 00:00:00 2001 From: Kim-Yukyung Date: Mon, 16 Jun 2025 21:41:29 +0900 Subject: [PATCH 04/27] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/sayup/SayUp/config/AsyncConfig.java | 1 - .../sayup/SayUp/config/SecurityConfig.java | 77 +++++++++++++++---- .../com/sayup/SayUp/config/SwaggerConfig.java | 2 +- 3 files changed, 61 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/sayup/SayUp/config/AsyncConfig.java b/src/main/java/com/sayup/SayUp/config/AsyncConfig.java index b6f244a..ecb5e9c 100644 --- a/src/main/java/com/sayup/SayUp/config/AsyncConfig.java +++ b/src/main/java/com/sayup/SayUp/config/AsyncConfig.java @@ -29,4 +29,3 @@ public Executor taskExecutor() { return executor; } } - diff --git a/src/main/java/com/sayup/SayUp/config/SecurityConfig.java b/src/main/java/com/sayup/SayUp/config/SecurityConfig.java index eff3110..434c6d9 100644 --- a/src/main/java/com/sayup/SayUp/config/SecurityConfig.java +++ b/src/main/java/com/sayup/SayUp/config/SecurityConfig.java @@ -1,8 +1,10 @@ package com.sayup.SayUp.config; import com.sayup.SayUp.security.JwtAuthenticationFilter; +import com.sayup.SayUp.security.JwtTokenProvider; import com.sayup.SayUp.service.AuthService; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; @@ -17,15 +19,30 @@ import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import java.util.Collections; +import java.util.Arrays; +import java.util.List; @Configuration -@EnableWebSecurity // Spring Security의 설정을 활성화 +@EnableWebSecurity // Spring Security 설정 활성화 @RequiredArgsConstructor public class SecurityConfig { - private final JwtAuthenticationFilter jwtAuthenticationFilter; // JWT 인증 필터 private final AuthService authService; // 사용자 인증 서비스 (사용자 정보를 로드하고 인증 처리) private final PasswordEncoder passwordEncoder; // 비밀번호 암호화 인코더 + private final JwtTokenProvider jwtTokenProvider; // JWT 토큰 제공자 + + @Value("${spring.profiles.active:prod}") + private String activeProfile; + + @Value("${frontend.url:}") + private String frontendUrl; + + /** + * JWT 인증 필터 Bean 생성 + */ + @Bean + public JwtAuthenticationFilter jwtAuthenticationFilter() { + return new JwtAuthenticationFilter(jwtTokenProvider, authService); + } /** * 보안 필터 체인 구성 @@ -35,14 +52,15 @@ public class SecurityConfig { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { // 공개 API 경로 - 인증 없이 접근 가능한 경로 목록 final String[] PUBLIC_URLS = { - "/api/auth/**", // 인증 관련 API - "/swagger-ui/**", // Swagger UI - "/v3/api-docs/**", // OpenAPI 문서 - "/callback/**" // 카카오 로그인 + "/api/auth/**", // 인증 관련 API + "/swagger-ui/**", // Swagger UI + "/v3/api-docs/**", // OpenAPI 문서 + "/callback/**", // 카카오 로그인 + "/actuator/health" // 헬스체크 }; return http - // CSRF 보호 비활성화 (REST API는 CSRF 공격에 덜 취약함) + // CSRF 보호 비활성 .csrf(csrf -> csrf.disable()) // 세션 관리 설정 - JWT 사용으로 세션 상태 저장 안함 @@ -59,7 +77,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .cors(cors -> cors.configurationSource(corsConfigurationSource())) // JWT 필터 추가 (UsernamePasswordAuthenticationFilter 이전에 실행) - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) .build(); } @@ -72,7 +90,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception { AuthenticationManagerBuilder authenticationManagerBuilder = http.getSharedObject(AuthenticationManagerBuilder.class); authenticationManagerBuilder - .userDetailsService(authService) // 사용자 정보 서비스 설정 + .userDetailsService(authService) // 사용자 정보 서비스 설정 .passwordEncoder(passwordEncoder); // 비밀번호 인코더 설정 return authenticationManagerBuilder.build(); } @@ -85,16 +103,41 @@ public AuthenticationManager authenticationManager(HttpSecurity http) throws Exc public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOrigins(Collections.singletonList("*")); // 모든 Origin 허용 - configuration.setAllowedMethods(Collections.singletonList("*")); // 모든 HTTP Method 허용 - configuration.setAllowedHeaders(Collections.singletonList("*")); // 모든 Header 허용 - - configuration.setAllowCredentials(false); // 쿠키 사용 X + // 환경별 CORS 설정 + if ("dev".equals(activeProfile)) { + // 개발 환경: 로컬호스트 허용 + configuration.setAllowedOrigins(Arrays.asList( + "http://localhost:3000", + "http://localhost:8080", + "http://127.0.0.1:3000", + "http://127.0.0.1:8080" + )); + } else { + // 운영 환경: 환경변수에서 프론트엔드 URL 가져오기 + if (frontendUrl != null && !frontendUrl.trim().isEmpty()) { + configuration.setAllowedOrigins(Arrays.asList(frontendUrl)); + } else { + // 도메인 변경 필요 + } + } + + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(Arrays.asList( + "Authorization", + "Content-Type", + "X-Requested-With", + "Accept", + "Origin", + "Access-Control-Request-Method", + "Access-Control-Request-Headers" + )); + configuration.setExposedHeaders(Arrays.asList("Authorization")); + configuration.setAllowCredentials(true); + configuration.setMaxAge(3600L); - // 모든 경로에 적용 UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; } -} \ No newline at end of file +} diff --git a/src/main/java/com/sayup/SayUp/config/SwaggerConfig.java b/src/main/java/com/sayup/SayUp/config/SwaggerConfig.java index 7095d52..ac879c5 100644 --- a/src/main/java/com/sayup/SayUp/config/SwaggerConfig.java +++ b/src/main/java/com/sayup/SayUp/config/SwaggerConfig.java @@ -16,4 +16,4 @@ public OpenAPI openAPI() { .description("SayUp 프로젝트의 API 문서입니다") .version("v1.0.0")); } -} \ No newline at end of file +} From f1610918c886a8eacadf2fb885f24f6a49db2237 Mon Sep 17 00:00:00 2001 From: Kim-Yukyung Date: Mon, 16 Jun 2025 21:43:30 +0900 Subject: [PATCH 05/27] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/sayup/SayUp/dto/AuthRequestDTO.java | 17 +++++++++------ .../com/sayup/SayUp/dto/AuthResponseDTO.java | 21 ++++++++++++++++--- .../com/sayup/SayUp/dto/ErrorResponse.java | 17 +++++++++++++++ .../com/sayup/SayUp/dto/UserVoiceDTO.java | 15 ------------- 4 files changed, 46 insertions(+), 24 deletions(-) create mode 100644 src/main/java/com/sayup/SayUp/dto/ErrorResponse.java delete mode 100644 src/main/java/com/sayup/SayUp/dto/UserVoiceDTO.java diff --git a/src/main/java/com/sayup/SayUp/dto/AuthRequestDTO.java b/src/main/java/com/sayup/SayUp/dto/AuthRequestDTO.java index 6ee9e7c..1920580 100644 --- a/src/main/java/com/sayup/SayUp/dto/AuthRequestDTO.java +++ b/src/main/java/com/sayup/SayUp/dto/AuthRequestDTO.java @@ -11,13 +11,18 @@ @AllArgsConstructor @NoArgsConstructor public class AuthRequestDTO { - @NotBlank(message = "Email cannot be blank") - @Pattern(regexp="^[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])+[.][a-zA-Z]{2,3}$", message="이메일 주소 양식을 확인해주세요") + @NotBlank(message = "이메일을 입력해주세요") + @Pattern( + regexp = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + message = "올바른 이메일 형식을 입력해주세요" + ) private String email; - @NotBlank(message = "Password cannot be blank") - @Size(min = 4, message = "Password must be at least 4 characters long") + @NotBlank(message = "비밀번호를 입력해주세요") + @Size(min = 8, max = 100, message = "비밀번호는 8자 이상 100자 이하여야 합니다") + @Pattern( + regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&].*$", + message = "비밀번호는 대문자, 소문자, 숫자, 특수문자를 포함해야 합니다" + ) private String password; } - - diff --git a/src/main/java/com/sayup/SayUp/dto/AuthResponseDTO.java b/src/main/java/com/sayup/SayUp/dto/AuthResponseDTO.java index 60b3189..b5613ac 100644 --- a/src/main/java/com/sayup/SayUp/dto/AuthResponseDTO.java +++ b/src/main/java/com/sayup/SayUp/dto/AuthResponseDTO.java @@ -4,8 +4,23 @@ @Data @AllArgsConstructor +@NoArgsConstructor +@Builder public class AuthResponseDTO { - private final String token; - private final String email; - private final String id; + private String accessToken; + private String tokenType; + private Long expiresIn; + private String refreshToken; + private UserInfo userInfo; + + @Data + @AllArgsConstructor + @NoArgsConstructor + @Builder + public static class UserInfo { + private Long userId; + private String email; + private String username; + private String role; + } } diff --git a/src/main/java/com/sayup/SayUp/dto/ErrorResponse.java b/src/main/java/com/sayup/SayUp/dto/ErrorResponse.java new file mode 100644 index 0000000..b13867e --- /dev/null +++ b/src/main/java/com/sayup/SayUp/dto/ErrorResponse.java @@ -0,0 +1,17 @@ +package com.sayup.SayUp.dto; + +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.Map; + +@Data +@Builder +public class ErrorResponse { + private LocalDateTime timestamp; + private int status; + private String error; + private String message; + private Map details; +} diff --git a/src/main/java/com/sayup/SayUp/dto/UserVoiceDTO.java b/src/main/java/com/sayup/SayUp/dto/UserVoiceDTO.java deleted file mode 100644 index d710397..0000000 --- a/src/main/java/com/sayup/SayUp/dto/UserVoiceDTO.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.sayup.SayUp.dto; - -import com.sayup.SayUp.entity.User; -import lombok.Data; - -@Data -public class UserVoiceDTO { - private Long id; - - private User user; - - private String fileName; - - private String filePath; -} From 61770b82fc7ee49d11478258f89ae3aa03973b2d Mon Sep 17 00:00:00 2001 From: Kim-Yukyung Date: Mon, 16 Jun 2025 21:46:00 +0900 Subject: [PATCH 06/27] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EB=AA=85=20=EB=B3=80=EA=B2=BD=20&=20=EC=A0=9C=EC=95=BD?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/sayup/SayUp/entity/AuthToken.java | 2 +- .../java/com/sayup/SayUp/entity/Chat.java | 2 +- .../java/com/sayup/SayUp/entity/ChatRoom.java | 2 +- .../SayUp/entity/FriendRelationship.java | 35 ++++++++++----- .../com/sayup/SayUp/entity/Friendship.java | 4 +- .../sayup/SayUp/entity/PendingRequest.java | 4 +- .../com/sayup/SayUp/entity/UserVoice.java | 2 +- .../SayUp/entity/{User.java => Users.java} | 44 +++++++++++++------ 8 files changed, 63 insertions(+), 32 deletions(-) rename src/main/java/com/sayup/SayUp/entity/{User.java => Users.java} (53%) diff --git a/src/main/java/com/sayup/SayUp/entity/AuthToken.java b/src/main/java/com/sayup/SayUp/entity/AuthToken.java index 3dac6fe..3ad4567 100644 --- a/src/main/java/com/sayup/SayUp/entity/AuthToken.java +++ b/src/main/java/com/sayup/SayUp/entity/AuthToken.java @@ -15,7 +15,7 @@ public class AuthToken { @OneToOne @JoinColumn(name = "userId", nullable = false, unique = true) - private User user; + private Users user; @Column(nullable = false, unique = true) private String token; diff --git a/src/main/java/com/sayup/SayUp/entity/Chat.java b/src/main/java/com/sayup/SayUp/entity/Chat.java index 9ef8af6..c80bb42 100644 --- a/src/main/java/com/sayup/SayUp/entity/Chat.java +++ b/src/main/java/com/sayup/SayUp/entity/Chat.java @@ -15,5 +15,5 @@ public class Chat { @OneToOne @JoinColumn(name = "userId", nullable = false, unique = true) - private User user; + private Users user; } diff --git a/src/main/java/com/sayup/SayUp/entity/ChatRoom.java b/src/main/java/com/sayup/SayUp/entity/ChatRoom.java index d31f31b..8c6daff 100644 --- a/src/main/java/com/sayup/SayUp/entity/ChatRoom.java +++ b/src/main/java/com/sayup/SayUp/entity/ChatRoom.java @@ -26,7 +26,7 @@ public class ChatRoom { joinColumns = @JoinColumn(name = "chatroom_id"), inverseJoinColumns = @JoinColumn(name = "user_id") ) - private List participants = new ArrayList<>(); + private List participants = new ArrayList<>(); @Lob private String metadata; // TTS 벡터 등 JSON 문자열로 저장 diff --git a/src/main/java/com/sayup/SayUp/entity/FriendRelationship.java b/src/main/java/com/sayup/SayUp/entity/FriendRelationship.java index 89334bd..ae9f7f2 100644 --- a/src/main/java/com/sayup/SayUp/entity/FriendRelationship.java +++ b/src/main/java/com/sayup/SayUp/entity/FriendRelationship.java @@ -1,39 +1,52 @@ package com.sayup.SayUp.entity; import jakarta.persistence.*; -import lombok.Getter; -import lombok.Setter; +import lombok.*; import java.time.LocalDateTime; -// 현재: FriendRelationship 테이블에서 친구 관련 데이터 모두 저장 -// -> PendingRequest과 Friendship으로 분리 -// -> 일단 보류 - @Entity @Table(name = "FriendRelationship") @Getter @Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder public class FriendRelationship { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @ManyToOne + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "requester_id", nullable = false) - private User requester; + private Users requester; - @ManyToOne + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "addressee_id", nullable = false) - private User addressee; + private Users addressee; @Enumerated(EnumType.STRING) + @Column(nullable = false) private FriendshipStatus status; - @Column(nullable = false) + @Column(nullable = false, updatable = false) private LocalDateTime requestedAt; + @Column private LocalDateTime acceptedAt; + @Column + private LocalDateTime rejectedAt; + + @PrePersist + protected void onCreate() { + if (requestedAt == null) { + requestedAt = LocalDateTime.now(); + } + if (status == null) { + status = FriendshipStatus.PENDING; + } + } + public enum FriendshipStatus { PENDING, // 친구 요청이 보내진 상태 ACCEPTED, // 친구 요청이 수락된 상태 diff --git a/src/main/java/com/sayup/SayUp/entity/Friendship.java b/src/main/java/com/sayup/SayUp/entity/Friendship.java index 3743829..25abab7 100644 --- a/src/main/java/com/sayup/SayUp/entity/Friendship.java +++ b/src/main/java/com/sayup/SayUp/entity/Friendship.java @@ -20,9 +20,9 @@ public class Friendship { @ManyToOne @JoinColumn(name = "user1Id", nullable = false) - private User user1; + private Users user1; @ManyToOne @JoinColumn(name = "user2Id", nullable = false) - private User user2; + private Users user2; } diff --git a/src/main/java/com/sayup/SayUp/entity/PendingRequest.java b/src/main/java/com/sayup/SayUp/entity/PendingRequest.java index f05088b..3817aca 100644 --- a/src/main/java/com/sayup/SayUp/entity/PendingRequest.java +++ b/src/main/java/com/sayup/SayUp/entity/PendingRequest.java @@ -23,11 +23,11 @@ public class PendingRequest { @ManyToOne @JoinColumn(name = "requesterId", nullable = false) - private User requester; + private Users requester; @ManyToOne @JoinColumn(name = "receiverId", nullable = false) - private User receiver; + private Users receiver; public enum Status { PENDING, ACCEPTED, REJECTED diff --git a/src/main/java/com/sayup/SayUp/entity/UserVoice.java b/src/main/java/com/sayup/SayUp/entity/UserVoice.java index 5c2773a..86631b9 100644 --- a/src/main/java/com/sayup/SayUp/entity/UserVoice.java +++ b/src/main/java/com/sayup/SayUp/entity/UserVoice.java @@ -15,7 +15,7 @@ public class UserVoice { @OneToOne @JoinColumn(name = "userId", nullable = false, unique = true) - private User user; + private Users user; @Column(nullable = false) private String fileName; diff --git a/src/main/java/com/sayup/SayUp/entity/User.java b/src/main/java/com/sayup/SayUp/entity/Users.java similarity index 53% rename from src/main/java/com/sayup/SayUp/entity/User.java rename to src/main/java/com/sayup/SayUp/entity/Users.java index 2e6d6e1..af59e67 100644 --- a/src/main/java/com/sayup/SayUp/entity/User.java +++ b/src/main/java/com/sayup/SayUp/entity/Users.java @@ -1,47 +1,65 @@ package com.sayup.SayUp.entity; import jakarta.persistence.*; -import lombok.Builder; -import lombok.Getter; -import lombok.Setter; - -import java.util.Objects; +import lombok.*; import java.time.LocalDateTime; +import java.util.Objects; @Entity @Table(name = "Users") @Getter @Setter -public class User { - public User() { - } - +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Users { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long userId; + @Column(length = 100) private String username; - @Column(unique = true, nullable = false) + @Column(unique = true, nullable = false, length = 255) private String email; - @Column(nullable = false) + @Column(nullable = false, length = 255) private String password; @Lob + @Column(columnDefinition = "TEXT") private String ttsVector; @Column(nullable = false, updatable = false) - private LocalDateTime createdAt = LocalDateTime.now(); + private LocalDateTime createdAt; + @Column(length = 20) private String role; + @Column + private LocalDateTime lastLoginAt; + + @Column + private Boolean isActive = true; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + if (role == null) { + role = "USER"; + } + if (isActive == null) { + isActive = true; + } + } + @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null || getClass() != obj.getClass()) return false; - User user = (User) obj; + Users user = (Users) obj; return Objects.equals(userId, user.userId); } From 92ddda62bd6be647deb648f171a6ead1fed8450a Mon Sep 17 00:00:00 2001 From: Kim-Yukyung Date: Mon, 16 Jun 2025 21:54:43 +0900 Subject: [PATCH 07/27] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=EB=A1=9C=EA=B9=85?= =?UTF-8?q?=20=EA=B4=80=EB=A0=A8=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/sayup/SayUp/aop/Logging.java | 96 ++++++++++--- .../exception/GlobalExceptionHandler.java | 133 ++++++++++++++++++ 2 files changed, 211 insertions(+), 18 deletions(-) create mode 100644 src/main/java/com/sayup/SayUp/exception/GlobalExceptionHandler.java diff --git a/src/main/java/com/sayup/SayUp/aop/Logging.java b/src/main/java/com/sayup/SayUp/aop/Logging.java index 92740b2..6e78e9a 100644 --- a/src/main/java/com/sayup/SayUp/aop/Logging.java +++ b/src/main/java/com/sayup/SayUp/aop/Logging.java @@ -8,7 +8,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; -import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; @@ -37,7 +36,6 @@ public Object logHttpRequests(ProceedingJoinPoint joinPoint) throws Throwable { String methodName = signature.getMethod().getName(); String className = signature.getDeclaringType().getSimpleName(); - // Query Parameter StringBuilder queryParams = new StringBuilder(); Enumeration parameterNames = request.getParameterNames(); @@ -51,29 +49,91 @@ public Object logHttpRequests(ProceedingJoinPoint joinPoint) throws Throwable { } long startTime = System.currentTimeMillis(); - Object result = joinPoint.proceed(); - long elapsedTime = System.currentTimeMillis() - startTime; - - logger.info("[{}] {} {} | Method: {}.{}() | Execution time: {} ms | QueryParams: [{}]", - httpMethod, uri, queryParams.toString(), className, methodName, elapsedTime, queryParams); + Object result = null; + boolean isSuccess = true; + String errorMessage = null; - return result; + try { + result = joinPoint.proceed(); + return result; + } catch (Exception e) { + isSuccess = false; + errorMessage = e.getMessage(); + throw e; + } finally { + long elapsedTime = System.currentTimeMillis() - startTime; + + if (isSuccess) { + logger.info("[{}] {} {} | Method: {}.{}() | Execution time: {} ms | QueryParams: [{}] | Status: SUCCESS", + httpMethod, uri, queryParams.toString(), className, methodName, elapsedTime, queryParams); + } else { + logger.error("[{}] {} {} | Method: {}.{}() | Execution time: {} ms | QueryParams: [{}] | Status: FAILED | Error: {}", + httpMethod, uri, queryParams.toString(), className, methodName, elapsedTime, queryParams, errorMessage); + } + } } - // @RestController 또는 @Service 가 붙은 클래스만 AOP 적용 - @Around("within(@org.springframework.web.bind.annotation.RestController *) || within(@org.springframework.stereotype.Service *)") - // 예외 발생 시 로그 출력 - public Object logExceptions(ProceedingJoinPoint joinPoint) throws Throwable { + // @Service 클래스의 메서드 실행 시간과 예외 로깅 + @Around("within(@org.springframework.stereotype.Service *)") + public Object logServiceMethods(ProceedingJoinPoint joinPoint) throws Throwable { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + String methodName = signature.getMethod().getName(); + String className = signature.getDeclaringType().getSimpleName(); + + long startTime = System.currentTimeMillis(); + Object result = null; + boolean isSuccess = true; + String errorMessage = null; + try { - return joinPoint.proceed(); // 원래 메서드 실행 + result = joinPoint.proceed(); + return result; } catch (Exception e) { - MethodSignature signature = (MethodSignature) joinPoint.getSignature(); - String methodName = signature.getMethod().getName(); - String className = signature.getDeclaringType().getSimpleName(); + isSuccess = false; + errorMessage = e.getMessage(); + logger.error("Service Exception in {}.{}() | Error: {}", className, methodName, e.getMessage()); + throw e; + } finally { + long elapsedTime = System.currentTimeMillis() - startTime; + + if (isSuccess) { + logger.debug("Service Method: {}.{}() | Execution time: {} ms | Status: SUCCESS", + className, methodName, elapsedTime); + } else { + logger.error("Service Method: {}.{}() | Execution time: {} ms | Status: FAILED | Error: {}", + className, methodName, elapsedTime, errorMessage); + } + } + } + + // Repository 메서드 실행 시간 모니터링 + @Around("within(@org.springframework.stereotype.Repository *)") + public Object logRepositoryMethods(ProceedingJoinPoint joinPoint) throws Throwable { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + String methodName = signature.getMethod().getName(); + String className = signature.getDeclaringType().getSimpleName(); - logger.error("Exception in {}.{}() | Error: {}", className, methodName, e.getMessage()); + long startTime = System.currentTimeMillis(); + Object result = null; + boolean isSuccess = true; - throw e; // 예외를 다시 던져서 spring이 처리하게 함 + try { + result = joinPoint.proceed(); + return result; + } catch (Exception e) { + isSuccess = false; + logger.error("Repository Exception in {}.{}() | Error: {}", className, methodName, e.getMessage()); + throw e; + } finally { + long elapsedTime = System.currentTimeMillis() - startTime; + + if (isSuccess) { + logger.debug("Repository Method: {}.{}() | Execution time: {} ms | Status: SUCCESS", + className, methodName, elapsedTime); + } else { + logger.error("Repository Method: {}.{}() | Execution time: {} ms | Status: FAILED", + className, methodName, elapsedTime); + } } } } diff --git a/src/main/java/com/sayup/SayUp/exception/GlobalExceptionHandler.java b/src/main/java/com/sayup/SayUp/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..93cc6e2 --- /dev/null +++ b/src/main/java/com/sayup/SayUp/exception/GlobalExceptionHandler.java @@ -0,0 +1,133 @@ +package com.sayup.SayUp.exception; + +import com.sayup.SayUp.dto.ErrorResponse; +import com.sayup.SayUp.kakao.exception.KakaoApiException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import jakarta.servlet.http.HttpServletRequest; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +@RestControllerAdvice +@Slf4j +public class GlobalExceptionHandler { + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgumentException(IllegalArgumentException e) { + logExceptionDetails("IllegalArgumentException", e); + + ErrorResponse errorResponse = ErrorResponse.builder() + .timestamp(LocalDateTime.now()) + .status(HttpStatus.BAD_REQUEST.value()) + .error("Bad Request") + .message(e.getMessage()) + .build(); + + return ResponseEntity.badRequest().body(errorResponse); + } + + @ExceptionHandler(BadCredentialsException.class) + public ResponseEntity handleBadCredentialsException(BadCredentialsException e) { + logExceptionDetails("BadCredentialsException", e); + + ErrorResponse errorResponse = ErrorResponse.builder() + .timestamp(LocalDateTime.now()) + .status(HttpStatus.UNAUTHORIZED.value()) + .error("Unauthorized") + .message("Invalid email or password") + .build(); + + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(errorResponse); + } + + @ExceptionHandler(UsernameNotFoundException.class) + public ResponseEntity handleUsernameNotFoundException(UsernameNotFoundException e) { + logExceptionDetails("UsernameNotFoundException", e); + + ErrorResponse errorResponse = ErrorResponse.builder() + .timestamp(LocalDateTime.now()) + .status(HttpStatus.NOT_FOUND.value()) + .error("Not Found") + .message("User not found") + .build(); + + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse); + } + + @ExceptionHandler(KakaoApiException.class) + public ResponseEntity handleKakaoApiException(KakaoApiException e) { + logExceptionDetails("KakaoApiException", e); + + ErrorResponse errorResponse = ErrorResponse.builder() + .timestamp(LocalDateTime.now()) + .status(HttpStatus.BAD_GATEWAY.value()) + .error("Kakao API Error") + .message(e.getMessage()) + .build(); + + return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(errorResponse); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationExceptions(MethodArgumentNotValidException e) { + logExceptionDetails("ValidationException", e); + + Map errors = new HashMap<>(); + e.getBindingResult().getAllErrors().forEach((error) -> { + String fieldName = ((FieldError) error).getField(); + String errorMessage = error.getDefaultMessage(); + errors.put(fieldName, errorMessage); + }); + + ErrorResponse errorResponse = ErrorResponse.builder() + .timestamp(LocalDateTime.now()) + .status(HttpStatus.BAD_REQUEST.value()) + .error("Validation Failed") + .message("Input validation failed") + .details(errors) + .build(); + + return ResponseEntity.badRequest().body(errorResponse); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGenericException(Exception e) { + logExceptionDetails("GenericException", e); + + ErrorResponse errorResponse = ErrorResponse.builder() + .timestamp(LocalDateTime.now()) + .status(HttpStatus.INTERNAL_SERVER_ERROR.value()) + .error("Internal Server Error") + .message("An unexpected error occurred") + .build(); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); + } + + /** + * 예외 상세 정보 로깅 + */ + private void logExceptionDetails(String exceptionType, Exception e) { + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + String requestInfo = "Unknown Request"; + + if (attributes != null) { + HttpServletRequest request = attributes.getRequest(); + requestInfo = String.format("[%s] %s", request.getMethod(), request.getRequestURI()); + } + + log.error("{} | {} | Error: {} | Stack Trace: {}", + exceptionType, requestInfo, e.getMessage(), e.getStackTrace()[0]); + } +} From ee920f539584c9b75a1601c4bc9e225fd41890f3 Mon Sep 17 00:00:00 2001 From: Kim-Yukyung Date: Mon, 16 Jun 2025 21:57:21 +0900 Subject: [PATCH 08/27] =?UTF-8?q?=F0=9F=9A=9A=20=EC=98=88=EC=95=BD?= =?UTF-8?q?=EC=96=B4=20=EC=B6=A9=EB=8F=8C=EB=A1=9C=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/sayup/SayUp/entity/AuthToken.java | 2 +- src/main/java/com/sayup/SayUp/entity/Chat.java | 2 +- src/main/java/com/sayup/SayUp/entity/ChatRoom.java | 2 +- .../java/com/sayup/SayUp/entity/FriendRelationship.java | 4 ++-- src/main/java/com/sayup/SayUp/entity/Friendship.java | 4 ++-- src/main/java/com/sayup/SayUp/entity/PendingRequest.java | 4 ++-- .../java/com/sayup/SayUp/entity/{Users.java => User.java} | 6 +++--- src/main/java/com/sayup/SayUp/entity/UserVoice.java | 2 +- 8 files changed, 13 insertions(+), 13 deletions(-) rename src/main/java/com/sayup/SayUp/entity/{Users.java => User.java} (94%) diff --git a/src/main/java/com/sayup/SayUp/entity/AuthToken.java b/src/main/java/com/sayup/SayUp/entity/AuthToken.java index 3ad4567..3dac6fe 100644 --- a/src/main/java/com/sayup/SayUp/entity/AuthToken.java +++ b/src/main/java/com/sayup/SayUp/entity/AuthToken.java @@ -15,7 +15,7 @@ public class AuthToken { @OneToOne @JoinColumn(name = "userId", nullable = false, unique = true) - private Users user; + private User user; @Column(nullable = false, unique = true) private String token; diff --git a/src/main/java/com/sayup/SayUp/entity/Chat.java b/src/main/java/com/sayup/SayUp/entity/Chat.java index c80bb42..9ef8af6 100644 --- a/src/main/java/com/sayup/SayUp/entity/Chat.java +++ b/src/main/java/com/sayup/SayUp/entity/Chat.java @@ -15,5 +15,5 @@ public class Chat { @OneToOne @JoinColumn(name = "userId", nullable = false, unique = true) - private Users user; + private User user; } diff --git a/src/main/java/com/sayup/SayUp/entity/ChatRoom.java b/src/main/java/com/sayup/SayUp/entity/ChatRoom.java index 8c6daff..d31f31b 100644 --- a/src/main/java/com/sayup/SayUp/entity/ChatRoom.java +++ b/src/main/java/com/sayup/SayUp/entity/ChatRoom.java @@ -26,7 +26,7 @@ public class ChatRoom { joinColumns = @JoinColumn(name = "chatroom_id"), inverseJoinColumns = @JoinColumn(name = "user_id") ) - private List participants = new ArrayList<>(); + private List participants = new ArrayList<>(); @Lob private String metadata; // TTS 벡터 등 JSON 문자열로 저장 diff --git a/src/main/java/com/sayup/SayUp/entity/FriendRelationship.java b/src/main/java/com/sayup/SayUp/entity/FriendRelationship.java index ae9f7f2..6ba97ef 100644 --- a/src/main/java/com/sayup/SayUp/entity/FriendRelationship.java +++ b/src/main/java/com/sayup/SayUp/entity/FriendRelationship.java @@ -18,11 +18,11 @@ public class FriendRelationship { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "requester_id", nullable = false) - private Users requester; + private User requester; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "addressee_id", nullable = false) - private Users addressee; + private User addressee; @Enumerated(EnumType.STRING) @Column(nullable = false) diff --git a/src/main/java/com/sayup/SayUp/entity/Friendship.java b/src/main/java/com/sayup/SayUp/entity/Friendship.java index 25abab7..3743829 100644 --- a/src/main/java/com/sayup/SayUp/entity/Friendship.java +++ b/src/main/java/com/sayup/SayUp/entity/Friendship.java @@ -20,9 +20,9 @@ public class Friendship { @ManyToOne @JoinColumn(name = "user1Id", nullable = false) - private Users user1; + private User user1; @ManyToOne @JoinColumn(name = "user2Id", nullable = false) - private Users user2; + private User user2; } diff --git a/src/main/java/com/sayup/SayUp/entity/PendingRequest.java b/src/main/java/com/sayup/SayUp/entity/PendingRequest.java index 3817aca..f05088b 100644 --- a/src/main/java/com/sayup/SayUp/entity/PendingRequest.java +++ b/src/main/java/com/sayup/SayUp/entity/PendingRequest.java @@ -23,11 +23,11 @@ public class PendingRequest { @ManyToOne @JoinColumn(name = "requesterId", nullable = false) - private Users requester; + private User requester; @ManyToOne @JoinColumn(name = "receiverId", nullable = false) - private Users receiver; + private User receiver; public enum Status { PENDING, ACCEPTED, REJECTED diff --git a/src/main/java/com/sayup/SayUp/entity/Users.java b/src/main/java/com/sayup/SayUp/entity/User.java similarity index 94% rename from src/main/java/com/sayup/SayUp/entity/Users.java rename to src/main/java/com/sayup/SayUp/entity/User.java index af59e67..eee5f10 100644 --- a/src/main/java/com/sayup/SayUp/entity/Users.java +++ b/src/main/java/com/sayup/SayUp/entity/User.java @@ -7,13 +7,13 @@ import java.util.Objects; @Entity -@Table(name = "Users") +@Table(name = "User") @Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder -public class Users { +public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -59,7 +59,7 @@ protected void onCreate() { public boolean equals(Object obj) { if (this == obj) return true; if (obj == null || getClass() != obj.getClass()) return false; - Users user = (Users) obj; + User user = (User) obj; return Objects.equals(userId, user.userId); } diff --git a/src/main/java/com/sayup/SayUp/entity/UserVoice.java b/src/main/java/com/sayup/SayUp/entity/UserVoice.java index 86631b9..5c2773a 100644 --- a/src/main/java/com/sayup/SayUp/entity/UserVoice.java +++ b/src/main/java/com/sayup/SayUp/entity/UserVoice.java @@ -15,7 +15,7 @@ public class UserVoice { @OneToOne @JoinColumn(name = "userId", nullable = false, unique = true) - private Users user; + private User user; @Column(nullable = false) private String fileName; From 1ea974446eeb375ec543a9810ee0cabbcf7d3861 Mon Sep 17 00:00:00 2001 From: Kim-Yukyung Date: Mon, 16 Jun 2025 22:04:02 +0900 Subject: [PATCH 09/27] =?UTF-8?q?=E2=9C=A8=20=EC=B9=B4=EC=B9=B4=EC=98=A4?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/KakaoCallbackController.java | 63 ++++-- .../sayup/SayUp/kakao/dto/KakaoLoginDto.java | 13 -- .../kakao/dto/KakaoTokenResponseDto.java | 37 ++-- .../kakao/dto/KakaoUserInfoResponseDto.java | 209 +++++++----------- .../kakao/exception/KakaoApiException.java | 12 + .../SayUp/kakao/service/KakaoService.java | 174 ++++++++++----- 6 files changed, 275 insertions(+), 233 deletions(-) delete mode 100644 src/main/java/com/sayup/SayUp/kakao/dto/KakaoLoginDto.java create mode 100644 src/main/java/com/sayup/SayUp/kakao/exception/KakaoApiException.java diff --git a/src/main/java/com/sayup/SayUp/kakao/controller/KakaoCallbackController.java b/src/main/java/com/sayup/SayUp/kakao/controller/KakaoCallbackController.java index ee086de..1ae7b52 100644 --- a/src/main/java/com/sayup/SayUp/kakao/controller/KakaoCallbackController.java +++ b/src/main/java/com/sayup/SayUp/kakao/controller/KakaoCallbackController.java @@ -1,19 +1,21 @@ package com.sayup.SayUp.kakao.controller; import com.sayup.SayUp.dto.AuthResponseDTO; -import com.sayup.SayUp.entity.User; import com.sayup.SayUp.kakao.dto.KakaoUserInfoResponseDto; +import com.sayup.SayUp.kakao.exception.KakaoApiException; import com.sayup.SayUp.kakao.service.KakaoService; -import com.sayup.SayUp.security.JwtTokenProvider; import com.sayup.SayUp.service.AuthService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; 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.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.Map; + @Slf4j @RestController @RequiredArgsConstructor @@ -21,24 +23,55 @@ public class KakaoCallbackController { private final KakaoService kakaoService; - private final JwtTokenProvider jwtTokenProvider; private final AuthService authService; @GetMapping("/callback") - public ResponseEntity callback(@RequestParam("code") String code) { - String accessToken = kakaoService.getAccessTokenFromKakao(code); + public ResponseEntity callback(@RequestParam("code") String code) { + try { + log.info("Kakao login callback received with code: {}", code.substring(0, Math.min(10, code.length()))); + + // 카카오 액세스 토큰 획득 + String accessToken = kakaoService.getAccessToken(code).getAccessToken(); + + // 카카오 사용자 정보 획득 + KakaoUserInfoResponseDto userInfo = kakaoService.getUserInfo(accessToken); + + // 이메일 검증 + if (userInfo.getKakaoAccount() == null || + userInfo.getKakaoAccount().getEmail() == null || + userInfo.getKakaoAccount().getEmail().trim().isEmpty()) { + + log.warn("Kakao login failed: Email not provided by user"); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "이메일 정보 제공에 동의해주세요.")); + } - KakaoUserInfoResponseDto userInfo = kakaoService.getUserInfo(accessToken); + String email = userInfo.getKakaoAccount().getEmail(); + + // 이메일 인증 여부 확인 + if (userInfo.getKakaoAccount().getIsEmailVerified() != null && + !userInfo.getKakaoAccount().getIsEmailVerified()) { + + log.warn("Kakao login failed: Email not verified for user: {}", email); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "인증된 이메일을 사용해주세요.")); + } - if (userInfo.getKakaoAccount() == null || userInfo.getKakaoAccount().getEmail() == null) { - return ResponseEntity.badRequest().body(new AuthResponseDTO(null, null, null)); + // 사용자 로그인/등록 처리 + AuthResponseDTO response = authService.kakaoLogin(email); + + log.info("Kakao login successful for user: {}", email); + + return ResponseEntity.ok(response); + + } catch (KakaoApiException e) { + log.error("Kakao API error during login: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_GATEWAY) + .body(Map.of("error", "카카오 로그인 중 오류가 발생했습니다. 다시 시도해주세요.")); + } catch (Exception e) { + log.error("Unexpected error during Kakao login: ", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "로그인 처리 중 오류가 발생했습니다.")); } - - String email = userInfo.getKakaoAccount().getEmail(); - User user = authService.loadOrCreateUser(email); - String jwt = jwtTokenProvider.createTokenFromEmail(email); - - AuthResponseDTO response = new AuthResponseDTO(jwt, email, String.valueOf(user.getUserId())); - return ResponseEntity.ok(response); } } diff --git a/src/main/java/com/sayup/SayUp/kakao/dto/KakaoLoginDto.java b/src/main/java/com/sayup/SayUp/kakao/dto/KakaoLoginDto.java deleted file mode 100644 index 389c77d..0000000 --- a/src/main/java/com/sayup/SayUp/kakao/dto/KakaoLoginDto.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.sayup.SayUp.kakao.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; - -@AllArgsConstructor -@Builder -@Getter -public class KakaoLoginDto { - public String accessToken; - public String refreshToken; -} diff --git a/src/main/java/com/sayup/SayUp/kakao/dto/KakaoTokenResponseDto.java b/src/main/java/com/sayup/SayUp/kakao/dto/KakaoTokenResponseDto.java index 56e5692..256cfc9 100644 --- a/src/main/java/com/sayup/SayUp/kakao/dto/KakaoTokenResponseDto.java +++ b/src/main/java/com/sayup/SayUp/kakao/dto/KakaoTokenResponseDto.java @@ -10,24 +10,35 @@ @JsonIgnoreProperties(ignoreUnknown = true) // JSON에서 이 클래스에 정의되지 않은 필드가 있어도 무시 public class KakaoTokenResponseDto { - @JsonProperty("token_type") - public String tokenType; - @JsonProperty("access_token") - public String accessToken; + private String accessToken; - @JsonProperty("id_token") - public String idToken; + @JsonProperty("refresh_token") + private String refreshToken; @JsonProperty("expires_in") - public Integer expiresIn; - - @JsonProperty("refresh_token") - public String refreshToken; + private Integer expiresIn; @JsonProperty("refresh_token_expires_in") - public Integer refreshTokenExpiresIn; + private Integer refreshTokenExpiresIn; + + @JsonProperty("token_type") + private String tokenType; @JsonProperty("scope") - public String scope; -} \ No newline at end of file + private String scope; + + /** + * 토큰 유효성 검증 + */ + public boolean isValid() { + return accessToken != null && !accessToken.trim().isEmpty(); + } + + /** + * 리프레시 토큰 유효성 검증 + */ + public boolean hasRefreshToken() { + return refreshToken != null && !refreshToken.trim().isEmpty(); + } +} diff --git a/src/main/java/com/sayup/SayUp/kakao/dto/KakaoUserInfoResponseDto.java b/src/main/java/com/sayup/SayUp/kakao/dto/KakaoUserInfoResponseDto.java index 4c3a1e2..c422125 100644 --- a/src/main/java/com/sayup/SayUp/kakao/dto/KakaoUserInfoResponseDto.java +++ b/src/main/java/com/sayup/SayUp/kakao/dto/KakaoUserInfoResponseDto.java @@ -6,185 +6,128 @@ import lombok.NoArgsConstructor; import java.util.Date; -import java.util.HashMap; +import java.util.Map; @Getter @NoArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) public class KakaoUserInfoResponseDto { - //회원 번호 @JsonProperty("id") - public Long id; + private Long id; - //자동 연결 설정을 비활성화한 경우만 존재. - //true : 연결 상태, false : 연결 대기 상태 - @JsonProperty("has_signed_up") - public Boolean hasSignedUp; - - //서비스에 연결 완료된 시각. UTC @JsonProperty("connected_at") - public Date connectedAt; - - //카카오싱크 간편가입을 통해 로그인한 시각. UTC - @JsonProperty("synched_at") - public Date synchedAt; + private Date connectedAt; - //사용자 프로퍼티 @JsonProperty("properties") - public HashMap properties; + private Map properties; - //카카오 계정 정보 @JsonProperty("kakao_account") - public KakaoAccount kakaoAccount; - - //uuid 등 추가 정보 - @JsonProperty("for_partner") - public Partner partner; + private KakaoAccount kakaoAccount; @Getter @NoArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) - public class KakaoAccount { - - //프로필 정보 제공 동의 여부 - @JsonProperty("profile_needs_agreement") - public Boolean isProfileAgree; - - //닉네임 제공 동의 여부 - @JsonProperty("profile_nickname_needs_agreement") - public Boolean isNickNameAgree; - - //프로필 사진 제공 동의 여부 - @JsonProperty("profile_image_needs_agreement") - public Boolean isProfileImageAgree; + public static class KakaoAccount { - //사용자 프로필 정보 @JsonProperty("profile") - public Profile profile; + private Profile profile; - //이름 제공 동의 여부 - @JsonProperty("name_needs_agreement") - public Boolean isNameAgree; - - //카카오계정 이름 - @JsonProperty("name") - public String name; - - //이메일 제공 동의 여부 - @JsonProperty("email_needs_agreement") - public Boolean isEmailAgree; - - //이메일이 유효 여부 - // true : 유효한 이메일, false : 이메일이 다른 카카오 계정에 사용돼 만료 - @JsonProperty("is_email_valid") - public Boolean isEmailValid; + @JsonProperty("email") + private String email; - //이메일이 인증 여부 - //true : 인증된 이메일, false : 인증되지 않은 이메일 @JsonProperty("is_email_verified") - public Boolean isEmailVerified; + private Boolean isEmailVerified; - //카카오계정 대표 이메일 - @JsonProperty("email") - public String email; - - //연령대 제공 동의 여부 - @JsonProperty("age_range_needs_agreement") - public Boolean isAgeAgree; + @JsonProperty("name") + private String name; - //연령대 - //참고 https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info - @JsonProperty("age_range") - public String ageRange; + @JsonProperty("phone_number") + private String phoneNumber; - //출생 연도 제공 동의 여부 - @JsonProperty("birthyear_needs_agreement") - public Boolean isBirthYearAgree; + @JsonProperty("gender") + private String gender; - //출생 연도 (YYYY 형식) @JsonProperty("birthyear") - public String birthYear; - - //생일 제공 동의 여부 - @JsonProperty("birthday_needs_agreement") - public Boolean isBirthDayAgree; + private String birthYear; - //생일 (MMDD 형식) @JsonProperty("birthday") - public String birthDay; - - //생일 타입 - // SOLAR(양력) 혹은 LUNAR(음력) - @JsonProperty("birthday_type") - public String birthDayType; + private String birthDay; - //성별 제공 동의 여부 - @JsonProperty("gender_needs_agreement") - public Boolean isGenderAgree; - - //성별 - @JsonProperty("gender") - public String gender; - - //전화번호 제공 동의 여부 - @JsonProperty("phone_number_needs_agreement") - public Boolean isPhoneNumberAgree; - - //전화번호 - //국내 번호인 경우 +82 00-0000-0000 형식 - @JsonProperty("phone_number") - public String phoneNumber; - - //CI 동의 여부 - @JsonProperty("ci_needs_agreement") - public Boolean isCIAgree; - - //CI, 연계 정보 - @JsonProperty("ci") - public String ci; - - //CI 발급 시각, UTC - @JsonProperty("ci_authenticated_at") - public Date ciCreatedAt; + @JsonProperty("age_range") + private String ageRange; @Getter @NoArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) - public class Profile { + public static class Profile { - //닉네임 @JsonProperty("nickname") - public String nickName; + private String nickname; - //프로필 미리보기 이미지 URL @JsonProperty("thumbnail_image_url") - public String thumbnailImageUrl; + private String thumbnailImageUrl; - //프로필 사진 URL @JsonProperty("profile_image_url") - public String profileImageUrl; + private String profileImageUrl; - //프로필 사진 URL 기본 프로필인지 여부 - //true : 기본 프로필, false : 사용자 등록 @JsonProperty("is_default_image") - public String isDefaultImage; + private Boolean isDefaultImage; + } + } + + /** + * 사용자 이메일 추출 (우선순위: kakao_account.email > properties.email) + */ + public String getEmail() { + if (kakaoAccount != null && kakaoAccount.getEmail() != null) { + return kakaoAccount.getEmail(); + } + if (properties != null && properties.containsKey("email")) { + return properties.get("email"); + } + return null; + } - //닉네임이 기본 닉네임인지 여부 - //true : 기본 닉네임, false : 사용자 등록 - @JsonProperty("is_default_nickname") - public Boolean isDefaultNickName; + /** + * 사용자 닉네임 추출 (우선순위: kakao_account.profile.nickname > properties.nickname) + */ + public String getNickname() { + if (kakaoAccount != null && kakaoAccount.getProfile() != null) { + String profileNickname = kakaoAccount.getProfile().getNickname(); + if (profileNickname != null) { + return profileNickname; + } + } + if (properties != null && properties.containsKey("nickname")) { + return properties.get("nickname"); + } + return null; + } + /** + * 사용자 프로필 이미지 URL 추출 + */ + public String getProfileImageUrl() { + if (kakaoAccount != null && kakaoAccount.getProfile() != null) { + return kakaoAccount.getProfile().getProfileImageUrl(); } + return null; } - @Getter - @NoArgsConstructor - @JsonIgnoreProperties(ignoreUnknown = true) - public class Partner { - //고유 ID - @JsonProperty("uuid") - public String uuid; + /** + * 사용자 정보 유효성 검증 + */ + public boolean isValid() { + return id != null && getEmail() != null; } -} \ No newline at end of file + /** + * 이메일 인증 여부 확인 + */ + public boolean isEmailVerified() { + return kakaoAccount != null && + kakaoAccount.getIsEmailVerified() != null && + kakaoAccount.getIsEmailVerified(); + } +} diff --git a/src/main/java/com/sayup/SayUp/kakao/exception/KakaoApiException.java b/src/main/java/com/sayup/SayUp/kakao/exception/KakaoApiException.java new file mode 100644 index 0000000..ed5f303 --- /dev/null +++ b/src/main/java/com/sayup/SayUp/kakao/exception/KakaoApiException.java @@ -0,0 +1,12 @@ +package com.sayup.SayUp.kakao.exception; + +public class KakaoApiException extends RuntimeException { + + public KakaoApiException(String message) { + super(message); + } + + public KakaoApiException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/sayup/SayUp/kakao/service/KakaoService.java b/src/main/java/com/sayup/SayUp/kakao/service/KakaoService.java index ecd6b45..afa4e7f 100644 --- a/src/main/java/com/sayup/SayUp/kakao/service/KakaoService.java +++ b/src/main/java/com/sayup/SayUp/kakao/service/KakaoService.java @@ -2,90 +2,146 @@ import com.sayup.SayUp.kakao.dto.KakaoTokenResponseDto; import com.sayup.SayUp.kakao.dto.KakaoUserInfoResponseDto; -import io.netty.handler.codec.http.HttpHeaderValues; -import lombok.RequiredArgsConstructor; +import com.sayup.SayUp.kakao.exception.KakaoApiException; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Mono; +import java.time.Duration; @Slf4j -@RequiredArgsConstructor @Service public class KakaoService { - private String clientId; // 카카오 개발자 콘솔에서 발급 - private final String KAUTH_TOKEN_URL_HOST; // 토큰 발급 서버 - private final String KAUTH_USER_URL_HOST; // 사용자 정보 API 서버 + private static final String KAUTH_TOKEN_URL = "https://kauth.kakao.com/oauth/token"; + private static final String KAPI_USER_URL = "https://kapi.kakao.com/v2/user/me"; + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + private final String clientId; + private final WebClient webClient; - @Autowired public KakaoService(@Value("${kakao.client_id}") String clientId) { this.clientId = clientId; - KAUTH_TOKEN_URL_HOST ="https://kauth.kakao.com"; - KAUTH_USER_URL_HOST = "https://kapi.kakao.com"; + this.webClient = WebClient.builder() + .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(1024 * 1024)) // 1MB + .build(); } /** - * 실제 인증 요청에 사용할 Access Token 반환 + * 카카오 인증 코드로 액세스 토큰 발급 */ - public String getAccessTokenFromKakao(String code) { - - KakaoTokenResponseDto kakaoTokenResponseDto = WebClient.create(KAUTH_TOKEN_URL_HOST).post() - .uri(uriBuilder -> uriBuilder - .scheme("https") - .path("/oauth/token") - .queryParam("grant_type", "authorization_code") - .queryParam("client_id", clientId) - .queryParam("code", code) - .build(true)) - .header(HttpHeaders.CONTENT_TYPE, HttpHeaderValues.APPLICATION_X_WWW_FORM_URLENCODED.toString()) - .retrieve() - //TODO : Custom Exception - .onStatus(HttpStatusCode::is4xxClientError, clientResponse -> Mono.error(new RuntimeException("Invalid Parameter"))) - .onStatus(HttpStatusCode::is5xxServerError, clientResponse -> Mono.error(new RuntimeException("Internal Server Error"))) - .bodyToMono(KakaoTokenResponseDto.class) - .block(); - - - log.info(" [Kakao Service] Access Token ------> {}", kakaoTokenResponseDto.getAccessToken()); - log.info(" [Kakao Service] Refresh Token ------> {}", kakaoTokenResponseDto.getRefreshToken()); - //제공 조건: OpenID Connect가 활성화 된 앱의 토큰 발급 요청인 경우 또는 scope에 openid를 포함한 추가 항목 동의 받기 요청을 거친 토큰 발급 요청인 경우 - log.info(" [Kakao Service] Id Token ------> {}", kakaoTokenResponseDto.getIdToken()); - log.info(" [Kakao Service] Scope ------> {}", kakaoTokenResponseDto.getScope()); - - return kakaoTokenResponseDto.getAccessToken(); - } + public KakaoTokenResponseDto getAccessToken(String code) { + validateCode(code); + + try { + log.info("Requesting Kakao access token with code: {}...", + StringUtils.truncate(code, 10)); + + KakaoTokenResponseDto response = webClient.post() + .uri(KAUTH_TOKEN_URL) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE) + .bodyValue(buildTokenRequest(code)) + .retrieve() + .onStatus(HttpStatusCode::is4xxClientError, + this::handleClientError) + .onStatus(HttpStatusCode::is5xxServerError, + this::handleServerError) + .bodyToMono(KakaoTokenResponseDto.class) + .timeout(TIMEOUT) + .block(); + validateTokenResponse(response); + log.info("Kakao access token obtained successfully"); + return response; + + } catch (Exception e) { + log.error("Error obtaining Kakao access token: {}", e.getMessage()); + throw new KakaoApiException("Failed to authenticate with Kakao: " + e.getMessage()); + } + } /** - * Access Token을 사용해 로그인한 카카오 사용자 정보를 가져옴 + * 액세스 토큰으로 카카오 사용자 정보 조회 */ public KakaoUserInfoResponseDto getUserInfo(String accessToken) { + validateAccessToken(accessToken); + + try { + log.info("Requesting Kakao user info"); + + KakaoUserInfoResponseDto response = webClient.get() + .uri(KAPI_USER_URL) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE) + .retrieve() + .onStatus(HttpStatusCode::is4xxClientError, + this::handleClientError) + .onStatus(HttpStatusCode::is5xxServerError, + this::handleServerError) + .bodyToMono(KakaoUserInfoResponseDto.class) + .timeout(TIMEOUT) + .block(); + + validateUserInfoResponse(response); + log.info("Kakao user info obtained successfully for user ID: {}", response.getId()); + return response; + + } catch (Exception e) { + log.error("Error obtaining Kakao user info: {}", e.getMessage()); + throw new KakaoApiException("Failed to get user info from Kakao: " + e.getMessage()); + } + } + + /** + * 카카오 로그인 처리 (토큰 발급 + 사용자 정보 조회) + */ + public KakaoUserInfoResponseDto processKakaoLogin(String code) { + KakaoTokenResponseDto tokenResponse = getAccessToken(code); + return getUserInfo(tokenResponse.getAccessToken()); + } + + // Private helper methods + + private void validateCode(String code) { + if (!StringUtils.hasText(code)) { + throw new IllegalArgumentException("Authorization code is required"); + } + } + + private void validateAccessToken(String accessToken) { + if (!StringUtils.hasText(accessToken)) { + throw new IllegalArgumentException("Access token is required"); + } + } + + private void validateTokenResponse(KakaoTokenResponseDto response) { + if (response == null || !response.isValid()) { + throw new KakaoApiException("Failed to obtain valid access token from Kakao"); + } + } + + private void validateUserInfoResponse(KakaoUserInfoResponseDto response) { + if (response == null || !response.isValid()) { + throw new KakaoApiException("Failed to obtain valid user info from Kakao"); + } + } + + private String buildTokenRequest(String code) { + return String.format("grant_type=authorization_code&client_id=%s&code=%s", + clientId, code); + } + + private Mono handleClientError(org.springframework.web.reactive.function.client.ClientResponse response) { + return Mono.error(new KakaoApiException("Kakao API client error: " + response.statusCode())); + } - KakaoUserInfoResponseDto userInfo = WebClient.create(KAUTH_USER_URL_HOST) - .get() - .uri(uriBuilder -> uriBuilder - .scheme("https") - .path("/v2/user/me") - .build(true)) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) // access token 인가 - .header(HttpHeaders.CONTENT_TYPE, HttpHeaderValues.APPLICATION_X_WWW_FORM_URLENCODED.toString()) - .retrieve() - //TODO : Custom Exception - .onStatus(HttpStatusCode::is4xxClientError, clientResponse -> Mono.error(new RuntimeException("Invalid Parameter"))) - .onStatus(HttpStatusCode::is5xxServerError, clientResponse -> Mono.error(new RuntimeException("Internal Server Error"))) - .bodyToMono(KakaoUserInfoResponseDto.class) - .block(); - - log.info("[ Kakao Service ] Auth ID ---> {} ", userInfo.getId()); - log.info("[ Kakao Service ] NickName ---> {} ", userInfo.getKakaoAccount().getProfile().getNickName()); - log.info("[ Kakao Service ] ProfileImageUrl ---> {} ", userInfo.getKakaoAccount().getProfile().getProfileImageUrl()); - - return userInfo; + private Mono handleServerError(org.springframework.web.reactive.function.client.ClientResponse response) { + return Mono.error(new KakaoApiException("Kakao API server error: " + response.statusCode())); } } From 4db253062aaca01caf6b6d3eec2aeb0de9620310 Mon Sep 17 00:00:00 2001 From: Kim-Yukyung Date: Mon, 16 Jun 2025 22:04:53 +0900 Subject: [PATCH 10/27] =?UTF-8?q?=E2=9C=A8=20JPA=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/sayup/SayUp/repository/ChatRoomRepository.java | 9 +++++++++ .../java/com/sayup/SayUp/repository/UserRepository.java | 8 ++++++++ 2 files changed, 17 insertions(+) diff --git a/src/main/java/com/sayup/SayUp/repository/ChatRoomRepository.java b/src/main/java/com/sayup/SayUp/repository/ChatRoomRepository.java index 2beaa09..2bfff84 100644 --- a/src/main/java/com/sayup/SayUp/repository/ChatRoomRepository.java +++ b/src/main/java/com/sayup/SayUp/repository/ChatRoomRepository.java @@ -5,6 +5,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.util.List; import java.util.Optional; public interface ChatRoomRepository extends JpaRepository { @@ -14,4 +15,12 @@ public interface ChatRoomRepository extends JpaRepository { Optional findByUserIds(@Param("userId1") Long userId1, @Param("userId2") Long userId2 ); + + // 특정 사용자가 참여한 채팅방 목록 조회 + @Query("SELECT DISTINCT r FROM ChatRoom r JOIN r.participants p WHERE p.userId = :userId") + List findByUserId(@Param("userId") Long userId); + + // 채팅방 참여자 수로 채팅방 조회 (1:1 채팅방만) + @Query("SELECT r FROM ChatRoom r JOIN r.participants p GROUP BY r HAVING COUNT(p) = 2") + List findOneToOneChatRooms(); } diff --git a/src/main/java/com/sayup/SayUp/repository/UserRepository.java b/src/main/java/com/sayup/SayUp/repository/UserRepository.java index 01ccbc7..5bceaf1 100644 --- a/src/main/java/com/sayup/SayUp/repository/UserRepository.java +++ b/src/main/java/com/sayup/SayUp/repository/UserRepository.java @@ -13,7 +13,15 @@ public interface UserRepository extends JpaRepository { Optional findByEmail(String email); + boolean existsByEmail(String email); + + Optional findByEmailAndIsActiveTrue(String email); + // email 또는 username 중 하나라도 일치하는 유저 조회 @Query("SELECT u FROM User u WHERE (:email IS NULL OR u.email = :email) AND (:username IS NULL OR u.username = :username)") List findByEmailOrUsername(@Param("email") String email, @Param("username") String username); + + // 활성 사용자만 조회 + @Query("SELECT u FROM User u WHERE u.isActive = true") + List findAllActiveUsers(); } From 30e9c63c580d36917bf0a942200f0971708ea2d6 Mon Sep 17 00:00:00 2001 From: Kim-Yukyung Date: Mon, 16 Jun 2025 22:06:48 +0900 Subject: [PATCH 11/27] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20JWT=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SayUp/security/CustomUserDetails.java | 1 - .../security/JwtAuthenticationFilter.java | 2 - .../SayUp/security/JwtTokenProvider.java | 162 ++++++++++++++---- 3 files changed, 128 insertions(+), 37 deletions(-) diff --git a/src/main/java/com/sayup/SayUp/security/CustomUserDetails.java b/src/main/java/com/sayup/SayUp/security/CustomUserDetails.java index 023ed8b..162dab5 100644 --- a/src/main/java/com/sayup/SayUp/security/CustomUserDetails.java +++ b/src/main/java/com/sayup/SayUp/security/CustomUserDetails.java @@ -58,4 +58,3 @@ public User getUser() { return user; } } - diff --git a/src/main/java/com/sayup/SayUp/security/JwtAuthenticationFilter.java b/src/main/java/com/sayup/SayUp/security/JwtAuthenticationFilter.java index 26f1854..2798c05 100644 --- a/src/main/java/com/sayup/SayUp/security/JwtAuthenticationFilter.java +++ b/src/main/java/com/sayup/SayUp/security/JwtAuthenticationFilter.java @@ -12,12 +12,10 @@ import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; -import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; -@Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class); diff --git a/src/main/java/com/sayup/SayUp/security/JwtTokenProvider.java b/src/main/java/com/sayup/SayUp/security/JwtTokenProvider.java index ccc644e..e4e1b71 100644 --- a/src/main/java/com/sayup/SayUp/security/JwtTokenProvider.java +++ b/src/main/java/com/sayup/SayUp/security/JwtTokenProvider.java @@ -3,62 +3,102 @@ import com.sayup.SayUp.service.AuthService; import io.jsonwebtoken.*; import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Lazy; import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Component; +import jakarta.annotation.PostConstruct; import java.security.Key; import java.util.Date; +@Slf4j @Component public class JwtTokenProvider { - private final Key secretKey; - private final long validityInMilliseconds; + + private Key secretKey; + private long accessTokenValidityInMilliseconds; + private long refreshTokenValidityInMilliseconds; private final AuthService authService; - public JwtTokenProvider( - @Value("${jwt.secret}") String secretKeyString, - @Value("${jwt.expiration}") long validityInMilliseconds, - @Lazy AuthService authService - ) { + @Value("${jwt.secret}") + private String secretKeyString; + + @Value("${jwt.expiration}") + private String expirationStr; + + @Value("${jwt.refresh-expiration}") + private String refreshExpirationStr; + + public JwtTokenProvider(@Lazy AuthService authService) { + this.authService = authService; + } + + @PostConstruct + public void init() { if (secretKeyString.length() < 32) { throw new IllegalArgumentException("Secret key must be at least 32 characters long"); } this.secretKey = Keys.hmacShaKeyFor(secretKeyString.getBytes()); - this.validityInMilliseconds = validityInMilliseconds; - this.authService = authService; + + try { + this.accessTokenValidityInMilliseconds = Long.parseLong(expirationStr.trim()); + this.refreshTokenValidityInMilliseconds = Long.parseLong(refreshExpirationStr.trim()); + } catch (NumberFormatException e) { + log.error("Invalid JWT expiration configuration: expiration={}, refresh-expiration={}", + expirationStr, refreshExpirationStr); + throw new IllegalArgumentException("JWT expiration values must be valid numbers", e); + } + + log.info("JWT Token Provider initialized - Access Token Expiration: {}ms, Refresh Token Expiration: {}ms", + accessTokenValidityInMilliseconds, refreshTokenValidityInMilliseconds); } /** - * JWT 토큰 생성 - * @param authentication Spring Security Authentication 객체 - * @return 생성된 JWT 토큰 + * Access Token 생성 */ public String createToken(Authentication authentication) { UserDetails userDetails = (UserDetails) authentication.getPrincipal(); - String email = userDetails.getUsername(); // 이메일 반환 + return createTokenFromEmail(userDetails.getUsername()); + } + + /** + * Refresh Token 생성 + */ + public String createRefreshToken(Authentication authentication) { + UserDetails userDetails = (UserDetails) authentication.getPrincipal(); + return createRefreshTokenFromEmail(userDetails.getUsername()); + } + /** + * 이메일로 Access Token 생성 + */ + public String createTokenFromEmail(String email) { Date now = new Date(); - Date validity = new Date(now.getTime() + validityInMilliseconds); + Date validity = new Date(now.getTime() + accessTokenValidityInMilliseconds); return Jwts.builder() - .setSubject(email) // 이메일을 subject로 설정 - .claim("roles", userDetails.getAuthorities().toString()) + .setSubject(email) + .claim("type", "access") + .claim("roles", "ROLE_USER") .setIssuedAt(now) .setExpiration(validity) .signWith(secretKey, SignatureAlgorithm.HS256) .compact(); } - public String createTokenFromEmail(String email) { + /** + * 이메일로 Refresh Token 생성 + */ + public String createRefreshTokenFromEmail(String email) { Date now = new Date(); - Date validity = new Date(now.getTime() + validityInMilliseconds); + Date validity = new Date(now.getTime() + refreshTokenValidityInMilliseconds); return Jwts.builder() - .setSubject(email) // 이메일을 subject로 설정 - .claim("roles", "ROLE_USER") // 기본 권한 설정 (필요하면 변경) + .setSubject(email) + .claim("type", "refresh") .setIssuedAt(now) .setExpiration(validity) .signWith(secretKey, SignatureAlgorithm.HS256) @@ -66,39 +106,68 @@ public String createTokenFromEmail(String email) { } /** - * JWT 토큰 유효성 검증 - * @param token 검증할 JWT 토큰 - * @return 토큰이 유효하면 true, 그렇지 않으면 false + * Access Token 유효성 검증 */ public boolean validateToken(String token) { try { - // 블랙리스트에 있는지 확인 if (authService.isTokenBlacklisted(token)) { - System.err.println("Token is blacklisted"); + log.warn("Token is blacklisted"); return false; } - Jwts.parserBuilder() + Claims claims = Jwts.parserBuilder() .setSigningKey(secretKey) .build() - .parseClaimsJws(token); + .parseClaimsJws(token) + .getBody(); + + // Access Token 타입 확인 + String tokenType = claims.get("type", String.class); + if (!"access".equals(tokenType)) { + log.warn("Invalid token type: {}", tokenType); + return false; + } + return true; } catch (ExpiredJwtException e) { - System.err.println("Token expired: " + e.getMessage()); + log.warn("Token expired: {}", e.getMessage()); } catch (MalformedJwtException e) { - System.err.println("Invalid token format: " + e.getMessage()); + log.warn("Invalid token format: {}", e.getMessage()); } catch (UnsupportedJwtException e) { - System.err.println("Unsupported token: " + e.getMessage()); + log.warn("Unsupported token: {}", e.getMessage()); } catch (IllegalArgumentException e) { - System.err.println("Token is null or empty: " + e.getMessage()); + log.warn("Token is null or empty: {}", e.getMessage()); } return false; } /** - * JWT 토큰에서 이메일(사용자 식별자) 추출 - * @param token JWT 토큰 - * @return 토큰에서 추출한 이메일 + * Refresh Token 유효성 검증 + */ + public boolean validateRefreshToken(String token) { + try { + Claims claims = Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody(); + + // Refresh Token 타입 확인 + String tokenType = claims.get("type", String.class); + if (!"refresh".equals(tokenType)) { + log.warn("Invalid refresh token type: {}", tokenType); + return false; + } + + return true; + } catch (Exception e) { + log.warn("Invalid refresh token: {}", e.getMessage()); + return false; + } + } + + /** + * JWT 토큰에서 이메일 추출 */ public String getUsernameFromToken(String token) { Claims claims = Jwts.parserBuilder() @@ -108,4 +177,29 @@ public String getUsernameFromToken(String token) { .getBody(); return claims.getSubject(); } + + /** + * JWT 토큰에서 만료 시간 추출 + */ + public long getExpirationTime(String token) { + try { + Claims claims = Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody(); + + Date expiration = claims.getExpiration(); + return expiration != null ? expiration.getTime() : System.currentTimeMillis() + accessTokenValidityInMilliseconds; + } catch (Exception e) { + return System.currentTimeMillis() + accessTokenValidityInMilliseconds; + } + } + + /** + * Access Token 만료 시간 반환 + */ + public long getExpirationTime() { + return accessTokenValidityInMilliseconds; + } } From 23befa7a88f7419a6e3e76ad27586f2ef978f520 Mon Sep 17 00:00:00 2001 From: Kim-Yukyung Date: Mon, 16 Jun 2025 22:07:28 +0900 Subject: [PATCH 12/27] =?UTF-8?q?=E2=9C=A8=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SayUp/controller/AuthController.java | 88 ++++++++++++++----- 1 file changed, 65 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/sayup/SayUp/controller/AuthController.java b/src/main/java/com/sayup/SayUp/controller/AuthController.java index 2439ab0..da5f49a 100644 --- a/src/main/java/com/sayup/SayUp/controller/AuthController.java +++ b/src/main/java/com/sayup/SayUp/controller/AuthController.java @@ -4,60 +4,102 @@ import com.sayup.SayUp.dto.AuthResponseDTO; import com.sayup.SayUp.service.AuthService; import jakarta.validation.Valid; -import lombok.AllArgsConstructor; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.util.Map; + +@Slf4j @RestController @RequestMapping("/api/auth") -@AllArgsConstructor +@RequiredArgsConstructor public class AuthController { + private final AuthService authService; /** * 사용자 회원가입 처리 - * @param authRequestDTO 사용자 이메일 및 비밀번호 정보 - * @return 회원가입 성공 또는 실패 메시지 */ @PostMapping("/register") - public ResponseEntity register(@RequestBody @Valid AuthRequestDTO authRequestDTO) { - authService.register(authRequestDTO); - return ResponseEntity.ok("User registered successfully!"); + public ResponseEntity register(@RequestBody @Valid AuthRequestDTO authRequestDTO) { + log.info("Registration attempt for email: {}", authRequestDTO.getEmail()); + + AuthResponseDTO response = authService.register(authRequestDTO); + + return ResponseEntity.status(HttpStatus.CREATED).body(response); } /** * 사용자 로그인 처리 - * @param authRequestDTO 사용자의 이메일 및 비밀번호 - * @return JWT 토큰 및 사용자 정보 */ @PostMapping("/login") public ResponseEntity login(@RequestBody @Valid AuthRequestDTO authRequestDTO) { + log.info("Login attempt for email: {}", authRequestDTO.getEmail()); + AuthResponseDTO response = authService.login(authRequestDTO); + + return ResponseEntity.ok(response); + } + + /** + * 토큰 refresh + */ + @PostMapping("/refresh") + public ResponseEntity refreshToken(@RequestBody Map request) { + String refreshToken = request.get("refreshToken"); + + if (refreshToken == null || refreshToken.trim().isEmpty()) { + return ResponseEntity.badRequest().build(); + } + + AuthResponseDTO response = authService.refreshToken(refreshToken); + return ResponseEntity.ok(response); } /** - * 로그아웃 엔드포인트 - * @param token 클라이언트가 전달한 JWT - * @return 성공 여부 + * 로그아웃 처리 */ @PostMapping("/logout") - public ResponseEntity logout(@RequestHeader(value = "Authorization", required = false) String token) { - if (token == null || token.isBlank()) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Authorization header is missing or empty"); + public ResponseEntity> logout(@RequestHeader("Authorization") String token) { + if (token == null || !token.startsWith("Bearer ")) { + return ResponseEntity.badRequest() + .body(Map.of("error", "Invalid authorization header")); } - // "Bearer " 제거 후 토큰 추출 - token = token.substring(7); + String actualToken = token.substring(7); + + try { + authService.logout(actualToken); + return ResponseEntity.ok(Map.of("message", "Logout successful")); + } catch (Exception e) { + log.error("Logout failed: {}", e.getMessage()); + return ResponseEntity.badRequest() + .body(Map.of("error", e.getMessage())); + } + } - // 이미 블랙리스트에 있는지 확인 - if (authService.isTokenBlacklisted(token)) { - return ResponseEntity.badRequest().body("Token is already invalidated."); + /** + * 토큰 유효성 검증 + */ + @GetMapping("/validate") + public ResponseEntity> validateToken(@RequestHeader("Authorization") String token) { + if (token == null || !token.startsWith("Bearer ")) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("valid", false, "error", "Invalid authorization header")); } - // 토큰 블랙리스트에 추가 - authService.invalidateToken(token); - return ResponseEntity.ok("Logout successful"); + String actualToken = token.substring(7); + + try { + boolean isValid = !authService.isTokenBlacklisted(actualToken); + return ResponseEntity.ok(Map.of("valid", isValid)); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("valid", false, "error", e.getMessage())); + } } } From f72d5f243180be2510ed6c0723d1b981952e12ea Mon Sep 17 00:00:00 2001 From: Kim-Yukyung Date: Mon, 16 Jun 2025 22:08:18 +0900 Subject: [PATCH 13/27] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor=20(?= =?UTF-8?q?=EC=A3=BC=EC=84=9D,=20JWT=20=EB=A1=9C=EC=A7=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/FriendshipController.java | 1 - .../SayUp/controller/OpenaiController.java | 25 ++-- .../UserVoiceConversionController.java | 109 +++++++++++------- 3 files changed, 77 insertions(+), 58 deletions(-) diff --git a/src/main/java/com/sayup/SayUp/controller/FriendshipController.java b/src/main/java/com/sayup/SayUp/controller/FriendshipController.java index 9d4a8de..6d34af8 100644 --- a/src/main/java/com/sayup/SayUp/controller/FriendshipController.java +++ b/src/main/java/com/sayup/SayUp/controller/FriendshipController.java @@ -5,7 +5,6 @@ import com.sayup.SayUp.security.CustomUserDetails; import com.sayup.SayUp.service.FriendshipService; import lombok.AllArgsConstructor; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; diff --git a/src/main/java/com/sayup/SayUp/controller/OpenaiController.java b/src/main/java/com/sayup/SayUp/controller/OpenaiController.java index 29a3c40..dce60b1 100644 --- a/src/main/java/com/sayup/SayUp/controller/OpenaiController.java +++ b/src/main/java/com/sayup/SayUp/controller/OpenaiController.java @@ -2,28 +2,23 @@ import com.sayup.SayUp.security.JwtTokenProvider; import com.sayup.SayUp.service.OpenaiService; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.Map; +@Slf4j @RestController @RequestMapping("/api/chat") +@RequiredArgsConstructor public class OpenaiController { private final OpenaiService openAIService; private final JwtTokenProvider jwtTokenProvider; - @Autowired - public OpenaiController(OpenaiService openAIService, JwtTokenProvider jwtTokenProvider) { - this.openAIService = openAIService; - this.jwtTokenProvider = jwtTokenProvider; - } - @PostMapping(value = "/generate", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.TEXT_PLAIN_VALUE) @@ -32,17 +27,21 @@ public ResponseEntity generateChatResponse( @RequestBody String userMessage) { // 토큰 값 검증 - if (token.startsWith("Bearer ")) { - token = token.substring(7); // "Bearer " 제거 + if (token == null || !token.startsWith("Bearer ")) { + return ResponseEntity.status(401).body("Invalid authorization header"); } + String actualToken = token.substring(7); + // 토큰이 유효한지 검증 - if (!jwtTokenProvider.validateToken(token)) { + if (!jwtTokenProvider.validateToken(actualToken)) { return ResponseEntity.status(401).body("Invalid or expired token"); } + log.info("Chat request from user: {}", jwtTokenProvider.getUsernameFromToken(actualToken)); + // 토큰 검증 후 사용자 메시지 처리 String content = openAIService.getChatResponse(userMessage); - return ResponseEntity.ok(content); // content 값만 반환 + return ResponseEntity.ok(content); } } diff --git a/src/main/java/com/sayup/SayUp/controller/UserVoiceConversionController.java b/src/main/java/com/sayup/SayUp/controller/UserVoiceConversionController.java index e772db9..404fa73 100644 --- a/src/main/java/com/sayup/SayUp/controller/UserVoiceConversionController.java +++ b/src/main/java/com/sayup/SayUp/controller/UserVoiceConversionController.java @@ -4,92 +4,108 @@ import com.sayup.SayUp.entity.User; import com.sayup.SayUp.repository.UserRepository; import com.sayup.SayUp.security.JwtTokenProvider; -import com.sayup.SayUp.service.UserVoiceService; -import lombok.AllArgsConstructor; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; -import org.springframework.core.io.InputStreamResource; import org.springframework.http.*; +import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.web.bind.annotation.*; import org.springframework.web.client.RestTemplate; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.util.Map; -// 서비스로 분리해야 할 것 같은데 일단 보류 ... - +@Slf4j @RestController @RequestMapping("/api/voice/conversion") +@RequiredArgsConstructor public class UserVoiceConversionController { - private static final Logger logger = LoggerFactory.getLogger(UserVoiceService.class); - private final JwtTokenProvider jwtTokenProvider; private final RestTemplate restTemplate; private final UserRepository userRepository; private final ObjectMapper objectMapper; - @Value("${python.server.url}") private String pythonServerUrl; // Python 서버 URL - public UserVoiceConversionController(JwtTokenProvider jwtTokenProvider, RestTemplate restTemplate, UserRepository userRepository, ObjectMapper objectMapper) { - this.jwtTokenProvider = jwtTokenProvider; - this.restTemplate = restTemplate; - this.userRepository = userRepository; - this.objectMapper = objectMapper; - } - @PostMapping public ResponseEntity convertVoice( @RequestHeader("Authorization") String token, @RequestBody Map requestBody) { try { + // 토큰 검증 + if (token == null || !token.startsWith("Bearer ")) { + log.warn("Invalid authorization header format"); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "Invalid authorization header format")); + } + + String actualToken = token.substring(7); + if (!jwtTokenProvider.validateToken(actualToken)) { + log.warn("Invalid or expired token"); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "Invalid or expired token")); + } + // JWT 토큰에서 사용자 이메일 검증 - String email = jwtTokenProvider.getUsernameFromToken(token.substring(7)); + String email = jwtTokenProvider.getUsernameFromToken(actualToken); + if (email == null || email.trim().isEmpty()) { + log.warn("Invalid email from token"); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "Invalid token content")); + } // 사용자 정보 조회 User user = userRepository.findByEmail(email) - .orElseThrow(() -> new RuntimeException("User not found with email: " + email)); + .orElseThrow(() -> new UsernameNotFoundException("User not found with email: " + email)); + + // 사용자 활성 상태 확인 + if (!user.getIsActive()) { + log.warn("Inactive user attempted voice conversion: {}", email); + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(Map.of("error", "Account is not active")); + } // 벡터 확인 (DB에서 가져옴) String vector = user.getTtsVector(); - if (vector == null || vector.isEmpty()) { + if (vector == null || vector.trim().isEmpty()) { + log.warn("No TTS vector found for user: {}", email); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of( - "status", "error", - "message", "No TTS vector found for the user." + "error", "No TTS vector found for the user. Please upload your voice sample first." )); } - // 요청 데이터 확인 + // 요청 데이터 검증 String sentence = requestBody.get("sentence"); + if (sentence == null || sentence.trim().isEmpty()) { + log.warn("Empty sentence provided for voice conversion"); + return ResponseEntity.badRequest().body(Map.of( + "error", "Sentence is required and cannot be empty." + )); + } - if (sentence == null || sentence.isEmpty()) { + // 입력 길이 제한 + if (sentence.length() > 1000) { + log.warn("Sentence too long for user: {}, length: {}", email, sentence.length()); return ResponseEntity.badRequest().body(Map.of( - "status", "error", - "message", "Both 'sentence' and 'vector' are required." + "error", "Sentence is too long. Maximum 1000 characters allowed." )); } - logger.info("Sending request to Python server for voice conversion."); + log.info("Processing voice conversion for user: {}", email); // Python 서버로 HTTP 요청 String pythonEndpoint = pythonServerUrl + "/convert"; HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); - headers.set("Authorization", token); // Authorization 헤더 추가 + headers.set("Authorization", token); - ObjectMapper objectMapper = new ObjectMapper(); - String vectorJson = objectMapper.writeValueAsString(objectMapper.readValue(vector, Object.class)); // JSON 문자열로 변환 - - logger.info("Sending vector: " + vectorJson); + String vectorJson = objectMapper.writeValueAsString(objectMapper.readValue(vector, Object.class)); Map request = Map.of( - "text", sentence, - "vector", vectorJson // JSON으로 변환된 벡터 값 + "text", sentence.trim(), + "vector", vectorJson ); HttpEntity> entity = new HttpEntity<>(request, headers); @@ -101,23 +117,28 @@ public ResponseEntity convertVoice( byte[].class ); - if (pythonResponse.getStatusCode().is2xxSuccessful()) { - logger.info("Voice conversion completed, returning audio file."); - - // 파일 반환 + if (pythonResponse.getStatusCode().is2xxSuccessful() && pythonResponse.getBody() != null) { + log.info("Voice conversion completed successfully for user: {}", email); + return ResponseEntity.ok() .contentType(MediaType.parseMediaType("audio/wav")) + .header("Content-Disposition", "attachment; filename=\"converted_voice.wav\"") .body(pythonResponse.getBody()); } else { + log.error("Python server returned error status: {} for user: {}", pythonResponse.getStatusCode(), email); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("status", "error", "message", "Failed to process voice conversion.")); + .body(Map.of("error", "Failed to process voice conversion. Please try again later.")); } + } catch (UsernameNotFoundException e) { + log.error("User not found during voice conversion: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "User not found")); } catch (Exception ex) { + log.error("Error during voice conversion: ", ex); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of( - "status", "error", - "message", "Voice conversion request failed." + "error", "Voice conversion failed. Please try again later." )); } } -} \ No newline at end of file +} From 21d4437c774965a19586f6042b39ce1acdc70ab9 Mon Sep 17 00:00:00 2001 From: Kim-Yukyung Date: Mon, 16 Jun 2025 22:09:25 +0900 Subject: [PATCH 14/27] =?UTF-8?q?=E2=9C=A8=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85/=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/sayup/SayUp/service/AuthService.java | 298 ++++++++++++------ 1 file changed, 200 insertions(+), 98 deletions(-) diff --git a/src/main/java/com/sayup/SayUp/service/AuthService.java b/src/main/java/com/sayup/SayUp/service/AuthService.java index 2cf9677..dc75cc3 100644 --- a/src/main/java/com/sayup/SayUp/service/AuthService.java +++ b/src/main/java/com/sayup/SayUp/service/AuthService.java @@ -1,162 +1,264 @@ package com.sayup.SayUp.service; -import com.sayup.SayUp.controller.AuthController; import com.sayup.SayUp.dto.AuthRequestDTO; import com.sayup.SayUp.dto.AuthResponseDTO; import com.sayup.SayUp.entity.User; +import com.sayup.SayUp.kakao.dto.KakaoUserInfoResponseDto; +import com.sayup.SayUp.kakao.service.KakaoService; import com.sayup.SayUp.repository.UserRepository; import com.sayup.SayUp.security.CustomUserDetails; import com.sayup.SayUp.security.JwtTokenProvider; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.context.annotation.Lazy; -import org.springframework.security.authentication.AuthenticationManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; -import java.util.Collection; +import java.time.LocalDateTime; import java.util.Collections; -import java.util.HashSet; -import java.util.Set; - @Service +@RequiredArgsConstructor +@Slf4j +@Transactional public class AuthService implements UserDetailsService { + private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; - private final AuthenticationManager authenticationManager; private final JwtTokenProvider jwtTokenProvider; - private final Set tokenBlacklist = new HashSet<>(); - - private static final Logger logger = LoggerFactory.getLogger(AuthController.class); - - public AuthService(UserRepository userRepository, - PasswordEncoder passwordEncoder, - JwtTokenProvider jwtTokenProvider, - @Lazy AuthenticationManager authenticationManager) { - this.userRepository = userRepository; - this.passwordEncoder = passwordEncoder; - this.authenticationManager = authenticationManager; - this.jwtTokenProvider = jwtTokenProvider; - } + private final TokenBlacklistService tokenBlacklistService; + private final KakaoService kakaoService; /** * 회원가입 로직 - * @param authRequestDTO 회원가입 요청 DTO */ - public void register(AuthRequestDTO authRequestDTO) { - userRepository.findByEmail(authRequestDTO.getEmail()).ifPresent(user -> { - logger.info("Registration attempt failed: Email already exists - {}", authRequestDTO.getEmail()); - throw new IllegalArgumentException("The email address is already in use. Please try another one."); - }); - - User user = new User(); - user.setEmail(authRequestDTO.getEmail()); - user.setPassword(passwordEncoder.encode(authRequestDTO.getPassword())); // 비밀번호 암호화 - - userRepository.save(user); - logger.info("User registered successfully with email: {}", authRequestDTO.getEmail()); - } + public AuthResponseDTO register(AuthRequestDTO authRequestDTO) { + // 이메일 중복 확인 + if (userRepository.existsByEmail(authRequestDTO.getEmail())) { + log.warn("Registration attempt failed: Email already exists - {}", authRequestDTO.getEmail()); + throw new IllegalArgumentException("이미 사용 중인 이메일입니다."); + } - // 카카오 로그인 시 사용자 자동 등록 - public User loadOrCreateUser(String email) { - return userRepository.findByEmail(email) - .orElseGet(() -> { - User user = new User(); - user.setEmail(email); - user.setPassword(passwordEncoder.encode("kakao_user")); // OAuth 사용자는 임시 비밀번호 + // 사용자 생성 + User user = User.builder() + .email(authRequestDTO.getEmail()) + .username(authRequestDTO.getEmail().split("@")[0]) + .password(passwordEncoder.encode(authRequestDTO.getPassword())) + .role("USER") + .createdAt(LocalDateTime.now()) + .build(); - user.setRole("USER"); // 기본 역할 설정 + User savedUser = userRepository.save(user); + log.info("User registered successfully with email: {}", authRequestDTO.getEmail()); - return userRepository.save(user); - }); + // 자동 로그인 처리 + return login(authRequestDTO); } /** - * Spring Security UserDetailsService 구현 - * @param email 사용자 이메일 - * @return UserDetails 객체 - * @throws UsernameNotFoundException 사용자 찾지 못했을 때 예외 + * 로그인 로직 */ - @Override - public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { - User user = userRepository.findByEmail(email) - .orElseThrow(() -> new UsernameNotFoundException("User not found with email: " + email)); + public AuthResponseDTO login(AuthRequestDTO authRequestDTO) { + try { + // 사용자 조회 + User user = userRepository.findByEmail(authRequestDTO.getEmail()) + .orElseThrow(() -> new BadCredentialsException("이메일 또는 비밀번호가 올바르지 않습니다.")); - /* - return org.springframework.security.core.userdetails.User.builder() - .username(user.getEmail()) - .password(user.getPassword()) - .authorities(Collections.emptyList()) // 권한 설정 - .build(); - */ - return new CustomUserDetails(user); + // 비밀번호 검증 + if (!passwordEncoder.matches(authRequestDTO.getPassword(), user.getPassword())) { + log.warn("Login failed for email: {} - Invalid password", authRequestDTO.getEmail()); + throw new BadCredentialsException("이메일 또는 비밀번호가 올바르지 않습니다."); + } + + // 인증 토큰 생성 + Authentication authentication = new UsernamePasswordAuthenticationToken( + user.getEmail(), + null, + Collections.singletonList(new SimpleGrantedAuthority(user.getRole())) + ); + + // JWT 토큰 생성 + String accessToken = jwtTokenProvider.createToken(authentication); + String refreshToken = jwtTokenProvider.createRefreshToken(authentication); + + log.info("Login successful for email: {}", authRequestDTO.getEmail()); + + // 응답 생성 + return AuthResponseDTO.builder() + .accessToken(accessToken) + .tokenType("Bearer") + .expiresIn(jwtTokenProvider.getExpirationTime()) + .refreshToken(refreshToken) + .userInfo(AuthResponseDTO.UserInfo.builder() + .userId(user.getUserId()) + .email(user.getEmail()) + .username(user.getUsername()) + .role(user.getRole()) + .build()) + .build(); + + } catch (BadCredentialsException e) { + log.warn("Login failed for email: {} - Invalid credentials", authRequestDTO.getEmail()); + throw new BadCredentialsException("이메일 또는 비밀번호가 올바르지 않습니다."); + } } /** - * 로그인 로직 - * @param authRequestDTO 로그인 요청 DTO - * @return AuthResponseDTO (JWT와 사용자 이메일) + * 카카오 로그인 처리 */ - public AuthResponseDTO login(AuthRequestDTO authRequestDTO) { - // Authentication 객체 생성 및 인증 - Authentication authentication = authenticationManager.authenticate( - new UsernamePasswordAuthenticationToken( - authRequestDTO.getEmail(), - authRequestDTO.getPassword() - ) - ); + public AuthResponseDTO kakaoLogin(String code) { + try { + // 카카오 API로 사용자 정보 조회 + KakaoUserInfoResponseDto kakaoUserInfo = kakaoService.processKakaoLogin(code); + + // 이메일 검증 + String email = kakaoUserInfo.getEmail(); + if (!StringUtils.hasText(email)) { + throw new IllegalArgumentException("카카오 계정에서 이메일 정보를 가져올 수 없습니다."); + } + + // 이메일 인증 여부 확인 (선택사항) + if (!kakaoUserInfo.isEmailVerified()) { + log.warn("Kakao user email not verified: {}", email); + } - String jwt = jwtTokenProvider.createToken(authentication); + // 사용자 조회 또는 생성 + User user = userRepository.findByEmail(email) + .orElseGet(() -> createKakaoUser(kakaoUserInfo)); - User user = userRepository.findByEmail(authRequestDTO.getEmail()) - .orElseThrow(() -> new RuntimeException("User not found")); + // JWT 토큰 생성 + String accessToken = jwtTokenProvider.createTokenFromEmail(email); + String refreshToken = jwtTokenProvider.createRefreshTokenFromEmail(email); - logger.info("Login successful for email: {}", authRequestDTO.getEmail()); + log.info("Kakao login successful for user: {}", email); - return new AuthResponseDTO(jwt, authRequestDTO.getEmail(), String.valueOf(user.getUserId())); + return AuthResponseDTO.builder() + .accessToken(accessToken) + .tokenType("Bearer") + .expiresIn(jwtTokenProvider.getExpirationTime()) + .refreshToken(refreshToken) + .userInfo(AuthResponseDTO.UserInfo.builder() + .userId(user.getUserId()) + .email(user.getEmail()) + .username(user.getUsername()) + .role(user.getRole()) + .build()) + .build(); + + } catch (Exception e) { + log.error("Kakao login failed: {}", e.getMessage()); + throw new IllegalArgumentException("카카오 로그인 처리 중 오류가 발생했습니다: " + e.getMessage()); + } } + /** + * 카카오 사용자 생성 + */ + private User createKakaoUser(KakaoUserInfoResponseDto kakaoUserInfo) { + String email = kakaoUserInfo.getEmail(); + String nickname = kakaoUserInfo.getNickname(); + String username = StringUtils.hasText(nickname) ? nickname : email.split("@")[0]; + User newUser = User.builder() + .email(email) + .username(username) + .password(passwordEncoder.encode(generateSecureRandomPassword())) + .role("USER") + .createdAt(LocalDateTime.now()) + .build(); + + User savedUser = userRepository.save(newUser); + log.info("New Kakao user created: {} (ID: {})", email, savedUser.getUserId()); + return savedUser; + } /** - * 토큰을 블랙리스트에 추가 - * @param token 무효화할 토큰 + * 토큰 갱신 */ - public void invalidateToken(String token) { - if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) { - tokenBlacklist.add(token); - logger.info("Token invalidated and added to blacklist: {}", token); - } else { - logger.warn("Attempted to invalidate an invalid or empty token."); - throw new IllegalArgumentException("Invalid token. Cannot invalidate."); + public AuthResponseDTO refreshToken(String refreshToken) { + if (!jwtTokenProvider.validateRefreshToken(refreshToken)) { + throw new IllegalArgumentException("유효하지 않은 리프레시 토큰입니다."); } + + String email = jwtTokenProvider.getUsernameFromToken(refreshToken); + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); + + String newAccessToken = jwtTokenProvider.createTokenFromEmail(email); + String newRefreshToken = jwtTokenProvider.createRefreshTokenFromEmail(email); + + return AuthResponseDTO.builder() + .accessToken(newAccessToken) + .tokenType("Bearer") + .expiresIn(jwtTokenProvider.getExpirationTime()) + .refreshToken(newRefreshToken) + .userInfo(AuthResponseDTO.UserInfo.builder() + .userId(user.getUserId()) + .email(user.getEmail()) + .username(user.getUsername()) + .role(user.getRole()) + .build()) + .build(); } + /** + * 로그아웃 + */ + public void logout(String token) { + if (!StringUtils.hasText(token)) { + throw new IllegalArgumentException("토큰이 비어있습니다."); + } + + if (!jwtTokenProvider.validateToken(token)) { + throw new IllegalArgumentException("유효하지 않은 토큰입니다."); + } + + long expirationTime = jwtTokenProvider.getExpirationTime(token); + tokenBlacklistService.addToBlacklist(token, expirationTime); + + log.info("User logged out successfully"); + } /** - * 토큰이 블랙리스트에 있는지 확인 - * @param token 확인할 토큰 - * @return 블랙리스트 여부 + * 토큰 블랙리스트 확인 */ public boolean isTokenBlacklisted(String token) { if (!StringUtils.hasText(token)) { - logger.warn("Empty token checked for blacklist."); - throw new IllegalArgumentException("Token cannot be empty."); + return false; } - return tokenBlacklist.contains(token); + return tokenBlacklistService.isBlacklisted(token); } - private Collection getAuthorities(User user) { - // 모든 사용자에게 "ROLE_USER" 권한을 부여 - return Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")); + /** + * Spring Security UserDetailsService 구현 + */ + @Override + @Transactional(readOnly = true) + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException("User not found with email: " + email)); + + return new CustomUserDetails(user); + } + + /** + * 보안을 위한 랜덤 비밀번호 생성 + */ + private String generateSecureRandomPassword() { + String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*"; + StringBuilder sb = new StringBuilder(); + java.util.Random random = new java.util.Random(); + for (int i = 0; i < 32; i++) { + sb.append(chars.charAt(random.nextInt(chars.length()))); + } + return sb.toString(); } } From 92597b1057793e542c0c40713fa8600b4acd053f Mon Sep 17 00:00:00 2001 From: Kim-Yukyung Date: Mon, 16 Jun 2025 22:10:40 +0900 Subject: [PATCH 15/27] =?UTF-8?q?=E2=9C=A8=20=EA=B1=B0=EC=A0=88/=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20&=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SayUp/service/FriendshipService.java | 211 ++++++++++++++---- 1 file changed, 171 insertions(+), 40 deletions(-) diff --git a/src/main/java/com/sayup/SayUp/service/FriendshipService.java b/src/main/java/com/sayup/SayUp/service/FriendshipService.java index 888f524..f7659bb 100644 --- a/src/main/java/com/sayup/SayUp/service/FriendshipService.java +++ b/src/main/java/com/sayup/SayUp/service/FriendshipService.java @@ -1,13 +1,13 @@ package com.sayup.SayUp.service; -import com.sayup.SayUp.controller.FriendshipController; import com.sayup.SayUp.dto.PendingRequestDTO; import com.sayup.SayUp.entity.FriendRelationship; import com.sayup.SayUp.entity.User; import com.sayup.SayUp.repository.FriendshipRepository; import com.sayup.SayUp.repository.UserRepository; import com.sayup.SayUp.security.CustomUserDetails; -import org.springframework.beans.factory.annotation.Autowired; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -16,49 +16,70 @@ import java.util.Optional; import java.util.stream.Collectors; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - +@Slf4j @Service +@RequiredArgsConstructor +@Transactional public class FriendshipService { - private static final Logger logger = LoggerFactory.getLogger(FriendshipController.class); - - @Autowired - private FriendshipRepository friendshipRepository; - @Autowired - private UserRepository userRepository; + private final FriendshipRepository friendshipRepository; + private final UserRepository userRepository; /** - * @param addresseeId: 친구 요청을 받을 사용자의 userId + * 친구 요청 보내기 + * + * @param requesterDetails 요청자 정보 + * @param addresseeId 친구 요청을 받을 사용자의 userId */ - @Transactional public void sendFriendRequest(CustomUserDetails requesterDetails, Long addresseeId) { - User requester = requesterDetails.getUser(); // User 객체 직접 접근 - User addressee = userRepository.findById(addresseeId) - .orElseThrow(() -> new RuntimeException("User not found")); + // 입력 검증 + if (requesterDetails == null || addresseeId == null) { + throw new IllegalArgumentException("요청자 정보와 대상 사용자 ID는 null일 수 없습니다."); + } + User requester = requesterDetails.getUser(); + // 자기 자신에게 친구 요청을 보내는 것을 방지 - if (requester.getUserId().equals(addresseeId.longValue())) { - throw new RuntimeException("Cannot send friend request to yourself"); + if (requester.getUserId().equals(addresseeId)) { + log.warn("User {} attempted to send friend request to themselves", requester.getUserId()); + throw new IllegalArgumentException("자기 자신에게는 친구 요청을 보낼 수 없습니다."); } + // 대상 사용자 존재 확인 + User addressee = userRepository.findById(addresseeId) + .orElseThrow(() -> new IllegalArgumentException("대상 사용자를 찾을 수 없습니다. ID: " + addresseeId)); + + // 사용자 활성 상태 확인 + if (!requester.getIsActive() || !addressee.getIsActive()) { + throw new IllegalArgumentException("비활성 사용자와는 친구 관계를 맺을 수 없습니다."); + } + + log.info("Friend request from user {} to user {}", requester.getUserId(), addresseeId); + // 이미 존재하는 친구 관계 검증 - Optional existingRelationship = + Optional existingRelationship = friendshipRepository.findRelationship(requester, addressee); if (existingRelationship.isPresent()) { FriendRelationship relationship = existingRelationship.get(); + if (relationship.getStatus() == FriendRelationship.FriendshipStatus.ACCEPTED) { - throw new RuntimeException("Already friends"); + log.warn("Friend request failed: Already friends between {} and {}", + requester.getUserId(), addresseeId); + throw new IllegalArgumentException("이미 친구 관계입니다."); } else if (relationship.getStatus() == FriendRelationship.FriendshipStatus.PENDING) { - throw new RuntimeException("Friend request already pending"); + log.warn("Friend request failed: Request already pending between {} and {}", + requester.getUserId(), addresseeId); + throw new IllegalArgumentException("이미 대기 중인 친구 요청이 있습니다."); } else if (relationship.getStatus() == FriendRelationship.FriendshipStatus.REJECTED) { // 거절된 요청의 경우 새로운 요청을 허용하되, 이전 요청을 삭제 + log.info("Deleting previous rejected request and creating new one between {} and {}", + requester.getUserId(), addresseeId); friendshipRepository.delete(relationship); } } + // 새로운 친구 요청 생성 FriendRelationship relationship = new FriendRelationship(); relationship.setRequester(requester); relationship.setAddressee(addressee); @@ -66,56 +87,166 @@ public void sendFriendRequest(CustomUserDetails requesterDetails, Long addressee relationship.setRequestedAt(LocalDateTime.now()); friendshipRepository.save(relationship); + log.info("Friend request created successfully between {} and {}", + requester.getUserId(), addresseeId); } /** - * 전체 흐름 정리 - * 1. A 유저가 B에게 친구 요청 - * 서버에서 FriendRelationship 생성 -> relationshipId 생성 - * 2. B 유저가 getPendingRequests() API 호출 - * B를 향한 PENDING 상태의 요청들을 조회 - * 각 요청마다 relationshipId + 요청자 정보 반환 - * 3. B 유저가 특정 요청을 수락 - * /friend/accept API 호출 시 relationshipId 전달 - * 서버에서 해당 요청의 상태를 ACCEPTED로 변경 + * 친구 요청 수락 + * + * @param addresseeDetails 요청을 받은 사용자 정보 + * @param relationshipId 친구 관계 ID */ - @Transactional public void acceptFriendRequest(CustomUserDetails addresseeDetails, Long relationshipId) { + // 입력 검증 + if (addresseeDetails == null || relationshipId == null) { + throw new IllegalArgumentException("사용자 정보와 관계 ID는 null일 수 없습니다."); + } + User addressee = addresseeDetails.getUser(); + + log.info("Friend request acceptance attempt by user {} for relationship {}", + addressee.getUserId(), relationshipId); + + // 친구 관계 조회 FriendRelationship relationship = friendshipRepository.findById(relationshipId) - .orElseThrow(() -> new RuntimeException("Friend request not found")); + .orElseThrow(() -> new IllegalArgumentException("친구 요청을 찾을 수 없습니다. ID: " + relationshipId)); - if (!relationship.getAddressee().equals(addressee)) { - throw new RuntimeException("Not authorized to accept this request"); + // 권한 검증: 요청을 받은 사용자가 맞는지 확인 + if (!relationship.getAddressee().getUserId().equals(addressee.getUserId())) { + log.warn("Unauthorized friend request acceptance attempt by user {} for relationship {}", + addressee.getUserId(), relationshipId); + throw new IllegalArgumentException("해당 친구 요청을 수락할 권한이 없습니다."); } + // 상태 검증 if (relationship.getStatus() != FriendRelationship.FriendshipStatus.PENDING) { - throw new RuntimeException("Request is not in PENDING status"); + log.warn("Invalid friend request status for acceptance: {} (relationship: {})", + relationship.getStatus(), relationshipId); + throw new IllegalArgumentException("대기 중인 요청만 수락할 수 있습니다."); } + // 친구 요청 수락 처리 relationship.setStatus(FriendRelationship.FriendshipStatus.ACCEPTED); relationship.setAcceptedAt(LocalDateTime.now()); friendshipRepository.save(relationship); + log.info("Friend request accepted successfully: {} by user {}", + relationshipId, addressee.getUserId()); + } + + /** + * 친구 요청 거절 + */ + public void rejectFriendRequest(CustomUserDetails addresseeDetails, Long relationshipId) { + // 입력 검증 + if (addresseeDetails == null || relationshipId == null) { + throw new IllegalArgumentException("사용자 정보와 관계 ID는 null일 수 없습니다."); + } + + User addressee = addresseeDetails.getUser(); + + log.info("Friend request rejection attempt by user {} for relationship {}", + addressee.getUserId(), relationshipId); + + // 친구 관계 조회 + FriendRelationship relationship = friendshipRepository.findById(relationshipId) + .orElseThrow(() -> new IllegalArgumentException("친구 요청을 찾을 수 없습니다. ID: " + relationshipId)); + + // 권한 검증 + if (!relationship.getAddressee().getUserId().equals(addressee.getUserId())) { + log.warn("Unauthorized friend request rejection attempt by user {} for relationship {}", + addressee.getUserId(), relationshipId); + throw new IllegalArgumentException("해당 친구 요청을 거절할 권한이 없습니다."); + } + + // 상태 검증 + if (relationship.getStatus() != FriendRelationship.FriendshipStatus.PENDING) { + throw new IllegalArgumentException("대기 중인 요청만 거절할 수 있습니다."); + } + + // 친구 요청 거절 처리 + relationship.setStatus(FriendRelationship.FriendshipStatus.REJECTED); + friendshipRepository.save(relationship); + + log.info("Friend request rejected successfully: {} by user {}", + relationshipId, addressee.getUserId()); } + /** + * 친구 목록 조회 + */ + @Transactional(readOnly = true) public List getFriendsList(CustomUserDetails userDetails) { + if (userDetails == null) { + throw new IllegalArgumentException("사용자 정보는 null일 수 없습니다."); + } + User user = userDetails.getUser(); + log.info("Fetching friends list for user: {}", user.getUserId()); + return friendshipRepository.findAllFriends(user).stream() - .map(relationship -> - relationship.getRequester().equals(user) + .map(relationship -> + relationship.getRequester().getUserId().equals(user.getUserId()) ? relationship.getAddressee() : relationship.getRequester()) + .filter(friend -> friend.getIsActive()) // 활성 사용자만 필터링 .collect(Collectors.toList()); } + /** + * 대기 중인 친구 요청 목록 조회 + */ + @Transactional(readOnly = true) public List getPendingRequests(CustomUserDetails userDetails) { + if (userDetails == null) { + throw new IllegalArgumentException("사용자 정보는 null일 수 없습니다."); + } + User user = userDetails.getUser(); - List relationships = friendshipRepository.findByAddresseeAndStatus(user, FriendRelationship.FriendshipStatus.PENDING); - logger.info("Finding pending requests for user: {}, results: {}", user.getUserId(), relationships.size()); + log.info("Fetching pending friend requests for user: {}", user.getUserId()); + + List relationships = friendshipRepository + .findByAddresseeAndStatus(user, FriendRelationship.FriendshipStatus.PENDING); - return relationships.stream() + List pendingRequests = relationships.stream() + .filter(rel -> rel.getRequester().getIsActive()) // 활성 사용자의 요청만 필터링 .map(rel -> new PendingRequestDTO(rel.getId(), rel.getRequester())) .collect(Collectors.toList()); + + log.info("Found {} pending requests for user: {}", pendingRequests.size(), user.getUserId()); + return pendingRequests; + } + + /** + * 친구 관계 삭제 + */ + public void removeFriend(CustomUserDetails userDetails, Long friendUserId) { + if (userDetails == null || friendUserId == null) { + throw new IllegalArgumentException("사용자 정보와 친구 ID는 null일 수 없습니다."); + } + + User user = userDetails.getUser(); + + log.info("Friend removal attempt by user {} for friend {}", user.getUserId(), friendUserId); + + // 친구 관계 조회 + Optional relationship = friendshipRepository.findRelationship(user, + userRepository.findById(friendUserId) + .orElseThrow(() -> new IllegalArgumentException("친구 사용자를 찾을 수 없습니다. ID: " + friendUserId))); + + if (relationship.isEmpty()) { + throw new IllegalArgumentException("친구 관계가 존재하지 않습니다."); + } + + FriendRelationship rel = relationship.get(); + if (rel.getStatus() != FriendRelationship.FriendshipStatus.ACCEPTED) { + throw new IllegalArgumentException("친구 관계가 아닙니다."); + } + + // 친구 관계 삭제 + friendshipRepository.delete(rel); + log.info("Friend relationship removed successfully between {} and {}", + user.getUserId(), friendUserId); } } From f17442bc91405974eaa5e84092c6e7139fe26334 Mon Sep 17 00:00:00 2001 From: Kim-Yukyung Date: Mon, 16 Jun 2025 22:11:12 +0900 Subject: [PATCH 16/27] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=20=EC=A0=9C=ED=95=9C=20=EB=B0=8F=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sayup/SayUp/service/OpenaiService.java | 59 ++++++++++++++----- 1 file changed, 43 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/sayup/SayUp/service/OpenaiService.java b/src/main/java/com/sayup/SayUp/service/OpenaiService.java index f6eeaaa..0185607 100644 --- a/src/main/java/com/sayup/SayUp/service/OpenaiService.java +++ b/src/main/java/com/sayup/SayUp/service/OpenaiService.java @@ -2,7 +2,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import org.springframework.beans.factory.annotation.Autowired; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.http.*; @@ -11,8 +11,10 @@ import java.util.*; +@Slf4j @Service public class OpenaiService { + @Value("${openai.api.url}") private String openaiApiUrl; @@ -22,7 +24,6 @@ public class OpenaiService { private final RestTemplate restTemplate; private final ObjectMapper objectMapper; - @Autowired public OpenaiService(RestTemplateBuilder builder, ObjectMapper objectMapper) { this.restTemplate = builder.build(); this.objectMapper = objectMapper; @@ -30,19 +31,39 @@ public OpenaiService(RestTemplateBuilder builder, ObjectMapper objectMapper) { public String getChatResponse(String userMessage) { try { + // 입력 검증 + if (userMessage == null || userMessage.trim().isEmpty()) { + log.warn("Empty user message received"); + return "메시지를 입력해주세요."; + } + + // 입력 길이 제한 + if (userMessage.length() > 2000) { + log.warn("User message too long: {} characters", userMessage.length()); + return "메시지가 너무 깁니다. 2000자 이내로 입력해주세요."; + } + + // API 키 검증 + if (openaiApiKey == null || openaiApiKey.trim().isEmpty()) { + log.error("OpenAI API key is not configured"); + return "서비스 설정 오류가 발생했습니다. 관리자에게 문의해주세요."; + } + // 한국어 학습용 프롬프트 최적화 String systemMessage = "You are a helpful Korean language tutor. Assist users with learning Korean. " + "Your responses should be clear, polite, and tailored to help a non-native speaker improve their Korean. " + - "Include explanations when needed, use simple sentences, and provide translations."; + "Include explanations when needed, use simple sentences, and provide translations. " + + "Always respond in Korean unless specifically asked to use another language."; // 요청 Body 설정 Map requestBody = Map.of( "model", "gpt-3.5-turbo", "messages", Arrays.asList( Map.of("role", "system", "content", systemMessage), - Map.of("role", "user", "content", userMessage) + Map.of("role", "user", "content", userMessage.trim()) ), - "temperature", 0.7 + "temperature", 0.7, + "max_tokens", 1000 ); HttpHeaders headers = new HttpHeaders(); @@ -51,6 +72,8 @@ public String getChatResponse(String userMessage) { HttpEntity> entity = new HttpEntity<>(requestBody, headers); + log.info("Sending request to OpenAI API for user message: {}", userMessage.substring(0, Math.min(50, userMessage.length()))); + // OpenAI API 호출 ResponseEntity response = restTemplate.exchange( openaiApiUrl, @@ -59,22 +82,26 @@ public String getChatResponse(String userMessage) { String.class ); - if (response.getStatusCode() == HttpStatus.OK) { + if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) { // JSON 응답에서 content만 추출 JsonNode root = objectMapper.readTree(response.getBody()); - String content = root.path("choices") - .get(0) - .path("message") - .path("content") - .asText(); - - return content; + JsonNode choices = root.path("choices"); + + if (choices.isArray() && choices.size() > 0) { + String content = choices.get(0).path("message").path("content").asText(); + log.info("OpenAI API response received successfully"); + return content; + } else { + log.error("Invalid response format from OpenAI API"); + return "응답 처리 중 오류가 발생했습니다. 다시 시도해주세요."; + } } else { - throw new RuntimeException("OpenAI API failed: " + response.getStatusCode()); + log.error("OpenAI API returned error status: {}", response.getStatusCode()); + return "서비스 일시적 오류가 발생했습니다. 잠시 후 다시 시도해주세요."; } } catch (Exception e) { - e.printStackTrace(); - return "Error occurred while calling OpenAI API."; + log.error("Error calling OpenAI API: ", e); + return "서비스 오류가 발생했습니다. 잠시 후 다시 시도해주세요."; } } } From 0f20154ca7fe6dbcd8298f4af40e347d4f02ad11 Mon Sep 17 00:00:00 2001 From: Kim-Yukyung Date: Mon, 16 Jun 2025 22:11:38 +0900 Subject: [PATCH 17/27] =?UTF-8?q?=E2=9C=A8=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EB=B8=94=EB=9E=99=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SayUp/service/TokenBlacklistService.java | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 src/main/java/com/sayup/SayUp/service/TokenBlacklistService.java diff --git a/src/main/java/com/sayup/SayUp/service/TokenBlacklistService.java b/src/main/java/com/sayup/SayUp/service/TokenBlacklistService.java new file mode 100644 index 0000000..822ac60 --- /dev/null +++ b/src/main/java/com/sayup/SayUp/service/TokenBlacklistService.java @@ -0,0 +1,86 @@ +package com.sayup.SayUp.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +@Slf4j +public class TokenBlacklistService { + + private final RedisTemplate redisTemplate; + private static final String BLACKLIST_PREFIX = "blacklist:"; + + /** + * 토큰을 블랙리스트에 추가 + * @param token 무효화할 토큰 + * @param expirationTime 토큰 만료 시간 (밀리초) + */ + public void addToBlacklist(String token, long expirationTime) { + if (token == null || token.trim().isEmpty()) { + log.warn("Attempted to blacklist null or empty token"); + return; + } + + String key = BLACKLIST_PREFIX + token; + long ttl = calculateTTL(expirationTime); + + redisTemplate.opsForValue().set(key, "blacklisted", ttl, TimeUnit.MILLISECONDS); + log.info("Token added to blacklist with TTL: {} ms", ttl); + } + + /** + * 토큰이 블랙리스트에 있는지 확인 + * @param token 확인할 토큰 + * @return 블랙리스트 여부 + */ + public boolean isBlacklisted(String token) { + if (token == null || token.trim().isEmpty()) { + log.warn("Attempted to check null or empty token for blacklist"); + return false; + } + + String key = BLACKLIST_PREFIX + token; + Boolean exists = redisTemplate.hasKey(key); + + if (Boolean.TRUE.equals(exists)) { + log.debug("Token found in blacklist: {}", token); + return true; + } + + return false; + } + + /** + * 토큰을 블랙리스트에서 제거 (필요시 사용) + * @param token 제거할 토큰 + */ + public void removeFromBlacklist(String token) { + if (token == null || token.trim().isEmpty()) { + log.warn("Attempted to remove null or empty token from blacklist"); + return; + } + + String key = BLACKLIST_PREFIX + token; + redisTemplate.delete(key); + log.info("Token removed from blacklist: {}", token); + } + + /** + * TTL 계산 (토큰 만료 시간까지 남은 시간) + * @param expirationTime 토큰 만료 시간 + * @return TTL (밀리초) + */ + private long calculateTTL(long expirationTime) { + long currentTime = System.currentTimeMillis(); + long ttl = expirationTime - currentTime; + + // 최소 1분, 최대 24시간으로 제한 + return Math.max(60000, Math.min(ttl, 86400000)); + } +} From 8754d4b792499cbba5393852f8e7ddbc3f0eb798 Mon Sep 17 00:00:00 2001 From: Kim-Yukyung Date: Mon, 16 Jun 2025 22:12:45 +0900 Subject: [PATCH 18/27] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20=EA=B4=80=EB=A0=A8=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sayup/SayUp/service/ChatRoomService.java | 113 +++++++++++++++--- 1 file changed, 94 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/sayup/SayUp/service/ChatRoomService.java b/src/main/java/com/sayup/SayUp/service/ChatRoomService.java index 363fb43..50a6abe 100644 --- a/src/main/java/com/sayup/SayUp/service/ChatRoomService.java +++ b/src/main/java/com/sayup/SayUp/service/ChatRoomService.java @@ -6,15 +6,19 @@ import com.sayup.SayUp.repository.ChatRoomRepository; import com.sayup.SayUp.repository.UserRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.Arrays; import java.util.HashMap; import java.util.Map; import java.util.Optional; +@Slf4j @Service @RequiredArgsConstructor +@Transactional public class ChatRoomService { private final ChatRoomRepository chatRoomRepository; @@ -22,35 +26,106 @@ public class ChatRoomService { private final ObjectMapper objectMapper; /** - * 두 사용자가 참여하는 채팅방이 이미 존재하면 해당 방을 반환 - * 없으면 새 채팅방을 생성하여 반환 - * + * 두 사용자가 참여하는 채팅방 생성 또는 기존 방 반환 + * * @param currentUserId 현재 로그인한 유저의 ID * @param friendUserId 친구로 선택한 유저의 ID * @return 기존 또는 새로 생성된 채팅방 - * @throws Exception JSON 변환 실패 등 예외 발생 시 */ - public ChatRoom createOrEnterRoom(Long currentUserId, Long friendUserId) throws Exception { + public ChatRoom createOrEnterRoom(Long currentUserId, Long friendUserId) { + // 입력 검증 + if (currentUserId == null || friendUserId == null) { + throw new IllegalArgumentException("사용자 ID는 null일 수 없습니다."); + } + + if (currentUserId.equals(friendUserId)) { + throw new IllegalArgumentException("자기 자신과는 채팅방을 만들 수 없습니다."); + } + + log.info("Creating or entering chat room between users: {} and {}", currentUserId, friendUserId); + // 두 사용자 간의 기존 채팅방이 존재하는지 확인 Optional existingRoom = chatRoomRepository.findByUserIds(currentUserId, friendUserId); - if (existingRoom.isPresent()) return existingRoom.get(); + if (existingRoom.isPresent()) { + log.info("Existing chat room found: {}", existingRoom.get().getId()); + return existingRoom.get(); + } + + // 사용자 정보 조회 및 검증 + User currentUser = userRepository.findById(currentUserId) + .orElseThrow(() -> new IllegalArgumentException("현재 사용자를 찾을 수 없습니다. ID: " + currentUserId)); + + User friendUser = userRepository.findById(friendUserId) + .orElseThrow(() -> new IllegalArgumentException("친구 사용자를 찾을 수 없습니다. ID: " + friendUserId)); + + // 사용자 활성 상태 확인 + if (!currentUser.getIsActive() || !friendUser.getIsActive()) { + throw new IllegalArgumentException("비활성 사용자와는 채팅방을 만들 수 없습니다."); + } + + try { + // 친구의 TTS 벡터를 메타데이터에 저장 (암호화 고려 필요) + Map metadataMap = new HashMap<>(); + if (friendUser.getTtsVector() != null && !friendUser.getTtsVector().trim().isEmpty()) { + metadataMap.put("tts_vector_" + friendUserId, friendUser.getTtsVector()); + log.debug("TTS vector stored for user: {}", friendUserId); + } + + String metadataJson = objectMapper.writeValueAsString(metadataMap); + + // 새로운 채팅방 객체 생성 및 저장 + ChatRoom room = ChatRoom.builder() + .participants(Arrays.asList(currentUser, friendUser)) + .metadata(metadataJson) + .build(); + + ChatRoom savedRoom = chatRoomRepository.save(room); + log.info("New chat room created: {} between users: {} and {}", + savedRoom.getId(), currentUserId, friendUserId); + + return savedRoom; - // 사용자 정보 조회 (존재하지 않으면 예외 발생) - User currentUser = userRepository.findById(currentUserId).orElseThrow(); - User friendUser = userRepository.findById(friendUserId).orElseThrow(); + } catch (Exception e) { + log.error("Error creating chat room between users {} and {}: {}", + currentUserId, friendUserId, e.getMessage()); + throw new IllegalStateException("채팅방 생성 중 오류가 발생했습니다.", e); + } + } + + /** + * 사용자가 참여한 채팅방 목록 조회 + */ + @Transactional(readOnly = true) + public java.util.List getUserChatRooms(Long userId) { + if (userId == null) { + throw new IllegalArgumentException("사용자 ID는 null일 수 없습니다."); + } + + log.info("Fetching chat rooms for user: {}", userId); + return chatRoomRepository.findByUserId(userId); + } + + /** + * 채팅방 정보 조회 + */ + @Transactional(readOnly = true) + public ChatRoom getChatRoom(Long roomId, Long userId) { + if (roomId == null || userId == null) { + throw new IllegalArgumentException("채팅방 ID와 사용자 ID는 null일 수 없습니다."); + } - // 친구의 TTS 벡터를 메타데이터에 저장 (key: tts_vector_{friendUserId}) - Map metadataMap = new HashMap<>(); - metadataMap.put("tts_vector_" + friendUserId, friendUser.getTtsVector()); + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다. ID: " + roomId)); - String metadataJson = objectMapper.writeValueAsString(metadataMap); + // 사용자가 해당 채팅방의 참여자인지 확인 + boolean isParticipant = room.getParticipants().stream() + .anyMatch(user -> user.getUserId().equals(userId)); - // 새로운 채팅방 객체 생성 및 저장 - ChatRoom room = ChatRoom.builder() - .participants(Arrays.asList(currentUser, friendUser)) - .metadata(metadataJson) - .build(); + if (!isParticipant) { + log.warn("User {} attempted to access chat room {} without permission", userId, roomId); + throw new IllegalArgumentException("해당 채팅방에 접근할 권한이 없습니다."); + } - return chatRoomRepository.save(room); + return room; } } From fb785ef2dac295a2956da38d165b416550e2ed0d Mon Sep 17 00:00:00 2001 From: Kim-Yukyung Date: Mon, 16 Jun 2025 22:13:24 +0900 Subject: [PATCH 19/27] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20=EA=B4=80=EB=A0=A8=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/sayup/SayUp/service/UserService.java | 67 +++++++++++++++- .../sayup/SayUp/service/UserVoiceService.java | 80 +++++++++++++------ 2 files changed, 118 insertions(+), 29 deletions(-) diff --git a/src/main/java/com/sayup/SayUp/service/UserService.java b/src/main/java/com/sayup/SayUp/service/UserService.java index 40b448b..5056331 100644 --- a/src/main/java/com/sayup/SayUp/service/UserService.java +++ b/src/main/java/com/sayup/SayUp/service/UserService.java @@ -2,29 +2,88 @@ import com.sayup.SayUp.entity.User; import com.sayup.SayUp.repository.UserRepository; -import org.springframework.beans.factory.annotation.Autowired; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.List; +@Slf4j @Service +@RequiredArgsConstructor +@Transactional public class UserService { - @Autowired - private UserRepository userRepository; + private final UserRepository userRepository; + /** + * 이메일로 사용자 ID 조회 + */ + @Transactional(readOnly = true) public Long findUserIdByEmail(String email) { + if (email == null || email.trim().isEmpty()) { + throw new IllegalArgumentException("이메일은 null이거나 비어있을 수 없습니다."); + } + + log.debug("Finding user ID by email: {}", email); + return userRepository.findByEmail(email) .map(User::getUserId) .orElseThrow(() -> new UsernameNotFoundException("해당 이메일로 사용자를 찾을 수 없습니다: " + email)); } + /** + * 이메일 또는 사용자명으로 사용자 ID 조회 + */ + @Transactional(readOnly = true) public Long findUserIdByEmailOrUsername(String emailOrUsername) { + if (emailOrUsername == null || emailOrUsername.trim().isEmpty()) { + throw new IllegalArgumentException("이메일 또는 사용자명은 null이거나 비어있을 수 없습니다."); + } + + log.debug("Finding user ID by email or username: {}", emailOrUsername); + List users = userRepository.findByEmailOrUsername(emailOrUsername, emailOrUsername); if (users.isEmpty()) { throw new UsernameNotFoundException("해당 이메일 또는 사용자명으로 사용자를 찾을 수 없습니다: " + emailOrUsername); } - return users.get(0).getUserId(); + + User user = users.get(0); + if (!user.getIsActive()) { + throw new UsernameNotFoundException("비활성 사용자입니다: " + emailOrUsername); + } + + return user.getUserId(); + } + + /** + * 사용자 정보 조회 + */ + @Transactional(readOnly = true) + public User getUserById(Long userId) { + if (userId == null) { + throw new IllegalArgumentException("사용자 ID는 null일 수 없습니다."); + } + + log.debug("Finding user by ID: {}", userId); + + return userRepository.findById(userId) + .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다. ID: " + userId)); + } + + /** + * 사용자 활성 상태 확인 + */ + @Transactional(readOnly = true) + public boolean isUserActive(Long userId) { + if (userId == null) { + return false; + } + + return userRepository.findById(userId) + .map(User::getIsActive) + .orElse(false); } } diff --git a/src/main/java/com/sayup/SayUp/service/UserVoiceService.java b/src/main/java/com/sayup/SayUp/service/UserVoiceService.java index 1a8d1e2..85da3af 100644 --- a/src/main/java/com/sayup/SayUp/service/UserVoiceService.java +++ b/src/main/java/com/sayup/SayUp/service/UserVoiceService.java @@ -6,12 +6,12 @@ import com.sayup.SayUp.repository.UserVoiceRepository; import com.sayup.SayUp.security.JwtTokenProvider; import lombok.RequiredArgsConstructor; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.FileSystemResource; import org.springframework.http.*; import org.springframework.scheduling.annotation.Async; +import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; @@ -24,10 +24,10 @@ import java.nio.file.Paths; import java.util.concurrent.CompletableFuture; +@Slf4j @Service @RequiredArgsConstructor public class UserVoiceService { - private static final Logger logger = LoggerFactory.getLogger(UserVoiceService.class); private final UserVoiceRepository userVoiceRepository; private final UserRepository userRepository; @@ -42,27 +42,55 @@ public class UserVoiceService { public ResponseEntity uploadFile(String token, MultipartFile file) { try { - if (file.isEmpty()) { - logger.warn("Upload failed: No file attached."); - return ResponseEntity.badRequest().body("File is empty"); + // 입력 검증 + if (token == null || !token.startsWith("Bearer ")) { + log.warn("Invalid authorization header format"); + return ResponseEntity.badRequest().body("Invalid authorization header format"); + } + + if (file == null || file.isEmpty()) { + log.warn("Upload failed: No file attached or file is empty"); + return ResponseEntity.badRequest().body("File is required and cannot be empty"); } // JWT 토큰에서 이메일 추출 - String email = jwtTokenProvider.getUsernameFromToken(token.substring(7)); + String actualToken = token.substring(7); + if (!jwtTokenProvider.validateToken(actualToken)) { + log.warn("Invalid or expired token during file upload"); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid or expired token"); + } + + String email = jwtTokenProvider.getUsernameFromToken(actualToken); + if (email == null || email.trim().isEmpty()) { + log.warn("Invalid email from token during file upload"); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid token content"); + } + User user = userRepository.findByEmail(email) - .orElseThrow(() -> new IllegalArgumentException("User not found with email: " + email)); + .orElseThrow(() -> new UsernameNotFoundException("User not found with email: " + email)); + + // 사용자 활성 상태 확인 + if (!user.getIsActive()) { + log.warn("Inactive user attempted file upload: {}", email); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Account is not active"); + } // 파일 저장 String originalFileName = file.getOriginalFilename(); + if (originalFileName == null || originalFileName.trim().isEmpty()) { + log.warn("Invalid file name provided"); + return ResponseEntity.badRequest().body("Invalid file name"); + } + Path uploadPath = Paths.get(uploadDir); if (!Files.exists(uploadPath)) { Files.createDirectories(uploadPath); - logger.info("Upload directory created at: {}", uploadPath); + log.info("Upload directory created at: {}", uploadPath); } Path destination = Paths.get(uploadDir, originalFileName); file.transferTo(destination.toFile()); - logger.info("File saved at: {}", destination); + log.info("File saved at: {} for user: {}", destination, email); // 기존 UserVoice 확인 UserVoice userVoice = userVoiceRepository.findByUser(user) @@ -74,17 +102,21 @@ public ResponseEntity uploadFile(String token, MultipartFile file) { userVoice.setFilePath(destination.toString()); userVoiceRepository.save(userVoice); - logger.info("File information saved to DB for user: {}", email); + log.info("File information saved to DB for user: {}", email); + // 비동기로 파이썬 서버 호출 processFileAsync(token, destination.toString(), user); return ResponseEntity.ok("File upload successful"); + } catch (UsernameNotFoundException e) { + log.error("User not found during file upload: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body("User not found"); } catch (IOException e) { - logger.error("Error saving file", e); - return ResponseEntity.internalServerError().body("File save failed: " + e.getMessage()); + log.error("Error saving file: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("File save failed: " + e.getMessage()); } catch (Exception e) { - logger.error("Unexpected error occurred", e); - return ResponseEntity.internalServerError().body("Unexpected error: " + e.getMessage()); + log.error("Unexpected error occurred during file upload: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Unexpected error: " + e.getMessage()); } } @@ -94,11 +126,9 @@ public CompletableFuture processFileAsync(String token, String filePath, U try { // 파이썬 서버 호출 String pythonResponse = sendToPythonServer(token, filePath); - - logger.info("Python server response: {}", pythonResponse); - + log.info("Python server response for user {}: {}", user.getEmail(), pythonResponse); } catch (Exception e) { - logger.error("Error processing file asynchronously", e); + log.error("Error processing file asynchronously for user {}: {}", user.getEmail(), e.getMessage()); } }); } @@ -122,15 +152,15 @@ private String sendToPythonServer(String token, String filePath) { ResponseEntity response = restTemplate.postForEntity(pythonEndpoint, requestEntity, String.class); if (response.getStatusCode() == HttpStatus.OK) { - logger.info("Python server response received successfully."); + log.info("Python server response received successfully"); return response.getBody(); } else { - logger.warn("Python server returned non-200 status: {}", response.getStatusCode()); - throw new RuntimeException("Python server error: " + response.getStatusCode()); + log.warn("Python server returned non-200 status: {}", response.getStatusCode()); + throw new IllegalStateException("Python server error: " + response.getStatusCode()); } } catch (Exception e) { - logger.error("Error sending file to Python server", e); - throw new RuntimeException("Error sending file to Python server: " + e.getMessage(), e); + log.error("Error sending file to Python server: {}", e.getMessage()); + throw new IllegalStateException("Error sending file to Python server: " + e.getMessage(), e); } } -} \ No newline at end of file +} From 188536f48609fce312ad86153a86cbb14e38b36a Mon Sep 17 00:00:00 2001 From: Kim-Yukyung Date: Mon, 16 Jun 2025 22:17:54 +0900 Subject: [PATCH 20/27] =?UTF-8?q?=E2=9C=A8=20=ED=99=98=EA=B2=BD=EB=B3=80?= =?UTF-8?q?=EC=88=98=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-dev.yaml | 51 +++++++++++++++++++ src/main/resources/application-prod.yaml | 62 +++++++++++++++++++++++ src/main/resources/application.properties | 44 ---------------- src/main/resources/application.yaml | 62 +++++++++++++++++++++++ 4 files changed, 175 insertions(+), 44 deletions(-) create mode 100644 src/main/resources/application-dev.yaml create mode 100644 src/main/resources/application-prod.yaml delete mode 100644 src/main/resources/application.properties create mode 100644 src/main/resources/application.yaml diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml new file mode 100644 index 0000000..2228374 --- /dev/null +++ b/src/main/resources/application-dev.yaml @@ -0,0 +1,51 @@ +spring: + application: + name: SayUp-Dev + + datasource: + url: jdbc:mysql://${DB_CONNECTION}:3306/${DB_NAME} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + + jpa: + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.MySQL8Dialect + + data: + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT} + +logging: + level: + com.sayup.SayUp: DEBUG + org.springframework.security: DEBUG + org.hibernate.SQL: DEBUG + org.hibernate.type.descriptor.sql.BasicBinder: TRACE + +jwt: + secret: ${JWT_SECRET} + expiration: ${JWT_EXPIRATION} + refresh-expiration: ${JWT_REFRESH_EXPIRATION} + +file: + upload-dir: ${FILE_UPLOAD_DIR} + +openai: + api: + url: https://api.openai.com/v1/chat/completions + key: ${API_KEY} + +python: + server: + url: ${PYTHON_SERVER_URL} + +kakao: + client_id: ${KAKAO} + redirect_uri: ${KAKAO_REDIRECT_URI} diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml new file mode 100644 index 0000000..85ce81f --- /dev/null +++ b/src/main/resources/application-prod.yaml @@ -0,0 +1,62 @@ +# ?? ?? ?? +spring: + application: + name: SayUp-Prod + + datasource: + url: jdbc:mysql://${DB_CONNECTION}/${DB_NAME} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + + jpa: + hibernate: + ddl-auto: validate + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.MySQL8Dialect + + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + + servlet: + multipart: + enabled: true + max-file-size: 10MB + max-request-size: 10MB + +# JWT ?? +jwt: + secret: ${JWT_SECRET} + expiration: ${JWT_EXPIRATION:86400000} + +# ?? ??? ?? +file: + upload-dir: ${FILE_UPLOAD_DIR} + +# OpenAI ?? +openai: + api: + url: https://api.openai.com/v1/chat/completions + key: ${API_KEY} + +# Python ?? ?? +python: + server: + url: ${PYTHON_SERVER_URL} + +# ??? ?? +kakao: + client_id: ${KAKAO} + redirect_uri: ${KAKAO_REDIRECT_URI} + +# ?? ?? +logging: + level: + com.sayup.SayUp: INFO + org.springframework.security: WARN + root: WARN diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 638d322..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1,44 +0,0 @@ -spring.application.name=SayUp - -spring.datasource.url=jdbc:mysql://${DB_CONNECTION}/${DB_NAME} -spring.datasource.username=${DB_USERNAME} -spring.datasource.password=${DB_PASSWORD} -spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver - -spring.jpa.hibernate.ddl-auto=update -spring.jpa.show-sql=true -spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect - -# application.properties -jwt.secret=SnNvbldlYlRva2VuQXV0aGVudGljYXRpb25XaXRoU3ByaW5nQm9vdFRlc3RQcm9qZWN0U2VjcmV0S2V5Cg== -jwt.expiration=86400000 - -spring.main.allow-circular-references=true - -# ??? ?? ?? ?? -file.upload-dir=${FILE_UPLOAD_DIR} - -spring.servlet.multipart.enabled=true -spring.servlet.multipart.max-file-size=10MB -spring.servlet.multipart.max-request-size=10MB - -openai.api.url=https://api.openai.com/v1/chat/completions -openai.api.key=${API_KEY} - -python.server.url=http://127.0.0.1:8000 - -# Swagger UI ?? ?? -springdoc.swagger-ui.path=/swagger-ui.html - -# API ?? ?? -springdoc.api-docs.path=/v3/api-docs - -# Swagger UI ?? ?? -springdoc.swagger-ui.tags-sorter=alpha -springdoc.swagger-ui.operations-sorter=alpha - -# API ?? ?? -springdoc.packages-to-scan=com.sayup.SayUp.controller - -kakao.client_id=${KAKAO} -kakao.redirect_uri=http://localhost:8080/api/auth/kakao/callback diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..afccd76 --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,62 @@ +spring: + application: + name: SayUp + + datasource: + url: jdbc:mysql://${DB_CONNECTION}/${DB_NAME} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + + jpa: + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate.dialect: org.hibernate.dialect.MySQL8Dialect + + main: + allow-circular-references: true + + servlet: + multipart: + enabled: true + max-file-size: 10MB + max-request-size: 10MB + +logging: + level: + com.sayup.SayUp: INFO + org.springframework.security: DEBUG + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n" + +jwt: + secret: ${JWT_SECRET:SnNvbldlYlRva2VuQXV0aGVudGljYXRpb25XaXRoU3ByaW5nQm9vdFRlc3RQcm9qZWN0U2VjcmV0S2V5Cg==} + expiration: ${JWT_EXPIRATION:86400000} + refresh-expiration: ${JWT_REFRESH_EXPIRATION:604800000} + +file: + upload-dir: ${FILE_UPLOAD_DIR} + +openai: + api: + url: https://api.openai.com/v1/chat/completions + key: ${API_KEY} + +python: + server: + url: http://127.0.0.1:8000 + +springdoc: + swagger-ui: + path: /swagger-ui.html + tags-sorter: alpha + operations-sorter: alpha + api-docs: + path: /v3/api-docs + packages-to-scan: com.sayup.SayUp.controller + +kakao: + client_id: ${KAKAO} + redirect_uri: http://localhost:8080/api/auth/kakao/callback From 24f3f57b1e69449dab1fb0e49671b11ace9d54e4 Mon Sep 17 00:00:00 2001 From: Kim-Yukyung Date: Mon, 16 Jun 2025 22:19:25 +0900 Subject: [PATCH 21/27] =?UTF-8?q?=F0=9F=92=A1=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-prod.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index 85ce81f..ec2ca0a 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -1,4 +1,3 @@ -# ?? ?? ?? spring: application: name: SayUp-Prod From 20ae79078925fee7cd7960a07832766288e0c38a Mon Sep 17 00:00:00 2001 From: Kim-Yukyung Date: Mon, 16 Jun 2025 22:21:58 +0900 Subject: [PATCH 22/27] =?UTF-8?q?=F0=9F=94=A7=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 8adc301..7cf5dbf 100644 --- a/build.gradle +++ b/build.gradle @@ -55,7 +55,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-websocket' // Swagger 3 (SpringDoc OpenAPI) 의존성 - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' // ClassGraph 버전 명시 implementation 'io.github.classgraph:classgraph:4.8.149' @@ -74,7 +74,11 @@ dependencies { // AOP implementation 'org.springframework.boot:spring-boot-starter-aop' - implementation 'org.springframework.boot:spring-boot-starter-webflux' + // Redis 의존성 추가 + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // Actuator + implementation 'org.springframework.boot:spring-boot-starter-actuator' } tasks.named('test') { From ef5efc5be9267787db3300bdfddd785abef3e0f6 Mon Sep 17 00:00:00 2001 From: Kim-Yukyung Date: Mon, 16 Jun 2025 22:23:41 +0900 Subject: [PATCH 23/27] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=ED=99=98=EA=B2=BD?= =?UTF-8?q?=EB=B3=80=EC=88=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/Dockerfile b/Dockerfile index 52c1754..15df42a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,18 +2,6 @@ FROM openjdk:17-jdk-slim WORKDIR /app -# JAR 파일 복사 (빌드된 JAR 파일 이름에 맞춰 수정 필요) COPY build/libs/SayUp-0.0.1-SNAPSHOT.jar app.jar -ARG DB_PASSWORD -ARG API_KEY - -# 환경변수 설정 -ENV DB_CONNECTION=mysql -ENV DB_NAME=sayup_db -ENV DB_USERNAME=root -ENV DB_PASSWORD=$DB_PASSWORD -ENV API_KEY=$API_KEY -ENV FILE_UPLOAD_DIR=/file/temp - -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "app.jar"] From 581659d54d4a993ce25d080ff76da0413e1dccaa Mon Sep 17 00:00:00 2001 From: Kim-Yukyung Date: Mon, 16 Jun 2025 22:40:11 +0900 Subject: [PATCH 24/27] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=EB=8F=84=EC=BB=A4?= =?UTF-8?q?=20=EA=B4=80=EB=A0=A8=20=ED=8C=8C=EC=9D=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 45 +++++++++++++++++++ .env.prod | 31 ++++++++++++- Dockerfile | 29 ++++++++++++- docker-compose.yml | 105 +++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 198 insertions(+), 12 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fe42d4b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,45 @@ +# Git +.git +.gitignore +.gitattributes + +# IDE +.idea +.vscode +*.iml + +# Build outputs +build/ +.gradle/ +gradle/ +gradlew +gradlew.bat + +# Documentation +README.md +ENVIRONMENT_VARIABLES.md +*.md + +# Environment files +.env* +!.env.example + +# Logs +*.log +logs/ + +# OS +.DS_Store +Thumbs.db + +# Docker +Dockerfile +docker-compose.yml +.dockerignore + +# Test files +src/test/ + +# Temporary files +tmp/ +temp/ diff --git a/.env.prod b/.env.prod index 9ab5a62..9a66594 100644 --- a/.env.prod +++ b/.env.prod @@ -1,3 +1,32 @@ +# .env.prod + +# 데이터베이스 환경변수 +DB_USERNAME=root DB_PASSWORD=0000 +DB_CONNECTION=mysql +DB_NAME=sayup + +# Redis 환경변수 +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_PASSWORD=redis + +# JWT 관련 +JWT_SECRET=your-jwt-secret +JWT_EXPIRATION=86400000 +JWT_REFRESH_EXPIRATION=604800000 + +# 외부 API 키 CHATGPT_API_KEY=temp -DOCKERHUB_USERNAME=yukyung0 \ No newline at end of file +KAKAO_CLIENT_ID=kakao-temp-client-id +KAKAO_REDIRECT_URI=http://localhost:8080/api/auth/kakao/callback + +# 파일 업로드 경로 +FILE_UPLOAD_DIR=/app/tmp/file/userVoice + +# Python 서버 URL +PYTHON_SERVER_URL=http://127.0.0.1:8000 + +# Docker 이미지 관련 +DOCKERHUB_USERNAME=user +IMAGE_TAG=latest diff --git a/Dockerfile b/Dockerfile index 15df42a..2894142 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,32 @@ +FROM gradle:8.5-jdk17 AS build +WORKDIR /app +COPY . . +RUN gradle build -x test + FROM openjdk:17-jdk-slim +# 보안을 위한 비루트 사용자 생성 +RUN groupadd -r sayup && useradd -r -g sayup sayup + +# 필수 패키지 설치 +RUN apt-get update && apt-get install -y \ + curl \ + && rm -rf /var/lib/apt/lists/* \ + WORKDIR /app +COPY --from=build /app/build/libs/*.jar app.jar + +# 필요한 디렉토리 생성 및 권한 설정 +RUN mkdir -p /app/logs /app/tmp/file/userVoice && \ + chown -R sayup:sayup /app + +USER sayup + +ENV JAVA_OPTS="-Xms512m -Xmx1024m -XX:+UseG1GC -XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0" + +HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \ + CMD curl -f http://localhost:8080/actuator/health || exit 1 -COPY build/libs/SayUp-0.0.1-SNAPSHOT.jar app.jar +EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] diff --git a/docker-compose.yml b/docker-compose.yml index cc3621f..ab368f0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,41 +4,128 @@ services: mysql: image: mysql:8.0 container_name: sayup_mysql - restart: always + restart: unless-stopped environment: MYSQL_ROOT_PASSWORD: "${DB_PASSWORD}" MYSQL_DATABASE: "sayup_db" + MYSQL_USER: "${DB_USERNAME}" + MYSQL_PASSWORD: "${DB_PASSWORD}" + TZ: "Asia/Seoul" ports: - "3306:3306" volumes: - mysql_data:/var/lib/mysql + - mysql_backup:/backup + - ./mysql/init:/docker-entrypoint-initdb.d networks: - app_network + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + timeout: 20s + retries: 10 + deploy: + resources: + limits: + memory: 1G + reservations: + memory: 512M + + redis: + image: redis:7-alpine + container_name: sayup_redis + restart: unless-stopped + ports: + - "6379:6379" + volumes: + - redis_data:/data + - redis_backup:/backup + networks: + - app_network + command: redis-server --appendonly yes --requirepass "${REDIS_PASSWORD}" + healthcheck: + test: ["CMD", "redis-cli", "--raw", "incr", "ping"] + timeout: 3s + retries: 5 + deploy: + resources: + limits: + memory: 256M + reservations: + memory: 128M app: - build: . - image: ${DOCKERHUB_USERNAME}/sayup:latest - container_name: sayup - restart: always + build: + context: . + dockerfile: Dockerfile + image: ${DOCKERHUB_USERNAME:-sayup}/sayup:${IMAGE_TAG:-latest} + container_name: sayup_app + restart: unless-stopped depends_on: - - mysql + mysql: + condition: service_healthy + redis: + condition: service_healthy env_file: - .env.prod environment: + SPRING_PROFILES_ACTIVE: "prod" DB_CONNECTION: "mysql" + DB_HOST: "mysql" DB_NAME: "sayup_db" - DB_USERNAME: "root" + DB_USERNAME: "${DB_USERNAME}" DB_PASSWORD: "${DB_PASSWORD}" - # 경로 변경 필요 - FILE_UPLOAD_DIR: "tmp/file/userVoice" + REDIS_HOST: "redis" + REDIS_PORT: "6379" + REDIS_PASSWORD: "${REDIS_PASSWORD}" + FILE_UPLOAD_DIR: "/app/tmp/file/userVoice" API_KEY: "${CHATGPT_API_KEY}" + JWT_SECRET: "${JWT_SECRET}" + KAKAO_CLIENT_ID: "${KAKAO_CLIENT_ID}" + TZ: "Asia/Seoul" ports: - "8080:8080" + volumes: + - app_logs:/app/logs + - app_files:/app/tmp/file/userVoice networks: - app_network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + deploy: + resources: + limits: + memory: 2G + cpus: '2.0' + reservations: + memory: 1G + cpus: '1.0' + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" networks: app_network: + driver: bridge + ipam: + config: + - subnet: 172.20.0.0/16 volumes: mysql_data: + driver: local + mysql_backup: + driver: local + redis_data: + driver: local + redis_backup: + driver: local + app_logs: + driver: local + app_files: + driver: local From f8c304f1a69fcd9341ef3a0f408cd075328b3d00 Mon Sep 17 00:00:00 2001 From: Kim-Yukyung Date: Mon, 16 Jun 2025 22:45:40 +0900 Subject: [PATCH 25/27] =?UTF-8?q?=E2=9C=85=20Test=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sayup/SayUp/SayUpApplicationTests.java | 2 + .../sayup/SayUp/service/AuthServiceTest.java | 190 ++++++++++++++++++ src/test/resources/application-test.yaml | 46 +++++ 3 files changed, 238 insertions(+) create mode 100644 src/test/java/com/sayup/SayUp/service/AuthServiceTest.java create mode 100644 src/test/resources/application-test.yaml diff --git a/src/test/java/com/sayup/SayUp/SayUpApplicationTests.java b/src/test/java/com/sayup/SayUp/SayUpApplicationTests.java index 5fd9b57..11e91bf 100644 --- a/src/test/java/com/sayup/SayUp/SayUpApplicationTests.java +++ b/src/test/java/com/sayup/SayUp/SayUpApplicationTests.java @@ -2,8 +2,10 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; @SpringBootTest +@ActiveProfiles("test") class SayUpApplicationTests { @Test diff --git a/src/test/java/com/sayup/SayUp/service/AuthServiceTest.java b/src/test/java/com/sayup/SayUp/service/AuthServiceTest.java new file mode 100644 index 0000000..5f4945c --- /dev/null +++ b/src/test/java/com/sayup/SayUp/service/AuthServiceTest.java @@ -0,0 +1,190 @@ +package com.sayup.SayUp.service; + +import com.sayup.SayUp.dto.AuthRequestDTO; +import com.sayup.SayUp.dto.AuthResponseDTO; +import com.sayup.SayUp.entity.User; +import com.sayup.SayUp.kakao.service.KakaoService; +import com.sayup.SayUp.repository.UserRepository; +import com.sayup.SayUp.security.JwtTokenProvider; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class AuthServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @Mock + private JwtTokenProvider jwtTokenProvider; + + @Mock + private TokenBlacklistService tokenBlacklistService; + + @Mock + private KakaoService kakaoService; + + @InjectMocks + private AuthService authService; + + private AuthRequestDTO authRequestDTO; + private User user; + private AuthResponseDTO authResponseDTO; + + @BeforeEach + void setUp() { + authRequestDTO = new AuthRequestDTO(); + authRequestDTO.setEmail("test@example.com"); + authRequestDTO.setPassword("password123"); + + user = new User(); + user.setUserId(1L); + user.setEmail("test@example.com"); + user.setPassword("encodedPassword"); + user.setUsername("test"); + user.setRole("USER"); + + authResponseDTO = AuthResponseDTO.builder() + .accessToken("accessToken") + .refreshToken("refreshToken") + .tokenType("Bearer") + .expiresIn(3600L) + .build(); + } + + @Test + void register_WithNewEmail_ShouldRegisterSuccessfully() { + // Given + when(userRepository.existsByEmail(anyString())).thenReturn(false); + when(passwordEncoder.encode(anyString())).thenReturn("encodedPassword"); + when(userRepository.save(any(User.class))).thenReturn(user); + when(userRepository.findByEmail(anyString())).thenReturn(Optional.of(user)); + when(passwordEncoder.matches(anyString(), anyString())).thenReturn(true); + when(jwtTokenProvider.createToken(any())).thenReturn("accessToken"); + when(jwtTokenProvider.createRefreshToken(any())).thenReturn("refreshToken"); + when(jwtTokenProvider.getExpirationTime()).thenReturn(3600L); + + // When + AuthResponseDTO result = authService.register(authRequestDTO); + + // Then + assertNotNull(result); + assertEquals("accessToken", result.getAccessToken()); + assertEquals("refreshToken", result.getRefreshToken()); + verify(userRepository).existsByEmail(authRequestDTO.getEmail()); + verify(passwordEncoder).encode(authRequestDTO.getPassword()); + verify(userRepository).save(any(User.class)); + } + + @Test + void register_WithExistingEmail_ShouldThrowException() { + // Given + when(userRepository.existsByEmail(anyString())).thenReturn(true); + + // When & Then + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> authService.register(authRequestDTO) + ); + + assertEquals("이미 사용 중인 이메일입니다.", exception.getMessage()); + verify(userRepository).existsByEmail(authRequestDTO.getEmail()); + verify(userRepository, never()).save(any(User.class)); + } + + @Test + void login_WithValidCredentials_ShouldReturnAuthResponse() { + // Given + when(userRepository.findByEmail(anyString())).thenReturn(Optional.of(user)); + when(passwordEncoder.matches(anyString(), anyString())).thenReturn(true); + when(jwtTokenProvider.createToken(any())).thenReturn("accessToken"); + when(jwtTokenProvider.createRefreshToken(any())).thenReturn("refreshToken"); + when(jwtTokenProvider.getExpirationTime()).thenReturn(3600L); + + // When + AuthResponseDTO result = authService.login(authRequestDTO); + + // Then + assertNotNull(result); + assertEquals("accessToken", result.getAccessToken()); + assertEquals("refreshToken", result.getRefreshToken()); + verify(userRepository).findByEmail(authRequestDTO.getEmail()); + verify(passwordEncoder).matches(authRequestDTO.getPassword(), user.getPassword()); + } + + @Test + void login_WithInvalidEmail_ShouldThrowBadCredentialsException() { + // Given + when(userRepository.findByEmail(anyString())).thenReturn(Optional.empty()); + + // When & Then + BadCredentialsException exception = assertThrows( + BadCredentialsException.class, + () -> authService.login(authRequestDTO) + ); + + assertEquals("이메일 또는 비밀번호가 올바르지 않습니다.", exception.getMessage()); + verify(userRepository).findByEmail(authRequestDTO.getEmail()); + verify(passwordEncoder, never()).matches(anyString(), anyString()); + } + + @Test + void login_WithInvalidPassword_ShouldThrowBadCredentialsException() { + // Given + when(userRepository.findByEmail(anyString())).thenReturn(Optional.of(user)); + when(passwordEncoder.matches(anyString(), anyString())).thenReturn(false); + + // When & Then + BadCredentialsException exception = assertThrows( + BadCredentialsException.class, + () -> authService.login(authRequestDTO) + ); + + assertEquals("이메일 또는 비밀번호가 올바르지 않습니다.", exception.getMessage()); + verify(userRepository).findByEmail(authRequestDTO.getEmail()); + verify(passwordEncoder).matches(authRequestDTO.getPassword(), user.getPassword()); + } + + @Test + void isTokenBlacklisted_WithValidToken_ShouldReturnFalse() { + // Given + String token = "valid.token.here"; + when(tokenBlacklistService.isBlacklisted(token)).thenReturn(false); + + // When + boolean result = authService.isTokenBlacklisted(token); + + // Then + assertFalse(result); + verify(tokenBlacklistService).isBlacklisted(token); + } + + @Test + void isTokenBlacklisted_WithEmptyToken_ShouldReturnFalse() { + // Given + String token = ""; + + // When + boolean result = authService.isTokenBlacklisted(token); + + // Then + assertFalse(result); + verify(tokenBlacklistService, never()).isBlacklisted(anyString()); + } +} \ No newline at end of file diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml new file mode 100644 index 0000000..2428b6a --- /dev/null +++ b/src/test/resources/application-test.yaml @@ -0,0 +1,46 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + username: sa + password: + driver-class-name: org.h2.Driver + + jpa: + hibernate: + ddl-auto: create-drop + show-sql: true + properties: + hibernate.dialect: org.hibernate.dialect.H2Dialect + hibernate.format_sql: true + + h2: + console: + enabled: true + +logging: + level: + com.sayup.SayUp: DEBUG + org.springframework.security: DEBUG + org.hibernate.SQL: DEBUG + org.hibernate.type.descriptor.sql.BasicBinder: TRACE + +jwt: + secret: testSecretKeyForTestingPurposesOnlyDoNotUseInProduction + expiration: 86400000 + refresh-expiration: 604800000 + +file: + upload-dir: ./test-uploads + +openai: + api: + url: https://api.openai.com/v1/chat/completions + key: test-key + +python: + server: + url: http://127.0.0.1:8000 + +kakao: + client_id: test-kakao-client-id + redirect_uri: http://localhost:8080/api/auth/kakao/callback From e8cf114d3209400f764e2a32687888a5a6c718a0 Mon Sep 17 00:00:00 2001 From: Kim-Yukyung Date: Mon, 16 Jun 2025 22:46:52 +0900 Subject: [PATCH 26/27] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20@Builder.Default=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=A0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + src/main/java/com/sayup/SayUp/entity/ChatRoom.java | 2 ++ src/main/java/com/sayup/SayUp/entity/User.java | 1 + 3 files changed, 4 insertions(+) diff --git a/build.gradle b/build.gradle index 7cf5dbf..cb24d18 100644 --- a/build.gradle +++ b/build.gradle @@ -31,6 +31,7 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-testcontainers' testImplementation 'org.testcontainers:junit-jupiter' testImplementation 'org.testcontainers:mysql:1.20.0' + testImplementation 'com.h2database:h2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' implementation 'io.jsonwebtoken:jjwt-api:0.11.5' diff --git a/src/main/java/com/sayup/SayUp/entity/ChatRoom.java b/src/main/java/com/sayup/SayUp/entity/ChatRoom.java index d31f31b..bd060d3 100644 --- a/src/main/java/com/sayup/SayUp/entity/ChatRoom.java +++ b/src/main/java/com/sayup/SayUp/entity/ChatRoom.java @@ -26,10 +26,12 @@ public class ChatRoom { joinColumns = @JoinColumn(name = "chatroom_id"), inverseJoinColumns = @JoinColumn(name = "user_id") ) + @Builder.Default private List participants = new ArrayList<>(); @Lob private String metadata; // TTS 벡터 등 JSON 문자열로 저장 + @Builder.Default private LocalDateTime createdAt = LocalDateTime.now(); } diff --git a/src/main/java/com/sayup/SayUp/entity/User.java b/src/main/java/com/sayup/SayUp/entity/User.java index eee5f10..31d405a 100644 --- a/src/main/java/com/sayup/SayUp/entity/User.java +++ b/src/main/java/com/sayup/SayUp/entity/User.java @@ -42,6 +42,7 @@ public class User { private LocalDateTime lastLoginAt; @Column + @Builder.Default private Boolean isActive = true; @PrePersist From 6cfece3098e95a0a3de8a6b37f7e7a5adbd879e3 Mon Sep 17 00:00:00 2001 From: Kim-Yukyung Date: Mon, 16 Jun 2025 22:53:21 +0900 Subject: [PATCH 27/27] =?UTF-8?q?=F0=9F=93=9D=20=ED=99=98=EA=B2=BD?= =?UTF-8?q?=EB=B3=80=EC=88=98=20=EC=98=88=EC=8B=9C=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 1 - .env.example | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 .env.example diff --git a/.dockerignore b/.dockerignore index fe42d4b..f6e55dc 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,7 +17,6 @@ gradlew.bat # Documentation README.md -ENVIRONMENT_VARIABLES.md *.md # Environment files diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ad325e7 --- /dev/null +++ b/.env.example @@ -0,0 +1,32 @@ +SPRING_PROFILES_ACTIVE=dev + +# MySQL +DB_CONNECTION=localhost +DB_NAME=sayup_dev +DB_USERNAME=root +DB_PASSWORD=dev_password + +# JWT +JWT_SECRET=dev_jwt_secret_key_for_development +JWT_EXPIRATION=3600000 # 1시간 +JWT_REFRESH_EXPIRATION=86400000 # 1일 + +# Redis +REDIS_HOST=localhost +REDIS_PORT=6379 + +# 파일 업로드 +FILE_UPLOAD_DIR=./uploads/dev + +# 카카오 설정 +KAKAO=your_kakao_dev_key +KAKAO_REDIRECT_URI=http://localhost:8080/api/auth/kakao/callback + +# Python 서버 +PYTHON_SERVER_URL=http://127.0.0.1:8000 + +# 프론트엔드 URL +FRONTEND_URL=http://localhost:3000 + +# OpenAI API 키 +API_KEY=sk-your_openai_api_key_here