diff --git a/pom.xml b/pom.xml index 3051501..46f9e7b 100644 --- a/pom.xml +++ b/pom.xml @@ -87,6 +87,13 @@ runtime + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.8.6 + + com.h2database diff --git a/src/main/java/com/gcsc/guide/auth/AuthController.java b/src/main/java/com/gcsc/guide/auth/AuthController.java index 2a6d750..e399059 100644 --- a/src/main/java/com/gcsc/guide/auth/AuthController.java +++ b/src/main/java/com/gcsc/guide/auth/AuthController.java @@ -7,6 +7,13 @@ import com.gcsc.guide.entity.User; import com.gcsc.guide.repository.UserRepository; import com.gcsc.guide.service.ActivityService; import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; +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 io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -21,6 +28,7 @@ import org.springframework.web.server.ResponseStatusException; @RestController @RequestMapping("/api/auth") @RequiredArgsConstructor +@Tag(name = "01. 인증", description = "Google OAuth2 로그인 및 JWT 토큰 관리") public class AuthController { private static final String AUTO_ADMIN_EMAIL = "htlee@gcsc.co.kr"; @@ -30,9 +38,16 @@ public class AuthController { private final UserRepository userRepository; private final ActivityService activityService; - /** - * Google ID Token으로 로그인/회원가입 처리 후 JWT 발급 - */ + @Operation(summary = "Google 로그인", + description = "Google ID Token을 검증하고 JWT를 발급합니다. " + + "신규 사용자는 PENDING 상태로 생성되며, htlee@gcsc.co.kr은 자동 ACTIVE + 관리자 부여됩니다.", + security = {}) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "로그인 성공, JWT 토큰 발급", + content = @Content(schema = @Schema(implementation = AuthResponse.class))), + @ApiResponse(responseCode = "401", description = "유효하지 않은 Google 토큰 또는 허용되지 않은 이메일 도메인", + content = @Content) + }) @PostMapping("/google") public ResponseEntity googleLogin( @Valid @RequestBody GoogleLoginRequest request, @@ -70,9 +85,14 @@ public class AuthController { return ResponseEntity.ok(new AuthResponse(token, UserResponse.from(userWithRoles))); } - /** - * 현재 인증된 사용자 정보 조회 - */ + @Operation(summary = "현재 사용자 정보 조회", + description = "JWT 토큰으로 인증된 현재 사용자의 상세 정보와 롤 목록을 반환합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "사용자 정보 조회 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패 (토큰 없음/만료)", content = @Content), + @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음", content = @Content) + }) + @SecurityRequirement(name = "Bearer JWT") @GetMapping("/me") public ResponseEntity getCurrentUser(Authentication authentication) { Long userId = (Long) authentication.getPrincipal(); @@ -83,9 +103,10 @@ public class AuthController { return ResponseEntity.ok(UserResponse.from(user)); } - /** - * 로그아웃 (Stateless JWT이므로 서버 측 처리 없음, 프론트에서 토큰 삭제) - */ + @Operation(summary = "로그아웃", + description = "Stateless JWT 방식이므로 서버 측 처리 없이 204를 반환합니다. 클라이언트에서 토큰을 삭제하세요.") + @ApiResponse(responseCode = "204", description = "로그아웃 성공") + @SecurityRequirement(name = "Bearer JWT") @PostMapping("/logout") public ResponseEntity logout() { return ResponseEntity.noContent().build(); diff --git a/src/main/java/com/gcsc/guide/config/OpenApiConfig.java b/src/main/java/com/gcsc/guide/config/OpenApiConfig.java new file mode 100644 index 0000000..732b418 --- /dev/null +++ b/src/main/java/com/gcsc/guide/config/OpenApiConfig.java @@ -0,0 +1,54 @@ +package com.gcsc.guide.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Configuration +public class OpenApiConfig { + + private static final String SECURITY_SCHEME_NAME = "Bearer JWT"; + + @Value("${server.port:8080}") + private int serverPort; + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .info(new Info() + .title("GC Guide API") + .description("GC SI 개발자 가이드 사이트 백엔드 API.\n\n" + + "### 인증 방식\n" + + "1. `POST /api/auth/google`에 Google ID Token을 전송하여 JWT를 발급받습니다.\n" + + "2. 발급받은 JWT를 `Authorization: Bearer {token}` 헤더에 포함하여 요청합니다.\n\n" + + "### 권한 구분\n" + + "- **Public**: 인증 없이 접근 가능\n" + + "- **Authenticated**: 로그인 필요 (ACTIVE 상태)\n" + + "- **Admin**: 관리자 권한 필요 (isAdmin=true)") + .version("1.0.0") + .contact(new Contact() + .name("GC SI Dev Team") + .email("htlee@gcsc.co.kr"))) + .servers(List.of( + new Server().url("https://guide.gc-si.dev").description("Production"), + new Server().url("http://localhost:" + serverPort).description("Local"))) + .addSecurityItem(new SecurityRequirement().addList(SECURITY_SCHEME_NAME)) + .components(new Components() + .addSecuritySchemes(SECURITY_SCHEME_NAME, + new SecurityScheme() + .name(SECURITY_SCHEME_NAME) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .description("Google 로그인 후 발급받은 JWT 토큰"))); + } +} diff --git a/src/main/java/com/gcsc/guide/config/SecurityConfig.java b/src/main/java/com/gcsc/guide/config/SecurityConfig.java index b1386a6..f6c7b90 100644 --- a/src/main/java/com/gcsc/guide/config/SecurityConfig.java +++ b/src/main/java/com/gcsc/guide/config/SecurityConfig.java @@ -38,7 +38,10 @@ public class SecurityConfig { "/api/auth/**", "/api/health", "/actuator/health", - "/h2-console/**" + "/h2-console/**", + "/swagger-ui/**", + "/swagger-ui.html", + "/v3/api-docs/**" ).permitAll() .requestMatchers("/api/admin/**").authenticated() .anyRequest().authenticated() diff --git a/src/main/java/com/gcsc/guide/controller/ActivityController.java b/src/main/java/com/gcsc/guide/controller/ActivityController.java index 212d510..ba8ea89 100644 --- a/src/main/java/com/gcsc/guide/controller/ActivityController.java +++ b/src/main/java/com/gcsc/guide/controller/ActivityController.java @@ -3,6 +3,14 @@ package com.gcsc.guide.controller; import com.gcsc.guide.dto.LoginHistoryResponse; import com.gcsc.guide.dto.TrackPageViewRequest; import com.gcsc.guide.service.ActivityService; +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 io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -11,17 +19,21 @@ import org.springframework.web.bind.annotation.*; import java.util.List; -/** - * 활동 기록 API - */ @RestController @RequestMapping("/api/activity") @RequiredArgsConstructor +@Tag(name = "05. 활동 기록", description = "페이지 뷰 추적 및 로그인 이력 조회") +@SecurityRequirement(name = "Bearer JWT") public class ActivityController { private final ActivityService activityService; - /** 페이지 뷰 기록 */ + @Operation(summary = "페이지 뷰 기록", + description = "현재 사용자가 특정 페이지를 조회했음을 기록합니다. 프론트엔드에서 페이지 이동 시 호출합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "기록 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content) + }) @PostMapping("/track") public ResponseEntity trackPageView( Authentication authentication, @@ -31,7 +43,13 @@ public class ActivityController { return ResponseEntity.ok().build(); } - /** 현재 사용자의 로그인 이력 조회 */ + @Operation(summary = "현재 사용자의 로그인 이력 조회", + description = "JWT로 인증된 현재 사용자의 최근 로그인 이력(IP, User-Agent, 시간)을 반환합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = LoginHistoryResponse.class)))), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content) + }) @GetMapping("/login-history") public ResponseEntity> getLoginHistory(Authentication authentication) { Long userId = (Long) authentication.getPrincipal(); diff --git a/src/main/java/com/gcsc/guide/controller/AdminRoleController.java b/src/main/java/com/gcsc/guide/controller/AdminRoleController.java index 6538533..26c6b53 100644 --- a/src/main/java/com/gcsc/guide/controller/AdminRoleController.java +++ b/src/main/java/com/gcsc/guide/controller/AdminRoleController.java @@ -4,6 +4,15 @@ import com.gcsc.guide.dto.AddPermissionRequest; import com.gcsc.guide.dto.CreateRoleRequest; import com.gcsc.guide.dto.RoleResponse; import com.gcsc.guide.service.RoleService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +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 io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -12,61 +21,115 @@ import org.springframework.web.bind.annotation.*; import java.net.URI; import java.util.List; -/** - * 관리자 롤/권한 관리 API - */ @RestController @RequestMapping("/api/admin/roles") @RequiredArgsConstructor +@Tag(name = "03. 관리자 - 롤/권한", description = "롤 CRUD 및 URL 패턴 기반 권한 관리") +@SecurityRequirement(name = "Bearer JWT") public class AdminRoleController { private final RoleService roleService; - /** 전체 롤 목록 */ + @Operation(summary = "전체 롤 목록 조회", + description = "등록된 모든 롤과 각 롤에 할당된 URL 패턴을 조회합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = RoleResponse.class)))), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse(responseCode = "403", description = "관리자 권한 필요", content = @Content) + }) @GetMapping public ResponseEntity> getRoles() { return ResponseEntity.ok(roleService.getRoles()); } - /** 롤 생성 */ + @Operation(summary = "롤 생성", + description = "새로운 롤을 생성합니다. 생성 후 URL 패턴을 별도로 추가해야 합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "롤 생성 성공", + content = @Content(schema = @Schema(implementation = RoleResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse(responseCode = "403", description = "관리자 권한 필요", content = @Content) + }) @PostMapping public ResponseEntity createRole(@Valid @RequestBody CreateRoleRequest request) { RoleResponse role = roleService.createRole(request.name(), request.description()); return ResponseEntity.created(URI.create("/api/admin/roles/" + role.id())).body(role); } - /** 롤 수정 */ + @Operation(summary = "롤 수정", + description = "기존 롤의 이름과 설명을 수정합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "롤 수정 성공", + content = @Content(schema = @Schema(implementation = RoleResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse(responseCode = "403", description = "관리자 권한 필요", content = @Content), + @ApiResponse(responseCode = "404", description = "롤을 찾을 수 없음", content = @Content) + }) @PutMapping("/{id}") public ResponseEntity updateRole( - @PathVariable Long id, + @Parameter(description = "롤 ID", required = true) @PathVariable Long id, @Valid @RequestBody CreateRoleRequest request) { return ResponseEntity.ok(roleService.updateRole(id, request.name(), request.description())); } - /** 롤 삭제 */ + @Operation(summary = "롤 삭제", + description = "롤을 삭제합니다. 해당 롤에 연결된 URL 패턴도 함께 삭제됩니다.") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "롤 삭제 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse(responseCode = "403", description = "관리자 권한 필요", content = @Content), + @ApiResponse(responseCode = "404", description = "롤을 찾을 수 없음", content = @Content) + }) @DeleteMapping("/{id}") - public ResponseEntity deleteRole(@PathVariable Long id) { + public ResponseEntity deleteRole( + @Parameter(description = "롤 ID", required = true) @PathVariable Long id) { roleService.deleteRole(id); return ResponseEntity.noContent().build(); } - /** 롤의 URL 패턴 목록 */ + @Operation(summary = "롤의 URL 패턴 목록 조회", + description = "특정 롤에 할당된 URL 패턴(권한) 목록을 조회합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = String.class)))), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse(responseCode = "403", description = "관리자 권한 필요", content = @Content), + @ApiResponse(responseCode = "404", description = "롤을 찾을 수 없음", content = @Content) + }) @GetMapping("/{id}/permissions") - public ResponseEntity> getPermissions(@PathVariable Long id) { + public ResponseEntity> getPermissions( + @Parameter(description = "롤 ID", required = true) @PathVariable Long id) { return ResponseEntity.ok(roleService.getPermissions(id)); } - /** URL 패턴 추가 */ + @Operation(summary = "URL 패턴 추가", + description = "롤에 새로운 URL 패턴(권한)을 추가합니다. Ant-style 패턴을 지원합니다 (예: /dev/**, /dev/front/**).") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "URL 패턴 추가 성공", + content = @Content(schema = @Schema(implementation = RoleResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse(responseCode = "403", description = "관리자 권한 필요", content = @Content), + @ApiResponse(responseCode = "404", description = "롤을 찾을 수 없음", content = @Content) + }) @PostMapping("/{id}/permissions") public ResponseEntity addPermission( - @PathVariable Long id, + @Parameter(description = "롤 ID", required = true) @PathVariable Long id, @Valid @RequestBody AddPermissionRequest request) { return ResponseEntity.ok(roleService.addPermission(id, request.urlPattern())); } - /** URL 패턴 삭제 */ + @Operation(summary = "URL 패턴 삭제", + description = "특정 URL 패턴(권한)을 삭제합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "URL 패턴 삭제 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse(responseCode = "403", description = "관리자 권한 필요", content = @Content), + @ApiResponse(responseCode = "404", description = "URL 패턴을 찾을 수 없음", content = @Content) + }) @DeleteMapping("/permissions/{permissionId}") - public ResponseEntity deletePermission(@PathVariable Long permissionId) { + public ResponseEntity deletePermission( + @Parameter(description = "URL 패턴 ID", required = true) @PathVariable Long permissionId) { roleService.deletePermission(permissionId); return ResponseEntity.noContent().build(); } diff --git a/src/main/java/com/gcsc/guide/controller/AdminStatsController.java b/src/main/java/com/gcsc/guide/controller/AdminStatsController.java index 0a4a6d7..194c7bf 100644 --- a/src/main/java/com/gcsc/guide/controller/AdminStatsController.java +++ b/src/main/java/com/gcsc/guide/controller/AdminStatsController.java @@ -2,23 +2,36 @@ package com.gcsc.guide.controller; import com.gcsc.guide.dto.StatsResponse; import com.gcsc.guide.service.UserService; +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 io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -/** - * 관리자 통계 API - */ @RestController @RequestMapping("/api/admin/stats") @RequiredArgsConstructor +@Tag(name = "04. 관리자 - 통계", description = "사용자 통계 및 시스템 현황 대시보드") +@SecurityRequirement(name = "Bearer JWT") public class AdminStatsController { private final UserService userService; - /** 전체 통계 조회 */ + @Operation(summary = "전체 통계 조회", + description = "사용자 상태별 수, 오늘 로그인 수, 전체 롤 수 등 시스템 현황 통계를 반환합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "통계 조회 성공", + content = @Content(schema = @Schema(implementation = StatsResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse(responseCode = "403", description = "관리자 권한 필요", content = @Content) + }) @GetMapping public ResponseEntity getStats() { return ResponseEntity.ok(userService.getStats()); diff --git a/src/main/java/com/gcsc/guide/controller/AdminUserController.java b/src/main/java/com/gcsc/guide/controller/AdminUserController.java index 2f53d71..578d185 100644 --- a/src/main/java/com/gcsc/guide/controller/AdminUserController.java +++ b/src/main/java/com/gcsc/guide/controller/AdminUserController.java @@ -3,6 +3,15 @@ package com.gcsc.guide.controller; import com.gcsc.guide.dto.UpdateRolesRequest; import com.gcsc.guide.dto.UserResponse; import com.gcsc.guide.service.UserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +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 io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -10,58 +19,119 @@ import org.springframework.web.bind.annotation.*; import java.util.List; -/** - * 관리자 사용자 관리 API - */ @RestController @RequestMapping("/api/admin/users") @RequiredArgsConstructor +@Tag(name = "02. 관리자 - 사용자", description = "사용자 관리 (승인/거절/비활성화/롤/관리자 권한)") +@SecurityRequirement(name = "Bearer JWT") public class AdminUserController { private final UserService userService; - /** 전체 사용자 목록 조회 (status 필터 선택) */ + @Operation(summary = "전체 사용자 목록 조회", + description = "모든 사용자 목록을 조회합니다. status 파라미터로 특정 상태의 사용자만 필터링할 수 있습니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserResponse.class)))), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse(responseCode = "403", description = "관리자 권한 필요", content = @Content) + }) @GetMapping public ResponseEntity> getUsers( + @Parameter(description = "사용자 상태 필터 (PENDING, ACTIVE, REJECTED, DISABLED)", + example = "PENDING") @RequestParam(required = false) String status) { return ResponseEntity.ok(userService.getUsers(status)); } - /** 사용자 승인 (PENDING → ACTIVE) */ + @Operation(summary = "사용자 승인", + description = "PENDING 상태의 사용자를 ACTIVE로 변경합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "승인 성공", + content = @Content(schema = @Schema(implementation = UserResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse(responseCode = "403", description = "관리자 권한 필요", content = @Content), + @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음", content = @Content) + }) @PutMapping("/{id}/approve") - public ResponseEntity approveUser(@PathVariable Long id) { + public ResponseEntity approveUser( + @Parameter(description = "사용자 ID", required = true) @PathVariable Long id) { return ResponseEntity.ok(userService.approveUser(id)); } - /** 사용자 거절 (PENDING → REJECTED) */ + @Operation(summary = "사용자 거절", + description = "PENDING 상태의 사용자를 REJECTED로 변경합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "거절 성공", + content = @Content(schema = @Schema(implementation = UserResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse(responseCode = "403", description = "관리자 권한 필요", content = @Content), + @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음", content = @Content) + }) @PutMapping("/{id}/reject") - public ResponseEntity rejectUser(@PathVariable Long id) { + public ResponseEntity rejectUser( + @Parameter(description = "사용자 ID", required = true) @PathVariable Long id) { return ResponseEntity.ok(userService.rejectUser(id)); } - /** 사용자 비활성화 (ACTIVE → DISABLED) */ + @Operation(summary = "사용자 비활성화", + description = "ACTIVE 상태의 사용자를 DISABLED로 변경합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "비활성화 성공", + content = @Content(schema = @Schema(implementation = UserResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse(responseCode = "403", description = "관리자 권한 필요", content = @Content), + @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음", content = @Content) + }) @PutMapping("/{id}/disable") - public ResponseEntity disableUser(@PathVariable Long id) { + public ResponseEntity disableUser( + @Parameter(description = "사용자 ID", required = true) @PathVariable Long id) { return ResponseEntity.ok(userService.disableUser(id)); } - /** 사용자 롤 업데이트 */ + @Operation(summary = "사용자 롤 업데이트", + description = "사용자에게 할당된 롤을 변경합니다. 기존 롤을 모두 제거하고 전달된 roleIds로 교체합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "롤 업데이트 성공", + content = @Content(schema = @Schema(implementation = UserResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse(responseCode = "403", description = "관리자 권한 필요", content = @Content), + @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음", content = @Content) + }) @PutMapping("/{id}/roles") public ResponseEntity updateUserRoles( - @PathVariable Long id, + @Parameter(description = "사용자 ID", required = true) @PathVariable Long id, @Valid @RequestBody UpdateRolesRequest request) { return ResponseEntity.ok(userService.updateUserRoles(id, request.roleIds())); } - /** 관리자 권한 부여 */ + @Operation(summary = "관리자 권한 부여", + description = "사용자에게 관리자(isAdmin) 권한을 부여합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "관리자 권한 부여 성공", + content = @Content(schema = @Schema(implementation = UserResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse(responseCode = "403", description = "관리자 권한 필요", content = @Content), + @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음", content = @Content) + }) @PostMapping("/{id}/admin") - public ResponseEntity grantAdmin(@PathVariable Long id) { + public ResponseEntity grantAdmin( + @Parameter(description = "사용자 ID", required = true) @PathVariable Long id) { return ResponseEntity.ok(userService.grantAdmin(id)); } - /** 관리자 권한 해제 */ + @Operation(summary = "관리자 권한 해제", + description = "사용자의 관리자(isAdmin) 권한을 해제합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "관리자 권한 해제 성공", + content = @Content(schema = @Schema(implementation = UserResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse(responseCode = "403", description = "관리자 권한 필요", content = @Content), + @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음", content = @Content) + }) @DeleteMapping("/{id}/admin") - public ResponseEntity revokeAdmin(@PathVariable Long id) { + public ResponseEntity revokeAdmin( + @Parameter(description = "사용자 ID", required = true) @PathVariable Long id) { return ResponseEntity.ok(userService.revokeAdmin(id)); } } diff --git a/src/main/java/com/gcsc/guide/controller/HealthController.java b/src/main/java/com/gcsc/guide/controller/HealthController.java index 13443ca..78452de 100644 --- a/src/main/java/com/gcsc/guide/controller/HealthController.java +++ b/src/main/java/com/gcsc/guide/controller/HealthController.java @@ -1,13 +1,22 @@ package com.gcsc.guide.controller; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; import java.util.Map; @RestController +@Tag(name = "00. 시스템", description = "헬스체크 및 시스템 상태 확인") public class HealthController { + @Operation(summary = "헬스체크", + description = "서버 가동 상태를 확인합니다. 인증 없이 접근 가능합니다.", + security = {}) + @ApiResponse(responseCode = "200", description = "서버 정상 가동 중") @GetMapping("/api/health") public Map health() { return Map.of( diff --git a/src/main/java/com/gcsc/guide/controller/IssueController.java b/src/main/java/com/gcsc/guide/controller/IssueController.java index 5852b9e..b765780 100644 --- a/src/main/java/com/gcsc/guide/controller/IssueController.java +++ b/src/main/java/com/gcsc/guide/controller/IssueController.java @@ -2,6 +2,14 @@ package com.gcsc.guide.controller; import com.gcsc.guide.dto.*; import com.gcsc.guide.service.IssueService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +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 io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -15,25 +23,37 @@ import org.springframework.web.bind.annotation.*; import java.net.URI; import java.util.Map; -/** - * 이슈 관리 API - */ @RestController @RequestMapping("/api/issues") @RequiredArgsConstructor +@Tag(name = "06. 이슈 관리", description = "이슈 등록/조회/수정 및 코멘트 관리") +@SecurityRequirement(name = "Bearer JWT") public class IssueController { private final IssueService issueService; - /** 이슈 목록 (status 필터, 페이징) */ + @Operation(summary = "이슈 목록 조회", + description = "이슈 목록을 페이징으로 조회합니다. status 파라미터로 상태별 필터링이 가능합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content) + }) @GetMapping public ResponseEntity> getIssues( + @Parameter(description = "이슈 상태 필터 (OPEN, IN_PROGRESS, CLOSED)", example = "OPEN") @RequestParam(required = false) String status, + @Parameter(description = "페이징 파라미터 (page, size, sort)") @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) { return ResponseEntity.ok(issueService.getIssues(status, pageable)); } - /** 이슈 생성 */ + @Operation(summary = "이슈 생성", + description = "새로운 이슈를 생성합니다. 프로젝트명, 위치, Gitea 이슈 링크 등을 선택적으로 포함할 수 있습니다.") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "이슈 생성 성공", + content = @Content(schema = @Schema(implementation = IssueResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content) + }) @PostMapping public ResponseEntity createIssue( Authentication authentication, @@ -43,24 +63,45 @@ public class IssueController { return ResponseEntity.created(URI.create("/api/issues/" + issue.id())).body(issue); } - /** 이슈 상세 (코멘트 포함) */ + @Operation(summary = "이슈 상세 조회", + description = "이슈의 상세 정보와 코멘트 목록을 함께 반환합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse(responseCode = "404", description = "이슈를 찾을 수 없음", content = @Content) + }) @GetMapping("/{id}") - public ResponseEntity> getIssue(@PathVariable Long id) { + public ResponseEntity> getIssue( + @Parameter(description = "이슈 ID", required = true) @PathVariable Long id) { return ResponseEntity.ok(issueService.getIssueDetail(id)); } - /** 이슈 수정 */ + @Operation(summary = "이슈 수정", + description = "이슈의 제목, 내용, 상태, 우선순위, 담당자 등을 수정합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "수정 성공", + content = @Content(schema = @Schema(implementation = IssueResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse(responseCode = "404", description = "이슈를 찾을 수 없음", content = @Content) + }) @PutMapping("/{id}") public ResponseEntity updateIssue( - @PathVariable Long id, + @Parameter(description = "이슈 ID", required = true) @PathVariable Long id, @RequestBody UpdateIssueRequest request) { return ResponseEntity.ok(issueService.updateIssue(id, request)); } - /** 코멘트 추가 */ + @Operation(summary = "코멘트 추가", + description = "이슈에 새로운 코멘트를 추가합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "코멘트 추가 성공", + content = @Content(schema = @Schema(implementation = IssueCommentResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse(responseCode = "404", description = "이슈를 찾을 수 없음", content = @Content) + }) @PostMapping("/{id}/comments") public ResponseEntity addComment( - @PathVariable Long id, + @Parameter(description = "이슈 ID", required = true) @PathVariable Long id, Authentication authentication, @Valid @RequestBody CreateCommentRequest request) { Long authorId = (Long) authentication.getPrincipal(); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b625f85..3d589d5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -31,6 +31,17 @@ app: cors: allowed-origins: ${CORS_ORIGINS:http://localhost:5173,https://guide.gc-si.dev} +# SpringDoc / Swagger +springdoc: + api-docs: + path: /v3/api-docs + swagger-ui: + path: /swagger-ui.html + tags-sorter: alpha + operations-sorter: method + doc-expansion: none + display-request-duration: true + # Actuator management: endpoints: