237 lines
9.5 KiB
JavaScript
237 lines
9.5 KiB
JavaScript
/* =============================================================================
|
||
* Prology – Related Products API (Node.js / Express)
|
||
* -----------------------------------------------------------------------------
|
||
* Server riêng đứng giữa blog WordPress và Magento, để:
|
||
* - Tự login Magento lấy admin token (KHÔNG lộ ra trình duyệt) + cache token
|
||
* - Chọn store theo vị trí client: AU -> 'au', còn lại -> 'us'
|
||
* - Tìm sản phẩm liên quan theo keyword, lọc: status=1 + có ảnh + CÒN HÀNG
|
||
* - Random và trả về mảng đã chuẩn hóa {name,image,url,price,qty}
|
||
* - Bật CORS để trang blog gọi được, không dính CORS
|
||
* - Cache kết quả ngắn hạn để giảm tải Magento
|
||
*
|
||
* CHẠY:
|
||
* cd server && npm install && cp .env.example .env (sửa .env nếu cần)
|
||
* npm start # mặc định http://localhost:8787
|
||
*
|
||
* Yêu cầu: Node.js >= 18 (dùng global fetch).
|
||
*
|
||
* Endpoint:
|
||
* GET /api/related-products?q=Catalyst%209300,Cisco&count=16&store=au
|
||
* - store: client tự phát hiện (qua Cloudflare /cdn-cgi/trace) rồi gửi lên.
|
||
* Nếu thiếu, server fallback theo header CF-IPCountry, rồi tới DEFAULT_STORE.
|
||
* ========================================================================== */
|
||
'use strict';
|
||
|
||
const express = require('express');
|
||
require('dotenv').config();
|
||
|
||
const {
|
||
PORT = 8787,
|
||
MAGENTO_BASE = 'https://prology.net',
|
||
// Danh sách store hợp lệ + store mặc định khi không phát hiện được
|
||
STORES = 'au,us',
|
||
DEFAULT_STORE = 'us',
|
||
MAGENTO_USER = 'admin',
|
||
MAGENTO_PASS = 'Work1234',
|
||
// Origin được phép gọi (vd: https://prology.net). '*' = mọi nơi (chỉ nên dùng khi test)
|
||
ALLOWED_ORIGIN = '*',
|
||
POOL = '40', // số sản phẩm lấy về để lọc/random
|
||
PRODUCTS_TTL = '300', // cache danh sách sản phẩm (giây)
|
||
STOCK_TTL = '60', // cache tồn kho (giây)
|
||
} = process.env;
|
||
|
||
const STORE_LIST = STORES.split(',').map((s) => s.trim()).filter(Boolean);
|
||
const apiBase = (store) => `${MAGENTO_BASE}/${store}/rest/V1`;
|
||
const mediaBase = (store) => `${MAGENTO_BASE}/${store}/media/catalog/product`;
|
||
const app = express();
|
||
app.set('trust proxy', true);
|
||
|
||
// ----------------------------- CORS ----------------------------------------
|
||
app.use((req, res, next) => {
|
||
res.set('Access-Control-Allow-Origin', ALLOWED_ORIGIN);
|
||
res.set('Vary', 'Origin');
|
||
res.set('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||
res.set('Access-Control-Allow-Headers', 'Content-Type');
|
||
if (req.method === 'OPTIONS') return res.sendStatus(204);
|
||
next();
|
||
});
|
||
|
||
// ------------------- Chọn store theo vị trí client -------------------------
|
||
function pickStore(req) {
|
||
// 1) client tự phát hiện (Cloudflare trace) rồi gửi ?store=
|
||
const q = String(req.query.store || '').toLowerCase();
|
||
if (STORE_LIST.includes(q)) return q;
|
||
// 2) fallback: nếu server đứng sau Cloudflare -> có header CF-IPCountry
|
||
const cc = String(req.get('CF-IPCountry') || '').toUpperCase();
|
||
if (cc === 'AU') return 'au';
|
||
if (cc && cc !== 'XX' && STORE_LIST.includes('us')) return 'us';
|
||
// 3) mặc định
|
||
return DEFAULT_STORE;
|
||
}
|
||
|
||
// ------------------------- Token (cache + tự gia hạn) -----------------------
|
||
let tokenCache = { value: null, exp: 0 };
|
||
|
||
async function getToken(force = false) {
|
||
const now = Date.now();
|
||
if (!force && tokenCache.value && now < tokenCache.exp) return tokenCache.value;
|
||
|
||
// Token admin dùng chung cho mọi store; login qua store mặc định
|
||
const r = await fetch(`${apiBase(DEFAULT_STORE)}/integration/admin/token`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ username: MAGENTO_USER, password: MAGENTO_PASS }),
|
||
});
|
||
if (!r.ok) throw new Error(`Magento login failed: ${r.status} ${await r.text()}`);
|
||
const token = (await r.json());
|
||
|
||
// Token Magento mặc định sống ~1h; cache 50 phút cho an toàn
|
||
tokenCache = { value: token, exp: now + 50 * 60 * 1000 };
|
||
return token;
|
||
}
|
||
|
||
// Gọi Magento (theo store) kèm Bearer, tự refetch token 1 lần nếu gặp 401
|
||
async function mget(store, path) {
|
||
let token = await getToken();
|
||
let r = await fetch(`${apiBase(store)}${path}`, { headers: { Authorization: `Bearer ${token}` } });
|
||
if (r.status === 401) {
|
||
token = await getToken(true);
|
||
r = await fetch(`${apiBase(store)}${path}`, { headers: { Authorization: `Bearer ${token}` } });
|
||
}
|
||
if (!r.ok) throw new Error(`Magento ${store}${path} -> ${r.status}`);
|
||
return r.json();
|
||
}
|
||
|
||
// ------------------------------ Cache nhỏ (TTL) ----------------------------
|
||
function makeCache(ttlSec) {
|
||
const m = new Map();
|
||
return {
|
||
get(k) { const e = m.get(k); if (e && Date.now() < e.exp) return e.val; m.delete(k); return null; },
|
||
set(k, val) { m.set(k, { val, exp: Date.now() + ttlSec * 1000 }); return val; },
|
||
};
|
||
}
|
||
const prodCache = makeCache(Number(PRODUCTS_TTL));
|
||
const stockCache = makeCache(Number(STOCK_TTL));
|
||
|
||
// ------------------------------- Helpers -----------------------------------
|
||
const attr = (it, code) =>
|
||
(it.custom_attributes || []).find((c) => c.attribute_code === code)?.value ?? null;
|
||
|
||
function imageOf(it, store) {
|
||
let f = attr(it, 'image') || attr(it, 'small_image');
|
||
if (!f || f === 'no_selection') {
|
||
const g = (it.media_gallery_entries || []).find((e) => e.media_type === 'image' && !e.disabled);
|
||
f = g ? g.file : null;
|
||
}
|
||
return f ? mediaBase(store) + f : null;
|
||
}
|
||
function urlOf(it, store) {
|
||
const k = attr(it, 'url_key');
|
||
return k ? `${MAGENTO_BASE}/${store}/${k}` : `${MAGENTO_BASE}/${store}/`;
|
||
}
|
||
function priceOf(it, store) {
|
||
const v = Number(it.price || 0);
|
||
if (!v) return '';
|
||
const locale = store === 'au' ? 'en-AU' : 'en-US';
|
||
const currency = store === 'au' ? 'AUD' : 'USD';
|
||
try {
|
||
return new Intl.NumberFormat(locale, { style: 'currency', currency, maximumFractionDigits: 0 }).format(v);
|
||
} catch { return '$' + v; }
|
||
}
|
||
function shuffle(a) {
|
||
for (let i = a.length - 1; i > 0; i--) { const j = (Math.random() * (i + 1)) | 0; [a[i], a[j]] = [a[j], a[i]]; }
|
||
return a;
|
||
}
|
||
|
||
// ------------------------- Magento data access -----------------------------
|
||
async function searchByName(store, keyword) {
|
||
const ck = `${store}|${keyword}`;
|
||
const cached = prodCache.get(ck);
|
||
if (cached) return cached;
|
||
|
||
const p = new URLSearchParams();
|
||
p.set('searchCriteria[filterGroups][0][filters][0][field]', 'name');
|
||
p.set('searchCriteria[filterGroups][0][filters][0][value]', `%${keyword}%`);
|
||
p.set('searchCriteria[filterGroups][0][filters][0][conditionType]', 'like');
|
||
p.set('searchCriteria[filterGroups][1][filters][0][field]', 'status');
|
||
p.set('searchCriteria[filterGroups][1][filters][0][value]', '1');
|
||
p.set('searchCriteria[filterGroups][1][filters][0][conditionType]', 'eq');
|
||
p.set('searchCriteria[pageSize]', POOL);
|
||
p.set('searchCriteria[currentPage]', '1');
|
||
p.set('searchCriteria[sortOrders][0][field]', 'updated_at');
|
||
p.set('searchCriteria[sortOrders][0][direction]', 'DESC');
|
||
|
||
const data = await mget(store, `/products?${p.toString()}`);
|
||
return prodCache.set(ck, data.items || []);
|
||
}
|
||
|
||
async function inStock(store, sku) {
|
||
const ck = `${store}|${sku}`;
|
||
const cached = stockCache.get(ck);
|
||
if (cached !== null) return cached;
|
||
try {
|
||
const s = await mget(store, `/stockItems/${encodeURIComponent(sku)}`);
|
||
return stockCache.set(ck, { in_stock: !!s.is_in_stock, qty: s.qty });
|
||
} catch {
|
||
return stockCache.set(ck, { in_stock: false, qty: 0 });
|
||
}
|
||
}
|
||
|
||
// ----------------------------- Endpoint ------------------------------------
|
||
app.get('/api/related-products', async (req, res) => {
|
||
try {
|
||
const store = pickStore(req);
|
||
const keywords = String(req.query.q || 'Cisco')
|
||
.split(',').map((s) => s.trim()).filter(Boolean);
|
||
const count = Math.min(Math.max(parseInt(req.query.count, 10) || 16, 1), 20);
|
||
|
||
// 1) Gom ứng viên theo nhiều keyword (cụ thể -> tổng quát), khử trùng SKU
|
||
const seen = new Set();
|
||
const candidates = [];
|
||
for (const kw of keywords) {
|
||
if (candidates.length >= Number(POOL) * 1.5) break;
|
||
let items = [];
|
||
try { items = await searchByName(store, kw); } catch (_) { /* bỏ qua keyword lỗi */ }
|
||
for (const it of items) {
|
||
if (!seen.has(it.sku)) { seen.add(it.sku); candidates.push(it); }
|
||
}
|
||
}
|
||
|
||
// 2) Lọc cơ bản: có ảnh + đang bật + không phải "not visible individually"
|
||
const usable = shuffle(candidates.filter(
|
||
(it) => imageOf(it, store) && Number(it.status) === 1 && Number(it.visibility) !== 1
|
||
));
|
||
|
||
// 3) Random + check tồn kho lazy cho tới khi đủ count
|
||
const picked = [];
|
||
for (const it of usable) {
|
||
if (picked.length >= count) break;
|
||
const st = await inStock(store, it.sku);
|
||
if (st.in_stock) {
|
||
picked.push({
|
||
sku: it.sku,
|
||
name: it.name,
|
||
image: imageOf(it, store),
|
||
url: urlOf(it, store),
|
||
price: priceOf(it, store),
|
||
qty: st.qty,
|
||
});
|
||
}
|
||
}
|
||
|
||
res.set('Cache-Control', 'public, max-age=60');
|
||
res.json(picked);
|
||
} catch (err) {
|
||
console.error('[related-products]', err);
|
||
res.status(500).json({ error: 'failed', message: String(err.message || err) });
|
||
}
|
||
});
|
||
|
||
app.get('/health', (_req, res) => res.json({ ok: true, stores: STORE_LIST }));
|
||
|
||
app.listen(PORT, () => {
|
||
console.log(`Prology related-products API: http://localhost:${PORT}`);
|
||
console.log(`Stores: ${STORE_LIST.join(', ')} (default: ${DEFAULT_STORE})`);
|
||
console.log(`Try: http://localhost:${PORT}/api/related-products?q=Catalyst%209300,Cisco&count=16&store=au`);
|
||
});
|