Pular para o conteúdo

D-1c — Gestão de Anexos

Parte da D-1 — Ingestão de Demanda


Gerencia o ciclo de vida dos arquivos anexados a demandas: fotos, documentos, áudio. Consome demanda.recebida quando o payload contém midia_urls, demanda.evidencia_adicionada para as evidências de confirmação e conclusão coletiva e moderacao.decidida para aplicar a decisão humana. Em cada arquivo, valida o tipo por magic bytes, verifica o tamanho, calcula o hash SHA-256, deduplica por hash, armazena o buffer limpo com chave baseada em hash no MinIO e publica anexo.processado com a chave do objeto, a URL canônica e a via de origem. A URL assinada de acesso é gerada na leitura pelo consumidor, com validade curta.

Não processa o conteúdo semântico dos arquivos. Isso é responsabilidade da D-1b (Normalização). A D-1c garante que o arquivo existe, é válido, está acessível, tem integridade verificável e não contém dados sensíveis não mitigados.

A D-1c opera com BFF leve acoplado. Expõe endpoints de leitura de anexos (GET /api/anexos/:id/download e GET /api/anexos/:id/info), com identificação obrigatória. O upload é orquestrado pela D-1a (presigned PUT URL), o front-end faz upload direto ao MinIO, e a D-1c recebe as chaves via evento para processamento.

No MVP, a mídia da captura é pública por padrão (transparência radical). Metadados EXIF com geolocalização são removidos do arquivo armazenado e não são persistidos. A classificação local (NSFW, pessoa e heurística de documento) e a revisão humana definem o acesso: anexo com dado sensível ou bloqueado sai da projeção pública da D-7 e exige papel de moderador no endpoint de download. O blur automático entra na Fase 2.

A decisão removido da moderação é a exceção ao append-only. A D-1c apaga o objeto permanente e os temporários de todas as cópias do mesmo hash, marca as linhas como removidas e publica anexo.removido por anexo afetado. O hash permanece no registro e no log. O re-upload do mesmo arquivo para a mesma demanda é recusado enquanto a linha removida existir (tombstone).


A D-1c é um módulo NestJS com encapsulamento próprio dentro do monolito modular do MVP. Expõe controllers REST para acesso a anexos e consome eventos do barramento via EventBusService (N-0a). É consumidora e produtora de eventos.

src/demanda/d-1c-anexos/
├── d1c.module.ts # Module definition
├── d1c.constants.ts # Constantes: tipos MIME, limites, magic bytes, TTLs, limiares
├── controllers/
│ └── anexo.controller.ts # GET /anexos/:id/download, GET /anexos/:id/info
├── dto/
│ └── anexo-response.dto.ts # Shape de resposta dos endpoints (info e download)
├── repositories/
│ ├── anexo.repository.ts # Acesso a d1c.anexos
│ └── evento-processado.repository.ts # Acesso a d1c.eventos_processados e d1c.consumer_offset
├── services/
│ ├── processamento-anexo.service.ts # Orquestração: iniciar, consumir eventos, validar, armazenar, publicar
│ ├── validacao-midia.service.ts # Validação: magic bytes, MIME e tamanho
│ ├── armazenamento.service.ts # MinIO: download, object get, upload do buffer limpo, remoção e presigned GET
│ ├── acesso-anexo.service.ts # Metadados e presigned URL de download para o BFF
│ ├── deteccao-sensivel.service.ts # Orquestra EXIF e a detecção sensível (heurística + classificadores)
│ ├── classificador-nsfw.service.ts # Classificação NSFW local (vit-base-nsfw-detector)
│ └── classificador-pessoa.service.ts # Detecção de pessoa local (detr-resnet-50)
└── (specs ao lado dos arquivos)
@Module({
imports: [],
controllers: [
AnexoController,
],
providers: [
ProcessamentoAnexoService,
ValidacaoMidiaService,
ArmazenamentoService,
AcessoAnexoService,
DeteccaoSensivelService,
ClassificadorNsfwService,
ClassificadorPessoaService,
AnexoRepository,
EventoProcessadoRepository,
IdentidadeCidadaoGuard,
],
exports: [],
})
export class D1cModule implements OnModuleInit {
constructor(
private readonly processamentoService: ProcessamentoAnexoService,
) {}
async onModuleInit(): Promise<void> {
await this.processamentoService.iniciar();
}
}
  • O módulo não é @Global(). A D-1c não é dependência de nenhuma outra colônia.
  • O módulo não importa EventBusModule explicitamente. EventBusModule é @Global(), e o EventBusService é injetável sem import.
  • O OnModuleInit chama iniciar(). O método semeia os cursores com obterMaiorSequence(), refaz o replay dos eventos perdidos e só então registra os consumidores ao vivo.
  • A D-1c é produtora e consumidora. Usa d1c.eventos_processados para a idempotência de demanda.recebida e d1c.consumer_offset para o replay de demanda.evidencia_adicionada e moderacao.decidida.
  • O AnexoController é exclusivamente de leitura. Nenhum endpoint de escrita. O upload é orquestrado pela D-1a.
  • O ArmazenamentoService cria o bucket no boot quando ele não existe.
  • Os classificadores NSFW e de pessoa usam comTempoLimite (src/shared/ia/com-tempo-limite.ts) com TIMEOUT_CLASSIFICACAO_MS e degradam para a heurística quando falham.

Os contratos dos serviços, com as assinaturas reais:

Serviço Métodos
ProcessamentoAnexoService iniciar(), registrarConsumidores(), processarDemandaRecebida(evento), processarEvidenciaAdicionada(evento), processarModeracaoDecidida(evento)
ValidacaoMidiaService validarMagicBytes(cabecalhoHex), validarTamanho(tamanhoBytes, tipoMime), tiposPermitidos(), validarContentTypeDeclarado(contentTypeArmazenado, mimeDetectado)
ArmazenamentoService verificarExistencia(objectKey), obterContentType(objectKey), downloadTemporario(objectKey), uploadPermanente(objectKey, buffer, tipoMime), removerObjeto(objectKey), gerarPresignedGet(objectKey, ttlSegundos)
AcessoAnexoService obterInfo(anexoId, contexto), gerarUrlDownload(anexoId, contexto)
DeteccaoSensivelService verificarFormatoImagem(buffer), removerMetadadosExif(buffer, tipoMime), detectarConteudoSensivel(buffer, tipoMime)
ClassificadorNsfwService estaDisponivel(), classificar(buffer)
ClassificadorPessoaService estaDisponivel(), detectar(buffer)

Tipos de retorno exportados:

interface AnexoRegistro {
id: string;
demanda_id: string;
hash_sha256: string;
tipo_mime: string;
tamanho_bytes: bigint | number;
object_key: string;
object_key_temp: string | null;
possui_dado_sensivel: boolean;
score_sensivel: number;
categoria_sensivel: string | null;
motivo_sensivel: string | null;
score_nsfw: number | null;
score_pessoa: number | null;
moderacao_status: string;
moderado_por: string | null;
moderado_em: Date | null;
metadados_exif: unknown;
duplicata_de: string | null;
status: string;
processado_em: Date;
criado_em: Date;
atualizado_em: Date;
}
interface ResultadoDeteccao {
possuiDadoSensivel: boolean;
score: number;
detalhes: string[];
categoriaSensivel: 'nsfw' | 'pessoa' | 'documento' | null;
motivoSensivel: string | null;
scoreNsfw: number | null;
scorePessoa: number | null;
moderacaoStatus: 'nao_aplicavel' | 'pendente';
}
interface ResultadoNsfw {
score: number;
}
interface ResultadoPessoa {
detectada: boolean;
score: number;
}
interface ContextoAcessoAnexo {
roles: string[];
}
Método Rota Controller Descrição
GET /api/anexos/:id/info AnexoController Retorna metadados do anexo: tipo MIME, tamanho, hash, status e score de sensibilidade. Exige identificação (JWT Bearer ou X-Cidadao-Id). Anexo sensível exige papel de moderador ou admin (403). 120/min.
GET /api/anexos/:id/download AnexoController Redireciona (HTTP 302) para a URL assinada do storage, com TTL de 5 minutos. Exige identificação. Anexo sensível exige papel de moderador ou admin. Anexo removido responde 410 Gone. 60/min.

A D-1c é uma colônia de processamento com BFF leve, ao contrário da D-1a (BFF é o propósito principal) e da D-1b (puramente reativa). O BFF existe por necessidade prática: URLs pré-assinadas expiram e o front-end precisa de um endpoint estável para acessar anexos sem depender de URLs efêmeras no evento.

Os endpoints são exclusivamente de leitura e não envolvem lógica de negócio. O AcessoAnexoService consulta o estado próprio (metadados), aplica a checagem de acesso sensível e gera a URL assinada via ArmazenamentoService. O BFF não modifica estado, não toma decisões e não publica eventos. A identificação fica no IdentidadeCidadaoGuard.


Todas as tabelas da D-1c residem no schema d1c do PostgreSQL. Este schema é de uso exclusivo do módulo D-1c. Nenhuma outra colônia lê ou escreve nestas tabelas.

Registro de todos os anexos processados. Mesmo arquivo referenciado por duas demandas diferentes gera duas linhas (mesmo hash_sha256, demanda_id distinto), mas o objeto no MinIO é armazenado uma única vez (chave = anexos/{hash_sha256}).

