Files
Projeto-Portaria/docs/01-ARQUITETURA.md

14 KiB
Raw Permalink Blame History

01 — Arquitetura

Documento espinha. Todos os outros dependem deste. Se houver conflito entre documentos, este prevalece sobre decisões de stack e estrutura.

1. Visão geral

                    ┌─────────────────── Docker Compose ───────────────────┐
 QR / Wi-Fi         │                                                      │
 na portaria        │   traefik (TLS automático, roteamento)               │
   │                │      │                                               │
   ▼                │      ├── portaria-backend  (Spring Boot, stateless)  │
 web/visitor  ──────┼──────┤      ├── REST (OpenAPI)                       │
 React PWA          │      │      ├── WebSocket (sinalização de chamada)   │
                    │      │      ├── Outbox publisher + ShedLock          │
 apps/  morador ────┼──────┤      └── Resilience4j → LiveKit / FCM / APNs  │
        operador    │      │                                               │
 Compose MP         │      ├── postgres 17 + PostGIS                       │
                    │      │      └── flyway · outbox · fila · jobs        │
 web/admin    ──────┼──────┤                                               │
 React + TS         │      ├── livekit-server (SFU)  +  coturn (TURN)      │
                    │      ├── minio (fotos, recados, gravações)           │
                    │      └── otel-collector → prometheus/grafana/loki    │
                    └──────────────────────────────────────────────────────┘
                                    │
                        FCM (Android) · APNs + PushKit/CallKit (iOS)

Princípio central: o backend é stateless. Todo estado vive no Postgres, no MinIO ou no LiveKit. Nenhuma informação de chamada em andamento, timer de escalonamento ou sessão fica em memória de processo. Isso é o que permite replicar o backend e sobreviver a restart sem deixar visitante preso na porta.

2. Stack por camada

Camada Tecnologia Justificativa
Backend Spring Boot 3 + Kotlin (JDK 21) Transações declarativas, Actuator, ShedLock, Resilience4j e Spring Security de fábrica. A robustez aqui vem de infraestrutura pronta e testada, não de código nosso
Persistência PostgreSQL 17 + PostGIS Geofence do visitante (ST_DWithin), fila durável, outbox e dados de negócio num só lugar transacional
Migrations Flyway Versionamento linear e auditável do schema
Acesso a dados jOOQ SQL tipado. Preferido sobre JPA aqui porque as consultas de auditoria e relatório do admin são analíticas, e JPA atrapalha nesse perfil
Mídia LiveKit (self-hosted) + coturn SFU open-source, dockerizado, com SDKs oficiais Android, Swift e JS
Objetos MinIO (API S3) Fotos, recados em vídeo e gravações. Trocável por S3 sem mudar código
Apps móveis Compose Multiplatform (Android + iOS) Uma base de UI para morador e operador
Web visitante Vite + React + TS (PWA) TTI ~1s em 4G. O visitante não instala nada e não espera
Web admin Vite + React + TS Mesma toolchain; ecossistema maduro de tabela densa, mapa e player
Compartilhado Kotlin Multiplatform (shared/) DTOs e máquina de estados, únicos entre backend e apps
Observabilidade OpenTelemetry → Prometheus / Grafana / Loki SLOs medidos, não presumidos

3. Estrutura do monorepo

Projeto-Portaria/
├── settings.gradle.kts          # inclui :backend e :shared
├── shared/                      # Kotlin Multiplatform
│   └── src/commonMain/kotlin/br/com/portaria/shared/
│       ├── model/               # entidades de domínio
│       ├── dto/                 # kotlinx.serialization — contrato de rede
│       ├── state/               # máquina de estados (VisitStateMachine)
│       └── validation/          # regras compartilhadas
├── backend/                     # Spring Boot 3 + Kotlin
│   └── src/main/kotlin/br/com/portaria/
│       ├── domain/              # entidades, agregados, portas
│       ├── application/         # casos de uso (services)
│       ├── adapter/
│       │   ├── in/rest/         # controllers + OpenAPI
│       │   ├── in/ws/           # WebSocket de sinalização
│       │   └── out/             # jooq, livekit, push, storage, notification
│       ├── infra/               # outbox, scheduler, security, config
│       └── resources/db/migration/   # Flyway
├── apps/                        # Compose Multiplatform
│   ├── composeApp/              # código comum (morador + operador)
│   ├── androidApp/
│   └── iosApp/
├── web/
│   ├── visitor/                 # Vite + React + TS (PWA)
│   ├── admin/                   # Vite + React + TS
│   └── shared-ui/               # design tokens + componentes comuns
├── infra/
│   ├── docker-compose.yml
│   ├── docker-compose.prod.yml
│   ├── livekit/  coturn/  traefik/  observability/
└── docs/  prompts/

