diff --git a/build.gradle b/build.gradle index ee2963c..47d9ccc 100644 --- a/build.gradle +++ b/build.gradle @@ -22,14 +22,15 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'me.paulschwarz:spring-dotenv:4.0.0' - implementation 'org.springframework.boot:spring-boot-starter-oauth2-client:3.2.4' - implementation 'com.nimbusds:nimbus-jose-jwt:9.37.2' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' // 3.3.x 버전으로 변경 없음 implementation 'io.jsonwebtoken:jjwt-api:0.11.2' implementation 'io.jsonwebtoken:jjwt-impl:0.11.2' implementation 'io.jsonwebtoken:jjwt-jackson:0.11.2' // 또는 jjwt-gson 사용 시 'io.jsonwebtoken:jjwt-gson:0.11.2' - compileOnly 'org.projectlombok:lombok:1.18.30' // 최신 버전 확인 후 사용 + implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.2' + implementation 'com.fasterxml.jackson.core:jackson-core:2.15.2' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.1.0' // Spring Boot 3.3.4에 맞는 2.x 버전 + compileOnly 'org.projectlombok:lombok:1.18.30' annotationProcessor 'org.projectlombok:lombok:1.18.30' - developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'com.mysql:mysql-connector-j' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' diff --git a/src/main/java/com/mtvs/devlinkbackend/config/CorsConfig.java b/src/main/java/com/mtvs/devlinkbackend/config/CorsConfig.java new file mode 100644 index 0000000..1cecdac --- /dev/null +++ b/src/main/java/com/mtvs/devlinkbackend/config/CorsConfig.java @@ -0,0 +1,39 @@ +package com.mtvs.devlinkbackend.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +@Configuration +public class CorsConfig { + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + + configuration.addAllowedOrigin("http://125.132.216.190:15530"); + configuration.addAllowedOrigin("http://localhost:8080"); + configuration.addAllowedOrigin("http://localhost:5173"); // 테스트에서 사용되는 도메인 추가 + + configuration.addAllowedMethod("GET"); + configuration.addAllowedMethod("POST"); + configuration.addAllowedMethod("PUT"); + configuration.addAllowedMethod("PATCH"); + configuration.addAllowedMethod("DELETE"); + + configuration.addAllowedHeader("*"); // 모든 헤더 허용 + + configuration.setAllowCredentials(true); // 인증 정보 포함 허용 + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } + + @Bean + public CorsFilter corsFilter(CorsConfigurationSource corsConfigurationSource) { + return new CorsFilter(corsConfigurationSource); + } +} diff --git a/src/main/java/com/mtvs/devlinkbackend/config/JwtAuthenticationFilter.java b/src/main/java/com/mtvs/devlinkbackend/config/JwtAuthenticationFilter.java index a248ef6..c497b64 100644 --- a/src/main/java/com/mtvs/devlinkbackend/config/JwtAuthenticationFilter.java +++ b/src/main/java/com/mtvs/devlinkbackend/config/JwtAuthenticationFilter.java @@ -28,6 +28,8 @@ public JwtAuthenticationFilter(JwtUtil jwtUtil, EpicGamesTokenService epicGamesT protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { + System.out.println("JwtAuthenticationFilter 실행됨: " + request.getRequestURI()); + // Authorization 헤더에서 Bearer 토큰 추출 String authorizationHeader = request.getHeader("Authorization"); String token = null; @@ -45,29 +47,41 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse // 새로 발급된 액세스 토큰을 Authorization 헤더에 추가 response.setHeader("Authorization", "Bearer " + token); } else { - System.out.println("refrehToken으로 AccessToken 발급하려다가 refreshToken 없어서 실패"); + System.out.println("refrehToken으로 accessToken 발급하려다가 refreshToken 없어서 실패"); + return; } } - if (token != null) { // refreshToken도 없어 AccessToken이 아예 없는 경우 지나가기 - try { - // 토큰 검증 | 검증 성공 시 SecurityContext에 인증 정보 저장 - String userPrincipal = jwtUtil.getSubjectFromTokenWithAuth(token); - UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken(userPrincipal, null, null); + try { + // 토큰 검증 | 검증 성공 시 SecurityContext에 인증 정보 저장 + String userPrincipal = jwtUtil.getSubjectFromTokenWithAuth(token); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userPrincipal, null, null); - SecurityContextHolder.getContext().setAuthentication(authentication); - } catch (Exception e) { - // 검증 실패 시 401 에러 설정 - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - return; - } + SecurityContextHolder.getContext().setAuthentication(authentication); + } catch (Exception e) { + // 검증 실패 시 401 에러 설정 + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; } // 필터 체인 진행 chain.doFilter(request, response); } + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getRequestURI(); + // Swagger 관련 모든 경로 예외 처리 + return path.startsWith("/swagger-ui") + || path.startsWith("/v3/api-docs") + || path.equals("/swagger-ui.html") + || path.startsWith("/swagger-resources") + || path.startsWith("/webjars") + || path.startsWith("/login") + || path.startsWith("/**"); + } + // 쿠키에서 리프레시 토큰을 추출하는 메서드 private String getRefreshTokenFromCookies(HttpServletRequest request) { if (request.getCookies() != null) { diff --git a/src/main/java/com/mtvs/devlinkbackend/config/SecurityConfig.java b/src/main/java/com/mtvs/devlinkbackend/config/SecurityConfig.java index a5c3aaa..de37e30 100644 --- a/src/main/java/com/mtvs/devlinkbackend/config/SecurityConfig.java +++ b/src/main/java/com/mtvs/devlinkbackend/config/SecurityConfig.java @@ -9,6 +9,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; @@ -21,10 +22,8 @@ import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.logout.LogoutHandler; -import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import org.springframework.web.filter.CorsFilter; +import org.springframework.security.web.session.InvalidSessionStrategy; +import org.springframework.web.cors.CorsConfigurationSource; @Configuration @EnableWebSecurity @@ -32,10 +31,12 @@ public class SecurityConfig { private final UserService userService; private final OAuth2AuthorizedClientService authorizedClientService; + private final CorsConfigurationSource corsConfigurationSource; private final JwtAuthenticationFilter jwtAuthenticationFilter; - public SecurityConfig(UserService userService, OAuth2AuthorizedClientService authorizedClientService, JwtAuthenticationFilter jwtAuthenticationFilter) { + public SecurityConfig(UserService userService, CorsConfigurationSource corsConfigurationSource, OAuth2AuthorizedClientService authorizedClientService, JwtAuthenticationFilter jwtAuthenticationFilter) { this.userService = userService; + this.corsConfigurationSource = corsConfigurationSource; this.authorizedClientService = authorizedClientService; this.jwtAuthenticationFilter = jwtAuthenticationFilter; } @@ -43,31 +44,53 @@ public SecurityConfig(UserService userService, OAuth2AuthorizedClientService aut @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http + .cors(cors -> cors.configurationSource(corsConfigurationSource)) // CORS 설정 + .csrf(AbstractHttpConfigurer::disable) // CSRF 비활성화 + .sessionManagement(sessionManagement -> sessionManagement + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) .authorizeHttpRequests(authorizeRequests -> authorizeRequests - .requestMatchers("*", "/question/**", "/login").permitAll() // "*"에 대한 설정은 CorsTest를 위함 + .requestMatchers( + "/api/auth/epicgames/callback", // 토큰 호출 부분 + "/login", + "/v3/api-docs/**", // Swagger API Docs 경로 + "/swagger-ui/**", // Swagger UI 정적 리소스 경로 + "/swagger-ui.html", // Swagger UI 페이지 경로 + "/swagger-resources/**", // Swagger 관련 리소스 경로 + "/webjars/**", // Webjars로 제공되는 Swagger 리소스 경로 + "/configuration/ui", // 추가 Swagger 설정 경로 + "/configuration/security", // 추가 Swagger 설정 경로 + "/error" // 오류 페이지 경로 허용 + ).permitAll() // Swagger 관련 경로 허용 .anyRequest().authenticated() ) + + // oauth2Login 설정이 다른 경로에서만 작동하도록 설정 .oauth2Login(oauth2Login -> oauth2Login .loginPage("/login") .defaultSuccessUrl("/", true) + .failureUrl("/login?error=true") .userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint .userService(oauth2UserService()) ) - .successHandler(oauth2AuthenticationSuccessHandler()) // 성공 핸들러 추가 - ) - .logout(logout -> logout - .logoutUrl("/logout") - .addLogoutHandler(logoutHandler()) - .logoutSuccessHandler(logoutSuccessHandler()) + .successHandler(oauth2AuthenticationSuccessHandler()) ) - // 세션을 생성하지 않도록 설정 - .sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - // JWT 필터 추가 - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) - .addFilter(corsFilter()); +// .logout(logout -> logout +// .logoutUrl("/logout") +// .addLogoutHandler(logoutHandler()) +// .logoutSuccessUrl("/") +// ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); } + // 가능성 : + // "/swagger" 경로로 들어온 요청은 필터에서 permitAll로 권한 인증을 통과했지만 + // "index.html"로 서블릿 포워딩 되면서 시큐리티 필터를 다시 거치게 되는데 권한 없음으로 403이 내려오는 것이다. + // 요청 한번에 권한 인증이 여러번 되는 이유는 직접 구현한 토큰 인증 필터와 같은 경우 OncePerRequestFilter를 확장해서 + // 요청 당 한번만 거치도록 설정하지만 기본적으로 필터는 서블릿에 대한 매요청마다 거치는 것이 기본 전략임 + @Bean public OAuth2UserService oauth2UserService() { return new DefaultOAuth2UserService(); @@ -76,6 +99,7 @@ public OAuth2UserService oauth2UserService() { @Bean public AuthenticationSuccessHandler oauth2AuthenticationSuccessHandler() { return (HttpServletRequest request, HttpServletResponse response, Authentication authentication) -> { + System.out.println("여기 오냐"); OAuth2User oauthUser = (OAuth2User) authentication.getPrincipal(); // OAuth2AuthorizedClient를 사용하여 액세스 토큰과 리프레시 토큰 가져오기 @@ -97,46 +121,21 @@ public AuthenticationSuccessHandler oauth2AuthenticationSuccessHandler() { }; } - @Bean - public LogoutHandler logoutHandler() { - return (HttpServletRequest request, HttpServletResponse response, Authentication authentication) -> { - // 쿠키 삭제 - deleteCookie(response, "access_token"); - deleteCookie(response, "refresh_token"); - }; - } - - @Bean - public LogoutSuccessHandler logoutSuccessHandler() { - return (HttpServletRequest request, HttpServletResponse response, Authentication authentication) -> { - // 로그아웃 성공 후 리디렉트 - response.sendRedirect("/login?logout"); - }; - } - - private void deleteCookie(HttpServletResponse response, String cookieName) { - Cookie cookie = new Cookie(cookieName, null); - cookie.setPath("/"); - cookie.setMaxAge(0); // 쿠키 즉시 삭제 - cookie.setHttpOnly(true); - cookie.setSecure(true); - response.addCookie(cookie); - } - - @Bean - public CorsFilter corsFilter() { - CorsConfiguration configuration = new CorsConfiguration(); - configuration.addAllowedOrigin("http://localhost:5173"); // 테스트에서 사용되는 도메인 추가 - configuration.addAllowedMethod("GET"); - configuration.addAllowedMethod("POST"); - configuration.addAllowedMethod("PUT"); - configuration.addAllowedMethod("PATCH"); - configuration.addAllowedMethod("DELETE"); - configuration.addAllowedHeader("*"); // 모든 헤더 허용 - configuration.setAllowCredentials(true); // 인증 정보 포함 허용 - - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", configuration); // 모든 경로에 대해 CORS 설정 적용 - return new CorsFilter(source); - } +// @Bean +// public LogoutHandler logoutHandler() { +// return (HttpServletRequest request, HttpServletResponse response, Authentication authentication) -> { +// // 쿠키 삭제 +// deleteCookie(response, "access_token"); +// deleteCookie(response, "refresh_token"); +// }; +// } +// +// private void deleteCookie(HttpServletResponse response, String cookieName) { +// Cookie cookie = new Cookie(cookieName, null); +// cookie.setPath("/"); +// cookie.setMaxAge(0); // 쿠키 즉시 삭제 +// cookie.setHttpOnly(true); +// cookie.setSecure(true); +// response.addCookie(cookie); +// } } \ No newline at end of file diff --git a/src/main/java/com/mtvs/devlinkbackend/config/SwaggerConfig.java b/src/main/java/com/mtvs/devlinkbackend/config/SwaggerConfig.java new file mode 100644 index 0000000..646af86 --- /dev/null +++ b/src/main/java/com/mtvs/devlinkbackend/config/SwaggerConfig.java @@ -0,0 +1,18 @@ +package com.mtvs.devlinkbackend.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + @Bean + public OpenAPI customOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("DevLink API Documentation") + .version("1.0.0") + .description("API documentation for DevLink Backend")); + } +} diff --git a/src/main/java/com/mtvs/devlinkbackend/config/WebConfig.java b/src/main/java/com/mtvs/devlinkbackend/config/WebConfig.java new file mode 100644 index 0000000..efddc4b --- /dev/null +++ b/src/main/java/com/mtvs/devlinkbackend/config/WebConfig.java @@ -0,0 +1,15 @@ +package com.mtvs.devlinkbackend.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + registry.addResourceHandler("/swagger-ui/**") + .addResourceLocations("classpath:/META-INF/resources/webjars/springdoc-openapi-ui/") + .setCachePeriod(3600); + } +} diff --git a/src/main/java/com/mtvs/devlinkbackend/ether/controller/EtherController.java b/src/main/java/com/mtvs/devlinkbackend/ether/controller/EtherController.java index 8eab829..3aec6a7 100644 --- a/src/main/java/com/mtvs/devlinkbackend/ether/controller/EtherController.java +++ b/src/main/java/com/mtvs/devlinkbackend/ether/controller/EtherController.java @@ -5,6 +5,9 @@ import com.mtvs.devlinkbackend.ether.dto.EtherUpdateRequestDTO; import com.mtvs.devlinkbackend.ether.entity.Ether; import com.mtvs.devlinkbackend.ether.service.EtherService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -21,7 +24,12 @@ public EtherController(EtherService etherService, JwtUtil jwtUtil) { this.jwtUtil = jwtUtil; } - // Create + @Operation(summary = "새 에테르 이력 등록", description = "새 에테르 이력를 등록합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "에테르가 성공적으로 등록됨"), + @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터"), + @ApiResponse(responseCode = "401", description = "인증되지 않음") + }) @PostMapping public ResponseEntity registEther( @RequestBody EtherRegistRequestDTO etherRegistRequestDTO, @@ -32,14 +40,23 @@ public ResponseEntity registEther( return ResponseEntity.ok(newEther); } - // Read - Find by Ether ID + @Operation(summary = "Ether ID로 Ether 조회", description = "Ether ID를 사용하여 Ether를 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Ether를 성공적으로 찾음"), + @ApiResponse(responseCode = "401", description = "인증되지 않음"), + @ApiResponse(responseCode = "404", description = "Ether를 찾을 수 없음") + }) @GetMapping("/{etherId}") public ResponseEntity findEtherByEtherId(@PathVariable Long etherId) { Ether ether = etherService.findEtherByEtherId(etherId); return ether != null ? ResponseEntity.ok(ether) : ResponseEntity.notFound().build(); } - // Read - Find by Account ID + @Operation(summary = "계정 ID로 Ether 목록 조회", description = "계정 ID를 사용하여 관련된 모든 Ether를 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Ether 목록을 성공적으로 찾음"), + @ApiResponse(responseCode = "401", description = "인증되지 않음") + }) @GetMapping("/account") public ResponseEntity> findEthersByAccountId( @RequestHeader(name = "Authorization") String authorizationHeader) throws Exception { @@ -49,14 +66,23 @@ public ResponseEntity> findEthersByAccountId( return ResponseEntity.ok(ethers); } - // Read - Find by Reason + @Operation(summary = "이유로 Ether 목록 조회", description = "지정된 이유로 관련된 모든 Ether를 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Ether 목록을 성공적으로 찾음"), + @ApiResponse(responseCode = "401", description = "인증되지 않음") + }) @GetMapping("/reason/{reason}") public ResponseEntity> findEthersByReason(@PathVariable String reason) { List ethers = etherService.findEthersByReason(reason); return ResponseEntity.ok(ethers); } - // Update + @Operation(summary = "Ether 수정", description = "제공된 데이터를 기반으로 특정 Ether를 수정합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Ether가 성공적으로 수정됨"), + @ApiResponse(responseCode = "400", description = "잘못된 수정 데이터"), + @ApiResponse(responseCode = "401", description = "인증되지 않음") + }) @PatchMapping public ResponseEntity updateEther(@RequestBody EtherUpdateRequestDTO etherUpdateRequestDTO) { try { @@ -67,7 +93,12 @@ public ResponseEntity updateEther(@RequestBody EtherUpdateRequestDTO ethe } } - // Delete + @Operation(summary = "Ether 삭제", description = "Ether ID를 사용하여 Ether를 삭제합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "204", description = "Ether가 성공적으로 삭제됨"), + @ApiResponse(responseCode = "401", description = "인증되지 않음"), + @ApiResponse(responseCode = "404", description = "Ether를 찾을 수 없음") + }) @DeleteMapping("/{etherId}") public ResponseEntity deleteEtherByEtherId(@PathVariable Long etherId) { etherService.deleteEtherByEtherId(etherId); diff --git a/src/main/java/com/mtvs/devlinkbackend/ether/entity/Ether.java b/src/main/java/com/mtvs/devlinkbackend/ether/entity/Ether.java index f9ab298..0c8464a 100644 --- a/src/main/java/com/mtvs/devlinkbackend/ether/entity/Ether.java +++ b/src/main/java/com/mtvs/devlinkbackend/ether/entity/Ether.java @@ -1,7 +1,8 @@ package com.mtvs.devlinkbackend.ether.entity; import jakarta.persistence.*; -import lombok.NoArgsConstructor; +import lombok.Getter; +import lombok.ToString; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; @@ -9,7 +10,8 @@ @Table(name = "ETHER") @Entity(name = "ETHER") -@NoArgsConstructor +@Getter +@ToString public class Ether { @Id @@ -34,6 +36,8 @@ public class Ether { @Column(name = "ACCOUNT_ID") private String accountId; + public Ether () {} + public Ether(String accountId, String reason, Long amount) { this.accountId = accountId; this.reason = reason; diff --git a/src/main/java/com/mtvs/devlinkbackend/oauth2/controller/Oauth2UserController.java b/src/main/java/com/mtvs/devlinkbackend/oauth2/controller/Oauth2UserController.java index a503a8b..6dcfb3c 100644 --- a/src/main/java/com/mtvs/devlinkbackend/oauth2/controller/Oauth2UserController.java +++ b/src/main/java/com/mtvs/devlinkbackend/oauth2/controller/Oauth2UserController.java @@ -1,7 +1,11 @@ package com.mtvs.devlinkbackend.oauth2.controller; +import com.mtvs.devlinkbackend.oauth2.dto.EpicGamesCallbackRequestDTO; import com.mtvs.devlinkbackend.oauth2.service.EpicGamesTokenService; import com.mtvs.devlinkbackend.oauth2.service.UserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletResponse; import org.springframework.http.*; @@ -23,6 +27,15 @@ public Oauth2UserController(EpicGamesTokenService epicGamesTokenService, UserSer // 로컬 user 정보 가져오는 API @GetMapping("/local/user-info") + @Operation( + summary = "로컬 유저 정보 조회", + description = "DevLink만의 DB에 저장된 유저 정보를 조회한다." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 헤더 또는 파라미터 전달"), + @ApiResponse(responseCode = "401", description = "인증되지 않음") + }) public ResponseEntity getLocalUserInfo(@RequestHeader("Authorization") String authorizationHeader) { try { String token = extractToken(authorizationHeader); @@ -35,6 +48,15 @@ public ResponseEntity getLocalUserInfo(@RequestHeader("Authorization") String // epicgames 계정 정보 가져오는 API @GetMapping("/epicgames/user-info") + @Operation( + summary = "EpicGames 유저 정보 조회", + description = "EpicGames의 유저 정보를 조회한다." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 헤더 또는 파라미터 전달"), + @ApiResponse(responseCode = "401", description = "인증되지 않음") + }) public ResponseEntity getEpicGamesUserInfo( @RequestHeader("Authorization") String authorizationHeader) { @@ -49,10 +71,18 @@ public ResponseEntity getEpicGamesUserInfo( } @PostMapping("/epicgames/callback") + @Operation( + summary = "EpicGames AccessToken 요청", + description = "EpicGames로부터 사용자에게 AccessToken을 전달한다." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "AccessToken 생성 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 헤더 또는 파라미터 전달"), + }) public ResponseEntity handleEpicGamesCallback( - @RequestBody Map payload, HttpServletResponse response) { + @RequestBody EpicGamesCallbackRequestDTO payload, HttpServletResponse response) { - String code = payload.get("code"); + String code = payload.getCode(); Map tokenBody = epicGamesTokenService.getAccessTokenAndRefreshTokenByCode(code); @@ -71,7 +101,7 @@ public ResponseEntity handleEpicGamesCallback( response.addCookie(accessTokenCookie); response.addCookie(refreshTokenCookie); - return ResponseEntity.ok().build(); + return ResponseEntity.status(HttpStatus.CREATED).build(); } return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); diff --git a/src/main/java/com/mtvs/devlinkbackend/oauth2/dto/EpicGamesCallbackRequestDTO.java b/src/main/java/com/mtvs/devlinkbackend/oauth2/dto/EpicGamesCallbackRequestDTO.java new file mode 100644 index 0000000..07f535f --- /dev/null +++ b/src/main/java/com/mtvs/devlinkbackend/oauth2/dto/EpicGamesCallbackRequestDTO.java @@ -0,0 +1,13 @@ +package com.mtvs.devlinkbackend.oauth2.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; + +@Getter @Setter +@NoArgsConstructor +@AllArgsConstructor +@ToString +public class EpicGamesCallbackRequestDTO { + @Schema(description = "Authorization code returned from Epic Games", example = "cfd1de1a8d224203b0445fe977838d81") + private String code; +} diff --git a/src/main/java/com/mtvs/devlinkbackend/question/controller/QuestionController.java b/src/main/java/com/mtvs/devlinkbackend/question/controller/QuestionController.java index 7169b22..da7343c 100644 --- a/src/main/java/com/mtvs/devlinkbackend/question/controller/QuestionController.java +++ b/src/main/java/com/mtvs/devlinkbackend/question/controller/QuestionController.java @@ -5,6 +5,9 @@ import com.mtvs.devlinkbackend.question.dto.QuestionUpdateRequestDTO; import com.mtvs.devlinkbackend.question.entity.Question; import com.mtvs.devlinkbackend.question.service.QuestionService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -26,6 +29,15 @@ public QuestionController(QuestionService questionService, JwtUtil jwtUtil) { // Create a new question @PostMapping + @Operation( + summary = "공개 질문 생성", + description = "사용자가 올린 공개 질문을 생성한다." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "질문 생성 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 헤더 또는 파라미터 전달"), + @ApiResponse(responseCode = "401", description = "인증되지 않음") + }) public ResponseEntity createQuestion( @RequestBody QuestionRegistRequestDTO questionRegistRequestDTO, @RequestHeader(name = "Authorization") String accessToken) throws Exception { @@ -37,6 +49,15 @@ public ResponseEntity createQuestion( // Retrieve a question by ID @GetMapping("/{questionId}") + @Operation( + summary = "PK에 따른 질문 조회", + description = "PK값으로 사용자가 올린 공개 질문 1개를 조회한다." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 헤더 또는 파라미터 전달"), + @ApiResponse(responseCode = "401", description = "인증되지 않음") + }) public ResponseEntity getQuestionById(@PathVariable Long questionId) { Question question = questionService.findQuestionByQuestionId(questionId); if (question != null) { @@ -48,6 +69,15 @@ public ResponseEntity getQuestionById(@PathVariable Long questionId) { // Retrieve all questions with pagination @GetMapping("/all") + @Operation( + summary = "Pagination으로 전체 질문 조회", + description = "Pagination으로 공개 질문 전체를 조회한다. 최대 20개가 주어진다" + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 헤더 또는 파라미터 전달"), + @ApiResponse(responseCode = "401", description = "인증되지 않음") + }) public ResponseEntity> getAllQuestionsWithPaging(@RequestParam int page) { List questions = questionService.findAllQuestionsWithPaging(page); return ResponseEntity.ok(questions); @@ -55,6 +85,15 @@ public ResponseEntity> getAllQuestionsWithPaging(@RequestParam in // Retrieve questions by account ID with pagination @GetMapping + @Operation( + summary = "Pagination으로 로그인한 사용자의 질문 조회", + description = "Pagination으로 사용자가 했던 질문 전체를 조회한다. 최대 20개가 주어진다" + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 헤더 또는 파라미터 전달"), + @ApiResponse(responseCode = "401", description = "인증되지 않음") + }) public ResponseEntity> getQuestionsByAccountIdWithPaging( @RequestParam int page, @RequestHeader(name = "Authorization") String accessToken) throws Exception { @@ -66,6 +105,15 @@ public ResponseEntity> getQuestionsByAccountIdWithPaging( // Update a question by ID @PatchMapping("/{id}") + @Operation( + summary = "사용자 질문 수정", + description = "사용자가 했던 질문을 수정한다." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "수정 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 헤더 또는 파라미터 전달"), + @ApiResponse(responseCode = "401", description = "인증되지 않음") + }) public ResponseEntity updateQuestion( @RequestBody QuestionUpdateRequestDTO questionUpdateRequestDTO, @RequestHeader(name = "Authorization") String accessToken) throws Exception { @@ -81,6 +129,15 @@ public ResponseEntity updateQuestion( // Delete a question by ID @DeleteMapping("/{questionId}") + @Operation( + summary = "사용자 질문 삭제", + description = "사용자가 했던 질문을 삭제한다" + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "수정 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 헤더 또는 파라미터 전달"), + @ApiResponse(responseCode = "401", description = "인증되지 않음") + }) public ResponseEntity deleteQuestion(@PathVariable Long questionId) { questionService.deleteQuestion(questionId); diff --git a/src/main/java/com/mtvs/devlinkbackend/reply/controller/ReplyController.java b/src/main/java/com/mtvs/devlinkbackend/reply/controller/ReplyController.java index 1314eae..c26ed24 100644 --- a/src/main/java/com/mtvs/devlinkbackend/reply/controller/ReplyController.java +++ b/src/main/java/com/mtvs/devlinkbackend/reply/controller/ReplyController.java @@ -5,6 +5,9 @@ import com.mtvs.devlinkbackend.reply.dto.ReplyUpdateRequestDTO; import com.mtvs.devlinkbackend.reply.entity.Reply; import com.mtvs.devlinkbackend.reply.service.ReplyService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -22,6 +25,12 @@ public ReplyController(ReplyService replyService, JwtUtil jwtUtil) { this.jwtUtil = jwtUtil; } + @Operation(summary = "새 댓글 등록", description = "질문에 대한 새 댓글을 등록합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "댓글이 성공적으로 등록됨"), + @ApiResponse(responseCode = "400", description = "잘못된 입력"), + @ApiResponse(responseCode = "401", description = "인증되지 않음") + }) @PostMapping public ResponseEntity registReply( @RequestBody ReplyRegistRequestDTO replyRegistRequestDTO, @@ -32,18 +41,36 @@ public ResponseEntity registReply( return ResponseEntity.ok(reply); } + @Operation(summary = "PK로 댓글 조회", description = "댓글 ID, 즉 PK를 통해 댓글을 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "댓글을 성공적으로 찾음"), + @ApiResponse(responseCode = "401", description = "인증되지 않음"), + @ApiResponse(responseCode = "404", description = "댓글을 찾을 수 없음") + }) @GetMapping("/{replyId}") public ResponseEntity findReplyByReplyId(@PathVariable Long replyId) { Reply reply = replyService.findReplyByReplyId(replyId); return reply != null ? ResponseEntity.ok(reply) : ResponseEntity.notFound().build(); } + @Operation(summary = "질문 ID로 댓글 목록 조회", description = "질문 ID로 연결된 모든 댓글을 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "댓글을 성공적으로 찾음"), + @ApiResponse(responseCode = "401", description = "인증되지 않음"), + @ApiResponse(responseCode = "404", description = "댓글을 찾을 수 없음") + }) @GetMapping("/question/{questionId}") public ResponseEntity> findRepliesByQuestionId(@PathVariable Long questionId) { List replies = replyService.findRepliesByQuestionId(questionId); return ResponseEntity.ok(replies); } + @Operation(summary = "계정 ID로 댓글 목록 조회", description = "인증된 계정과 연결된 모든 댓글을 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "댓글 목록을 성공적으로 찾음"), + @ApiResponse(responseCode = "401", description = "인증되지 않음"), + @ApiResponse(responseCode = "404", description = "댓글을 찾을 수 없음") + }) @GetMapping("/account/{accountId}") public ResponseEntity> findRepliesByAccountId( @RequestHeader(name = "Authorization") String token) throws Exception { @@ -53,6 +80,12 @@ public ResponseEntity> findRepliesByAccountId( return ResponseEntity.ok(replies); } + @Operation(summary = "댓글 수정", description = "제공된 데이터를 기반으로 특정 댓글을 수정합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "댓글이 성공적으로 수정됨"), + @ApiResponse(responseCode = "400", description = "잘못된 수정 데이터"), + @ApiResponse(responseCode = "401", description = "인증되지 않음") + }) @PatchMapping public ResponseEntity updateReply( @RequestBody ReplyUpdateRequestDTO replyUpdateRequestDTO, @@ -67,6 +100,12 @@ public ResponseEntity updateReply( } } + @Operation(summary = "댓글 삭제", description = "댓글 ID로 댓글을 삭제합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "204", description = "댓글이 성공적으로 삭제됨"), + @ApiResponse(responseCode = "400", description = "잘못된 파라미터"), + @ApiResponse(responseCode = "401", description = "인증되지 않음") + }) @DeleteMapping("/{replyId}") public ResponseEntity deleteReply( @PathVariable Long replyId) { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2880310..d41b934 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -32,3 +32,19 @@ spring: properties: hibernate: format_sql: true +springdoc: + api-docs: + path: /v3/api-docs + swagger-ui: + path: /swagger-ui.html + display-operation-id: true # 메서드 ID 표시 + default-model-expand-depth: 1 # 모델 깊이 + doc-expansion: none # 기본 확장 상태 (none, list, full) + show-extensions: true # 확장 정보 표시 + operationsSorter: alpha # 알파벳순 정렬 + tagsSorter: alpha # 태그 알파벳순 정렬 +logging: + level: + org: + springframework: + security: DEBUG \ No newline at end of file