CREATE SCHEMA IF NOT EXISTS d1c;
CREATE TABLE d1c.anexos (
id UUID PRIMARY KEY,
demanda_id UUID NOT NULL,
hash_sha256 VARCHAR(64) NOT NULL,
tipo_mime VARCHAR(127) NOT NULL,
tamanho_bytes BIGINT NOT NULL,
object_key VARCHAR(500) NOT NULL,
object_key_temp VARCHAR(500),
possui_dado_sensivel BOOLEAN NOT NULL DEFAULT false,
score_sensivel DOUBLE PRECISION NOT NULL DEFAULT 0,
categoria_sensivel VARCHAR(30),
motivo_sensivel VARCHAR(100),
score_nsfw DOUBLE PRECISION,
score_pessoa DOUBLE PRECISION,
moderacao_status VARCHAR(20) NOT NULL DEFAULT 'nao_aplicavel',
moderado_por UUID,
moderado_em TIMESTAMPTZ(2),
metadados_exif JSONB,
duplicata_de UUID,
status VARCHAR(20) NOT NULL DEFAULT 'ativo',
processado_em TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP,
criado_em TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP,
atualizado_em TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX anexos_hash_sha256_demanda_id_key ON d1c.anexos (hash_sha256, demanda_id);
CREATE INDEX anexos_demanda_id_idx ON d1c.anexos (demanda_id);
CREATE INDEX anexos_hash_sha256_idx ON d1c.anexos (hash_sha256);
CREATE INDEX anexos_status_idx ON d1c.anexos (status);
CREATE INDEX anexos_possui_dado_sensivel_idx ON d1c.anexos (possui_dado_sensivel);
CREATE INDEX anexos_moderacao_status_idx ON d1c.anexos (moderacao_status);

Os CHECKs de status e tamanho_bytes não existem no banco. Prisma não gera CHECK. A validação dos enums ocorre na aplicação, pelas constantes de d1c.constants.ts e pelas validações do pipeline.

Coluna Tipo Descrição
id UUID PK anexo_id. Gerado pelo Prisma (uuid v4). Identificador único do registro de processamento.
demanda_id UUID Demanda à qual o anexo está vinculado. Origem: payload do evento.
hash_sha256 VARCHAR(64) Hash SHA-256 do conteúdo do arquivo. Chave para deduplicação de storage.
tipo_mime VARCHAR(127) MIME type validado (ex: image/jpeg). Detectado por magic bytes, não confiado do declarado.
tamanho_bytes BIGINT Tamanho real do arquivo em bytes, obtido do storage.
object_key VARCHAR(500) Chave do objeto permanente no MinIO: anexos/{hash_sha256}.
object_key_temp VARCHAR(500) Chave do objeto temporário do upload. Limpa na remoção por moderação. Fora isso, a limpeza dos temporários é da N-0d (retenção).
possui_dado_sensivel BOOLEAN Flag informativa. true quando o classificador NSFW ou a heurística de documento encontram conteúdo sensível.
score_sensivel DOUBLE PRECISION Score de confiança da detecção (0-1). 0 = nenhum indício, 1 = alta confiança.
categoria_sensivel VARCHAR(30) Categoria que motivou a sinalização: nsfw, pessoa ou documento. null quando nada foi detectado.
motivo_sensivel VARCHAR(100) Motivo específico da sinalização: nsfw_alto, nsfw_revisao, pessoa_detectada ou o padrão de documento.
score_nsfw DOUBLE PRECISION Score do classificador NSFW, quando executado.
score_pessoa DOUBLE PRECISION Score do detector de pessoa, quando executado.
moderacao_status VARCHAR(20) Estado na moderação: nao_aplicavel, pendente, aprovado, bloqueado ou removido.
moderado_por UUID Moderador que decidiu o anexo. null enquanto não houver decisão.
moderado_em TIMESTAMPTZ(2) Momento da decisão de moderação. null enquanto não houver decisão.
metadados_exif JSONB Coluna reservada para metadados EXIF. Não é populada no MVP: o GPS é descartado na remoção e nunca persistido.
duplicata_de UUID anexo_id do primeiro registro com o mesmo hash_sha256. null se é o primeiro.
status VARCHAR(20) ativo (acessível), removido (excluído) ou bloqueado (conteúdo sensível pendente de revisão).
processado_em TIMESTAMPTZ(2) Timestamp de conclusão do processamento.
criado_em TIMESTAMPTZ(2) Timestamp de criação do registro.
atualizado_em TIMESTAMPTZ(2) Timestamp da última alteração.

Registro de idempotência para consumo de eventos. Garante que cada demanda.recebida seja processada exatamente uma vez, mesmo com reentrega do barramento.

CREATE TABLE d1c.eventos_processados (
event_id UUID PRIMARY KEY,
demanda_id UUID NOT NULL,
processado_em TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP,
criado_em TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX eventos_processados_demanda_id_idx ON d1c.eventos_processados (demanda_id);
Coluna Tipo Descrição
event_id UUID PK event_id do evento demanda.recebida processado. Chave de idempotência.
demanda_id UUID demanda_id correspondente. Índice para consultas de reconciliação.
processado_em TIMESTAMPTZ(2) Timestamp de conclusão do processamento.
criado_em TIMESTAMPTZ(2) Timestamp de criação do registro.

Cursor de replay para demanda.evidencia_adicionada e moderacao.decidida. O demanda.recebida não usa cursor: a idempotência dele fica em d1c.eventos_processados.

CREATE TABLE d1c.consumer_offset (
tipo_evento VARCHAR(255) PRIMARY KEY,
last_sequence BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
Coluna Tipo Descrição
tipo_evento VARCHAR(255) PK Tipo de evento monitorado.
last_sequence BIGINT Última sequence_number processada. O repositório só avança quando a sequência é maior.
updated_at TIMESTAMPTZ(2) Timestamp da última atualização do cursor.

Três migrations:

  1. 20260809110838_d1c_criar_schema_anexos — Cria o schema d1c, as tabelas anexos e eventos_processados, a unique anexos_hash_sha256_demanda_id_key e os índices.
  2. 20260912120000_d1c_consumer_offset — Cria d1c.consumer_offset com PK em tipo_evento.
  3. 20260912140000_d1c_classificacao_conteudo — Adiciona categoria_sensivel, motivo_sensivel, score_nsfw, score_pessoa, moderacao_status, moderado_por e moderado_em, com o índice anexos_moderacao_status_idx. A remoção de conteúdo usa essas colunas e não cria migration nova: status='removido' e moderacao_status='removido' são gravados por updateMany.

Não há foreign keys entre as tabelas do schema d1c. A constraint UNIQUE (hash_sha256, demanda_id) em anexos garante que o mesmo arquivo não seja processado duas vezes para a mesma demanda. O campo duplicata_de é uma FK lógica (aponta para outro id na mesma tabela), sem constraint formal. A integridade é garantida pelo ProcessamentoAnexoService.

object_key baseado em hash, não em UUID. A chave anexos/{hash_sha256} permite deduplicação real no storage: dois registros em d1c.anexos (demandas diferentes) apontam para o mesmo objeto MinIO. A remoção do objeto atinge todas as cópias. Por isso, na decisão removido da moderação, o ProcessamentoAnexoService remove o objeto permanente e os temporários do hash e marca todas as linhas do mesmo hash_sha256 como removidas, em qualquer demanda.

duplicata_de como FK lógica, não constraint. O registro original pode ser removido (status removido) sem quebrar constraints. O campo existe para rastreabilidade: dado um anexo, é possível encontrar o primeiro registro daquele hash. Útil para auditoria e para o tombstone de re-upload.

O registro removido é um tombstone. O par hash_sha256 + demanda_id é único. A linha removida permanece com status='removido' e impede que o mesmo arquivo seja reprocessado para a mesma demanda: o processarUmaMidia reconhece a linha e devolve invalido, sem recriar o objeto no bucket.

eventos_processados com PK no event_id, não sequence. O event_id do barramento é UUID v4 único por publicação. Usá-lo como PK garante idempotência natural: se o mesmo evento for entregue duas vezes, o segundo INSERT viola a PK e é ignorado.

object_key_temp como VARCHAR(500), não JSONB. Cada anexo tem exatamente um objeto temporário de origem. Uma coluna simples é suficiente. Não há cenário no MVP em que um anexo tenha múltiplos objetos temporários.

Índice em possui_dado_sensivel e em moderacao_status. O índice cobre as consultas de revisão (WHERE possui_dado_sensivel = true) e a fila da D-1d (WHERE moderacao_status = 'pendente'). São consultas raras e pontuais, mas caras sem índice em volume.

metadados_exif fica reservada. A remoção de EXIF descarta os metadados e o GPS nunca é persistido (LGPD, art. 6º, III). A coluna existe para evolução, mas o pipeline do MVP não escreve nela.


A D-1c consome demanda.recebida, demanda.evidencia_adicionada e moderacao.decidida, e produz anexo.processado, anexo.moderado e anexo.removido. Os schemas completos (JSON Schema draft-2020-12) estão definidos no Registry (N-0b).

Propriedade Valor
Tipo demanda.recebida
Schema version 1.1.0 (a 1.0.0 permanece aceita)
Produtor D-1a
Consumidores D-1b (Normalização), D-1c (Anexos), N-0d (Direitos do Titular)
Descrição Input bruto do cidadão aceito pelo sistema.

Payload consumido:

interface DemandaRecebidaPayload {
demanda_id: string;
texto_bruto: string;
tipo_midia?: string; // 'texto' | 'foto' | 'audio'
localizacao_bruta: {
lat: number;
lng: number;
};
midia_urls: Array<{ // campo adicional publicado pela D-1a, fora do schema declarado
tipo: 'imagem' | 'audio'; // vídeo fora do contrato
url: string; // URL completa de acesso ao MinIO
object_key: string; // chave do objeto no bucket
}>;
cidadao_id: string;
canal: string; // 'app' | 'web' | 'sms' | '156'
timestamp_criacao: string;
termos_versao?: string; // v1.1.0
consentimentos?: Array<{ // v1.1.0
finalidade: string;
versao: string;
aceito_em: string;
}>;
categoria_id?: string; // v1.1.0
subcategoria_id?: string; // v1.1.0
}

Campo midia_urls: acompanha o payload publicado pela D-1a como campo adicional, fora do schema declarado do evento. A D-1c o consome quando existe e não é vazio. Ausente ou vazio, o evento é tratado como demanda sem anexos.

Critério de processamento: A D-1c só processa o evento se midia_urls existe e não é vazio. Se midia_urls for undefined ou [], o handler registra o evento em eventos_processados e retorna sem inserção em anexos e sem publicação.

3.1.1 Evento consumido: demanda.evidencia_adicionada

Seção intitulada “3.1.1 Evento consumido: demanda.evidencia_adicionada”

Evidência anexada por terceiro em uma confirmação ou conclusão coletiva. A D-1c consome o evento, valida a url contra a allowlist de hosts, deriva a chave do objeto da URL com o bucket configurado e reprocessa a mídia pelo mesmo pipeline da captura (magic bytes, content type, tamanho, EXIF, detecção sensível). O evento demanda.evidencia_adicionada não projeta evidência na D-7 por si só: a evidência assinável nasce do anexo.processado publicado pela D-1c.

A D-1c mantém cursor próprio em d1c.consumer_offset para esse tipo, com replay no boot (N-0a - Event Bus.md, seção “Reentrega (replay)”). O demanda.recebida segue design próprio, com idempotência por eventos_processados e sem cursor.

Propriedade Valor
Tipo anexo.processado
Schema version 1.4.0 (as versões 1.0.0 a 1.3.0 permanecem no catálogo)
Produtor D-1c
Consumidores D-7 (Transparência), D-6b (Relatoria e Acompanhamento), D-1d (Moderação), demais colônias que referenciam anexos
Descrição Arquivo anexado a uma demanda foi validado, armazenado, classificado e está acessível. A 1.4.0 acrescenta object_key_temp com a chave temporária da captura.

Payload publicado:

interface AnexoProcessadoPayload {
demanda_id: string;
anexo_id: string;
tipo_mime: string;
tamanho_bytes: number;
hash_sha256: string;
url_canonica: string; // /api/anexos/{anexo_id}/download — estável
object_key: string; // chave do objeto no bucket, para assinatura na leitura
object_key_temp?: string; // chave temporária da captura; a D-1d e a D-7 casam a descrição automática da imagem
possui_dado_sensivel: boolean; // true quando o NSFW ou a heurística de documento acusam
duplicata_de: string | null; // anexo_id do primeiro registro com mesmo hash (null se é o primeiro)
via: 'captura' | 'confirmacao' | 'conclusao'; // origem da evidência
categoria_sensivel: 'nsfw' | 'pessoa' | 'documento' | null;
motivo_sensivel: string | null; // nsfw_alto, nsfw_revisao, pessoa_detectada, documento
moderacao_status: 'nao_aplicavel' | 'pendente' | 'aprovado' | 'bloqueado';
}

Detalhamento do payload:

Campo Descrição
demanda_id Demanda à qual o anexo foi vinculado.
anexo_id UUID do registro em d1c.anexos. Referência canônica para o endpoint de download.
tipo_mime MIME type validado por magic bytes (não o declarado pelo cliente).
tamanho_bytes Tamanho real do arquivo, verificado durante o download.
hash_sha256 Hash do conteúdo. Permite verificação de integridade por qualquer consumidor.
url_canonica Caminho estável do BFF da D-1c: /api/anexos/{anexo_id}/download.
object_key Chave do objeto no bucket. O consumidor assina a URL de GET na leitura, com validade curta, sem armazenar assinatura no evento.
object_key_temp Chave temporária do upload da captura, campo opcional da versão 1.4.0. A D-1d guarda a chave no item da fila (midia_object_key) e casa a descrição automática da imagem publicada em midias_descritas por demanda.normalizada; a D-7 guarda a chave na evidência e casa as mesmas descrições para exibir o texto por imagem no resumo e no relatório.
possui_dado_sensivel true quando a heurística de documento ou o classificador NSFW bloqueiam. Anexo com a flag fica fora da projeção pública da D-7 e exige papel de moderador no download.
duplicata_de Se o arquivo é duplicata de um processamento anterior, contém o anexo_id original. null se é o primeiro registro daquele hash.
via Origem da evidência: captura, confirmação ou conclusão coletiva.
categoria_sensivel Categoria que motivou a sinalização: nsfw, pessoa ou documento. null quando nada foi detectado.
motivo_sensivel Motivo específico: nsfw_alto, nsfw_revisao, pessoa_detectada ou o padrão de documento identificado.
moderacao_status Estado na moderação humana: nao_aplicavel sem sinalização, pendente quando entra na fila da D-1d.

Classificação local de imagem.

A D-1c classifica imagens com dois modelos ONNX executados no mesmo processo, no cache compartilhado com a D-1b (D1B_CACHE_MODELOS):

Modelo Tarefa dtype Limiar
AdamCodd/vit-base-nsfw-detector classificação NSFW q8 (D1C_DTYPE_NSFW) 0.85 bloqueio, 0.5 revisão
skillsafe-ai/detr-resnet-50 detecção de pessoa q8 0.5 (sinalização)

Regras de decisão, nesta prioridade:

  1. NSFW com score ≥ LIMIAR_NSFW_BLOQUEIO (0.85): possui_dado_sensivel=true, categoria_sensivel='nsfw', motivo_sensivel='nsfw_alto', moderacao_status='pendente'.
  2. NSFW entre LIMIAR_NSFW_REVISAO (0.5) e 0.85: bloqueia igual, com motivo_sensivel='nsfw_revisao'.
  3. Heurística de documento/PII: categoria_sensivel='documento', moderacao_status='pendente'.
  4. Pessoa detectada sem NSFW: possui_dado_sensivel=false, categoria_sensivel='pessoa', motivo_sensivel='pessoa_detectada', moderacao_status='pendente'. A evidência continua na projeção pública.
  5. Nada detectado: categoria_sensivel=null, moderacao_status='nao_aplicavel'.

As features D1C_NSFW_HABILITADO e D1C_PESSOA_HABILITADA desligam cada classificador. Falha, timeout (TIMEOUT_CLASSIFICACAO_MS) ou modelo indisponível degradam para a heurística, sem interromper o processamento do anexo. O output é marcado como automático e a revisão humana é reativa.

3.2.1 Evento consumido: moderacao.decidida (1.1.0, tipo anexo)

Seção intitulada “3.2.1 Evento consumido: moderacao.decidida (1.1.0, tipo anexo)”
Propriedade Valor
Tipo moderacao.decidida
Versão consumida 1.1.0
Produtor D-1d
Consumidores D-1c (anexo), D-7 (texto e trilha), N-0d (limpeza transversal)

A D-1c trata apenas tipo === 'anexo'. Eventos de texto são descartados com o cursor avançado. O handler tem cursor próprio (d1c.consumer_offset) com replay no boot, ao lado de demanda.evidencia_adicionada.

  • aprovado: status='ativo', possui_dado_sensivel=false, moderacao_status='aprovado', moderado_por e moderado_em gravados. Publica anexo.moderado.
  • bloqueado: status='bloqueado', moderacao_status='bloqueado', mantendo o possui_dado_sensivel anterior, com moderado_por e moderado_em. Publica anexo.moderado.
  • removido: apaga o objeto permanente e os temporários de todas as cópias do hash_sha256, publica anexo.removido por anexo afetado e marca as linhas com status='removido', moderacao_status='removido', moderado_por, moderado_em e object_key_temp nulo.

Regras de robustez:

  • Anexo inexistente: o evento é descartado com o cursor avançado.
  • Anexo já removido: a remoção é ignorada e o cursor avança (idempotência do replay).
  • Falha ao remover objeto ou ao publicar anexo.removido: a exceção impede marcar as linhas e avançar o cursor. O evento vai para a DLQ e o replay do boot retenta.
  • O object_key_temp é limpo na remoção; o object_key permanente permanece na linha para auditoria do rastro, mas o objeto deixa de existir no bucket.
Propriedade Valor
Tipo anexo.removido
Versão 1.0.0
Produtor D-1c
Consumidores D-7
interface AnexoRemovidoPayload {
anexo_id: string;
demanda_id: string;
hash_sha256: string;
}

Um evento por anexo afetado, publicado via publicarComRetry antes de marcar as linhas. O object_key não entra no payload: a D-1c não persiste esse campo como referência viva depois da remoção. O hash_sha256 permanece como impressão digital do conteúdo.

3.3 Ordem de operações — processamento de demanda.recebida

Seção intitulada “3.3 Ordem de operações — processamento de demanda.recebida”

O fluxo no ProcessamentoAnexoService.processarDemandaRecebida() segue esta ordem:

1. Verificar idempotência de consumo
→ consultar d1c.eventos_processados por event_id
→ se encontrado: retornar sem processar
2. Verificar se há anexos
→ se evento.payload.midia_urls é undefined, null ou []:
→ registrar event_id em d1c.eventos_processados
→ retornar (sem publicar nada)
3. Para cada midia_url em evento.payload.midia_urls, chamar processarUmaMidia:
a. Idempotência por upload
→ buscarPorDemandaEObjectKey(demanda_id, midia_url.object_key)
→ se encontrado: resultado 'duplicata', seguir para o próximo midia_url
b. Existência no storage
→ verificarExistencia(object_key)
→ se não existe: log.error, resultado 'invalido', seguir para o próximo
c. Content-Type armazenado
→ obterContentType(object_key)
d. Download e hash
→ downloadTemporario(object_key) devolve o stream e o tamanho
→ ler o stream acumulando os bytes, atualizar o hash SHA-256 e guardar os primeiros 512 bytes
→ hashSha256 = digest('hex'); cabecalhoHex = primeiros 512 bytes em hexadecimal
e. Validar magic bytes
→ validarMagicBytes(cabecalhoHex)
→ se inválido: log.warn, resultado 'invalido', seguir para o próximo
f. Validar o Content-Type declarado
→ validarContentTypeDeclarado(contentTypeArmazenado, mimeDetectado)
→ se divergente: log.warn, resultado 'invalido', seguir para o próximo
g. Validar tamanho
→ validarTamanho(tamanhoBytes, mimeDetectado)
→ se excede o limite: log.warn, resultado 'invalido', seguir para o próximo
h. Imagens: formato real
→ verificarFormatoImagem(buffer) com sharp
→ se o formato divergir do MIME detectado: log.warn, resultado 'invalido', seguir para o próximo
i. Imagens: remover EXIF
→ removerMetadadosExif(buffer, mimeDetectado) devolve o buffer limpo
→ GPS é descartado e os metadados não são persistidos
j. Detectar conteúdo sensível
→ detectarConteudoSensivel(bufferFinal, mimeDetectado) devolve
possuiDadoSensivel, score, categoria, motivo, scores e moderacaoStatus
k. Duplicidade por hash
→ buscarPrimeiroPorHash(hashSha256)
→ se encontrado: duplicataDe = registro.id
l. Tombstone e duplicidade na mesma demanda
→ buscarPorDemandaEHash(demandaId, hashSha256)
→ se a linha da demanda existe com status 'removido':
log.warn, resultado 'invalido' (reenvio bloqueado pelo tombstone)
→ se a linha da demanda existe ativa: resultado 'duplicata'
m. Upload permanente
→ uploadPermanente(`anexos/${hashSha256}`, bufferFinal, mimeDetectado)
→ falha é logada e o anexo é descartado sem persistência; a reconciliação de órfãos é Fase 2
n. Persistir metadados
→ INSERT em d1c.anexos com demanda_id, hash_sha256, tipo_mime, tamanho_bytes,
object_key, object_key_temp, possui_dado_sensivel, score_sensivel,
categoria_sensivel, motivo_sensivel, score_nsfw, score_pessoa,
moderacao_status, metadados_exif = null, duplicata_de e
status = possui_dado_sensivel ? 'bloqueado' : 'ativo'
o. Publicar anexo.processado
→ url_canonica = `/api/anexos/${anexo.id}/download`
→ eventBus.publicar com versao_schema '1.4.0', event_id UUID v4 novo e
correlacao_id do evento; payload com demanda_id, anexo_id, tipo_mime,
tamanho_bytes, hash_sha256, url_canonica, object_key, object_key_temp,
possui_dado_sensivel, duplicata_de, via, categoria_sensivel,
motivo_sensivel e moderacao_status
→ falha é logada; o registro persiste e a reconciliação de órfãos é Fase 2
4. Registrar idempotência de consumo
→ INSERT em d1c.eventos_processados (event_id, demanda_id)
→ falha de gravação é ignorada
5. Log de conclusão
→ resumo com total de URLs, processados, duplicatas, falhas e tempo

Os objetos temporários do upload não são removidos pela D-1c: a D-1b ainda baixa a mídia pela URL assinada para transcrever ou descrever. A limpeza dos temporários fica com a N-0d, na retenção.

Camada 1 — Consumo de evento (eventos_processados): O event_id do demanda.recebida é a chave de idempotência. Se o barramento reentregar o mesmo evento, a PK event_id em d1c.eventos_processados detecta e o handler retorna sem processar. Garante at-most-once para eventos completos.

Camada 2 — Processamento por anexo (object_key_temp): Dentro de um mesmo evento, a verificação WHERE demanda_id = ? AND object_key_temp = ? em d1c.anexos protege contra reprocessamento parcial. Se o handler cair após processar 2 de 5 anexos, o replay do evento pula os 2 já processados e processa os 3 restantes. A chave object_key_temp identifica unicamente o arquivo enviado.

Camada 3 — Publicação de evento (event_id): O event_id do anexo.processado é um UUID v4 novo gerado no publish, não o anexo_id nem o event_id do evento consumido. A idempotência do consumo fica em d1c.eventos_processados. As publicações de anexo.moderado e anexo.removido usam publicarComRetry e reutilizam o mesmo event_id nas tentativas, com a deduplicação do barramento. O anexo.processado usa publicar direto.

Cenário Comportamento
demanda.recebida sem midia_urls Handler registra event_id em eventos_processados e retorna. Nenhum evento publicado.
midia_urls com array vazio Mesmo comportamento.
Objeto não encontrado no storage log.error com object_key. Anexo inválido. Processa os demais. Não bloqueia o pipeline.
Magic bytes inválidos log.warn. Anexo inválido. Processa os demais. O objeto temporário não é removido pela D-1c.
Content-Type declarado diverge do detectado log.warn. Anexo inválido. Processa os demais.
Tamanho excede limite log.warn. Anexo inválido. Processa os demais.
Formato real da imagem diverge do detectado log.warn. Anexo inválido. Processa os demais.
Falha no download do storage Exceção capturada no handler do anexo. log.error. Conta falha e continua para o próximo.
Falha no upload permanente log.error. O anexo é descartado sem persistência e sem publicação; a reconciliação de órfãos é Fase 2.
INSERT em d1c.anexos falha Exceção capturada no handler do anexo. log.error. Conta falha e continua. O evento termina registrado; o reprocessamento de órfãos é Fase 2.
INSERT em d1c.eventos_processados falha Qualquer falha de gravação é ignorada.
Reenvio do mesmo arquivo após remoção O tombstone em hash_sha256 + demanda_id devolve invalido; o objeto não é recriado no bucket.
Publicação de anexo.processado falha log.error. O registro no banco existe e o evento não é publicado. A reconciliação de órfãos é Fase 2.
Handler lança exceção não tratada O barramento captura via wrapper e registra na DLQ. Na reentrega, a idempotência por event_id e por object_key_temp evita duplicação.
Evento demanda.recebida na versão 1.0.0 (sem midia_urls) Campo ausente = undefined. Handler retorna sem processar. Backward-compatible.

Consumir demanda.recebida, não demanda.normalizada. O processamento de anexos é operação de binário, independente da normalização de texto. D-1c e D-1b operam em paralelo, ambas disparadas pelo mesmo evento. Isso reduz a latência do pipeline: o anexo fica disponível antes de a normalização terminar.

Persistir antes de publicar. O registro em d1c.anexos é criado antes de eventBus.publicar(). Se o publish falhar, o dado está salvo e o registro pode ser reconciliado. A ordem inversa (publicar antes de persistir) arriscaria perda de dado: se o INSERT falhar após publish bem-sucedido, o evento anexo.processado trafegaria no sistema apontando para um anexo que não existe.

Processamento independente por anexo. Cada anexo é processado e persistido de forma independente. Uma falha em um anexo não desfaz os anteriores: o handler conta a falha e segue, e o replay do evento pula os já processados. Processar todos em uma única transação criaria um ponto de rollback único: se o último de 10 anexos falhar, todos os 9 anteriores seriam desfeitos e o evento inteiro reprocessado, repetindo downloads e validações.

Hash calculado durante a leitura, buffer remontado para EXIF e detecção. O hash e os primeiros 512 bytes são calculados enquanto o stream é lido. O buffer completo é remontado em memória para a remoção de EXIF e a detecção de conteúdo sensível, com uma única leitura do storage. Para arquivos de até 20 MB, o reassembly é aceitável no MVP.

Remoção de EXIF como padrão, não opcional. Metadados EXIF de fotos (GPS, modelo da câmera, timestamp) são removidos do arquivo armazenado. Os metadados são descartados na operação; o GPS não é persistido. Isso mitiga o risco de expor localização exata de residências via coordenadas embutidas em fotos. A localização da demanda já está registrada no evento demanda.recebida e não precisa ser duplicada nos metadados da foto.

URL canônica e chave do objeto no payload de anexo.processado. url_canonica é o caminho estável do BFF e garante que qualquer consumidor do evento sempre tenha um ponto de acesso válido. object_key permite ao consumidor assinar a URL de GET na leitura, com validade curta, sem armazenar assinatura no evento. A D-7 usa esse caminho para exibir a evidência no resumo e no relatório da demanda públicos.

A D-1a publica o evento na versão 1.1.0 e inclui midia_urls no payload quando há anexos. O object_key é o elo do contrato: a D-1a o devolve no endpoint de presigned PUT URL, o front-end o repassa no POST /demandas e a D-1c o usa para acessar o objeto no storage. Sem midia_urls, o evento é tratado como demanda sem anexos. A D-1b ignora o campo e a projeção da D-7 não é afetada pela presença dele.


4.1 ValidacaoMidiaService.validarMagicBytes() — pseudocódigo

Seção intitulada “4.1 ValidacaoMidiaService.validarMagicBytes() — pseudocódigo”
função validarMagicBytes(cabecalhoHex: string) → { valido, mimeDetectado }:
mapeamentoMagicBytes = {
'image/jpeg': ['FFD8FF'],
'image/png': ['89504E470D0A1A0A'],
'image/webp': ['52494646'], // RIFF
'image/gif': ['47494638'],
'audio/mpeg': ['FFFB', 'FFF3', 'FFE3', 'FFF2'],
'audio/mp4': ['0000001866747970'], // ftyp
'audio/ogg': ['4F676753'],
'audio/wav': ['52494646'], // RIFF
'audio/webm': ['1A45DFA3'], // EBML
'application/pdf': ['25504446'],
'application/zip': ['504B0304', '504B0506', '504B0708'],
}
prefixo = cabecalhoHex.toUpperCase()
para cada (mime, assinaturas) em mapeamentoMagicBytes:
para cada assinatura em assinaturas:
se prefixo começa com assinatura:
retornar { valido: true, mimeDetectado: mime }
retornar { valido: false, mimeDetectado: 'application/octet-stream' }

A assinatura dos arquivos RIFF (image/webp e audio/wav) é a mesma nos primeiros bytes; a distinção fina não é feita nessa etapa. O contêiner WebM usa a assinatura EBML (1A45DFA3), que cobre o áudio gravado pelos navegadores Chrome, Brave e Android. text/plain fica fora da lista de tipos permitidos: o formato não tem assinatura no mapa de magic bytes.

4.2 ValidacaoMidiaService.validarTamanho() — pseudocódigo

Seção intitulada “4.2 ValidacaoMidiaService.validarTamanho() — pseudocódigo”
função validarTamanho(tamanhoBytes: number, tipoMime: string) -> { valido, limite }:
limitesPorTipo = {
'image': 10 * 1024 * 1024, // 10 MB
'audio': 5 * 1024 * 1024, // 5 MB
'application': 20 * 1024 * 1024, // 20 MB (PDF, documentos)
}
tipoGenerico = tipoMime.split('/')[0]
limite = limitesPorTipo[tipoGenerico] ?? 0
se limite == 0:
retornar { valido: false, limite: 0 }
se tamanhoBytes <= 0:
retornar { valido: false, limite }
se tamanhoBytes > limite:
retornar { valido: false, limite }
retornar { valido: true, limite }

4.3 DeteccaoSensivelService.removerMetadadosExif() — pseudocódigo

Seção intitulada “4.3 DeteccaoSensivelService.removerMetadadosExif() — pseudocódigo”
função removerMetadadosExif(buffer: Buffer, tipoMime: string) -> { bufferLimpo, metadadosExtraidos }:
se tipoMime não é 'image/jpeg' e não é 'image/tiff':
retornar { bufferLimpo: buffer, metadadosExtraidos: {} }
se buffer.length > LIMITE_EXIF_BUFFER (15 MB):
log.warn do limite e retornar { bufferLimpo: buffer, metadadosExtraidos: {} }
metadados = sharp(buffer).metadata()
se metadados.exif tem GPSLatitude ou GPSLongitude:
log.info do GPS removido da imagem (não persistido)
bufferLimpo = sharp(buffer).withMetadata({}).toBuffer()
retornar { bufferLimpo, metadadosExtraidos: {} }

Se sharp lançar erro (imagem corrompida), o serviço loga aviso e devolve o buffer original.

Decisão de implementação: sharp é a referência para processamento de imagem em Node.js. É nativa (libvips em C++), rápida e remove EXIF com .withMetadata({}). A operação ocorre sobre o buffer em memória e é limitada a LIMITE_EXIF_BUFFER (15 MB). Acima disso o EXIF não é removido, com log de aviso. O GPS é descartado e nunca persistido.

4.4 DeteccaoSensivelService.detectarConteudoSensivel() — pseudocódigo

Seção intitulada “4.4 DeteccaoSensivelService.detectarConteudoSensivel() — pseudocódigo”
função detectarConteudoSensivel(buffer: Buffer, tipoMime: string) -> ResultadoDeteccao:
heuristica = detectarHeuristica(buffer, tipoMime)
scoreNsfw = null
scorePessoa = null
se tipoMime começa com 'image/':
[nsfw, pessoa] = await Promise.all([
classificadorNsfw.classificar(buffer),
classificadorPessoa.detectar(buffer),
])
scoreNsfw = nsfw?.score ?? null
scorePessoa = pessoa?.score ?? null
nsfwBloqueio = scoreNsfw >= LIMIAR_NSFW_BLOQUEIO (0.85)
nsfwRevisao = scoreNsfw >= LIMIAR_NSFW_REVISAO (0.5) E scoreNsfw < LIMIAR_NSFW_BLOQUEIO
pessoaDetectada = scorePessoa >= LIMIAR_PESSOA (0.5)
se nsfwBloqueio OU nsfwRevisao:
motivo = nsfwBloqueio ? 'nsfw_alto' : 'nsfw_revisao'
retornar { possuiDadoSensivel: true, categoria: 'nsfw', motivo,
score: Math.max(heuristica.score, scoreNsfw), moderacaoStatus: 'pendente' }
se heuristica.possuiDadoSensivel:
retornar { possuiDadoSensivel: true, categoria: 'documento',
motivo: heuristica.detalhes[0] ?? 'documento_identificavel',
score: heuristica.score, moderacaoStatus: 'pendente' }
se pessoaDetectada:
retornar { possuiDadoSensivel: false, categoria: 'pessoa', motivo: 'pessoa_detectada',
score: 0, moderacaoStatus: 'pendente' }
retornar { possuiDadoSensivel: false, categoria: null, motivo: null, score: 0,
moderacaoStatus: 'nao_aplicavel' }
função detectarHeuristica(buffer: Buffer, tipoMime: string):
// 1. Imagem: regiões com alta densidade de bordas (possível documento ou texto)
se tipoMime começa com 'image/':
temTexto = detectarRegioesTexto(buffer) // sharp: resize 100, greyscale, bordas acima de 5% dos pixels
se temTexto:
detalhes.push('possivel_documento_ou_texto_identificavel')
scoreTotal += 0.6
fatorContagem++
// 2. Texto: padrões de CPF, CNPJ e telefone
se tipoMime == 'text/plain':
texto = buffer.toString('utf-8').substring(0, 5000)
se detectarPadroesDocumento(texto):
detalhes.push('padrao_documento_identificavel')
scoreTotal += 0.7
fatorContagem++
se fatorContagem == 0:
retornar { possuiDadoSensivel: false, score: 0, detalhes: [] }
score = scoreTotal / fatorContagem
retornar { possuiDadoSensivel: score >= 0.5, score, detalhes }

Decisão de implementação — classificadores locais. A detecção de pessoa usa skillsafe-ai/detr-resnet-50 com dtype q8, limiar 0.50 e sinalização sem bloqueio. A classificação NSFW usa AdamCodd/vit-base-nsfw-detector com dtype configurável (D1C_DTYPE_NSFW, padrão q8), limiar de bloqueio 0.85 e de revisão 0.50. Os dois rodam com comTempoLimite (TIMEOUT_CLASSIFICACAO_MS) e degradam para a heurística quando falham ou estão desligados pelas features D1C_NSFW_HABILITADO e D1C_PESSOA_HABILITADA. A heurística de regiões de texto usa sharp para análise de bordas, sem OCR completo.

Decisão de implementação — OCR na Fase 2: Quando ativado, o OCR usaria Tesseract.js com modelo em português para detectar texto em imagens. O texto extraído seria analisado para padrões de CPF, RG, telefone via regex. Se encontrados, o anexo seria marcado possui_dado_sensivel = true e, na Fase 2, blur automático seria aplicado nas regiões identificadas.

4.5 ProcessamentoAnexoService — fluxo de processamento

Seção intitulada “4.5 ProcessamentoAnexoService — fluxo de processamento”
função processarDemandaRecebida(evento):
se eventoProcessadoRepo.existeEvento(evento.event_id):
retornar
payload = evento.payload
demandaId = payload.demanda_id
se payload.midia_urls é undefined, null ou vazio:
eventoProcessadoRepo.registrar(evento.event_id, demandaId)
retornar
para cada midiaUrl em payload.midia_urls:
tentar:
resultado = processarUmaMidia({
demandaId,
objectKey: midiaUrl.object_key,
correlacaoId: evento.correlacao_id ?? demandaId,
via: 'captura',
})
se resultado == 'processado': processados++
senão se resultado == 'duplicata': duplicatas++
senão: falhas++
capturar erro:
log.error e falhas++
eventoProcessadoRepo.registrar(evento.event_id, demandaId) // falha é ignorada
log.info do resumo (total, processados, duplicatas, falhas, tempo)
função processarUmaMidia({ demandaId, objectKey, correlacaoId, via }):
// Idempotência por upload
se anexoRepo.buscarPorDemandaEObjectKey(demandaId, objectKey) não é null:
retornar 'duplicata'
se não armazenamentoService.verificarExistencia(objectKey):
log.error; retornar 'invalido'
contentTypeArmazenado = armazenamentoService.obterContentType(objectKey)
{ stream, tamanhoBytes } = armazenamentoService.downloadTemporario(objectKey)
hashStream = crypto.createHash('sha256'); chunks = []; cabecalho = null
para cada chunk do stream:
chunks.push(chunk); hashStream.update(chunk)
se cabecalho é null: cabecalho = primeiros 512 bytes
bufferOriginal = Buffer.concat(chunks)
hashSha256 = hashStream.digest('hex')
cabecalhoHex = cabecalho.toString('hex').toUpperCase()
{ valido, mimeDetectado } = validacaoMidiaService.validarMagicBytes(cabecalhoHex)
se não valido: log.warn; retornar 'invalido'
se não validacaoMidiaService.validarContentTypeDeclarado(contentTypeArmazenado, mimeDetectado):
log.warn; retornar 'invalido'
se não validacaoMidiaService.validarTamanho(tamanhoBytes, mimeDetectado).valido:
log.warn; retornar 'invalido'
bufferFinal = bufferOriginal
se mimeDetectado começa com 'image/':
formatoReal = deteccaoSensivelService.verificarFormatoImagem(bufferOriginal)
se formatoReal não é null E formatoReal != mimeDetectado:
log.warn; retornar 'invalido'
bufferFinal = deteccaoSensivelService.removerMetadadosExif(bufferOriginal, mimeDetectado).bufferLimpo
deteccao = deteccaoSensivelService.detectarConteudoSensivel(bufferFinal, mimeDetectado)
duplicataDe = null
registroExistente = anexoRepo.buscarPrimeiroPorHash(hashSha256)
se registroExistente não é null:
duplicataDe = registroExistente.id
registroDaDemanda = anexoRepo.buscarPorDemandaEHash(demandaId, hashSha256)
se registroDaDemanda não é null:
se registroDaDemanda.status == 'removido':
log.warn do tombstone; retornar 'invalido'
retornar 'duplicata'
objectKeyPermanente = `anexos/${hashSha256}`
tentar:
armazenamentoService.uploadPermanente(objectKeyPermanente, bufferFinal, mimeDetectado)
capturar:
log.error; retornar 'invalido' // o anexo não é persistido
anexo = anexoRepo.inserir({
demanda_id: demandaId,
hash_sha256: hashSha256,
tipo_mime: mimeDetectado,
tamanho_bytes: tamanhoBytes,
object_key: objectKeyPermanente,
object_key_temp: objectKey,
possui_dado_sensivel: deteccao.possuiDadoSensivel,
score_sensivel: deteccao.score,
categoria_sensivel: deteccao.categoriaSensivel,
motivo_sensivel: deteccao.motivoSensivel,
score_nsfw: deteccao.scoreNsfw,
score_pessoa: deteccao.scorePessoa,
moderacao_status: deteccao.moderacaoStatus,
metadados_exif: null,
duplicata_de: duplicataDe,
status: deteccao.possuiDadoSensivel ? 'bloqueado' : 'ativo',
})
tentar:
eventBus.publicar({
tipo: 'anexo.processado',
origem: 'D-1c',
versao_schema: '1.4.0',
event_id: uuidv4(),
correlacao_id: correlacaoId,
payload: {
demanda_id, anexo_id: anexo.id, tipo_mime, tamanho_bytes, hash_sha256,
url_canonica: `/api/anexos/${anexo.id}/download`,
object_key: objectKeyPermanente, object_key_temp: objectKey,
possui_dado_sensivel,
duplicata_de: duplicataDe, via, categoria_sensivel, motivo_sensivel,
moderacao_status,
},
})
capturar:
log.error // o registro persiste e a reconciliação de órfãos é Fase 2
retornar 'processado'
função gerarUrlDownload(anexoId: string, contexto: { roles }) -> string:
anexo = anexoRepo.buscarPorId(anexoId)
se anexo é null:
lançar NotFoundException("Anexo não encontrado")
se anexo.status == 'removido':
lançar GoneException("Anexo removido")
sensivel = anexo.possui_dado_sensivel == true OU anexo.status == 'bloqueado'
se sensivel E contexto.roles não inclui 'moderador' ou 'admin':
log.warn e lançar ForbiddenException("Anexo com conteúdo sensível exige papel de moderador")
se sensivel:
log.warn do acesso liberado para moderador
url = armazenamentoService.gerarPresignedGet(anexo.object_key, PRESIGNED_URL_DOWNLOAD_TTL)
retornar url
função obterInfo(anexoId: string, contexto: { roles }) -> AnexoInfoDto:
// cache de metadados de 5 minutos; a checagem de acesso sensível roda também no cache hit
anexo = cache válido OU anexoRepo.buscarPorId(anexoId)
se anexo é null:
lançar NotFoundException("Anexo não encontrado")
se sensivel E contexto.roles não inclui 'moderador' ou 'admin':
lançar ForbiddenException
retornar o AnexoInfoDto (anexo_id, demanda_id, tipo_mime, tamanho_bytes, hash_sha256,
possui_dado_sensivel, score_sensivel, status, duplicata_de, processado_em, criado_em)
@ApiTags('Anexos')
@Controller('anexos') // com o prefixo global: /api/anexos
@UseGuards(IdentidadeCidadaoGuard)
export class AnexoController {
constructor(private readonly acessoAnexoService: AcessoAnexoService) {}
@Get(':id/info')
@Throttle({ default: { limit: 120, ttl: 60000 } })
async getInfo(@Param('id') id: string, @Req() request: RequisicaoCidadao): Promise<AnexoInfoDto> {
return this.acessoAnexoService.obterInfo(id, { roles: request.cidadao_roles ?? [] });
}
@Get(':id/download')
@Header('Cache-Control', 'no-store')
@Throttle({ default: { limit: 60, ttl: 60000 } })
async download(
@Param('id') id: string,
@Req() request: RequisicaoCidadao,
@Res() res: Response,
): Promise<void> {
const url = await this.acessoAnexoService.gerarUrlDownload(id, {
roles: request.cidadao_roles ?? [],
});
res.redirect(HttpStatus.FOUND, url);
}
}
Caso Comportamento
demanda.recebida sem midia_urls (campo ausente ou []) Handler registra event_id em eventos_processados e retorna. Nenhum evento publicado.
midia_urls contém URL de objeto que já foi removido do storage verificarExistencia() retorna false. log.error. Anexo inválido. Processa os demais.
Dois handlers concorrentes processando o mesmo evento (race condition) O segundo registrar em eventos_processados falha e é ignorado. O primeiro handler conclui normalmente.
Arquivo com magic bytes de JPEG mas extensão .png Magic bytes vencem. tipo_mime registrado como image/jpeg. Extensão ignorada.
Arquivo com 0 bytes validarTamanho() rejeita (tamanhoBytes <= 0). log.warn. Anexo inválido.
Arquivo exatamente no limite de tamanho Aceito. O limite é validado em aplicação por tipo (imagem 10 MB, áudio 5 MB, documento 20 MB), com igualdade permitida.
Arquivo text/plain Fora da lista de tipos permitidos: o formato não tem assinatura no mapa de magic bytes. A validação rejeita.
Download do storage interrompido (timeout de rede) Exceção capturada no handler do anexo. log.error. Continua para o próximo.
Upload permanente falha log.error e o anexo é descartado. A linha não é gravada e nenhum evento é publicado; a reconciliação de órfãos é Fase 2.
eventBus.publicar() falha após o INSERT O registro em d1c.anexos existe, a idempotência de consumo é registrada e o anexo.processado não é publicado. A reconciliação de órfãos é Fase 2.
Remoção de EXIF falha (imagem corrompida) sharp lança exceção. O serviço loga aviso e devolve o buffer original, sem metadados. O processamento continua.
Acesso a anexo sensível Anexo com possui_dado_sensivel=true ou status='bloqueado' sai da projeção pública da D-7. O endpoint de download exige papel de moderador ou admin (403 para os demais).
Reenvio do mesmo arquivo para a mesma demanda depois da remoção A linha removida com o mesmo hash_sha256 + demanda_id é um tombstone. processarUmaMidia devolve invalido e não recria o objeto no bucket.
Mesmo arquivo em duas demandas Duas linhas em d1c.anexos, um único objeto permanente e duplicata_de na segunda linha.
Decisão removido para anexo já removido A remoção é ignorada, sem republicar anexo.removido, e o cursor avança (idempotência).
Falha ao remover o objeto do bucket A D-1c não marca as linhas nem publica anexo.removido. O cursor não avança, o evento vai para a DLQ e o replay do boot retenta.
Acesso a anexo removido O download responde 410 Gone. A mídia não existe mais no bucket e a evidência sai da projeção da D-7.
Demanda duplicada (idempotência da D-1a) gera mesmo demanda_id A D-1c processa o evento apenas uma vez (idempotência por event_id). Se o mesmo demanda_id chegar em dois eventos distintos, o segundo terá midia_urls possivelmente idêntico. A idempotência por object_key_temp + demanda_id detecta e pula o reprocessamento.

Streaming com reassembly para EXIF e detecção sensível. O hash SHA-256 é calculado durante a leitura do stream, mas a remoção de EXIF e a detecção de conteúdo sensível exigem o buffer completo. O pseudocódigo remonta o buffer após o hash. Alternativa considerada: baixar o arquivo duas vezes (uma para hash, outra para processamento). Rejeitada por duplicar tráfego de rede. O reassembly em memória para arquivos de até 20 MB é aceitável no MVP.

Temporários ficam com a N-0d. A D-1c não remove o objeto temporário do upload: a D-1b ainda precisa da mídia para transcrever ou descrever. A limpeza dos temporários fica com a retenção da N-0d.

Assinatura da URL de mídia na leitura. O evento anexo.processado carrega a object_key, não a URL assinada. O consumidor assina a URL de GET no momento da leitura, com validade de 5 minutos (TTL_URL_EVIDENCIA_SEGUNDOS em src/shared/midia/assinatura-midia.ts). A assinatura de longa duração sai do event_log e das projeções, e a evidência do relatório da demanda público deixa de ser um link portador de longa duração. O endpoint de download do BFF continua gerando URL de 5 minutos sob demanda. O relatório público da demanda usa esse endpoint para carregar a miniatura e o player de áudio, porque a URL assinada do DTO expira em 5 minutos; a URL assinada permanece como fallback.

Deduplicação por hash no MinIO, não no PostgreSQL. O objeto MinIO é único por hash_sha256. O PostgreSQL armazena múltiplas referências (uma por demanda). A remoção do objeto atinge todas as referências. Na decisão removido da moderação, a D-1c remove o objeto permanente e os temporários do hash, publica anexo.removido por cópia e marca todas as linhas. O par hash_sha256 + demanda_id removido funciona como tombstone: o re-upload do mesmo arquivo para a mesma demanda é recusado.

Remoção física na moderação, não no bloqueio. A decisão bloqueado mantém a mídia no bucket e apenas a oculta da vitrine. A remoção física é reservada à decisão removido, com irreversibilidade e auditoria. O download de anexo removido responde 410 Gone.


5. Integração com o Barramento e Outras Colônias

Seção intitulada “5. Integração com o Barramento e Outras Colônias”

A D-1c injeta EventBusService (do módulo @Global() N-0a) tanto para consumir quanto para publicar eventos:

@Injectable()
export class ProcessamentoAnexoService {
private iniciado = false;
constructor(
private readonly eventBus: EventBusService,
private readonly validacaoMidiaService: ValidacaoMidiaService,
private readonly armazenamentoService: ArmazenamentoService,
private readonly deteccaoSensivelService: DeteccaoSensivelService,
private readonly anexoRepo: AnexoRepository,
private readonly eventoProcessadoRepo: EventoProcessadoRepository,
) {}
async iniciar(): Promise<void> {
if (this.iniciado) return;
this.iniciado = true;
// 1. Seed dos cursores, sem sobrescrever
const maiorSequence = await this.eventBus.obterMaiorSequence();
for (const tipo of ['demanda.evidencia_adicionada', 'moderacao.decidida']) {
if ((await this.eventoProcessadoRepo.obterOffset(tipo)) === null) {
await this.eventoProcessadoRepo.semearOffset(tipo, maiorSequence);
}
}
// 2. Replay dos eventos perdidos
await this.reprocessarEventosPerdidos();
// 3. Registro dos consumidores ao vivo
this.registrarConsumidores();
}
registrarConsumidores(): void {
this.eventBus.inscrever('demanda.recebida', 'D-1c', this.processarDemandaRecebida.bind(this));
this.eventBus.inscrever(
'demanda.evidencia_adicionada',
'D-1c',
this.processarEvidenciaAdicionada.bind(this),
);
this.eventBus.inscrever(
'moderacao.decidida',
'D-1c',
this.processarModeracaoDecidida.bind(this),
);
}
}

A D-1c mantém a idempotência de demanda.recebida em d1c.eventos_processados e cursor próprio (d1c.consumer_offset) com replay no boot para demanda.evidencia_adicionada e moderacao.decidida. O EventBusService entrega eventos com at-least-once. A idempotência está em eventos_processados (PK por event_id), na unique de d1c.anexos e na checagem por object_key_temp.

5.2 Relação com outras colônias — fluxo de eventos

Seção intitulada “5.2 Relação com outras colônias — fluxo de eventos”
D-1a → demanda.recebida → D-1c (processa anexos)
D-12 → demanda.evidencia_adicionada → D-1c (processa evidência de confirmação/conclusão)
D-1d → moderacao.decidida → D-1c (aplica aprovar, bloquear ou remover nos anexos)
D-1c → anexo.processado → D-7 (timeline pública + dashboard)
D-1c → anexo.processado → D-6b (referência em atualizações do conselheiro, tipo documento_anexado)
D-1c → anexo.processado → D-15 (Integridade — Fase 2, para hashing checkpoints)
D-1c → anexo.moderado → D-7 (exposição da evidência após a decisão)
D-1c → anexo.removido → D-7 (remove a evidência da projeção e grava a trilha pública)

A D-1c não invoca outras colônias diretamente. Apenas publica no barramento. As colônias consumidoras recebem os metadados e decidem o que fazer com eles. O anexo.removido carrega apenas anexo_id, demanda_id e hash_sha256.

No MVP, o BFF da D-1c não faz chamadas HTTP para outras colônias. Os endpoints são autossuficientes: consultam apenas o estado próprio (d1c.anexos) e o MinIO. Na Fase 2, se a visibilidade de anexos depender de status de demanda (público/privado), o BFF pode consultar D-7 ou consumir projeção local.

A D-1c não consome projeções de leitura de outras colônias. O estado necessário (existência da demanda, visibilidade) é obtido do payload do evento demanda.recebida ou mantido em estado próprio. Na Fase 2, se a D-1c precisar diferenciar acesso público/privado, pode consumir o evento demanda.visibilidade_alterada e manter projeção local em d1c.visibilidade_demandas.

A D-1c e a D-1a compartilham o mesmo bucket MinIO. Isso não viola as regras de isolamento do Formigueiro: MinIO é infraestrutura (como o PostgreSQL), não estado próprio de colônia. Cada colônia acessa o bucket com seu próprio client e credenciais. As operações são isoladas por prefixo de chave:

Colônia Operação Prefixo
D-1a Gera presigned PUT URL {uuid4}-{filename sanitizado} (temporário, sem prefixo)
Front-end Upload direto {uuid4}-{filename sanitizado}
D-1c putObject do buffer limpo, object get, remoção e presigned GET anexos/{hash_sha256} (permanente)

A chave permanente (anexos/{hash_sha256}) nunca colide com a chave temporária porque o hash SHA-256 tem 64 caracteres hexadecimais, enquanto UUIDs têm 36. Não há risco de sobrescrita acidental.

Portabilidade entre provedores: MinIO e AWS S3 (bem como DigitalOcean Spaces, Cloudflare R2 e outros S3-compatible) usam a mesma API. O ArmazenamentoService é a única camada que conhece o provedor. Para trocar de MinIO para S3, mudam-se as variáveis de ambiente do storage. O resto do código (presigned URLs, remoção, object keys baseadas em hash) funciona identicamente. No desenvolvimento local, sobe-se MinIO via Docker Compose. Em produção na AWS, aponta-se para S3.

O contrato entre as colônias é estabelecido exclusivamente via payload do evento:

  1. D-1a gera presigned URL e retorna { url, expires_in, object_key } ao front-end
  2. Front-end faz upload e envia { tipo, url, object_key } no POST /demandas
  3. D-1a inclui midia_urls no payload de demanda.recebida como campo adicional
  4. D-1c extrai midia_urls do evento, usa object_key para acessar o arquivo no MinIO

O object_key é o elo crítico do contrato. Sem ele, a D-1c precisaria parsear a URL para extrair a chave do objeto, o que seria frágil e dependente do formato de URL do MinIO. O campo existe justamente para desacoplar a D-1c do formato de URL.


Rota Limite Janela Chave Biblioteca
GET /api/anexos/:id/info 120 1 minuto IP @nestjs/throttler in-memory
GET /api/anexos/:id/download 60 1 minuto IP @nestjs/throttler in-memory

Os limites de download são mais restritivos que info porque cada download gera chamada ao MinIO para presigned URL. O info é apenas consulta ao banco, mais leve.

Limite Valor Justificativa
Arquivo — imagem 10 MB Fotos de celular moderno raramente excedem 10 MB.
Arquivo — áudio 5 MB Áudio de até ~3 minutos em compressão padrão.
Arquivo — documento (PDF) 20 MB Documentos técnicos, laudos, relatórios.
midia_urls por demanda 10 Alinhado com o limite da D-1a. Suficiente para múltiplas fotos de um problema.
Assinatura da URL de evidência (leitura) 300s (5 min) TTL_URL_EVIDENCIA_SEGUNDOS em src/shared/midia/assinatura-midia.ts.
Presigned GET URL TTL (download endpoint) 300s (5 min) Curto porque o endpoint gera URL nova a cada request.
Tamanho máximo de buffer para EXIF 15 MB Arquivos acima de 15 MB não têm EXIF removido (apenas logado). Sharp com imagens muito grandes pode exceder memória. Limite de segurança.
Índice Query atendida
anexos_pkey (id) buscarPorId() — endpoints GET info/download, acesso mais frequente
anexos_hash_sha256_demanda_id_key (unique) Idempotência: WHERE hash_sha256 = ? AND demanda_id = ? — tombstone e checagem por hash da demanda
anexos_demanda_id_idx “Anexos desta demanda”: WHERE demanda_id = ? — usado por D-7 e D-6b
anexos_hash_sha256_idx Deduplicação: WHERE hash_sha256 = ? — busca do primeiro registro para duplicata_de
anexos_status_idx Filtro por status: WHERE status = ?
anexos_possui_dado_sensivel_idx Revisão humana: WHERE possui_dado_sensivel = true
anexos_moderacao_status_idx Consumo da fila da D-1d: WHERE moderacao_status = 'pendente'
eventos_processados_pkey (event_id) Idempotência de consumo: WHERE event_id = ? — todo evento recebido
eventos_processados_demanda_id_idx Reconciliação: WHERE demanda_id = ?
consumer_offset_pkey (tipo_evento) Cursor de replay de demanda.evidencia_adicionada e moderacao.decidida

O padrão de acesso dominante é INSERT durante processamento de eventos e SELECT por id nos endpoints de acesso.

Processamento de evento (write path — 1 vez por demanda com anexos):

  • SELECT FROM eventos_processados WHERE event_id = ? — 1 query
  • Para cada anexo:
    • SELECT FROM anexos WHERE demanda_id = ? AND object_key_temp = ? — verificação de idempotência
    • SELECT FROM anexos WHERE hash_sha256 = ? LIMIT 1 — verificação de duplicidade
    • INSERT INTO anexos — 1 query
    • INSERT INTO eventos_processados — 1 query ao final

Acesso a anexo (read path — 1 vez por visualização):

  • GET /api/anexos/:id/info: SELECT FROM anexos WHERE id = ? — 1 query
  • GET /api/anexos/:id/download: SELECT FROM anexos WHERE id = ? + chamada MinIO para presigned URL

Projeção de volume — acesso a anexos: No MVP de bairro, estima-se que cada demanda com anexos gere em média 2-3 downloads (conselheiro visualizando evidências, cidadão acompanhando timeline, dashboard público). Para 500 demandas/dia com 30% contendo anexos, são ~450 downloads/dia (< 1 por minuto). O gargalo não é o banco. É o MinIO para presigned URLs e o tráfego de saída.

Cache de metadados no AcessoAnexoService:

// Map<string, { data: AnexoInfoDto, cachedAt: number }>
// TTL: 300 segundos (5 minutos)
// Invalidado em: nunca (anexos são imutáveis após processamento)

Metadados de anexo (tipo_mime, tamanho_bytes, hash_sha256) nunca mudam após o processamento. Apenas status pode mudar (ativobloqueadoremovido). O cache em memória com TTL de 5 minutos reduz queries ao banco para acessos repetidos ao mesmo anexo (ex: múltiplos usuários visualizando a mesma foto na timeline). A checagem de acesso sensível roda também no cache hit; o status em cache pode ficar até 5 minutos defasado para exibição.

Sem cache para presigned URLs: Cada chamada a GET /api/anexos/:id/download gera uma nova presigned URL. Não há cache porque: (a) cada URL é única (assinatura criptográfica com timestamp), (b) o TTL é curto (5 minutos), e (c) a geração de presigned URL pelo MinIO é operação local (sem chamada de rede) quando o client está na mesma rede.

Redis não se justifica no MVP para a D-1c: O volume de leitura é baixo (dezenas de downloads/dia no MVP de bairro). Cache em memória com TTL curto é suficiente. Redis seria overhead operacional desproporcional.

Cenário Anexos/dia Tamanho médio Storage (ano) Objetos MinIO (ano)
PoC (1 bairro) ~15 3 MB ~16 GB ~5.500
MVP (1 município) ~150 3 MB ~164 GB ~55.000
Fase 2 (regional) ~15.000 3 MB ~16 TB ~5.5M

A deduplicação reduz o número de objetos (hash-based), mas o fator de redução depende da taxa de repetição de arquivos. Em cenário de bairro com fotos de problemas urbanos, a taxa de duplicação é baixa (fotos diferentes do mesmo buraco têm hashes diferentes). A deduplicação é mais relevante para documentos (PDFs de legislação, formulários) que podem ser anexados a múltiplas demandas.


Teste unitário do ProcessamentoAnexoService:

// processamento-anexo.service.spec.ts — estrutura de teste
describe('ProcessamentoAnexoService', () => {
let service: ProcessamentoAnexoService;
const eventBusMock = {
inscrever: jest.fn(),
publicar: jest.fn().mockResolvedValue(undefined),
obterMaiorSequence: jest.fn().mockResolvedValue(10n),
replayDeSequence: jest.fn().mockResolvedValue([]),
};
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
ProcessamentoAnexoService,
{ provide: EventBusService, useValue: eventBusMock },
{ provide: ValidacaoMidiaService, useValue: mockValidacaoMidia },
{ provide: ArmazenamentoService, useValue: mockArmazenamento },
{ provide: DeteccaoSensivelService, useValue: mockDeteccaoSensivel },
{ provide: AnexoRepository, useValue: mockAnexoRepo },
{ provide: EventoProcessadoRepository, useValue: mockEventoProcRepo },
],
}).compile();
service = module.get(ProcessamentoAnexoService);
});
});