Gradle governa shared/, backend/ e apps/. pnpm workspaces governa web/. São dois mundos de build que só se encontram no contrato OpenAPI.

Fluxo de contratos

shared/dto (Kotlin)  ──────► backend usa direto
                     ──────► apps Compose usam direto
                        │
                        └──► backend expõe /v3/api-docs (OpenAPI)
                                  │
                                  └──► openapi-typescript ──► web/*/src/api/types.ts

Os webs nunca escrevem tipos de API à mão. São gerados no build a partir do OpenAPI, o que faz uma mudança de contrato quebrar o build do front em vez de quebrar em produção.

4. O módulo shared/

Contém apenas o que precisa ser idêntico entre servidor e app. Não é uma biblioteca de utilidades.

// shared/src/commonMain/kotlin/br/com/portaria/shared/state/VisitState.kt
enum class VisitState {
    PENDENTE, TOCANDO, ESCALONADA, FILA_OPERADOR,
    EM_CHAMADA, AUTORIZADA, NEGADA, RECADO_EM_VIDEO, EXPIRADA, CANCELADA
}

object VisitStateMachine {
    private val transicoes: Map<VisitState, Set<VisitState>> = mapOf(
        PENDENTE   to setOf(TOCANDO, CANCELADA),
        TOCANDO    to setOf(EM_CHAMADA, ESCALONADA, AUTORIZADA, NEGADA, CANCELADA),
        ESCALONADA to setOf(EM_CHAMADA, FILA_OPERADOR, RECADO_EM_VIDEO, AUTORIZADA, NEGADA, EXPIRADA),
        // ...
    )
    fun permite(de: VisitState, para: VisitState) = para in (transicoes[de] ?: emptySet())
    fun ehFinal(estado: VisitState) = estado in setOf(AUTORIZADA, NEGADA, EXPIRADA, CANCELADA, RECADO_EM_VIDEO)
}

A mesma classe valida no app (para desabilitar botões) e no servidor (para rejeitar comandos inválidos). O servidor é a autoridade — o cliente só antecipa.

5. Os sete padrões de robustez

Não são recomendações. São o núcleo de confiabilidade do sistema, e cada um existe porque há um modo de falha concreto em que gente fica presa na porta.

5.1 Transactional outbox

Falha que previne: a visita é criada, o processo cai antes de publicar a notificação, e o morador nunca fica sabendo que há alguém na porta.

@Transactional
fun criarVisita(cmd: CriarVisitaCommand): Visit {
    val visita = visitRepository.save(Visit.nova(cmd))
    outbox.enfileirar(VisitaCriada(visita.id, visita.unitId))   // mesma transação
    return visita
}

Um publisher separado lê a outbox, despacha e marca como publicado, com retry e backoff exponencial. Entrega ao menos uma vez — por isso os consumidores são idempotentes.

5.2 Timeouts como jobs persistidos

Falha que previne: o processo que segurava o timer de 20 segundos morre e o escalonamento nunca acontece.

Nunca delay(), Timer ou @Scheduled de instância única guardando estado. O timeout é uma linha na tabela scheduled_jobs com run_at. Um poller com ShedLock garante que apenas uma réplica execute cada job.

@Scheduled(fixedDelay = 1000)
@SchedulerLock(name = "visit-timeouts", lockAtMostFor = "30s")
fun processarTimeouts() { /* SELECT ... FOR UPDATE SKIP LOCKED */ }

5.3 Optimistic locking

Falha que previne: dois moradores da mesma unidade respondem juntos; um autoriza, o outro nega, e o último a gravar vence em silêncio.

Coluna version em visits. Conflito devolve 409 com o estado atual, e o app mostra "outro morador já respondeu".

5.4 Idempotência

Falha que previne: morador com 4G ruim toca "Autorizar" três vezes e gera três autorizações.

Toda mutação aceita header Idempotency-Key. A tabela idempotency_keys guarda a resposta por 24h; repetição devolve a resposta original sem reexecutar.

5.5 Degradação graciosa

Falha que previne: LiveKit fora do ar significa prédio trancado.

LiveKit indisponível (circuit breaker aberto)
  └─► modo ÁUDIO (menos banda, mesma sala)
        └─► modo FOTO+TEXTO (visitante manda foto, morador aprova sem chamada)
              └─► FILA_OPERADOR (humano resolve por telefone)

O nível de degradação vigente é exposto em /actuator/health e visível no painel admin.

5.6 Fila durável em Postgres

