Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@


import com.gpt.geumpumtabackend.global.jwt.JwtAuthenticationFilter;
import com.gpt.geumpumtabackend.global.maintenance.MaintenanceFilter;
import com.gpt.geumpumtabackend.global.oauth.handler.OAuth2AuthenticationFailureHandler;
import com.gpt.geumpumtabackend.global.oauth.handler.OAuth2AuthenticationSuccessHandler;
import com.gpt.geumpumtabackend.global.oauth.resolver.CustomAuthorizationRequestResolver;
Expand Down Expand Up @@ -36,6 +37,7 @@ public class SecurityConfig {
private final CustomAuthorizationRequestResolver customAuthorizationRequestResolver;
private final OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> authorizationCodeTokenResponseClient;
private final OAuth2AuthenticationFailureHandler oAuth2AuthenticationFailureHandler;
private final MaintenanceFilter maintenanceFilter;

@Bean
public SecurityFilterChain filterChainPermitAll(HttpSecurity http) throws Exception {
Expand All @@ -62,6 +64,7 @@ public HttpSecurity defaultSecurity(HttpSecurity http) throws Exception {
.successHandler(oAuth2AuthenticationSuccessHandler)
.failureHandler(oAuth2AuthenticationFailureHandler)
)
.addFilterBefore(maintenanceFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(new JwtAuthenticationFilter(authenticationManager),
UsernamePasswordAuthenticationFilter.class);
}
Expand All @@ -82,4 +85,4 @@ public MethodSecurityExpressionHandler expressionHandler(RoleHierarchy roleHiera
return expressionHandler;
}

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,10 @@ public enum ExceptionType {
BADGE_NOT_FOUND(NOT_FOUND, "B001", "배지가 존재하지 않습니다"),
BADGE_NOT_OWNED(FORBIDDEN, "B002", "해당 배지를 소유하지 않습니다"),
BADGE_CODE_ALREADY_EXISTS(CONFLICT, "B003", "이미 존재하는 배지 코드입니다"),
BADGE_IN_USE(CONFLICT, "B004", "이미 지급되어 삭제할 수 없는 배지입니다")
BADGE_IN_USE(CONFLICT, "B004", "이미 지급되어 삭제할 수 없는 배지입니다"),

// Maintenance
MAINTENANCE_IN_PROGRESS(SERVICE_UNAVAILABLE, "MT001", "서버 점검 중입니다.")
;

private final HttpStatus status;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.gpt.geumpumtabackend.global.maintenance;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.gpt.geumpumtabackend.global.exception.ExceptionType;
import com.gpt.geumpumtabackend.global.response.ResponseUtil;
import com.gpt.geumpumtabackend.maintenance.service.MaintenanceService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.List;

@Component
@RequiredArgsConstructor
public class MaintenanceFilter extends OncePerRequestFilter {

private static final List<String> WHITELIST = List.of(
"/api/v1/maintenance/status",
"/actuator/health",
"/swagger-ui",
"/swagger-ui/",
"/swagger-ui/**",
"/v3/api-docs",
"/v3/api-docs/**",
"/error"
);

private final MaintenanceService maintenanceService;
private final ObjectMapper objectMapper;
private final AntPathMatcher pathMatcher = new AntPathMatcher();

@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String requestUri = request.getServletPath();

if (isWhitelisted(requestUri) || !maintenanceService.isMaintenanceInProgress()) {
filterChain.doFilter(request, response);
return;
}

response.setStatus(ExceptionType.MAINTENANCE_IN_PROGRESS.getStatus().value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
objectMapper.writeValue(
response.getWriter(),
ResponseUtil.createFailureResponse(ExceptionType.MAINTENANCE_IN_PROGRESS)
);
}

private boolean isWhitelisted(String requestUri) {
return WHITELIST.stream().anyMatch(pattern -> pathMatcher.match(pattern, requestUri));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package com.gpt.geumpumtabackend.maintenance.api;

import com.gpt.geumpumtabackend.global.config.swagger.SwaggerApiFailedResponse;
import com.gpt.geumpumtabackend.global.config.swagger.SwaggerApiResponses;
import com.gpt.geumpumtabackend.global.config.swagger.SwaggerApiSuccessResponse;
import com.gpt.geumpumtabackend.global.exception.ExceptionType;
import com.gpt.geumpumtabackend.global.response.ResponseBody;
import com.gpt.geumpumtabackend.maintenance.dto.request.MaintenanceStatusUpdateRequest;
import com.gpt.geumpumtabackend.maintenance.dto.response.MaintenanceStatusResponse;
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.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.RequestBody;

@Tag(name = "서비스 상태 API", description = "서비스 점검 상태 조회 및 변경 API")
public interface MaintenanceApi {

@Operation(
summary = "점검 상태 조회",
description = "현재 서비스 점검 상태와 안내 메시지를 조회합니다."
)
@ApiResponse(content = @Content(schema = @Schema(implementation = MaintenanceStatusResponse.class)))
@SwaggerApiResponses(
success = @SwaggerApiSuccessResponse(
response = MaintenanceStatusResponse.class,
description = "점검 상태 조회 성공"
)
)
@GetMapping("/status")
ResponseEntity<ResponseBody<MaintenanceStatusResponse>> getCurrentStatus();

@Operation(
summary = "점검 상태 변경",
description = """
ADMIN 권한으로 서비스 점검 상태를 변경합니다.
- status: NORMAL 또는 MAINTENANCE
- message: 점검 안내 문구 (선택)
"""
)
@ApiResponse(content = @Content(schema = @Schema(implementation = MaintenanceStatusResponse.class)))
@SwaggerApiResponses(
success = @SwaggerApiSuccessResponse(
response = MaintenanceStatusResponse.class,
description = "점검 상태 변경 성공"
),
errors = {
@SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED),
@SwaggerApiFailedResponse(ExceptionType.ACCESS_DENIED)
}
)
@PatchMapping("/status")
@PreAuthorize("isAuthenticated() and hasRole('ADMIN')")
ResponseEntity<ResponseBody<MaintenanceStatusResponse>> updateStatus(
@RequestBody @Valid MaintenanceStatusUpdateRequest request
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.gpt.geumpumtabackend.maintenance.controller;

import com.gpt.geumpumtabackend.global.response.ResponseBody;
import com.gpt.geumpumtabackend.global.response.ResponseUtil;
import com.gpt.geumpumtabackend.maintenance.api.MaintenanceApi;
import com.gpt.geumpumtabackend.maintenance.dto.request.MaintenanceStatusUpdateRequest;
import com.gpt.geumpumtabackend.maintenance.dto.response.MaintenanceStatusResponse;
import com.gpt.geumpumtabackend.maintenance.service.MaintenanceService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
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/v1/maintenance")
public class MaintenanceController implements MaintenanceApi {

private final MaintenanceService maintenanceService;

@GetMapping("/status")
public ResponseEntity<ResponseBody<MaintenanceStatusResponse>> getCurrentStatus() {
return ResponseEntity.ok(ResponseUtil.createSuccessResponse(
maintenanceService.getCurrentStatus()
));
}

@PatchMapping("/status")
@PreAuthorize("isAuthenticated() and hasRole('ADMIN')")
public ResponseEntity<ResponseBody<MaintenanceStatusResponse>> updateStatus(
@RequestBody @Valid MaintenanceStatusUpdateRequest request
) {
return ResponseEntity.ok(ResponseUtil.createSuccessResponse(
maintenanceService.updateStatus(request)
));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.gpt.geumpumtabackend.maintenance.domain;

import com.gpt.geumpumtabackend.global.base.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Maintenance extends BaseEntity {

public static final Long DEFAULT_ID = 1L;

@Id
private Long id;

@Enumerated(EnumType.STRING)
@Column(nullable = false)
private ServiceStatus status;

@Column(length = 255)
private String message;

private Maintenance(Long id, ServiceStatus status, String message) {
this.id = id;
this.status = status;
this.message = message;
}

public static Maintenance initialize(ServiceStatus status, String message) {
return new Maintenance(DEFAULT_ID, status, message);
}

public void update(ServiceStatus status, String message) {
this.status = status;
this.message = message;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.gpt.geumpumtabackend.maintenance.domain;

import lombok.Getter;

@Getter
public enum ServiceStatus {
NORMAL("정상"),
MAINTENANCE("점검중");

private final String status;

ServiceStatus(String status) {
this.status = status;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.gpt.geumpumtabackend.maintenance.dto.request;

import com.gpt.geumpumtabackend.maintenance.domain.ServiceStatus;
import jakarta.validation.constraints.NotNull;

public record MaintenanceStatusUpdateRequest(
@NotNull ServiceStatus status,
String message
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.gpt.geumpumtabackend.maintenance.dto.response;

import com.gpt.geumpumtabackend.maintenance.domain.Maintenance;
import com.gpt.geumpumtabackend.maintenance.domain.ServiceStatus;

public record MaintenanceStatusResponse(
ServiceStatus status,
String message
) {
public static MaintenanceStatusResponse from(Maintenance maintenance) {
return new MaintenanceStatusResponse(
maintenance.getStatus(),
maintenance.getMessage()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.gpt.geumpumtabackend.maintenance.repository;

import com.gpt.geumpumtabackend.maintenance.domain.Maintenance;
import org.springframework.data.jpa.repository.JpaRepository;

public interface MaintenanceRepository extends JpaRepository<Maintenance, Long> {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.gpt.geumpumtabackend.maintenance.service;

import com.gpt.geumpumtabackend.maintenance.domain.Maintenance;
import com.gpt.geumpumtabackend.maintenance.domain.ServiceStatus;
import com.gpt.geumpumtabackend.maintenance.dto.request.MaintenanceStatusUpdateRequest;
import com.gpt.geumpumtabackend.maintenance.dto.response.MaintenanceStatusResponse;
import com.gpt.geumpumtabackend.maintenance.repository.MaintenanceRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MaintenanceService {

private final MaintenanceRepository maintenanceRepository;

@Transactional
public MaintenanceStatusResponse updateStatus(MaintenanceStatusUpdateRequest request) {
Maintenance maintenance = maintenanceRepository.findById(Maintenance.DEFAULT_ID)
.orElseGet(() -> Maintenance.initialize(request.status(), request.message()));

maintenance.update(request.status(), request.message());

return MaintenanceStatusResponse.from(maintenanceRepository.save(maintenance));
}

public MaintenanceStatusResponse getCurrentStatus() {
Maintenance maintenance = maintenanceRepository.findById(Maintenance.DEFAULT_ID)
.orElseGet(() -> Maintenance.initialize(
ServiceStatus.NORMAL,
null
));
return MaintenanceStatusResponse.from(maintenance);
}

public boolean isMaintenanceInProgress() {
return getCurrentStatus().status() == ServiceStatus.MAINTENANCE;
}
}
Loading