diff --git a/README.md b/README.md index e19f8e392..7c7fcc426 100644 --- a/README.md +++ b/README.md @@ -1 +1,391 @@ # SPRING PLUS + + + + + +### 1-1. 문제 인식 및 정의 +- /todos API를 호출하여 할 일을 저장하려고 할 때 다음과 같은 에러가 발생합니다 +``` +jakarta.servlet.ServletException: Request processing failed: +org.springframework.orm.jpa.JpaSystemException: +could not execute statement [Connection is read-only. +Queries leading to data modification are not allowed] +``` +- 상단에 @Transactional(readOnly = true)어노테이션으로 인하여 DB 연결이 read-only 상태이기 때문에 INSERT, UPDATE와 같은 쓰기 작업이 차단되어 발생한 오류입니다. +- 해당 API는 데이터를 저장하는 쓰기 작업이므로 트랜잭션이 read-write 모드로 설정되어 있어야 합니다. + + +### 1-1. 해결 방안 +- @Transactional을 서비스 메서드에 올바르게 적용 + - saveTodo() 같이 수정이 필요한 메서드에 @Transactional을 추가하여 쓰기 가능한 트랜잭션을 활성화 + + +### 1-1. 해결 완료 +``` +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TodoService { + private final TodoRepository todoRepository; + private final WeatherClient weatherClient; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + + @Transactional + public TodoSaveResponse saveTodo(AuthUser authUser, TodoSaveRequest todoSaveRequest) { + User user = User.fromAuthUser(authUser); + ... + Todo savedTodo = todoRepository.save(newTodo); + ... + } +} +``` +- saveTodo()가 실행될 때 Spring이 트랜잭션을 열고, 쓰기 작업도 허용되기 때문에 정상적으로 할 일이 저장됩니다. + +
+
+ + +### 1-2. 문제 인식 및 정의 +- 현재 서비스에서 JWT를 발급할 , 사용자 식별 정보로 userId, email, userRole만 포함하고 있으며 프론트엔드 화면에서 nickname을 함께 표시하고자 하는 요구사항이 생겼습니다. +- 기존 JWT에는 nickname 정보가 포함되어 있지 않기 때문에 클라이언트는 서버에서 nickname을 다시 조회하거나 요청을 추가로 보내야 하는 불편이 있습니다. + +### 1-2. 해결 방안 +1. User 엔티티 및 요청 객체 수정 + - User 테이블에 nickname 컬럼을 추가 + - 닉네임은 중복 가능하고 별도의 제약은 두지 않음 + - 회원가입 및 응답 객체에 nickname 추가 +2. JWT 토큰 생성 시 nickname 추가 + - JwtUtil.createToken() 메서드의 파라미터에 nickname 추가 + - .claim("nickname", nickname) 으로 JWT에 nickname 추가 +3. JwtFilter에서 nickname 추출 +4. AuthUser 클래스 수정 + - nickname 컬럼을 추가 +5. 각종 응답값에 nickname 추가 + +### 1-2. 해결 완료 +- User.nickname 일관되게 반영 +- JWT 생성시 nickname 추가 + +
+
+ +### 1-3. 문제 인식 및 정의 +- 기존의 할 일 목록 조회 API (/todos)에 기능추가 + - 날씨 조건으로 검색 + - 수정일 기간 검색 + +### 1-3. 해결 방안 +- @GetMapping 이므로 날씨, 시작일, 종료일 모두 바디가 아닌 @RequestParam 값으로 각각 요청 +- QueryDSL 사용하여 추후에 유지보수하기 용이함 + +
+
+ +### 1-4. 문제 인식 및 정의 +- 존재하지 않는 할일인 경우 InvalidRequestException이 발생하도록 처리하고 있습니다.
+하지만 컨트롤러 테스트 메서드 todo_단건_조회_시_todo가_존재하지_않아_예외가_발생한다()는 예외가 발생하였음에도 불구하고 응답 코드로 HttpStatus.OK(200) 되어있습니다. + +### 1-4. 해결 방안 +``` +// 변경 전 +.andExpect(status().isOk()) +.andExpect(jsonPath("$.status").value(HttpStatus.OK.name())) +.andExpect(jsonPath("$.code").value(HttpStatus.OK.value())) +//변경 후 +.andExpect(status().isBadRequest()) +.andExpect(jsonPath("$.status").value(HttpStatus.BAD_REQUEST.name())) +.andExpect(jsonPath("$.code").value(HttpStatus.BAD_REQUEST.value())) + ``` +- InvalidRequestException 처리에 따라 반환되는 HTTP 상태 코드 400 (BAD_REQUEST) 와 일치하도록 수정 + +### 1-5. 문제 인식 및 정의 +- 현재 AOP 클래스인 AdminAccessLoggingAspect는 @After 어노테이션으로 UserController의 getUser 메소드 실행 시점에만 동작하도록 설정되어 있습니다.
+ 하지만 여기서의 요구하는 의도는 changeUserRole() 메소드 실행 시점에서 관리자가 접근로그를 남기려고 합니다.
+ AOP 포인트컷이 잘못 설정되어 있어 원하는 시점에 로그가 기록되지 않는 문제가 있었습니다. + +### 1-5. 해결 방안 +- AOP 포인트컷 수정 +``` +// 변경 전 +@After("execution(* org.example.expert.domain.user.controller.UserController.getUser(..))") +// 변경 후 +@Before("execution(* org.example.expert.domain.user.controller.UserController.getUser(..))") +``` + +
+
+ +### 2-6. 문제 인식 및 정의 +- 할 일을 작성하여 저장시 유저가 자동적으로 담당자로 등로되어야 합니다. 하지만 Manager 엔티티가 Todo와 연관 관계를 맺고 있음에도 불구하고 Todo 저장 시 Manager는 저장되지 않아 문제가 발생했습니다. + +### 2-6. 해결 방안 +- CascadeType.PERSIST 적용 + - @OneToMany(mappedBy = "todo", cascade = CascadeType.PERSIST)를 통해 Todo 저장 시 함께 등록된 Manager 엔티티도 함께 저장 + +### 2-6. 해결 완료 +``` +@OneToMany(mappedBy = "todo", cascade = CascadeType.PERSIST) +private List managers = new ArrayList<>(); +``` + +
+
+ +### 2-7. 문제 인식 및 정의 +- N+1 문제 + - CommentController.getComments() API 호출 시 각 Comment 마다 User를 별도 쿼리로 조회하여 N+1 문제 발생 + +### 2-7 해결 방안 +``` +JOIN FETCH : 연관 객체도 한 번의 쿼리로 즉시 로딩 + - 단점 : 페이징이 불가능 한 경우도 있음 +EntityGraph : 간결한 문법으로 fetch join 처리 + - 단점 : 복잡한 쿼리에서는 사용이 불가능 +``` + +### 2-7. 해결 완료 +``` + @Query("SELECT c FROM Comment c JOIN FETCH c.user WHERE c.todo.id = :todoId") + List findByTodoIdWithUser(@Param("todoId") Long todoId); +``` +- JOIN FETCH 를 사용하여 Comment 와 연관된 User 를 한 번의 쿼리로 함께 조회 + +
+
+ +### 2-8. 문제 인식 및 정의 +- TodoService.getTodo 메소드 내 todoRepository.findByIdWithUser(todoId) 호출 시 기존 JPQL로 작성된 쿼리가 N+1 문제를 발생시킬 수 있습니다. + +### 2-8 해결 방안 +- QueryDSL 의존성 추가 +``` + implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:5.1.0:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" +``` +- JPAQueryFactory 빈을 등록하여 QueryDSL 쿼리를 사용할 수 있도록 설정 +``` +@Configuration +public class QueryDslConfig { + + private final EntityManager entityManager; + + public QueryDslConfig(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} +``` +- QueryDSL을 사용하여 구현할 메소드 +``` +public interface TodoCustomRepository { +Optional findByIdWithUser(Long todoId); +} +``` +- TodoCustomRepository 인터페이스를 구현 +- JPAQueryFactory를 사용하여 Todo와 User를 fetchJoin()으로 함께 조회 +``` +public class TodoCustomRepositoryImpl implements TodoCustomRepository { + private final JPAQueryFactory jpaQueryFactory; + public TodoCustomRepositoryImpl(JPAQueryFactory jpaQueryFactory) { + this.jpaQueryFactory = jpaQueryFactory; + } + + @Override + public Optional findByIdWithUser(Long todoId) { + // Q클래스 인스턴스 생성 + // todo와 user 필드 접근 ? + QTodo todo = QTodo.todo; + QUser user = QUser.user; + + // QueryDSL 쿼리 작성: todo와 user를 fetchJoin하여 함께 조회 + return Optional.ofNullable(jpaQueryFactory + .selectFrom(todo) + .leftJoin(todo.user, user) + .fetchJoin() + .where(todo.id.eq(todoId)) + .fetchOne()); + } +} +``` +- JpaRepository를 상속받는 TodoRepository 인터페이스에 TodoCustomRepository 인터페이스를 추가로 상속 + +``` +public interface TodoRepository extends JpaRepository, TodoCustomRepository { +``` +### 2-8. 해결 완료 +- Todo를 조회할 때 연관된 User 정보도 fetchJoin을 통해 한번의 쿼리로 가져오게되어 N+1 문제를 해결 + +
+
+ +### 2-9. 문제 인식 및 정의 +- Filter와 Argument Resolver를 사용하여 접근 권한 및 사용자 권한 관리를 하고 있습니다. +- Spring Security로 전환하고, 기존의 접근 권한 및 사용자 권한 기능은 그대로 유지하되 권한 관리는 Spring Security의 기능을 활용하는 것 +- 기존의 토큰 기반 인증 방식(JWT)은 그대로 유지 + +### 2-9. 해결 방안 +- Spring Security 의존성 추가 +``` + implementation 'org.springframework.boot:spring-boot-starter-security' +``` +- SecurityConfig 생성 + - CSRF 비활성화 + - BasicAuthenticationFilter 비활성화 + - UsernamePasswordAuthenticationFilter, DefaultLoginPageGeneratingFilter 비활성화 + - 세션 관리 정책: STATELESS (서버에 세션 저장 안함) + - 인가 규칙 설정 + +``` +@Configuration +@EnableWebSecurity //시큐리티 활성화 +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtFilter jwtFilter; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + // CSRF 보호 비활성화 (JWT 토큰 기반 인증 사용) + .csrf(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) // BasicAuthenticationFilter 비활성화 + .formLogin(AbstractHttpConfigurer::disable) // UsernamePasswordAuthenticationFilter, DefaultLoginPageGeneratingFilter 비활성화 + // 세션 관리 정책: STATELESS (서버에 세션 저장 안함) + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + // 접근 권한 설정 + .authorizeHttpRequests(authorize -> authorize + //허용 경로지정 + // "/auth"로 시작하는 모든 요청은 인증 없이 허용 (회원가입, 로그인) + .requestMatchers("/auth/**").permitAll() + // "/admin"으로 시작하는 요청은 'ADMIN' 역할을 가진 사용자만 허용 + .requestMatchers("/admin/**").hasRole("ADMIN") + // 나머지는 인증 필요 + .anyRequest().authenticated() + ).addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } +} +``` + +- JwtFilter를 OncePerRequestFilter로 변경 + - 기존 Filter 인터페이스를 상속받던 JwtFilter를 Spring Security가 제공하는 OncePerRequestFilter로 변경 + * HTTP 요청당 한 번만 필터를 실행하도록 보장하고 Spring Security의 필터 체인에 통합하기 좋다고 합니다. + * -> FilterConfig 제거 +``` +@Slf4j +@RequiredArgsConstructor +@Component // Spring Bean으로 등록 +public class JwtFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String bearerJwt = request.getHeader("Authorization"); + + if (bearerJwt == null || !bearerJwt.startsWith("Bearer ")) { + response.sendError(HttpServletResponse.SC_BAD_REQUEST, "JWT 토큰이 필요합니다."); + return; + } + + try { + String jwt = bearerJwt.substring(7); // "Bearer " 제거 + Claims claims = jwtUtil.extractClaims(jwt); + + if (claims == null) { + response.sendError(HttpServletResponse.SC_BAD_REQUEST, "잘못된 JWT 토큰입니다."); + return; + } + + // 클레임에서 사용자 정보 추출 + Long id = Long.parseLong(claims.getSubject()); + String email = claims.get("email", String.class); + String userRoleString = claims.get("userRole", String.class); + String nickname = claims.get("nickname", String.class); + UserRole userRole = UserRole.valueOf(userRoleString); + + // AuthUser 객체 생성 + AuthUser authUser = new AuthUser(id, email, userRole, nickname); + + // Spring Security의 Authentication 객체 생성 + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + authUser, null, authUser.getAuthorities()); + + // SecurityContextHolder에 Authentication 객체를 설정 + SecurityContextHolder.getContext().setAuthentication(authentication); + + ... + } +} +``` +- AuthUser에 UserDetails 인터페이스 구현 + - AuthUser 클래스가 Spring Security에서 사용자 상세 정보를 가져올 수 있게 구현 + - +``` +@Getter +public class AuthUser implements UserDetails { + + ... + + // 권환 반환 + @Override + public Collection getAuthorities() { + return Collections.singleton(userRole.getGrantedAuthority()); + } + + @Override + public String getPassword() { + return null; + } + + @Override + public String getUsername() { + return email; + } +} +``` + +- @AuthenticationPrincipal 어노테이션 활용 + - 기존 @Auth 커스텀 Argument Resolver 대신해서 Spring Security에서 제공하는 @AuthenticationPrincipal 어노테이션을 사용해서 인증된 값을 주입받을 수 있다 + - -> WebConfig, AuthUserArgumentResolver 제거 + + +
+
+ + +### 3-10. 문제 인식 및 정의 +- 검색 api 작성 +- 검색 조건 + - 검색 키워드로 일정의 제목을 검색 + - 제목은 부분적으로 일치해도 검색이 가능 + - 일정의 생성일 범위로 검색 + - 일정을 생성일 최신순으로 정렬해주세요. + - 담당자의 닉네임으로도 검색이 가능 + - 닉네임은 부분적으로 일치해도 검색이 가능 +- 다음의 내용을 포함해서 검색 결과를 반환 + - 일정에 대한 모든 정보가 아닌, 제목만 반환 + - 해당 일정의 담당자 수를 반환 + - 해당 일정의 총 댓글 개수를 반환 +- 검색 결과는 페이징 처리되어 반환 + +### 3-10. 해결 방안 +- SearchTodoConditionRequest + - keyword, nickname, startDate, endDate +- BooleanExpression 사용 + - QueryDSL에서 WHERE 조건을 표현하는 객체 + - .where() 절에 조건을 걸고 싶을 때 사용 가능 + diff --git a/build.gradle b/build.gradle index a7fd3e706..f0a2f02cc 100644 --- a/build.gradle +++ b/build.gradle @@ -41,6 +41,15 @@ dependencies { compileOnly group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5' runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5' runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5' + + // QueryDsl + implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:5.1.0:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" + + // Spring Security + implementation 'org.springframework.boot:spring-boot-starter-security' } tasks.named('test') { diff --git a/src/main/java/org/example/expert/aop/AdminAccessLoggingAspect.java b/src/main/java/org/example/expert/aop/AdminAccessLoggingAspect.java index c90e8c792..b2cc2fddb 100644 --- a/src/main/java/org/example/expert/aop/AdminAccessLoggingAspect.java +++ b/src/main/java/org/example/expert/aop/AdminAccessLoggingAspect.java @@ -4,8 +4,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.JoinPoint; -import org.aspectj.lang.annotation.After; import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; import org.springframework.stereotype.Component; import java.time.LocalDateTime; @@ -18,7 +18,7 @@ public class AdminAccessLoggingAspect { private final HttpServletRequest request; - @After("execution(* org.example.expert.domain.user.controller.UserController.getUser(..))") + @Before("execution(* org.example.expert.domain.user.controller.UserController.getUser(..))") public void logAfterChangeUserRole(JoinPoint joinPoint) { String userId = String.valueOf(request.getAttribute("userId")); String requestUrl = request.getRequestURI(); diff --git a/src/main/java/org/example/expert/config/AuthUserArgumentResolver.java b/src/main/java/org/example/expert/config/AuthUserArgumentResolver.java deleted file mode 100644 index db00211de..000000000 --- a/src/main/java/org/example/expert/config/AuthUserArgumentResolver.java +++ /dev/null @@ -1,46 +0,0 @@ -package org.example.expert.config; - -import jakarta.servlet.http.HttpServletRequest; -import org.example.expert.domain.auth.exception.AuthException; -import org.example.expert.domain.common.annotation.Auth; -import org.example.expert.domain.common.dto.AuthUser; -import org.example.expert.domain.user.enums.UserRole; -import org.springframework.core.MethodParameter; -import org.springframework.lang.Nullable; -import org.springframework.web.bind.support.WebDataBinderFactory; -import org.springframework.web.context.request.NativeWebRequest; -import org.springframework.web.method.support.HandlerMethodArgumentResolver; -import org.springframework.web.method.support.ModelAndViewContainer; - -public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver { - - @Override - public boolean supportsParameter(MethodParameter parameter) { - boolean hasAuthAnnotation = parameter.getParameterAnnotation(Auth.class) != null; - boolean isAuthUserType = parameter.getParameterType().equals(AuthUser.class); - - // @Auth 어노테이션과 AuthUser 타입이 함께 사용되지 않은 경우 예외 발생 - if (hasAuthAnnotation != isAuthUserType) { - throw new AuthException("@Auth와 AuthUser 타입은 함께 사용되어야 합니다."); - } - - return hasAuthAnnotation; - } - - @Override - public Object resolveArgument( - @Nullable MethodParameter parameter, - @Nullable ModelAndViewContainer mavContainer, - NativeWebRequest webRequest, - @Nullable WebDataBinderFactory binderFactory - ) { - HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); - - // JwtFilter 에서 set 한 userId, email, userRole 값을 가져옴 - Long userId = (Long) request.getAttribute("userId"); - String email = (String) request.getAttribute("email"); - UserRole userRole = UserRole.of((String) request.getAttribute("userRole")); - - return new AuthUser(userId, email, userRole); - } -} diff --git a/src/main/java/org/example/expert/config/FilterConfig.java b/src/main/java/org/example/expert/config/FilterConfig.java deleted file mode 100644 index 34cb4088a..000000000 --- a/src/main/java/org/example/expert/config/FilterConfig.java +++ /dev/null @@ -1,22 +0,0 @@ -package org.example.expert.config; - -import lombok.RequiredArgsConstructor; -import org.springframework.boot.web.servlet.FilterRegistrationBean; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -@RequiredArgsConstructor -public class FilterConfig { - - private final JwtUtil jwtUtil; - - @Bean - public FilterRegistrationBean jwtFilter() { - FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); - registrationBean.setFilter(new JwtFilter(jwtUtil)); - registrationBean.addUrlPatterns("/*"); // 필터를 적용할 URL 패턴을 지정합니다. - - return registrationBean; - } -} diff --git a/src/main/java/org/example/expert/config/JwtFilter.java b/src/main/java/org/example/expert/config/JwtFilter.java index 03908abe1..5224507b6 100644 --- a/src/main/java/org/example/expert/config/JwtFilter.java +++ b/src/main/java/org/example/expert/config/JwtFilter.java @@ -10,38 +10,33 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.example.expert.domain.common.dto.AuthUser; import org.example.expert.domain.user.enums.UserRole; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; @Slf4j @RequiredArgsConstructor -public class JwtFilter implements Filter { +// Been 등록 +@Component +public class JwtFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; @Override - public void init(FilterConfig filterConfig) throws ServletException { - Filter.super.init(filterConfig); - } + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain + ) throws ServletException, IOException { - @Override - public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { - HttpServletRequest httpRequest = (HttpServletRequest) request; - HttpServletResponse httpResponse = (HttpServletResponse) response; + // auth 경로 Spring Security에서 설정 - String url = httpRequest.getRequestURI(); - - if (url.startsWith("/auth")) { - chain.doFilter(request, response); - return; - } - - String bearerJwt = httpRequest.getHeader("Authorization"); + String bearerJwt = request.getHeader("Authorization"); if (bearerJwt == null) { - // 토큰이 없는 경우 400을 반환합니다. - httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "JWT 토큰이 필요합니다."); + response.sendError(HttpServletResponse.SC_BAD_REQUEST, "JWT 토큰이 필요합니다."); return; } @@ -51,44 +46,45 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha // JWT 유효성 검사와 claims 추출 Claims claims = jwtUtil.extractClaims(jwt); if (claims == null) { - httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "잘못된 JWT 토큰입니다."); + response.sendError(HttpServletResponse.SC_BAD_REQUEST, "잘못된 JWT 토큰입니다."); return; } - UserRole userRole = UserRole.valueOf(claims.get("userRole", String.class)); + // 클레임에서 사용자 정보 추출 + Long id = Long.parseLong(claims.getSubject()); + String email = claims.get("email", String.class); + String userRoleString = claims.get("userRole", String.class); + String nickname = claims.get("nickname", String.class); - httpRequest.setAttribute("userId", Long.parseLong(claims.getSubject())); - httpRequest.setAttribute("email", claims.get("email")); - httpRequest.setAttribute("userRole", claims.get("userRole")); + UserRole userRole = UserRole.valueOf(userRoleString); - if (url.startsWith("/admin")) { - // 관리자 권한이 없는 경우 403을 반환합니다. - if (!UserRole.ADMIN.equals(userRole)) { - httpResponse.sendError(HttpServletResponse.SC_FORBIDDEN, "관리자 권한이 없습니다."); - return; - } - chain.doFilter(request, response); - return; - } + // authUser 가져온 값들을 주입 + AuthUser authUser = new AuthUser(id, email, userRole, nickname); + + // Spring Security의 Authentication 객체 생성 + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + authUser, null, authUser.getAuthorities()); + + // SecurityContextHolder에 Authentication 객체를 설정합니다. + SecurityContextHolder.getContext().setAuthentication(authentication); - chain.doFilter(request, response); + filterChain.doFilter(request, response); } catch (SecurityException | MalformedJwtException e) { log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.", e); - httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않는 JWT 서명입니다."); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않는 JWT 서명입니다."); } catch (ExpiredJwtException e) { log.error("Expired JWT token, 만료된 JWT token 입니다.", e); - httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "만료된 JWT 토큰입니다."); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "만료된 JWT 토큰입니다."); } catch (UnsupportedJwtException e) { log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.", e); - httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "지원되지 않는 JWT 토큰입니다."); + response.sendError(HttpServletResponse.SC_BAD_REQUEST, "지원되지 않는 JWT 토큰입니다."); + } catch (IllegalArgumentException e) { // UserRole.valueOf() 실패 등 추가 예외 처리 + log.error("JWT claims parsing error or invalid user role.", e); + response.sendError(HttpServletResponse.SC_BAD_REQUEST, "JWT 클레임 처리 오류 또는 유효하지 않은 사용자 역할입니다."); } catch (Exception e) { - log.error("Internal server error", e); - httpResponse.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + log.error("Internal server error during JWT processing.", e); + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "JWT 처리 중 서버 내부 오류가 발생했습니다."); } } - - @Override - public void destroy() { - Filter.super.destroy(); - } -} +} \ No newline at end of file diff --git a/src/main/java/org/example/expert/config/JwtUtil.java b/src/main/java/org/example/expert/config/JwtUtil.java index 07e0a2c7c..230bec1b8 100644 --- a/src/main/java/org/example/expert/config/JwtUtil.java +++ b/src/main/java/org/example/expert/config/JwtUtil.java @@ -34,7 +34,7 @@ public void init() { key = Keys.hmacShaKeyFor(bytes); } - public String createToken(Long userId, String email, UserRole userRole) { + public String createToken(Long userId, String email, UserRole userRole, String nickname) { Date date = new Date(); return BEARER_PREFIX + @@ -42,6 +42,7 @@ public String createToken(Long userId, String email, UserRole userRole) { .setSubject(String.valueOf(userId)) .claim("email", email) .claim("userRole", userRole) + .claim("nickname", nickname) .setExpiration(new Date(date.getTime() + TOKEN_TIME)) .setIssuedAt(date) // 발급일 .signWith(key, signatureAlgorithm) // 암호화 알고리즘 diff --git a/src/main/java/org/example/expert/config/QueryDslConfig.java b/src/main/java/org/example/expert/config/QueryDslConfig.java new file mode 100644 index 000000000..2575bf500 --- /dev/null +++ b/src/main/java/org/example/expert/config/QueryDslConfig.java @@ -0,0 +1,22 @@ +package org.example.expert.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QueryDslConfig { + + private EntityManager entityManager; + + public QueryDslConfig(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} + diff --git a/src/main/java/org/example/expert/config/SecurityConfig.java b/src/main/java/org/example/expert/config/SecurityConfig.java new file mode 100644 index 000000000..06afc5109 --- /dev/null +++ b/src/main/java/org/example/expert/config/SecurityConfig.java @@ -0,0 +1,44 @@ +package org.example.expert.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + + +@Configuration +@EnableWebSecurity //시큐리티 활성화 +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtFilter jwtFilter; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + // CSRF 보호 비활성화 (JWT 토큰 기반 인증 사용) + .csrf(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) // BasicAuthenticationFilter 비활성화 + .formLogin(AbstractHttpConfigurer::disable) // UsernamePasswordAuthenticationFilter, DefaultLoginPageGeneratingFilter 비활성화 + // 세션 관리 정책: STATELESS (서버에 세션 저장 안함) + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + // 접근 권한 설정 + .authorizeHttpRequests(authorize -> authorize + //허용 경로지정 + // "/auth"로 시작하는 모든 요청은 인증 없이 허용 (회원가입, 로그인) + .requestMatchers("/auth/**").permitAll() + // "/admin"으로 시작하는 요청은 'ADMIN' 역할을 가진 사용자만 허용 + .requestMatchers("/admin/**").hasRole("ADMIN") + // 나머지는 인증 필요 + .anyRequest().authenticated() + ).addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } +} diff --git a/src/main/java/org/example/expert/config/WebConfig.java b/src/main/java/org/example/expert/config/WebConfig.java deleted file mode 100644 index adff06b82..000000000 --- a/src/main/java/org/example/expert/config/WebConfig.java +++ /dev/null @@ -1,19 +0,0 @@ -package org.example.expert.config; - -import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.method.support.HandlerMethodArgumentResolver; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; - -import java.util.List; - -@Configuration -@RequiredArgsConstructor -public class WebConfig implements WebMvcConfigurer { - - // ArgumentResolver 등록 - @Override - public void addArgumentResolvers(List resolvers) { - resolvers.add(new AuthUserArgumentResolver()); - } -} diff --git a/src/main/java/org/example/expert/domain/auth/dto/request/SignupRequest.java b/src/main/java/org/example/expert/domain/auth/dto/request/SignupRequest.java index cdb103690..084e64b34 100644 --- a/src/main/java/org/example/expert/domain/auth/dto/request/SignupRequest.java +++ b/src/main/java/org/example/expert/domain/auth/dto/request/SignupRequest.java @@ -17,4 +17,6 @@ public class SignupRequest { private String password; @NotBlank private String userRole; + @NotBlank + private String nickname; } diff --git a/src/main/java/org/example/expert/domain/auth/service/AuthService.java b/src/main/java/org/example/expert/domain/auth/service/AuthService.java index a662239dc..2a5bc1a35 100644 --- a/src/main/java/org/example/expert/domain/auth/service/AuthService.java +++ b/src/main/java/org/example/expert/domain/auth/service/AuthService.java @@ -38,11 +38,12 @@ public SignupResponse signup(SignupRequest signupRequest) { User newUser = new User( signupRequest.getEmail(), encodedPassword, - userRole + userRole, + signupRequest.getNickname() ); User savedUser = userRepository.save(newUser); - String bearerToken = jwtUtil.createToken(savedUser.getId(), savedUser.getEmail(), userRole); + String bearerToken = jwtUtil.createToken(savedUser.getId(), savedUser.getEmail(), userRole, savedUser.getNickname()); return new SignupResponse(bearerToken); } @@ -56,7 +57,7 @@ public SigninResponse signin(SigninRequest signinRequest) { throw new AuthException("잘못된 비밀번호입니다."); } - String bearerToken = jwtUtil.createToken(user.getId(), user.getEmail(), user.getUserRole()); + String bearerToken = jwtUtil.createToken(user.getId(), user.getEmail(), user.getUserRole(),user.getNickname()); return new SigninResponse(bearerToken); } diff --git a/src/main/java/org/example/expert/domain/comment/controller/CommentController.java b/src/main/java/org/example/expert/domain/comment/controller/CommentController.java index 51264b12e..e8734a2ef 100644 --- a/src/main/java/org/example/expert/domain/comment/controller/CommentController.java +++ b/src/main/java/org/example/expert/domain/comment/controller/CommentController.java @@ -6,9 +6,9 @@ import org.example.expert.domain.comment.dto.response.CommentResponse; import org.example.expert.domain.comment.dto.response.CommentSaveResponse; import org.example.expert.domain.comment.service.CommentService; -import org.example.expert.domain.common.annotation.Auth; import org.example.expert.domain.common.dto.AuthUser; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -21,7 +21,7 @@ public class CommentController { @PostMapping("/todos/{todoId}/comments") public ResponseEntity saveComment( - @Auth AuthUser authUser, + @AuthenticationPrincipal AuthUser authUser, @PathVariable long todoId, @Valid @RequestBody CommentSaveRequest commentSaveRequest ) { diff --git a/src/main/java/org/example/expert/domain/comment/repository/CommentRepository.java b/src/main/java/org/example/expert/domain/comment/repository/CommentRepository.java index 3c97b95dc..ecb21ce56 100644 --- a/src/main/java/org/example/expert/domain/comment/repository/CommentRepository.java +++ b/src/main/java/org/example/expert/domain/comment/repository/CommentRepository.java @@ -9,6 +9,6 @@ public interface CommentRepository extends JpaRepository { - @Query("SELECT c FROM Comment c JOIN c.user WHERE c.todo.id = :todoId") + @Query("SELECT c FROM Comment c JOIN FETCH c.user WHERE c.todo.id = :todoId") List findByTodoIdWithUser(@Param("todoId") Long todoId); } diff --git a/src/main/java/org/example/expert/domain/comment/service/CommentService.java b/src/main/java/org/example/expert/domain/comment/service/CommentService.java index 37f857491..3da98ba15 100644 --- a/src/main/java/org/example/expert/domain/comment/service/CommentService.java +++ b/src/main/java/org/example/expert/domain/comment/service/CommentService.java @@ -43,7 +43,7 @@ public CommentSaveResponse saveComment(AuthUser authUser, long todoId, CommentSa return new CommentSaveResponse( savedComment.getId(), savedComment.getContents(), - new UserResponse(user.getId(), user.getEmail()) + new UserResponse(user.getId(), user.getEmail(), user.getNickname()) ); } @@ -56,7 +56,7 @@ public List getComments(long todoId) { CommentResponse dto = new CommentResponse( comment.getId(), comment.getContents(), - new UserResponse(user.getId(), user.getEmail()) + new UserResponse(user.getId(), user.getEmail(), user.getNickname()) ); dtoList.add(dto); } diff --git a/src/main/java/org/example/expert/domain/common/annotation/Auth.java b/src/main/java/org/example/expert/domain/common/annotation/Auth.java deleted file mode 100644 index 770061855..000000000 --- a/src/main/java/org/example/expert/domain/common/annotation/Auth.java +++ /dev/null @@ -1,11 +0,0 @@ -package org.example.expert.domain.common.annotation; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Target(ElementType.PARAMETER) -@Retention(RetentionPolicy.RUNTIME) -public @interface Auth { -} diff --git a/src/main/java/org/example/expert/domain/common/dto/AuthUser.java b/src/main/java/org/example/expert/domain/common/dto/AuthUser.java index 7f4bc52e1..cde9a2032 100644 --- a/src/main/java/org/example/expert/domain/common/dto/AuthUser.java +++ b/src/main/java/org/example/expert/domain/common/dto/AuthUser.java @@ -2,17 +2,42 @@ import lombok.Getter; import org.example.expert.domain.user.enums.UserRole; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; @Getter -public class AuthUser { +public class AuthUser implements UserDetails { private final Long id; private final String email; private final UserRole userRole; + private final String nickname; - public AuthUser(Long id, String email, UserRole userRole) { + public AuthUser(Long id, String email, UserRole userRole, String nickname) { this.id = id; this.email = email; this.userRole = userRole; + this.nickname = nickname; + } + + // 권환 반환 + @Override + public Collection getAuthorities() { + return Collections.singleton(userRole.getGrantedAuthority()); + } + + @Override + public String getPassword() { + return null; + } + + @Override + public String getUsername() { + return email; } + } diff --git a/src/main/java/org/example/expert/domain/manager/controller/ManagerController.java b/src/main/java/org/example/expert/domain/manager/controller/ManagerController.java index 327b6452b..918ffe28c 100644 --- a/src/main/java/org/example/expert/domain/manager/controller/ManagerController.java +++ b/src/main/java/org/example/expert/domain/manager/controller/ManagerController.java @@ -2,13 +2,13 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.example.expert.domain.common.annotation.Auth; import org.example.expert.domain.common.dto.AuthUser; import org.example.expert.domain.manager.dto.request.ManagerSaveRequest; import org.example.expert.domain.manager.dto.response.ManagerResponse; import org.example.expert.domain.manager.dto.response.ManagerSaveResponse; import org.example.expert.domain.manager.service.ManagerService; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -21,7 +21,7 @@ public class ManagerController { @PostMapping("/todos/{todoId}/managers") public ResponseEntity saveManager( - @Auth AuthUser authUser, + @AuthenticationPrincipal AuthUser authUser, @PathVariable long todoId, @Valid @RequestBody ManagerSaveRequest managerSaveRequest ) { @@ -35,7 +35,7 @@ public ResponseEntity> getMembers(@PathVariable long todoI @DeleteMapping("/todos/{todoId}/managers/{managerId}") public void deleteManager( - @Auth AuthUser authUser, + @AuthenticationPrincipal AuthUser authUser, @PathVariable long todoId, @PathVariable long managerId ) { diff --git a/src/main/java/org/example/expert/domain/manager/service/ManagerService.java b/src/main/java/org/example/expert/domain/manager/service/ManagerService.java index 9e14df0f1..6f3fdbb3b 100644 --- a/src/main/java/org/example/expert/domain/manager/service/ManagerService.java +++ b/src/main/java/org/example/expert/domain/manager/service/ManagerService.java @@ -52,7 +52,7 @@ public ManagerSaveResponse saveManager(AuthUser authUser, long todoId, ManagerSa return new ManagerSaveResponse( savedManagerUser.getId(), - new UserResponse(managerUser.getId(), managerUser.getEmail()) + new UserResponse(managerUser.getId(), managerUser.getEmail(), managerUser.getNickname()) ); } @@ -67,7 +67,7 @@ public List getManagers(long todoId) { User user = manager.getUser(); dtoList.add(new ManagerResponse( manager.getId(), - new UserResponse(user.getId(), user.getEmail()) + new UserResponse(user.getId(), user.getEmail(), user.getNickname()) )); } return dtoList; diff --git a/src/main/java/org/example/expert/domain/todo/controller/TodoController.java b/src/main/java/org/example/expert/domain/todo/controller/TodoController.java index eed1a1b46..955214ec7 100644 --- a/src/main/java/org/example/expert/domain/todo/controller/TodoController.java +++ b/src/main/java/org/example/expert/domain/todo/controller/TodoController.java @@ -2,14 +2,17 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.example.expert.domain.common.annotation.Auth; import org.example.expert.domain.common.dto.AuthUser; +import org.example.expert.domain.todo.dto.request.SearchTodoConditionRequest; import org.example.expert.domain.todo.dto.request.TodoSaveRequest; +import org.example.expert.domain.todo.dto.response.SearchTodoResponse; import org.example.expert.domain.todo.dto.response.TodoResponse; import org.example.expert.domain.todo.dto.response.TodoSaveResponse; import org.example.expert.domain.todo.service.TodoService; import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @RestController @@ -20,7 +23,7 @@ public class TodoController { @PostMapping("/todos") public ResponseEntity saveTodo( - @Auth AuthUser authUser, + @AuthenticationPrincipal AuthUser authUser, @Valid @RequestBody TodoSaveRequest todoSaveRequest ) { return ResponseEntity.ok(todoService.saveTodo(authUser, todoSaveRequest)); @@ -29,13 +32,23 @@ public ResponseEntity saveTodo( @GetMapping("/todos") public ResponseEntity> getTodos( @RequestParam(defaultValue = "1") int page, - @RequestParam(defaultValue = "10") int size + @RequestParam(defaultValue = "10") int size, + @RequestParam(required = false) String weather, + @RequestParam(required = false) String startDate, + @RequestParam(required = false) String endDate ) { - return ResponseEntity.ok(todoService.getTodos(page, size)); + return ResponseEntity.ok(todoService.getTodos(page, size, weather, startDate, endDate)); } @GetMapping("/todos/{todoId}") public ResponseEntity getTodo(@PathVariable long todoId) { return ResponseEntity.ok(todoService.getTodo(todoId)); } + + @GetMapping("/search") + public ResponseEntity> searchTodo( + SearchTodoConditionRequest conditionRequest, + Pageable pageable) { + return ResponseEntity.ok(todoService.searchTodo(conditionRequest, pageable)); + } } diff --git a/src/main/java/org/example/expert/domain/todo/dto/request/SearchTodoConditionRequest.java b/src/main/java/org/example/expert/domain/todo/dto/request/SearchTodoConditionRequest.java new file mode 100644 index 000000000..eeb57a2a0 --- /dev/null +++ b/src/main/java/org/example/expert/domain/todo/dto/request/SearchTodoConditionRequest.java @@ -0,0 +1,12 @@ +package org.example.expert.domain.todo.dto.request; + +import lombok.Getter; +import java.time.LocalDate; + +@Getter +public class SearchTodoConditionRequest { + private String keyword; + private String Nickname; + private LocalDate startDate; + private LocalDate endDate; +} diff --git a/src/main/java/org/example/expert/domain/todo/dto/response/SearchTodoResponse.java b/src/main/java/org/example/expert/domain/todo/dto/response/SearchTodoResponse.java new file mode 100644 index 000000000..2d63a6056 --- /dev/null +++ b/src/main/java/org/example/expert/domain/todo/dto/response/SearchTodoResponse.java @@ -0,0 +1,13 @@ +package org.example.expert.domain.todo.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class SearchTodoResponse { + private String title; + private Long managerCount; + private Long commentCount; + +} diff --git a/src/main/java/org/example/expert/domain/todo/entity/Todo.java b/src/main/java/org/example/expert/domain/todo/entity/Todo.java index b4efcced1..6a1bbf3ae 100644 --- a/src/main/java/org/example/expert/domain/todo/entity/Todo.java +++ b/src/main/java/org/example/expert/domain/todo/entity/Todo.java @@ -30,7 +30,7 @@ public class Todo extends Timestamped { @OneToMany(mappedBy = "todo", cascade = CascadeType.REMOVE) private List comments = new ArrayList<>(); - @OneToMany(mappedBy = "todo") + @OneToMany(mappedBy = "todo", cascade = CascadeType.PERSIST) private List managers = new ArrayList<>(); public Todo(String title, String contents, String weather, User user) { diff --git a/src/main/java/org/example/expert/domain/todo/repository/TodoCustomRepository.java b/src/main/java/org/example/expert/domain/todo/repository/TodoCustomRepository.java new file mode 100644 index 000000000..31d7fafe3 --- /dev/null +++ b/src/main/java/org/example/expert/domain/todo/repository/TodoCustomRepository.java @@ -0,0 +1,15 @@ +package org.example.expert.domain.todo.repository; + +import org.example.expert.domain.todo.dto.request.SearchTodoConditionRequest; +import org.example.expert.domain.todo.dto.response.SearchTodoResponse; +import org.example.expert.domain.todo.entity.Todo; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; + +public interface TodoCustomRepository { + Optional findByIdWithUser(Long todoId); + + Page searchByCondition(SearchTodoConditionRequest conditionRequest, Pageable pageable); +} diff --git a/src/main/java/org/example/expert/domain/todo/repository/TodoCustomRepositoryImpl.java b/src/main/java/org/example/expert/domain/todo/repository/TodoCustomRepositoryImpl.java new file mode 100644 index 000000000..c3c16c5e1 --- /dev/null +++ b/src/main/java/org/example/expert/domain/todo/repository/TodoCustomRepositoryImpl.java @@ -0,0 +1,118 @@ +package org.example.expert.domain.todo.repository; + +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import org.example.expert.domain.comment.entity.QComment; +import org.example.expert.domain.manager.entity.QManager; +import org.example.expert.domain.todo.dto.request.SearchTodoConditionRequest; +import org.example.expert.domain.todo.dto.response.SearchTodoResponse; +import org.example.expert.domain.todo.entity.QTodo; +import org.example.expert.domain.todo.entity.Todo; +import org.example.expert.domain.user.entity.QUser; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public class TodoCustomRepositoryImpl implements TodoCustomRepository{ + + private final JPAQueryFactory jpaQueryFactory; + + public TodoCustomRepositoryImpl(JPAQueryFactory jpaQueryFactory) { + this.jpaQueryFactory = jpaQueryFactory; + } + + @Override + public Optional findByIdWithUser(Long todoId) { + + QTodo todo = QTodo.todo; + QUser user = QUser.user; + + return Optional.ofNullable(jpaQueryFactory + .selectFrom(todo) + .leftJoin(todo.user,user) + .fetchJoin() + .where(todo.id.eq(todoId)) + .fetchOne()); + } + + @Override + public Page searchByCondition( + SearchTodoConditionRequest conditionRequest, + Pageable pageable) { + QTodo todo = QTodo.todo; + QUser user = QUser.user; + QComment comment = QComment.comment; + QManager manager = QManager.manager; + + List content = jpaQueryFactory + .select(Projections.constructor(SearchTodoResponse.class, + todo.title, + manager.id.countDistinct(), + comment.id.countDistinct() + )) + .from(todo) + .leftJoin(todo.managers, manager) + .leftJoin(manager.user,user) + .leftJoin(todo.comments, comment) + .where( + titleContains(conditionRequest.getKeyword()), + managerNicknameContains(conditionRequest.getNickname()), + createdDateBetween(conditionRequest.getStartDate(), conditionRequest.getEndDate()) + ) + .groupBy(todo.id) + .orderBy(todo.createdAt.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = jpaQueryFactory + .select(todo.id.countDistinct()) + .from(todo) + .leftJoin(todo.managers, manager) + .leftJoin(manager.user, user) + .where( + titleContains(conditionRequest.getKeyword()), + managerNicknameContains(conditionRequest.getNickname()), + createdDateBetween(conditionRequest.getStartDate(), conditionRequest.getEndDate()) + ) + .fetchOne(); + + long totalCount = 0; + if (total != null) { + totalCount = total; + } + + return new PageImpl<>(content, pageable, totalCount); + } + + private BooleanExpression titleContains(String keyword) { + if (keyword != null || keyword.isBlank()) { + return null; + } + return QTodo.todo.title.containsIgnoreCase(keyword); + } + + private BooleanExpression managerNicknameContains(String nickname) { + if (nickname == null || nickname.isBlank()) { + return null; + } + return QManager.manager.user.nickname.containsIgnoreCase(nickname); + } + + private BooleanExpression createdDateBetween(LocalDate start, LocalDate end) { + if (start != null && end != null) { + return QTodo.todo.createdAt.between(start.atStartOfDay(), end.plusDays(1).atStartOfDay()); + } else if (start != null) { + return QTodo.todo.createdAt.goe(start.atStartOfDay()); + } else if (end != null) { + return QTodo.todo.createdAt.loe(end.plusDays(1).atStartOfDay()); + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/org/example/expert/domain/todo/repository/TodoRepository.java b/src/main/java/org/example/expert/domain/todo/repository/TodoRepository.java index a3e4e0749..2482c3e58 100644 --- a/src/main/java/org/example/expert/domain/todo/repository/TodoRepository.java +++ b/src/main/java/org/example/expert/domain/todo/repository/TodoRepository.java @@ -7,15 +7,25 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; import java.util.Optional; -public interface TodoRepository extends JpaRepository { +public interface TodoRepository extends JpaRepository, TodoCustomRepository { - @Query("SELECT t FROM Todo t LEFT JOIN FETCH t.user u ORDER BY t.modifiedAt DESC") - Page findAllByOrderByModifiedAtDesc(Pageable pageable); +// @Query("SELECT t FROM Todo t " + +// "LEFT JOIN t.user " + +// "WHERE t.id = :todoId") +// Optional findByIdWithUser(@Param("todoId") Long todoId); - @Query("SELECT t FROM Todo t " + - "LEFT JOIN t.user " + - "WHERE t.id = :todoId") - Optional findByIdWithUser(@Param("todoId") Long todoId); + // 날씨가 없을경우 + // 날짜없는 경우 + // @Param 메소드와 연결을 위함 + @Query("select t from Todo t " + + "WHERE (:weather IS NULL OR t.weather = :weather) " + + "AND (:startDate IS NULL OR t.modifiedAt >= :startDate) " + + "AND (:endDate IS NULL OR t.modifiedAt <= :endDate)") + Page findTodosByConditions(Pageable pageable, + @Param("weather") String weather, + @Param("startDate") LocalDateTime startDate, + @Param("endDate") LocalDateTime endDate); } diff --git a/src/main/java/org/example/expert/domain/todo/service/TodoService.java b/src/main/java/org/example/expert/domain/todo/service/TodoService.java index 922991ce7..d9d189136 100644 --- a/src/main/java/org/example/expert/domain/todo/service/TodoService.java +++ b/src/main/java/org/example/expert/domain/todo/service/TodoService.java @@ -4,7 +4,9 @@ import org.example.expert.client.WeatherClient; import org.example.expert.domain.common.dto.AuthUser; import org.example.expert.domain.common.exception.InvalidRequestException; +import org.example.expert.domain.todo.dto.request.SearchTodoConditionRequest; import org.example.expert.domain.todo.dto.request.TodoSaveRequest; +import org.example.expert.domain.todo.dto.response.SearchTodoResponse; import org.example.expert.domain.todo.dto.response.TodoResponse; import org.example.expert.domain.todo.dto.response.TodoSaveResponse; import org.example.expert.domain.todo.entity.Todo; @@ -16,6 +18,11 @@ import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.format.DateTimeFormatter; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; @Service @RequiredArgsConstructor @@ -24,7 +31,10 @@ public class TodoService { private final TodoRepository todoRepository; private final WeatherClient weatherClient; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + @Transactional public TodoSaveResponse saveTodo(AuthUser authUser, TodoSaveRequest todoSaveRequest) { User user = User.fromAuthUser(authUser); @@ -43,21 +53,46 @@ public TodoSaveResponse saveTodo(AuthUser authUser, TodoSaveRequest todoSaveRequ savedTodo.getTitle(), savedTodo.getContents(), weather, - new UserResponse(user.getId(), user.getEmail()) + new UserResponse(user.getId(), user.getEmail(), user.getNickname()) ); } - public Page getTodos(int page, int size) { + public Page getTodos(int page, int size, String weather, String startDateStr, String endDateStr) { Pageable pageable = PageRequest.of(page - 1, size); - Page todos = todoRepository.findAllByOrderByModifiedAtDesc(pageable); + LocalDate startDate = null; + if (startDateStr != null && !startDateStr.isEmpty()) { + startDate = LocalDate.parse(startDateStr, DATE_FORMATTER); + } + + LocalDate endDate = null; + if (endDateStr != null && !endDateStr.isEmpty()) { + endDate = LocalDate.parse(endDateStr, DATE_FORMATTER); + } + + LocalDateTime startDateTime = null; + if (startDate != null) { + startDateTime = startDate.atStartOfDay(); + } + + LocalDateTime endDateTime = null; + if (endDate != null) { + endDateTime = endDate.atTime(LocalTime.MAX); + } + + Page todos = todoRepository.findTodosByConditions( + pageable, + weather, + startDateTime, + endDateTime + ); return todos.map(todo -> new TodoResponse( todo.getId(), todo.getTitle(), todo.getContents(), todo.getWeather(), - new UserResponse(todo.getUser().getId(), todo.getUser().getEmail()), + new UserResponse(todo.getUser().getId(), todo.getUser().getEmail(), todo.getUser().getNickname()), todo.getCreatedAt(), todo.getModifiedAt() )); @@ -74,9 +109,15 @@ public TodoResponse getTodo(long todoId) { todo.getTitle(), todo.getContents(), todo.getWeather(), - new UserResponse(user.getId(), user.getEmail()), + new UserResponse(user.getId(), user.getEmail(), user.getNickname()), todo.getCreatedAt(), todo.getModifiedAt() ); } + + public Page searchTodo( + SearchTodoConditionRequest conditionRequest, + Pageable pageable) { + return todoRepository.searchByCondition(conditionRequest, pageable); + } } diff --git a/src/main/java/org/example/expert/domain/user/controller/UserController.java b/src/main/java/org/example/expert/domain/user/controller/UserController.java index bb1ef7a95..f737e84cf 100644 --- a/src/main/java/org/example/expert/domain/user/controller/UserController.java +++ b/src/main/java/org/example/expert/domain/user/controller/UserController.java @@ -1,12 +1,12 @@ package org.example.expert.domain.user.controller; import lombok.RequiredArgsConstructor; -import org.example.expert.domain.common.annotation.Auth; import org.example.expert.domain.common.dto.AuthUser; import org.example.expert.domain.user.dto.request.UserChangePasswordRequest; import org.example.expert.domain.user.dto.response.UserResponse; import org.example.expert.domain.user.service.UserService; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @RestController @@ -21,7 +21,7 @@ public ResponseEntity getUser(@PathVariable long userId) { } @PutMapping("/users") - public void changePassword(@Auth AuthUser authUser, @RequestBody UserChangePasswordRequest userChangePasswordRequest) { + public void changePassword(@AuthenticationPrincipal AuthUser authUser, @RequestBody UserChangePasswordRequest userChangePasswordRequest) { userService.changePassword(authUser.getId(), userChangePasswordRequest); } } diff --git a/src/main/java/org/example/expert/domain/user/dto/response/UserResponse.java b/src/main/java/org/example/expert/domain/user/dto/response/UserResponse.java index 23794a3ca..91f3240aa 100644 --- a/src/main/java/org/example/expert/domain/user/dto/response/UserResponse.java +++ b/src/main/java/org/example/expert/domain/user/dto/response/UserResponse.java @@ -7,9 +7,11 @@ public class UserResponse { private final Long id; private final String email; + private final String nickname; - public UserResponse(Long id, String email) { + public UserResponse(Long id, String email, String nickname) { this.id = id; this.email = email; + this.nickname = nickname; } } diff --git a/src/main/java/org/example/expert/domain/user/entity/User.java b/src/main/java/org/example/expert/domain/user/entity/User.java index 30a0cc54f..0d504aa76 100644 --- a/src/main/java/org/example/expert/domain/user/entity/User.java +++ b/src/main/java/org/example/expert/domain/user/entity/User.java @@ -21,20 +21,25 @@ public class User extends Timestamped { @Enumerated(EnumType.STRING) private UserRole userRole; - public User(String email, String password, UserRole userRole) { + @Column(nullable = true) + private String nickname; + + public User(String email, String password, UserRole userRole, String nickname) { this.email = email; this.password = password; this.userRole = userRole; + this.nickname = nickname; } - private User(Long id, String email, UserRole userRole) { + private User(Long id, String email, UserRole userRole, String nickname) { this.id = id; this.email = email; this.userRole = userRole; + this.nickname = nickname; } public static User fromAuthUser(AuthUser authUser) { - return new User(authUser.getId(), authUser.getEmail(), authUser.getUserRole()); + return new User(authUser.getId(), authUser.getEmail(), authUser.getUserRole(), authUser.getNickname()); } public void changePassword(String password) { diff --git a/src/main/java/org/example/expert/domain/user/enums/UserRole.java b/src/main/java/org/example/expert/domain/user/enums/UserRole.java index 6fe177896..ec173c25b 100644 --- a/src/main/java/org/example/expert/domain/user/enums/UserRole.java +++ b/src/main/java/org/example/expert/domain/user/enums/UserRole.java @@ -1,6 +1,8 @@ package org.example.expert.domain.user.enums; import org.example.expert.domain.common.exception.InvalidRequestException; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import java.util.Arrays; @@ -13,4 +15,10 @@ public static UserRole of(String role) { .findFirst() .orElseThrow(() -> new InvalidRequestException("유효하지 않은 UerRole")); } + + // 시큐리티 권한 반환을 위한 메서드 + public GrantedAuthority getGrantedAuthority() { + return new SimpleGrantedAuthority("ROLE_" + this.name()); + } + } diff --git a/src/main/java/org/example/expert/domain/user/service/UserService.java b/src/main/java/org/example/expert/domain/user/service/UserService.java index 15baec417..15bdfa48b 100644 --- a/src/main/java/org/example/expert/domain/user/service/UserService.java +++ b/src/main/java/org/example/expert/domain/user/service/UserService.java @@ -20,7 +20,7 @@ public class UserService { public UserResponse getUser(long userId) { User user = userRepository.findById(userId).orElseThrow(() -> new InvalidRequestException("User not found")); - return new UserResponse(user.getId(), user.getEmail()); + return new UserResponse(user.getId(), user.getEmail(), user.getNickname()); } @Transactional diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 000000000..141200635 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,19 @@ +jwt: + secret: + key: afd456af34s435s62dfa46ads5f425as4562asfd2564fads5462asdf4562afds2456 + +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://localhost:3306/spring_plus + username: root + password: 6745 + jpa: + hibernate: + ddl-auto: update + properties: + hibernate: + show_sql: true + format_sql: true + use_sql_comments: true + dialect: org.hibernate.dialect.MySQLDialect \ No newline at end of file diff --git a/src/test/java/org/example/expert/domain/todo/controller/TodoControllerTest.java b/src/test/java/org/example/expert/domain/todo/controller/TodoControllerTest.java index 737193874..a0047abe1 100644 --- a/src/test/java/org/example/expert/domain/todo/controller/TodoControllerTest.java +++ b/src/test/java/org/example/expert/domain/todo/controller/TodoControllerTest.java @@ -35,9 +35,9 @@ class TodoControllerTest { // given long todoId = 1L; String title = "title"; - AuthUser authUser = new AuthUser(1L, "email", UserRole.USER); + AuthUser authUser = new AuthUser(1L, "email", UserRole.USER, "하룻강아지"); User user = User.fromAuthUser(authUser); - UserResponse userResponse = new UserResponse(user.getId(), user.getEmail()); + UserResponse userResponse = new UserResponse(user.getId(), user.getEmail(), authUser.getNickname()); TodoResponse response = new TodoResponse( todoId, title, @@ -61,7 +61,7 @@ class TodoControllerTest { @Test void todo_단건_조회_시_todo가_존재하지_않아_예외가_발생한다() throws Exception { // given - long todoId = 1L; + long todoId = 5L; // when when(todoService.getTodo(todoId)) @@ -69,9 +69,9 @@ class TodoControllerTest { // then mockMvc.perform(get("/todos/{todoId}", todoId)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.status").value(HttpStatus.OK.name())) - .andExpect(jsonPath("$.code").value(HttpStatus.OK.value())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(HttpStatus.BAD_REQUEST.name())) + .andExpect(jsonPath("$.code").value(HttpStatus.BAD_REQUEST.value())) .andExpect(jsonPath("$.message").value("Todo not found")); } }