241 lines
14 KiB
Markdown
241 lines
14 KiB
Markdown
# 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.
|
||
|
||
```kotlin
|
||
// 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.
|
||
|
||
```kotlin
|
||
@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.
|
||
|
||
```kotlin
|
||
@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 `DISABLE`d na v1.
|
||
|
||
```sql
|
||
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 (3–8MB) daria TTI de 3–6s 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
|