- раз в сутки удаляются notification_log и sync_log старше 90 дней и vps_health_checks старше 30 дней; синки, зависшие в running дольше часа, помечаются ошибкой - dashboard stats не грузит тарифы, домены и проекты - запросы характеристик VPS в BILLmanager идут по 4 параллельно Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
446 lines
14 KiB
TypeScript
446 lines
14 KiB
TypeScript
/**
|
|
* Sync BILLmanager data into vps-tracker DB (Drizzle / @cfdm/db)
|
|
*/
|
|
|
|
import { eq } from 'drizzle-orm'
|
|
import { getCurrentSpaceId, getDb, getSqlite, schema } from '@cfdm/db'
|
|
import { findSyncedVps } from '../providers/find-synced-vps.js'
|
|
|
|
import type { BillmanagerSyncAccount } from './context.js'
|
|
import { syncFallbackCurrency } from '@cfdm/shared/utils/account-balance'
|
|
import {
|
|
fetchDashboardInfo,
|
|
fetchPayments,
|
|
fetchVds,
|
|
fetchVdsOrderPricelistAllDatacenters,
|
|
mapPaymentWithProfile,
|
|
mapVdsWithProfile,
|
|
type DashboardInfo,
|
|
type TariffItem,
|
|
} from './operations.js'
|
|
import { resolveBillmanagerProfile } from './profiles/index.js'
|
|
import {
|
|
applyEditSpecs,
|
|
enrichMappedVpsFromTariffs,
|
|
fetchVdsEditSpecs,
|
|
needsHardwareSpecs,
|
|
} from './vds-specs.js'
|
|
|
|
export interface SyncFromBillmanagerOptions {
|
|
skipTariffs?: boolean
|
|
skipVpsPayments?: boolean
|
|
}
|
|
|
|
export interface SyncSummary {
|
|
added: { id: string; label: string }[]
|
|
updated: { id: string; label: string; fields: string[] }[]
|
|
paymentsAdded: number
|
|
tariffsOnly?: boolean
|
|
}
|
|
|
|
export interface SyncFromBillmanagerResult {
|
|
vpsCount: number
|
|
paymentsCount: number
|
|
tariffsCount: number
|
|
newTariffs: { name: string; price: string; providerId: string }[]
|
|
balance: DashboardInfo | null
|
|
syncSummary: SyncSummary
|
|
}
|
|
|
|
const SYNC_UPDATE_FIELDS = [
|
|
'country',
|
|
'city',
|
|
'datacenter',
|
|
'os',
|
|
'notes',
|
|
'status',
|
|
'tariffType',
|
|
'currency',
|
|
'dailyRate',
|
|
'monthlyRate',
|
|
'paidUntil',
|
|
] as const
|
|
|
|
const SYNC_HARDWARE_FIELDS = ['vcpu', 'ramGb', 'diskGb', 'diskType', 'virtualization'] as const
|
|
|
|
function normVal(v: unknown): string {
|
|
if (v == null || v === '') return ''
|
|
if (typeof v === 'number') return Number.isFinite(v) ? String(v) : ''
|
|
return String(v)
|
|
}
|
|
|
|
async function mapVdsWithSpecs(
|
|
profile: ReturnType<typeof resolveBillmanagerProfile>,
|
|
item: Record<string, string>,
|
|
providerId: string,
|
|
accountId: string,
|
|
tariffItems: TariffItem[],
|
|
apiBaseUrl: string,
|
|
authinfo: string,
|
|
) {
|
|
let vps = mapVdsWithProfile(profile, item, providerId, accountId)
|
|
vps = enrichMappedVpsFromTariffs(item, vps, tariffItems)
|
|
if (needsHardwareSpecs(vps) && profile.options?.fetchVdsEditForSpecs) {
|
|
const elid = String(item.id || vps.externalId || '').trim()
|
|
const specs = await fetchVdsEditSpecs(apiBaseUrl, authinfo, elid)
|
|
if (specs) vps = applyEditSpecs(vps, specs)
|
|
}
|
|
return vps
|
|
}
|
|
|
|
/** vds.edit lookups are one HTTP call per VPS — run a few at once, not all (hoster rate limits). */
|
|
const VDS_SPECS_CONCURRENCY = 4
|
|
|
|
export async function mapConcurrent<T, R>(
|
|
items: T[],
|
|
limit: number,
|
|
fn: (item: T) => Promise<R>,
|
|
): Promise<R[]> {
|
|
const out: R[] = new Array(items.length)
|
|
let next = 0
|
|
const worker = async () => {
|
|
while (next < items.length) {
|
|
const i = next++
|
|
out[i] = await fn(items[i]!)
|
|
}
|
|
}
|
|
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
|
|
return out
|
|
}
|
|
|
|
export async function syncFromBillmanager(
|
|
account: BillmanagerSyncAccount,
|
|
opts: SyncFromBillmanagerOptions = {},
|
|
): Promise<SyncFromBillmanagerResult> {
|
|
const { skipTariffs = false, skipVpsPayments = false } = opts
|
|
const { apiBaseUrl, apiCredentials, providerId, id: accountId } = account
|
|
if (!apiBaseUrl?.trim() || !apiCredentials?.trim()) {
|
|
throw new Error('API URL and credentials are required')
|
|
}
|
|
const authinfo = apiCredentials.trim()
|
|
const db = getDb()
|
|
const profile = resolveBillmanagerProfile(apiBaseUrl)
|
|
|
|
const fetchVpsPayments = !skipVpsPayments
|
|
const fetchTariffs = !skipTariffs
|
|
|
|
const fallbackCurrency = syncFallbackCurrency(account)
|
|
|
|
const [vdsItems, paymentItems, dashboardInfo, tariffResult] = await Promise.all([
|
|
fetchVpsPayments ? fetchVds(apiBaseUrl, authinfo, profile) : [],
|
|
fetchVpsPayments
|
|
? fetchPayments(apiBaseUrl, authinfo, { profile })
|
|
: [],
|
|
fetchVpsPayments
|
|
? fetchDashboardInfo(apiBaseUrl, authinfo, {
|
|
fallbackCurrency,
|
|
profile,
|
|
}).catch(() => null)
|
|
: null,
|
|
fetchTariffs
|
|
? fetchVdsOrderPricelistAllDatacenters(apiBaseUrl, authinfo, profile).catch(
|
|
(err) => {
|
|
console.warn(
|
|
'fetchVdsOrderPricelistAllDatacenters failed:',
|
|
err instanceof Error ? err.message : err,
|
|
)
|
|
return {
|
|
tariffItems: [] as TariffItem[],
|
|
slist: {} as Record<string, unknown>,
|
|
}
|
|
},
|
|
)
|
|
: { tariffItems: [] as TariffItem[], slist: {} as Record<string, unknown> },
|
|
])
|
|
const { tariffItems = [], slist = {} } = tariffResult || {}
|
|
|
|
let vpsCount = 0
|
|
const syncSummary: SyncSummary = { added: [], updated: [], paymentsAdded: 0 }
|
|
|
|
if (fetchVpsPayments) {
|
|
const mappedRaw = await mapConcurrent(vdsItems, VDS_SPECS_CONCURRENCY, (item) =>
|
|
mapVdsWithSpecs(profile, item, providerId, accountId, tariffItems, apiBaseUrl, authinfo),
|
|
)
|
|
const mappedList = profile.map.enrichVdsBatch
|
|
? profile.map.enrichVdsBatch(mappedRaw, {
|
|
balance: dashboardInfo?.balance ?? null,
|
|
})
|
|
: mappedRaw
|
|
|
|
for (const vps of mappedList) {
|
|
const id = `vps-bm-${accountId}-${vps.externalId}`
|
|
const additionalIps = JSON.stringify(vps.additionalIps || [])
|
|
const dailyRate = vps.dailyRate
|
|
const monthlyRate = vps.monthlyRate
|
|
const paidUntil = vps.paidUntil || ''
|
|
const notes = vps.notes ? `${vps.notes} [bm-${vps.externalId}]` : `bm-${vps.externalId}`
|
|
|
|
const existing = findSyncedVps(accountId, id, [`bm-${vps.externalId}`], vps.ip)
|
|
|
|
if (existing) {
|
|
let userOverrides: string[] = []
|
|
try {
|
|
userOverrides = existing.userOverrides ? JSON.parse(existing.userOverrides) : []
|
|
} catch {
|
|
userOverrides = []
|
|
}
|
|
const merged = {
|
|
ip: vps.ip,
|
|
ipv6: vps.ipv6,
|
|
additionalIps,
|
|
dns: vps.dns,
|
|
country: vps.country,
|
|
city: vps.city,
|
|
datacenter: vps.datacenter,
|
|
os: vps.os,
|
|
status: vps.status,
|
|
tariffType: vps.tariffType,
|
|
currency: vps.currency,
|
|
dailyRate,
|
|
monthlyRate,
|
|
paidUntil,
|
|
notes,
|
|
vcpu: vps.vcpu,
|
|
ramGb: vps.ramGb,
|
|
diskGb: vps.diskGb,
|
|
diskType: vps.diskType,
|
|
virtualization: vps.virtualization,
|
|
}
|
|
for (const f of SYNC_UPDATE_FIELDS) {
|
|
if (userOverrides.includes(f)) {
|
|
merged[f] = existing[f as keyof typeof existing] as never
|
|
}
|
|
}
|
|
for (const f of SYNC_HARDWARE_FIELDS) {
|
|
if (userOverrides.includes(f)) {
|
|
merged[f] = existing[f as keyof typeof existing] as never
|
|
} else {
|
|
// Keep previous non-zero hardware if API still returns empty
|
|
const next = merged[f]
|
|
const prev = existing[f as keyof typeof existing]
|
|
if (
|
|
(next == null || next === 0 || next === '') &&
|
|
prev != null &&
|
|
prev !== 0 &&
|
|
prev !== ''
|
|
) {
|
|
merged[f] = prev as never
|
|
}
|
|
}
|
|
}
|
|
const compareFields = [
|
|
'ip',
|
|
'ipv6',
|
|
'dns',
|
|
...SYNC_UPDATE_FIELDS,
|
|
...SYNC_HARDWARE_FIELDS,
|
|
] as const
|
|
const changedFields = compareFields.filter(
|
|
(f) => normVal(merged[f as keyof typeof merged]) !== normVal(existing[f as keyof typeof existing]),
|
|
)
|
|
if (changedFields.length > 0) {
|
|
const label = merged.dns || merged.ip || existing.id
|
|
syncSummary.updated.push({ id: existing.id, label, fields: [...changedFields] })
|
|
}
|
|
db.update(schema.vps)
|
|
.set({
|
|
ip: merged.ip,
|
|
ipv6: merged.ipv6,
|
|
additionalIps: merged.additionalIps,
|
|
dns: merged.dns,
|
|
country: merged.country,
|
|
city: merged.city,
|
|
datacenter: merged.datacenter,
|
|
os: merged.os,
|
|
status: merged.status,
|
|
tariffType: merged.tariffType,
|
|
currency: merged.currency,
|
|
dailyRate: merged.dailyRate,
|
|
monthlyRate: merged.monthlyRate,
|
|
paidUntil: merged.paidUntil,
|
|
notes: merged.notes,
|
|
vcpu: merged.vcpu,
|
|
ramGb: merged.ramGb,
|
|
diskGb: merged.diskGb,
|
|
diskType: merged.diskType,
|
|
virtualization: merged.virtualization,
|
|
})
|
|
.where(eq(schema.vps.id, existing.id))
|
|
.run()
|
|
} else {
|
|
const label = vps.dns || vps.ip || id
|
|
syncSummary.added.push({ id, label })
|
|
db.insert(schema.vps)
|
|
.values({
|
|
spaceId: getCurrentSpaceId(),
|
|
id,
|
|
ip: vps.ip,
|
|
ipv6: vps.ipv6,
|
|
additionalIps,
|
|
dns: vps.dns,
|
|
providerId: vps.providerId,
|
|
providerAccountId: vps.providerAccountId,
|
|
country: vps.country,
|
|
city: vps.city,
|
|
datacenter: vps.datacenter,
|
|
os: vps.os,
|
|
vcpu: vps.vcpu,
|
|
ramGb: vps.ramGb,
|
|
diskGb: vps.diskGb,
|
|
diskType: vps.diskType,
|
|
virtualization: vps.virtualization,
|
|
bandwidthTb: vps.bandwidthTb,
|
|
sshPort: vps.sshPort,
|
|
rootUser: vps.rootUser,
|
|
purpose: vps.purpose,
|
|
environment: vps.environment,
|
|
project: vps.project,
|
|
projectId: null,
|
|
monitoringEnabled: vps.monitoringEnabled ? 1 : 0,
|
|
backupEnabled: vps.backupEnabled ? 1 : 0,
|
|
status: vps.status,
|
|
tariffType: vps.tariffType,
|
|
currency: vps.currency,
|
|
dailyRate,
|
|
monthlyRate,
|
|
createdAt: vps.createdAt,
|
|
paidUntil,
|
|
notes,
|
|
userOverrides: '[]',
|
|
})
|
|
.run()
|
|
}
|
|
vpsCount++
|
|
}
|
|
}
|
|
|
|
let paymentsCount = 0
|
|
if (fetchVpsPayments) {
|
|
const existingPaymentRows = db
|
|
.select({ note: schema.payments.note })
|
|
.from(schema.payments)
|
|
.where(eq(schema.payments.providerAccountId, accountId))
|
|
.all()
|
|
const existingPayments = new Set(
|
|
existingPaymentRows.map((r) => r.note).filter((n): n is string => Boolean(n)),
|
|
)
|
|
|
|
for (const item of paymentItems) {
|
|
const payment = mapPaymentWithProfile(profile, item, accountId)
|
|
if (!payment || payment.amount <= 0) continue
|
|
const note = payment.note
|
|
if (existingPayments.has(note)) continue
|
|
const payId = `pay-bm-${accountId}-${payment.externalId}`
|
|
db.insert(schema.payments)
|
|
.values({
|
|
spaceId: getCurrentSpaceId(),
|
|
id: payId,
|
|
type: payment.type,
|
|
date: payment.date,
|
|
amount: payment.amount,
|
|
currency: payment.currency,
|
|
providerAccountId: payment.providerAccountId,
|
|
vpsId: payment.vpsId,
|
|
note,
|
|
})
|
|
.run()
|
|
existingPayments.add(note)
|
|
paymentsCount++
|
|
syncSummary.paymentsAdded += 1
|
|
}
|
|
}
|
|
|
|
if (fetchVpsPayments && dashboardInfo) {
|
|
db.update(schema.providerAccounts)
|
|
.set({
|
|
balanceApi: dashboardInfo.balance,
|
|
balanceCurrency: dashboardInfo.currency || fallbackCurrency,
|
|
balanceUpdatedAt: new Date().toISOString(),
|
|
enoughmoneyto: dashboardInfo.enoughmoneyto || '',
|
|
})
|
|
.where(eq(schema.providerAccounts.id, accountId))
|
|
.run()
|
|
}
|
|
|
|
let tariffsCount = 0
|
|
const newTariffs: { name: string; price: string; providerId: string }[] = []
|
|
// Fetch errors come back as [] — keep the old list instead of wiping it (and re-announcing all as new).
|
|
if (fetchTariffs && tariffItems.length > 0) {
|
|
getSqlite().transaction(() => {
|
|
const existingTariffIds = new Set(
|
|
db
|
|
.select({ id: schema.activeTariffs.id })
|
|
.from(schema.activeTariffs)
|
|
.where(eq(schema.activeTariffs.providerAccountId, accountId))
|
|
.all()
|
|
.map((r) => r.id),
|
|
)
|
|
const syncedAt = new Date().toISOString()
|
|
db.delete(schema.activeTariffs)
|
|
.where(eq(schema.activeTariffs.providerAccountId, accountId))
|
|
.run()
|
|
|
|
for (const t of tariffItems) {
|
|
const dcKey = t.datacenterKey ?? ''
|
|
const dcName = t.datacenterName ?? ''
|
|
const tariffId = dcKey
|
|
? `tariff-bm-${accountId}-${t.externalId}-${dcKey}`
|
|
: `tariff-bm-${accountId}-${t.externalId}`
|
|
if (!existingTariffIds.has(tariffId)) {
|
|
newTariffs.push({ name: t.name || '', price: t.price || '', providerId })
|
|
}
|
|
db.insert(schema.activeTariffs)
|
|
.values({
|
|
spaceId: getCurrentSpaceId(),
|
|
id: tariffId,
|
|
providerAccountId: accountId,
|
|
providerId,
|
|
externalId: t.externalId,
|
|
datacenterKey: dcKey,
|
|
datacenterName: dcName,
|
|
name: t.name || '',
|
|
desc: t.desc || '',
|
|
vcpu: t.vcpu || 0,
|
|
ramGb: t.ramGb || 0,
|
|
diskGb: t.diskGb || 0,
|
|
diskType: t.diskType || 'SSD',
|
|
virtualization: t.virtualization || 'KVM',
|
|
channel: t.channel || '',
|
|
location: t.location || '',
|
|
country: t.country || '',
|
|
cpuModel: t.cpuModel || '',
|
|
orderAvailable: t.orderAvailable ? 1 : 0,
|
|
price: t.price || '',
|
|
syncedAt,
|
|
})
|
|
.run()
|
|
tariffsCount++
|
|
}
|
|
|
|
if (Object.keys(slist).length > 0) {
|
|
const datacenters = Array.isArray(slist.datacenter) ? JSON.stringify(slist.datacenter) : '[]'
|
|
const periods = Array.isArray(slist.period) ? JSON.stringify(slist.period) : '[]'
|
|
db.insert(schema.tariffSyncOptions)
|
|
.values({
|
|
spaceId: getCurrentSpaceId(),
|
|
providerAccountId: accountId,
|
|
datacenters,
|
|
periods,
|
|
syncedAt,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: schema.tariffSyncOptions.providerAccountId,
|
|
set: { datacenters, periods, syncedAt },
|
|
})
|
|
.run()
|
|
}
|
|
})()
|
|
}
|
|
|
|
if (!fetchVpsPayments) {
|
|
syncSummary.tariffsOnly = true
|
|
}
|
|
|
|
return { vpsCount, paymentsCount, tariffsCount, newTariffs, balance: dashboardInfo, syncSummary }
|
|
}
|