# Documentação de implementação — integração Spring Boot + PostgreSQL

Guia para levar a captura de placas validada no protótipo Python para o backend
Spring Boot do Reecar, persistindo **todas** as leituras no PostgreSQL.

---

## 1. Objetivo

Capturar todas as placas lidas pela câmera LPR, independentemente de listas
Permitido/Proibida, e persistir cada evento com:

- placa;
- data/hora do evento;
- câmera/canal de origem;
- marca, cor, categoria, porte, faixa, sentido e velocidade;
- payload bruto preservado;
- data de recebimento;
- deduplicação por sequência.

---

## 2. Arquitetura

```text
┌──────────────┐     ┌──────────────────┐     ┌────────────────────┐
│ Câmera LPR   │────▶│ NVR iNVD 3008-P  │────▶│ SIM Next (túnel)   │
│ VIP Intelbras│     │ 192.168.15.20    │     │ 127.0.0.1:50702    │
└──────────────┘     └──────────────────┘     └─────────┬──────────┘
                                                         │ HTTP Digest
                                                         ▼
                                              ┌────────────────────┐
                                              │ NvrCgiClient       │
                                              │ (Spring Boot)      │
                                              └─────────┬──────────┘
                                                        │ polling 30s
                                                        ▼
                                              ┌────────────────────┐
                                              │ LprPollingService  │
                                              └─────────┬──────────┘
                                                        │ upsert
                                                        ▼
                                              ┌────────────────────┐
                                              │ PostgreSQL         │
                                              │ lpr_event          │
                                              └────────────────────┘
```

### Por que polling e não Alarme Upload?

O **Alarme Upload** da Central de Alarme só dispara para placas cadastradas nas
listas Permitido/Proibida. Para capturar **todas** as placas, a API CGI
(`mediaFileFind.cgi`) com filtro curinga é o caminho validado.

| Abordagem | Todas as placas? | Complexidade | Status |
|---|---|---|---|
| Alarme Upload (HTTP) | Não (só listas) | Baixa | Descartado |
| Polling CGI | **Sim** | Média | **Validado** |
| Export CSV periódico | Sim | Média | Alternativa |

---

## 3. Estrutura de pacotes sugerida

```text
br.com.reecar.lpr
├── config
│   ├── NvrProperties.java
│   └── HttpClientConfig.java
├── client
│   ├── NvrCgiClient.java
│   ├── NvrCgiException.java
│   └── CgiResponseParser.java
├── domain
│   └── LprEvent.java
├── repository
│   └── LprEventRepository.java
├── service
│   ├── LprPollingService.java
│   └── LprEventService.java
├── web
│   └── LprController.java
└── health
    └── NvrHealthIndicator.java
```

---

## 4. Dependências

```xml
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
  <groupId>org.postgresql</groupId>
  <artifactId>postgresql</artifactId>
  <scope>runtime</scope>
</dependency>
<!-- Digest auth -->
<dependency>
  <groupId>org.apache.httpcomponents.client5</groupId>
  <artifactId>httpclient5</artifactId>
</dependency>
```

> O `java.net.http.HttpClient` nativo só oferece autenticação **Basic** via
> `Authenticator`. Para **Digest**, use o Apache HttpClient 5.

---

## 5. Configuração

```yaml
nvr:
  cgi:
    enabled: ${NVR_CGI_ENABLED:true}
    base-url: ${NVR_CGI_BASE_URL:http://127.0.0.1:50702}
    username: ${NVR_CGI_USERNAME:admin}
    password: ${NVR_CGI_PASSWORD:}
    connect-timeout: 10s
    response-timeout: 30s
    poll-interval: 30s
    overlap: 2m
    channel: 1
    page-size: 100
    max-records-per-poll: 5000
    camera-name: VIP Intelbras
```

### Variáveis de ambiente

| Variável | Exemplo |
|---|---|
| `NVR_CGI_BASE_URL` | `http://127.0.0.1:50702` |
| `NVR_CGI_USERNAME` | `admin` |
| `NVR_CGI_PASSWORD` | `********` |
| `NVR_CGI_ENABLED` | `true` |

> **Nunca** versione a senha. Use `.env`, Docker secrets ou o cofre da
> infraestrutura.

### Docker

Se o Spring Boot roda em container no mesmo host do SIM Next:

