diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 13c85c8..0be114b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -32,30 +32,39 @@ jobs: - name: Grant execute permission for gradlew run: chmod +x gradlew + # --- (1) build 시 테스트 무시: -x test --- - name: Build with Gradle run: ./gradlew build -x test - name: Install sshpass run: sudo apt-get install -y sshpass + # 빌드 산출물(JAR 파일)을 원격 서버로 복사 - name: Copy build artifacts run: | JAR_FILE=$(ls build/libs/*.jar | sort -r | head -n 1) echo "JAR_FILE=$JAR_FILE" >> $GITHUB_ENV - sshpass -p ${{ secrets.SSH_PASSWORD }} scp -o StrictHostKeyChecking=no -r $JAR_FILE ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/home/anhye0n/web/tutorial_sejong/backend/ + sshpass -p ${{ secrets.SSH_PASSWORD }} scp -o StrictHostKeyChecking=no -r "$JAR_FILE" ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/home/anhye0n/web/tutorial_sejong/backend/ + # --- (2) systemd daemon-reload 추가 후 서비스 재시작 --- - name: Restart backend service run: | sshpass -p ${{ secrets.SSH_PASSWORD }} ssh -o StrictHostKeyChecking=no ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << EOF cd /home/anhye0n/web/tutorial_sejong/backend/ git submodule update --init --recursive + CURRENT_PID=\$(lsof -t -i:8080) if [ -n "\$CURRENT_PID" ]; then echo "Stopping process using port 8080 with PID \$CURRENT_PID" echo ${{ secrets.SSH_PASSWORD }} | sudo -S kill -9 \$CURRENT_PID fi + + # systemd 설정 변경/추가 후 재로딩 + echo ${{ secrets.SSH_PASSWORD }} | sudo -S systemctl daemon-reload echo ${{ secrets.SSH_PASSWORD }} | sudo -S systemctl restart tutorial_sejong_backend echo ${{ secrets.SSH_PASSWORD }} | sudo -S systemctl status tutorial_sejong_backend + + # DB 스크립트 실행 (15초 대기 후) sleep 15 mysql -u ${{ secrets.MARIADB_ID }} -p${{ secrets.MARIADB_PASSWORD }} < /home/anhye0n/web/tutorial_sejong/backend/tutorial_sejong.sql EOF diff --git a/.gitignore b/.gitignore index 17d2209..66b7e79 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,5 @@ out/ application*.properties src/main/resources/config + +logs/* \ No newline at end of file diff --git a/build.gradle b/build.gradle index 0c61490..3618269 100644 --- a/build.gradle +++ b/build.gradle @@ -23,11 +23,17 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + implementation 'org.projectlombok:lombok' + implementation 'cn.apiclub.tool:simplecaptcha:1.2.2' + + annotationProcessor 'org.projectlombok:lombok' runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' + testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' testImplementation 'io.rest-assured:rest-assured:5.4.0' diff --git a/src/main/java/com/tutorialsejong/courseregistration/auth/JwtAuthenticationFilter.java b/src/main/java/com/tutorialsejong/courseregistration/auth/JwtAuthenticationFilter.java deleted file mode 100644 index 0e699d4..0000000 --- a/src/main/java/com/tutorialsejong/courseregistration/auth/JwtAuthenticationFilter.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.tutorialsejong.courseregistration.auth; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import java.io.IOException; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; -import org.springframework.util.StringUtils; -import org.springframework.web.filter.OncePerRequestFilter; - -public class JwtAuthenticationFilter extends OncePerRequestFilter { - - private final JwtTokenProvider tokenProvider; - - public JwtAuthenticationFilter(JwtTokenProvider tokenProvider) { - this.tokenProvider = tokenProvider; - } - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) - throws ServletException, IOException { - try { - String jwt = getJwtFromRequest(request); - tokenProvider.validateToken(jwt); - - if (StringUtils.hasText(jwt)) { - Authentication authentication = tokenProvider.getAuthentication(jwt); - - if (authentication instanceof UsernamePasswordAuthenticationToken) { - ((UsernamePasswordAuthenticationToken) authentication) - .setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); - } - - SecurityContextHolder.getContext().setAuthentication(authentication); - } - } catch (Exception ex) { - logger.error("Could not set user authentication in security context", ex); - } - - filterChain.doFilter(request, response); - } - - private String getJwtFromRequest(HttpServletRequest request) { - String bearerToken = request.getHeader("Authorization"); - if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { - return bearerToken.substring(7); - } - return null; - } -} diff --git a/src/main/java/com/tutorialsejong/courseregistration/auth/dto/LoginRequest.java b/src/main/java/com/tutorialsejong/courseregistration/auth/dto/LoginRequest.java deleted file mode 100644 index 1ded328..0000000 --- a/src/main/java/com/tutorialsejong/courseregistration/auth/dto/LoginRequest.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.tutorialsejong.courseregistration.auth.dto; - -import jakarta.validation.constraints.NotBlank; - -public record LoginRequest( - @NotBlank(message = "studentId should not be empty") - String studentId, - - @NotBlank(message = "password should not be empty") - String password -) { -} diff --git a/src/main/java/com/tutorialsejong/courseregistration/auth/dto/LoginResponse.java b/src/main/java/com/tutorialsejong/courseregistration/auth/dto/LoginResponse.java deleted file mode 100644 index f68c197..0000000 --- a/src/main/java/com/tutorialsejong/courseregistration/auth/dto/LoginResponse.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.tutorialsejong.courseregistration.auth.dto; - -public record LoginResponse( - String accessToken, - String username) { -} diff --git a/src/main/java/com/tutorialsejong/courseregistration/auth/dto/MacroResponse.java b/src/main/java/com/tutorialsejong/courseregistration/auth/dto/MacroResponse.java deleted file mode 100644 index febb752..0000000 --- a/src/main/java/com/tutorialsejong/courseregistration/auth/dto/MacroResponse.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.tutorialsejong.courseregistration.auth.dto; - -public class MacroResponse { - private Integer statusCode; - private MacroData data; - - public MacroResponse(Integer statusCode, MacroData data) { - this.statusCode = statusCode; - this.data = data; - } - - public Integer getStatusCode() { - return statusCode; - } - - public void setStatusCode(Integer statusCode) { - this.statusCode = statusCode; - } - - public MacroData getData() { - return data; - } - - public void setData(MacroData data) { - this.data = data; - } - - public static class MacroData { - private String answer; - private String url; - - public MacroData(String answer, String url) { - this.answer = answer; - this.url = url; - } - - public String getAnswer() { - return answer; - } - - public void setAnswer(String answer) { - this.answer = answer; - } - - public String getUrl() { - return url; - } - - public void setUrl(String url) { - this.url = url; - } - } -} diff --git a/src/main/java/com/tutorialsejong/courseregistration/common/config/CaptchaConfig.java b/src/main/java/com/tutorialsejong/courseregistration/common/config/CaptchaConfig.java new file mode 100644 index 0000000..224c291 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/common/config/CaptchaConfig.java @@ -0,0 +1,31 @@ +package com.tutorialsejong.courseregistration.common.config; + +import cn.apiclub.captcha.backgrounds.BackgroundProducer; +import cn.apiclub.captcha.backgrounds.GradiatedBackgroundProducer; +import cn.apiclub.captcha.text.renderer.DefaultWordRenderer; +import cn.apiclub.captcha.text.renderer.WordRenderer; +import java.awt.Color; +import java.awt.Font; +import java.util.List; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class CaptchaConfig { + + @Bean + public WordRenderer wordRenderer() { + return new DefaultWordRenderer( + List.of(new Color(0, 0, 0)), + List.of(new Font("Helvetica", Font.PLAIN, 60)) + ); + } + + @Bean + public BackgroundProducer backgroundProducer() { + GradiatedBackgroundProducer producer = new GradiatedBackgroundProducer(); + producer.setFromColor(new Color(100, 100, 100)); + producer.setToColor(new Color(180, 180, 180)); + return producer; + } +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/common/config/JacksonConfig.java b/src/main/java/com/tutorialsejong/courseregistration/common/config/JacksonConfig.java new file mode 100644 index 0000000..0667ca5 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/common/config/JacksonConfig.java @@ -0,0 +1,14 @@ +package com.tutorialsejong.courseregistration.common.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JacksonConfig { + + @Bean + public ObjectMapper objectMapper() { + return new ObjectMapper(); + } +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/common/config/SecurityConfig.java b/src/main/java/com/tutorialsejong/courseregistration/common/config/SecurityConfig.java index 211667f..4ed2687 100644 --- a/src/main/java/com/tutorialsejong/courseregistration/common/config/SecurityConfig.java +++ b/src/main/java/com/tutorialsejong/courseregistration/common/config/SecurityConfig.java @@ -1,68 +1,119 @@ package com.tutorialsejong.courseregistration.common.config; -import com.tutorialsejong.courseregistration.auth.JwtAuthenticationFilter; -import com.tutorialsejong.courseregistration.auth.JwtTokenProvider; +import com.tutorialsejong.courseregistration.common.security.JwtAuthenticationEntryPoint; +import com.tutorialsejong.courseregistration.common.security.JwtAuthenticationFilter; +import com.tutorialsejong.courseregistration.common.security.JwtExceptionFilter; +import com.tutorialsejong.courseregistration.common.security.JwtTokenProvider; +import com.tutorialsejong.courseregistration.common.security.SwaggerAccessDeniedHandler; +import com.tutorialsejong.courseregistration.common.security.SwaggerAuthenticationEntryPoint; import java.util.Arrays; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; 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.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.cors.CorsConfiguration; +@RequiredArgsConstructor @Configuration @EnableWebSecurity public class SecurityConfig { + @Value("${security.cors.allowed-origins}") + private String[] allowedOrigins; + + @Value("${security.cors.allow-credentials}") + private boolean allowCredentials; + + @Value("${app.swagger.user.name}") + private String swaggerUsername; + + @Value("${app.swagger.user.password}") + private String swaggerPassword; + private final JwtTokenProvider tokenProvider; - public SecurityConfig(JwtTokenProvider tokenProvider) { - this.tokenProvider = tokenProvider; + @Bean + @Order(1) + public SecurityFilterChain swaggerFilterChain(HttpSecurity http, PasswordEncoder passwordEncoder) throws Exception { + // 1) Swagger용 인메모리 사용자 생성 + UserDetails admin = User.withUsername(swaggerUsername) + .password(passwordEncoder.encode(swaggerPassword)) + .roles("ADMIN") + .build(); + + InMemoryUserDetailsManager swaggerUserManager = new InMemoryUserDetailsManager(admin); + + // 2) Swagger 경로에 대해서만 Basic Auth 설정 + http + .securityMatcher("/docs/swagger-ui/**", "/v3/api-docs/**") + .csrf(AbstractHttpConfigurer::disable) + .httpBasic(Customizer.withDefaults()) + .userDetailsService(swaggerUserManager) + .authorizeHttpRequests(authorize -> authorize + .anyRequest().authenticated() + ) + .exceptionHandling(exceptionHandling -> exceptionHandling + .authenticationEntryPoint(new SwaggerAuthenticationEntryPoint()) // 401 + .accessDeniedHandler(new SwaggerAccessDeniedHandler()) // 403 + ); + + return http.build(); } @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + @Order(2) + public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) .headers(headers -> headers - .contentSecurityPolicy(csp -> csp - .policyDirectives("frame-ancestors 'self'")) + .contentSecurityPolicy(csp -> csp.policyDirectives("frame-ancestors 'self'")) ) .cors(cors -> cors .configurationSource(request -> { CorsConfiguration config = new CorsConfiguration(); - config.setAllowedOrigins(Arrays.asList("http://localhost:3000")); // 변경 + config.setAllowedOrigins(Arrays.asList(allowedOrigins)); + config.setAllowCredentials(allowCredentials); config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); config.setAllowedHeaders(Arrays.asList("*")); - config.setAllowCredentials(true); return config; }) ) - .sessionManagement(session -> session - .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers( "/api/auth/login", - "/api/auth/register", - "/api/auth/refresh" + "/api/auth/refresh", + "/api/auth/withdrawal/**" ).permitAll() .anyRequest().authenticated() ) - .addFilterBefore(new JwtAuthenticationFilter(tokenProvider), - UsernamePasswordAuthenticationFilter.class); - + // JWT 기반 인증 필터 적용 + .exceptionHandling(exceptionHandling -> exceptionHandling + .authenticationEntryPoint(new JwtAuthenticationEntryPoint()) + ) + .addFilterBefore(new JwtAuthenticationFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(new JwtExceptionFilter(), JwtAuthenticationFilter.class); return http.build(); } @Bean - public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { + public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) + throws Exception { return authenticationConfiguration.getAuthenticationManager(); } diff --git a/src/main/java/com/tutorialsejong/courseregistration/common/config/SpringDocConfig.java b/src/main/java/com/tutorialsejong/courseregistration/common/config/SpringDocConfig.java new file mode 100644 index 0000000..9f3ce2f --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/common/config/SpringDocConfig.java @@ -0,0 +1,36 @@ +package com.tutorialsejong.courseregistration.common.config; + +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.security.SecurityScheme; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.servers.Server; +import java.util.List; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@SecurityScheme( + name = "BearerAuth", + type = SecuritySchemeType.HTTP, + scheme = "bearer", + bearerFormat = "JWT" +) +public class SpringDocConfig { + + @Bean + public OpenAPI customOpenAPI() { + return new OpenAPI() + .servers(List.of( + new Server().url("https://tutorial-sejong.com").description("Production Server"), + new Server().url("http://dev-tutorial-sejong.o-r.kr:8090").description("Test Server"), + new Server().url("http://localhost:8080").description("Local Server") + )) + // API 정보 + .info(new Info() + .title("Tutorial Sejong API") + .version("v2.0.0") + .description("Tutorial Sejong API API") + ); + } +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/common/exception/AlreadyRegisteredException.java b/src/main/java/com/tutorialsejong/courseregistration/common/exception/AlreadyRegisteredException.java deleted file mode 100644 index 365edfa..0000000 --- a/src/main/java/com/tutorialsejong/courseregistration/common/exception/AlreadyRegisteredException.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.tutorialsejong.courseregistration.common.exception; - -public class AlreadyRegisteredException extends RuntimeException { - public AlreadyRegisteredException(String message) { - super(message); - } -} diff --git a/src/main/java/com/tutorialsejong/courseregistration/common/exception/BusinessException.java b/src/main/java/com/tutorialsejong/courseregistration/common/exception/BusinessException.java new file mode 100644 index 0000000..9be2936 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/common/exception/BusinessException.java @@ -0,0 +1,22 @@ +package com.tutorialsejong.courseregistration.common.exception; + +import com.tutorialsejong.courseregistration.domain.auth.controller.AuthController; +import lombok.Getter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Getter +public class BusinessException extends RuntimeException { + + private final ErrorCode errorCode; + + public BusinessException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public BusinessException(ErrorCode errorCode, Throwable cause) { + super(errorCode.getMessage(), cause); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/common/exception/CheckUserException.java b/src/main/java/com/tutorialsejong/courseregistration/common/exception/CheckUserException.java deleted file mode 100644 index ba66a9c..0000000 --- a/src/main/java/com/tutorialsejong/courseregistration/common/exception/CheckUserException.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.tutorialsejong.courseregistration.common.exception; - -public class CheckUserException extends RuntimeException{ - public CheckUserException(String message) { - super(message); - } -} diff --git a/src/main/java/com/tutorialsejong/courseregistration/common/exception/CourseNotRegisteredException.java b/src/main/java/com/tutorialsejong/courseregistration/common/exception/CourseNotRegisteredException.java deleted file mode 100644 index dc3310e..0000000 --- a/src/main/java/com/tutorialsejong/courseregistration/common/exception/CourseNotRegisteredException.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.tutorialsejong.courseregistration.common.exception; - -public class CourseNotRegisteredException extends RuntimeException { - public CourseNotRegisteredException(String message) { - super(message); - } -} diff --git a/src/main/java/com/tutorialsejong/courseregistration/common/exception/ErrorCode.java b/src/main/java/com/tutorialsejong/courseregistration/common/exception/ErrorCode.java new file mode 100644 index 0000000..baa80c1 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/common/exception/ErrorCode.java @@ -0,0 +1,16 @@ +package com.tutorialsejong.courseregistration.common.exception; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonIgnore; +import org.springframework.http.HttpStatus; + +@JsonFormat(shape = JsonFormat.Shape.OBJECT) +public interface ErrorCode { + + String getCode(); + + String getMessage(); + + @JsonIgnore + HttpStatus getStatus(); +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/common/exception/ErrorResponse.java b/src/main/java/com/tutorialsejong/courseregistration/common/exception/ErrorResponse.java new file mode 100644 index 0000000..07c8697 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/common/exception/ErrorResponse.java @@ -0,0 +1,47 @@ +package com.tutorialsejong.courseregistration.common.exception; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import com.tutorialsejong.courseregistration.common.utils.JsonUtils; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; + +public record ErrorResponse( + @JsonUnwrapped + ErrorCode errorCode, + + @JsonInclude(Include.NON_EMPTY) + List invalidParams +) { + + public static ErrorResponse from(ErrorCode errorCode) { + return new ErrorResponse(errorCode, null); + } + + public static ErrorResponse of(ErrorCode errorCode, List invalidParams) { + return new ErrorResponse(errorCode, invalidParams); + } + + public ResponseEntity toResponseEntity() { + return ResponseEntity.status(errorCode.getStatus()).body(this); + } + + public void writeTo(HttpServletResponse response) throws IOException { + response.setStatus(errorCode.getStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(JsonUtils.toJson(this)); + } + + public record InvalidParam(String name, String reason) { + + public static InvalidParam from(FieldError fieldError) { + return new InvalidParam(fieldError.getField(), fieldError.getDefaultMessage()); + } + } +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/common/exception/GlobalErrorCode.java b/src/main/java/com/tutorialsejong/courseregistration/common/exception/GlobalErrorCode.java new file mode 100644 index 0000000..e6b1191 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/common/exception/GlobalErrorCode.java @@ -0,0 +1,22 @@ +package com.tutorialsejong.courseregistration.common.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum GlobalErrorCode implements ErrorCode { + + INTERNAL_SERVER_ERROR("G001", "서버 내부 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + INVALID_INPUT_VALUE("G002", "잘못된 입력값입니다.", HttpStatus.BAD_REQUEST), + RESOURCE_NOT_FOUND("G003", "요청한 리소스를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), + METHOD_NOT_ALLOWED("G004", "허용되지 않는 메서드입니다.", HttpStatus.METHOD_NOT_ALLOWED), + HANDLE_ACCESS_DENIED("G005", "접근이 거부되었습니다.", HttpStatus.FORBIDDEN), + UNAUTHORIZED("G006", "인증되지 않은 사용자입니다.", HttpStatus.UNAUTHORIZED), + TOO_MANY_REQUESTS("G007", "과도한 요청을 보내셨습니다. 잠시 기다려 주세요.", HttpStatus.TOO_MANY_REQUESTS); + + private final String code; + private final String message; + private final HttpStatus status; +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/common/exception/GlobalExceptionHandler.java b/src/main/java/com/tutorialsejong/courseregistration/common/exception/GlobalExceptionHandler.java index 31b72fa..3137336 100644 --- a/src/main/java/com/tutorialsejong/courseregistration/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/tutorialsejong/courseregistration/common/exception/GlobalExceptionHandler.java @@ -1,43 +1,40 @@ package com.tutorialsejong.courseregistration.common.exception; -import io.jsonwebtoken.ExpiredJwtException; - import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.stream.Collectors; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.context.support.DefaultMessageSourceResolvable; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseEntity; -import org.springframework.security.authentication.BadCredentialsException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; @RestControllerAdvice -public class GlobalExceptionHandler { - - private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); +public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { - @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity handleValidationExceptions(MethodArgumentNotValidException ex) { - List errors = ex.getBindingResult().getAllErrors().stream() - .map(DefaultMessageSourceResolvable::getDefaultMessage) - .collect(Collectors.toList()); - Map body = new HashMap<>(); - body.put("message", errors); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body); + @ExceptionHandler(BusinessException.class) + public ResponseEntity handleBusinessException(BusinessException ex) { + return ErrorResponse.from(ex.getErrorCode()).toResponseEntity(); } - @ExceptionHandler(BadCredentialsException.class) - public ResponseEntity handleBadCredentialsException(BadCredentialsException ex) { - Map body = new HashMap<>(); - body.put("message", "올바르지 않은 비밀번호입니다!"); - return ResponseEntity.status(HttpStatus.NOT_ACCEPTABLE).body(body); + @Override + protected ResponseEntity handleMethodArgumentNotValid( + MethodArgumentNotValidException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request) { + List invalidParams = ex.getBindingResult() + .getFieldErrors() + .stream() + .map(ErrorResponse.InvalidParam::from) + .toList(); + + return ErrorResponse.of(GlobalErrorCode.INVALID_INPUT_VALUE, invalidParams).toResponseEntity(); } @ExceptionHandler(IllegalArgumentException.class) @@ -47,45 +44,8 @@ public ResponseEntity handleIllegalArgumentException(IllegalArgumentException return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body); } - @ExceptionHandler(JwtAuthenticationException.class) - public ResponseEntity handleJwtAuthenticationException(JwtAuthenticationException ex) { - logger.error("JWT authentication error: {}", ex.getMessage()); - Map body = new HashMap<>(); - if (ex.getCause() instanceof ExpiredJwtException) { - body.put("message", Collections.singletonList("토큰이 만료되었습니다.")); - } else { - body.put("message", Collections.singletonList("유효하지 않은 토큰입니다.")); - } - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(body); - } - - @ExceptionHandler(CheckUserException.class) - public ResponseEntity handleCheckUserException(CheckUserException ex) { - - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ex.getMessage()); - } - - @ExceptionHandler(AlreadyRegisteredException.class) - public ResponseEntity handleAlreadyRegisteredException(AlreadyRegisteredException ex) { - return ResponseEntity.status(HttpStatus.CONFLICT).body(ex.getMessage()); - } - - @ExceptionHandler(CourseNotRegisteredException.class) - public ResponseEntity handleCourseNotRegisteredException(CourseNotRegisteredException ex) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ex.getMessage()); - } - @ExceptionHandler(Exception.class) - public ResponseEntity handleGenericException(Exception ex) { - Map body = new HashMap<>(); - body.put("message", "An unexpected error occurred"); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body); - } - - @ExceptionHandler(NotFoundException.class) - public ResponseEntity handleNotFoundException(NotFoundException ex) { - Map body = new HashMap<>(); - body.put("message", ex.getMessage()); - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body); + public ResponseEntity handleGenericException(Exception ex) { + return ErrorResponse.from(GlobalErrorCode.INTERNAL_SERVER_ERROR).toResponseEntity(); } } diff --git a/src/main/java/com/tutorialsejong/courseregistration/common/exception/JwtAuthenticationException.java b/src/main/java/com/tutorialsejong/courseregistration/common/exception/JwtAuthenticationException.java deleted file mode 100644 index e734ea1..0000000 --- a/src/main/java/com/tutorialsejong/courseregistration/common/exception/JwtAuthenticationException.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.tutorialsejong.courseregistration.common.exception; - -public class JwtAuthenticationException extends RuntimeException { - public JwtAuthenticationException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/src/main/java/com/tutorialsejong/courseregistration/common/exception/NotFoundException.java b/src/main/java/com/tutorialsejong/courseregistration/common/exception/NotFoundException.java deleted file mode 100644 index 2a76861..0000000 --- a/src/main/java/com/tutorialsejong/courseregistration/common/exception/NotFoundException.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.tutorialsejong.courseregistration.common.exception; - -public class NotFoundException extends RuntimeException { - public NotFoundException(String message) { - super(message); - } -} diff --git a/src/main/java/com/tutorialsejong/courseregistration/common/security/JwtAuthenticationEntryPoint.java b/src/main/java/com/tutorialsejong/courseregistration/common/security/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..b474003 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/common/security/JwtAuthenticationEntryPoint.java @@ -0,0 +1,21 @@ +package com.tutorialsejong.courseregistration.common.security; + +import com.tutorialsejong.courseregistration.common.exception.ErrorResponse; +import com.tutorialsejong.courseregistration.common.security.exception.SecurityErrorCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException { + ErrorResponse.from(SecurityErrorCode.AUTHENTICATION_FAILED) + .writeTo(response); + } +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/common/security/JwtAuthenticationFilter.java b/src/main/java/com/tutorialsejong/courseregistration/common/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..39a4fac --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/common/security/JwtAuthenticationFilter.java @@ -0,0 +1,62 @@ +package com.tutorialsejong.courseregistration.common.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + private static final int BEARER_PREFIX_LENGTH = BEARER_PREFIX.length(); + + private final JwtTokenProvider tokenProvider; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + extractJwtFromRequest(request) + .ifPresent(jwt -> processJwtAuthentication(jwt, request)); + + filterChain.doFilter(request, response); + } + + private Optional extractJwtFromRequest(HttpServletRequest request) { + String bearerToken = request.getHeader(AUTHORIZATION_HEADER); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) { + return Optional.of(bearerToken.substring(BEARER_PREFIX_LENGTH)); + } + return Optional.empty(); + } + + private void processJwtAuthentication(String jwt, HttpServletRequest request) { + tokenProvider.validateToken(jwt); + Authentication authentication = tokenProvider.getAuthentication(jwt); + enhanceAuthenticationWithRequestDetails(authentication, request); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + private void enhanceAuthenticationWithRequestDetails(Authentication authentication, HttpServletRequest request) { + if (authentication instanceof UsernamePasswordAuthenticationToken) { + ((UsernamePasswordAuthenticationToken) authentication) + .setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + } + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getRequestURI(); + return path.equals("/api/auth/login") || path.equals("/api/auth/refresh") || path.equals("/api/auth/withdraw"); + } +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/common/security/JwtExceptionFilter.java b/src/main/java/com/tutorialsejong/courseregistration/common/security/JwtExceptionFilter.java new file mode 100644 index 0000000..4c1c97f --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/common/security/JwtExceptionFilter.java @@ -0,0 +1,24 @@ +package com.tutorialsejong.courseregistration.common.security; + +import com.tutorialsejong.courseregistration.common.exception.ErrorResponse; +import com.tutorialsejong.courseregistration.common.security.exception.JwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.web.filter.OncePerRequestFilter; + +public class JwtExceptionFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + try { + filterChain.doFilter(request, response); + } catch (JwtException e) { + ErrorResponse.from(e.getErrorCode()) + .writeTo(response); + } + } +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/auth/JwtTokenProvider.java b/src/main/java/com/tutorialsejong/courseregistration/common/security/JwtTokenProvider.java similarity index 66% rename from src/main/java/com/tutorialsejong/courseregistration/auth/JwtTokenProvider.java rename to src/main/java/com/tutorialsejong/courseregistration/common/security/JwtTokenProvider.java index ad0147c..22b4b51 100644 --- a/src/main/java/com/tutorialsejong/courseregistration/auth/JwtTokenProvider.java +++ b/src/main/java/com/tutorialsejong/courseregistration/common/security/JwtTokenProvider.java @@ -1,7 +1,12 @@ -package com.tutorialsejong.courseregistration.auth; +package com.tutorialsejong.courseregistration.common.security; -import com.tutorialsejong.courseregistration.auth.service.CustomUserDetailsService; -import com.tutorialsejong.courseregistration.common.exception.JwtAuthenticationException; +import com.tutorialsejong.courseregistration.common.security.exception.JwtTokenExpiredException; +import com.tutorialsejong.courseregistration.common.security.exception.JwtTokenInvalidException; +import com.tutorialsejong.courseregistration.common.utils.log.LogAction; +import com.tutorialsejong.courseregistration.common.utils.log.LogMessage; +import com.tutorialsejong.courseregistration.common.utils.log.LogReason; +import com.tutorialsejong.courseregistration.common.utils.log.LogResult; +import com.tutorialsejong.courseregistration.domain.auth.service.CustomUserDetailsService; import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jwts; @@ -10,6 +15,8 @@ import io.jsonwebtoken.security.Keys; import java.security.Key; import java.util.Date; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; @@ -18,6 +25,8 @@ @Component public class JwtTokenProvider { + private static final Logger logger = LoggerFactory.getLogger(JwtTokenProvider.class); + private final Key key; private final int accessTokenExpirationInMs; private final int refreshTokenExpirationInMs; @@ -57,9 +66,6 @@ private String generateToken(String username, int expirationInMs) { } public String generateAccessTokenFromUsername(String username) { - Date now = new Date(); - Date expiryDate = new Date(now.getTime() + accessTokenExpirationInMs); - return generateToken(username, accessTokenExpirationInMs); } @@ -74,15 +80,27 @@ public String getUsernameFromJWT(String token) { return claims.getSubject(); } - public void validateToken(String authToken) { + public void validateToken(String token) { try { - Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(authToken); - } catch (MalformedJwtException | UnsupportedJwtException ex) { - throw new JwtAuthenticationException("Invalid JWT token", ex); + Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); } catch (ExpiredJwtException ex) { - throw new JwtAuthenticationException("Expired JWT token", ex); - } catch (IllegalArgumentException ex) { - throw new JwtAuthenticationException("JWT claims string is empty", ex); + String username = ex.getClaims().getSubject(); + logger.warn(LogMessage.builder() + .action(LogAction.VALIDATE_TOKEN) + .subject("s" + username) + .result(LogResult.FAIL) + .reason(LogReason.EXPIRED) + .build().toString()); + throw new JwtTokenExpiredException(); + } catch (MalformedJwtException | UnsupportedJwtException | IllegalArgumentException ex) { + String username = getUsernameFromJWT(token); + logger.warn(LogMessage.builder() + .action(LogAction.VALIDATE_TOKEN) + .subject("s" + username) + .result(LogResult.FAIL) + .reason(LogReason.INVALID_CREDENTIAL) + .build().toString()); + throw new JwtTokenInvalidException(); } } @@ -91,8 +109,4 @@ public Authentication getAuthentication(String token) { UserDetails userDetails = customUserDetailsService.loadUserByUsername(username); return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); } - - public int getRefreshTokenExpirationInSeconds() { - return refreshTokenExpirationInMs / 1000; - } } diff --git a/src/main/java/com/tutorialsejong/courseregistration/common/security/SwaggerAccessDeniedHandler.java b/src/main/java/com/tutorialsejong/courseregistration/common/security/SwaggerAccessDeniedHandler.java new file mode 100644 index 0000000..db0e2b6 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/common/security/SwaggerAccessDeniedHandler.java @@ -0,0 +1,19 @@ +package com.tutorialsejong.courseregistration.common.security; + +import com.tutorialsejong.courseregistration.common.exception.ErrorResponse; +import com.tutorialsejong.courseregistration.common.exception.GlobalErrorCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; + +public class SwaggerAccessDeniedHandler implements AccessDeniedHandler { + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException { + ErrorResponse.from(GlobalErrorCode.HANDLE_ACCESS_DENIED) + .writeTo(response); + } +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/common/security/SwaggerAuthenticationEntryPoint.java b/src/main/java/com/tutorialsejong/courseregistration/common/security/SwaggerAuthenticationEntryPoint.java new file mode 100644 index 0000000..542d09f --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/common/security/SwaggerAuthenticationEntryPoint.java @@ -0,0 +1,20 @@ +package com.tutorialsejong.courseregistration.common.security; + +import com.tutorialsejong.courseregistration.common.exception.ErrorResponse; +import com.tutorialsejong.courseregistration.common.exception.GlobalErrorCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; + +public class SwaggerAuthenticationEntryPoint implements AuthenticationEntryPoint { + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException { + response.addHeader("WWW-Authenticate", "Basic realm=\"Tutorial Sejong Swagger\""); + ErrorResponse.from(GlobalErrorCode.UNAUTHORIZED) + .writeTo(response); + } +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/common/security/exception/JwtException.java b/src/main/java/com/tutorialsejong/courseregistration/common/security/exception/JwtException.java new file mode 100644 index 0000000..4eb7588 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/common/security/exception/JwtException.java @@ -0,0 +1,21 @@ +package com.tutorialsejong.courseregistration.common.security.exception; + +import com.tutorialsejong.courseregistration.common.exception.ErrorCode; +import lombok.Getter; +import org.springframework.security.core.AuthenticationException; + +@Getter +public class JwtException extends AuthenticationException { + + private final ErrorCode errorCode; + + public JwtException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public JwtException(ErrorCode errorCode, Throwable cause) { + super(errorCode.getMessage(), cause); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/common/security/exception/JwtTokenExpiredException.java b/src/main/java/com/tutorialsejong/courseregistration/common/security/exception/JwtTokenExpiredException.java new file mode 100644 index 0000000..17273ba --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/common/security/exception/JwtTokenExpiredException.java @@ -0,0 +1,8 @@ +package com.tutorialsejong.courseregistration.common.security.exception; + +public class JwtTokenExpiredException extends JwtException { + + public JwtTokenExpiredException() { + super(SecurityErrorCode.JWT_TOKEN_EXPIRED); + } +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/common/security/exception/JwtTokenInvalidException.java b/src/main/java/com/tutorialsejong/courseregistration/common/security/exception/JwtTokenInvalidException.java new file mode 100644 index 0000000..83d3553 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/common/security/exception/JwtTokenInvalidException.java @@ -0,0 +1,8 @@ +package com.tutorialsejong.courseregistration.common.security.exception; + +public class JwtTokenInvalidException extends JwtException { + + public JwtTokenInvalidException() { + super(SecurityErrorCode.JWT_TOKEN_INVALID); + } +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/common/security/exception/SecurityErrorCode.java b/src/main/java/com/tutorialsejong/courseregistration/common/security/exception/SecurityErrorCode.java new file mode 100644 index 0000000..cb61ee5 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/common/security/exception/SecurityErrorCode.java @@ -0,0 +1,20 @@ +package com.tutorialsejong.courseregistration.common.security.exception; + +import com.tutorialsejong.courseregistration.common.exception.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum SecurityErrorCode implements ErrorCode { + + AUTHENTICATION_FAILED("A001", "인증에 실패했습니다.", HttpStatus.UNAUTHORIZED), + JWT_TOKEN_EXPIRED("A002", "토큰이 만료되었습니다.", HttpStatus.UNAUTHORIZED), + JWT_TOKEN_INVALID("A003", "유효하지 않은 토큰입니다.", HttpStatus.UNAUTHORIZED), + ; + + private final String code; + private final String message; + private final HttpStatus status; +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/common/utils/JsonUtils.java b/src/main/java/com/tutorialsejong/courseregistration/common/utils/JsonUtils.java new file mode 100644 index 0000000..b809ef0 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/common/utils/JsonUtils.java @@ -0,0 +1,21 @@ +package com.tutorialsejong.courseregistration.common.utils; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class JsonUtils { + + private static ObjectMapper objectMapper; + + @Autowired + public void setObjectMapper(ObjectMapper objectMapper) { + JsonUtils.objectMapper = objectMapper; + } + + public static String toJson(Object object) throws JsonProcessingException { + return objectMapper.writeValueAsString(object); + } +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/common/utils/log/LogAction.java b/src/main/java/com/tutorialsejong/courseregistration/common/utils/log/LogAction.java new file mode 100644 index 0000000..311239f --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/common/utils/log/LogAction.java @@ -0,0 +1,15 @@ +package com.tutorialsejong.courseregistration.common.utils.log; + +public enum LogAction { + LOGIN, + LOGOUT, + VALIDATE_USER, + VALIDATE_COURSE, + VALIDATE_TOKEN, + REFRESH_TOKEN, + WITHDRAWAL, + FETCH_REGISTERED_COURSES, + REGISTER_COURSE, + ADD_WISHLIST, + REMOVE_WISHLIST +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/common/utils/log/LogMessage.java b/src/main/java/com/tutorialsejong/courseregistration/common/utils/log/LogMessage.java new file mode 100644 index 0000000..09daa71 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/common/utils/log/LogMessage.java @@ -0,0 +1,101 @@ +package com.tutorialsejong.courseregistration.common.utils.log; + +public class LogMessage { + private LogAction action; // 예: LOGIN, LOGOUT + private String subject; // 예: "Student(20230001)" + private String objectName; // 예: "Course(CS101)" 등 + private LogResult result; // 예: SUCCESS, FAIL + private LogReason reason; // 예: INVALID_CREDENTIAL + private String extras; // 예: "IP:192.168.0.10, UA:Mozilla/5.0" 등 + + // private 생성자 (Builder를 통해서만 객체 생성) + private LogMessage(LogAction action, + String subject, + String objectName, + LogResult result, + LogReason reason, + String extras) { + this.action = action; + this.subject = subject; + this.objectName = objectName; + this.result = result; + this.reason = reason; + this.extras = extras; + } + + // 빌더로 LogMessage 생성 + public static LogMessageBuilder builder() { + return new LogMessageBuilder(); + } + + // 최종적으로 로그 문자열로 출력될 형태 + // ACTION=액션 | SUBJECT=주체 | OBJECT=대상 | RESULT=결과 | REASON=원인 | EXTRAS=추가정보 + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + + if (action != null) { + sb.append("").append(action.name()); + } + if (subject != null) { + sb.append(" | ").append(subject); + } + if (objectName != null) { + sb.append(" | ").append(objectName); + } + if (result != null) { + sb.append(" | ").append(result.name()); + } + if (reason != null) { + sb.append(" | ").append(reason.name()); + } + if (extras != null && !extras.isEmpty()) { + sb.append(" | ").append(extras); + } + return sb.toString(); + } + + // 빌더 내부 클래스 + public static class LogMessageBuilder { + private LogAction action; + private String subject; + private String objectName; + private LogResult result; + private LogReason reason; + private String extras; + + public LogMessageBuilder action(LogAction action) { + this.action = action; + return this; + } + + public LogMessageBuilder subject(String subject) { + this.subject = subject; + return this; + } + + public LogMessageBuilder objectName(String objectName) { + this.objectName = objectName; + return this; + } + + public LogMessageBuilder result(LogResult result) { + this.result = result; + return this; + } + + public LogMessageBuilder reason(LogReason reason) { + this.reason = reason; + return this; + } + + public LogMessageBuilder extras(String extras) { + this.extras = extras; + return this; + } + + public LogMessage build() { + return new LogMessage(action, subject, objectName, result, reason, extras); + } + } +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/common/utils/log/LogReason.java b/src/main/java/com/tutorialsejong/courseregistration/common/utils/log/LogReason.java new file mode 100644 index 0000000..64a2d5e --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/common/utils/log/LogReason.java @@ -0,0 +1,10 @@ +package com.tutorialsejong.courseregistration.common.utils.log; + +public enum LogReason { + INVALID_CREDENTIAL, + NOT_FOUND, + TIMEOUT, + UNKNOWN, + EXPIRED, + ALREADY_EXIST +} \ No newline at end of file diff --git a/src/main/java/com/tutorialsejong/courseregistration/common/utils/log/LogResult.java b/src/main/java/com/tutorialsejong/courseregistration/common/utils/log/LogResult.java new file mode 100644 index 0000000..76eb74e --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/common/utils/log/LogResult.java @@ -0,0 +1,7 @@ +package com.tutorialsejong.courseregistration.common.utils.log; + +public enum LogResult { + SUCCESS, + FAIL, + ERROR +} \ No newline at end of file diff --git a/src/main/java/com/tutorialsejong/courseregistration/auth/controller/AuthController.java b/src/main/java/com/tutorialsejong/courseregistration/domain/auth/controller/AuthController.java similarity index 55% rename from src/main/java/com/tutorialsejong/courseregistration/auth/controller/AuthController.java rename to src/main/java/com/tutorialsejong/courseregistration/domain/auth/controller/AuthController.java index c6185c5..7e9df7a 100644 --- a/src/main/java/com/tutorialsejong/courseregistration/auth/controller/AuthController.java +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/auth/controller/AuthController.java @@ -1,51 +1,55 @@ -package com.tutorialsejong.courseregistration.auth.controller; - -import com.tutorialsejong.courseregistration.auth.dto.AuthenticationResult; -import com.tutorialsejong.courseregistration.auth.dto.JwtTokens; -import com.tutorialsejong.courseregistration.auth.dto.LoginRequest; -import com.tutorialsejong.courseregistration.auth.dto.LoginResponse; -import com.tutorialsejong.courseregistration.auth.dto.MacroResponse; -import com.tutorialsejong.courseregistration.auth.service.AuthService; +package com.tutorialsejong.courseregistration.domain.auth.controller; + +import com.tutorialsejong.courseregistration.domain.auth.dto.AuthenticationResult; +import com.tutorialsejong.courseregistration.domain.auth.dto.CaptchaResult; +import com.tutorialsejong.courseregistration.domain.auth.dto.JwtTokens; +import com.tutorialsejong.courseregistration.domain.auth.dto.LoginRequest; +import com.tutorialsejong.courseregistration.domain.auth.dto.LoginResponse; +import com.tutorialsejong.courseregistration.domain.auth.dto.MacroResponse; +import com.tutorialsejong.courseregistration.domain.auth.service.AuthService; +import com.tutorialsejong.courseregistration.domain.auth.service.CaptchaService; +import com.tutorialsejong.courseregistration.domain.auth.swagger.LoginOperation; +import com.tutorialsejong.courseregistration.domain.auth.swagger.MacroOperation; +import com.tutorialsejong.courseregistration.domain.auth.swagger.RefreshOperation; +import com.tutorialsejong.courseregistration.domain.auth.swagger.WithdrawalOperation; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import java.time.Duration; -import java.util.Arrays; import java.util.HashMap; -import java.util.List; import java.util.Map; -import java.util.Random; +import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController +@RequiredArgsConstructor @RequestMapping("/api/auth") +@Tag(name = "인증 API", description = "인증 관련 API") public class AuthController { + private static final String REFRESH_TOKEN_COOKIE_NAME = "refreshToken"; private static final String COOKIE_PATH = "/"; - private static final List MACRO_ANSWERS = Arrays.asList( - 1208, 2154, 2509, 2857, 3086, 3458, 3511, 3803, - 4613, 4139, 5106, 5802, 5648, 6352, 7086, 7414, - 8415, 8594, 9468, 9102 - ); private final AuthService authService; + private final CaptchaService captchaService; @Value("${app.jwt.refreshTokenExpirationInMs}") private int refreshTokenExpirationInMs; - public AuthController(AuthService authService) { - this.authService = authService; - } - + @LoginOperation @PostMapping("/login") public ResponseEntity login(@RequestBody @Valid LoginRequest loginRequest, HttpServletResponse response) { AuthenticationResult authResult = authService.loginOrSignup(loginRequest); @@ -57,11 +61,11 @@ public ResponseEntity login(@RequestBody @Valid LoginRequest loginRequest, Ht return ResponseEntity.ok(loginResponse); } - + @RefreshOperation @PostMapping("/refresh") public ResponseEntity refreshToken(@CookieValue(name = "refreshToken", required = false) String refreshToken) { if (refreshToken == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Refresh token is missing"); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Refresh token is missing"); } JwtTokens jwtTokens = authService.refreshAccessToken(refreshToken); @@ -70,10 +74,20 @@ public ResponseEntity refreshToken(@CookieValue(name = "refreshToken", requir return ResponseEntity.ok().body(body); } + @WithdrawalOperation + @DeleteMapping("/withdrawal/{studentId}") + public ResponseEntity withdrawal(@PathVariable("studentId") String studentId) { + + authService.withdrawalUser(studentId); + + return ResponseEntity.ok().build(); + } + + @MacroOperation @GetMapping("/macro") - public ResponseEntity verificationCodes() { - MacroResponse body = createMacroResponse(); - return ResponseEntity.ok(body); + public ResponseEntity getMacro() { + CaptchaResult captchaData = captchaService.generateCaptcha(); + return ResponseEntity.ok(new MacroResponse(200, captchaData)); } private ResponseCookie createRefreshTokenCookie(String refreshToken) { @@ -85,16 +99,4 @@ private ResponseCookie createRefreshTokenCookie(String refreshToken) { .path(COOKIE_PATH) .build(); } - - private MacroResponse createMacroResponse() { - Random random = new Random(); - int randomNumber = random.nextInt(20) + 1; - - MacroResponse.MacroData data = new MacroResponse.MacroData( - MACRO_ANSWERS.get(randomNumber - 1).toString(), - "/macro/" + randomNumber + ".jpg" - ); - - return new MacroResponse(200, data); - } } diff --git a/src/main/java/com/tutorialsejong/courseregistration/auth/dto/AuthenticationResult.java b/src/main/java/com/tutorialsejong/courseregistration/domain/auth/dto/AuthenticationResult.java similarity index 66% rename from src/main/java/com/tutorialsejong/courseregistration/auth/dto/AuthenticationResult.java rename to src/main/java/com/tutorialsejong/courseregistration/domain/auth/dto/AuthenticationResult.java index ee29a1b..a156410 100644 --- a/src/main/java/com/tutorialsejong/courseregistration/auth/dto/AuthenticationResult.java +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/auth/dto/AuthenticationResult.java @@ -1,4 +1,4 @@ -package com.tutorialsejong.courseregistration.auth.dto; +package com.tutorialsejong.courseregistration.domain.auth.dto; public record AuthenticationResult( String accessToken, diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/auth/dto/CaptchaResult.java b/src/main/java/com/tutorialsejong/courseregistration/domain/auth/dto/CaptchaResult.java new file mode 100644 index 0000000..e9c77a9 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/auth/dto/CaptchaResult.java @@ -0,0 +1,13 @@ +package com.tutorialsejong.courseregistration.domain.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record CaptchaResult( + @Schema(description = "캡차 정답(문자열)", example = "3427") + String answer, + + @Schema(description = "캡차 이미지 주소 (Base64 인코딩 포함 Data URL 형태)", + example = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAABQCAIAAADTD63nAAAA...") + String url +) { +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/auth/dto/JwtTokens.java b/src/main/java/com/tutorialsejong/courseregistration/domain/auth/dto/JwtTokens.java similarity index 58% rename from src/main/java/com/tutorialsejong/courseregistration/auth/dto/JwtTokens.java rename to src/main/java/com/tutorialsejong/courseregistration/domain/auth/dto/JwtTokens.java index 7dcca89..f2f6f67 100644 --- a/src/main/java/com/tutorialsejong/courseregistration/auth/dto/JwtTokens.java +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/auth/dto/JwtTokens.java @@ -1,4 +1,4 @@ -package com.tutorialsejong.courseregistration.auth.dto; +package com.tutorialsejong.courseregistration.domain.auth.dto; public record JwtTokens( String accessToken, diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/auth/dto/LoginRequest.java b/src/main/java/com/tutorialsejong/courseregistration/domain/auth/dto/LoginRequest.java new file mode 100644 index 0000000..ee8d96e --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/auth/dto/LoginRequest.java @@ -0,0 +1,15 @@ +package com.tutorialsejong.courseregistration.domain.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record LoginRequest( + @Schema(description = "11자리 이상 학번", example = "12345678911") + @NotBlank(message = "studentId should not be empty") + String studentId, + + @Schema(description = "비밀번호", example = "12345678911") + @NotBlank(message = "password should not be empty") + String password +) { +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/auth/dto/LoginResponse.java b/src/main/java/com/tutorialsejong/courseregistration/domain/auth/dto/LoginResponse.java new file mode 100644 index 0000000..30eef12 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/auth/dto/LoginResponse.java @@ -0,0 +1,11 @@ +package com.tutorialsejong.courseregistration.domain.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record LoginResponse( + @Schema(description = "엑세스 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c") + String accessToken, + + @Schema(description = "사용자 이름", example = "12345678911") + String username) { +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/auth/dto/MacroResponse.java b/src/main/java/com/tutorialsejong/courseregistration/domain/auth/dto/MacroResponse.java new file mode 100644 index 0000000..2577cb0 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/auth/dto/MacroResponse.java @@ -0,0 +1,12 @@ +package com.tutorialsejong.courseregistration.domain.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record MacroResponse( + @Schema(description = "상태 코드", example = "200") + int statusCode, + + @Schema(description = "캡차 데이터") + CaptchaResult data +) { +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/auth/service/AuthService.java b/src/main/java/com/tutorialsejong/courseregistration/domain/auth/service/AuthService.java similarity index 53% rename from src/main/java/com/tutorialsejong/courseregistration/auth/service/AuthService.java rename to src/main/java/com/tutorialsejong/courseregistration/domain/auth/service/AuthService.java index 5fbc1fc..4502da8 100644 --- a/src/main/java/com/tutorialsejong/courseregistration/auth/service/AuthService.java +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/auth/service/AuthService.java @@ -1,41 +1,70 @@ -package com.tutorialsejong.courseregistration.auth.service; - -import com.tutorialsejong.courseregistration.auth.JwtTokenProvider; -import com.tutorialsejong.courseregistration.auth.dto.AuthenticationResult; -import com.tutorialsejong.courseregistration.auth.dto.JwtTokens; -import com.tutorialsejong.courseregistration.auth.dto.LoginRequest; -import com.tutorialsejong.courseregistration.user.entity.User; -import com.tutorialsejong.courseregistration.user.repository.InvalidRefreshTokenException; -import com.tutorialsejong.courseregistration.user.repository.UserRepository; +package com.tutorialsejong.courseregistration.domain.auth.service; + +import com.tutorialsejong.courseregistration.common.security.JwtTokenProvider; +import com.tutorialsejong.courseregistration.common.utils.log.LogAction; +import com.tutorialsejong.courseregistration.common.utils.log.LogMessage; +import com.tutorialsejong.courseregistration.common.utils.log.LogReason; +import com.tutorialsejong.courseregistration.common.utils.log.LogResult; +import com.tutorialsejong.courseregistration.domain.auth.dto.AuthenticationResult; +import com.tutorialsejong.courseregistration.domain.auth.dto.JwtTokens; +import com.tutorialsejong.courseregistration.domain.auth.dto.LoginRequest; +import com.tutorialsejong.courseregistration.domain.registration.service.CourseRegistrationService; +import com.tutorialsejong.courseregistration.domain.user.entity.User; +import com.tutorialsejong.courseregistration.domain.user.exception.UserNotFoundException; +import com.tutorialsejong.courseregistration.domain.user.repository.InvalidRefreshTokenException; +import com.tutorialsejong.courseregistration.domain.user.repository.UserRepository; +import com.tutorialsejong.courseregistration.domain.wishlist.service.WishListService; +import jakarta.transaction.Transactional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @Service public class AuthService { + + private static final Logger logger = LoggerFactory.getLogger(AuthService.class); + private final AuthenticationManager authenticationManager; private final JwtTokenProvider tokenProvider; private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; + private final WishListService wishListService; + + private final CourseRegistrationService courseRegistrationService; + public AuthService(AuthenticationManager authenticationManager, JwtTokenProvider tokenProvider, UserRepository userRepository, - PasswordEncoder passwordEncoder) { + PasswordEncoder passwordEncoder, WishListService wishListService, + CourseRegistrationService courseRegistrationService) { this.authenticationManager = authenticationManager; this.tokenProvider = tokenProvider; this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; + + this.wishListService = wishListService; + this.courseRegistrationService = courseRegistrationService; } public AuthenticationResult loginOrSignup(LoginRequest loginRequest) { User user = findOrCreateUser(loginRequest); Authentication authentication = authenticate(loginRequest); JwtTokens jwtTokens = generateTokens(authentication, user); + + logger.info(LogMessage.builder() + .action(LogAction.LOGIN) + .subject("s"+user.getStudentId()) + .result(LogResult.SUCCESS) + .build() + .toString() + ); + return new AuthenticationResult(jwtTokens.accessToken(), jwtTokens.refreshToken(), user.getStudentId()); } @@ -45,6 +74,7 @@ private User findOrCreateUser(LoginRequest loginRequest) { } private User createNewUser(LoginRequest loginRequest) { + User newUser = new User(loginRequest.studentId(), encodePassword(loginRequest.password())); return userRepository.save(newUser); } @@ -78,13 +108,33 @@ public JwtTokens refreshAccessToken(String refreshToken) { String username = tokenProvider.getUsernameFromJWT(refreshToken); User user = userRepository.findByStudentId(username) - .orElseThrow(() -> new UsernameNotFoundException("User not found with username: " + username)); + .orElseThrow(() -> new UserNotFoundException()); if (!user.getRefreshToken().equals(refreshToken)) { + logger.warn(LogMessage.builder() + .action(LogAction.REFRESH_TOKEN) + .subject("s"+username) + .result(LogResult.FAIL) + .reason(LogReason.INVALID_CREDENTIAL) + .build().toString()); throw new InvalidRefreshTokenException("Invalid refresh token"); } String newAccessToken = tokenProvider.generateAccessTokenFromUsername(username); return new JwtTokens(newAccessToken, refreshToken); } + + @Transactional + public void withdrawalUser(String studentId) { + wishListService.deleteWishListsByStudent(studentId); + courseRegistrationService.deleteCourseRegistrationsByStudent(studentId); + userRepository.deleteByStudentId(studentId); + + logger.info(LogMessage.builder() + .action(LogAction.WITHDRAWAL) + .subject("s"+studentId) + .result(LogResult.SUCCESS) + .build() + .toString()); + } } diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/auth/service/CaptchaService.java b/src/main/java/com/tutorialsejong/courseregistration/domain/auth/service/CaptchaService.java new file mode 100644 index 0000000..2f73dc7 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/auth/service/CaptchaService.java @@ -0,0 +1,48 @@ +package com.tutorialsejong.courseregistration.domain.auth.service; + +import cn.apiclub.captcha.Captcha; +import cn.apiclub.captcha.backgrounds.BackgroundProducer; +import cn.apiclub.captcha.text.producer.NumbersAnswerProducer; +import cn.apiclub.captcha.text.renderer.WordRenderer; +import com.tutorialsejong.courseregistration.domain.auth.dto.CaptchaResult; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Base64; +import javax.imageio.ImageIO; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CaptchaService { + + private static final int CAPTCHA_WIDTH = 200; + private static final int CAPTCHA_HEIGHT = 80; + private static final int CAPTCHA_LENGTH = 4; + + private final WordRenderer wordRenderer; + private final BackgroundProducer backgroundProducer; + + public CaptchaResult generateCaptcha() { + Captcha captcha = new Captcha.Builder(CAPTCHA_WIDTH, CAPTCHA_HEIGHT) + .addBackground(backgroundProducer) + .addText(new NumbersAnswerProducer(CAPTCHA_LENGTH), wordRenderer) + .build(); + + String base64Image = convertToBase64(captcha.getImage()); + String imageUrl = "data:image/png;base64," + base64Image; + + return new CaptchaResult(captcha.getAnswer(), imageUrl); + } + + private String convertToBase64(BufferedImage image) { + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(image, "png", baos); + return Base64.getEncoder().encodeToString(baos.toByteArray()); + } catch (IOException e) { + throw new RuntimeException("Failed to convert image to Base64", e); + } + } +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/auth/service/CustomUserDetailsService.java b/src/main/java/com/tutorialsejong/courseregistration/domain/auth/service/CustomUserDetailsService.java similarity index 82% rename from src/main/java/com/tutorialsejong/courseregistration/auth/service/CustomUserDetailsService.java rename to src/main/java/com/tutorialsejong/courseregistration/domain/auth/service/CustomUserDetailsService.java index 5197103..e273511 100644 --- a/src/main/java/com/tutorialsejong/courseregistration/auth/service/CustomUserDetailsService.java +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/auth/service/CustomUserDetailsService.java @@ -1,7 +1,7 @@ -package com.tutorialsejong.courseregistration.auth.service; +package com.tutorialsejong.courseregistration.domain.auth.service; -import com.tutorialsejong.courseregistration.user.entity.User; -import com.tutorialsejong.courseregistration.user.repository.UserRepository; +import com.tutorialsejong.courseregistration.domain.user.entity.User; +import com.tutorialsejong.courseregistration.domain.user.repository.UserRepository; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/auth/swagger/LoginOperation.java b/src/main/java/com/tutorialsejong/courseregistration/domain/auth/swagger/LoginOperation.java new file mode 100644 index 0000000..080afc0 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/auth/swagger/LoginOperation.java @@ -0,0 +1,27 @@ +package com.tutorialsejong.courseregistration.domain.auth.swagger; + +import com.tutorialsejong.courseregistration.domain.auth.dto.LoginResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.http.MediaType; + +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Operation(summary = "로그인", description = "로그인 요청") +@ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "성공", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = LoginResponse.class)) + ) +}) +public @interface LoginOperation { +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/auth/swagger/MacroOperation.java b/src/main/java/com/tutorialsejong/courseregistration/domain/auth/swagger/MacroOperation.java new file mode 100644 index 0000000..445faf1 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/auth/swagger/MacroOperation.java @@ -0,0 +1,30 @@ +package com.tutorialsejong.courseregistration.domain.auth.swagger; + +import com.tutorialsejong.courseregistration.domain.auth.dto.MacroResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.http.MediaType; + +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Operation(summary = "매크로 코드", description = "매크로 코드 발급", security = @SecurityRequirement(name = "BearerAuth")) +@ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "성공", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = MacroResponse.class) + ) + ) +}) +@SecurityRequirement(name = "jwt") +public @interface MacroOperation { +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/auth/swagger/RefreshOperation.java b/src/main/java/com/tutorialsejong/courseregistration/domain/auth/swagger/RefreshOperation.java new file mode 100644 index 0000000..8d2b1d7 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/auth/swagger/RefreshOperation.java @@ -0,0 +1,41 @@ +package com.tutorialsejong.courseregistration.domain.auth.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.Map; + +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Operation(summary = "토큰 재발급", description = "Refresh Token 유효할 시, Access Token 재발급", security = @SecurityRequirement(name = "BearerAuth")) +@Parameter(name = "refreshToken", description = "발급 받은 Refresh Token", in = ParameterIn.COOKIE, required = true) +@ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = Map.class), + examples = @ExampleObject( + value = """ + { + "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + } + """ + ) + ) + ) +}) +public @interface RefreshOperation { +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/auth/swagger/WithdrawalOperation.java b/src/main/java/com/tutorialsejong/courseregistration/domain/auth/swagger/WithdrawalOperation.java new file mode 100644 index 0000000..c727afa --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/auth/swagger/WithdrawalOperation.java @@ -0,0 +1,35 @@ +package com.tutorialsejong.courseregistration.domain.auth.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Operation(summary = "회원 탈퇴", description = "회원 탈퇴 api") +@Parameters({ + @Parameter( + name = "studentId", + description = "탈퇴할 유저의 학번", + required = true, + example = "12345678911", + in = ParameterIn.PATH + ) +}) +@ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "성공", + content = @Content() + ) +}) +public @interface WithdrawalOperation { +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/registration/controller/CourseRegistrationController.java b/src/main/java/com/tutorialsejong/courseregistration/domain/registration/controller/CourseRegistrationController.java new file mode 100644 index 0000000..f1d6f59 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/registration/controller/CourseRegistrationController.java @@ -0,0 +1,76 @@ +package com.tutorialsejong.courseregistration.domain.registration.controller; + +import com.tutorialsejong.courseregistration.domain.registration.dto.CourseRegistrationResponse; +import com.tutorialsejong.courseregistration.domain.registration.dto.CourseRegistrationScheduleResponse; +import com.tutorialsejong.courseregistration.domain.registration.service.CourseRegistrationService; +import com.tutorialsejong.courseregistration.domain.registration.swagger.CancelAllCourseRegistrationsOperation; +import com.tutorialsejong.courseregistration.domain.registration.swagger.CancelCourseRegistrationOperation; +import com.tutorialsejong.courseregistration.domain.registration.swagger.GetRegisteredCoursesOperation; +import com.tutorialsejong.courseregistration.domain.registration.swagger.RegisterCourseOperation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/registrations") +@Tag(name = "수강신청 API", description = "수강신청 관련 API") +public class CourseRegistrationController { + + private final CourseRegistrationService courseRegistrationService; + + public CourseRegistrationController(CourseRegistrationService courseRegistrationService) { + this.courseRegistrationService = courseRegistrationService; + } + + @RegisterCourseOperation + @PostMapping("/{scheduleId}") + public ResponseEntity registerCourse( + @Parameter(description = "강의 ID", required = true, example = "1") + @PathVariable Long scheduleId, + @AuthenticationPrincipal UserDetails userDetails) { + CourseRegistrationResponse registration = courseRegistrationService.registerCourse( + userDetails.getUsername(), scheduleId); + return ResponseEntity.status(HttpStatus.CREATED).body(registration); + } + + @GetRegisteredCoursesOperation + @GetMapping + public ResponseEntity> getRegisteredCourses( + @AuthenticationPrincipal UserDetails userDetails + ) { + List registrations = courseRegistrationService.getRegisteredCourses( + userDetails.getUsername()); + if (registrations.isEmpty()) { + return ResponseEntity.noContent().build(); + } + return ResponseEntity.ok(registrations); + } + + @CancelCourseRegistrationOperation + @DeleteMapping("/{scheduleId}") + public ResponseEntity cancelCourseRegistration( + @Parameter(description = "수강신청할 강의의 ID", required = true, example = "1") + @PathVariable Long scheduleId, + @AuthenticationPrincipal UserDetails userDetails) { + courseRegistrationService.cancelCourseRegistration(userDetails.getUsername(), scheduleId); + return ResponseEntity.ok().build(); + } + + @CancelAllCourseRegistrationsOperation + @DeleteMapping("/all") + public ResponseEntity cancelAllCourseRegistrations( + @AuthenticationPrincipal UserDetails userDetails) { + courseRegistrationService.cancelAllCourseRegistrations(userDetails.getUsername()); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/registration/dto/CourseRegistrationResponse.java b/src/main/java/com/tutorialsejong/courseregistration/domain/registration/dto/CourseRegistrationResponse.java new file mode 100644 index 0000000..3cd9520 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/registration/dto/CourseRegistrationResponse.java @@ -0,0 +1,12 @@ +package com.tutorialsejong.courseregistration.domain.registration.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record CourseRegistrationResponse( + @Schema(description = "11자리 이상 학번", example = "12345678911") + String studentId, + + @Schema(description = "강의 ID", example = "1") + Long scheduleId +) { +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/registration/dto/CourseRegistrationScheduleResponse.java b/src/main/java/com/tutorialsejong/courseregistration/domain/registration/dto/CourseRegistrationScheduleResponse.java new file mode 100644 index 0000000..07683c4 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/registration/dto/CourseRegistrationScheduleResponse.java @@ -0,0 +1,151 @@ +package com.tutorialsejong.courseregistration.domain.registration.dto; + +import com.tutorialsejong.courseregistration.domain.schedule.entity.Schedule; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "수강 일정 응답 DTO") +public record CourseRegistrationScheduleResponse( + @Schema(description = "수강 일정 ID", example = "1") + Long scheduleId, + + @Schema(description = "학과 별칭", example = "대양휴머니티칼리지") + String schDeptAlias, + + @Schema(description = "교과목 번호", example = "009352") + String curiNo, + + @Schema(description = "분반 번호", example = "001") + String classNo, + + @Schema(description = "단과 대학 별칭", example = "대양휴머니티칼리지") + String schCollegeAlias, + + @Schema(description = "교과목 명", example = "사고와표현1") + String curiNm, + + @Schema(description = "교과목 수업 언어", example = "영어") + String curiLangNm, + + @Schema(description = "교과목 유형명", example = "공통교양필수") + String curiTypeCdNm, + + @Schema(description = "선택영역 코드명", example = "학문기초") + String sltDomainCdNm, + + @Schema(description = "시간 관련 정보", example = "3.0 / 3 / 0") + String tmNum, + + @Schema(description = "학년", example = "1") + String studentYear, + + @Schema(description = "이수 단위 그룹 코드명", example = "학사") + String corsUnitGrpCdNm, + + @Schema(description = "운영 부서 명", example = "국어국문학과") + String manageDeptNm, + + @Schema(description = "강사", example = "노지현") + String lesnEmp, + + @Schema(description = "수업 시간", example = "화 목 09:00~10:30") + String lesnTime, + + @Schema(description = "강의실", example = "세101") + String lesnRoom, + + @Schema(description = "사이버 유형 코드명", example = "null", nullable = true) + String cyberTypeCdNm, + + @Schema(description = "인턴십 유형 코드명", example = "null", nullable = true) + String internshipTypeCdNm, + + @Schema(description = "교과목 편입/교환 여부", example = "null", nullable = true) + String inoutSubCdtExchangeYn, + + @Schema(description = "비고", example = "외국인대상과목, 기초(Beginner)") + String remark, + + @Schema(description = "희망 수 강 인원수", example = "1") + Long wishCount +) { + + /** + * 엔티티 {@link Schedule}로부터 DTO 객체를 생성합니다. + */ + public static CourseRegistrationScheduleResponse from(Schedule schedule) { + return new CourseRegistrationScheduleResponse( + schedule.getScheduleId(), + schedule.getSchDeptAlias(), + schedule.getCuriNo(), + schedule.getClassNo(), + schedule.getSchCollegeAlias(), + schedule.getCuriNm(), + schedule.getCuriLangNm(), + schedule.getCuriTypeCdNm(), + schedule.getSltDomainCdNm(), + schedule.getTmNum(), + schedule.getStudentYear(), + schedule.getCorsUnitGrpCdNm(), + schedule.getManageDeptNm(), + schedule.getLesnEmp(), + schedule.getLesnTime(), + schedule.getLesnRoom(), + schedule.getCyberTypeCdNm(), + schedule.getInternshipTypeCdNm(), + schedule.getInoutSubCdtExchangeYn(), + schedule.getRemark(), + schedule.getWishCount() + ); + } + + /** + * 모든 필드를 인자로 받아 DTO 객체를 생성합니다. + */ + public static CourseRegistrationScheduleResponse of( + Long scheduleId, + String schDeptAlias, + String curiNo, + String classNo, + String schCollegeAlias, + String curiNm, + String curiLangNm, + String curiTypeCdNm, + String sltDomainCdNm, + String tmNum, + String studentYear, + String corsUnitGrpCdNm, + String manageDeptNm, + String lesnEmp, + String lesnTime, + String lesnRoom, + String cyberTypeCdNm, + String internshipTypeCdNm, + String inoutSubCdtExchangeYn, + String remark, + Long wishCount + ) { + return new CourseRegistrationScheduleResponse( + scheduleId, + schDeptAlias, + curiNo, + classNo, + schCollegeAlias, + curiNm, + curiLangNm, + curiTypeCdNm, + sltDomainCdNm, + tmNum, + studentYear, + corsUnitGrpCdNm, + manageDeptNm, + lesnEmp, + lesnTime, + lesnRoom, + cyberTypeCdNm, + internshipTypeCdNm, + inoutSubCdtExchangeYn, + remark, + wishCount + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/tutorialsejong/courseregistration/registration/entity/CourseRegistration.java b/src/main/java/com/tutorialsejong/courseregistration/domain/registration/entity/CourseRegistration.java similarity index 76% rename from src/main/java/com/tutorialsejong/courseregistration/registration/entity/CourseRegistration.java rename to src/main/java/com/tutorialsejong/courseregistration/domain/registration/entity/CourseRegistration.java index 62e0d6d..3adcce4 100644 --- a/src/main/java/com/tutorialsejong/courseregistration/registration/entity/CourseRegistration.java +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/registration/entity/CourseRegistration.java @@ -1,7 +1,7 @@ -package com.tutorialsejong.courseregistration.registration.entity; +package com.tutorialsejong.courseregistration.domain.registration.entity; -import com.tutorialsejong.courseregistration.schedule.entity.Schedule; -import com.tutorialsejong.courseregistration.user.entity.User; +import com.tutorialsejong.courseregistration.domain.schedule.entity.Schedule; +import com.tutorialsejong.courseregistration.domain.user.entity.User; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -10,10 +10,13 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import java.time.LocalDateTime; @Entity -@Table(name = "course_registration") +@Table(name = "course_registration", uniqueConstraints = { + @UniqueConstraint(columnNames = {"student_id", "schedule_id"}) +}) public class CourseRegistration { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/registration/exception/CourseAlreadyRegisteredException.java b/src/main/java/com/tutorialsejong/courseregistration/domain/registration/exception/CourseAlreadyRegisteredException.java new file mode 100644 index 0000000..64fc3fa --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/registration/exception/CourseAlreadyRegisteredException.java @@ -0,0 +1,10 @@ +package com.tutorialsejong.courseregistration.domain.registration.exception; + +import com.tutorialsejong.courseregistration.common.exception.BusinessException; + +public class CourseAlreadyRegisteredException extends BusinessException { + + public CourseAlreadyRegisteredException() { + super(CourseRegistrationErrorCode.COURSE_ALREADY_REGISTERED); + } +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/registration/exception/CourseRegistrationErrorCode.java b/src/main/java/com/tutorialsejong/courseregistration/domain/registration/exception/CourseRegistrationErrorCode.java new file mode 100644 index 0000000..eaa6121 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/registration/exception/CourseRegistrationErrorCode.java @@ -0,0 +1,18 @@ +package com.tutorialsejong.courseregistration.domain.registration.exception; + +import com.tutorialsejong.courseregistration.common.exception.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum CourseRegistrationErrorCode implements ErrorCode { + + COURSE_ALREADY_REGISTERED("C001", "이미 수강신청된 과목입니다.", HttpStatus.CONFLICT), + ; + + private final String code; + private final String message; + private final HttpStatus status; +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/registration/repository/CourseRegistrationRepository.java b/src/main/java/com/tutorialsejong/courseregistration/domain/registration/repository/CourseRegistrationRepository.java similarity index 56% rename from src/main/java/com/tutorialsejong/courseregistration/registration/repository/CourseRegistrationRepository.java rename to src/main/java/com/tutorialsejong/courseregistration/domain/registration/repository/CourseRegistrationRepository.java index fa859b4..e5aa91f 100644 --- a/src/main/java/com/tutorialsejong/courseregistration/registration/repository/CourseRegistrationRepository.java +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/registration/repository/CourseRegistrationRepository.java @@ -1,11 +1,12 @@ -package com.tutorialsejong.courseregistration.registration.repository; +package com.tutorialsejong.courseregistration.domain.registration.repository; -import com.tutorialsejong.courseregistration.registration.dto.CourseRegistrationResponse; -import com.tutorialsejong.courseregistration.registration.entity.CourseRegistration; -import com.tutorialsejong.courseregistration.user.entity.User; +import com.tutorialsejong.courseregistration.domain.registration.dto.CourseRegistrationResponse; +import com.tutorialsejong.courseregistration.domain.registration.entity.CourseRegistration; +import com.tutorialsejong.courseregistration.domain.user.entity.User; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -16,8 +17,12 @@ public interface CourseRegistrationRepository extends JpaRepository findAllByStudent(User student); - @Query("SELECT new com.tutorialsejong.courseregistration.registration.dto.CourseRegistrationResponse(" + + @Query("SELECT new com.tutorialsejong.courseregistration.domain.registration.dto.CourseRegistrationResponse(" + "cr.student.studentId, cr.schedule.scheduleId) " + "FROM CourseRegistration cr WHERE cr.student.studentId = :studentId") List findCourseRegistrationResponsesByStudentId(@Param("studentId") String studentId); + + @Modifying + @Query("DELETE FROM CourseRegistration cr WHERE cr.student.studentId = :studentId") + void deleteByStudentId(String studentId); } diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/registration/service/CourseRegistrationService.java b/src/main/java/com/tutorialsejong/courseregistration/domain/registration/service/CourseRegistrationService.java new file mode 100644 index 0000000..3f65320 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/registration/service/CourseRegistrationService.java @@ -0,0 +1,221 @@ +package com.tutorialsejong.courseregistration.domain.registration.service; + +import com.tutorialsejong.courseregistration.common.utils.log.LogAction; +import com.tutorialsejong.courseregistration.common.utils.log.LogMessage; +import com.tutorialsejong.courseregistration.common.utils.log.LogReason; +import com.tutorialsejong.courseregistration.common.utils.log.LogResult; +import com.tutorialsejong.courseregistration.domain.auth.controller.AuthController; +import com.tutorialsejong.courseregistration.domain.registration.dto.CourseRegistrationResponse; +import com.tutorialsejong.courseregistration.domain.registration.dto.CourseRegistrationScheduleResponse; +import com.tutorialsejong.courseregistration.domain.registration.entity.CourseRegistration; +import com.tutorialsejong.courseregistration.domain.registration.exception.CourseAlreadyRegisteredException; +import com.tutorialsejong.courseregistration.domain.registration.repository.CourseRegistrationRepository; +import com.tutorialsejong.courseregistration.domain.schedule.entity.Schedule; +import com.tutorialsejong.courseregistration.domain.schedule.exception.ScheduleNotFoundException; +import com.tutorialsejong.courseregistration.domain.schedule.repository.ScheduleRepository; +import com.tutorialsejong.courseregistration.domain.user.entity.User; +import com.tutorialsejong.courseregistration.domain.user.exception.UserNotFoundException; +import com.tutorialsejong.courseregistration.domain.user.repository.UserRepository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class CourseRegistrationService { + private static final Logger logger = LoggerFactory.getLogger(CourseRegistrationService.class); + + private final CourseRegistrationRepository courseRegistrationRepository; + private final UserRepository userRepository; + private final ScheduleRepository scheduleRepository; + + public CourseRegistrationService(CourseRegistrationRepository courseRegistrationRepository, + UserRepository userRepository, + ScheduleRepository scheduleRepository) { + this.courseRegistrationRepository = courseRegistrationRepository; + this.userRepository = userRepository; + this.scheduleRepository = scheduleRepository; + } + + @Transactional + public CourseRegistrationResponse registerCourse(String studentId, Long scheduleId) { + logger.info(LogMessage.builder() + .action(LogAction.REGISTER_COURSE) + .subject("s"+studentId) + .objectName("c"+scheduleId) + .result(LogResult.SUCCESS) + .extras("Starting course registration") + .build().toString()); + + User student = userRepository.findByStudentId(studentId) + .orElseThrow(() -> { + logger.warn(LogMessage.builder() + .action(LogAction.VALIDATE_USER) + .subject("s"+studentId) + .result(LogResult.FAIL) + .reason(LogReason.NOT_FOUND) + .build().toString()); + throw new UserNotFoundException(); + }); + + Schedule schedule = scheduleRepository.findById(scheduleId) + .orElseThrow(() -> { + logger.warn(LogMessage.builder() + .action(LogAction.VALIDATE_COURSE) + .subject("s"+studentId) + .objectName("c"+scheduleId) + .result(LogResult.FAIL) + .reason(LogReason.NOT_FOUND) + .build().toString()); + throw new ScheduleNotFoundException(); + }); + + boolean alreadyRegistered = courseRegistrationRepository.findAllByStudent(student).stream() + .anyMatch(registration -> registration.getSchedule().getCuriNo().equals(schedule.getCuriNo())); + + if (alreadyRegistered) { + logger.warn(LogMessage.builder() + .action(LogAction.REGISTER_COURSE) + .subject("s"+studentId) + .objectName("c"+scheduleId) + .result(LogResult.FAIL) + .reason(LogReason.ALREADY_EXIST) + .build().toString()); + throw new CourseAlreadyRegisteredException(); + } + + try { + CourseRegistration registration = new CourseRegistration(student, schedule, LocalDateTime.now()); + registration = courseRegistrationRepository.save(registration); + logger.info(LogMessage.builder() + .action(LogAction.REGISTER_COURSE) + .subject("s"+studentId) + .objectName("c"+scheduleId) + .result(LogResult.SUCCESS) + .extras("Registration completed") + .build().toString()); + return convertToDto(registration); + } catch (DataIntegrityViolationException e) { + logger.error(LogMessage.builder() + .action(LogAction.REGISTER_COURSE) + .subject("s"+studentId) + .objectName("c"+scheduleId) + .result(LogResult.ERROR) + .reason(LogReason.ALREADY_EXIST) + .extras("Data integrity violation") + .build().toString()); + throw new CourseAlreadyRegisteredException(); + } + } + + @Transactional(readOnly = true) + public List getRegisteredCourses(String studentId) { + logger.info(LogMessage.builder() + .action(LogAction.FETCH_REGISTERED_COURSES) + .subject("s" + studentId) + .result(LogResult.SUCCESS) + .extras("Fetching registered courses") + .build().toString()); + + User student = userRepository.findByStudentId(studentId) + .orElseThrow(() -> { + logger.warn(LogMessage.builder() + .action(LogAction.VALIDATE_USER) + .subject("s" + studentId) + .result(LogResult.FAIL) + .reason(LogReason.NOT_FOUND) + .build().toString()); + throw new UserNotFoundException(); + }); + + List registrations = courseRegistrationRepository.findAllByStudent(student); + + List response = registrations.stream() + .map(CourseRegistration::getSchedule) + .map(CourseRegistrationScheduleResponse::from) + .collect(Collectors.toList()); + + return response; + } + + @Transactional + public void cancelCourseRegistration(String studentId, Long scheduleId) { + logger.info(LogMessage.builder() + .action(LogAction.WITHDRAWAL) + .subject("s"+studentId) + .objectName("c"+scheduleId) + .result(LogResult.SUCCESS) + .extras("Canceling course registration") + .build().toString()); + + CourseRegistration registration = courseRegistrationRepository + .findByStudentStudentIdAndScheduleScheduleId(studentId, scheduleId) + .orElseThrow(() -> { + logger.warn(LogMessage.builder() + .action(LogAction.VALIDATE_COURSE) + .subject("s"+studentId) + .objectName("c"+scheduleId) + .result(LogResult.FAIL) + .reason(LogReason.NOT_FOUND) + .build().toString()); + throw new ScheduleNotFoundException(); + }); + + courseRegistrationRepository.delete(registration); + logger.info(LogMessage.builder() + .action(LogAction.WITHDRAWAL) + .subject("s"+studentId) + .objectName("c"+scheduleId) + .result(LogResult.SUCCESS) + .extras("Successfully canceled") + .build().toString()); + } + + @Transactional + public void cancelAllCourseRegistrations(String studentId) { + logger.info(LogMessage.builder() + .action(LogAction.WITHDRAWAL) + .subject("s"+studentId) + .result(LogResult.SUCCESS) + .extras("Starting cancellation of all registrations") + .build().toString()); + + User student = userRepository.findByStudentId(studentId) + .orElseThrow(() -> { + logger.warn(LogMessage.builder() + .action(LogAction.VALIDATE_USER) + .subject("s"+studentId) + .result(LogResult.FAIL) + .reason(LogReason.NOT_FOUND) + .build().toString()); + throw new UserNotFoundException(); + }); + + List registrations = courseRegistrationRepository.findAllByStudent(student); + courseRegistrationRepository.deleteAll(registrations); + + logger.info(LogMessage.builder() + .action(LogAction.WITHDRAWAL) + .subject("s"+studentId) + .result(LogResult.SUCCESS) + .extras("All registrations canceled successfully") + .build().toString()); + } + + private CourseRegistrationResponse convertToDto(CourseRegistration registration) { + return new CourseRegistrationResponse( + registration.getStudent().getStudentId(), + registration.getSchedule().getScheduleId() + ); + } + + public void deleteCourseRegistrationsByStudent(String studentId) { + courseRegistrationRepository.deleteByStudentId(studentId); + } +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/registration/swagger/CancelAllCourseRegistrationsOperation.java b/src/main/java/com/tutorialsejong/courseregistration/domain/registration/swagger/CancelAllCourseRegistrationsOperation.java new file mode 100644 index 0000000..c2fd3ca --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/registration/swagger/CancelAllCourseRegistrationsOperation.java @@ -0,0 +1,25 @@ +package com.tutorialsejong.courseregistration.domain.registration.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.http.MediaType; + +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Operation(summary = "전체 수강 취소", description = "로그인한 사용자의 전체 수강 등록을 취소한다.", security = @SecurityRequirement(name = "bearerAuth")) +@ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "전체 수강 취소 성공", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE) + ) +}) +public @interface CancelAllCourseRegistrationsOperation { +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/registration/swagger/CancelCourseRegistrationOperation.java b/src/main/java/com/tutorialsejong/courseregistration/domain/registration/swagger/CancelCourseRegistrationOperation.java new file mode 100644 index 0000000..097eabe --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/registration/swagger/CancelCourseRegistrationOperation.java @@ -0,0 +1,28 @@ +package com.tutorialsejong.courseregistration.domain.registration.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.http.MediaType; + +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Operation(summary = "단건 수강 취소", description = "해당 스케줄에 대해 등록된 수강을 취소한다.", security = @SecurityRequirement(name = "bearerAuth")) +@Parameter(name = "scheduleId", description = "취소할 수강의 스케줄 ID", required = true, in = ParameterIn.PATH, example = "1") +@ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "수강 취소 성공", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE) + ) +}) +public @interface CancelCourseRegistrationOperation { +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/registration/swagger/GetRegisteredCoursesOperation.java b/src/main/java/com/tutorialsejong/courseregistration/domain/registration/swagger/GetRegisteredCoursesOperation.java new file mode 100644 index 0000000..6d773ef --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/registration/swagger/GetRegisteredCoursesOperation.java @@ -0,0 +1,31 @@ +package com.tutorialsejong.courseregistration.domain.registration.swagger; + +import com.tutorialsejong.courseregistration.domain.registration.dto.CourseRegistrationScheduleResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.http.MediaType; + +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Operation(summary = "등록 내역 조회", description = "로그인한 사용자의 수강 등록 내역을 조회", security = @SecurityRequirement(name = "bearerAuth")) +@ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + array = @ArraySchema(schema = @Schema(implementation = CourseRegistrationScheduleResponse.class)) + ) + ), +}) +public @interface GetRegisteredCoursesOperation { +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/registration/swagger/RegisterCourseOperation.java b/src/main/java/com/tutorialsejong/courseregistration/domain/registration/swagger/RegisterCourseOperation.java new file mode 100644 index 0000000..4cff312 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/registration/swagger/RegisterCourseOperation.java @@ -0,0 +1,30 @@ +package com.tutorialsejong.courseregistration.domain.registration.swagger; + +import com.tutorialsejong.courseregistration.domain.registration.dto.CourseRegistrationResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.http.MediaType; + +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Operation(summary = "수강 등록", description = "해당 스케줄에 수강 신청을 진행한다.", security = @SecurityRequirement(name = "bearerAuth")) +@ApiResponses(value = { + @ApiResponse( + responseCode = "201", + description = "수강 등록 성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = CourseRegistrationResponse.class) + ) + ) +}) +public @interface RegisterCourseOperation { +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/schedule/controller/ScheduleController.java b/src/main/java/com/tutorialsejong/courseregistration/domain/schedule/controller/ScheduleController.java similarity index 60% rename from src/main/java/com/tutorialsejong/courseregistration/schedule/controller/ScheduleController.java rename to src/main/java/com/tutorialsejong/courseregistration/domain/schedule/controller/ScheduleController.java index 759710d..f7a5cb0 100644 --- a/src/main/java/com/tutorialsejong/courseregistration/schedule/controller/ScheduleController.java +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/schedule/controller/ScheduleController.java @@ -1,9 +1,12 @@ -package com.tutorialsejong.courseregistration.schedule.controller; +package com.tutorialsejong.courseregistration.domain.schedule.controller; -import com.tutorialsejong.courseregistration.schedule.dto.ErrorDto; -import com.tutorialsejong.courseregistration.schedule.dto.ScheduleSearchRequest; -import com.tutorialsejong.courseregistration.schedule.entity.Schedule; -import com.tutorialsejong.courseregistration.schedule.service.ScheduleService; +import com.tutorialsejong.courseregistration.domain.schedule.dto.ErrorDto; +import com.tutorialsejong.courseregistration.domain.schedule.dto.ScheduleResponse; +import com.tutorialsejong.courseregistration.domain.schedule.dto.ScheduleSearchRequest; +import com.tutorialsejong.courseregistration.domain.schedule.entity.Schedule; +import com.tutorialsejong.courseregistration.domain.schedule.service.ScheduleService; +import com.tutorialsejong.courseregistration.domain.schedule.swagger.GetPopularSchedulesOperation; +import com.tutorialsejong.courseregistration.domain.schedule.swagger.SearchSchedulesOperation; import java.util.Date; import java.util.List; import java.util.Set; @@ -11,6 +14,7 @@ import java.util.Spliterators; import java.util.stream.Collectors; import java.util.stream.StreamSupport; +import org.springdoc.core.annotations.ParameterObject; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -19,6 +23,7 @@ import org.springframework.security.core.userdetails.UserDetails; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.context.request.WebRequest; @@ -27,7 +32,8 @@ public class ScheduleController { private static final Set ALLOWED_PARAMS = Set.of( - "curiNo", "classNo", "schCollegeAlias", "schDeptAlias", "curiTypeCdNm", "sltDomainCdNm", "curiNm", "lesnEmp", "studentId" + "curiNo", "classNo", "schCollegeAlias", "schDeptAlias", "curiTypeCdNm", + "sltDomainCdNm", "curiNm", "lesnEmp", "studentId" ); private final ScheduleService scheduleService; @@ -37,10 +43,13 @@ public ScheduleController(ScheduleService scheduleService) { this.scheduleService = scheduleService; } + @SearchSchedulesOperation @GetMapping(value = "/search", produces = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity getSearchSchedules(ScheduleSearchRequest searchRequest, - WebRequest request, - @AuthenticationPrincipal UserDetails userDetails) { + public ResponseEntity getSearchSchedules( + @ParameterObject ScheduleSearchRequest searchRequest, + WebRequest request, + @AuthenticationPrincipal UserDetails userDetails + ) { Set invalidParams = validateParameters(request); if (!invalidParams.isEmpty()) { String message = "유효하지않은 Parameter. (" + String.join(", ", invalidParams) + ")"; @@ -55,12 +64,15 @@ public ResponseEntity getSearchSchedules(ScheduleSearchRequest searchRequest, return createErrorResponse(HttpStatus.NOT_FOUND, "검색된 값 없음", request); } - return ResponseEntity.ok().body(searchResult); + // 엔티티 그대로 반환 (권장: DTO 변환) + return ResponseEntity.ok(searchResult); } private Set validateParameters(WebRequest request) { return StreamSupport.stream( - Spliterators.spliteratorUnknownSize(request.getParameterNames(), Spliterator.ORDERED), false) + Spliterators.spliteratorUnknownSize(request.getParameterNames(), Spliterator.ORDERED), + false + ) .filter(param -> !ALLOWED_PARAMS.contains(param)) .collect(Collectors.toSet()); } @@ -69,4 +81,13 @@ private ResponseEntity createErrorResponse(HttpStatus status, String m return ResponseEntity.status(status) .body(new ErrorDto(new Date(), status.value(), message, request.getDescription(false))); } + + @GetPopularSchedulesOperation + @GetMapping("/popular") + public ResponseEntity> getPopularSchedules( + @RequestParam(defaultValue = "10") int limit + ) { + List popularSchedules = scheduleService.findPopularSchedules(limit); + return ResponseEntity.ok(popularSchedules); + } } diff --git a/src/main/java/com/tutorialsejong/courseregistration/schedule/dto/ErrorDto.java b/src/main/java/com/tutorialsejong/courseregistration/domain/schedule/dto/ErrorDto.java similarity index 64% rename from src/main/java/com/tutorialsejong/courseregistration/schedule/dto/ErrorDto.java rename to src/main/java/com/tutorialsejong/courseregistration/domain/schedule/dto/ErrorDto.java index 2552483..69b0569 100644 --- a/src/main/java/com/tutorialsejong/courseregistration/schedule/dto/ErrorDto.java +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/schedule/dto/ErrorDto.java @@ -1,4 +1,4 @@ -package com.tutorialsejong.courseregistration.schedule.dto; +package com.tutorialsejong.courseregistration.domain.schedule.dto; import java.util.Date; diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/schedule/dto/ScheduleResponse.java b/src/main/java/com/tutorialsejong/courseregistration/domain/schedule/dto/ScheduleResponse.java new file mode 100644 index 0000000..cc03381 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/schedule/dto/ScheduleResponse.java @@ -0,0 +1,36 @@ +package com.tutorialsejong.courseregistration.domain.schedule.dto; + +import com.tutorialsejong.courseregistration.domain.schedule.entity.Schedule; + +public record ScheduleResponse( + Long scheduleId, + String curiNm, // 과목명 + String curiNo, // 과목번호 + String classNo, // 분반 + String manageDeptNm, // 개설학과 + String lesnEmp, // 담당교수 + String lesnTime, // 강의시간 + String lesnRoom, // 강의실 + Long wishCount, // 관심과목 수 + Integer rank // 순위 +) { + public static ScheduleResponse from(Schedule schedule, Integer rank) { + return new ScheduleResponse( + schedule.getScheduleId(), + schedule.getCuriNm(), + schedule.getCuriNo(), + schedule.getClassNo(), + schedule.getManageDeptNm(), + schedule.getLesnEmp(), + schedule.getLesnTime(), + schedule.getLesnRoom(), + schedule.getWishCount(), + rank + ); + } + + // rank 없는 버전의 from 메서드 - 일반 과목 조회시 사용 + public static ScheduleResponse from(Schedule schedule) { + return from(schedule, null); + } +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/schedule/dto/ScheduleSearchRequest.java b/src/main/java/com/tutorialsejong/courseregistration/domain/schedule/dto/ScheduleSearchRequest.java new file mode 100644 index 0000000..f8c03cd --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/schedule/dto/ScheduleSearchRequest.java @@ -0,0 +1,55 @@ +package com.tutorialsejong.courseregistration.domain.schedule.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "스케줄 검색 요청 DTO") +public record ScheduleSearchRequest( + @Schema(description = "교과목 번호", example = "009352") + String curiNo, + + @Schema(description = "분반 번호", example = "001") + String classNo, + + @Schema(description = "단과대학 별칭", example = "대양휴머니티칼리지") + String schCollegeAlias, + + @Schema(description = "학과 별칭", example = "대양휴머니티칼리지") + String schDeptAlias, + + @Schema(description = "교과목 유형명", example = "공통교양필수") + String curiTypeCdNm, + + @Schema(description = "선택영역 코드명", example = "학문기초") + String sltDomainCdNm, + + @Schema(description = "교과목 명", example = "사고와표현1") + String curiNm, + + @Schema(description = "강사명", example = "노지현") + String lesnEmp +) { + + public static ScheduleSearchRequest of( + String curiNo, + String classNo, + String schCollegeAlias, + String schDeptAlias, + String curiTypeCdNm, + String sltDomainCdNm, + String curiNm, + String lesnEmp + ) { + return new ScheduleSearchRequest( + curiNo, + classNo, + schCollegeAlias, + schDeptAlias, + curiTypeCdNm, + sltDomainCdNm, + curiNm, + lesnEmp + ); + } +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/schedule/entity/Schedule.java b/src/main/java/com/tutorialsejong/courseregistration/domain/schedule/entity/Schedule.java new file mode 100644 index 0000000..3f28328 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/schedule/entity/Schedule.java @@ -0,0 +1,89 @@ +package com.tutorialsejong.courseregistration.domain.schedule.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "course_schedule") +@Getter +@NoArgsConstructor +public class Schedule { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long scheduleId; + + @Column(name = "sch_dept_alias") + private String schDeptAlias; + + @Column(name = "curi_no") + private String curiNo; + + @Column(name = "class_no") + private String classNo; + + @Column(name = "sch_college_alias") + private String schCollegeAlias; + + @Column(name = "curi_nm") + private String curiNm; + + @Column(name = "curi_lang_nm") + private String curiLangNm; + + @Column(name = "curi_type_cd_nm") + private String curiTypeCdNm; + + @Column(name = "slt_domain_cd_nm") + private String sltDomainCdNm; + + @Column(name = "tm_num") + private String tmNum; + + @Column(name = "student_year") + private String studentYear; + + @Column(name = "cors_unit_grp_cd_nm") + private String corsUnitGrpCdNm; + + @Column(name = "manage_dept_nm") + private String manageDeptNm; + + @Column(name = "lesn_emp") + private String lesnEmp; + + @Column(name = "lesn_time") + private String lesnTime; + + @Column(name = "lesn_room") + private String lesnRoom; + + @Column(name = "cyber_type_cd_nm") + private String cyberTypeCdNm; + + @Column(name = "internship_type_cd_nm") + private String internshipTypeCdNm; + + @Column(name = "inout_sub_cdt_exchange_yn") + private String inoutSubCdtExchangeYn; + + @Column(name = "remark") + private String remark; + + @Column(name = "wish_count", nullable = false) + private Long wishCount = 0L; + + public void incrementWishCount() { + this.wishCount++; + } + + public void decrementWishCount() { + this.wishCount = Math.max(0, this.wishCount - 1); + } +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/schedule/exception/ScheduleErrorCode.java b/src/main/java/com/tutorialsejong/courseregistration/domain/schedule/exception/ScheduleErrorCode.java new file mode 100644 index 0000000..4042485 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/schedule/exception/ScheduleErrorCode.java @@ -0,0 +1,18 @@ +package com.tutorialsejong.courseregistration.domain.schedule.exception; + +import com.tutorialsejong.courseregistration.common.exception.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum ScheduleErrorCode implements ErrorCode { + + SCHEDULE_NOT_FOUND("S001", "존재하지 않는 강의입니다.", HttpStatus.NOT_FOUND), + ; + + private final String code; + private final String message; + private final HttpStatus status; +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/schedule/exception/ScheduleNotFoundException.java b/src/main/java/com/tutorialsejong/courseregistration/domain/schedule/exception/ScheduleNotFoundException.java new file mode 100644 index 0000000..276dc5f --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/schedule/exception/ScheduleNotFoundException.java @@ -0,0 +1,10 @@ +package com.tutorialsejong.courseregistration.domain.schedule.exception; + +import com.tutorialsejong.courseregistration.common.exception.BusinessException; + +public class ScheduleNotFoundException extends BusinessException { + + public ScheduleNotFoundException() { + super(ScheduleErrorCode.SCHEDULE_NOT_FOUND); + } +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/schedule/repository/ScheduleRepository.java b/src/main/java/com/tutorialsejong/courseregistration/domain/schedule/repository/ScheduleRepository.java similarity index 86% rename from src/main/java/com/tutorialsejong/courseregistration/schedule/repository/ScheduleRepository.java rename to src/main/java/com/tutorialsejong/courseregistration/domain/schedule/repository/ScheduleRepository.java index 50713ba..55a6ab2 100644 --- a/src/main/java/com/tutorialsejong/courseregistration/schedule/repository/ScheduleRepository.java +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/schedule/repository/ScheduleRepository.java @@ -1,12 +1,12 @@ -package com.tutorialsejong.courseregistration.schedule.repository; +package com.tutorialsejong.courseregistration.domain.schedule.repository; -import com.tutorialsejong.courseregistration.schedule.entity.Schedule; +import com.tutorialsejong.courseregistration.domain.schedule.entity.Schedule; +import java.util.List; +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.List; - public interface ScheduleRepository extends JpaRepository { @Query("SELECT s FROM Schedule s WHERE " + @@ -36,4 +36,6 @@ List findAllBy( @Param("curi_nm") String curiNm, @Param("lesn_emp") String lesnEmp ); + + List findAllByOrderByWishCountDesc(Pageable pageable); } diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/schedule/service/ScheduleService.java b/src/main/java/com/tutorialsejong/courseregistration/domain/schedule/service/ScheduleService.java new file mode 100644 index 0000000..15ebce8 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/schedule/service/ScheduleService.java @@ -0,0 +1,98 @@ +package com.tutorialsejong.courseregistration.domain.schedule.service; + +import com.tutorialsejong.courseregistration.domain.registration.entity.CourseRegistration; +import com.tutorialsejong.courseregistration.domain.registration.repository.CourseRegistrationRepository; +import com.tutorialsejong.courseregistration.domain.schedule.dto.ScheduleResponse; +import com.tutorialsejong.courseregistration.domain.schedule.dto.ScheduleSearchRequest; +import com.tutorialsejong.courseregistration.domain.schedule.entity.Schedule; +import com.tutorialsejong.courseregistration.domain.schedule.repository.ScheduleRepository; +import com.tutorialsejong.courseregistration.domain.user.entity.User; +import com.tutorialsejong.courseregistration.domain.user.exception.UserNotFoundException; +import com.tutorialsejong.courseregistration.domain.user.repository.UserRepository; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import lombok.Getter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +@Service +public class ScheduleService { + + private final ScheduleRepository scheduleRepository; + private final UserRepository userRepository; + private final CourseRegistrationRepository courseRegistrationRepository; + + @Autowired + public ScheduleService(ScheduleRepository scheduleRepository, UserRepository userRepository, + CourseRegistrationRepository courseRegistrationRepository) { + this.scheduleRepository = scheduleRepository; + this.userRepository = userRepository; + this.courseRegistrationRepository = courseRegistrationRepository; + } + + public List getSearchResultSchedules(ScheduleSearchRequest scheduleSearchRequest, String studentId) { + User user = userRepository.findByStudentId(studentId) + .orElseThrow(UserNotFoundException::new); + + List findAllByResult = scheduleRepository.findAllBy( + scheduleSearchRequest.curiNo(), + scheduleSearchRequest.classNo(), + scheduleSearchRequest.schCollegeAlias(), + scheduleSearchRequest.schDeptAlias(), + scheduleSearchRequest.curiTypeCdNm(), + scheduleSearchRequest.sltDomainCdNm(), + scheduleSearchRequest.curiNm(), + scheduleSearchRequest.lesnEmp() + ); + + List registeredSchedules = courseRegistrationRepository.findAllByStudent(user).stream() + .map(CourseRegistration::getSchedule) + .toList(); + + return findAllByResult.stream() + .filter(schedule -> !registeredSchedules.contains(schedule)) + .collect(Collectors.toList()); + } + + public List findPopularSchedules(int limit) { + List schedules = scheduleRepository.findAllByOrderByWishCountDesc(PageRequest.of(0, limit)); + + if (schedules.isEmpty()) { + return Collections.emptyList(); + } + + return calculatePopularSchedulesWithRank(schedules); + } + + private List calculatePopularSchedulesWithRank(List schedules) { + List responses = new ArrayList<>(schedules.size()); + RankInfo rankInfo = new RankInfo(); + + for (Schedule schedule : schedules) { + rankInfo.updateRankIfWishCountChanged(schedule.getWishCount()); + responses.add(ScheduleResponse.from(schedule, rankInfo.getCurrentRank())); + } + + return responses; + } + + private static class RankInfo { + @Getter + private int currentRank = 1; + private Long previousWishCount = null; + + public void updateRankIfWishCountChanged(Long newWishCount) { + if (shouldIncrementRank(newWishCount)) { + currentRank++; + } + previousWishCount = newWishCount; + } + + private boolean shouldIncrementRank(Long newWishCount) { + return previousWishCount != null && !newWishCount.equals(previousWishCount); + } + } +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/schedule/swagger/GetPopularSchedulesOperation.java b/src/main/java/com/tutorialsejong/courseregistration/domain/schedule/swagger/GetPopularSchedulesOperation.java new file mode 100644 index 0000000..d02f5c2 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/schedule/swagger/GetPopularSchedulesOperation.java @@ -0,0 +1,38 @@ +package com.tutorialsejong.courseregistration.domain.schedule.swagger; + +import com.tutorialsejong.courseregistration.domain.registration.dto.CourseRegistrationScheduleResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.http.MediaType; + +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Operation(summary = "인기 스케줄 조회", description = "위시카운트가 높은 스케줄을 조회합니다.") +@ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + array = @ArraySchema(schema = @Schema(implementation = CourseRegistrationScheduleResponse.class)) + ) + ) +}) +@Parameter( + name = "limit", + description = "가져올 인기 스케줄 개수 (기본값=10)", + in = ParameterIn.QUERY, + example = "10" +) +public @interface GetPopularSchedulesOperation { +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/schedule/swagger/SearchSchedulesOperation.java b/src/main/java/com/tutorialsejong/courseregistration/domain/schedule/swagger/SearchSchedulesOperation.java new file mode 100644 index 0000000..3f0cdf3 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/schedule/swagger/SearchSchedulesOperation.java @@ -0,0 +1,50 @@ +package com.tutorialsejong.courseregistration.domain.schedule.swagger; + +import com.tutorialsejong.courseregistration.domain.registration.dto.CourseRegistrationScheduleResponse; +import com.tutorialsejong.courseregistration.domain.schedule.dto.ErrorDto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.http.MediaType; + +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Operation( + summary = "스케줄 검색", + description = "검색 조건에 맞는 스케줄을 조회합니다." +) +@ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "검색 결과 성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + array = @ArraySchema(schema = @Schema(implementation = CourseRegistrationScheduleResponse.class)) + ) + ), + @ApiResponse( + responseCode = "400", + description = "유효하지 않은 파라미터", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "검색된 값 없음", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ErrorDto.class) + ) + ) +}) +public @interface SearchSchedulesOperation { +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/user/entity/User.java b/src/main/java/com/tutorialsejong/courseregistration/domain/user/entity/User.java similarity index 94% rename from src/main/java/com/tutorialsejong/courseregistration/user/entity/User.java rename to src/main/java/com/tutorialsejong/courseregistration/domain/user/entity/User.java index 699ee3e..3c13a54 100644 --- a/src/main/java/com/tutorialsejong/courseregistration/user/entity/User.java +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/user/entity/User.java @@ -1,4 +1,4 @@ -package com.tutorialsejong.courseregistration.user.entity; +package com.tutorialsejong.courseregistration.domain.user.entity; import jakarta.persistence.Column; import jakarta.persistence.Entity; diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/user/exception/UserErrorCode.java b/src/main/java/com/tutorialsejong/courseregistration/domain/user/exception/UserErrorCode.java new file mode 100644 index 0000000..e91089c --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/user/exception/UserErrorCode.java @@ -0,0 +1,18 @@ +package com.tutorialsejong.courseregistration.domain.user.exception; + +import com.tutorialsejong.courseregistration.common.exception.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum UserErrorCode implements ErrorCode { + + USER_NOT_FOUND("U001", "존재하지 않는 사용자입니다.", HttpStatus.NOT_FOUND), + ; + + private final String code; + private final String message; + private final HttpStatus status; +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/user/exception/UserNotFoundException.java b/src/main/java/com/tutorialsejong/courseregistration/domain/user/exception/UserNotFoundException.java new file mode 100644 index 0000000..bc2bcbc --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/user/exception/UserNotFoundException.java @@ -0,0 +1,10 @@ +package com.tutorialsejong.courseregistration.domain.user.exception; + +import com.tutorialsejong.courseregistration.common.exception.BusinessException; + +public class UserNotFoundException extends BusinessException { + + public UserNotFoundException() { + super(UserErrorCode.USER_NOT_FOUND); + } +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/user/repository/InvalidRefreshTokenException.java b/src/main/java/com/tutorialsejong/courseregistration/domain/user/repository/InvalidRefreshTokenException.java similarity index 69% rename from src/main/java/com/tutorialsejong/courseregistration/user/repository/InvalidRefreshTokenException.java rename to src/main/java/com/tutorialsejong/courseregistration/domain/user/repository/InvalidRefreshTokenException.java index a109043..f7eba06 100644 --- a/src/main/java/com/tutorialsejong/courseregistration/user/repository/InvalidRefreshTokenException.java +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/user/repository/InvalidRefreshTokenException.java @@ -1,4 +1,4 @@ -package com.tutorialsejong.courseregistration.user.repository; +package com.tutorialsejong.courseregistration.domain.user.repository; public class InvalidRefreshTokenException extends RuntimeException { public InvalidRefreshTokenException(String message) { diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/user/repository/UserRepository.java b/src/main/java/com/tutorialsejong/courseregistration/domain/user/repository/UserRepository.java new file mode 100644 index 0000000..2a50617 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/user/repository/UserRepository.java @@ -0,0 +1,16 @@ +package com.tutorialsejong.courseregistration.domain.user.repository; + +import com.tutorialsejong.courseregistration.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + Optional findByStudentId(String studentId); + + @Modifying + @Query("DELETE FROM User u WHERE u.studentId = :studentId") + void deleteByStudentId(String studentId); +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/controller/WishListController.java b/src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/controller/WishListController.java new file mode 100644 index 0000000..b68a860 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/controller/WishListController.java @@ -0,0 +1,59 @@ +package com.tutorialsejong.courseregistration.domain.wishlist.controller; + +import com.tutorialsejong.courseregistration.domain.schedule.entity.Schedule; +import com.tutorialsejong.courseregistration.domain.wishlist.dto.WishListRequest; +import com.tutorialsejong.courseregistration.domain.wishlist.service.WishListService; +import com.tutorialsejong.courseregistration.domain.wishlist.swagger.DeleteWishListOperation; +import com.tutorialsejong.courseregistration.domain.wishlist.swagger.GetWishListOperation; +import com.tutorialsejong.courseregistration.domain.wishlist.swagger.SaveWishListOperation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/wishlist") +@Tag(name = "관심과목 API", description = "관심과목 관련 API") +public class WishListController { + + private final WishListService wishListService; + + public WishListController(WishListService wishListService) { + this.wishListService = wishListService; + } + + @SaveWishListOperation + @PostMapping("/save") + public ResponseEntity saveWishListItem(@RequestBody WishListRequest wishListRequest) { + wishListService.saveWishListItem(wishListRequest.studentId(), wishListRequest.scheduleId()); + return ResponseEntity.status(HttpStatus.CREATED).body("관심과목이 저장되었습니다."); + } + + @GetWishListOperation + @GetMapping + public ResponseEntity getWishList(@RequestParam String studentId) { + List wishList = wishListService.getWishList(studentId); + return ResponseEntity.ok(wishList); + } + + @DeleteWishListOperation + @DeleteMapping + public ResponseEntity deleteWishListItem( + @Parameter(description = "11자리 이상 학번", example = "12345678911") + @RequestParam String studentId, + + @Parameter(description = "강의 ID", example = "1") + @RequestParam Long scheduleId + ) { + wishListService.deleteWishListItem(studentId, scheduleId); + return ResponseEntity.ok("관심과목이 삭제되었습니다."); + } +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/wishlist/dto/CourseInformation.java b/src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/dto/CourseInformation.java similarity index 61% rename from src/main/java/com/tutorialsejong/courseregistration/wishlist/dto/CourseInformation.java rename to src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/dto/CourseInformation.java index e7c1b54..b758e21 100644 --- a/src/main/java/com/tutorialsejong/courseregistration/wishlist/dto/CourseInformation.java +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/dto/CourseInformation.java @@ -1,4 +1,4 @@ -package com.tutorialsejong.courseregistration.wishlist.dto; +package com.tutorialsejong.courseregistration.domain.wishlist.dto; public record CourseInformation( String curiNo, diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/dto/WishListRequest.java b/src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/dto/WishListRequest.java new file mode 100644 index 0000000..7f7b346 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/dto/WishListRequest.java @@ -0,0 +1,12 @@ +package com.tutorialsejong.courseregistration.domain.wishlist.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record WishListRequest( + @Schema(description = "11자리 이상 학번", example = "12345678911") + String studentId, + + @Schema(description = "강의 ID", example = "1") + Long scheduleId +) { +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/wishlist/entity/WishList.java b/src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/entity/WishList.java similarity index 80% rename from src/main/java/com/tutorialsejong/courseregistration/wishlist/entity/WishList.java rename to src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/entity/WishList.java index 354b90e..6963f87 100644 --- a/src/main/java/com/tutorialsejong/courseregistration/wishlist/entity/WishList.java +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/entity/WishList.java @@ -1,7 +1,7 @@ -package com.tutorialsejong.courseregistration.wishlist.entity; +package com.tutorialsejong.courseregistration.domain.wishlist.entity; -import com.tutorialsejong.courseregistration.schedule.entity.Schedule; -import com.tutorialsejong.courseregistration.user.entity.User; +import com.tutorialsejong.courseregistration.domain.schedule.entity.Schedule; +import com.tutorialsejong.courseregistration.domain.user.entity.User; import jakarta.persistence.*; @Entity @@ -45,4 +45,4 @@ public Schedule getScheduleId() { public void setScheduleId(Schedule scheduleId) { this.scheduleId = scheduleId; } -} \ No newline at end of file +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/exception/AlreadyInWishlistException.java b/src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/exception/AlreadyInWishlistException.java new file mode 100644 index 0000000..5bdc790 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/exception/AlreadyInWishlistException.java @@ -0,0 +1,10 @@ +package com.tutorialsejong.courseregistration.domain.wishlist.exception; + +import com.tutorialsejong.courseregistration.common.exception.BusinessException; + +public class AlreadyInWishlistException extends BusinessException { + + public AlreadyInWishlistException() { + super(WishlistErrorCode.ALREADY_IN_WISHLIST); + } +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/exception/WishlistCourseAlreadyRegisteredException.java b/src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/exception/WishlistCourseAlreadyRegisteredException.java new file mode 100644 index 0000000..14a5082 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/exception/WishlistCourseAlreadyRegisteredException.java @@ -0,0 +1,10 @@ +package com.tutorialsejong.courseregistration.domain.wishlist.exception; + +import com.tutorialsejong.courseregistration.common.exception.BusinessException; + +public class WishlistCourseAlreadyRegisteredException extends BusinessException { + + public WishlistCourseAlreadyRegisteredException() { + super(WishlistErrorCode.WISHLIST_COURSE_ALREADY_REGISTERED); + } +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/exception/WishlistErrorCode.java b/src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/exception/WishlistErrorCode.java new file mode 100644 index 0000000..f556428 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/exception/WishlistErrorCode.java @@ -0,0 +1,20 @@ +package com.tutorialsejong.courseregistration.domain.wishlist.exception; + +import com.tutorialsejong.courseregistration.common.exception.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum WishlistErrorCode implements ErrorCode { + + ALREADY_IN_WISHLIST("W001", "이미 관심과목 담기된 과목입니다.", HttpStatus.CONFLICT), + WISHLIST_COURSE_ALREADY_REGISTERED("W002", "이미 수강신청된 과목은 관심과목으로 담을 수 없습니다.", HttpStatus.CONFLICT), + WISHLIST_NOT_FOUND("W003", "존재하지 않는 과목입니다.", HttpStatus.NOT_FOUND), + ; + + private final String code; + private final String message; + private final HttpStatus status; +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/exception/WishlistNotFoundException.java b/src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/exception/WishlistNotFoundException.java new file mode 100644 index 0000000..6e7ae8a --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/exception/WishlistNotFoundException.java @@ -0,0 +1,10 @@ +package com.tutorialsejong.courseregistration.domain.wishlist.exception; + +import com.tutorialsejong.courseregistration.common.exception.BusinessException; + +public class WishlistNotFoundException extends BusinessException { + + public WishlistNotFoundException() { + super(WishlistErrorCode.WISHLIST_NOT_FOUND); + } +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/repository/WishListRepository.java b/src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/repository/WishListRepository.java new file mode 100644 index 0000000..aa86338 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/repository/WishListRepository.java @@ -0,0 +1,23 @@ +package com.tutorialsejong.courseregistration.domain.wishlist.repository; + +import com.tutorialsejong.courseregistration.domain.schedule.entity.Schedule; +import com.tutorialsejong.courseregistration.domain.user.entity.User; +import com.tutorialsejong.courseregistration.domain.wishlist.entity.WishList; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +public interface WishListRepository extends JpaRepository { + + List findAllByStudentId(User studentId); + + Optional findByStudentIdAndScheduleId(User user, Schedule schedule); + + boolean existsByStudentIdAndScheduleId(User studentId, Schedule scheduleId); + + @Modifying + @Query("DELETE FROM WishList w WHERE w.studentId.studentId = :studentId") + void deleteByStudentId(String studentId); +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/service/WishListService.java b/src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/service/WishListService.java new file mode 100644 index 0000000..0efa58e --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/service/WishListService.java @@ -0,0 +1,212 @@ +package com.tutorialsejong.courseregistration.domain.wishlist.service; + +import com.tutorialsejong.courseregistration.common.utils.log.LogAction; +import com.tutorialsejong.courseregistration.common.utils.log.LogMessage; +import com.tutorialsejong.courseregistration.common.utils.log.LogReason; +import com.tutorialsejong.courseregistration.common.utils.log.LogResult; +import com.tutorialsejong.courseregistration.domain.auth.controller.AuthController; +import com.tutorialsejong.courseregistration.domain.registration.repository.CourseRegistrationRepository; +import com.tutorialsejong.courseregistration.domain.schedule.entity.Schedule; +import com.tutorialsejong.courseregistration.domain.schedule.exception.ScheduleNotFoundException; +import com.tutorialsejong.courseregistration.domain.schedule.repository.ScheduleRepository; +import com.tutorialsejong.courseregistration.domain.user.entity.User; +import com.tutorialsejong.courseregistration.domain.user.exception.UserNotFoundException; +import com.tutorialsejong.courseregistration.domain.user.repository.UserRepository; +import com.tutorialsejong.courseregistration.domain.wishlist.entity.WishList; +import com.tutorialsejong.courseregistration.domain.wishlist.exception.AlreadyInWishlistException; +import com.tutorialsejong.courseregistration.domain.wishlist.exception.WishlistNotFoundException; +import com.tutorialsejong.courseregistration.domain.wishlist.repository.WishListRepository; +import java.util.List; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +@Service +public class WishListService { + + private static final Logger logger = LoggerFactory.getLogger(AuthController.class); + + private final WishListRepository wishListRepository; + private final UserRepository userRepository; + private final ScheduleRepository scheduleRepository; + private final CourseRegistrationRepository courseRegistrationRepository; + + public WishListService(WishListRepository wishListRepository, + UserRepository userRepository, + ScheduleRepository scheduleRepository, + CourseRegistrationRepository courseRegistrationRepository) { + this.wishListRepository = wishListRepository; + this.userRepository = userRepository; + this.scheduleRepository = scheduleRepository; + this.courseRegistrationRepository = courseRegistrationRepository; + } + + public void saveWishListItem(String studentId, Long scheduleId) { + logger.info(LogMessage.builder() + .action(LogAction.ADD_WISHLIST) + .subject("s" + studentId) + .objectName("c" + scheduleId) + .result(LogResult.SUCCESS) + .extras("Starting wishlist addition") + .build().toString()); + + User user = checkExistUser(studentId); + Schedule schedule = checkExistSchedule(scheduleId); + + String curiNo = schedule.getCuriNo(); + + boolean existsInWishList = wishListRepository.findAllByStudentId(user).stream() + .anyMatch(wishList -> wishList.getScheduleId().getCuriNo().equals(curiNo)); + + if (existsInWishList) { + logger.warn(LogMessage.builder() + .action(LogAction.ADD_WISHLIST) + .subject("s" + studentId) + .objectName("c" + scheduleId) + .result(LogResult.FAIL) + .reason(LogReason.ALREADY_EXIST) + .build().toString()); + throw new AlreadyInWishlistException(); + } + + WishList newWishList = new WishList(user, schedule); + wishListRepository.save(newWishList); + + schedule.incrementWishCount(); + scheduleRepository.save(schedule); + + logger.info(LogMessage.builder() + .action(LogAction.ADD_WISHLIST) + .subject("s" + studentId) + .objectName("c" + scheduleId) + .result(LogResult.SUCCESS) + .extras("Successfully added to wishlist") + .build().toString()); + } + + public List getWishList(String studentId) { + logger.info(LogMessage.builder() + .action(LogAction.FETCH_REGISTERED_COURSES) + .subject("s" + studentId) + .result(LogResult.SUCCESS) + .extras("Fetching wishlist") + .build().toString()); + + User user = checkExistUser(studentId); + + List wishListList = wishListRepository.findAllByStudentId(user); + + logger.info(LogMessage.builder() + .action(LogAction.FETCH_REGISTERED_COURSES) + .subject("s" + studentId) + .result(LogResult.SUCCESS) + .extras("Wishlist fetched successfully") + .build().toString()); + + return wishListList.stream() + .map(WishList::getScheduleId) + .filter(schedule -> !courseRegistrationRepository.existsByStudentStudentIdAndScheduleScheduleId( + studentId, schedule.getScheduleId())) // 수강신청된 과목 제외 + .collect(Collectors.toList()); + } + + public User checkExistUser(String studentId) { + logger.info(LogMessage.builder() + .action(LogAction.VALIDATE_USER) + .subject("s" + studentId) + .result(LogResult.SUCCESS) + .extras("Validating user existence") + .build().toString()); + + return userRepository.findByStudentId(studentId) + .orElseThrow(() -> { + logger.warn(LogMessage.builder() + .action(LogAction.VALIDATE_USER) + .subject("s" + studentId) + .result(LogResult.FAIL) + .reason(LogReason.NOT_FOUND) + .extras("User not found") + .build().toString()); + return new UserNotFoundException(); + }); + } + + public Schedule checkExistSchedule(Long scheduleId) { + logger.info(LogMessage.builder() + .action(LogAction.VALIDATE_COURSE) + .objectName("c" + scheduleId) + .result(LogResult.SUCCESS) + .extras("Validating schedule existence") + .build().toString()); + + return scheduleRepository.findById(scheduleId) + .orElseThrow(() -> { + logger.warn(LogMessage.builder() + .action(LogAction.VALIDATE_COURSE) + .objectName("c" + scheduleId) + .result(LogResult.FAIL) + .reason(LogReason.NOT_FOUND) + .extras("Schedule not found") + .build().toString()); + return new ScheduleNotFoundException(); + }); + } + + public void deleteWishListItem(String studentId, Long scheduleId) { + logger.info(LogMessage.builder() + .action(LogAction.REMOVE_WISHLIST) // 추가된 LogAction + .subject("s" + studentId) + .objectName("c" + scheduleId) + .result(LogResult.SUCCESS) + .extras("Starting removal of wishlist item") + .build().toString()); + + User user = checkExistUser(studentId); + Schedule schedule = checkExistSchedule(scheduleId); + + WishList wishList = wishListRepository.findByStudentIdAndScheduleId(user, schedule) + .orElseThrow(() -> { + logger.warn(LogMessage.builder() + .action(LogAction.REMOVE_WISHLIST) + .subject("s" + studentId) + .objectName("c" + scheduleId) + .result(LogResult.FAIL) + .reason(LogReason.NOT_FOUND) + .extras("Wishlist item not found") + .build().toString()); + return new WishlistNotFoundException(); + }); + + wishListRepository.delete(wishList); + + schedule.decrementWishCount(); + scheduleRepository.save(schedule); + + logger.info(LogMessage.builder() + .action(LogAction.REMOVE_WISHLIST) + .subject("s" + studentId) + .objectName("c" + scheduleId) + .result(LogResult.SUCCESS) + .extras("Removed wishlist item") + .build().toString()); + } + + public void deleteWishListsByStudent(String studentId) { + logger.info(LogMessage.builder() + .action(LogAction.REMOVE_WISHLIST) + .subject("s" + studentId) + .result(LogResult.SUCCESS) + .extras("Starting removal of all wishlist items for the user") + .build().toString()); + + wishListRepository.deleteByStudentId(studentId); + + logger.info(LogMessage.builder() + .action(LogAction.REMOVE_WISHLIST) + .subject("s" + studentId) + .result(LogResult.SUCCESS) + .extras("Removed all wishlist items for the user") + .build().toString()); + } +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/swagger/DeleteWishListOperation.java b/src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/swagger/DeleteWishListOperation.java new file mode 100644 index 0000000..b9e2e4a --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/swagger/DeleteWishListOperation.java @@ -0,0 +1,30 @@ +package com.tutorialsejong.courseregistration.domain.wishlist.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.http.MediaType; + +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Operation(summary = "관심과목 삭제", description = "특정 학생의 관심 과목을 삭제한다.") +@ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "삭제 성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(type = "string"), + examples = @ExampleObject(value = "관심과목이 삭제되었습니다.") + ) + ) +}) +public @interface DeleteWishListOperation { +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/swagger/GetWishListOperation.java b/src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/swagger/GetWishListOperation.java new file mode 100644 index 0000000..b519410 --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/swagger/GetWishListOperation.java @@ -0,0 +1,30 @@ +package com.tutorialsejong.courseregistration.domain.wishlist.swagger; + +import com.tutorialsejong.courseregistration.domain.registration.dto.CourseRegistrationScheduleResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.http.MediaType; + +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Operation(summary = "관심과목 조회", description = "특정 학생의 관심 과목 목록을 조회한다.") +@ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + array = @ArraySchema(schema = @Schema(implementation = CourseRegistrationScheduleResponse.class)) + ) + ) +}) +public @interface GetWishListOperation { +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/swagger/SaveWishListOperation.java b/src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/swagger/SaveWishListOperation.java new file mode 100644 index 0000000..440026e --- /dev/null +++ b/src/main/java/com/tutorialsejong/courseregistration/domain/wishlist/swagger/SaveWishListOperation.java @@ -0,0 +1,30 @@ +package com.tutorialsejong.courseregistration.domain.wishlist.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.http.MediaType; + +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Operation(summary = "관심과목 저장", description = "특정 학생의 관심 과목을 저장한다.") +@ApiResponses(value = { + @ApiResponse( + responseCode = "201", + description = "관심과목 저장 성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(type = "string"), + examples = @ExampleObject(value = "관심과목이 저장되었습니다.") + ) + ) +}) +public @interface SaveWishListOperation { +} diff --git a/src/main/java/com/tutorialsejong/courseregistration/registration/controller/CourseRegistrationController.java b/src/main/java/com/tutorialsejong/courseregistration/registration/controller/CourseRegistrationController.java deleted file mode 100644 index a3d0588..0000000 --- a/src/main/java/com/tutorialsejong/courseregistration/registration/controller/CourseRegistrationController.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.tutorialsejong.courseregistration.registration.controller; - - -import com.tutorialsejong.courseregistration.registration.dto.CourseRegistrationResponse; -import com.tutorialsejong.courseregistration.registration.service.CourseRegistrationService; -import com.tutorialsejong.courseregistration.schedule.entity.Schedule; -import java.util.List; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/registrations") -public class CourseRegistrationController { - - private final CourseRegistrationService courseRegistrationService; - - public CourseRegistrationController(CourseRegistrationService courseRegistrationService) { - this.courseRegistrationService = courseRegistrationService; - } - - @PostMapping("/{scheduleId}") - public ResponseEntity registerCourse( - @PathVariable Long scheduleId, - @AuthenticationPrincipal UserDetails userDetails) { - CourseRegistrationResponse registration = courseRegistrationService.registerCourse(userDetails.getUsername(), - scheduleId); - return ResponseEntity.status(HttpStatus.CREATED).body(registration); - } - - @GetMapping - public ResponseEntity> getRegisteredCourses( - @AuthenticationPrincipal UserDetails userDetails) { - List registrations = courseRegistrationService.getRegisteredCourses(userDetails.getUsername()); - if (registrations.isEmpty()) { - return ResponseEntity.noContent().build(); - } - return ResponseEntity.ok(registrations); - } - - @DeleteMapping("/{scheduleId}") - public ResponseEntity cancelCourseRegistration( - @PathVariable Long scheduleId, - @AuthenticationPrincipal UserDetails userDetails) { - courseRegistrationService.cancelCourseRegistration(userDetails.getUsername(), scheduleId); - return ResponseEntity.ok().build(); - } -} diff --git a/src/main/java/com/tutorialsejong/courseregistration/registration/dto/CourseRegistrationResponse.java b/src/main/java/com/tutorialsejong/courseregistration/registration/dto/CourseRegistrationResponse.java deleted file mode 100644 index 7b22b36..0000000 --- a/src/main/java/com/tutorialsejong/courseregistration/registration/dto/CourseRegistrationResponse.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.tutorialsejong.courseregistration.registration.dto; - -public record CourseRegistrationResponse( - String studentId, - Long scheduleId -) { -} diff --git a/src/main/java/com/tutorialsejong/courseregistration/registration/service/CourseRegistrationService.java b/src/main/java/com/tutorialsejong/courseregistration/registration/service/CourseRegistrationService.java deleted file mode 100644 index 12a7765..0000000 --- a/src/main/java/com/tutorialsejong/courseregistration/registration/service/CourseRegistrationService.java +++ /dev/null @@ -1,76 +0,0 @@ -package com.tutorialsejong.courseregistration.registration.service; - -import com.tutorialsejong.courseregistration.common.exception.AlreadyRegisteredException; -import com.tutorialsejong.courseregistration.common.exception.NotFoundException; -import com.tutorialsejong.courseregistration.registration.dto.CourseRegistrationResponse; -import com.tutorialsejong.courseregistration.registration.entity.CourseRegistration; -import com.tutorialsejong.courseregistration.registration.repository.CourseRegistrationRepository; -import com.tutorialsejong.courseregistration.schedule.entity.Schedule; -import com.tutorialsejong.courseregistration.schedule.repository.ScheduleRepository; -import com.tutorialsejong.courseregistration.user.entity.User; -import com.tutorialsejong.courseregistration.user.repository.UserRepository; -import java.time.LocalDateTime; -import java.util.List; -import java.util.stream.Collectors; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -public class CourseRegistrationService { - - private final CourseRegistrationRepository courseRegistrationRepository; - private final UserRepository userRepository; - private final ScheduleRepository scheduleRepository; - - public CourseRegistrationService(CourseRegistrationRepository courseRegistrationRepository, - UserRepository userRepository, - ScheduleRepository scheduleRepository) { - this.courseRegistrationRepository = courseRegistrationRepository; - this.userRepository = userRepository; - this.scheduleRepository = scheduleRepository; - } - - @Transactional - public CourseRegistrationResponse registerCourse(String studentId, Long scheduleId) { - User student = userRepository.findByStudentId(studentId) - .orElseThrow(() -> new NotFoundException("User not found with id: " + studentId)); - Schedule schedule = scheduleRepository.findById(scheduleId) - .orElseThrow(() -> new NotFoundException("Schedule not found with id: " + scheduleId)); - - if (courseRegistrationRepository.existsByStudentStudentIdAndScheduleScheduleId(studentId, scheduleId)) { - throw new AlreadyRegisteredException("Course already registered"); - } - - CourseRegistration registration = new CourseRegistration(student, schedule, LocalDateTime.now()); - registration = courseRegistrationRepository.save(registration); - return convertToDto(registration); - } - - @Transactional(readOnly = true) - public List getRegisteredCourses(String studentId) { - User student = userRepository.findByStudentId(studentId) - .orElseThrow(() -> new NotFoundException("User not found with id: " + studentId)); - - List registrations = courseRegistrationRepository.findAllByStudent(student); - - return registrations.stream() - .map(CourseRegistration::getSchedule) - .collect(Collectors.toList()); - } - - @Transactional - public void cancelCourseRegistration(String studentId, Long scheduleId) { - CourseRegistration registration = courseRegistrationRepository - .findByStudentStudentIdAndScheduleScheduleId(studentId, scheduleId) - .orElseThrow(() -> new NotFoundException("Course registration not found")); - - courseRegistrationRepository.delete(registration); - } - - private CourseRegistrationResponse convertToDto(CourseRegistration registration) { - return new CourseRegistrationResponse( - registration.getStudent().getStudentId(), - registration.getSchedule().getScheduleId() - ); - } -} diff --git a/src/main/java/com/tutorialsejong/courseregistration/schedule/dto/ScheduleRequest.java b/src/main/java/com/tutorialsejong/courseregistration/schedule/dto/ScheduleRequest.java deleted file mode 100644 index eb5e535..0000000 --- a/src/main/java/com/tutorialsejong/courseregistration/schedule/dto/ScheduleRequest.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.tutorialsejong.courseregistration.schedule.dto; - -public class ScheduleRequest { -} diff --git a/src/main/java/com/tutorialsejong/courseregistration/schedule/dto/ScheduleResponse.java b/src/main/java/com/tutorialsejong/courseregistration/schedule/dto/ScheduleResponse.java deleted file mode 100644 index 3c60306..0000000 --- a/src/main/java/com/tutorialsejong/courseregistration/schedule/dto/ScheduleResponse.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.tutorialsejong.courseregistration.schedule.dto; - -public class ScheduleResponse { -} diff --git a/src/main/java/com/tutorialsejong/courseregistration/schedule/dto/ScheduleSearchRequest.java b/src/main/java/com/tutorialsejong/courseregistration/schedule/dto/ScheduleSearchRequest.java deleted file mode 100644 index 065ad80..0000000 --- a/src/main/java/com/tutorialsejong/courseregistration/schedule/dto/ScheduleSearchRequest.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.tutorialsejong.courseregistration.schedule.dto; - -import com.fasterxml.jackson.annotation.JsonInclude; - -@JsonInclude(JsonInclude.Include.NON_NULL) -public record ScheduleSearchRequest( - String curiNo, - String classNo, - String schCollegeAlias, - String schDeptAlias, - String curiTypeCdNm, - String sltDomainCdNm, - String curiNm, - String lesnEmp -) { -} diff --git a/src/main/java/com/tutorialsejong/courseregistration/schedule/entity/Schedule.java b/src/main/java/com/tutorialsejong/courseregistration/schedule/entity/Schedule.java deleted file mode 100644 index 36ce4f2..0000000 --- a/src/main/java/com/tutorialsejong/courseregistration/schedule/entity/Schedule.java +++ /dev/null @@ -1,149 +0,0 @@ -package com.tutorialsejong.courseregistration.schedule.entity; - -import jakarta.persistence.*; - -@Entity -@Table(name = "course_schedule") -public class Schedule { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long scheduleId; - - @Column(name = "sch_dept_alias") - private String schDeptAlias; - - @Column(name = "curi_no") - private String curiNo; - - @Column(name = "class_no") - private String classNo; - - @Column(name = "sch_college_alias") - private String schCollegeAlias; - - @Column(name = "curi_nm") - private String curiNm; - - @Column(name = "curi_lang_nm") - private String curiLangNm; - - @Column(name = "curi_type_cd_nm") - private String curiTypeCdNm; - - @Column(name = "slt_domain_cd_nm") - private String sltDomainCdNm; - - @Column(name = "tm_num") - private String tmNum; - - @Column(name = "student_year") - private String studentYear; - - @Column(name = "cors_unit_grp_cd_nm") - private String corsUnitGrpCdNm; - - @Column(name = "manage_dept_nm") - private String manageDeptNm; - - @Column(name = "lesn_emp") - private String lesnEmp; - - @Column(name = "lesn_time") - private String lesnTime; - - @Column(name = "lesn_room") - private String lesnRoom; - - @Column(name = "cyber_type_cd_nm") - private String cyberTypeCdNm; - - @Column(name = "internship_type_cd_nm") - private String internshipTypeCdNm; - - @Column(name = "inout_sub_cdt_exchange_yn") - private String inoutSubCdtExchangeYn; - - @Column(name = "remark") - private String remark; - - public Long getScheduleId() { - return scheduleId; - } - - public String getSchDeptAlias() { - return schDeptAlias; - } - - public String getCuriNo() { - return curiNo; - } - - public String getClassNo() { - return classNo; - } - - public String getSchCollegeAlias() { - return schCollegeAlias; - } - - public String getCuriNm() { - return curiNm; - } - - public String getCuriLangNm() { - return curiLangNm; - } - - public String getCuriTypeCdNm() { - return curiTypeCdNm; - } - - public String getSltDomainCdNm() { - return sltDomainCdNm; - } - - public String getTmNum() { - return tmNum; - } - - public String getStudentYear() { - return studentYear; - } - - public String getCorsUnitGrpCdNm() { - return corsUnitGrpCdNm; - } - - public String getManageDeptNm() { - return manageDeptNm; - } - - public String getLesnEmp() { - return lesnEmp; - } - - public String getLesnTime() { - return lesnTime; - } - - public String getLesnRoom() { - return lesnRoom; - } - - public String getCyberTypeCdNm() { - return cyberTypeCdNm; - } - - public String getInternshipTypeCdNm() { - return internshipTypeCdNm; - } - - public String getInoutSubCdtExchangeYn() { - return inoutSubCdtExchangeYn; - } - - public String getRemark() { - return remark; - } -} diff --git a/src/main/java/com/tutorialsejong/courseregistration/schedule/service/ScheduleService.java b/src/main/java/com/tutorialsejong/courseregistration/schedule/service/ScheduleService.java deleted file mode 100644 index 16eb874..0000000 --- a/src/main/java/com/tutorialsejong/courseregistration/schedule/service/ScheduleService.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.tutorialsejong.courseregistration.schedule.service; - -import com.tutorialsejong.courseregistration.schedule.dto.ScheduleSearchRequest; -import com.tutorialsejong.courseregistration.schedule.entity.Schedule; -import com.tutorialsejong.courseregistration.schedule.repository.ScheduleRepository; -import com.tutorialsejong.courseregistration.user.entity.User; -import com.tutorialsejong.courseregistration.user.repository.UserRepository; -import com.tutorialsejong.courseregistration.wishlist.entity.WishList; -import com.tutorialsejong.courseregistration.wishlist.repository.WishListRepository; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; - -import java.util.List; -import java.util.stream.Collectors; - -@Service -public class ScheduleService { - - private final ScheduleRepository scheduleRepository; - private final WishListRepository wishListRepository; - private final UserRepository userRepository; - - @Autowired - public ScheduleService(ScheduleRepository scheduleRepository, WishListRepository wishListRepository, UserRepository userRepository) { - this.scheduleRepository = scheduleRepository; - this.wishListRepository = wishListRepository; - this.userRepository = userRepository; - } - - public List getSearchResultSchedules(ScheduleSearchRequest scheduleSearchRequest, String studentId) { - User user = userRepository.findByStudentId(studentId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다.")); - - List findAllByResult = scheduleRepository.findAllBy( - scheduleSearchRequest.curiNo(), - scheduleSearchRequest.classNo(), - scheduleSearchRequest.schCollegeAlias(), - scheduleSearchRequest.schDeptAlias(), - scheduleSearchRequest.curiTypeCdNm(), - scheduleSearchRequest.sltDomainCdNm(), - scheduleSearchRequest.curiNm(), - scheduleSearchRequest.lesnEmp() - ); - - List wishListSchedules = wishListRepository.findAllByStudentId(user).stream() - .map(WishList::getScheduleId) - .collect(Collectors.toList()); - - return findAllByResult.stream() - .filter(schedule -> !wishListSchedules.contains(schedule)) - .collect(Collectors.toList()); - } -} diff --git a/src/main/java/com/tutorialsejong/courseregistration/user/repository/UserRepository.java b/src/main/java/com/tutorialsejong/courseregistration/user/repository/UserRepository.java deleted file mode 100644 index 87a945d..0000000 --- a/src/main/java/com/tutorialsejong/courseregistration/user/repository/UserRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.tutorialsejong.courseregistration.user.repository; - -import com.tutorialsejong.courseregistration.user.entity.User; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.Optional; - -public interface UserRepository extends JpaRepository { - Optional findByStudentId(String studentId); -} diff --git a/src/main/java/com/tutorialsejong/courseregistration/wishlist/controller/WishListController.java b/src/main/java/com/tutorialsejong/courseregistration/wishlist/controller/WishListController.java deleted file mode 100644 index b9cbe31..0000000 --- a/src/main/java/com/tutorialsejong/courseregistration/wishlist/controller/WishListController.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.tutorialsejong.courseregistration.wishlist.controller; - -import com.tutorialsejong.courseregistration.wishlist.dto.WishListRequest; -import com.tutorialsejong.courseregistration.wishlist.service.WishListService; -import com.tutorialsejong.courseregistration.schedule.entity.Schedule; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -@RestController -@RequestMapping("/wishlist") -public class WishListController { - - private final WishListService wishListService; - - @Autowired - public WishListController(WishListService wishListService) { - this.wishListService = wishListService; - } - - - @PostMapping("/save") - public ResponseEntity saveWishList(@RequestBody WishListRequest wishListRequest) { - wishListService.saveWishList(wishListRequest.studentId(), wishListRequest.wishListIdList()); - - return ResponseEntity.status(HttpStatus.CREATED).body("관심과목이 저장되었습니다."); - } - - @GetMapping() - public ResponseEntity getWishList(@RequestParam String studentId) { - List wishList = wishListService.getWishList(studentId); - - return ResponseEntity.status(HttpStatus.OK).body(wishList); - } - - @DeleteMapping - public ResponseEntity deleteWishList(@RequestParam String studentId, @RequestParam Long scheduleId) { - wishListService.deleteWishList(studentId, scheduleId); - return ResponseEntity.status(HttpStatus.OK).body("관심과목이 삭제되었습니다."); - } -} diff --git a/src/main/java/com/tutorialsejong/courseregistration/wishlist/dto/WishListRequest.java b/src/main/java/com/tutorialsejong/courseregistration/wishlist/dto/WishListRequest.java deleted file mode 100644 index 99f83b3..0000000 --- a/src/main/java/com/tutorialsejong/courseregistration/wishlist/dto/WishListRequest.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.tutorialsejong.courseregistration.wishlist.dto; - - -import java.util.List; - -public record WishListRequest( - String studentId, - List wishListIdList -) { -} \ No newline at end of file diff --git a/src/main/java/com/tutorialsejong/courseregistration/wishlist/repository/WishListRepository.java b/src/main/java/com/tutorialsejong/courseregistration/wishlist/repository/WishListRepository.java deleted file mode 100644 index 3a56cd6..0000000 --- a/src/main/java/com/tutorialsejong/courseregistration/wishlist/repository/WishListRepository.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.tutorialsejong.courseregistration.wishlist.repository; - -import com.tutorialsejong.courseregistration.schedule.entity.Schedule; -import com.tutorialsejong.courseregistration.user.entity.User; -import com.tutorialsejong.courseregistration.wishlist.entity.WishList; -import java.util.List; -import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface WishListRepository extends JpaRepository { - - List findAllByStudentId(User studentId); - - Optional findByStudentIdAndScheduleId(User user, Schedule schedule); - - boolean existsByStudentIdAndScheduleId(User studentId, Schedule scheduleId); - -} diff --git a/src/main/java/com/tutorialsejong/courseregistration/wishlist/service/WishListService.java b/src/main/java/com/tutorialsejong/courseregistration/wishlist/service/WishListService.java deleted file mode 100644 index c485e3a..0000000 --- a/src/main/java/com/tutorialsejong/courseregistration/wishlist/service/WishListService.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.tutorialsejong.courseregistration.wishlist.service; - -import com.tutorialsejong.courseregistration.common.exception.CheckUserException; -import com.tutorialsejong.courseregistration.schedule.entity.Schedule; -import com.tutorialsejong.courseregistration.schedule.repository.ScheduleRepository; -import com.tutorialsejong.courseregistration.user.entity.User; -import com.tutorialsejong.courseregistration.user.repository.UserRepository; -import com.tutorialsejong.courseregistration.wishlist.entity.WishList; -import com.tutorialsejong.courseregistration.wishlist.repository.WishListRepository; -import org.springframework.stereotype.Service; - -import java.util.List; -import java.util.stream.Collectors; - -@Service -public class WishListService { - - private final WishListRepository wishListRepository; - private final UserRepository userRepository; - private final ScheduleRepository scheduleRepository; - - public WishListService(WishListRepository wishListRepository, UserRepository userRepository, ScheduleRepository scheduleRepository) { - this.wishListRepository = wishListRepository; - this.userRepository = userRepository; - this.scheduleRepository = scheduleRepository; - } - - public void saveWishList(String studentId, List wishListIdList) { - User user = checkExistUser(studentId); - - List wishList = wishListIdList.stream() - .map(this::checkExistSchedule) - .filter(schedule -> !wishListRepository.existsByStudentIdAndScheduleId(user, schedule)) // 이미 등록된 관심과목 제외 - .map(schedule -> new WishList(user, schedule)) - .collect(Collectors.toList()); - - if (wishList.isEmpty()) { - new CheckUserException("이미 신청된 관심과목이 포함되어있습니다."); - } - - wishListRepository.saveAll(wishList); - } - - public List getWishList(String studentId) { - User user = checkExistUser(studentId); - - List wishListList = wishListRepository.findAllByStudentId(user); - - return wishListList.stream() - .map(WishList::getScheduleId) - .collect(Collectors.toList()); - } - - public User checkExistUser(String studentId) { - return userRepository.findByStudentId(studentId) - .orElseThrow(() -> new CheckUserException(studentId + "회원이 존재하지 않습니다.")); - } - - public Schedule checkExistSchedule(Long scheduleId) { - return scheduleRepository.findById(scheduleId) - .orElseThrow(() -> new CheckUserException(scheduleId + "과목이 존재하지않습니다.")); - } - - public void deleteWishList(String studentId, Long scheduleId) { - User user = checkExistUser(studentId); - Schedule schedule = checkExistSchedule(scheduleId); - - WishList wishList = wishListRepository.findByStudentIdAndScheduleId(user, schedule) - .orElseThrow(() -> new CheckUserException("신청하지 않은 과목입니다.")); - - wishListRepository.delete(wishList); - } -} diff --git a/src/main/resources/config b/src/main/resources/config index af2aad1..7891b40 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit af2aad10e12d7ef09f5544cb0d38054603b7f3d6 +Subproject commit 7891b409b1463e12e1718f63eb73cede36ec35d5 diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..49666d1 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,62 @@ + + + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%highlight(%-5level)] [%cyan(%logger{36}.%method:line%line)] - %msg%n + + + + + + + + + + logs/tutorial-sejong-log.log + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%-5level] [%logger{36}.%method:line%line] - %msg%n + + + + + + logs/backup/tutorial-sejong-log-%d{yyyy-MM-dd}.log + + 30 + + + + + + + + /home/anhye0n/web/tutorial_sejong/backend/logs/tutorial-sejong-log.log + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%-5level] [%logger{36}.%method:line%line] - %msg%n + + + + /home/anhye0n/web/tutorial_sejong/backend/logs/backup/tutorial-sejong-log-%d{yyyy-MM-dd}.log + 50 + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/static/macro/10.jpg b/src/main/resources/static/macro/10.jpg index 697b15e..7f5a976 100644 Binary files a/src/main/resources/static/macro/10.jpg and b/src/main/resources/static/macro/10.jpg differ diff --git a/src/main/resources/static/macro/11.jpg b/src/main/resources/static/macro/11.jpg index 2fd3d27..f9ae0f9 100644 Binary files a/src/main/resources/static/macro/11.jpg and b/src/main/resources/static/macro/11.jpg differ diff --git a/src/main/resources/static/macro/12.jpg b/src/main/resources/static/macro/12.jpg index e2cdf0b..025c749 100644 Binary files a/src/main/resources/static/macro/12.jpg and b/src/main/resources/static/macro/12.jpg differ diff --git a/src/main/resources/static/macro/13.jpg b/src/main/resources/static/macro/13.jpg index 0ee70dd..e934b81 100644 Binary files a/src/main/resources/static/macro/13.jpg and b/src/main/resources/static/macro/13.jpg differ diff --git a/src/main/resources/static/macro/14.jpg b/src/main/resources/static/macro/14.jpg index 067acc6..7515abb 100644 Binary files a/src/main/resources/static/macro/14.jpg and b/src/main/resources/static/macro/14.jpg differ diff --git a/src/main/resources/static/macro/15.jpg b/src/main/resources/static/macro/15.jpg index 2baaa5e..f2428d9 100644 Binary files a/src/main/resources/static/macro/15.jpg and b/src/main/resources/static/macro/15.jpg differ diff --git a/src/main/resources/static/macro/16.jpg b/src/main/resources/static/macro/16.jpg index af0bb4d..8cd9856 100644 Binary files a/src/main/resources/static/macro/16.jpg and b/src/main/resources/static/macro/16.jpg differ diff --git a/src/main/resources/static/macro/17.jpg b/src/main/resources/static/macro/17.jpg index e111944..b2f0ca3 100644 Binary files a/src/main/resources/static/macro/17.jpg and b/src/main/resources/static/macro/17.jpg differ diff --git a/src/main/resources/static/macro/18.jpg b/src/main/resources/static/macro/18.jpg index f960174..f02a636 100644 Binary files a/src/main/resources/static/macro/18.jpg and b/src/main/resources/static/macro/18.jpg differ diff --git a/src/main/resources/static/macro/19.jpg b/src/main/resources/static/macro/19.jpg index 6df6bd5..451321d 100644 Binary files a/src/main/resources/static/macro/19.jpg and b/src/main/resources/static/macro/19.jpg differ diff --git a/src/main/resources/static/macro/20.jpg b/src/main/resources/static/macro/20.jpg index 5bc9b76..c4e6fec 100644 Binary files a/src/main/resources/static/macro/20.jpg and b/src/main/resources/static/macro/20.jpg differ diff --git a/src/main/resources/static/macro/21.jpg b/src/main/resources/static/macro/21.jpg new file mode 100644 index 0000000..2eb40c7 Binary files /dev/null and b/src/main/resources/static/macro/21.jpg differ diff --git a/src/main/resources/static/macro/22.jpg b/src/main/resources/static/macro/22.jpg new file mode 100644 index 0000000..49f9821 Binary files /dev/null and b/src/main/resources/static/macro/22.jpg differ diff --git a/src/main/resources/static/macro/23.jpg b/src/main/resources/static/macro/23.jpg new file mode 100644 index 0000000..3025305 Binary files /dev/null and b/src/main/resources/static/macro/23.jpg differ diff --git a/src/main/resources/static/macro/24.jpg b/src/main/resources/static/macro/24.jpg new file mode 100644 index 0000000..e9c998b Binary files /dev/null and b/src/main/resources/static/macro/24.jpg differ diff --git a/src/main/resources/static/macro/25.jpg b/src/main/resources/static/macro/25.jpg new file mode 100644 index 0000000..0154648 Binary files /dev/null and b/src/main/resources/static/macro/25.jpg differ diff --git a/src/main/resources/static/macro/26.jpg b/src/main/resources/static/macro/26.jpg new file mode 100644 index 0000000..81f227e Binary files /dev/null and b/src/main/resources/static/macro/26.jpg differ diff --git a/src/main/resources/static/macro/27.jpg b/src/main/resources/static/macro/27.jpg new file mode 100644 index 0000000..2f8cfda Binary files /dev/null and b/src/main/resources/static/macro/27.jpg differ diff --git a/src/main/resources/static/macro/28.jpg b/src/main/resources/static/macro/28.jpg new file mode 100644 index 0000000..bed725b Binary files /dev/null and b/src/main/resources/static/macro/28.jpg differ diff --git a/src/main/resources/static/macro/29.jpg b/src/main/resources/static/macro/29.jpg new file mode 100644 index 0000000..21970b0 Binary files /dev/null and b/src/main/resources/static/macro/29.jpg differ diff --git a/src/main/resources/static/macro/30.jpg b/src/main/resources/static/macro/30.jpg new file mode 100644 index 0000000..208ae9d Binary files /dev/null and b/src/main/resources/static/macro/30.jpg differ diff --git a/src/main/resources/static/macro/6.jpg b/src/main/resources/static/macro/6.jpg index b7e274f..4426435 100644 Binary files a/src/main/resources/static/macro/6.jpg and b/src/main/resources/static/macro/6.jpg differ diff --git a/src/main/resources/static/macro/7.jpg b/src/main/resources/static/macro/7.jpg index 69cba8d..8e8ae93 100644 Binary files a/src/main/resources/static/macro/7.jpg and b/src/main/resources/static/macro/7.jpg differ diff --git a/src/main/resources/static/macro/8.jpg b/src/main/resources/static/macro/8.jpg index f5efc4b..6923717 100644 Binary files a/src/main/resources/static/macro/8.jpg and b/src/main/resources/static/macro/8.jpg differ diff --git a/src/test/java/com/tutorialsejong/courseregistration/CourseRegistrationControllerTest.java b/src/test/java/com/tutorialsejong/courseregistration/CourseRegistrationControllerTest.java index 34e098c..9242435 100644 --- a/src/test/java/com/tutorialsejong/courseregistration/CourseRegistrationControllerTest.java +++ b/src/test/java/com/tutorialsejong/courseregistration/CourseRegistrationControllerTest.java @@ -3,8 +3,8 @@ import static io.restassured.RestAssured.given; import static org.assertj.core.api.Assertions.assertThat; -import com.tutorialsejong.courseregistration.registration.repository.CourseRegistrationRepository; -import com.tutorialsejong.courseregistration.schedule.entity.Schedule; +import com.tutorialsejong.courseregistration.domain.registration.repository.CourseRegistrationRepository; +import com.tutorialsejong.courseregistration.domain.schedule.entity.Schedule; import io.restassured.RestAssured; import io.restassured.http.ContentType; import io.restassured.response.Response;