O caminho feliz é configurado com existeEvento falso, buscarPorDemandaEObjectKey nulo, verificarExistencia verdadeiro, downloadTemporario devolvendo o stream, validarMagicBytes e validarTamanho válidos, validarContentTypeDeclarado verdadeiro, removerMetadadosExif devolvendo o buffer limpo, detectarConteudoSensivel sem sinalização, buscarPrimeiroPorHash e buscarPorDemandaEHash nulos e inserir devolvendo o registro. Os testes de moderação cobrem aprovar, bloquear, remover todas as cópias do hash e a falha de remoção no bucket.

Teste unitário do AcessoAnexoService:

Mock de AnexoRepository + ArmazenamentoService. O AnexoRepository é isolado por interface. Sem dependência do barramento (serviço de leitura pura).

Teste unitário do ValidacaoMidiaService:

Sem dependências externas. Testes parametrizados com arquivos reais de cada tipo MIME e arquivos maliciosos (ex: script PHP renomeado para .jpg).

Teste e2e do controller (supertest):

const app = await Test.createTestingModule({
imports: [D1cModule],
}).compile();
const httpServer = app.createNestApplication();
await httpServer.init();

Happy path:

# Cenário Verificação
T1 demanda.recebida com 1 midia_url de imagem JPEG válida 1 linha em d1c.anexos. 1 evento anexo.processado publicado. 1 linha em eventos_processados. Objeto permanente em anexos/{hash}. Objeto temporário mantido para a D-1b.
T2 demanda.recebida com 3 midia_urls (JPEG, PNG, MP3) 3 linhas em d1c.anexos. 3 eventos publicados. Storage: 3 objetos permanentes e os temporários preservados.
T3 demanda.recebida sem midia_urls 0 linhas em d1c.anexos. 0 eventos publicados. 1 linha em eventos_processados.
T4 demanda.recebida com midia_urls: [] Mesmo comportamento de T3.
T5 Arquivo duplicata (mesmo hash, demanda diferente) Segunda linha em d1c.anexos; objeto reenviado por uploadPermanente (idempotente pelo hash). Evento publicado com duplicata_de.
T6 GET /api/anexos/:id/info com anexo existente e identificação HTTP 200. Body: { anexo_id, demanda_id, tipo_mime, tamanho_bytes, hash_sha256, possui_dado_sensivel, score_sensivel, status, duplicata_de, processado_em, criado_em }.
T7 GET /api/anexos/:id/download com anexo ativo HTTP 302. Header Location contém URL assinada do storage.

