diff --git a/src/main/java/com/gcsc/connection/apikey/dto/ApiKeyRequestReviewDto.java b/src/main/java/com/gcsc/connection/apikey/dto/ApiKeyRequestReviewDto.java index 9daad38..7c38a28 100644 --- a/src/main/java/com/gcsc/connection/apikey/dto/ApiKeyRequestReviewDto.java +++ b/src/main/java/com/gcsc/connection/apikey/dto/ApiKeyRequestReviewDto.java @@ -9,6 +9,7 @@ public record ApiKeyRequestReviewDto( String reviewComment, List adjustedApiIds, String adjustedFromDate, - String adjustedToDate + String adjustedToDate, + Long adjustedDailyRequestLimit ) { } diff --git a/src/main/java/com/gcsc/connection/apikey/dto/CreateApiKeyRequest.java b/src/main/java/com/gcsc/connection/apikey/dto/CreateApiKeyRequest.java index ded5744..3f4680f 100644 --- a/src/main/java/com/gcsc/connection/apikey/dto/CreateApiKeyRequest.java +++ b/src/main/java/com/gcsc/connection/apikey/dto/CreateApiKeyRequest.java @@ -3,6 +3,7 @@ package com.gcsc.connection.apikey.dto; import jakarta.validation.constraints.NotBlank; public record CreateApiKeyRequest( - @NotBlank String keyName + @NotBlank String keyName, + Long dailyRequestLimit ) { } diff --git a/src/main/java/com/gcsc/connection/apikey/entity/SnpApiKey.java b/src/main/java/com/gcsc/connection/apikey/entity/SnpApiKey.java index 31f627c..81bca80 100644 --- a/src/main/java/com/gcsc/connection/apikey/entity/SnpApiKey.java +++ b/src/main/java/com/gcsc/connection/apikey/entity/SnpApiKey.java @@ -60,10 +60,13 @@ public class SnpApiKey extends BaseEntity { @Column(name = "last_used_at") private LocalDateTime lastUsedAt; + @Column(name = "daily_request_limit") + private Long dailyRequestLimit; + @Builder public SnpApiKey(SnpUser user, String apiKey, String apiKeyPrefix, String keyName, ApiKeyStatus status, SnpUser approvedBy, LocalDateTime approvedAt, - LocalDateTime expiresAt) { + LocalDateTime expiresAt, Long dailyRequestLimit) { this.user = user; this.apiKey = apiKey; this.apiKeyPrefix = apiKeyPrefix; @@ -72,6 +75,7 @@ public class SnpApiKey extends BaseEntity { this.approvedBy = approvedBy; this.approvedAt = approvedAt; this.expiresAt = expiresAt; + this.dailyRequestLimit = dailyRequestLimit; } public void revoke() { diff --git a/src/main/java/com/gcsc/connection/apikey/service/ApiKeyRequestService.java b/src/main/java/com/gcsc/connection/apikey/service/ApiKeyRequestService.java index 359fff8..b9db92b 100644 --- a/src/main/java/com/gcsc/connection/apikey/service/ApiKeyRequestService.java +++ b/src/main/java/com/gcsc/connection/apikey/service/ApiKeyRequestService.java @@ -150,6 +150,11 @@ public class ApiKeyRequestService { String prefix = rawKey.substring(0, PREFIX_LENGTH); String encryptedKey = aesEncryptor.encrypt(rawKey); + // 일일 요청 제한: 검토자가 조정한 값 > 신청자 입력값 > null(무제한) + Long dailyLimit = dto.adjustedDailyRequestLimit() != null + ? dto.adjustedDailyRequestLimit() + : request.getDailyRequestEstimate(); + SnpApiKey apiKey = SnpApiKey.builder() .user(request.getUser()) .apiKey(encryptedKey) @@ -159,6 +164,7 @@ public class ApiKeyRequestService { .approvedBy(reviewer) .approvedAt(LocalDateTime.now()) .expiresAt(request.getUsageToDate()) + .dailyRequestLimit(dailyLimit) .build(); SnpApiKey savedKey = snpApiKeyRepository.save(apiKey); diff --git a/src/main/java/com/gcsc/connection/apikey/service/ApiKeyService.java b/src/main/java/com/gcsc/connection/apikey/service/ApiKeyService.java index e291802..8c8df77 100644 --- a/src/main/java/com/gcsc/connection/apikey/service/ApiKeyService.java +++ b/src/main/java/com/gcsc/connection/apikey/service/ApiKeyService.java @@ -75,6 +75,7 @@ public class ApiKeyService { .apiKeyPrefix(prefix) .keyName(request.keyName()) .status(ApiKeyStatus.ACTIVE) + .dailyRequestLimit(request.dailyRequestLimit()) .build(); SnpApiKey saved = snpApiKeyRepository.save(apiKey); diff --git a/src/main/java/com/gcsc/connection/common/dto/ApiResponse.java b/src/main/java/com/gcsc/connection/common/dto/ApiResponse.java index 434b34f..3ba6b19 100644 --- a/src/main/java/com/gcsc/connection/common/dto/ApiResponse.java +++ b/src/main/java/com/gcsc/connection/common/dto/ApiResponse.java @@ -10,6 +10,7 @@ import lombok.Getter; public class ApiResponse { private final boolean success; + private final String code; private final String message; private final T data; @@ -34,4 +35,12 @@ public class ApiResponse { .message(message) .build(); } + + public static ApiResponse error(String code, String message) { + return ApiResponse.builder() + .success(false) + .code(code) + .message(message) + .build(); + } } diff --git a/src/main/java/com/gcsc/connection/common/exception/ErrorCode.java b/src/main/java/com/gcsc/connection/common/exception/ErrorCode.java index 31496a5..a1585d7 100644 --- a/src/main/java/com/gcsc/connection/common/exception/ErrorCode.java +++ b/src/main/java/com/gcsc/connection/common/exception/ErrorCode.java @@ -31,6 +31,7 @@ public enum ErrorCode { GATEWAY_SERVICE_INACTIVE(503, "GW004", "비활성 서비스입니다"), GATEWAY_PERMISSION_DENIED(403, "GW005", "해당 API에 대한 권한이 없습니다"), GATEWAY_PROXY_FAILED(502, "GW006", "서비스 요청에 실패했습니다"), + GATEWAY_DAILY_LIMIT_EXCEEDED(429, "GW007", "일일 최대 호출 건수 제한으로 사용할 수 없습니다"), INTERNAL_ERROR(500, "SYS001", "시스템 오류가 발생했습니다"); private final int status; diff --git a/src/main/java/com/gcsc/connection/common/exception/GlobalExceptionHandler.java b/src/main/java/com/gcsc/connection/common/exception/GlobalExceptionHandler.java index 57e1014..bb25fa5 100644 --- a/src/main/java/com/gcsc/connection/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/gcsc/connection/common/exception/GlobalExceptionHandler.java @@ -26,7 +26,7 @@ public class GlobalExceptionHandler { log.warn("Business exception: {} - {}", errorCode.getCode(), errorCode.getMessage()); return ResponseEntity .status(errorCode.getStatus()) - .body(ApiResponse.error(errorCode.getMessage())); + .body(ApiResponse.error(errorCode.getCode(), errorCode.getMessage())); } /** diff --git a/src/main/java/com/gcsc/connection/gateway/controller/GatewayController.java b/src/main/java/com/gcsc/connection/gateway/controller/GatewayController.java index 3f03b7b..173871a 100644 --- a/src/main/java/com/gcsc/connection/gateway/controller/GatewayController.java +++ b/src/main/java/com/gcsc/connection/gateway/controller/GatewayController.java @@ -37,7 +37,7 @@ public class GatewayController { String remainingPath = extractRemainingPath(serviceCode, request); return gatewayService.proxyRequest(serviceCode, remainingPath, request); } catch (BusinessException e) { - return buildErrorResponse(e.getErrorCode().getStatus(), e.getErrorCode().getMessage()); + return buildErrorResponse(e.getErrorCode().getStatus(), e.getErrorCode().getCode(), e.getErrorCode().getMessage()); } } @@ -60,8 +60,8 @@ public class GatewayController { /** * Gateway 소비자용 JSON 에러 응답 생성 */ - private ResponseEntity buildErrorResponse(int status, String message) { - String json = "{\"success\":false,\"message\":\"" + escapeJson(message) + "\"}"; + private ResponseEntity buildErrorResponse(int status, String code, String message) { + String json = "{\"success\":false,\"code\":\"" + escapeJson(code) + "\",\"message\":\"" + escapeJson(message) + "\"}"; return ResponseEntity.status(status) .contentType(MediaType.APPLICATION_JSON) .body(json.getBytes()); diff --git a/src/main/java/com/gcsc/connection/gateway/service/GatewayService.java b/src/main/java/com/gcsc/connection/gateway/service/GatewayService.java index fcd50ec..828fa9b 100644 --- a/src/main/java/com/gcsc/connection/gateway/service/GatewayService.java +++ b/src/main/java/com/gcsc/connection/gateway/service/GatewayService.java @@ -8,6 +8,7 @@ import com.gcsc.connection.common.exception.BusinessException; import com.gcsc.connection.common.exception.ErrorCode; import com.gcsc.connection.common.util.AesEncryptor; import com.gcsc.connection.monitoring.entity.SnpApiRequestLog; +import com.gcsc.connection.monitoring.repository.SnpApiRequestLogRepository; import com.gcsc.connection.monitoring.service.RequestLogService; import com.gcsc.connection.service.entity.SnpService; import com.gcsc.connection.service.entity.SnpServiceApi; @@ -23,6 +24,7 @@ import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClientResponseException; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.Enumeration; import java.util.List; @@ -34,8 +36,9 @@ import java.util.Set; public class GatewayService { private static final int API_KEY_PREFIX_LENGTH = 8; + private static final String AUTH_KEY_PARAM = "authKey"; private static final Set EXCLUDED_HEADERS = Set.of( - "host", "x-api-key", "connection", "content-length" + "host", "connection", "content-length" ); private final SnpApiKeyRepository snpApiKeyRepository; @@ -45,6 +48,7 @@ public class GatewayService { private final AesEncryptor aesEncryptor; private final WebClient webClient; private final RequestLogService requestLogService; + private final SnpApiRequestLogRepository snpApiRequestLogRepository; /** * API Gateway 프록시 요청 처리 @@ -70,8 +74,8 @@ public class GatewayService { // 2. 대상 URL 조합 (실패 로그에도 사용) targetUrl = buildTargetUrl(service.getServiceUrl(), remainingPath, request); - // 3. API Key 추출 - String rawKey = request.getHeader("X-API-KEY"); + // 3. API Key 추출 (쿼리 파라미터 authKey) + String rawKey = request.getParameter(AUTH_KEY_PARAM); if (rawKey == null || rawKey.isBlank()) { throw new BusinessException(ErrorCode.GATEWAY_API_KEY_MISSING); } @@ -82,7 +86,10 @@ public class GatewayService { // 5. Key 상태/만료 검증 validateApiKey(apiKey); - // 6. ServiceApi 조회 (경로 + 메서드 매칭, {변수} 패턴 지원) + // 6. 일일 요청량 제한 검증 + validateDailyLimit(apiKey); + + // 7. ServiceApi 조회 (경로 + 메서드 매칭, {변수} 패턴 지원) String apiPath = remainingPath.startsWith("/") ? remainingPath : "/" + remainingPath; SnpServiceApi serviceApi = matchServiceApi(service.getServiceId(), apiPath, request.getMethod()); @@ -109,7 +116,8 @@ public class GatewayService { } catch (BusinessException e) { int responseTime = (int) (System.currentTimeMillis() - startTime); - saveLog(request, service, apiKey, targetUrl, gatewayPath, "FAIL", + String logStatus = isDeniedError(e.getErrorCode()) ? "DENIED" : "FAIL"; + saveLog(request, service, apiKey, targetUrl, gatewayPath, logStatus, e.getErrorCode().getStatus(), responseTime, 0L, e.getErrorCode().getMessage(), requestedAt); throw e; @@ -192,6 +200,36 @@ public class GatewayService { } } + private static final Set DENIED_ERROR_CODES = Set.of( + ErrorCode.GATEWAY_API_KEY_MISSING, + ErrorCode.GATEWAY_API_KEY_INVALID, + ErrorCode.GATEWAY_API_KEY_EXPIRED, + ErrorCode.GATEWAY_PERMISSION_DENIED, + ErrorCode.GATEWAY_DAILY_LIMIT_EXCEEDED + ); + + private boolean isDeniedError(ErrorCode errorCode) { + return DENIED_ERROR_CODES.contains(errorCode); + } + + /** + * 일일 요청량 제한 검증 + */ + private void validateDailyLimit(SnpApiKey apiKey) { + Long limit = apiKey.getDailyRequestLimit(); + if (limit == null) { + return; + } + + LocalDateTime startOfDay = LocalDate.now().atStartOfDay(); + long todayCount = snpApiRequestLogRepository + .countByApiKeyApiKeyIdAndRequestedAtGreaterThanEqual(apiKey.getApiKeyId(), startOfDay); + + if (todayCount >= limit) { + throw new BusinessException(ErrorCode.GATEWAY_DAILY_LIMIT_EXCEEDED); + } + } + /** * 대상 URL 구성 */ @@ -206,7 +244,13 @@ public class GatewayService { String queryString = request.getQueryString(); if (queryString != null && !queryString.isEmpty()) { - url.append("?").append(queryString); + // authKey 파라미터는 프록시 대상에 전달하지 않음 + String filtered = java.util.Arrays.stream(queryString.split("&")) + .filter(p -> !p.startsWith(AUTH_KEY_PARAM + "=")) + .collect(java.util.stream.Collectors.joining("&")); + if (!filtered.isEmpty()) { + url.append("?").append(filtered); + } } return url.toString(); @@ -327,9 +371,7 @@ public class GatewayService { Enumeration headerNames = request.getHeaderNames(); while (headerNames.hasMoreElements()) { String name = headerNames.nextElement(); - if (!"x-api-key".equalsIgnoreCase(name)) { - sb.append(name).append(": ").append(request.getHeader(name)).append("\n"); - } + sb.append(name).append(": ").append(request.getHeader(name)).append("\n"); } return sb.toString(); } diff --git a/src/main/java/com/gcsc/connection/monitoring/repository/SnpApiRequestLogRepository.java b/src/main/java/com/gcsc/connection/monitoring/repository/SnpApiRequestLogRepository.java index bff32e8..9d54bae 100644 --- a/src/main/java/com/gcsc/connection/monitoring/repository/SnpApiRequestLogRepository.java +++ b/src/main/java/com/gcsc/connection/monitoring/repository/SnpApiRequestLogRepository.java @@ -12,6 +12,9 @@ import java.util.List; public interface SnpApiRequestLogRepository extends JpaRepository, JpaSpecificationExecutor { + /** API Key별 일일 요청 건수 */ + long countByApiKeyApiKeyIdAndRequestedAtGreaterThanEqual(Long apiKeyId, LocalDateTime startOfDay); + /** 오늘 요약: 총 요청, 성공 건수, 평균 응답시간 */ @Query(value = "SELECT COUNT(*) as total, " + "COUNT(CASE WHEN request_status = 'SUCCESS' THEN 1 END) as successCount, " +