OPEN-ISP v0.5 (pickle-lime) — rilis open-source perdana
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
This commit is contained in:
commit
eaf05f2811
280 files changed
+123975
No files matched your search
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* 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
|
||||
Reference in new issue
Block a user