Falhas e bordas:

# Cenário Verificação
T8 Evento demanda.recebida reentregue (mesmo event_id) eventos_processados impede reprocessamento. Nenhum efeito colateral.
T9 midia_url com objeto não encontrado no storage log.error. Anotado como falha. Próximo anexo processado normalmente.
T10 Arquivo com magic bytes de text/x-python renomeado para .jpg validarMagicBytes() retorna { valido: false }. log.warn. Anexo inválido.
T11 Arquivo de 25 MB (excede o limite do tipo) validarTamanho() retorna { valido: false }. log.warn. Anexo inválido.
T12 Falha de rede no download do storage Exceção capturada. log.error. Anotado como falha. Próximo anexo processado.
T13 Falha no upload permanente log.error e o anexo é descartado. Nenhuma linha em d1c.anexos e nenhum anexo.processado; a reconciliação de órfãos é Fase 2.
T14 eventBus.publicar() falha para um dos anexos log.error. O registro no banco existe e a idempotência de consumo é registrada. O evento não é publicado.
T15 Dois handlers concorrentes no mesmo evento A segunda gravação em eventos_processados falha e é ignorada. O primeiro handler conclui.
T16 GET /api/anexos/:id/info com anexo inexistente HTTP 404.
T17 GET /api/anexos/:id/download com anexo removido HTTP 410 Gone.
T18 GET /api/anexos/:id/download com anexo sensível HTTP 403 para quem não tem papel de moderação ou admin; 302 para moderador, com log de auditoria.
T19 Arquivo com EXIF contendo GPS GPS descartado e não persistido. Buffer limpo sem EXIF armazenado no storage.
T20 Arquivo > 15 MB com EXIF Remoção de EXIF pulada pelo LIMITE_EXIF_BUFFER. log.warn. Buffer original armazenado.
T21 demanda.recebida com midia_urls sem object_key A checagem de existência falha e o anexo é anotado como inválido, com log sugerindo a atualização da D-1a.

