Aplikasi billing & manajemen ISP (fiber optic) — PWA pelanggan, portal teknisi & kolektor, admin panel lengkap, multi-vendor OLT, RADIUS, WhatsApp/Telegram, pembayaran QRIS. Kredit & lisensi (ISC): - Base kode & repo: alijayanet/billing-rtrw oleh Alijaya Net — terima kasih banyak atas base kode dan repo yang bermanfaat. - Add-on: GoWA, LibreSpeed/OpenSpeedTest, Uptime Kuma, notification-forwarder. - Dikembangkan menjadi OPEN-ISP oleh oxpoe — https://github.com/oxpoe/open-isp
257 lines
9.1 KiB
JavaScript
257 lines
9.1 KiB
JavaScript
/**
|
|
* Voucher Cache Warmer Service
|
|
* Melakukan pre-warming cache untuk voucher profiles dan payment channels
|
|
* agar halaman voucher selalu cepat diakses
|
|
*/
|
|
|
|
const { logger } = require('../config/logger');
|
|
const { parseMikhmonOnLogin } = require('../utils/mikhmonParser');
|
|
|
|
// Flag untuk mencegah multiple warming berjalan bersamaan
|
|
let isWarming = false;
|
|
let warmingInterval = null;
|
|
|
|
/**
|
|
* Warm up voucher cache
|
|
* Dipanggil secara berkala untuk memastikan cache selalu fresh
|
|
*/
|
|
async function warmVoucherCache() {
|
|
if (isWarming) {
|
|
logger.debug('[VoucherCacheWarmer] Already warming, skip');
|
|
return;
|
|
}
|
|
|
|
isWarming = true;
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
// Import di dalam fungsi untuk menghindari circular dependency
|
|
const mikrotikService = require('./mikrotikService');
|
|
const paymentSvc = require('./paymentService');
|
|
const { getSettingsWithCache } = require('../config/settingsManager');
|
|
const db = require('../config/database');
|
|
|
|
logger.info('[VoucherCacheWarmer] Starting cache warming...');
|
|
|
|
// Get settings
|
|
const settings = getSettingsWithCache();
|
|
|
|
// 1. Warm voucher profiles cache
|
|
const CACHE_KEY = 'voucher_profiles_cache';
|
|
const routers = mikrotikService.getAllRouters().filter(r => r.is_active);
|
|
const routerList = routers.length > 0 ? routers : [{ id: null, name: '' }];
|
|
|
|
// Query database dan MikroTik secara parallel
|
|
const [configuredPricesMap, mikrotikResults] = await Promise.all([
|
|
(async () => {
|
|
const map = new Map();
|
|
try {
|
|
const rows = db.prepare(`
|
|
SELECT router_id, profile_name, price, validity
|
|
FROM voucher_batches
|
|
WHERE price > 0
|
|
GROUP BY router_id, profile_name
|
|
HAVING id = MAX(id)
|
|
`).all();
|
|
for (const row of rows) {
|
|
const key = `${row.router_id}_${row.profile_name}`;
|
|
map.set(key, { price: row.price, validity: row.validity });
|
|
}
|
|
} catch (e) {
|
|
logger.error('[VoucherCacheWarmer] Error loading configured prices: ' + e.message);
|
|
}
|
|
return map;
|
|
})(),
|
|
(async () => {
|
|
try {
|
|
// Query semua router secara parallel dengan timeout per router
|
|
const results = await Promise.allSettled(
|
|
routerList.map(r =>
|
|
Promise.race([
|
|
mikrotikService.getHotspotUserProfiles(r.id),
|
|
new Promise((_, reject) =>
|
|
setTimeout(() => reject(new Error(`Timeout for router ${r.name || r.id}`)), 4000)
|
|
)
|
|
])
|
|
)
|
|
);
|
|
return results;
|
|
} catch (e) {
|
|
logger.error('[VoucherCacheWarmer] Error querying MikroTik: ' + e.message);
|
|
return [];
|
|
}
|
|
})()
|
|
]);
|
|
|
|
// Process profiles
|
|
const allRows = [];
|
|
if (Array.isArray(mikrotikResults)) {
|
|
for (let i = 0; i < mikrotikResults.length; i++) {
|
|
const result = mikrotikResults[i];
|
|
const router = routerList[i];
|
|
if (result.status !== 'fulfilled' || !Array.isArray(result.value)) {
|
|
logger.warn(`[VoucherCacheWarmer] Router ${router.name || router.id} query failed or returned invalid data`);
|
|
continue;
|
|
}
|
|
for (const p of result.value) {
|
|
allRows.push({
|
|
routerId: router.id ?? null,
|
|
name: p?.name,
|
|
onLogin: p?.onLogin ?? p?.['on-login']
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
logger.info(`[VoucherCacheWarmer] Found ${allRows.length} profiles from MikroTik`);
|
|
|
|
const bestByName = new Map();
|
|
let skippedCount = 0;
|
|
for (const row of allRows) {
|
|
const name = String(row.name || '').trim();
|
|
if (!name) continue;
|
|
|
|
// Parse Mikhmon metadata (gunakan shared parser agar konsisten dengan route handler)
|
|
const onLogin = String(row.onLogin || '').trim();
|
|
const meta = parseMikhmonOnLogin(onLogin);
|
|
let price = Number(meta?.price || 0) || 0;
|
|
let validity = String(meta?.validity || '').trim();
|
|
|
|
if (price <= 0 || !validity) {
|
|
const key = `${row.routerId}_${name}`;
|
|
const configured = configuredPricesMap.get(key);
|
|
if (configured) {
|
|
price = Number(configured.price || 0) || 0;
|
|
validity = String(configured.validity || '').trim();
|
|
}
|
|
}
|
|
|
|
if (price <= 0) {
|
|
skippedCount++;
|
|
logger.debug(`[VoucherCacheWarmer] Skipped profile "${name}" - no price (onLogin: ${onLogin || 'empty'})`);
|
|
continue;
|
|
}
|
|
if (!validity) validity = '-';
|
|
|
|
const existing = bestByName.get(name);
|
|
if (!existing || Number(price) < Number(existing.price || 0)) {
|
|
bestByName.set(name, { name, validity, price, router_id: row.routerId });
|
|
}
|
|
}
|
|
|
|
if (skippedCount > 0) {
|
|
logger.warn(`[VoucherCacheWarmer] Skipped ${skippedCount} profiles without price metadata. Add Mikhmon metadata (e.g., $10000^1d) to profile's on-login script or configure price in voucher_batches table.`);
|
|
}
|
|
|
|
const profiles = Array.from(bestByName.values()).sort((a, b) => Number(a.price || 0) - Number(b.price || 0));
|
|
|
|
// Save to cache
|
|
global[CACHE_KEY] = {
|
|
data: profiles,
|
|
timestamp: Date.now()
|
|
};
|
|
|
|
logger.info(`[VoucherCacheWarmer] Profiles cached: ${profiles.length} profiles`);
|
|
|
|
// 2. Warm payment channels cache
|
|
const gateway = settings.default_gateway || 'tripay';
|
|
const gatewayEnabled = {
|
|
tripay: settings.tripay_enabled && settings.tripay_api_key,
|
|
midtrans: settings.midtrans_enabled && settings.midtrans_server_key,
|
|
xendit: settings.xendit_enabled && settings.xendit_api_key,
|
|
duitku: settings.duitku_enabled && settings.duitku_api_key
|
|
};
|
|
|
|
if (gatewayEnabled[gateway]) {
|
|
const PAYMENT_CACHE_KEY = `voucher_payment_channels_cache_${gateway}`;
|
|
let channels = [];
|
|
|
|
if (gateway === 'tripay') {
|
|
try {
|
|
channels = await Promise.race([
|
|
paymentSvc.getTripayChannels(),
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 1500))
|
|
]);
|
|
} catch (e) {
|
|
logger.warn('[VoucherCacheWarmer] Tripay channels fetch failed: ' + e.message);
|
|
channels = [];
|
|
}
|
|
} else {
|
|
const base = [
|
|
{ code: 'QRIS', name: 'QRIS', group: 'QRIS', active: true },
|
|
{ code: 'BCAVA', name: 'BCA Virtual Account', group: 'Virtual Account', active: true },
|
|
{ code: 'BNIVA', name: 'BNI Virtual Account', group: 'Virtual Account', active: true },
|
|
{ code: 'BRIVA', name: 'BRI Virtual Account', group: 'Virtual Account', active: true },
|
|
{ code: 'PERMATAVA', name: 'Permata Virtual Account', group: 'Virtual Account', active: true },
|
|
{ code: 'MANDIRIVA', name: 'Mandiri Virtual Account', group: 'Virtual Account', active: true }
|
|
];
|
|
if (gateway === 'midtrans') channels = [{ code: 'SNAP', name: 'Semua Metode (Snap)', group: 'E-Wallet', active: true }, ...base];
|
|
else if (gateway === 'xendit') channels = [{ code: 'XENDIT', name: 'Semua Metode', group: 'E-Wallet', active: true }, ...base];
|
|
else if (gateway === 'duitku') channels = [{ code: 'DUITKU', name: 'Semua Metode', group: 'E-Wallet', active: true }, ...base];
|
|
else channels = base;
|
|
}
|
|
|
|
global[PAYMENT_CACHE_KEY] = {
|
|
data: channels,
|
|
timestamp: Date.now()
|
|
};
|
|
|
|
logger.info(`[VoucherCacheWarmer] Payment channels cached: ${channels.length} channels`);
|
|
}
|
|
|
|
const duration = Date.now() - startTime;
|
|
logger.info(`[VoucherCacheWarmer] Cache warming completed in ${duration}ms`);
|
|
|
|
} catch (error) {
|
|
logger.error('[VoucherCacheWarmer] Error warming cache:', error.message);
|
|
} finally {
|
|
isWarming = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start background cache warming
|
|
* Warm cache setiap 8 menit (sebelum cache 10 menit expired)
|
|
*/
|
|
function startCacheWarming() {
|
|
if (warmingInterval) {
|
|
logger.warn('[VoucherCacheWarmer] Already started');
|
|
return;
|
|
}
|
|
|
|
// Warm immediately on start (500ms delay agar app sudah siap)
|
|
setTimeout(() => {
|
|
warmVoucherCache().catch(err => {
|
|
logger.error('[VoucherCacheWarmer] Initial warming failed:', err.message);
|
|
});
|
|
}, 500); // 500ms setelah aplikasi start (lebih cepat siap)
|
|
|
|
// Then warm every 8 minutes
|
|
warmingInterval = setInterval(() => {
|
|
warmVoucherCache().catch(err => {
|
|
logger.error('[VoucherCacheWarmer] Periodic warming failed:', err.message);
|
|
});
|
|
}, 3 * 60 * 1000); // 3 menit (agar profile selalu up-to-date)
|
|
|
|
logger.info('[VoucherCacheWarmer] Background cache warming started (every 3 minutes)');
|
|
}
|
|
|
|
/**
|
|
* Stop background cache warming
|
|
*/
|
|
function stopCacheWarming() {
|
|
if (warmingInterval) {
|
|
clearInterval(warmingInterval);
|
|
warmingInterval = null;
|
|
logger.info('[VoucherCacheWarmer] Background cache warming stopped');
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
warmVoucherCache,
|
|
startCacheWarming,
|
|
stopCacheWarming
|
|
};
|
|
|
|
// Made with Bob
|