- JsonCacheManager: a leitura inicial concorrente carregava o arquivo duas vezes e a segunda descartava as alterações da primeira; gravações simultâneas escreviam no mesmo arquivo. Agora a leitura é compartilhada e a gravação é enfileirada, via arquivo temporário + rename. - POST /api/cache/sync: aceitava qualquer objeto e usava code como chave do cache (inclusive ausente ou não numérico); agora só grava produtos com code numérico e conta os demais em failed. JSON malformado responde 400 em vez de 500. - GET /api/products/[barcode]: resposta da API sem product virava 200 com corpo vazio; agora responde 404. - Chamadas ao Open Food Facts (busca e produto) passam a ter timeout de 15 s e o User-Agent vem de um único lugar (lib/offSearch).
476 lines
13 KiB
TypeScript
476 lines
13 KiB
TypeScript
/**
|
|
* Gerenciador de Cache de Produtos baseado em JSON
|
|
* Gerencia armazenamento, busca e merge inteligente de dados no servidor
|
|
*/
|
|
|
|
import { promises as fs } from "fs";
|
|
import fsSync from "fs";
|
|
import path from "path";
|
|
import type { Product } from "./openFoodFactsApi";
|
|
import type { CachedProduct, StorageStats } from "./types";
|
|
|
|
interface JsonCacheData {
|
|
products: { [key: string]: CachedProduct };
|
|
lastUpdated: number;
|
|
version: string;
|
|
}
|
|
|
|
export class JsonCacheManager {
|
|
private cacheFilePath: string;
|
|
private cacheData: JsonCacheData | null = null;
|
|
/** Fila de gravações: evita que duas gravações simultâneas corrompam o arquivo. */
|
|
private writeQueue: Promise<void> = Promise.resolve();
|
|
/** Leitura inicial em andamento, compartilhada por chamadas concorrentes. */
|
|
private loadPromise: Promise<void> | null = null;
|
|
private static instance: JsonCacheManager;
|
|
|
|
constructor() {
|
|
// Definir caminho do arquivo de cache JSON
|
|
this.cacheFilePath = path.join(
|
|
process.cwd(),
|
|
"data",
|
|
"products-cache.json"
|
|
);
|
|
|
|
// Criar diretório se não existir
|
|
const dataDir = path.dirname(this.cacheFilePath);
|
|
try {
|
|
if (!fsSync.existsSync(dataDir)) {
|
|
fsSync.mkdirSync(dataDir, { recursive: true });
|
|
console.log(`[JSON Cache] Diretório criado: ${dataDir}`);
|
|
}
|
|
} catch (error) {
|
|
console.error("[JSON Cache] Erro ao criar diretório:", error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Singleton pattern para garantir uma única instância
|
|
*/
|
|
static getInstance(): JsonCacheManager {
|
|
if (!JsonCacheManager.instance) {
|
|
JsonCacheManager.instance = new JsonCacheManager();
|
|
}
|
|
return JsonCacheManager.instance;
|
|
}
|
|
|
|
/**
|
|
* Carrega dados do cache do arquivo JSON
|
|
*/
|
|
private loadCache(): Promise<void> {
|
|
// Sem isto, duas chamadas simultâneas na primeira leitura carregam o arquivo
|
|
// duas vezes e a segunda descarta as alterações feitas pela primeira.
|
|
this.loadPromise ??= this.readCacheFile();
|
|
return this.loadPromise;
|
|
}
|
|
|
|
private async readCacheFile(): Promise<void> {
|
|
if (this.cacheData) return;
|
|
|
|
try {
|
|
if (fsSync.existsSync(this.cacheFilePath)) {
|
|
const fileContent = await fs.readFile(this.cacheFilePath, "utf-8");
|
|
this.cacheData = JSON.parse(fileContent) as JsonCacheData;
|
|
console.log(
|
|
`[JSON Cache] Cache carregado: ${
|
|
Object.keys(this.cacheData.products).length
|
|
} produtos`
|
|
);
|
|
} else {
|
|
this.cacheData = {
|
|
products: {},
|
|
lastUpdated: 0,
|
|
version: "1.0.0",
|
|
};
|
|
console.log("[JSON Cache] Cache inicializado vazio");
|
|
}
|
|
} catch (error) {
|
|
console.error("[JSON Cache] Erro ao carregar cache:", error);
|
|
this.cacheData = {
|
|
products: {},
|
|
lastUpdated: 0,
|
|
version: "1.0.0",
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Salva dados do cache no arquivo JSON
|
|
*/
|
|
private saveCache(): Promise<void> {
|
|
const write = async () => {
|
|
if (!this.cacheData) return;
|
|
|
|
try {
|
|
// Grava em arquivo temporário e renomeia: o arquivo final nunca fica pela metade
|
|
const tempPath = `${this.cacheFilePath}.tmp`;
|
|
await fs.writeFile(tempPath, JSON.stringify(this.cacheData, null, 2), "utf-8");
|
|
await fs.rename(tempPath, this.cacheFilePath);
|
|
console.log(
|
|
`[JSON Cache] Cache salvo: ${
|
|
Object.keys(this.cacheData.products).length
|
|
} produtos`
|
|
);
|
|
} catch (error) {
|
|
console.error("[JSON Cache] Erro ao salvar cache:", error);
|
|
}
|
|
};
|
|
|
|
// write() nunca rejeita, então a fila continua mesmo após uma falha
|
|
this.writeQueue = this.writeQueue.then(write);
|
|
return this.writeQueue;
|
|
}
|
|
|
|
/**
|
|
* Converte Product para CachedProduct
|
|
*/
|
|
private convertToCachedProduct(product: Product): CachedProduct {
|
|
return {
|
|
...product,
|
|
_metadata: {
|
|
firstSeen: Date.now(),
|
|
lastUpdated: Date.now(),
|
|
updateCount: 1,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Converte CachedProduct para Product
|
|
*/
|
|
private convertToAPIProduct(cachedProduct: CachedProduct): Product {
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const { _metadata, ...product } = cachedProduct;
|
|
return product as Product;
|
|
}
|
|
|
|
/**
|
|
* Merge inteligente de dados de produto
|
|
*/
|
|
private mergeProductData(
|
|
existing: CachedProduct,
|
|
newData: Product
|
|
): CachedProduct {
|
|
return {
|
|
...existing,
|
|
...newData,
|
|
_metadata: {
|
|
...existing._metadata,
|
|
lastUpdated: Date.now(),
|
|
updateCount: existing._metadata.updateCount + 1,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Calcula score de relevância de um produto em relação à query
|
|
*/
|
|
private calculateRelevanceScore(
|
|
product: Product,
|
|
searchTerm: string
|
|
): number {
|
|
const productName = product.product_name?.toLowerCase() || "";
|
|
const lowerSearchTerm = searchTerm.toLowerCase();
|
|
|
|
// Nome exato = maior prioridade
|
|
if (productName === lowerSearchTerm) {
|
|
return 3;
|
|
}
|
|
|
|
// Nome começa com a query = alta prioridade
|
|
if (productName.startsWith(lowerSearchTerm)) {
|
|
return 2;
|
|
}
|
|
|
|
// Nome contém a query = média prioridade
|
|
if (productName.includes(lowerSearchTerm)) {
|
|
return 1;
|
|
}
|
|
|
|
// Correspondência em marca ou categoria = baixa prioridade
|
|
if (
|
|
product.brands?.toLowerCase().includes(lowerSearchTerm) ||
|
|
product.categories?.toLowerCase().includes(lowerSearchTerm)
|
|
) {
|
|
return 0.5;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Busca produto por código de barras
|
|
*/
|
|
async getProductByCode(code: string): Promise<Product | null> {
|
|
try {
|
|
await this.loadCache();
|
|
|
|
if (!this.cacheData) return null;
|
|
|
|
const cachedProduct = this.cacheData.products[code];
|
|
if (cachedProduct) {
|
|
console.log(`[JSON Cache] Produto encontrado: ${code}`);
|
|
return this.convertToAPIProduct(cachedProduct);
|
|
}
|
|
|
|
console.log(`[JSON Cache] Produto não encontrado: ${code}`);
|
|
return null;
|
|
} catch (error) {
|
|
console.error("[JSON Cache] Erro ao buscar produto por código:", error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Busca produtos por query (nome, marca, categoria)
|
|
*/
|
|
async searchProducts(
|
|
query: string,
|
|
country?: string,
|
|
limit: number = 50
|
|
): Promise<{
|
|
products: Product[];
|
|
totalCount: number;
|
|
fromCache: boolean;
|
|
fromAPI: boolean;
|
|
}> {
|
|
try {
|
|
await this.loadCache();
|
|
|
|
if (!this.cacheData) {
|
|
return {
|
|
products: [],
|
|
totalCount: 0,
|
|
fromCache: false,
|
|
fromAPI: false,
|
|
};
|
|
}
|
|
|
|
const searchTerm = query.toLowerCase();
|
|
console.log(
|
|
`[JSON Cache] Buscando: "${searchTerm}" (país: ${country || "todos"})`
|
|
);
|
|
|
|
// Filtrar produtos; a relevância é calculada uma vez por produto
|
|
const filteredProducts = Object.values(this.cacheData.products)
|
|
.filter((cachedProduct) => {
|
|
// Verificar se corresponde à query
|
|
const matchesQuery =
|
|
cachedProduct.product_name?.toLowerCase().includes(searchTerm) ||
|
|
cachedProduct.brands?.toLowerCase().includes(searchTerm) ||
|
|
cachedProduct.categories?.toLowerCase().includes(searchTerm);
|
|
|
|
// Verificar país se especificado
|
|
const matchesCountry =
|
|
!country ||
|
|
cachedProduct.countries_tags?.includes(country) ||
|
|
cachedProduct.countries?.toLowerCase().includes(country.toLowerCase());
|
|
|
|
return matchesQuery && matchesCountry;
|
|
})
|
|
.map((cachedProduct) => {
|
|
const product = this.convertToAPIProduct(cachedProduct);
|
|
return { product, score: this.calculateRelevanceScore(product, searchTerm) };
|
|
})
|
|
// Ordenar por relevância
|
|
.sort((a, b) => b.score - a.score)
|
|
.slice(0, limit)
|
|
.map(({ product }) => product);
|
|
|
|
console.log(
|
|
`[JSON Cache] Encontrados ${filteredProducts.length} produtos`
|
|
);
|
|
return {
|
|
products: filteredProducts,
|
|
totalCount: filteredProducts.length,
|
|
fromCache: true,
|
|
fromAPI: false,
|
|
};
|
|
} catch (error) {
|
|
console.error("[JSON Cache] Erro ao buscar produtos:", error);
|
|
return {
|
|
products: [],
|
|
totalCount: 0,
|
|
fromCache: false,
|
|
fromAPI: false,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Salva ou atualiza produto no cache
|
|
*/
|
|
async saveProduct(product: Product): Promise<boolean> {
|
|
try {
|
|
await this.loadCache();
|
|
|
|
if (!this.cacheData) {
|
|
this.cacheData = {
|
|
products: {},
|
|
lastUpdated: 0,
|
|
version: "1.0.0",
|
|
};
|
|
}
|
|
|
|
const code = product.code;
|
|
const existingProduct = this.cacheData.products[code];
|
|
|
|
if (existingProduct) {
|
|
this.cacheData.products[code] = this.mergeProductData(
|
|
existingProduct,
|
|
product
|
|
);
|
|
console.log(`[JSON Cache] Produto atualizado: ${code}`);
|
|
} else {
|
|
this.cacheData.products[code] = this.convertToCachedProduct(product);
|
|
console.log(`[JSON Cache] Produto adicionado: ${code}`);
|
|
}
|
|
|
|
this.cacheData.lastUpdated = Date.now();
|
|
await this.saveCache();
|
|
return true;
|
|
} catch (error) {
|
|
console.error("[JSON Cache] Erro ao salvar produto:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Salva múltiplos produtos no cache
|
|
*/
|
|
async saveProducts(products: Product[]): Promise<{
|
|
saved: number;
|
|
failed: number;
|
|
}> {
|
|
let saved = 0;
|
|
let failed = 0;
|
|
|
|
try {
|
|
await this.loadCache();
|
|
|
|
if (!this.cacheData) {
|
|
this.cacheData = {
|
|
products: {},
|
|
lastUpdated: 0,
|
|
version: "1.0.0",
|
|
};
|
|
}
|
|
|
|
// Adiciona todos os produtos ao objeto em memória primeiro
|
|
for (const product of products) {
|
|
try {
|
|
const code = product.code;
|
|
const existingProduct = this.cacheData.products[code];
|
|
|
|
if (existingProduct) {
|
|
this.cacheData.products[code] = this.mergeProductData(
|
|
existingProduct,
|
|
product
|
|
);
|
|
console.log(`[JSON Cache] Produto atualizado na memória: ${code}`);
|
|
} else {
|
|
this.cacheData.products[code] = this.convertToCachedProduct(product);
|
|
console.log(`[JSON Cache] Produto adicionado na memória: ${code}`);
|
|
}
|
|
saved++;
|
|
} catch {
|
|
failed++;
|
|
}
|
|
}
|
|
|
|
// Salva no disco uma única vez após processar todo o lote
|
|
if (saved > 0) {
|
|
this.cacheData.lastUpdated = Date.now();
|
|
await this.saveCache();
|
|
console.log(`[JSON Cache] Lote salvo no disco! Total processado: ${saved} produtos.`);
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error("[JSON Cache] Erro ao salvar lote de produtos:", error);
|
|
}
|
|
|
|
return { saved, failed };
|
|
}
|
|
|
|
/**
|
|
* Obtém estatísticas do cache
|
|
*/
|
|
async getStorageStats(): Promise<StorageStats> {
|
|
try {
|
|
await this.loadCache();
|
|
|
|
if (!this.cacheData) {
|
|
return {
|
|
totalProducts: 0,
|
|
storageSizeMB: 0,
|
|
lastUpdated: 0,
|
|
averageProductSize: 0,
|
|
};
|
|
}
|
|
|
|
const productCount = Object.keys(this.cacheData.products).length;
|
|
|
|
// Calcular tamanho do arquivo
|
|
let fileSize = 0;
|
|
try {
|
|
const stats = await fs.stat(this.cacheFilePath);
|
|
fileSize = stats.size;
|
|
} catch (error) {
|
|
console.error("[JSON Cache] Erro ao obter tamanho do arquivo:", error);
|
|
}
|
|
|
|
const sizeInMB = fileSize / (1024 * 1024);
|
|
const averageSize = productCount > 0 ? fileSize / productCount : 0;
|
|
|
|
return {
|
|
totalProducts: productCount,
|
|
storageSizeMB: parseFloat(sizeInMB.toFixed(2)),
|
|
lastUpdated: this.cacheData.lastUpdated,
|
|
averageProductSize: parseFloat(averageSize.toFixed(0)),
|
|
};
|
|
} catch (error) {
|
|
console.error("[JSON Cache] Erro ao obter estatísticas:", error);
|
|
return {
|
|
totalProducts: 0,
|
|
storageSizeMB: 0,
|
|
lastUpdated: 0,
|
|
averageProductSize: 0,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Limpa todo o cache
|
|
*/
|
|
async clearCache(): Promise<boolean> {
|
|
try {
|
|
this.cacheData = {
|
|
products: {},
|
|
lastUpdated: 0,
|
|
version: "1.0.0",
|
|
};
|
|
await this.saveCache();
|
|
console.log("[JSON Cache] Cache limpo");
|
|
return true;
|
|
} catch (error) {
|
|
console.error("[JSON Cache] Erro ao limpar cache:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verifica se o cache está inicializado
|
|
*/
|
|
async isInitialized(): Promise<boolean> {
|
|
try {
|
|
await this.loadCache();
|
|
return this.cacheData !== null;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Exportar instância singleton
|
|
export const jsonCacheManager = JsonCacheManager.getInstance();
|