```yaml
environment:
  NVR_CGI_BASE_URL: http://host.docker.internal:50702
extra_hosts:
  - "host.docker.internal:host-gateway"
```

---

## 6. Cliente CGI

### 6.1 Configuração do HttpClient

```java
@Configuration
public class HttpClientConfig {

    @Bean
    public CloseableHttpClient nvrHttpClient(NvrProperties props) {
        BasicCredentialsProvider credentials = new BasicCredentialsProvider();
        credentials.setCredentials(
            new AuthScope(null, -1),
            new UsernamePasswordCredentials(
                props.username(),
                props.password().toCharArray()
            )
        );

        return HttpClients.custom()
            .setDefaultCredentialsProvider(credentials)
            .setDefaultRequestConfig(RequestConfig.custom()
                .setConnectionRequestTimeout(Timeout.of(props.connectTimeout()))
                .setResponseTimeout(Timeout.of(props.responseTimeout()))
                .build())
            .build();
    }
}
```

O HttpClient 5 negocia **Digest** automaticamente ao receber o desafio `401`.

### 6.2 Cliente

```java
@Component
public class NvrCgiClient {

    private final CloseableHttpClient http;
    private final NvrProperties props;
    private final ObjectMapper objectMapper;

    public NvrCgiClient(CloseableHttpClient http,
                        NvrProperties props,
                        ObjectMapper objectMapper) {
        this.http = http;
        this.props = props;
        this.objectMapper = objectMapper;
    }

    public String rawGet(String path, Map<String, String> params) {
        try {
            URIBuilder builder = new URIBuilder(props.baseUrl() + path);
            params.forEach(builder::addParameter);

            HttpGet request = new HttpGet(builder.build());
            try (CloseableHttpResponse response = http.execute(request)) {
                String body = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
                int status = response.getCode();
                if (status != 200) {
                    throw new NvrCgiException("HTTP " + status + ": " + body);
                }
                return body;
            }
        } catch (IOException | URISyntaxException e) {
            throw new NvrCgiException("Falha ao chamar " + path, e);
        }
    }

    public Map<String, String> get(String path, Map<String, String> params) {
        return CgiResponseParser.parseKeyValue(rawGet(path, params));
    }

    public LocalDateTime currentTime() {
        String value = get("/cgi-bin/global.cgi", Map.of("action", "getCurrentTime"))
            .get("result");
        return LocalDateTime.parse(value, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
    }
}
```

### 6.3 Busca de placas

```java
public List<LprRecord> fetch(LocalDateTime start, LocalDateTime end) {
    String finder = get("/cgi-bin/mediaFileFind.cgi",
        Map.of("action", "factory.create")).get("result");

    try {
        Map<String, String> params = new LinkedHashMap<>();
        params.put("action", "findFile");
        params.put("object", finder);
        params.put("condition.Channel", String.valueOf(props.channel()));
        params.put("condition.StartTime", format(start));
        params.put("condition.EndTime", format(end));
        params.put("condition.Types[0]", "jpg");
        params.put("condition.Flags[0]", "Event");
        params.put("condition.DB.TrafficCar.PlateNumber[0]", "==");
        params.put("condition.DB.TrafficCar.PlateNumber[1]", "*");

        String result = rawGet("/cgi-bin/mediaFileFind.cgi", params);
        if (!"OK".equals(result.strip())) {
            throw new NvrCgiException("findFile retornou: " + result);
        }

        List<LprRecord> records = new ArrayList<>();
        while (records.size() < props.maxRecordsPerPoll()) {
            String body = rawGet("/cgi-bin/mediaFileFind.cgi", Map.of(
                "action", "findNextFile",
                "object", finder,
                "count", String.valueOf(props.pageSize())
            ));
            List<Map<String, String>> items = CgiResponseParser.parseItems(body);
            if (items.isEmpty()) {
                break;
            }
            items.stream().map(LprRecord::from).forEach(records::add);
            if (body.contains("found=0")) {
                break;
            }
        }
        return records;
    } finally {
        closeQuietly(finder);
    }
}

private void closeQuietly(String finder) {
    try {
        rawGet("/cgi-bin/mediaFileFind.cgi", Map.of("action", "close", "object", finder));
    } catch (Exception ignored) {
        // não mascarar a exceção original
    }
}

private String format(LocalDateTime value) {
    return value.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
```

---

