perf(inbox): cache de avatares no Wasabi e IndexedDB

Solução 3 — Wasabi:
- Após download do CDN do WhatsApp, faz upload fire-and-forget para
  Wasabi com path determinístico (avatars/{tenant}/{instance}/{jid}.jpg)
- Armazena o path relativo em contact.avatarUrl no banco
- Na próxima requisição, serve bytes direto do Wasabi sem tocar no CDN do WA
- Adiciona customPath em StorageUploadPayload e getBuffer() na interface
  StorageProviderInterface/StorageProviderRegistry e plugin

Solução 4 — IndexedDB:
- Backend expõe avatarVersion (MD5 curto do avatarUrl) no response /api/chats
- Campo propagado por chatStore → chatToLegacy → NewWhatsChat
- Hook useAvatarCache (IndexedDB nativo, sem deps) armazena dataUrl do avatar
  com chave jid:version — invalida automaticamente quando o avatar muda no banco
- Avatar.tsx serve do IndexedDB antes de disparar qualquer request HTTP;
  converte via canvas e persiste no IndexedDB na primeira carga bem-sucedida

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Deploy Agent
2026-05-11 16:02:08 +02:00
parent cdc32aa9b3
commit 2b9b855fc9
10 changed files with 268 additions and 67 deletions
@@ -90,6 +90,11 @@ class StorageProviderRegistry {
}
}
async getBuffer(path: string): Promise<Buffer | null> {
if (!this.provider) return null
return this.provider.getBuffer(path)
}
async deletePrefix(prefix: string): Promise<{ deleted: number }> {
if (!this.provider) {
rootLogger.warn('[StorageProvider] deletePrefix chamado mas nenhum provider está registrado')
@@ -119,6 +119,8 @@ export interface StorageUploadPayload {
usuarioSis?: string
/** JID da sessão WhatsApp — usado para compor o path de mídia WhatsApp */
sessionJID?: string
/** Path completo no bucket — quando fornecido, substitui o path gerado automaticamente */
customPath?: string
file: {
originalname: string
buffer: Buffer
@@ -131,4 +133,6 @@ export interface StorageProviderInterface {
checkDetailedHealth(): Promise<import('./StorageProvider').StorageHealthStatus>
upload(payload: StorageUploadPayload): Promise<{ provider: string; path: string }>
deletePrefix(prefix: string): Promise<{ deleted: number }>
/** Recupera os bytes de um arquivo pelo seu path relativo no bucket */
getBuffer(path: string): Promise<Buffer | null>
}
@@ -19,6 +19,7 @@
* @see MessageRepository — busca e paginação de mensagens
*/
import { Router, type Request, type Response } from 'express'
import { createHash } from 'crypto'
import { z } from 'zod'
import { ChatRepository } from './chat.repository'
import { MessageRepository } from './message.repository'
@@ -114,11 +115,16 @@ export function buildChatRoutes(manager: WhatsAppConnectionManager): Router {
name: resolvedName,
phone: contact.phone,
avatarUrl: contact.avatarUrl,
// Hash curto da URL armazenada — muda quando o avatar é atualizado no banco.
// Usado pelo frontend como chave de cache IndexedDB (invalida ao trocar foto).
avatarVersion: contact.avatarUrl
? createHash('md5').update(contact.avatarUrl).digest('hex').slice(0, 8)
: null,
scoreReputacao: contact.scoreReputacao,
flagRestricao: contact.flagRestricao,
}
: isGroup
? { name: resolvedName, phone: null, avatarUrl: null, scoreReputacao: 0, flagRestricao: false }
? { name: resolvedName, phone: null, avatarUrl: null, avatarVersion: null, scoreReputacao: 0, flagRestricao: false }
: null,
lastMessage: lastMsg
? {
@@ -23,6 +23,7 @@ import { z } from 'zod'
import { prisma } from '../../infra/database/prisma'
import { dragonfly } from '../../infra/cache/dragonfly'
import { logger } from '../../config/logger'
import { storageProvider } from '../../core/StorageProvider'
import type { WhatsAppConnectionManager } from './connection/WhatsAppConnectionManager'
import { sendRichMessage } from './rich-message'
@@ -715,6 +716,26 @@ export function buildAvatarRoute(manager: WhatsAppConnectionManager): Router {
// Dragonfly indisponível — segue o fluxo normal sem quebrar
}
// ── Helpers para persistência no Wasabi ────────────────────────────────────
// Path determinístico: avatars/{tenantId}/{instanceId}/{jid_safe}.jpg
// Não usa timestamp — sobrescreve o arquivo quando o avatar muda.
const avatarWasabiPath = (tid: string, iid: string, j: string) =>
`avatars/${tid}/${iid}/${j.replace(/[^a-zA-Z0-9]/g, '_')}.jpg`
// Faz upload fire-and-forget para Wasabi e atualiza o banco com o path relativo.
// Chamado após servir os bytes ao browser para não bloquear a resposta.
const persistToWasabi = (buffer: Buffer) => {
if (!storageProvider.isRegistered()) return
const wasabiPath = avatarWasabiPath(tenantId, instanceId, jid)
storageProvider.uploadFile({
category: 'whatsapp',
customPath: wasabiPath,
file: { originalname: `${jid.replace(/[^a-zA-Z0-9]/g, '_')}.jpg`, buffer, mimetype: 'image/jpeg' },
}).then(() =>
prisma.contact.updateMany({ where: { tenantId, instanceId, jid }, data: { avatarUrl: wasabiPath } })
).catch(() => {})
}
// 1. Tenta avatarUrl já salvo no banco (funciona para todos os tipos de JID)
const contact = await prisma.contact.findFirst({
where: { tenantId, instanceId, jid },
@@ -722,34 +743,48 @@ export function buildAvatarRoute(manager: WhatsAppConnectionManager): Router {
})
if (contact?.avatarUrl) {
// Revalidação condicional: se o browser mandou If-None-Match e bate,
// responde 304 sem baixar do CDN. Reduz o custo em rehit a ~zero.
const etag = etagFor(contact.avatarUrl)
const storedUrl = contact.avatarUrl
const isWasabi = !storedUrl.startsWith('http')
const etag = etagFor(storedUrl)
if (req.headers['if-none-match'] === etag) {
res.set('ETag', etag)
res.set('Cache-Control', HIT_CACHE_CONTROL)
res.status(304).end()
res.set('ETag', etag).set('Cache-Control', HIT_CACHE_CONTROL).status(304).end()
logger.debug({ jid, ms: Date.now() - t0, source: 'etag_304' }, '[Avatar] 304')
return
}
try {
// Busca imagem do CDN do WhatsApp (URL temporária, expira em ~24h)
const upstream = await fetch(contact.avatarUrl)
if (upstream.ok) {
const buffer = Buffer.from(await upstream.arrayBuffer())
res.set('Content-Type', upstream.headers.get('content-type') || 'image/jpeg')
res.set('Cache-Control', HIT_CACHE_CONTROL)
res.set('ETag', etag)
res.set('Content-Length', String(buffer.length))
res.send(buffer)
logger.debug({ jid, ms: Date.now() - t0, source: 'db_cache' }, '[Avatar] served')
return
}
// URL da DB expirou — tenta via Baileys abaixo
logger.debug({ jid, ms: Date.now() - t0 }, '[Avatar] DB URL expirada, fallback Baileys')
} catch {
// URL da DB expirou ou erro de rede — tenta via Baileys abaixo
if (isWasabi) {
// Já está no Wasabi — serve direto sem tocar no CDN do WhatsApp
try {
const buffer = await storageProvider.getBuffer(storedUrl)
if (buffer) {
res.set('Content-Type', 'image/jpeg')
.set('Cache-Control', HIT_CACHE_CONTROL)
.set('ETag', etag)
.set('Content-Length', String(buffer.length))
.send(buffer)
logger.debug({ jid, ms: Date.now() - t0, source: 'wasabi' }, '[Avatar] served')
return
}
} catch { /* Wasabi indisponível — cai no fallback Baileys abaixo */ }
} else {
// CDN URL do WhatsApp — busca bytes e inicia upload para Wasabi
try {
const upstream = await fetch(storedUrl)
if (upstream.ok) {
const buffer = Buffer.from(await upstream.arrayBuffer())
res.set('Content-Type', upstream.headers.get('content-type') || 'image/jpeg')
.set('Cache-Control', HIT_CACHE_CONTROL)
.set('ETag', etag)
.set('Content-Length', String(buffer.length))
.send(buffer)
logger.debug({ jid, ms: Date.now() - t0, source: 'db_cache' }, '[Avatar] served')
persistToWasabi(buffer)
return
}
// URL expirou — cai no fallback Baileys abaixo
logger.debug({ jid, ms: Date.now() - t0 }, '[Avatar] DB URL expirada, fallback Baileys')
} catch { /* Erro de rede — cai no fallback Baileys abaixo */ }
}
}
@@ -764,24 +799,19 @@ export function buildAvatarRoute(manager: WhatsAppConnectionManager): Router {
const url = await sock.profilePictureUrl(jid, 'image')
if (!url) { noAvatar(); return }
// Faz proxy do buffer — evita CORS ao redirecionar para o CDN do WhatsApp
const upstream = await fetch(url)
if (!upstream.ok) { noAvatar(); return }
const buffer = Buffer.from(await upstream.arrayBuffer())
res.set('Content-Type', upstream.headers.get('content-type') || 'image/jpeg')
res.set('Cache-Control', HIT_CACHE_CONTROL)
res.set('ETag', etagFor(url))
res.set('Content-Length', String(buffer.length))
res.send(buffer)
.set('Cache-Control', HIT_CACHE_CONTROL)
.set('ETag', etagFor(url))
.set('Content-Length', String(buffer.length))
.send(buffer)
logger.debug({ jid, ms: Date.now() - t0, source: 'baileys' }, '[Avatar] served')
// Salva URL nova no banco para próximas requisições (fire-and-forget).
// Também invalida o negative cache, caso exista: agora temos foto.
await prisma.contact.updateMany({
where: { tenantId, instanceId, jid },
data: { avatarUrl: url },
}).catch(() => {})
// Persiste no Wasabi (fire-and-forget) e invalida negative cache
persistToWasabi(buffer)
dragonfly.del(noAvatarKey(tenantId, instanceId, jid)).catch(() => {})
} catch {
noAvatar()