SELECT ... FOR UPDATE SKIP LOCKED sobre a tabela job_queue. Redis existe apenas para o LiveKit (obrigatório em multi-instância) e para rate limiting — nunca para dados que não podem se perder. Menos peças móveis, e o enfileiramento participa da mesma transação dos dados.

5.7 SLO medido

SLO Alvo Métrica
QR → celular do morador tocando p95 < 5s portaria.visit.ring_latency
Taxa de atendimento pelo morador > 70% portaria.visit.answered_ratio
QR → resposta em entrega p95 < 10s portaria.delivery.resolution_latency
Abandono do entregador < 10% portaria.delivery.abandoned_ratio
Disponibilidade do fluxo de entrada 99,5% uptime do caminho crítico

Sem essas métricas não se sabe se o produto está funcionando — a falha aqui é silenciosa por natureza.

6. Segurança — visão arquitetural

Três identidades distintas, três mecanismos:

Quem Autenticação Duração
Visitante JWT efêmero emitido ao validar o QR + geofence. Sem cadastro 15 min, escopo de uma visita
Morador / operador OIDC (Spring Security) + refresh token no keystore do dispositivo Access 15 min, refresh 30 dias
Admin OIDC + MFA obrigatório Sessão de 8h

Busca cega de unidade: o endpoint de destino recebe bloco + unidade e nunca confirma se existe ou quem mora lá. Resposta idêntica para unidade válida e inválida — inclusive na linha do tempo de estados: a visita-sombra percorre os mesmos estados nos mesmos tempos sem notificar ninguém (03-FLUXOS-E-CONTRATOS.md §2). Isso impede que qualquer pessoa com o QR enumere quem mora onde — um problema de segurança física antes de ser de privacidade. Detalhes em 06-LGPD-E-SEGURANCA.md.

7. Multi-tenant preparado, não ativado

Toda tabela de negócio tem tenant_id NOT NULL. Todo repositório filtra por ele. Policies de Row Level Security são criadas nas migrations mas ficam DISABLEd na v1.

CREATE POLICY tenant_isolation ON visits
  USING (tenant_id = current_setting('app.current_tenant')::uuid);
ALTER TABLE visits DISABLE ROW LEVEL SECURITY;  -- v1

Ativar multi-tenancy vira ENABLE ROW LEVEL SECURITY + a UI de gestão. Sem isso, adicionar tenant_id depois seria reescrever todo o schema e toda query.

8. ADRs — decisões e o que se abriu mão

ADR-001 · Spring Boot em vez de Ktor. Ktor é mais leve e idiomático, mas minimalista: agendamento, retry, métricas, transações e circuit breaker seriam código nosso. Num sistema onde falha silenciosa significa gente presa na porta, preferimos infraestrutura madura à elegância. Custo: mais cerimônia, startup mais lento, imagem maior.

ADR-002 · React em vez de Compose/Wasm na web. O bundle Wasm (38MB) daria TTI de 36s em 4G na tela do visitante — abandono garantido na portaria. Custo: a UI web não é compartilhada com os apps; só os contratos são.

ADR-003 · LiveKit self-hosted em vez de gerenciado. Atende o requisito de infra dockerizada e evita custo por minuto no núcleo do produto. Custo: operar SFU, TURN e banda. Mitigado por VideoCallProvider, que permite trocar por gerenciado sem tocar nos apps.

ADR-004 · jOOQ em vez de JPA. As consultas mais complexas do sistema são analíticas (auditoria, relatórios, filtros do admin), onde JPA atrapalha. Custo: passo de geração de código no build.

ADR-005 · Fila em Postgres em vez de Redis/Kafka. Volume real é de ~0,3 req/s; Kafka seria desproporcional. Postgres dá durabilidade e transacionalidade com os dados. Custo: não escala para milhões de mensagens — irrelevante nesta ordem de grandeza.

ADR-006 · Legítimo interesse em vez de consentimento. Consentimento sob "aceite ou não entre" não é livre e não é base legal válida. Ver 06-LGPD-E-SEGURANCA.md.

ADR-007 · Sem reconhecimento facial na v1. Elevaria os dados a sensíveis (LGPD art. 11), com exigência jurídica muito maior. Decisão de produto com fundamento legal, não limitação técnica.

9. Referências

  • 02-MODELO-DE-DADOS.md — schema, outbox, fila, versionamento
  • 03-FLUXOS-E-CONTRATOS.md — máquinas de estado e API
  • 05-INFRA-DOCKER.md — compose, portas, dimensionamento
  • 06-LGPD-E-SEGURANCA.md — bases legais e modelo de ameaças