## 7. Parser das respostas

### 7.1 `chave=valor`

```java
public final class CgiResponseParser {

    public static Map<String, String> parseKeyValue(String body) {
        Map<String, String> result = new LinkedHashMap<>();
        body.lines().forEach(line -> {
            int index = line.indexOf('=');
            if (index > 0) {
                result.put(line.substring(0, index), line.substring(index + 1));
            }
        });
        return result;
    }

    public static List<Map<String, String>> parseItems(String body) {
        Map<Integer, Map<String, String>> items = new TreeMap<>();
        body.lines().forEach(line -> {
            if (!line.startsWith("items[") || !line.contains("=")) {
                return;
            }
            int close = line.indexOf(']');
            int index = Integer.parseInt(line.substring(6, close));
            String[] pair = line.substring(close + 2).split("=", 2);
            if (pair.length == 2) {
                items.computeIfAbsent(index, key -> new LinkedHashMap<>())
                     .put(pair[0], pair[1]);
            }
        });
        return new ArrayList<>(items.values());
    }
}
```

### 7.2 DTO

```java
public record LprRecord(
    long sequence,
    String plate,
    LocalDateTime eventTime,
    int channel,
    String brand,
    String vehicleColor,
    String plateColor,
    String category,
    String vehicleSize,
    Integer lane,
    Integer direction,
    Integer speed,
    String country,
    String plateImagePath,
    String vehicleImagePath,
    Map<String, String> raw
) {
    private static final String CAR = "Summary.TrafficCar.";

    public static LprRecord from(Map<String, String> item) {
        return new LprRecord(
            Long.parseLong(item.getOrDefault(CAR + "Sequence", "0")),
            item.getOrDefault(CAR + "PlateNumber", ""),
            LocalDateTime.parse(
                item.getOrDefault("EventTime", "1970-01-01 00:00:00"),
                DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
            ),
            Integer.parseInt(item.getOrDefault("Channel", "0")),
            item.getOrDefault(CAR + "VehicleSign", ""),
            item.getOrDefault(CAR + "VehicleColor", ""),
            item.getOrDefault(CAR + "PlateColor", ""),
            item.getOrDefault(CAR + "Category", ""),
            item.getOrDefault(CAR + "VehicleSize", ""),
            parseInt(item.get(CAR + "Lane")),
            parseInt(item.get(CAR + "Direction")),
            parseInt(item.get(CAR + "Speed")),
            item.getOrDefault(CAR + "Country", ""),
            item.getOrDefault(CAR + "PlateImageInfo.FilePath", ""),
            item.getOrDefault(CAR + "ImageInfo.FilePath", ""),
            Map.copyOf(item)
        );
    }

    private static Integer parseInt(String value) {
        try {
            return value == null ? null : Integer.valueOf(value);
        } catch (NumberFormatException e) {
            return null;
        }
    }
}
```

---

## 8. Banco de dados

### 8.1 DDL

```sql
CREATE TABLE lpr_event (
    id                 BIGSERIAL PRIMARY KEY,
    sequence           BIGINT       NOT NULL UNIQUE,
    plate              VARCHAR(16)  NOT NULL,
    event_time         TIMESTAMP    NOT NULL,
    received_at        TIMESTAMPTZ  NOT NULL DEFAULT now(),
    channel            INTEGER,
    camera_name        VARCHAR(64),
    brand              VARCHAR(64),
    vehicle_color      VARCHAR(32),
    plate_color        VARCHAR(32),
    category           VARCHAR(32),
    vehicle_size       VARCHAR(32),
    lane               INTEGER,
    direction          INTEGER,
    speed              INTEGER,
    country            VARCHAR(8),
    plate_image_path   TEXT,
    vehicle_image_path TEXT,
    raw_payload        JSONB        NOT NULL,
    source             VARCHAR(16)  NOT NULL DEFAULT 'NVR_CGI'
);

CREATE INDEX idx_lpr_event_event_time ON lpr_event (event_time DESC);
CREATE INDEX idx_lpr_event_plate      ON lpr_event (plate);
CREATE INDEX idx_lpr_event_received   ON lpr_event (received_at DESC);
```

### 8.2 Upsert idempotente

