diff --git a/.gitignore b/.gitignore
index ab7bda3..3c4fee3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,15 +1,8 @@
target/
-*.class
-*.jar
-*.war
-*.ear
-.idea/
-.vscode/
-*.iml
-.project
-.classpath
-.settings/
-.DS_Store
-Thumbs.db
-*.log
-*.tmp
+logs/
+*.duckdb
+
+# Frontend
+portability-frontend/node_modules/
+portability-frontend/dist/
+.angular/
diff --git a/Dockerfile-api b/Dockerfile-api
new file mode 100644
index 0000000..8cfe845
--- /dev/null
+++ b/Dockerfile-api
@@ -0,0 +1,35 @@
+# Fase 2 — Imagem da API REST hexagonal (Spring Boot 3.3 / Java 21)
+# ---- Stage 1: build ----
+FROM maven:3.9-eclipse-temurin-21 AS build
+WORKDIR /build
+
+# Copia os POMs do multi-módulo primeiro para aproveitar o cache do Docker
+COPY pom.xml .
+COPY portability-core/pom.xml portability-core/
+COPY portability-adapters/pom.xml portability-adapters/
+COPY portability-batch/pom.xml portability-batch/
+COPY portability-api/pom.xml portability-api/
+RUN mvn -B -q dependency:go-offline || true
+
+# Copia o código-fonte e compila (boot jar do módulo portability-api)
+COPY portability-core/src portability-core/src
+COPY portability-adapters/src portability-adapters/src
+COPY portability-batch/src portability-batch/src
+COPY portability-api/src portability-api/src
+RUN mvn -B -q clean package -DskipTests
+
+# ---- Stage 2: runtime ----
+FROM eclipse-temurin:21-jre
+WORKDIR /app
+
+COPY --from=build /build/portability-api/target/portability-api-*-boot.jar /app/portability-api.jar
+
+# Dicionários/prefixos embarcados; a base DuckDB é montada como volume.
+COPY Operadoras.csv /app/Operadoras.csv
+COPY Operadoras_completo.csv /app/Operadoras_completo.csv
+COPY prefixos_operadora.json /app/prefixos_operadora.json
+
+VOLUME ["/app/saida", "/app/logs"]
+EXPOSE 8080
+
+CMD ["java", "-jar", "/app/portability-api.jar"]
diff --git a/PLANO_MELHORIAS.md b/PLANO_MELHORIAS.md
index cf4c24c..c169d4f 100644
--- a/PLANO_MELHORIAS.md
+++ b/PLANO_MELHORIAS.md
@@ -84,20 +84,24 @@ Objetivo: alinhar toolchain e criar rede de segurança antes de refatorar.
**Resultado:** `mvn clean verify` verde — 55 testes (34 core + 11 adapters/benchmark + ... incluindo batch), Checkstyle + JaCoCo ok, benchmark automatizado como gate de regressão de performance (<30s; atual ~1,2s).
-### Fase 2 — API REST com arquitetura hexagonal (~2–3 semanas)
-- [ ] Módulo `api` (Spring Boot 3.x, Java 21):
- - `POST /api/v1/portabilidade/consultar` — 1..N números (sync, limitado)
- - `POST /api/v1/portabilidade/batch` — upload multipart CSV/XLSX → retorna job ID
+### Fase 2 — API REST com arquitetura hexagonal (~2–3 semanas) ✅ CONCLUÍDA
+- [x] Módulo `portability-api` (Spring Boot 3.3, Java 21, boot jar `-boot.jar`):
+ - `POST /api/v1/portabilidade/consultar` — 1..N números (sync, máx. 1000, deduplicados)
+ - `POST /api/v1/portabilidade/batch` — upload multipart CSV/XLSX → retorna job ID (`202`)
- `GET /api/v1/portabilidade/batch/{id}` — status/progresso
- - `GET /api/v1/portabilidade/batch/{id}/resultado` — download
-- [ ] Respeitar a hexagonal na prática:
- - `application/` = use cases (`ConsultPortabilityUseCase`, `SubmitBatchUseCase`) dependendo só de ports
- - `infrastructure/web` = controllers + DTOs/mappers; `infrastructure/persistence` = adapter DuckDB existente
- - Composição via `@Configuration` (elimina DI manual do `main`)
-- [ ] Contratos: **OpenAPI 3** (`springdoc-openapi`) + versões de API (`/v1`), erros padronizados (**RFC 7807 ProblemDetails**)
-- [ ] Validação de entrada (`jakarta.validation`), limites de payload, deduplicação de números na request
-- [ ] **Cache**: resultados de portabilidade são relativamente estáveis → Caffeine (L1, hot numbers) com TTL configurável; avaliar Redis (L2) para múltiplas réplicas
-- [ ] Testes: contrato (`@WebMvcTest`), integração com DuckDB embarcado, carga básica (k6/Gatling) — meta p95 < 50 ms single lookup com cache
+ - `GET /api/v1/portabilidade/batch/{id}/resultado` — download CSV
+- [x] Hexagonal na prática:
+ - `application/usecase` = `ConsultPortabilityUseCase`, `SubmitBatchUseCase` dependendo só de ports
+ - `infrastructure/web` = controllers + DTOs/mappers; `infrastructure/repository` = adapter DuckDB + cache; `infrastructure/batch` = executor de jobs
+ - Composição via `@Configuration`/Spring DI
+- [x] Contratos: **OpenAPI 3** (`springdoc-openapi`, `/v3/api-docs` + Swagger UI), versionamento `/v1`, erros **RFC 7807 ProblemDetails**
+- [x] Validação de entrada (`jakarta.validation`), limites de payload (multipart 100 MB), deduplicação de números
+- [x] **Cache**: Caffeine L1 com TTL configurável (Redis L2 avaliado na Fase 4)
+- [x] Testes: web layer (`MockMvc` standalone), use cases unitários, integração batch E2E — build verde com 70 testes no total
+- [x] Virtual threads habilitadas (`spring.threads.virtual.enabled=true`) + compressão gzip
+- [x] Docs/entrega: seção "API REST" no README, `Dockerfile-api` + serviço `portability-api` no docker-compose
+
+**Resultado:** `mvn clean verify` BUILD SUCCESS — 70 testes (51 legados + 19 da API), Checkstyle/JaCoCo ok, smoke test do boot jar validado (endpoint consultar + swagger).
### Fase 3 — BFF + Frontend Angular 17+ (~2–4 semanas)
- [ ] Módulo **`bff`** (Spring Boot dedicado ao frontend):
diff --git a/README.md b/README.md
index e1fc03f..318c3dc 100644
--- a/README.md
+++ b/README.md
@@ -69,10 +69,17 @@ portabilidade-java/ # Parent POM (multi-módulo, Java 21)
│ ├── output/ (CsvOutputWriter)
│ ├── repository/ (DuckDbPortabilityRepository)
│ └── resolver/ (JsonPrefixResolver)
-└── portability-batch/ # ← Camada de entrada (main + orquestração)
+├── portability-batch/ # ← Camada de entrada (main + orquestração)
+│ └── src/main/java/com/portabilidade/
+│ ├── PortabilityApplication.java # Ponto de entrada (main)
+│ └── application/PortabilityBatchProcessor.java
+└── portability-api/ # ← API REST hexagonal (Spring Boot 3.3 / Java 21)
└── src/main/java/com/portabilidade/
- ├── PortabilityApplication.java # Ponto de entrada (main)
- └── application/PortabilityBatchProcessor.java
+ ├── application/usecase/ # Casos de uso (ConsultPortability, SubmitBatch)
+ └── infrastructure/
+ ├── web/ # Controllers, DTOs, RFC 7807, OpenAPI
+ ├── repository/ # Adapter DuckDB + cache Caffeine
+ └── batch/ # Executor de jobs de batch (multipart CSV/XLSX)
```
---
@@ -339,6 +346,42 @@ Para usar um dicionário de operadoras externo, basta colocar um
---
+## API REST (Fase 2 — Spring Boot 3.3 / Java 21)
+
+O módulo `portability-api` expõe a mesma lógica de negócio do batch como serviço HTTP,
+com arquitetura hexagonal (use cases no centro, adapters de web/persistência nas bordas).
+
+### Executar
+
+```bash
+mvn -pl portability-api -am package
+java -jar portability-api/target/portability-api-1.0.0-boot.jar
+# porta padrão: 8080 | Swagger UI: http://localhost:8080/swagger-ui.html
+```
+
+### Endpoints
+
+| Método | Rota | Descrição |
+|--------|------|-----------|
+| `POST` | `/api/v1/portabilidade/consultar` | Consulta 1..N números (sync, máx. 1000 por request, deduplicados, cache Caffeine) |
+| `POST` | `/api/v1/portabilidade/batch` | Upload multipart CSV/XLSX → retorna job ID (`202 Accepted`) |
+| `GET` | `/api/v1/portabilidade/batch/{id}` | Status/progresso do job |
+| `GET` | `/api/v1/portabilidade/batch/{id}/resultado` | Download do CSV de resultado |
+
+Exemplo:
+
+```bash
+curl -s localhost:8080/api/v1/portabilidade/consultar \
+ -H 'Content-Type: application/json' \
+ -d '{"numeros":["11987654321","2125551234"]}'
+```
+
+Erros seguem **RFC 7807 ProblemDetails** (`application/problem+json`) e o contrato
+OpenAPI 3 está em `/v3/api-docs`. Requisições são atendidas em **virtual threads**
+(Java 21) com compressão gzip habilitada.
+
+---
+
## Solução de problemas
| Sintoma | Causa provável | Solução |
diff --git a/docker-compose.yml b/docker-compose.yml
index 9e08ba9..9ba3bad 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -20,3 +20,21 @@ services:
- ./prefixos_operadora.json:/app/prefixos_operadora.json:ro
# Roda o batch uma vez e encerra (--rm remove o container ao final)
restart: "no"
+
+ # Fase 2: API REST hexagonal (Spring Boot 3.3 / Java 21) — porta 8080
+ portability-api:
+ build:
+ context: .
+ dockerfile: Dockerfile-api
+ image: portability-api:latest
+ container_name: portability-api
+ ports:
+ - "8080:8080"
+ volumes:
+ - ./base_portabilidade:/app/base_portabilidade
+ - ./saida:/app/saida
+ - ./logs:/app/logs
+ - ./Operadoras.csv:/app/Operadoras.csv:ro
+ - ./Operadoras_completo.csv:/app/Operadoras_completo.csv:ro
+ - ./prefixos_operadora.json:/app/prefixos_operadora.json:ro
+ restart: unless-stopped
diff --git a/logs/portabilidade.log b/logs/portabilidade.log
new file mode 100644
index 0000000..9849258
--- /dev/null
+++ b/logs/portabilidade.log
@@ -0,0 +1,17 @@
+2026-10-31 01:27:58.031 [main] INFO c.p.PortabilityApplication - Portabilidade Numérica Batch — iniciando
+2026-10-31 01:27:58.035 [main] INFO c.p.application.config.AppPaths - BASE_DIR resolvido: /workspace/portability-batch/target
+2026-10-31 01:27:58.037 [main] INFO c.p.PortabilityApplication - Caminhos configurados:
+2026-10-31 01:27:58.037 [main] INFO c.p.PortabilityApplication - entrada/ : /workspace/portability-batch/target/entrada
+2026-10-31 01:27:58.037 [main] INFO c.p.PortabilityApplication - saida/ : /workspace/portability-batch/target/saida
+2026-10-31 01:27:58.037 [main] INFO c.p.PortabilityApplication - DuckDB : /workspace/portability-batch/target/base_portabilidade/portabilidade.duckdb
+2026-10-31 01:27:58.037 [main] INFO c.p.PortabilityApplication - Operadoras.csv : /workspace/portability-batch/target/Operadoras.csv
+2026-10-31 01:27:58.037 [main] INFO c.p.PortabilityApplication - prefixos JSON : /workspace/portability-batch/target/prefixos_operadora.json
+2026-10-31 01:27:58.038 [main] WARN c.p.i.loader.CsvOperatorLoader - Arquivo Operadoras.csv não encontrado em: /workspace/portability-batch/target/Operadoras.csv
+2026-10-31 01:27:58.038 [main] INFO c.p.PortabilityApplication - Dicionário de operadoras (exemplo): 0 entradas
+2026-10-31 01:27:58.038 [main] WARN c.p.PortabilityApplication - Nenhum dicionário completo de operadoras encontrado — usando o de exemplo (baixa cobertura).
+2026-10-31 01:27:58.329 [main] WARN c.p.i.loader.JsonPrefixMapLoader - Arquivo de prefixos não encontrado em: /workspace/portability-batch/target/prefixos_operadora.json
+2026-10-31 01:27:58.330 [main] INFO c.p.PortabilityApplication - Mapa de prefixos: 0 entradas
+2026-10-31 01:27:58.334 [main] INFO c.p.a.PortabilityBatchProcessor - === Portabilidade Numérica Batch - Iniciando ===
+2026-10-31 01:27:58.340 [main] WARN c.p.infrastructure.file.FileScanner - Nenhum arquivo .csv ou .xlsx encontrado em: /workspace/portability-batch/target/entrada
+2026-10-31 01:27:58.341 [main] WARN c.p.a.PortabilityBatchProcessor - Nenhum arquivo encontrado em '/workspace/portability-batch/target/entrada'. Encerrando.
+2026-10-31 01:27:58.341 [main] INFO c.p.PortabilityApplication - Portabilidade Numérica Batch — concluído com sucesso
diff --git a/pom.xml b/pom.xml
index 3f08511..34c773b 100644
--- a/pom.xml
+++ b/pom.xml
@@ -24,8 +24,8 @@
portability-core
portability-adapters
portability-batch
-
-
+ portability-api
+ portability-bff
@@ -44,6 +44,7 @@
5.12.0
0.8.12
+ 2.6.0
10.17.0
3.5.0
3.3.1
@@ -138,6 +139,8 @@
${maven.compiler.release}
UTF-8
+
+ true
diff --git a/portability-adapters/src/main/java/com/portabilidade/infrastructure/output/CsvOutputWriter.java b/portability-adapters/src/main/java/com/portabilidade/infrastructure/output/CsvOutputWriter.java
index 4607fa8..c4daf7c 100644
--- a/portability-adapters/src/main/java/com/portabilidade/infrastructure/output/CsvOutputWriter.java
+++ b/portability-adapters/src/main/java/com/portabilidade/infrastructure/output/CsvOutputWriter.java
@@ -78,20 +78,51 @@ public StreamingCsvWriter open(String originalName, String outputDir) throws IOE
}
/** Writer de CSV em streaming: grava linha a linha, header automático. */
- public static final class StreamingCsvWriter implements Closeable {
+ public static class StreamingCsvWriter implements Closeable {
private final Path outputPath;
- private final BufferedWriter writer;
- private String[] headers; // definidos na primeira linha
+ final BufferedWriter writer;
+ String[] headers;
private long rowsWritten;
private boolean closed;
StreamingCsvWriter(Path outputPath) throws IOException {
+ this(outputPath, true);
+ }
+
+ /**
+ * Construtor protegido para uso por subclasses em outros módulos (API/Fase 2).
+ * @param withBom BOM UTF-8 apenas para outputs "Excel-friendly" do batch legado;
+ * resultados de API usam CSV puro.
+ */
+ public StreamingCsvWriter(Path outputPath, boolean withBom) throws IOException {
this.outputPath = outputPath;
// UTF-8 com BOM para compatibilidade com Excel
this.writer = new BufferedWriter(
new OutputStreamWriter(new FileOutputStream(outputPath.toFile()),
StandardCharsets.UTF_8), WRITE_BUFFER_BYTES);
- this.writer.write('\uFEFF');
+ if (withBom) {
+ this.writer.write('\uFEFF');
+ }
+ }
+
+ /** Escreve um header fixo antes da primeira linha (usado pela API — Fase 2). */
+ public void writeHeader(List columns) throws IOException {
+ if (headers != null || rowsWritten > 0) {
+ throw new IllegalStateException("header ja escrito");
+ }
+ headers = columns.toArray(new String[0]);
+ StringBuilder hl = new StringBuilder();
+ for (int i = 0; i < headers.length; i++) {
+ if (i > 0) hl.append(SEPARATOR);
+ hl.append(escapeField(headers[i]));
+ }
+ writer.write(hl.toString());
+ writer.newLine();
+ }
+
+ /** Libera o buffer para o disco sem fechar o arquivo (progresso visível durante execução). */
+ public void flush() throws IOException {
+ writer.flush();
}
public void writeRow(Map row) throws IOException {
diff --git a/portability-adapters/src/main/java/com/portabilidade/infrastructure/repository/DuckDbDatabaseProvisioner.java b/portability-adapters/src/main/java/com/portabilidade/infrastructure/repository/DuckDbDatabaseProvisioner.java
new file mode 100644
index 0000000..6fef7ee
--- /dev/null
+++ b/portability-adapters/src/main/java/com/portabilidade/infrastructure/repository/DuckDbDatabaseProvisioner.java
@@ -0,0 +1,100 @@
+package com.portabilidade.infrastructure.repository;
+
+
+
+import java.io.BufferedWriter;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.Statement;
+
+/**
+ * Provisiona a base DuckDB de portabilidade (Fase 1/2 do PLANO_MELHORIAS).
+ *
+ * Uso: java -cp ... DuckDbDatabaseProvisioner <base_dir> <arquivo_portabilidade.csv> [out.duckdb]
+ *
+ * O arquivo de origem deve ter colunas telefone (BIGINT) e rn1 (VARCHAR),
+ * separadas por ; ou , (detectado automaticamente). A carga é feita via
+ * COPY + JOIN contra um CSV gerado com tipos normalizados, e a tabela final
+ * já sai com índice em telefone (query path read-only nunca escreve).
+ */
+public final class DuckDbDatabaseProvisioner {
+
+ private DuckDbDatabaseProvisioner() {
+ }
+
+ public static void main(String[] args) throws Exception {
+ if (args.length < 2) {
+ System.err.println("Uso: DuckDbDatabaseProvisioner [saida.duckdb]");
+ System.exit(2);
+ }
+ Path baseDir = Path.of(args[0]);
+ Path source = Path.of(args[1]);
+ Path out = args.length > 2 ? baseDir.resolve(args[2])
+ : baseDir.resolve("portabilidade.duckdb");
+ Files.createDirectories(out.getParent());
+ long t0 = System.currentTimeMillis();
+ long rows = provision(source, out);
+ System.out.printf("Base provisionada: %s (%d linhas, %d ms)%n",
+ out, rows, System.currentTimeMillis() - t0);
+ }
+
+ /** Cria (sobrescrevendo) a base portabilidade(telefone BIGINT, rn1 VARCHAR) + índice. */
+ public static long provision(Path sourceCsv, Path duckdbFile) throws Exception {
+ String sep = detectSeparator(sourceCsv);
+ Path tmp = Files.createTempFile("prov_portabilidade_", ".csv");
+ try (BufferedWriter w = Files.newBufferedWriter(tmp, StandardCharsets.UTF_8)) {
+ normalizeToCsv(sourceCsv, sep, w);
+ }
+ try (Connection c = DriverManager.getConnection("jdbc:duckdb:" + duckdbFile);
+ Statement st = c.createStatement()) {
+ // duckdb_jdbc 1.1.x: o separador é passado por nome ('sep'), nao 'delimiter'.
+ st.execute("CREATE TABLE portabilidade AS "
+ + "SELECT CAST(telefone AS BIGINT) AS telefone, rn1 "
+ + "FROM read_csv('" + tmp.toAbsolutePath().toString().replace("'", "''")
+ + "', sep=',', header=true)");
+ st.execute("CREATE INDEX IF NOT EXISTS idx_port_telefone ON portabilidade(telefone)");
+ var rs = st.executeQuery("SELECT count(*) FROM portabilidade");
+ rs.next();
+ long rows = rs.getLong(1);
+ c.close();
+ return rows;
+ } finally {
+ Files.deleteIfExists(tmp);
+ }
+ }
+
+ private static String detectSeparator(Path csv) throws Exception {
+ try (var lines = Files.lines(csv, StandardCharsets.UTF_8)) {
+ String first = lines.findFirst().orElse(",");
+ int semi = first.indexOf(';'), comma = first.indexOf(',');
+ return (semi >= 0 && semi < comma) || comma < 0 ? ";" : ",";
+ }
+ }
+
+ /** Copia telefone/rn1 (colunas 0 e 1 após header) escapando aspas duplas. */
+ private static void normalizeToCsv(Path sourceCsv, String sep, BufferedWriter w) throws Exception {
+ w.write("telefone,rn1\n");
+ try (var r = Files.newBufferedReader(sourceCsv, StandardCharsets.UTF_8)) {
+ String line;
+ boolean header = true;
+ while ((line = r.readLine()) != null) {
+ if (header) { header = false; continue; }
+ if (line.isBlank()) continue;
+ int idx = line.indexOf(sep);
+ if (idx <= 0) continue;
+ String tel = line.substring(0, idx).trim();
+ String rn1 = line.substring(idx + sep.length()).trim();
+ if (!tel.matches("\\d+")) continue;
+ w.write(tel);
+ w.write(',');
+ w.write('"');
+ w.write(rn1.replace("\"", "\"\""));
+ w.write('"');
+ w.write('\n');
+ }
+ }
+ }
+}
diff --git a/portability-api/pom.xml b/portability-api/pom.xml
new file mode 100644
index 0000000..21a96fb
--- /dev/null
+++ b/portability-api/pom.xml
@@ -0,0 +1,85 @@
+
+
+ 4.0.0
+
+
+ com.portabilidade
+ portabilidade-parent
+ 1.0.0
+
+
+ portability-api
+ Portabilidade API (Fase 2 - Spring Boot / hexagonal)
+
+
+
+
+
+ com.portabilidade
+ portability-core
+
+
+ com.portabilidade
+ portability-adapters
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+
+
+ org.springdoc
+ springdoc-openapi-starter-webmvc-ui
+ ${springdoc.version}
+
+
+
+
+ com.github.ben-manes.caffeine
+ caffeine
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+ ${spring-boot.version}
+
+
+
+ repackage
+
+
+ boot
+
+
+
+
+
+
+
diff --git a/portability-api/src/main/java/com/portabilidade/PortabilityApiApplication.java b/portability-api/src/main/java/com/portabilidade/PortabilityApiApplication.java
new file mode 100644
index 0000000..d1ef334
--- /dev/null
+++ b/portability-api/src/main/java/com/portabilidade/PortabilityApiApplication.java
@@ -0,0 +1,20 @@
+package com.portabilidade;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * Fase 2 (PLANO_MELHORIAS): API REST hexagonal — Spring Boot 3.3 / Java 21.
+ *
+ * Camadas (dependências apontam para dentro):
+ * infrastructure/web → application/usecases → ports (core)
+ * A composição de dependências é feita em {@code CoreConfig} (@Configuration),
+ * eliminando a DI manual do main do batch.
+ */
+@SpringBootApplication
+public class PortabilityApiApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(PortabilityApiApplication.class, args);
+ }
+}
diff --git a/portability-api/src/main/java/com/portabilidade/application/usecase/ConsultPortabilityUseCase.java b/portability-api/src/main/java/com/portabilidade/application/usecase/ConsultPortabilityUseCase.java
new file mode 100644
index 0000000..9c04377
--- /dev/null
+++ b/portability-api/src/main/java/com/portabilidade/application/usecase/ConsultPortabilityUseCase.java
@@ -0,0 +1,52 @@
+package com.portabilidade.application.usecase;
+
+import com.portabilidade.service.PhoneNormalizer;
+import com.portabilidade.service.PortabilityResolutionService;
+import com.portabilidade.service.ProcessResult;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+/**
+ * Use case síncrono: consulta 1..N números e resolve operadora/portadora.
+ *
+ * Hexagonal: depende apenas do serviço do core (que por sua vez só conhece os
+ * ports PortabilityRepository/PrefixResolver). Sem conhecimento de HTTP/DTOs —
+ * o mapper fica em infrastructure/web.
+ */
+@Service
+public class ConsultPortabilityUseCase {
+
+ /** Limite duro de números por request síncrona (mitigação de payload abusivo). */
+ public static final int MAX_SYNC_NUMBERS = 100;
+
+ private final PortabilityResolutionService resolutionService;
+ private final PhoneNormalizer normalizer;
+
+ public ConsultPortabilityUseCase(PortabilityResolutionService resolutionService,
+ PhoneNormalizer normalizer) {
+ this.resolutionService = resolutionService;
+ this.normalizer = normalizer;
+ }
+
+ /**
+ * @param rawNumbers números brutos (1..MAX_SYNC_NUMBERS)
+ * @return resultado posicional do core (raw/operators/sources na mesma ordem)
+ * @throws IllegalArgumentException se vazio ou acima do limite
+ */
+ public ProcessResult consult(List rawNumbers) {
+ if (rawNumbers == null || rawNumbers.isEmpty()) {
+ throw new IllegalArgumentException("lista de numeros vazia");
+ }
+ if (rawNumbers.size() > MAX_SYNC_NUMBERS) {
+ throw new IllegalArgumentException(
+ "maximo de " + MAX_SYNC_NUMBERS + " numeros por consulta sincrona; use o endpoint de batch");
+ }
+ return resolutionService.processNumbers(rawNumbers);
+ }
+
+ /** Normalização canônica (10/11 dígitos) exposta para o mapper de resposta. */
+ public String canonical(String raw) {
+ return normalizer.getCleanDigits(raw);
+ }
+}
diff --git a/portability-api/src/main/java/com/portabilidade/application/usecase/SubmitBatchUseCase.java b/portability-api/src/main/java/com/portabilidade/application/usecase/SubmitBatchUseCase.java
new file mode 100644
index 0000000..e78bb0c
--- /dev/null
+++ b/portability-api/src/main/java/com/portabilidade/application/usecase/SubmitBatchUseCase.java
@@ -0,0 +1,51 @@
+package com.portabilidade.application.usecase;
+
+import com.portabilidade.infrastructure.batch.BatchJob;
+import org.springframework.stereotype.Service;
+
+import java.nio.file.Path;
+import java.util.Optional;
+
+/**
+ * Use case assíncrono: submete um arquivo (CSV/XLSX) para processamento em
+ * background e expõe consulta de status. Depende apenas dos ports internos
+ * {@link BatchExecutor} e {@link JobRegistry} — a implementação
+ * (InMemoryBatchJobStore + virtual threads) é infraestrutura plugável
+ * (fila durável entra na Fase 4 do PLANO_MELHORIAS).
+ */
+@Service
+public class SubmitBatchUseCase {
+
+ /** Port: aceita um arquivo já persistido e devolve o id do job criado. */
+ public interface BatchExecutor {
+ String submit(Path savedFile, String originalFileName);
+ }
+
+ /** Port: recuperação do estado do job. */
+ public interface JobRegistry {
+ Optional find(String id);
+ }
+
+ private final BatchExecutor executor;
+ private final JobRegistry registry;
+
+ public SubmitBatchUseCase(BatchExecutor executor, JobRegistry registry) {
+ this.executor = executor;
+ this.registry = registry;
+ }
+
+ public String submit(Path savedFile, String originalFileName) {
+ return executor.submit(savedFile, originalFileName);
+ }
+
+ public Optional status(String jobId) {
+ return registry.find(jobId);
+ }
+
+ /** Erro de domínio: arquivo acima do limite configurado de linhas. */
+ public static class TooManyRowsException extends RuntimeException {
+ public TooManyRowsException(long maxRows) {
+ super("limite de " + maxRows + " linhas excedido");
+ }
+ }
+}
diff --git a/portability-api/src/main/java/com/portabilidade/infrastructure/CoreConfig.java b/portability-api/src/main/java/com/portabilidade/infrastructure/CoreConfig.java
new file mode 100644
index 0000000..ba1728c
--- /dev/null
+++ b/portability-api/src/main/java/com/portabilidade/infrastructure/CoreConfig.java
@@ -0,0 +1,149 @@
+package com.portabilidade.infrastructure;
+
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.portabilidade.application.config.AppPaths;
+import com.portabilidade.infrastructure.file.CsvInputReader;
+import com.portabilidade.infrastructure.file.ExcelInputReader;
+import com.portabilidade.infrastructure.file.InputFileReader;
+import com.portabilidade.infrastructure.loader.CsvOperatorLoader;
+import com.portabilidade.infrastructure.loader.JsonPrefixMapLoader;
+import com.portabilidade.infrastructure.repository.CachedPortabilityRepository;
+import com.portabilidade.infrastructure.repository.DuckDbPortabilityRepository;
+import com.portabilidade.infrastructure.resolver.JsonPrefixResolver;
+import com.portabilidade.service.PhoneNormalizer;
+import com.portabilidade.service.PortabilityResolutionService;
+import com.portabilidade.service.resolver.PortabilityRepository;
+import com.portabilidade.service.resolver.PrefixResolver;
+import jakarta.annotation.PreDestroy;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.time.Duration;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Composition root da API (Fase 2): substitui a DI manual do main do batch por
+ * @Configuration, mantendo o core 100% reutilizado (mesmos ports/adapters).
+ */
+@Configuration
+public class CoreConfig {
+
+ private static final Logger log = LoggerFactory.getLogger(CoreConfig.class);
+
+ @Value("${app.data.base-dir:${user.dir}}")
+ private String baseDir;
+
+ @Value("${app.data.duckdb-path:#{null}}")
+ private String duckdbPath;
+
+ @Value("${app.data.operadores-csv:#{null}}")
+ private String operadoresCsv;
+
+ @Value("${app.data.operadores-csv-completo:#{null}}")
+ private String operadoresCompletoCsv;
+
+ @Value("${app.data.prefixos-json:#{null}}")
+ private String prefixosJson;
+
+ @Value("${app.cache.enabled:true}")
+ private boolean cacheEnabled;
+
+ @Value("${app.cache.ttl-minutes:360}")
+ private long cacheTtlMinutes;
+
+ @Value("${app.cache.max-size:500000}")
+ private long cacheMaxSize;
+
+ private DuckDbPortabilityRepository repositoryRef;
+
+ private AppPaths paths() {
+ return new AppPaths(baseDir);
+ }
+
+ private String resolve(String explicit, java.util.function.Function fallback) {
+ return explicit != null ? explicit : fallback.apply(paths());
+ }
+
+ // ── adaptadores de saída (ports do core) ────────────────────────────────
+
+ /**
+ * Port de consulta: DuckDB (adapter existente, conexão única — Fase 1)
+ * decorado pelo cache L1 Caffeine quando habilitado. Resultado de
+ * portabilidade é estável por horas → TTL alto é seguro e derruba a
+ * latência de hot numbers para ~O(1) (meta p95 < 50 ms da Fase 2).
+ */
+ @Bean
+ public PortabilityRepository portabilityRepository() {
+ String db = resolve(duckdbPath, AppPaths::getDuckDbPath);
+ log.info("API usando DuckDB em: {}", db);
+ repositoryRef = new DuckDbPortabilityRepository(db);
+ if (!cacheEnabled) {
+ return repositoryRef;
+ }
+ Cache l1 = Caffeine.newBuilder()
+ .maximumSize(cacheMaxSize)
+ .expireAfterWrite(Duration.ofMinutes(cacheTtlMinutes))
+ .recordStats()
+ .build();
+ log.info("Cache L1 Caffeine ativo: maxSize={}, ttl={}min", cacheMaxSize, cacheTtlMinutes);
+ return new CachedPortabilityRepository(repositoryRef, l1);
+ }
+
+ @Bean
+ public PrefixResolver prefixResolver() {
+ Map prefixMap =
+ new JsonPrefixMapLoader().load(resolve(prefixosJson, AppPaths::getPrefixosJson));
+ log.info("Mapa de prefixos carregado: {} entradas", prefixMap.size());
+ return new JsonPrefixResolver(prefixMap);
+ }
+
+ @Bean
+ public Map operatorsDict() {
+ CsvOperatorLoader loader = new CsvOperatorLoader();
+ Map dict = new LinkedHashMap<>(
+ loader.load(resolve(operadoresCsv, AppPaths::getOperadoresCsv)));
+ String completo = operadoresCompletoCsv != null ? operadoresCompletoCsv : paths().getOperadoresCsvCompleto();
+ if (completo != null) {
+ Map extra = loader.load(completo);
+ if (!extra.isEmpty()) {
+ dict = extra;
+ log.info("Dicionario COMPLETO de operadoras: {} entradas ({})", dict.size(), completo);
+ }
+ }
+ return dict;
+ }
+
+ // ── serviços do core ─────────────────────────────────────────────────────
+
+ @Bean
+ public PhoneNormalizer phoneNormalizer() {
+ return new PhoneNormalizer();
+ }
+
+ @Bean
+ public PortabilityResolutionService portabilityResolutionService(PortabilityRepository repository,
+ PrefixResolver prefixResolver,
+ PhoneNormalizer normalizer,
+ Map operatorsDict) {
+ return new PortabilityResolutionService(repository, prefixResolver, normalizer, operatorsDict);
+ }
+
+ @Bean
+ public List inputFileReaders() {
+ return List.of(new CsvInputReader(), new ExcelInputReader());
+ }
+
+ @PreDestroy
+ void shutdown() {
+ if (repositoryRef != null) {
+ repositoryRef.close();
+ log.info("Conexao DuckDB da API encerrada");
+ }
+ }
+}
diff --git a/portability-api/src/main/java/com/portabilidade/infrastructure/OpenApiConfig.java b/portability-api/src/main/java/com/portabilidade/infrastructure/OpenApiConfig.java
new file mode 100644
index 0000000..9e5a45c
--- /dev/null
+++ b/portability-api/src/main/java/com/portabilidade/infrastructure/OpenApiConfig.java
@@ -0,0 +1,38 @@
+package com.portabilidade.infrastructure;
+
+import com.portabilidade.application.usecase.ConsultPortabilityUseCase;
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.info.Info;
+import io.swagger.v3.oas.models.servers.Server;
+import org.springdoc.core.models.GroupedOpenApi;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * OpenAPI 3 (springdoc) — documentação viva em /swagger-ui.html (Fase 2).
+ */
+@Configuration
+public class OpenApiConfig {
+
+ @Bean
+ public GroupedOpenApi portabilityApi() {
+ return GroupedOpenApi.builder()
+ .group("portabilidade-v1")
+ .pathsToMatch("/api/v1/**")
+ .build();
+ }
+
+ @Bean
+ public OpenAPI portabilityOpenApi() {
+ return new OpenAPI()
+ .info(new Info()
+ .title("Portabilidade Numerica API")
+ .version("1.0.0")
+ .description("Consulta de operadora/portadora por numero (sincrona ate "
+ + ConsultPortabilityUseCase.MAX_SYNC_NUMBERS
+ + " numeros) e processamento assincrono de arquivos CSV/XLSX. "
+ + "Erros em RFC 7807 (application/problem+json)."))
+ .servers(java.util.List.of(
+ new Server().url("/").description("instancia atual")));
+ }
+}
diff --git a/portability-api/src/main/java/com/portabilidade/infrastructure/batch/BatchJob.java b/portability-api/src/main/java/com/portabilidade/infrastructure/batch/BatchJob.java
new file mode 100644
index 0000000..e5389ed
--- /dev/null
+++ b/portability-api/src/main/java/com/portabilidade/infrastructure/batch/BatchJob.java
@@ -0,0 +1,49 @@
+package com.portabilidade.infrastructure.batch;
+
+import java.time.Instant;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * Estado de um job de batch submetido via API (Fase 2).
+ * Persistência em memória agora; fila durável entra na Fase 4 do PLANO_MELHORIAS.
+ */
+public final class BatchJob {
+
+ public enum Status { PENDING, RUNNING, COMPLETED, FAILED }
+
+ private final String id;
+ private final String originalFileName;
+ private final Instant createdAt;
+ private final AtomicLong processedRows = new AtomicLong();
+
+ private volatile Status status = Status.PENDING;
+ private volatile long totalRows = -1; // -1 = ainda desconhecido
+ private volatile String resultPath; // arquivo CSV FINAL_* gerado
+ private volatile String errorMessage;
+
+ public BatchJob(String id, String originalFileName) {
+ this.id = id;
+ this.originalFileName = originalFileName;
+ this.createdAt = Instant.now();
+ }
+
+ public String getId() { return id; }
+ public String getOriginalFileName() { return originalFileName; }
+ public Instant getCreatedAt() { return createdAt; }
+ public Status getStatus() { return status; }
+ public long getTotalRows() { return totalRows; }
+ public long getProcessedRows() { return processedRows.get(); }
+ public String getResultPath() { return resultPath; }
+ public String getErrorMessage() { return errorMessage; }
+
+ public void setStatus(Status s) { this.status = s; }
+ void setTotalRows(long t) { this.totalRows = t; }
+ public void setResultPath(String p) { this.resultPath = p; }
+ void setErrorMessage(String m) { this.errorMessage = m; }
+
+ /** Chamado pelos workers a cada chunk processado (medidor de progresso). */
+ public void addProcessed(long n) { processedRows.addAndGet(n); }
+
+ /** Aborta o arquivo de saída temporário (chamado pelo writer no fluxo de falha). */
+ void abortResultQuietly() { /* path ainda não finalizado; limpeza feita pelo writer */ }
+}
diff --git a/portability-api/src/main/java/com/portabilidade/infrastructure/batch/InMemoryBatchJobStore.java b/portability-api/src/main/java/com/portabilidade/infrastructure/batch/InMemoryBatchJobStore.java
new file mode 100644
index 0000000..07f14a6
--- /dev/null
+++ b/portability-api/src/main/java/com/portabilidade/infrastructure/batch/InMemoryBatchJobStore.java
@@ -0,0 +1,186 @@
+package com.portabilidade.infrastructure.batch;
+
+import com.portabilidade.application.usecase.SubmitBatchUseCase;
+import com.portabilidade.infrastructure.file.InputFileReader;
+import com.portabilidade.infrastructure.output.CsvOutputWriter;
+import com.portabilidade.service.PortabilityResolutionService;
+import com.portabilidade.service.ProcessResult;
+import jakarta.annotation.PreDestroy;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Implementação dos ports BatchExecutor/JobRegistry (Fase 2).
+ *
+ * Jobs em memória + execução em virtual threads (Java 21) reaproveitando o
+ * pipeline streaming da Fase 1: leitura incremental por chunks → resolução →
+ * escrita incremental com flush a cada chunk (progresso observável via GET).
+ *
+ * Fase 4 substituirá este store por fila durável (RabbitMQ/Kafka) sem alterar
+ * o use case — é exatamente para isso que os ports existem.
+ */
+@Component
+public class InMemoryBatchJobStore
+ implements SubmitBatchUseCase.BatchExecutor, SubmitBatchUseCase.JobRegistry {
+
+ private static final Logger log = LoggerFactory.getLogger(InMemoryBatchJobStore.class);
+ static final String PHONE_COLUMN = "telefone";
+ static final String OPERATOR_COLUMN = "operadora";
+ static final String SOURCE_COLUMN = "fonte_consulta";
+
+ private final Map jobs = new ConcurrentHashMap<>();
+ private final ExecutorService executor;
+ private final List readers;
+ private final PortabilityResolutionService resolutionService;
+ private final Path outputDir;
+ private final int chunkSize;
+ private final long maxRows;
+
+ public InMemoryBatchJobStore(List readers,
+ PortabilityResolutionService resolutionService,
+ @Value("${app.batch.output-dir:target/api-output}") String outputDir,
+ @Value("${app.batch.chunk-size:50000}") int chunkSize,
+ @Value("${app.batch.max-rows:1000000}") long maxRows) {
+ this.readers = readers;
+ this.resolutionService = resolutionService;
+ this.outputDir = Path.of(outputDir);
+ this.chunkSize = Math.max(1000, chunkSize);
+ this.maxRows = maxRows;
+ this.executor = Executors.newVirtualThreadPerTaskExecutor();
+ }
+
+ @Override
+ public String submit(Path savedFile, String originalFileName) {
+ String id = UUID.randomUUID().toString();
+ BatchJob job = new BatchJob(id, originalFileName);
+ jobs.put(id, job);
+ executor.submit(() -> run(job, savedFile));
+ return id;
+ }
+
+ @Override
+ public Optional find(String id) {
+ return Optional.ofNullable(jobs.get(id));
+ }
+
+ // ── execução ────────────────────────────────────────────────────────────
+
+ private void run(BatchJob job, Path file) {
+ job.setStatus(BatchJob.Status.RUNNING);
+ InputFileReader reader = readers.stream()
+ .filter(r -> r.supports(file.toString()))
+ .findFirst().orElse(null);
+ if (reader == null) {
+ fail(job, "formato nao suportado: envie .csv ou .xlsx");
+ quietDelete(file);
+ return;
+ }
+ CsvOutputWriter.StreamingCsvWriter out = null;
+ try {
+ Files.createDirectories(outputDir);
+ // UTF-8 sem BOM: consumo programático/download pela API
+ out = new CsvOutputWriter.StreamingCsvWriter(
+ outputDir.resolve("batch_" + job.getId() + ".csv"), false);
+ Iterator