diff --git a/build.gradle b/build.gradle index a7fd3e706..742188be5 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ plugins { id 'java' - id 'org.springframework.boot' version '3.3.3' - id 'io.spring.dependency-management' version '1.1.6' + id 'org.springframework.boot' version '3.4.5' + id 'io.spring.dependency-management' version '1.1.7' } group = 'org.example' @@ -27,8 +27,10 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.security:spring-security-test' compileOnly 'org.projectlombok:lombok' - runtimeOnly 'com.h2database:h2' + testRuntimeOnly 'com.h2database:h2' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' @@ -41,6 +43,12 @@ 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" } tasks.named('test') { diff --git a/src/main/generated/org/example/expert/domain/comment/entity/QComment.java b/src/main/generated/org/example/expert/domain/comment/entity/QComment.java new file mode 100644 index 000000000..a2aec389f --- /dev/null +++ b/src/main/generated/org/example/expert/domain/comment/entity/QComment.java @@ -0,0 +1,64 @@ +package org.example.expert.domain.comment.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QComment is a Querydsl query type for Comment + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QComment extends EntityPathBase { + + private static final long serialVersionUID = 1329458967L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QComment comment = new QComment("comment"); + + public final org.example.expert.domain.common.entity.QTimestamped _super = new org.example.expert.domain.common.entity.QTimestamped(this); + + public final StringPath contents = createString("contents"); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + public final NumberPath id = createNumber("id", Long.class); + + //inherited + public final DateTimePath modifiedAt = _super.modifiedAt; + + public final org.example.expert.domain.todo.entity.QTodo todo; + + public final org.example.expert.domain.user.entity.QUser user; + + public QComment(String variable) { + this(Comment.class, forVariable(variable), INITS); + } + + public QComment(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QComment(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QComment(PathMetadata metadata, PathInits inits) { + this(Comment.class, metadata, inits); + } + + public QComment(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.todo = inits.isInitialized("todo") ? new org.example.expert.domain.todo.entity.QTodo(forProperty("todo"), inits.get("todo")) : null; + this.user = inits.isInitialized("user") ? new org.example.expert.domain.user.entity.QUser(forProperty("user")) : null; + } + +} + diff --git a/src/main/generated/org/example/expert/domain/common/entity/QTimestamped.java b/src/main/generated/org/example/expert/domain/common/entity/QTimestamped.java new file mode 100644 index 000000000..cc062d17a --- /dev/null +++ b/src/main/generated/org/example/expert/domain/common/entity/QTimestamped.java @@ -0,0 +1,39 @@ +package org.example.expert.domain.common.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QTimestamped is a Querydsl query type for Timestamped + */ +@Generated("com.querydsl.codegen.DefaultSupertypeSerializer") +public class QTimestamped extends EntityPathBase { + + private static final long serialVersionUID = -1617243527L; + + public static final QTimestamped timestamped = new QTimestamped("timestamped"); + + public final DateTimePath createdAt = createDateTime("createdAt", java.time.LocalDateTime.class); + + public final DateTimePath modifiedAt = createDateTime("modifiedAt", java.time.LocalDateTime.class); + + public QTimestamped(String variable) { + super(Timestamped.class, forVariable(variable)); + } + + public QTimestamped(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QTimestamped(PathMetadata metadata) { + super(Timestamped.class, metadata); + } + +} + diff --git a/src/main/generated/org/example/expert/domain/manager/entity/QManager.java b/src/main/generated/org/example/expert/domain/manager/entity/QManager.java new file mode 100644 index 000000000..cd3eb8edb --- /dev/null +++ b/src/main/generated/org/example/expert/domain/manager/entity/QManager.java @@ -0,0 +1,54 @@ +package org.example.expert.domain.manager.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QManager is a Querydsl query type for Manager + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QManager extends EntityPathBase { + + private static final long serialVersionUID = 216623447L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QManager manager = new QManager("manager"); + + public final NumberPath id = createNumber("id", Long.class); + + public final org.example.expert.domain.todo.entity.QTodo todo; + + public final org.example.expert.domain.user.entity.QUser user; + + public QManager(String variable) { + this(Manager.class, forVariable(variable), INITS); + } + + public QManager(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QManager(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QManager(PathMetadata metadata, PathInits inits) { + this(Manager.class, metadata, inits); + } + + public QManager(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.todo = inits.isInitialized("todo") ? new org.example.expert.domain.todo.entity.QTodo(forProperty("todo"), inits.get("todo")) : null; + this.user = inits.isInitialized("user") ? new org.example.expert.domain.user.entity.QUser(forProperty("user")) : null; + } + +} + diff --git a/src/main/generated/org/example/expert/domain/todo/entity/QTodo.java b/src/main/generated/org/example/expert/domain/todo/entity/QTodo.java new file mode 100644 index 000000000..e6bf31f74 --- /dev/null +++ b/src/main/generated/org/example/expert/domain/todo/entity/QTodo.java @@ -0,0 +1,69 @@ +package org.example.expert.domain.todo.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QTodo is a Querydsl query type for Todo + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QTodo extends EntityPathBase { + + private static final long serialVersionUID = -1664369315L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QTodo todo = new QTodo("todo"); + + public final org.example.expert.domain.common.entity.QTimestamped _super = new org.example.expert.domain.common.entity.QTimestamped(this); + + public final ListPath comments = this.createList("comments", org.example.expert.domain.comment.entity.Comment.class, org.example.expert.domain.comment.entity.QComment.class, PathInits.DIRECT2); + + public final StringPath contents = createString("contents"); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + public final NumberPath id = createNumber("id", Long.class); + + public final ListPath managers = this.createList("managers", org.example.expert.domain.manager.entity.Manager.class, org.example.expert.domain.manager.entity.QManager.class, PathInits.DIRECT2); + + //inherited + public final DateTimePath modifiedAt = _super.modifiedAt; + + public final StringPath title = createString("title"); + + public final org.example.expert.domain.user.entity.QUser user; + + public final StringPath weather = createString("weather"); + + public QTodo(String variable) { + this(Todo.class, forVariable(variable), INITS); + } + + public QTodo(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QTodo(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QTodo(PathMetadata metadata, PathInits inits) { + this(Todo.class, metadata, inits); + } + + public QTodo(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.user = inits.isInitialized("user") ? new org.example.expert.domain.user.entity.QUser(forProperty("user")) : null; + } + +} + diff --git a/src/main/generated/org/example/expert/domain/user/entity/QUser.java b/src/main/generated/org/example/expert/domain/user/entity/QUser.java new file mode 100644 index 000000000..1faeeb9da --- /dev/null +++ b/src/main/generated/org/example/expert/domain/user/entity/QUser.java @@ -0,0 +1,53 @@ +package org.example.expert.domain.user.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QUser is a Querydsl query type for User + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QUser extends EntityPathBase { + + private static final long serialVersionUID = -1825397529L; + + public static final QUser user = new QUser("user"); + + public final org.example.expert.domain.common.entity.QTimestamped _super = new org.example.expert.domain.common.entity.QTimestamped(this); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + public final StringPath email = createString("email"); + + public final NumberPath id = createNumber("id", Long.class); + + //inherited + public final DateTimePath modifiedAt = _super.modifiedAt; + + public final StringPath nickname = createString("nickname"); + + public final StringPath password = createString("password"); + + public final EnumPath userRole = createEnum("userRole", org.example.expert.domain.user.enums.UserRole.class); + + public QUser(String variable) { + super(User.class, forVariable(variable)); + } + + public QUser(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QUser(PathMetadata metadata) { + super(User.class, metadata); + } + +} + diff --git a/src/main/java/org/example/expert/aop/AdminAccessLoggingAspect.java b/src/main/java/org/example/expert/aop/AdminAccessLoggingAspect.java index c90e8c792..3286b27fd 100644 --- a/src/main/java/org/example/expert/aop/AdminAccessLoggingAspect.java +++ b/src/main/java/org/example/expert/aop/AdminAccessLoggingAspect.java @@ -6,6 +6,7 @@ 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 +19,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.UserAdminController.changeUserRole(..))") 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/aop/ManagerRegistrationLogAspect.java b/src/main/java/org/example/expert/aop/ManagerRegistrationLogAspect.java new file mode 100644 index 000000000..6d4f567e6 --- /dev/null +++ b/src/main/java/org/example/expert/aop/ManagerRegistrationLogAspect.java @@ -0,0 +1,42 @@ +package org.example.expert.aop; + + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.After; +import org.aspectj.lang.annotation.Aspect; +import org.example.expert.aop.service.LogService; +import org.example.expert.domain.common.dto.AuthUser; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +@Aspect +@Component +@RequiredArgsConstructor +public class ManagerRegistrationLogAspect { + + private final LogService logService; + private final HttpServletRequest request; // 요청 정보 추출을 위해 필요 + + // 매니저 등록 메서드 실행 "직후" 자동 실행 + @After("execution(* org.example.expert.domain.manager.service.ManagerService.saveManager(..))") + public void logManagerRegistration(JoinPoint joinPoint) { + // 예시: 첫 번째 파라미터로 User 객체가 들어온다고 가정 + Object[] args = joinPoint.getArgs(); + String nickname = null; + for (Object arg : args) { + if (arg instanceof AuthUser) { + nickname = ((AuthUser) arg).getNickname(); + break; + } + } + String requestUrl = request.getRequestURI(); + LocalDateTime now = LocalDateTime.now(); + String method = joinPoint.getSignature().getName(); + + // 로그 저장 (항상 별도 트랜잭션) + logService.saveLog(nickname, now, requestUrl, method); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/expert/aop/entity/Log.java b/src/main/java/org/example/expert/aop/entity/Log.java new file mode 100644 index 000000000..d797414cc --- /dev/null +++ b/src/main/java/org/example/expert/aop/entity/Log.java @@ -0,0 +1,31 @@ +package org.example.expert.aop.entity; + + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "log") +@Getter +@NoArgsConstructor +public class Log { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String nickname; // 관리자 아이디 + private LocalDateTime requestTime; // 요청 시간 + private String requestUrl; // 요청 경로 + private String method; // 호출 메서드 + + public Log(String nickname, LocalDateTime requestTime, String requestUrl, String method) { + this.nickname = nickname; + this.requestTime = requestTime; + this.requestUrl = requestUrl; + this.method = method; + } +} diff --git a/src/main/java/org/example/expert/aop/repository/LogRepository.java b/src/main/java/org/example/expert/aop/repository/LogRepository.java new file mode 100644 index 000000000..b78e64c57 --- /dev/null +++ b/src/main/java/org/example/expert/aop/repository/LogRepository.java @@ -0,0 +1,10 @@ +package org.example.expert.aop.repository; + +import org.example.expert.aop.entity.Log; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface LogRepository extends JpaRepository { + +} diff --git a/src/main/java/org/example/expert/aop/service/LogService.java b/src/main/java/org/example/expert/aop/service/LogService.java new file mode 100644 index 000000000..c93a54e29 --- /dev/null +++ b/src/main/java/org/example/expert/aop/service/LogService.java @@ -0,0 +1,23 @@ +package org.example.expert.aop.service; + +import lombok.RequiredArgsConstructor; +import org.example.expert.aop.entity.Log; +import org.example.expert.aop.repository.LogRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +public class LogService { + + private final LogRepository logRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void saveLog(String nickname, LocalDateTime requestTime, String requestUrl, String method) { + Log log = new Log(nickname, requestTime, requestUrl, method); + logRepository.save(log); + } +} 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..487161652 100644 --- a/src/main/java/org/example/expert/config/JwtFilter.java +++ b/src/main/java/org/example/expert/config/JwtFilter.java @@ -10,9 +10,14 @@ 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.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; import java.io.IOException; +import java.util.List; @Slf4j @RequiredArgsConstructor @@ -55,11 +60,20 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha return; } - UserRole userRole = UserRole.valueOf(claims.get("userRole", String.class)); + Long userId = jwtUtil.getUserId(jwt); + String email = jwtUtil.getEmail(jwt); + String nickname = jwtUtil.getNickName(jwt); + UserRole userRole = jwtUtil.getUserRole(jwt); - httpRequest.setAttribute("userId", Long.parseLong(claims.getSubject())); - httpRequest.setAttribute("email", claims.get("email")); - httpRequest.setAttribute("userRole", claims.get("userRole")); + AuthUser authUser = new AuthUser(userId, email, nickname, userRole); + + // 인증 객체를 직접만들어준다... + UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( + authUser, "", List.of(new SimpleGrantedAuthority("ROLE_" + userRole.name())) // 권한까지 부여 + ); + + // 거를 contextholder에 대입 + SecurityContextHolder.getContext().setAuthentication(authenticationToken); if (url.startsWith("/admin")) { // 관리자 권한이 없는 경우 403을 반환합니다. diff --git a/src/main/java/org/example/expert/config/JwtUtil.java b/src/main/java/org/example/expert/config/JwtUtil.java index 07e0a2c7c..a8370d664 100644 --- a/src/main/java/org/example/expert/config/JwtUtil.java +++ b/src/main/java/org/example/expert/config/JwtUtil.java @@ -34,13 +34,14 @@ public void init() { key = Keys.hmacShaKeyFor(bytes); } - public String createToken(Long userId, String email, UserRole userRole) { + public String createToken(Long userId, String email, String nickname, UserRole userRole) { Date date = new Date(); return BEARER_PREFIX + Jwts.builder() .setSubject(String.valueOf(userId)) .claim("email", email) + .claim("nickname", nickname) .claim("userRole", userRole) .setExpiration(new Date(date.getTime() + TOKEN_TIME)) .setIssuedAt(date) // 발급일 @@ -62,4 +63,20 @@ public Claims extractClaims(String token) { .parseClaimsJws(token) .getBody(); } + + public Long getUserId(String token) { + return Long.parseLong(extractClaims(token).getSubject()); + } + + public String getEmail(String token) { + return extractClaims(token).get("email", String.class); + } + + public String getNickName(String token) { + return extractClaims(token).get("nickname", String.class); + } + + public UserRole getUserRole(String token) { + return UserRole.of(extractClaims(token).get("userRole", String.class)); + } } 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..0a21b3eac --- /dev/null +++ b/src/main/java/org/example/expert/config/QueryDslConfig.java @@ -0,0 +1,21 @@ +package org.example.expert.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + + +// querydsl 사용을 위한 사전준비 +@Configuration +@RequiredArgsConstructor +public class QueryDslConfig { + + private final EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} 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/config/security/CustomAccessDeniedHandler.java b/src/main/java/org/example/expert/config/security/CustomAccessDeniedHandler.java new file mode 100644 index 000000000..414116cbf --- /dev/null +++ b/src/main/java/org/example/expert/config/security/CustomAccessDeniedHandler.java @@ -0,0 +1,39 @@ +package org.example.expert.config.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + + +import java.io.IOException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { + + log.error("No Authorities", accessDeniedException); + log.error("Request Uri : {}", request.getRequestURI()); + + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.FORBIDDEN.value(), accessDeniedException.getMessage()); + String responseBody = objectMapper.writeValueAsString(errorResponse); + + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setStatus(HttpStatus.FORBIDDEN.value()); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(responseBody); + } +} diff --git a/src/main/java/org/example/expert/config/security/CustomAuthenticationEntryPoint.java b/src/main/java/org/example/expert/config/security/CustomAuthenticationEntryPoint.java new file mode 100644 index 000000000..307b5c99c --- /dev/null +++ b/src/main/java/org/example/expert/config/security/CustomAuthenticationEntryPoint.java @@ -0,0 +1,38 @@ +package org.example.expert.config.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + // 어떻게 처리할지 여기서 커스텀해서 시큐리티컨피규어에 등록을 해줘야함 + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { + + log.error("Not Authenticated Request", authException); + log.error("Request Uri : {}", request.getRequestURI()); + + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.UNAUTHORIZED.value(), authException.getMessage()); + String responseBody = objectMapper.writeValueAsString(errorResponse); + + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(responseBody); + } +} diff --git a/src/main/java/org/example/expert/config/security/ErrorResponse.java b/src/main/java/org/example/expert/config/security/ErrorResponse.java new file mode 100644 index 000000000..15b67da35 --- /dev/null +++ b/src/main/java/org/example/expert/config/security/ErrorResponse.java @@ -0,0 +1,13 @@ +package org.example.expert.config.security; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class ErrorResponse { + + private final int status; + private final String message; + +} diff --git a/src/main/java/org/example/expert/config/security/SecurityConfig.java b/src/main/java/org/example/expert/config/security/SecurityConfig.java new file mode 100644 index 000000000..9344b7cb5 --- /dev/null +++ b/src/main/java/org/example/expert/config/security/SecurityConfig.java @@ -0,0 +1,57 @@ +package org.example.expert.config.security; + + +import lombok.RequiredArgsConstructor; +import org.example.expert.config.JwtFilter; +import org.example.expert.config.JwtUtil; +import org.example.expert.domain.user.enums.UserRole; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +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 JwtUtil jwtUtil; + private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + private final CustomAccessDeniedHandler customAccessDeniedHandler; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception { + + return httpSecurity + .cors(Customizer.withDefaults()) //Cross-Origin Resource Sharing // 프론트엔드랑 협업할때 주로 사용 + .csrf(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + + .sessionManagement(session -> session // session 안쓰겠다고 알리는거 + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + + // config로 url에 대한 인증/인가를 관리 + .authorizeHttpRequests(auth -> auth + .requestMatchers("/auth/**").permitAll() + .requestMatchers("/admin/**").hasRole(UserRole.ADMIN.name()) + .anyRequest().authenticated() + ) + + //필터 등록 + .addFilterBefore(new JwtFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class) + // 에러 처리까지 완벽하게 하고 싶다면 exceptionHandling 을 등록을 해야한다. + .exceptionHandling(configurer -> + configurer + .authenticationEntryPoint(customAuthenticationEntryPoint) // 인증할때 발생하는 오류를 처리하는... + .accessDeniedHandler(customAccessDeniedHandler) // 인가처리할때 발생하는 오류를 처리하는 ... + ) + .build(); + } +} diff --git a/src/main/java/org/example/expert/domain/auth/controller/AuthController.java b/src/main/java/org/example/expert/domain/auth/controller/AuthController.java index 32d943d0a..c7cf99684 100644 --- a/src/main/java/org/example/expert/domain/auth/controller/AuthController.java +++ b/src/main/java/org/example/expert/domain/auth/controller/AuthController.java @@ -26,4 +26,5 @@ public SignupResponse signup(@Valid @RequestBody SignupRequest signupRequest) { public SigninResponse signin(@Valid @RequestBody SigninRequest signinRequest) { return authService.signin(signinRequest); } + } 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..1a5eb5b02 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 @@ -14,6 +14,8 @@ public class SignupRequest { @NotBlank @Email private String email; @NotBlank + private String nickname; + @NotBlank private String password; @NotBlank private String userRole; 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..fd40a14c7 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 @@ -37,12 +37,13 @@ public SignupResponse signup(SignupRequest signupRequest) { User newUser = new User( signupRequest.getEmail(), + signupRequest.getNickname(), encodedPassword, userRole ); User savedUser = userRepository.save(newUser); - String bearerToken = jwtUtil.createToken(savedUser.getId(), savedUser.getEmail(), userRole); + String bearerToken = jwtUtil.createToken(savedUser.getId(), savedUser.getEmail(), savedUser.getNickname(), userRole); 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.getNickname(), user.getUserRole()); 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..4ab2afb7b 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,7 @@ public interface CommentRepository extends JpaRepository { - @Query("SELECT c FROM Comment c JOIN c.user WHERE c.todo.id = :todoId") + // JOIN 이었던거를 JOIN FETCH 로 바꿈 그로 인해서 N + 1 문제를 해결!! + @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/dto/AuthUser.java b/src/main/java/org/example/expert/domain/common/dto/AuthUser.java index 7f4bc52e1..9511d910b 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 @@ -8,11 +8,13 @@ public class AuthUser { private final Long id; private final String email; + private final String nickname; private final UserRole userRole; - public AuthUser(Long id, String email, UserRole userRole) { + public AuthUser(Long id, String email, String nickname,UserRole userRole) { this.id = id; this.email = email; + this.nickname = nickname; this.userRole = userRole; } } 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..66f9812b7 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,15 @@ 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.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.*; import java.util.List; @@ -21,7 +23,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,10 +37,18 @@ 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 ) { managerService.deleteManager(authUser, todoId, managerId); } + + @GetMapping("/todos/managers") + public Page findManager( + @RequestParam String nickname, + Pageable pageable + ) { + return managerService.findManger(nickname, pageable); + } } diff --git a/src/main/java/org/example/expert/domain/manager/dto/request/ManagerSaveRequest.java b/src/main/java/org/example/expert/domain/manager/dto/request/ManagerSaveRequest.java index 9af5f3a9c..abedd64fd 100644 --- a/src/main/java/org/example/expert/domain/manager/dto/request/ManagerSaveRequest.java +++ b/src/main/java/org/example/expert/domain/manager/dto/request/ManagerSaveRequest.java @@ -12,4 +12,5 @@ public class ManagerSaveRequest { @NotNull private Long managerUserId; // 일정 작상자가 배치하는 유저 id + } diff --git a/src/main/java/org/example/expert/domain/manager/repository/ManagerRepository.java b/src/main/java/org/example/expert/domain/manager/repository/ManagerRepository.java index 23acf898b..bda5bac69 100644 --- a/src/main/java/org/example/expert/domain/manager/repository/ManagerRepository.java +++ b/src/main/java/org/example/expert/domain/manager/repository/ManagerRepository.java @@ -7,7 +7,7 @@ import java.util.List; -public interface ManagerRepository extends JpaRepository { +public interface ManagerRepository extends JpaRepository, QManagerRepository { @Query("SELECT m FROM Manager m JOIN FETCH m.user WHERE m.todo.id = :todoId") List findByTodoIdWithUser(@Param("todoId") Long todoId); } diff --git a/src/main/java/org/example/expert/domain/manager/repository/QManagerRepository.java b/src/main/java/org/example/expert/domain/manager/repository/QManagerRepository.java new file mode 100644 index 000000000..eb93e5bcb --- /dev/null +++ b/src/main/java/org/example/expert/domain/manager/repository/QManagerRepository.java @@ -0,0 +1,10 @@ +package org.example.expert.domain.manager.repository; + +import org.example.expert.domain.manager.dto.response.ManagerResponse; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface QManagerRepository { + + Page findManager(String nickname, Pageable pageable); +} diff --git a/src/main/java/org/example/expert/domain/manager/repository/QManagerRepositoryImpl.java b/src/main/java/org/example/expert/domain/manager/repository/QManagerRepositoryImpl.java new file mode 100644 index 000000000..20611235a --- /dev/null +++ b/src/main/java/org/example/expert/domain/manager/repository/QManagerRepositoryImpl.java @@ -0,0 +1,54 @@ +package org.example.expert.domain.manager.repository; + +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.example.expert.domain.manager.dto.response.ManagerResponse; +import org.example.expert.domain.user.dto.response.UserResponse; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.util.List; + +import static org.example.expert.domain.manager.entity.QManager.manager; +import static org.example.expert.domain.user.entity.QUser.user; + +@Repository +@RequiredArgsConstructor +public class QManagerRepositoryImpl implements QManagerRepository{ + + private final JPAQueryFactory queryFactory; + + @Override + public Page findManager(String nickname, Pageable pageable) { + List content = queryFactory + .select(Projections.constructor( + ManagerResponse.class, + manager.id, + Projections.constructor( + UserResponse.class, + manager.user.id, + manager.user.email, + manager.user.nickname + ) + )) + .from(manager) + .join(manager.user, user) + .where(user.nickname.containsIgnoreCase(nickname)) + .orderBy(manager.todo.createdAt.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = queryFactory + .select(manager.count()) + .from(manager) + .join(manager.user, user) + .where(user.nickname.containsIgnoreCase(nickname)) + .fetchOne(); + + return new PageImpl<>(content, pageable, total != null ? total : 0); + } +} 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..9cee6954c 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 @@ -13,6 +13,8 @@ import org.example.expert.domain.user.dto.response.UserResponse; import org.example.expert.domain.user.entity.User; import org.example.expert.domain.user.repository.UserRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.ObjectUtils; @@ -52,7 +54,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 +69,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; @@ -93,4 +95,18 @@ public void deleteManager(AuthUser authUser, long todoId, long managerId) { managerRepository.delete(manager); } + + public Page findManger(String nickname, Pageable pageable) { + Page manager = managerRepository.findManager(nickname, pageable); + return manager.map( + managerResponse -> new ManagerResponse( + managerResponse.getId(), + new UserResponse( + managerResponse.getId(), + managerResponse.getUser().getEmail(), + managerResponse.getUser().getNickname() + ) + ) + ); + } } 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..facc0701e 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,16 +2,19 @@ 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.TodoSaveRequest; -import org.example.expert.domain.todo.dto.response.TodoResponse; -import org.example.expert.domain.todo.dto.response.TodoSaveResponse; +import org.example.expert.domain.todo.dto.response.*; import org.example.expert.domain.todo.service.TodoService; import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import java.time.LocalDate; + @RestController @RequiredArgsConstructor public class TodoController { @@ -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)); @@ -38,4 +41,24 @@ public ResponseEntity> getTodos( public ResponseEntity getTodo(@PathVariable long todoId) { return ResponseEntity.ok(todoService.getTodo(todoId)); } + + @GetMapping("/todos/search") + public Page searchTodo( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int size, + @RequestParam(required = false) String weather, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDate startDate, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDate endDate + ) { + return todoService.searchTodo(page, size, weather, startDate, endDate); + } + + + @GetMapping("/todos/search2") + public Page searchTodos( + @ModelAttribute TodoSearchCondition condition, + Pageable pageable + ) { + return todoService.searchTodos(condition, pageable); + } } diff --git a/src/main/java/org/example/expert/domain/todo/dto/response/TodoSearchCondition.java b/src/main/java/org/example/expert/domain/todo/dto/response/TodoSearchCondition.java new file mode 100644 index 000000000..6ea9cad6d --- /dev/null +++ b/src/main/java/org/example/expert/domain/todo/dto/response/TodoSearchCondition.java @@ -0,0 +1,19 @@ +package org.example.expert.domain.todo.dto.response; + + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDate; + +@Getter +@Setter +@NoArgsConstructor +public class TodoSearchCondition { + + private String title; + private String nickname; + private LocalDate startDate; + private LocalDate endDate; +} diff --git a/src/main/java/org/example/expert/domain/todo/dto/response/TodoSearchDto.java b/src/main/java/org/example/expert/domain/todo/dto/response/TodoSearchDto.java new file mode 100644 index 000000000..cab682ef8 --- /dev/null +++ b/src/main/java/org/example/expert/domain/todo/dto/response/TodoSearchDto.java @@ -0,0 +1,14 @@ +package org.example.expert.domain.todo.dto.response; + + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class TodoSearchDto { + + 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/QTodoRepository.java b/src/main/java/org/example/expert/domain/todo/repository/QTodoRepository.java new file mode 100644 index 000000000..db5251704 --- /dev/null +++ b/src/main/java/org/example/expert/domain/todo/repository/QTodoRepository.java @@ -0,0 +1,18 @@ +package org.example.expert.domain.todo.repository; + +import org.example.expert.domain.todo.dto.response.TodoResponse; +import org.example.expert.domain.todo.dto.response.TodoSearchCondition; +import org.example.expert.domain.todo.dto.response.TodoSearchDto; +import org.example.expert.domain.todo.entity.Todo; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDate; +import java.util.Optional; + +public interface QTodoRepository { + + Optional findByIdWithUser(Long id); + Page findAllByWeatherAndDateRange(String weather, LocalDate startDate, LocalDate endDate, Pageable pageable); + Page searchTodos(TodoSearchCondition condition, Pageable pageable); +} diff --git a/src/main/java/org/example/expert/domain/todo/repository/QTodoRepositoryImpl.java b/src/main/java/org/example/expert/domain/todo/repository/QTodoRepositoryImpl.java new file mode 100644 index 000000000..26c735d77 --- /dev/null +++ b/src/main/java/org/example/expert/domain/todo/repository/QTodoRepositoryImpl.java @@ -0,0 +1,140 @@ +package org.example.expert.domain.todo.repository; + + +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.example.expert.domain.todo.dto.response.TodoResponse; +import org.example.expert.domain.todo.dto.response.TodoSearchCondition; +import org.example.expert.domain.todo.dto.response.TodoSearchDto; +import org.example.expert.domain.todo.entity.Todo; +import org.example.expert.domain.user.dto.response.UserResponse; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import static org.example.expert.domain.comment.entity.QComment.comment; +import static org.example.expert.domain.manager.entity.QManager.manager; +import static org.example.expert.domain.todo.entity.QTodo.todo; +import static org.example.expert.domain.user.entity.QUser.user; + +@Repository +@RequiredArgsConstructor +public class QTodoRepositoryImpl implements QTodoRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public Optional findByIdWithUser(Long id) { + return Optional.ofNullable( + queryFactory.selectFrom(todo) + .leftJoin(todo.user, user).fetchJoin() + .where(todo.id.eq(id)) + .fetchOne()); + } + + @Override + public Page findAllByWeatherAndDateRange(String weather, LocalDate startDate, LocalDate endDate, Pageable pageable) { + BooleanBuilder builder = new BooleanBuilder(); + + if (weather != null) { + builder.and(todo.weather.eq(weather)); + } + if (startDate != null) { + builder.and(todo.modifiedAt.goe(startDate.atStartOfDay())); + } + if (endDate != null) { + builder.and(todo.modifiedAt.loe(endDate.atStartOfDay())); + } + List content = queryFactory + .select(Projections.constructor( + TodoResponse.class, + todo.id, + todo.title, + todo.contents, + todo.weather, + Projections.constructor(UserResponse.class, + todo.user.id, + todo.user.email, + todo.user.nickname + ), + todo.createdAt, + todo.modifiedAt + )) + .from(todo) + .join(todo.user) + .where(builder) + .orderBy(todo.modifiedAt.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + // 2. 전체 개수 쿼리 (count) + Long total = queryFactory + .select(todo.count()) + .from(todo) + .where(builder) + .fetchOne(); + + // 3. Page로 변환 + return new PageImpl<>(content, pageable, total != null ? total : 0); + } + + @Override + public Page searchTodos(TodoSearchCondition condition, Pageable pageable) { + BooleanBuilder builder = new BooleanBuilder(); + + if (StringUtils.hasText(condition.getTitle())) { + builder.and(todo.title.containsIgnoreCase(condition.getTitle())); + } + + if (StringUtils.hasText(condition.getNickname())) { + builder.and(todo.managers.any().user.nickname.containsIgnoreCase(condition.getNickname())); + } + + // 생성일 범위 검색 (createdAt 기준) + if (condition.getStartDate() != null) { + builder.and(todo.createdAt.goe(condition.getStartDate().atStartOfDay())); + } + if (condition.getEndDate() != null) { + builder.and(todo.createdAt.loe(condition.getEndDate().atTime(23, 59, 59))); + } + + List content = queryFactory + .select(Projections.constructor( + TodoSearchDto.class, + todo.title, + manager.countDistinct(), + JPAExpressions.select(comment.count()) + .from(comment) + .where(comment.todo.id.eq(todo.id)) + )) + .from(todo) + .leftJoin(todo.managers, manager) + .where(builder) + .groupBy(todo.id, todo.title) + .orderBy(todo.createdAt.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + // 전체 개수 (for 페이징) + Long total = queryFactory + .select(todo.count()) + .from(todo) + .where(builder) + .fetchOne(); + + return new PageImpl<>(content, pageable, total != null ? total : 0); + } + + +} 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..572693d4b 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 @@ -5,17 +5,11 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import java.util.Optional; - -public interface TodoRepository extends JpaRepository { +public interface TodoRepository extends JpaRepository, QTodoRepository { @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); + } 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..58d4e96f8 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 @@ -5,8 +5,7 @@ import org.example.expert.domain.common.dto.AuthUser; import org.example.expert.domain.common.exception.InvalidRequestException; import org.example.expert.domain.todo.dto.request.TodoSaveRequest; -import org.example.expert.domain.todo.dto.response.TodoResponse; -import org.example.expert.domain.todo.dto.response.TodoSaveResponse; +import org.example.expert.domain.todo.dto.response.*; import org.example.expert.domain.todo.entity.Todo; import org.example.expert.domain.todo.repository.TodoRepository; import org.example.expert.domain.user.dto.response.UserResponse; @@ -17,9 +16,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; + @Service @RequiredArgsConstructor -@Transactional(readOnly = true) +@Transactional public class TodoService { private final TodoRepository todoRepository; @@ -43,11 +44,12 @@ 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) { + Pageable pageable = PageRequest.of(page - 1, size); Page todos = todoRepository.findAllByOrderByModifiedAtDesc(pageable); @@ -57,12 +59,13 @@ public Page getTodos(int page, int size) { 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() )); } + // 쿼리dsl 사용 public TodoResponse getTodo(long todoId) { Todo todo = todoRepository.findByIdWithUser(todoId) .orElseThrow(() -> new InvalidRequestException("Todo not found")); @@ -74,9 +77,32 @@ 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(int page, int size, String weather, LocalDate startDate, LocalDate endDate) { + Pageable pageable = PageRequest.of(page - 1, size); + if (startDate == null) { + startDate = LocalDate.of(1, 1, 1); + } + if (endDate == null) { + endDate = LocalDate.of(9999, 12, 31); + } + Page todos = todoRepository.findAllByWeatherAndDateRange(weather, startDate, endDate, pageable); + + return todos.map(todo -> new TodoResponse( + todo.getId(), todo.getTitle(), todo.getContents(), todo.getWeather(), + new UserResponse(todo.getId(), todo.getUser().getEmail(), todo.getUser().getNickname()), + todo.getCreatedAt(), + todo.getModifiedAt() + )); + } + + + public Page searchTodos(TodoSearchCondition condition, Pageable pageable) { + return todoRepository.searchTodos(condition, pageable); + } } diff --git a/src/main/java/org/example/expert/domain/user/controller/UserAdminController.java b/src/main/java/org/example/expert/domain/user/controller/UserAdminController.java index 53d45c8b5..7be019c62 100644 --- a/src/main/java/org/example/expert/domain/user/controller/UserAdminController.java +++ b/src/main/java/org/example/expert/domain/user/controller/UserAdminController.java @@ -15,7 +15,9 @@ public class UserAdminController { private final UserAdminService userAdminService; @PatchMapping("/admin/users/{userId}") - public void changeUserRole(@PathVariable long userId, @RequestBody UserRoleChangeRequest userRoleChangeRequest) { + public void changeUserRole( + @PathVariable long userId, + @RequestBody UserRoleChangeRequest userRoleChangeRequest) { userAdminService.changeUserRole(userId, userRoleChangeRequest); } } 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..d5403efe1 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 @@ -7,6 +7,7 @@ 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 +22,10 @@ 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..fd71423cf 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 @@ -13,28 +13,33 @@ @Table(name = "users") public class User extends Timestamped { - @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(unique = true) private String email; private String password; @Enumerated(EnumType.STRING) private UserRole userRole; + private String nickname; - public User(String email, String password, UserRole userRole) { + public User(String email, String nickname, String password, UserRole userRole) { this.email = email; + this.nickname = nickname; this.password = password; this.userRole = userRole; } - private User(Long id, String email, UserRole userRole) { + private User(Long id, String email, String nickname, UserRole userRole) { this.id = id; this.email = email; + this.nickname = nickname; this.userRole = userRole; } + public static User fromAuthUser(AuthUser authUser) { - return new User(authUser.getId(), authUser.getEmail(), authUser.getUserRole()); + return new User(authUser.getId(), authUser.getEmail(), authUser.getNickname(), authUser.getUserRole()); } public void changePassword(String password) { 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/test/java/org/example/expert/domain/todo/controller/TodoControllerTest.java b/src/test/java/org/example/expert/domain/todo/controller/TodoControllerTest.java index 737193874..27d1e216e 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 @@ -8,9 +8,9 @@ import org.example.expert.domain.user.entity.User; import org.example.expert.domain.user.enums.UserRole; import org.junit.jupiter.api.Test; +import org.mockito.Mock; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.HttpStatus; import org.springframework.test.web.servlet.MockMvc; @@ -27,7 +27,7 @@ class TodoControllerTest { @Autowired private MockMvc mockMvc; - @MockBean + @Mock private TodoService todoService; @Test @@ -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", "nickname" ,UserRole.USER); User user = User.fromAuthUser(authUser); - UserResponse userResponse = new UserResponse(user.getId(), user.getEmail()); + UserResponse userResponse = new UserResponse(user.getId(), user.getEmail(), user.getNickname()); TodoResponse response = new TodoResponse( todoId, title, @@ -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")); } } diff --git a/src/test/java/org/example/expert/domain/todo/repository/TodoRepositoryTest.java b/src/test/java/org/example/expert/domain/todo/repository/TodoRepositoryTest.java new file mode 100644 index 000000000..c0c3e24b8 --- /dev/null +++ b/src/test/java/org/example/expert/domain/todo/repository/TodoRepositoryTest.java @@ -0,0 +1,54 @@ +package org.example.expert.domain.todo.repository; + +import org.example.expert.config.QueryDslConfig; +import org.example.expert.domain.todo.entity.Todo; +import org.example.expert.domain.user.entity.User; +import org.example.expert.domain.user.enums.UserRole; +import org.example.expert.domain.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +@DataJpaTest +@DisplayName("Repository:Todo") +//@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(QueryDslConfig.class) +class TodoRepositoryTest { + @Autowired + private UserRepository userRepository; + + @Autowired + private TodoRepository todoRepository; + + private User user; + + @BeforeEach + void setUp() { + user = new User("hong-gd@gmail.com", "nickname","password", UserRole.USER); + userRepository.save(user); + + Todo todo = new Todo("todo title", "todo contents", "sunny", user); + todoRepository.save(todo); + } + + @Test + @DisplayName("QueryDsl로 전환한 findByIdWithUser의 동작 검증") + void findByIdWithUser() { + // Given + Long userId = user.getId(); + + // When + Todo todo = todoRepository.findByIdWithUser(userId).orElseThrow(); + + // Then + assertThat(todo).isNotNull(); + } +} \ No newline at end of file