```sql
INSERT INTO lpr_event (
    sequence, plate, event_time, channel, camera_name,
    brand, vehicle_color, plate_color, category, vehicle_size,
    lane, direction, speed, country,
    plate_image_path, vehicle_image_path, raw_payload, source
) VALUES (
    ?, ?, ?, ?, ?,
    ?, ?, ?, ?, ?,
    ?, ?, ?, ?,
    ?, ?, ?::jsonb, 'NVR_CGI'
)
ON CONFLICT (sequence) DO NOTHING;
```

Implementação em lote com `JdbcTemplate.batchUpdate(...)` para suportar o
volume de ~540 registros/hora.

> `sequence` é a chave natural do NVR. A constraint `UNIQUE` + `ON CONFLICT`
> garante idempotência mesmo com sobreposição de janelas ou reprocessamento.

---

## 9. Serviço de polling

```java
@Service
public class LprPollingService {

    private static final DateTimeFormatter FMT =
        DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

    private final NvrCgiClient client;
    private final LprEventRepository repository;
    private final NvrProperties props;
    private final Lock lock = new ReentrantLock();

    private volatile LocalDateTime lastSuccess;

    @Scheduled(fixedDelayString = "${nvr.cgi.poll-interval:30s}")
    public void poll() {
        if (!props.enabled() || !lock.tryLock()) {
            return;
        }
        LocalDateTime startedAt = LocalDateTime.now();
        try {
            LocalDateTime nvrNow = client.currentTime();
            LocalDateTime start = (lastSuccess == null
                ? nvrNow.minus(props.overlap())
                : lastSuccess.minus(props.overlap()));
            LocalDateTime end = nvrNow;

            List<LprRecord> records = client.fetch(start, end);
            int inserted = repository.upsertAll(records);

            lastSuccess = end;
            log.info("LPR {}..{}: {} lidos, {} novos em {} ms",
                start, end, records.size(), inserted,
                Duration.between(startedAt, LocalDateTime.now()).toMillis());
        } catch (Exception e) {
            log.warn("Falha no polling LPR: {}", e.getMessage(), e);
        } finally {
            lock.unlock();
        }
    }
}
```

Habilite o agendamento:

```java
@Configuration
@EnableScheduling
public class SchedulingConfig {
}
```

### Janela sobreposta

```text
execução N-1:  [10:00:00 ───────── 10:00:30]
execução N:         [10:00:28 ───────── 10:01:00]
                    └─ sobreposição de 2 min cobre atraso e relógio
```

A sobreposição de **2 minutos** cobre latência de indexação do NVR e pequenas
diferenças de relógio. A deduplicação por `sequence` absorve as repetições.

---

## 10. Serviço de domínio

```java
@Service
public class LprEventService {

    private final LprEventRepository repository;

    public int ingest(List<LprRecord> records) {
        List<LprRecord> valid = records.stream()
            .filter(r -> r.sequence() > 0)
            .filter(r -> !r.plate().isBlank())
            .toList();
        return repository.upsertAll(valid);
    }
}
```

Regras sugeridas:

- descartar `sequence <= 0`;
- descartar placa vazia;
- normalizar placa para maiúsculas e remover espaços;
- manter o payload bruto exatamente como recebido.

---

## 11. Resiliência

| Falha | Tratamento |
|---|---|
| Túnel do SIM Next fora | Log + retry no próximo ciclo |
| Timeout | `response-timeout` de 30s |
| Finder expirado | `finally` fecha; próximo ciclo recria |
| NVR lento | `fixedDelay` + lock impedem sobreposição |
| Duplicidade | `ON CONFLICT (sequence) DO NOTHING` |
| Reinício do backend | `lastSuccess` reinicia com `overlap` |
| Múltiplas instâncias | ShedLock ou líder único |

Para retry imediato em falhas transitórias, use Spring Retry ou Resilience4j
com backoff exponencial (ex.: 3 tentativas, 1s/2s/4s).

---

## 12. Health e métricas

```java
@Component
public class NvrHealthIndicator implements HealthIndicator {

    private final LprPollingService polling;

    @Override
    public Health health() {
        Duration age = polling.ageOfLastSuccess();
        if (age == null) {
            return Health.unknown().withDetail("motivo", "nenhum poll concluído").build();
        }
        return age.toMinutes() < 5
            ? Health.up().withDetail("ultimoPoll", age.toSeconds() + "s").build()
            : Health.down().withDetail("ultimoPoll", age.toSeconds() + "s").build();
    }
}
```