Teste de integração (com PostgreSQL + MinIO de teste):

# Cenário Verificação
T22 Ciclo completo: upload via D-1a → processamento D-1c → download Arquivo no MinIO com chave anexos/{hash}. d1c.anexos com 1 linha. event_log com 1 anexo.processado. Download via BFF retorna arquivo idêntico.
T23 Deduplicação real: mesmo arquivo em duas demandas 2 linhas em d1c.anexos. 1 objeto no MinIO. duplicata_de na segunda linha.
T24 Reprocessamento após crash: handler cai após processar 2 de 5 anexos Reentrega do evento: 2 anexos pulados (idempotência), 3 processados. Total: 5 linhas em d1c.anexos, 5 eventos.
T25 Race condition: dois handlers concorrentes Apenas 1 handler completa o processamento. Total de anexos correto. Sem objetos órfãos no MinIO.
T26 EXIF stripping: foto com GPS O GPS é descartado e não persistido. O arquivo no storage fica sem metadados EXIF (verificável via exiftool).
-- Schema
CREATE SCHEMA IF NOT EXISTS d1c;
-- Anexos de exemplo (demandas simuladas)
INSERT INTO d1c.anexos (id, demanda_id, hash_sha256, tipo_mime, tamanho_bytes, object_key, object_key_temp, possui_dado_sensivel, score_sensivel, metadados_exif, duplicata_de, status, processado_em)
VALUES
-- Foto de buraco na rua
(
'f1a2b3c4-d5e6-7890-abcd-ef1234567890',
'c3d4e5f6-a7b8-9012-cdef-123456789012', -- mesma demanda do seed da D-1a
'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
'image/jpeg',
2048000,
'anexos/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
'550e8400-e29b-41d4-a716-446655440000-buraco.jpg',
false,
0.0,
NULL,
NULL,
'ativo',
'2026-06-15T10:30:05Z'
),
-- Áudio de denúncia (duplicata da foto acima para teste de deduplicação)
(
'f2b3c4d5-e6f7-8901-bcde-f12345678901',
'd4e5f6a7-b8c9-0123-defa-123456789abc', -- outra demanda
'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', -- mesmo hash
'image/jpeg',
2048000,
'anexos/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', -- mesmo objeto
'660e8400-e29b-41d4-a716-446655440001-buraco2.jpg',
false,
0.0,
NULL,
'f1a2b3c4-d5e6-7890-abcd-ef1234567890', -- duplicata_de
'ativo',
'2026-06-15T11:00:00Z'
);
-- Eventos já processados
INSERT INTO d1c.eventos_processados (event_id, demanda_id, processado_em)
VALUES
(
'c3d4e5f6-a7b8-9012-cdef-123456789012', -- event_id = demanda_id (convenção D-1a)
'c3d4e5f6-a7b8-9012-cdef-123456789012',
'2026-06-15T10:30:05Z'
),
(
'd4e5f6a7-b8c9-0123-defa-123456789abc',
'd4e5f6a7-b8c9-0123-defa-123456789abc',
'2026-06-15T11:00:00Z'
);

