diff --git a/backend/src/main/java/br/com/fiap/vigisus/application/busca/BuscaCompletaUseCase.java b/backend/src/main/java/br/com/fiap/vigisus/application/busca/BuscaCompletaUseCase.java
index d298cc5..d03f40b 100644
--- a/backend/src/main/java/br/com/fiap/vigisus/application/busca/BuscaCompletaUseCase.java
+++ b/backend/src/main/java/br/com/fiap/vigisus/application/busca/BuscaCompletaUseCase.java
@@ -8,6 +8,7 @@
import br.com.fiap.vigisus.exception.MunicipioNotFoundException;
import br.com.fiap.vigisus.exception.RecursoNaoEncontradoException;
import br.com.fiap.vigisus.model.Municipio;
+import br.com.fiap.vigisus.service.AdminMetricsService;
import br.com.fiap.vigisus.service.EncaminhamentoService;
import br.com.fiap.vigisus.service.IaService;
import br.com.fiap.vigisus.service.MunicipioService;
@@ -34,12 +35,15 @@ public class BuscaCompletaUseCase {
private final PrevisaoRiscoService previsaoRiscoService;
private final EncaminhamentoService encaminhamentoService;
private final MunicipioService municipioService;
+ private final AdminMetricsService adminMetricsService;
public BuscaCompletaResponse buscarPorPergunta(String pergunta) {
+ adminMetricsService.registrarBuscaIa(pergunta);
IntencaoDTO intencao = iaService.interpretarPergunta(pergunta);
Municipio municipio = encontrarMunicipio(intencao.getMunicipio(), intencao.getUf());
String coIbge = municipio.getCoIbge();
intencao.setCoIbge(coIbge);
+ adminMetricsService.registrarBusca(municipio.getNoMunicipio(), municipio.getSgUf());
BuscaCompletaResponse response = buscarPorCoIbge(
coIbge,
@@ -56,6 +60,8 @@ public BuscaCompletaResponse buscarDireto(String municipio, String uf, String do
.findFirst()
.orElseThrow(() -> new MunicipioNotFoundException(municipio.trim() + " / " + uf.trim()));
+ adminMetricsService.registrarBusca(municipioEncontrado.getNoMunicipio(), municipioEncontrado.getSgUf());
+
return buscarPorCoIbge(
municipioEncontrado.getCoIbge(),
resolverDoenca(doenca),
diff --git a/backend/src/main/java/br/com/fiap/vigisus/config/SecurityConfig.java b/backend/src/main/java/br/com/fiap/vigisus/config/SecurityConfig.java
index 147644a..a653c11 100644
--- a/backend/src/main/java/br/com/fiap/vigisus/config/SecurityConfig.java
+++ b/backend/src/main/java/br/com/fiap/vigisus/config/SecurityConfig.java
@@ -14,9 +14,11 @@
* Por serem dados abertos, todos os endpoints de consulta são públicos.
* Não há dados pessoais de pacientes — apenas estatísticas agregadas.
*
+ * NOTA: /actuator/** e /admin/** são servidos exclusivamente na porta 9090
+ * (management.server.port), portanto não precisam de regras aqui.
+ *
* ROADMAP v2.0:
- * Endpoints administrativos (/api/admin/**, /actuator/**) receberão
- * autenticação JWT para operadores de saúde pública.
+ * Endpoints administrativos receberão autenticação JWT para operadores de saúde pública.
*/
@Configuration
@EnableWebSecurity
@@ -28,7 +30,6 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.csrf(csrf -> csrf.disable())
.cors(cors -> cors.configure(http))
.authorizeHttpRequests(auth -> auth
- .requestMatchers("/actuator/**").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/api/**").permitAll()
.anyRequest().authenticated()
diff --git a/backend/src/main/java/br/com/fiap/vigisus/controller/AdminDashboardController.java b/backend/src/main/java/br/com/fiap/vigisus/controller/AdminDashboardController.java
new file mode 100644
index 0000000..08cdd34
--- /dev/null
+++ b/backend/src/main/java/br/com/fiap/vigisus/controller/AdminDashboardController.java
@@ -0,0 +1,158 @@
+package br.com.fiap.vigisus.controller;
+
+import br.com.fiap.vigisus.service.AdminMetricsService;
+import io.swagger.v3.oas.annotations.Hidden;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.MediaType;
+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.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.time.Instant;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Dashboard administrativo do VigiSUS.
+ *
+ *
Este controller é servido exclusivamente na porta 9090
+ * (management.server.port), nunca exposto na porta pública 8080.
+ * A anotação @Hidden impede que apareça no Swagger público.
+ */
+@Hidden
+@RestController
+@RequestMapping("/admin")
+@RequiredArgsConstructor
+public class AdminDashboardController {
+
+ private final AdminMetricsService adminMetricsService;
+
+ @GetMapping("/resumo")
+ public ResponseEntity> resumo() {
+ Map kpis = new LinkedHashMap<>();
+ kpis.put("buscas_total", adminMetricsService.getBuscasTotal());
+ kpis.put("buscas_ia", adminMetricsService.getBuscasIa());
+ kpis.put("triagens", adminMetricsService.getTriagens());
+ kpis.put("cache_hits", adminMetricsService.getCacheHits());
+ kpis.put("timestamp", Instant.now().toString());
+ return ResponseEntity.ok(kpis);
+ }
+
+ @GetMapping("/top-municipios")
+ public ResponseEntity>> topMunicipios(
+ @RequestParam(defaultValue = "10") int top) {
+ return ResponseEntity.ok(adminMetricsService.getTopMunicipios(top));
+ }
+
+ @GetMapping("/top-estados")
+ public ResponseEntity>> topEstados(
+ @RequestParam(defaultValue = "10") int top) {
+ return ResponseEntity.ok(adminMetricsService.getTopEstados(top));
+ }
+
+ @GetMapping("/municipios-risco")
+ public ResponseEntity>> municipiosRisco(
+ @RequestParam(defaultValue = "50") int top) {
+ return ResponseEntity.ok(adminMetricsService.getTopMunicipios(top));
+ }
+
+ @GetMapping("/buscas-ia")
+ public ResponseEntity>> buscasIa(
+ @RequestParam(defaultValue = "20") int top) {
+ return ResponseEntity.ok(adminMetricsService.getTopPerguntasIa(top));
+ }
+
+ @GetMapping(value = "/index.html", produces = MediaType.TEXT_HTML_VALUE)
+ public ResponseEntity dashboard() {
+ String html = """
+
+
+
+
+
+ VigiSUS — Dashboard Admin
+
+
+
+
+ 📈 VigiSUS — Dashboard Administrativo
+
+
+
↻ Atualizar
+
+
Carregando KPIs...
+
🏢 Top Municípios Consultados
+
+ # Município Consultas
+ Carregando...
+
+
🇧🇷 Top Estados Consultados
+
+ # Estado Consultas
+ Carregando...
+
+
🤖 Perguntas Frequentes (IA)
+
+ # Pergunta Frequência
+ Carregando...
+
+
+ VigiSUS — Plataforma de Vigilância Epidemiológica do SUS — Admin Port 9090
+
+
+
+ """;
+ return ResponseEntity.ok()
+ .contentType(MediaType.TEXT_HTML)
+ .body(html);
+ }
+}
diff --git a/backend/src/main/java/br/com/fiap/vigisus/service/AdminMetricsService.java b/backend/src/main/java/br/com/fiap/vigisus/service/AdminMetricsService.java
new file mode 100644
index 0000000..79ce166
--- /dev/null
+++ b/backend/src/main/java/br/com/fiap/vigisus/service/AdminMetricsService.java
@@ -0,0 +1,132 @@
+package br.com.fiap.vigisus.service;
+
+import io.micrometer.core.instrument.Counter;
+import io.micrometer.core.instrument.MeterRegistry;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * Coleta e expõe métricas operacionais do VigiSUS para o dashboard administrativo.
+ * Os contadores são mantidos em memória e também registrados no MeterRegistry
+ * para exposição via /actuator/metrics na porta 9090.
+ */
+@Service
+public class AdminMetricsService {
+
+ private final Counter buscasTotalCounter;
+ private final Counter buscasIaCounter;
+ private final Counter triagensCounter;
+ private final Counter cacheHitsCounter;
+ private final Counter buscasMunicipioCounter;
+
+ private final AtomicLong buscasTotal = new AtomicLong(0);
+ private final AtomicLong buscasIa = new AtomicLong(0);
+ private final AtomicLong triagens = new AtomicLong(0);
+ private final AtomicLong cacheHits = new AtomicLong(0);
+
+ private final ConcurrentHashMap contagemMunicipios = new ConcurrentHashMap<>();
+ private final ConcurrentHashMap contagemEstados = new ConcurrentHashMap<>();
+ private final ConcurrentHashMap contagemPerguntasIa = new ConcurrentHashMap<>();
+
+ public AdminMetricsService(MeterRegistry meterRegistry) {
+ this.buscasTotalCounter = Counter.builder("vigisus.buscas.total")
+ .description("Total de buscas realizadas")
+ .register(meterRegistry);
+ this.buscasIaCounter = Counter.builder("vigisus.buscas.ia")
+ .description("Buscas que utilizaram interpretação por IA")
+ .register(meterRegistry);
+ this.triagensCounter = Counter.builder("vigisus.triagens.total")
+ .description("Total de triagens realizadas")
+ .register(meterRegistry);
+ this.cacheHitsCounter = Counter.builder("vigisus.cache.hits")
+ .description("Total de acertos de cache")
+ .register(meterRegistry);
+ this.buscasMunicipioCounter = Counter.builder("vigisus.buscas.municipio")
+ .description("Total de buscas que identificaram um município")
+ .register(meterRegistry);
+ }
+
+ public void registrarBusca(String municipio, String sgUf) {
+ buscasTotalCounter.increment();
+ buscasTotal.incrementAndGet();
+ if (municipio != null && !municipio.isBlank()) {
+ buscasMunicipioCounter.increment();
+ String chave = municipio.trim() + (sgUf != null ? "/" + sgUf.trim().toUpperCase() : "");
+ contagemMunicipios.computeIfAbsent(chave, k -> new AtomicLong(0)).incrementAndGet();
+ if (sgUf != null && !sgUf.isBlank()) {
+ contagemEstados.computeIfAbsent(sgUf.trim().toUpperCase(), k -> new AtomicLong(0)).incrementAndGet();
+ }
+ }
+ }
+
+ public void registrarBuscaIa(String pergunta) {
+ buscasIaCounter.increment();
+ buscasIa.incrementAndGet();
+ if (pergunta != null && !pergunta.isBlank()) {
+ String chave = pergunta.trim().toLowerCase();
+ contagemPerguntasIa.computeIfAbsent(chave, k -> new AtomicLong(0)).incrementAndGet();
+ }
+ }
+
+ public void registrarTriagem() {
+ triagensCounter.increment();
+ triagens.incrementAndGet();
+ }
+
+ public void registrarCacheHit() {
+ cacheHitsCounter.increment();
+ cacheHits.incrementAndGet();
+ }
+
+ public long getBuscasTotal() {
+ return buscasTotal.get();
+ }
+
+ public long getBuscasIa() {
+ return buscasIa.get();
+ }
+
+ public long getTriagens() {
+ return triagens.get();
+ }
+
+ public long getCacheHits() {
+ return cacheHits.get();
+ }
+
+ public List> getTopMunicipios(int top) {
+ return rankearContagem(contagemMunicipios, top);
+ }
+
+ public List> getTopEstados(int top) {
+ return rankearContagem(contagemEstados, top);
+ }
+
+ public List> getTopPerguntasIa(int top) {
+ return rankearContagem(contagemPerguntasIa, top);
+ }
+
+ private List> rankearContagem(ConcurrentHashMap contagem, int top) {
+ List> entries = new ArrayList<>(contagem.entrySet());
+ entries.sort(Comparator.comparingLong((Map.Entry e) -> e.getValue().get()).reversed());
+
+ List> resultado = new ArrayList<>();
+ int limite = Math.min(top, entries.size());
+ for (int i = 0; i < limite; i++) {
+ Map item = new LinkedHashMap<>();
+ item.put("nome", entries.get(i).getKey());
+ item.put("total", entries.get(i).getValue().get());
+ item.put("posicao", i + 1);
+ resultado.add(Collections.unmodifiableMap(item));
+ }
+ return Collections.unmodifiableList(resultado);
+ }
+}
diff --git a/backend/src/main/java/br/com/fiap/vigisus/service/TriagemService.java b/backend/src/main/java/br/com/fiap/vigisus/service/TriagemService.java
index 2326028..ac7589f 100644
--- a/backend/src/main/java/br/com/fiap/vigisus/service/TriagemService.java
+++ b/backend/src/main/java/br/com/fiap/vigisus/service/TriagemService.java
@@ -6,6 +6,7 @@
import br.com.fiap.vigisus.dto.PerfilEpidemiologicoResponse;
import br.com.fiap.vigisus.dto.TriagemRequest;
import br.com.fiap.vigisus.dto.TriagemResponse;
+import br.com.fiap.vigisus.service.AdminMetricsService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -41,8 +42,10 @@ public class TriagemService {
private final IaService iaService;
private final CalculadoraScoreTriagem calculadoraScoreTriagem;
private final PriorizacaoTriagemPolicy priorizacaoTriagemPolicy;
+ private final AdminMetricsService adminMetricsService;
public TriagemResponse avaliar(TriagemRequest req) {
+ adminMetricsService.registrarTriagem();
double score = calcularScore(req);
int anoAtual = Year.now().getValue();
diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml
index f040ec7..e2d8a72 100644
--- a/backend/src/main/resources/application.yml
+++ b/backend/src/main/resources/application.yml
@@ -24,10 +24,13 @@ server:
port: 8080
management:
+ server:
+ port: 9090
endpoints:
web:
exposure:
- include: health,info
+ include: health,info,metrics
+ base-path: /actuator
endpoint:
health:
show-details: always
diff --git a/backend/src/test/java/br/com/fiap/vigisus/application/busca/BuscaCompletaUseCaseTest.java b/backend/src/test/java/br/com/fiap/vigisus/application/busca/BuscaCompletaUseCaseTest.java
index c2d205f..539a5bc 100644
--- a/backend/src/test/java/br/com/fiap/vigisus/application/busca/BuscaCompletaUseCaseTest.java
+++ b/backend/src/test/java/br/com/fiap/vigisus/application/busca/BuscaCompletaUseCaseTest.java
@@ -8,11 +8,13 @@
import br.com.fiap.vigisus.exception.MunicipioNotFoundException;
import br.com.fiap.vigisus.exception.RecursoNaoEncontradoException;
import br.com.fiap.vigisus.model.Municipio;
+import br.com.fiap.vigisus.service.AdminMetricsService;
import br.com.fiap.vigisus.service.EncaminhamentoService;
import br.com.fiap.vigisus.service.IaService;
import br.com.fiap.vigisus.service.MunicipioService;
import br.com.fiap.vigisus.service.PerfilEpidemiologicoService;
import br.com.fiap.vigisus.service.PrevisaoRiscoService;
+import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -46,7 +48,8 @@ void setUp() {
perfilService,
previsaoRiscoService,
encaminhamentoService,
- municipioService
+ municipioService,
+ new AdminMetricsService(new SimpleMeterRegistry())
);
}
diff --git a/backend/src/test/java/br/com/fiap/vigisus/controller/AdminDashboardControllerTest.java b/backend/src/test/java/br/com/fiap/vigisus/controller/AdminDashboardControllerTest.java
new file mode 100644
index 0000000..3a1d219
--- /dev/null
+++ b/backend/src/test/java/br/com/fiap/vigisus/controller/AdminDashboardControllerTest.java
@@ -0,0 +1,121 @@
+package br.com.fiap.vigisus.controller;
+
+import br.com.fiap.vigisus.service.AdminMetricsService;
+import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class AdminDashboardControllerTest {
+
+ private AdminMetricsService metricsService;
+ private AdminDashboardController controller;
+
+ @BeforeEach
+ void setUp() {
+ metricsService = new AdminMetricsService(new SimpleMeterRegistry());
+ controller = new AdminDashboardController(metricsService);
+ }
+
+ @Test
+ void resumo_retornaKpisComTimestamp() {
+ metricsService.registrarBusca("Campinas", "SP");
+ metricsService.registrarBuscaIa("dengue em SP");
+ metricsService.registrarTriagem();
+ metricsService.registrarCacheHit();
+
+ var response = controller.resumo();
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ Map body = response.getBody();
+ assertThat(body).isNotNull();
+ assertThat(body).containsKey("buscas_total");
+ assertThat(body).containsKey("buscas_ia");
+ assertThat(body).containsKey("triagens");
+ assertThat(body).containsKey("cache_hits");
+ assertThat(body).containsKey("timestamp");
+ assertThat((Long) body.get("buscas_total")).isEqualTo(1L);
+ assertThat((Long) body.get("buscas_ia")).isEqualTo(1L);
+ assertThat((Long) body.get("triagens")).isEqualTo(1L);
+ assertThat((Long) body.get("cache_hits")).isEqualTo(1L);
+ }
+
+ @Test
+ void topMunicipios_retornaListaComMunicipiosConsultados() {
+ metricsService.registrarBusca("Campinas", "SP");
+ metricsService.registrarBusca("Campinas", "SP");
+ metricsService.registrarBusca("São Paulo", "SP");
+
+ var response = controller.topMunicipios(10);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ List> body = response.getBody();
+ assertThat(body).isNotNull().isNotEmpty();
+ assertThat(body.get(0).get("nome")).isEqualTo("Campinas/SP");
+ assertThat(body.get(0).get("total")).isEqualTo(2L);
+ }
+
+ @Test
+ void topMunicipios_retornaListaVaziaQuandoSemConsultas() {
+ var response = controller.topMunicipios(10);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody()).isNotNull().isEmpty();
+ }
+
+ @Test
+ void topEstados_retornaRankingDeEstados() {
+ metricsService.registrarBusca("Campinas", "SP");
+ metricsService.registrarBusca("Santos", "SP");
+ metricsService.registrarBusca("Rio de Janeiro", "RJ");
+
+ var response = controller.topEstados(10);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ List> body = response.getBody();
+ assertThat(body).isNotNull().isNotEmpty();
+ assertThat(body.get(0).get("nome")).isEqualTo("SP");
+ assertThat(body.get(0).get("total")).isEqualTo(2L);
+ }
+
+ @Test
+ void buscasIa_retornaPerguntas() {
+ metricsService.registrarBuscaIa("dengue em campinas");
+ metricsService.registrarBuscaIa("dengue em campinas");
+ metricsService.registrarBuscaIa("febre amarela em sp");
+
+ var response = controller.buscasIa(20);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ List> body = response.getBody();
+ assertThat(body).isNotNull().isNotEmpty();
+ assertThat(body.get(0).get("nome")).isEqualTo("dengue em campinas");
+ assertThat(body.get(0).get("total")).isEqualTo(2L);
+ }
+
+ @Test
+ void municipiosRisco_retornaListaDeMunicipios() {
+ metricsService.registrarBusca("Manaus", "AM");
+
+ var response = controller.municipiosRisco(50);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody()).isNotNull().isNotEmpty();
+ }
+
+ @Test
+ void dashboard_retornaHtml() {
+ var response = controller.dashboard();
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.TEXT_HTML);
+ assertThat(response.getBody()).contains("VigiSUS");
+ assertThat(response.getBody()).contains("/admin/resumo");
+ }
+}
diff --git a/backend/src/test/java/br/com/fiap/vigisus/service/TriagemServiceTest.java b/backend/src/test/java/br/com/fiap/vigisus/service/TriagemServiceTest.java
index e0079a5..4db7de9 100644
--- a/backend/src/test/java/br/com/fiap/vigisus/service/TriagemServiceTest.java
+++ b/backend/src/test/java/br/com/fiap/vigisus/service/TriagemServiceTest.java
@@ -48,7 +48,8 @@ void setUp() {
encaminhamentoService,
iaService,
new CalculadoraScoreTriagem(),
- new PriorizacaoTriagemPolicy()
+ new PriorizacaoTriagemPolicy(),
+ new AdminMetricsService(new io.micrometer.core.instrument.simple.SimpleMeterRegistry())
);
}
diff --git a/docker-compose.yml b/docker-compose.yml
index 045d095..03bf9db 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -34,6 +34,8 @@ services:
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
ports:
- "8080:8080"
+ # Porta administrativa — acessível apenas para operadores; nunca exposta publicamente.
+ - "9090:9090"
depends_on:
postgres:
condition: service_healthy