Métricas sugeridas (Micrometer):

- `lpr.poll.duration` (timer);
- `lpr.records.read` (counter);
- `lpr.records.inserted` (counter);
- `lpr.poll.errors` (counter);
- `lpr.last.success.age` (gauge).

Exponha em `/actuator/health` e `/actuator/metrics`.

---

## 13. Fuso horário

O relógio do NVR foi validado igual ao da máquina local. Recomendações:

1. Use a hora do NVR (`getCurrentTime`) como âncora das janelas.
2. Armazene `event_time` como `TIMESTAMP` (sem fuso) para preservar o horário
   de parede do equipamento.
3. Registre `received_at` como `TIMESTAMPTZ` para auditoria.
4. Se o NVR for configurado para UTC, converta explicitamente para
   `America/Sao_Paulo` na ingestão.

---

## 14. Segurança

- Credenciais somente em variáveis de ambiente/secret.
- Não logar a senha nem o header `Authorization`.
- Restringir a porta `50702` do SIM Next no firewall.
- Se o backend for remoto, usar VPN ou túnel autenticado.
- A interface de exploração deve escutar apenas em `127.0.0.1`.
- Considerar HTTPS no Spring Boot se houver exposição de rede.

---

## 15. Testes

| Tipo | O que validar |
|---|---|
| Unitário | `CgiResponseParser` com fixture real de `resultado.json` |
| Unitário | `LprRecord.from` com todos os campos |
| Integração | `fetch` contra o NVR com janela de 2 minutos |
| Integração | `upsertAll` duas vezes → nenhuma duplicata |
| Contrato | Campos obrigatórios: `sequence`, `plate`, `event_time` |
| Carga | Paginação de 5.000 registros |
| Resiliência | Túnel desligado → health `DOWN`, sem exceção não tratada |

Fixture de teste: use o `resultado.json` gerado por `test_flow.py`.

---

## 16. Capacidade e retenção

| Métrica | Estimativa |
|---|---|
| Volume observado | ~540 registros/hora |
| Diário | ~13.000 registros |
| Anual | ~4,7 milhões de registros |
| Tamanho por linha | ~1 KB (com JSONB) |
| Crescimento anual | ~5 GB |

Recomendações:

- particionar `lpr_event` por mês (`PARTITION BY RANGE (event_time)`);
- retenção de 90 a 180 dias;
- arquivamento em objeto storage (MinIO) se necessário;
- índices em `event_time`, `plate` e `sequence` (unique).

---

## 17. Checklist de implementação

- [ ] Criar migration com a tabela `lpr_event` e índices.
- [ ] Adicionar `NvrProperties` + configuração no `application.yml`.
- [ ] Configurar `CloseableHttpClient` com credenciais Digest.
- [ ] Implementar `CgiResponseParser` + testes com fixture.
- [ ] Implementar `NvrCgiClient.fetch` com paginação e `finally`.
- [ ] Implementar `LprRecord.from`.
- [ ] Implementar `LprEventRepository.upsertAll` com `ON CONFLICT`.
- [ ] Implementar `LprPollingService` com `@Scheduled` e lock.
- [ ] Adicionar `@EnableScheduling`.
- [ ] Adicionar health indicator e métricas.
- [ ] Testar localmente contra o túnel (`NVR_CGI_ENABLED=true`).
- [ ] Validar contagem contra `test_flow.py`.
- [ ] Habilitar em staging e monitorar por 24h.
- [ ] Definir política de retenção/particionamento.

---

## 18. Critérios de aceite

1. **Todas as placas** são capturadas, sem depender de listas.
2. Nenhum registro duplicado por `sequence`.
3. Payload bruto preservado em `raw_payload`.
4. Poll a cada 30s com lacuna máxima inferior a 1 minuto.
5. `/actuator/health` reflete o último poll bem-sucedido.
6. Falha do túnel não derruba o backend.
7. Consulta por placa e por período disponível na API do Reecar.

---

## 19. Execução local do protótipo

```powershell
# Teste de linha de comando
cd C:\Repositorio\Startup-naylton\test_lpr
python test_flow.py --minutes 10

# Interface de exploração
.\start_explorer.ps1
# http://127.0.0.1:8090
```

Use a interface para comparar os resultados do backend Java com os do Python
durante o desenvolvimento.