Para testes de MinIO local, subir container:

Janela do terminal
docker run -d --name minio-dev \
-p 9000:9000 -p 9001:9001 \
-e MINIO_ROOT_USER=minioadmin \
-e MINIO_ROOT_PASSWORD=minioadmin \
minio/minio server /data --console-address ":9001"

E criar o bucket e um objeto de teste:

Janela do terminal
mc alias set local http://localhost:9000 minioadmin minioadmin
mc mb local/anexos-dev
echo "teste" | mc pipe local/anexos-dev/anexos/550e8400-e29b-41d4-a716-446655440000-buraco.jpg

Funcionalidade Status
Consumo de demanda.recebida (versão 1.1.0 com midia_urls) MVP obrigatório
Consumo de demanda.evidencia_adicionada com cursor e replay no boot MVP obrigatório
Validação de tipo MIME por magic bytes MVP obrigatório
Validação de tamanho por tipo MVP obrigatório
Cálculo de hash SHA-256 durante a leitura do stream MVP obrigatório
Deduplicação por hash (registro duplicado, objeto único no storage) MVP obrigatório
Armazenamento permanente com chave baseada em hash (anexos/{sha256}) MVP obrigatório
Remoção de metadados EXIF (GPS e demais) para imagens, sem persistência MVP obrigatório
Classificação local de imagem (NSFW e pessoa) e heurística de documento MVP obrigatório
Publicação de anexo.processado com payload completo MVP obrigatório
Consumo de moderacao.decidida com aprovar, bloquear e remover MVP obrigatório
Remoção física do objeto e de todas as cópias do hash na decisão removido MVP obrigatório
Publicação de anexo.moderado e anexo.removido MVP obrigatório
Tombstone de re-upload do mesmo hash após a remoção MVP obrigatório
GET /api/anexos/:id/info (metadados do anexo, com identificação) MVP obrigatório
GET /api/anexos/:id/download (redirect para a URL assinada) MVP obrigatório
Idempotência de consumo via d1c.eventos_processados e cursor em d1c.consumer_offset MVP obrigatório
Logs estruturados com demanda_id, anexo_id, hash_sha256 e event_id MVP obrigatório
MinIO client para upload do buffer limpo, download e URL assinada MVP obrigatório
Rate limiting nos endpoints de acesso MVP obrigatório
Simplificação Justificativa Quando remover
Detecção de pessoa com modelo leve (skillsafe-ai/detr-resnet-50, q8) O modelo local cobre o MVP sem serviço externo. Fase 2: modelos maiores se houver GPU ou serviço de visão.
OCR: heurística de regiões de texto com sharp (sem Tesseract) Tesseract.js adiciona ~20 MB de modelos e latência de OCR. A heurística de detecção de regiões de texto é suficiente para sinalizar. Fase 2: integrar Tesseract.js com modelo pt-BR para OCR completo + regex de documentos.
Sem blur automático de conteúdo sensível Blur requer processamento de imagem com bounding boxes das regiões detectadas. Complexo para MVP. A flag possui_dado_sensivel permite revisão humana. Fase 2: implementar blur via sharp com máscara nas coordenadas detectadas.
Cache de metadados em memória (sem Redis) Volume de leitura baixo no MVP. TTL de 5 minutos cobre reacessos. Migrar para Redis na Fase 2 se houver múltiplas instâncias do monolito.
@nestjs/throttler in-memory Monolito de processo único. Volume baixo de downloads. Migrar para Redis store na Fase 2.
Sem endpoint de reconciliação (republicação de eventos) Janela de falha publish-após-INSERT é mínima no monolito. Scheduled job na Fase 2.
Sem visibilidade por demanda: anexo ativo não sensível é acessível com identificação Transparência radical como padrão. O acesso sensível já exige papel de moderação. Fase 2: consumir evento de visibilidade da demanda e restringir acesso.
Streaming com reassembly em memória (sem disco) Arquivos de até 20 MB cabem em memória. Para MVP com volume baixo, o reassembly não é gargalo. Fase 2: streaming para disco temporário (/tmp) com cleanup.
Temporários limpos pela N-0d A D-1b precisa da mídia para transcrever ou descrever depois do processamento da D-1c. Fase 2: política de limpeza dedicada.
Sem remoção de EXIF para arquivos > 15 MB Sharp com imagens grandes pode consumir memória excessiva. Limite de segurança. Fase 2: processamento em disco com sharp streaming.
Exceção de demanda.recebida sem midia_urls tratada como “sem anexos” Backward-compatible com schema 1.0.0. Quando todas as colônias migrarem para 1.1.0, remover o fallback.
MinIO com credenciais estáticas (não IAM/STS) Suficiente para MVP self-hosted com MinIO em container. Fase 2: IAM roles ou STS tokens temporários para ambiente cloud.
  • Detecção de pessoa com modelos maiores e GPU
  • OCR completo com Tesseract.js e modelo pt-BR para detecção de texto em imagens
  • Blur automático de regiões com conteúdo sensível (faces, documentos)
  • Diferenciação público/privado: consumir evento de visibilidade da demanda e manter projeção local em d1c.visibilidade_demandas
  • Restrição de acesso a anexos de demandas privadas
  • Scheduled job de reconciliação: varre d1c.anexos sem evento correspondente em core.event_log e republica anexo.processado
  • Scheduled job de limpeza: remove objetos MinIO órfãos (sem referência em d1c.anexos com status ativo)
  • Rate limiting com Redis store (suporte a múltiplas instâncias)
  • Processamento de vídeo: extração de thumbnail, validação de codec, limite de duração
  • Anti-malware: scan de arquivos com ClamAV antes do armazenamento permanente
  • Métricas Prometheus: anexos_processados_total, anexos_duplicatas_total, anexos_falhas_total, anexos_downloads_total, storage_bytes_total
  • Blur/anonymização seletiva: conselheiro pode solicitar blur de regiões específicas (via D-6b)

