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..137ef378f 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 email; + } + + @Override + public String getUsername() { + return null; } + } 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..11727fe21 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("/secrch") + 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..dd64b9f78 --- /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")); } }