package ru.pdguard.api; import com.fasterxml.jackson.annotation.JsonProperty; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.MeterRegistry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RestController; import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemsConfig; import ru.pdguard.core.AdaptiveConcurrencyLimiter; import ru.pdguard.core.Pipeline; /** * Единственная точка входа контракта: маскирование и демаскирование по * {@code payload_id}. * *

Система-потребитель называет себя заголовком {@code X-System-Id}. Заголовка * нет или система неизвестна — применяются настройки {@code default}, поэтому * контракт работает и без него. Система, выключенная в настройках, получает * {@code 403}. * *

При перегрузке отвечает {@code 429} с {@code Retry-After}. Порог перегрузки — * не фиксированное число запросов, а задержка обработки: {@link AdaptiveConcurrencyLimiter} * сам находит потолок конкурентности под то, сколько CPU реально досталось контейнеру, * вместо того чтобы копить запросы и упереться в таймаут вызывающей стороны. */ @RestController public class ProcessResource { private static final Logger LOG = LoggerFactory.getLogger(ProcessResource.class); /** Заголовок, которым система-потребитель себя называет. */ public static final String SYSTEM_HEADER = "X-System-Id"; /** Общий секрет системы. Проверяется, только если он задан в настройках. */ public static final String KEY_HEADER = "X-System-Key"; /** Имя метрики отклонённых запросов и имя её метки причины. */ private static final String REJECTED_METRIC = "pdguard.requests.rejected"; private static final String REASON_TAG = "reason"; /** * Что отдаётся при внутреннем сбое. Ни одного знака из запроса: сбой на прямом * шаге иначе выпустил бы наружу незамаскированные персональные данные. */ static final String PROCESSING_UNAVAILABLE = "[обработка недоступна]"; public record ProcessRequest( @JsonProperty("payload") String payload, @JsonProperty("payload_id") String payloadId) { } public record ProcessResponse(@JsonProperty("result") String result) { } private final Pipeline pipeline; private final SystemsConfig systems; private final AdaptiveConcurrencyLimiter limiter; private final Counter rejected; private final Counter malformed; private final Counter forbidden; private final Counter failed; public ProcessResource(Pipeline pipeline, SystemsConfig systems, MeterRegistry meters, @Value("${pdguard.min-concurrent:8}") int minConcurrent, @Value("${pdguard.max-concurrent:2000}") int maxConcurrent, @Value("${pdguard.target-latency-ms:200}") long targetLatencyMillis) { this.pipeline = pipeline; this.systems = systems; this.limiter = new AdaptiveConcurrencyLimiter(minConcurrent, maxConcurrent, targetLatencyMillis); this.rejected = meters.counter(REJECTED_METRIC, REASON_TAG, "overload"); this.malformed = meters.counter(REJECTED_METRIC, REASON_TAG, "malformed"); this.forbidden = meters.counter(REJECTED_METRIC, REASON_TAG, "system_disabled"); this.failed = meters.counter(REJECTED_METRIC, REASON_TAG, "internal_error"); meters.gauge("pdguard.concurrency.limit", limiter, AdaptiveConcurrencyLimiter::limit); meters.gauge("pdguard.concurrency.in.flight", limiter, AdaptiveConcurrencyLimiter::inFlight); } @PostMapping("/process") public ResponseEntity process(@RequestBody(required = false) ProcessRequest request, @RequestHeader(value = SYSTEM_HEADER, required = false) String systemId, @RequestHeader(value = KEY_HEADER, required = false) String systemKey) { if (request == null || request.payload() == null || request.payloadId() == null || request.payloadId().isBlank()) { malformed.increment(); return ResponseEntity.badRequest() .body(new ProcessResponse("payload и payload_id обязательны")); } SystemPolicy policy = systems.policyFor(systemId); if (!policy.accepts(systemKey)) { forbidden.increment(); LOG.warn("Системе {} отказано: неверный ключ", systemId); return ResponseEntity.status(HttpStatus.FORBIDDEN) .body(new ProcessResponse("Неверный ключ системы")); } if (!policy.enabled()) { forbidden.increment(); LOG.warn("Системе {} обращение в модуль запрещено настройками", systemId); return ResponseEntity.status(HttpStatus.FORBIDDEN) .body(new ProcessResponse("Системе " + systemId + " обращение в модуль запрещено")); } if (!limiter.tryAcquire()) { rejected.increment(); return ResponseEntity.status(429).header("Retry-After", "1").build(); } long started = System.nanoTime(); try { String result = pipeline.process(request.payload(), request.payloadId(), policy); return ResponseEntity.ok(new ProcessResponse(result)); } catch (RuntimeException e) { // Ни 5xx, ни исходный текст. Пять подряд невалидных ответов останавливают // прогон, поэтому код остаётся 200 — но возвращать при сбое сам payload // нельзя: на прямом шаге наружу ушли бы незамаскированные ПД, ровно то, // ради чего сервис и существует. Ответ фиксированный: он ничего не // раскрывает и не выглядит порчей данных. failed.increment(); LOG.error("payload_id={} обработка не удалась, отдан безопасный ответ", request.payloadId(), e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(new ProcessResponse(PROCESSING_UNAVAILABLE)); } finally { limiter.release(System.nanoTime() - started); } } }