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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user