8.4 Verificação de conflitos com outras colônias

Seção intitulada “8.4 Verificação de conflitos com outras colônias”

Dependência do campo midia_urls publicado pela D-1a. A D-1c depende do object_key que a D-1a publica em midia_urls. Sem o campo, o evento é tratado como demanda sem anexos. A D-1b ignora o campo e não é afetada. Sem conflito.

Conflito potencial: presigned URL de upload na D-1a vs. processamento na D-1c. A D-1a gera presigned PUT URL com TTL curto. Se o processamento da D-1c demorar mais que o TTL para iniciar (fila de eventos, retry), o objeto temporário no storage ainda existe e é acessível pelo client com credenciais. O ArmazenamentoService da D-1c usa credenciais do storage, não a presigned URL. A presigned URL é apenas para o upload do front-end. Sem conflito.

Conflito potencial: bucket compartilhado. D-1a e D-1c acessam o mesmo bucket. As operações são isoladas por chave: D-1a gera chaves temporárias com UUID, D-1c grava o buffer limpo em anexos/{hash_sha256}. Não há risco de colisão (UUID 36 chars vs SHA-256 64 chars). Os temporários permanecem no bucket até a retenção da N-0d. Sem conflito.

Conflito potencial: anexo.processado publicado antes de demanda.normalizada. D-1c publica anexo.processado logo após processar o binário. D-1b publica demanda.normalizada após processar o texto/áudio. A ordem relativa entre esses eventos não é garantida (ambos consomem demanda.recebida em paralelo). A D-7 (Transparência) consome ambos e monta a timeline. Se anexo.processado chegar antes de demanda.normalizada, a D-7 pode exibir o anexo na timeline antes do texto normalizado. Isso é aceitável: a timeline é eventualmente consistente e a ordem de publicação não afeta a correção dos dados. Sem conflito.

Conflito potencial: D-6b referenciando anexo por anexo_id antes do processamento concluir. A D-6b (Relatoria) publica conselheiro.atualização_publicada com tipo documento_anexado e referência ao anexo_id. Mas a D-6b só pode referenciar um anexo depois que ele foi processado (o conselheiro seleciona anexos existentes na interface). O fluxo natural é: D-1c processa e publica anexo.processado → D-7 projeta na timeline → conselheiro vê o anexo e decide anexá-lo a uma atualização → D-6b publica referenciando anexo_id. A ordem causal é garantida pelo fluxo de uso, não por constraints técnicas. Sem conflito.

Conflito potencial: endpoint GET /api/anexos/:id/download vs. regra de que colônias não se comunicam diretamente. O front-end chama o endpoint da D-1c diretamente. Isso é uma chamada HTTP de cliente para servidor, não de colônia para colônia. O BFF da D-1c é a interface pública do módulo. A regra de isolamento proíbe comunicação entre colônias, não entre front-end e colônia. Sem conflito.