feat(api): завершение миграции на Fastify — sync, scheduler, backup
Docker / build (push) Has been cancelled
Docker / build (push) Has been cancelled
Портированы BILLmanager sync/test/balance, планировщик и Telegram, импорт/экспорт бэкапов и migrate API; Docker по умолчанию на fastify. Добавлен .understand-anything/ в gitignore, исправлен path codegraph в MCP. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
+1
-1
@@ -7,7 +7,7 @@
|
||||
"serve",
|
||||
"--mcp",
|
||||
"--path",
|
||||
"C:\\Users\\shats\\Dev\\cloudflare-domain-manager"
|
||||
"C:\\Users\\shats\\Dev\\vps-tracker"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ dist-ssr
|
||||
coverage
|
||||
*.tsbuildinfo
|
||||
|
||||
# Understand Anything (knowledge graph)
|
||||
.understand-anything/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
|
||||
+3
-3
@@ -37,9 +37,9 @@ RUN apk add --no-cache nodejs
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3001 \
|
||||
RUNTIME=express
|
||||
RUNTIME=fastify
|
||||
# Single layer for app code + pruned prod node_modules (symlinks resolved by COPY)
|
||||
COPY --from=build /app ./
|
||||
EXPOSE 3001
|
||||
# RUNTIME=express (default, legacy) | RUNTIME=fastify (new stack)
|
||||
CMD ["sh", "-c", "if [ \"$RUNTIME\" = \"fastify\" ]; then node apps/api/dist/index.js; else node apps/api/index.js; fi"]
|
||||
# RUNTIME=express — legacy fallback (apps/api/index.js)
|
||||
CMD ["sh", "-c", "if [ \"$RUNTIME\" = \"express\" ]; then node apps/api/index.js; else node apps/api/dist/index.js; fi"]
|
||||
|
||||
@@ -19,6 +19,8 @@ import { syncRoutes } from './routes/sync.js'
|
||||
import { projectsRoutes } from './routes/projects.js'
|
||||
import { backupRoutes } from './routes/backup.js'
|
||||
import { ratesProxyRoutes } from './routes/rates-proxy.js'
|
||||
import { migrateRoutes } from './routes/migrate.js'
|
||||
import { startScheduler } from './services/scheduler.js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
@@ -49,6 +51,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
||||
await app.register(projectsRoutes)
|
||||
await app.register(backupRoutes)
|
||||
await app.register(ratesProxyRoutes)
|
||||
await app.register(migrateRoutes)
|
||||
|
||||
const staticDir = opts.staticDir ?? join(__dirname, '..', '..', 'web', 'dist')
|
||||
if (existsSync(staticDir)) {
|
||||
@@ -72,6 +75,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
||||
async function start() {
|
||||
const port = Number(process.env.PORT ?? 3001)
|
||||
const app = await buildApp()
|
||||
startScheduler()
|
||||
try {
|
||||
await app.listen({ port, host: '0.0.0.0' })
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,13 +1,32 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { desc } from 'drizzle-orm'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { getDbPath } from '@cfdm/db'
|
||||
import { getDb, getDbPath, reloadDatabaseFromBuffer, schema } from '@cfdm/db'
|
||||
import { getSnapshot } from '@cfdm/db/repositories/snapshot'
|
||||
|
||||
import { importJsonSnapshot, type BackupPayload } from '../services/backup-import.js'
|
||||
import { restartScheduler } from '../services/scheduler.js'
|
||||
|
||||
const BACKUP_VERSION = 1
|
||||
|
||||
export const backupRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (_req, body, done) => {
|
||||
done(null, body)
|
||||
})
|
||||
|
||||
app.get('/api/backup/json', async (_req, reply) => {
|
||||
const snapshot = { backupVersion: BACKUP_VERSION, exportedAt: new Date().toISOString(), ...getSnapshot() }
|
||||
const syncLog = getDb()
|
||||
.select()
|
||||
.from(schema.syncLog)
|
||||
.orderBy(desc(schema.syncLog.startedAt))
|
||||
.limit(500)
|
||||
.all()
|
||||
const snapshot = {
|
||||
backupVersion: BACKUP_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
...getSnapshot(),
|
||||
syncLog,
|
||||
}
|
||||
reply.header('Content-Type', 'application/json; charset=utf-8')
|
||||
reply.header('Content-Disposition', 'attachment; filename="vps-tracker-backup.json"')
|
||||
return reply.send(JSON.stringify(snapshot, null, 2))
|
||||
@@ -29,16 +48,30 @@ export const backupRoutes: FastifyPluginAsync = async (app) => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Неверное тело запроса' } })
|
||||
}
|
||||
// TODO: implement JSON snapshot import via repositories
|
||||
return reply.code(501).send({ error: { code: 'NOT_IMPLEMENTED', message: 'JSON import pending migration' } })
|
||||
try {
|
||||
importJsonSnapshot(payload as BackupPayload)
|
||||
restartScheduler()
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
req.log.error(err)
|
||||
const message = err instanceof Error ? err.message : 'Импорт не удался'
|
||||
return reply.code(500).send({ error: { code: 'INTERNAL_ERROR', message } })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/backup/database', async (req, reply) => {
|
||||
const buf = req.body as Buffer
|
||||
if (!buf || !buf.length) {
|
||||
if (!Buffer.isBuffer(buf) || !buf.length) {
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Пустой файл' } })
|
||||
}
|
||||
// TODO: implement DB restore via better-sqlite3 backup API
|
||||
return reply.code(501).send({ error: { code: 'NOT_IMPLEMENTED', message: 'DB restore pending migration' } })
|
||||
try {
|
||||
reloadDatabaseFromBuffer(buf)
|
||||
restartScheduler()
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
req.log.error(err)
|
||||
const message = err instanceof Error ? err.message : 'Восстановление не удалось'
|
||||
return reply.code(500).send({ error: { code: 'INTERNAL_ERROR', message } })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { consolidateAllProviderApiSources, getSqlite } from '@cfdm/db'
|
||||
|
||||
export const migrateRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.post('/api/migrate', async (req, reply) => {
|
||||
const data = req.body
|
||||
if (!data || typeof data !== 'object') {
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Invalid payload' } })
|
||||
}
|
||||
|
||||
const payload = data as Record<string, unknown>
|
||||
const sqlite = getSqlite()
|
||||
|
||||
try {
|
||||
if (Array.isArray(payload.providers) && payload.providers.length > 0) {
|
||||
const stmt = sqlite.prepare(
|
||||
`INSERT OR REPLACE INTO providers (id, name, website, contact, baseCurrency, usdRate, eurRate, notes, apiType, apiBaseUrl) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
for (const raw of payload.providers) {
|
||||
const p = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
|
||||
stmt.run(
|
||||
p.id ?? '',
|
||||
p.name ?? '',
|
||||
p.website ?? '',
|
||||
p.contact ?? '',
|
||||
p.baseCurrency ?? '',
|
||||
p.usdRate ?? '',
|
||||
p.eurRate ?? '',
|
||||
p.notes ?? '',
|
||||
p.apiType ?? '',
|
||||
p.apiBaseUrl ?? '',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(payload.providerAccounts) && payload.providerAccounts.length > 0) {
|
||||
const stmt = sqlite.prepare(
|
||||
`INSERT OR REPLACE INTO provider_accounts (id, providerId, name, panelUrl, currency, billingMode, notes, apiType, apiBaseUrl, apiCredentials) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
for (const raw of payload.providerAccounts) {
|
||||
const acc = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
|
||||
stmt.run(
|
||||
acc.id ?? '',
|
||||
acc.providerId ?? '',
|
||||
acc.name ?? '',
|
||||
acc.panelUrl ?? '',
|
||||
acc.currency ?? '',
|
||||
acc.billingMode ?? '',
|
||||
acc.notes ?? '',
|
||||
acc.apiType ?? '',
|
||||
acc.apiBaseUrl ?? '',
|
||||
acc.apiCredentials ?? '',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(payload.vps) && payload.vps.length > 0) {
|
||||
const sql = `INSERT OR REPLACE INTO vps (id, ip, ipv6, additionalIps, dns, providerId, providerAccountId, country, city, datacenter, os, vcpu, ramGb, diskGb, diskType, virtualization, bandwidthTb, sshPort, rootUser, purpose, environment, project, projectId, monitoringEnabled, backupEnabled, status, tariffType, currency, dailyRate, monthlyRate, createdAt, paidUntil, notes, userOverrides) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
const stmt = sqlite.prepare(sql)
|
||||
for (const raw of payload.vps) {
|
||||
const v = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
|
||||
const additionalIps = Array.isArray(v.additionalIps) ? JSON.stringify(v.additionalIps) : '[]'
|
||||
const dailyRate = v.dailyRate === '' || v.dailyRate == null ? null : Number(v.dailyRate)
|
||||
const monthlyRate = v.monthlyRate === '' || v.monthlyRate == null ? null : Number(v.monthlyRate)
|
||||
const userOverrides = Array.isArray(v.userOverrides)
|
||||
? JSON.stringify(v.userOverrides)
|
||||
: String(v.userOverrides ?? '[]')
|
||||
stmt.run(
|
||||
v.id ?? '',
|
||||
v.ip ?? '',
|
||||
v.ipv6 ?? '',
|
||||
additionalIps,
|
||||
v.dns ?? '',
|
||||
v.providerId ?? '',
|
||||
v.providerAccountId ?? '',
|
||||
v.country ?? '',
|
||||
v.city ?? '',
|
||||
v.datacenter ?? '',
|
||||
v.os ?? '',
|
||||
v.vcpu ?? 0,
|
||||
v.ramGb ?? 0,
|
||||
v.diskGb ?? 0,
|
||||
v.diskType ?? '',
|
||||
v.virtualization ?? '',
|
||||
v.bandwidthTb ?? 0,
|
||||
v.sshPort ?? 22,
|
||||
v.rootUser ?? '',
|
||||
v.purpose ?? '',
|
||||
v.environment ?? '',
|
||||
v.project ?? '',
|
||||
v.projectId ?? null,
|
||||
v.monitoringEnabled ? 1 : 0,
|
||||
v.backupEnabled ? 1 : 0,
|
||||
v.status ?? 'active',
|
||||
v.tariffType ?? '',
|
||||
v.currency ?? '',
|
||||
dailyRate,
|
||||
monthlyRate,
|
||||
v.createdAt ?? '',
|
||||
v.paidUntil ?? '',
|
||||
v.notes ?? '',
|
||||
userOverrides,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(payload.payments) && payload.payments.length > 0) {
|
||||
const stmt = sqlite.prepare(
|
||||
`INSERT OR REPLACE INTO payments (id, type, date, amount, currency, providerAccountId, vpsId, note) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
for (const raw of payload.payments) {
|
||||
const pm = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
|
||||
stmt.run(
|
||||
pm.id ?? '',
|
||||
pm.type ?? '',
|
||||
pm.date ?? '',
|
||||
Number(pm.amount) || 0,
|
||||
pm.currency ?? '',
|
||||
pm.providerAccountId ?? '',
|
||||
pm.vpsId ?? '',
|
||||
pm.note ?? '',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(payload.balanceLedger) && payload.balanceLedger.length > 0) {
|
||||
const stmt = sqlite.prepare(
|
||||
`INSERT OR REPLACE INTO balance_ledger (id, type, date, amount, currency, direction, providerAccountId, vpsId, note) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
for (const raw of payload.balanceLedger) {
|
||||
const bl = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
|
||||
stmt.run(
|
||||
bl.id ?? '',
|
||||
bl.type ?? '',
|
||||
bl.date ?? '',
|
||||
Number(bl.amount) || 0,
|
||||
bl.currency ?? '',
|
||||
bl.direction ?? '',
|
||||
bl.providerAccountId ?? '',
|
||||
bl.vpsId ?? '',
|
||||
bl.note ?? '',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const settingsList = Array.isArray(payload.settings)
|
||||
? payload.settings
|
||||
: payload.settings
|
||||
? [payload.settings]
|
||||
: []
|
||||
if (settingsList.length > 0) {
|
||||
const stmt = sqlite.prepare(
|
||||
`INSERT OR REPLACE INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
for (const raw of settingsList) {
|
||||
const s = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
|
||||
stmt.run(
|
||||
s.id ?? 'settings-main',
|
||||
s.baseCurrency ?? 'RUB',
|
||||
s.ratesUrl ?? '',
|
||||
s.autoConvert !== false ? 1 : 0,
|
||||
s.ratesUpdatedAt ?? '',
|
||||
s.syncEnabled ? 1 : 0,
|
||||
s.syncIntervalMinutes ?? 60,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
consolidateAllProviderApiSources(sqlite)
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
req.log.error(err)
|
||||
const message = err instanceof Error ? err.message : 'Migration failed'
|
||||
return reply.code(500).send({ error: { code: 'INTERNAL_ERROR', message } })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -2,6 +2,9 @@ import type { FastifyPluginAsync } from 'fastify'
|
||||
import { settingsRepository } from '@cfdm/db/repositories/settings'
|
||||
import { settingsSchema } from '@cfdm/shared/contracts/settings'
|
||||
|
||||
import { restartScheduler } from '../services/scheduler.js'
|
||||
import { sendTelegramMessage } from '../services/telegram.js'
|
||||
|
||||
export const settingsRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get('/api/settings', async () => settingsRepository.list())
|
||||
|
||||
@@ -11,7 +14,9 @@ export const settingsRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
|
||||
}
|
||||
const id = (req.body as { id?: string })?.id ?? 'settings-main'
|
||||
return reply.code(201).send(settingsRepository.upsert(id, parsed.data))
|
||||
const result = settingsRepository.upsert(id, parsed.data)
|
||||
restartScheduler()
|
||||
return reply.code(201).send(result)
|
||||
})
|
||||
|
||||
app.put<{ Params: { id: string } }>('/api/settings/:id', async (req, reply) => {
|
||||
@@ -19,8 +24,22 @@ export const settingsRoutes: FastifyPluginAsync = async (app) => {
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
|
||||
}
|
||||
return settingsRepository.upsert(req.params.id, parsed.data)
|
||||
const result = settingsRepository.upsert(req.params.id, parsed.data)
|
||||
restartScheduler()
|
||||
return result
|
||||
})
|
||||
|
||||
app.post('/api/settings/telegram/test', async () => ({ ok: true }))
|
||||
app.post('/api/settings/telegram/test', async () => {
|
||||
const settings = settingsRepository.getRow('settings-main')
|
||||
if (!settings?.telegramBotToken?.trim() || !settings.telegramChatId?.trim()) {
|
||||
return { ok: false, error: 'Укажите токен бота и chat ID в настройках' }
|
||||
}
|
||||
await sendTelegramMessage(
|
||||
settings.telegramBotToken,
|
||||
settings.telegramChatId,
|
||||
'✅ VPS Tracker: тестовое сообщение',
|
||||
settings.telegramMessageThreadId,
|
||||
)
|
||||
return { ok: true }
|
||||
})
|
||||
}
|
||||
|
||||
+79
-28
@@ -1,9 +1,16 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { desc } from 'drizzle-orm'
|
||||
import { desc, eq } from 'drizzle-orm'
|
||||
import { getDb, schema } from '@cfdm/db'
|
||||
import { providerAccountsRepository } from '@cfdm/db/repositories/provider-accounts'
|
||||
import { providersRepository } from '@cfdm/db/repositories/providers'
|
||||
|
||||
import {
|
||||
billmanagerAccountRowForSync,
|
||||
fetchDashboardInfo,
|
||||
runBillmanagerAccountSync,
|
||||
testConnection,
|
||||
} from '../services/billmanager/index.js'
|
||||
|
||||
interface SyncLogRow {
|
||||
id: string
|
||||
accountId: string
|
||||
@@ -38,6 +45,9 @@ function mapSyncLog(row: typeof schema.syncLog.$inferSelect): SyncLogRow {
|
||||
}
|
||||
}
|
||||
|
||||
const BILLMANAGER_SETUP_ERROR =
|
||||
'Укажите в настройках хостера тип API BILLmanager и URL; в аккаунте — логин и пароль API'
|
||||
|
||||
export const syncRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get('/api/sync/status', async () => {
|
||||
const rows = getDb()
|
||||
@@ -49,32 +59,71 @@ export const syncRoutes: FastifyPluginAsync = async (app) => {
|
||||
return rows.map(mapSyncLog)
|
||||
})
|
||||
|
||||
app.post<{ Params: { accountId: string } }>('/api/sync/:accountId', async (req, reply) => {
|
||||
const account = providerAccountsRepository.getWithCredentials(req.params.accountId)
|
||||
if (!account) {
|
||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Account not found' } })
|
||||
}
|
||||
const provider = account.providerId ? providersRepository.get(account.providerId) : undefined
|
||||
// TODO: port billmanager sync job
|
||||
return reply.code(501).send({
|
||||
accountId: req.params.accountId,
|
||||
provider: provider?.name ?? null,
|
||||
status: 'pending-migration',
|
||||
note: 'Sync job port pending migration from Express adapters',
|
||||
})
|
||||
})
|
||||
app.post<{ Params: { accountId: string }; Body: { onlyTariffs?: boolean } }>(
|
||||
'/api/sync/:accountId',
|
||||
async (req, reply) => {
|
||||
const account = providerAccountsRepository.getWithCredentials(req.params.accountId)
|
||||
if (!account) {
|
||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Account not found' } })
|
||||
}
|
||||
const provider = account.providerId ? providersRepository.get(account.providerId) : undefined
|
||||
const syncRow = billmanagerAccountRowForSync(account, provider)
|
||||
if (!syncRow) {
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: BILLMANAGER_SETUP_ERROR } })
|
||||
}
|
||||
|
||||
const onlyTariffs = Boolean(req.body?.onlyTariffs)
|
||||
const opts = onlyTariffs ? { skipVpsPayments: true } : { skipTariffs: true }
|
||||
|
||||
try {
|
||||
const result = await runBillmanagerAccountSync(syncRow, opts)
|
||||
return {
|
||||
ok: true,
|
||||
synced: {
|
||||
vpsCount: result.vpsCount,
|
||||
paymentsCount: result.paymentsCount,
|
||||
tariffsCount: result.tariffsCount ?? 0,
|
||||
},
|
||||
}
|
||||
} catch (err) {
|
||||
req.log.error(err)
|
||||
const message = err instanceof Error ? err.message : 'Sync failed'
|
||||
return reply.code(500).send({ ok: false, error: message })
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
app.get<{ Params: { accountId: string } }>('/api/sync/:accountId/balance', async (req, reply) => {
|
||||
const account = providerAccountsRepository.getWithCredentials(req.params.accountId)
|
||||
if (!account) {
|
||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Account not found' } })
|
||||
}
|
||||
// TODO: port fetchDashboardInfo
|
||||
return reply.code(501).send({
|
||||
accountId: req.params.accountId,
|
||||
status: 'pending-migration',
|
||||
note: 'Balance fetch pending migration from Express adapters',
|
||||
})
|
||||
const provider = account.providerId ? providersRepository.get(account.providerId) : undefined
|
||||
const syncRow = billmanagerAccountRowForSync(account, provider)
|
||||
if (!syncRow) {
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: BILLMANAGER_SETUP_ERROR } })
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await fetchDashboardInfo(syncRow.apiBaseUrl, String(syncRow.apiCredentials).trim(), {
|
||||
fallbackCurrency: account.currency,
|
||||
})
|
||||
getDb()
|
||||
.update(schema.providerAccounts)
|
||||
.set({
|
||||
balanceApi: info.balance,
|
||||
balanceCurrency: info.currency || 'RUB',
|
||||
balanceUpdatedAt: new Date().toISOString(),
|
||||
enoughmoneyto: info.enoughmoneyto || '',
|
||||
})
|
||||
.where(eq(schema.providerAccounts.id, req.params.accountId))
|
||||
.run()
|
||||
return { ok: true, balance: info }
|
||||
} catch (err) {
|
||||
req.log.error(err)
|
||||
const message = err instanceof Error ? err.message : 'Failed to fetch balance'
|
||||
return reply.code(500).send({ ok: false, error: message })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/sync/test-connection', async (req, reply) => {
|
||||
@@ -83,13 +132,15 @@ export const syncRoutes: FastifyPluginAsync = async (app) => {
|
||||
apiCredentials?: string
|
||||
}
|
||||
if (!apiBaseUrl?.trim() || !apiCredentials?.trim()) {
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Укажите URL и учётные данные' } })
|
||||
return reply.code(400).send({ ok: false, error: 'Укажите URL и учётные данные' })
|
||||
}
|
||||
try {
|
||||
const result = await testConnection(apiBaseUrl.trim(), apiCredentials.trim())
|
||||
return result
|
||||
} catch (err) {
|
||||
req.log.error(err)
|
||||
const message = err instanceof Error ? err.message : 'Ошибка проверки'
|
||||
return reply.code(500).send({ ok: false, error: message })
|
||||
}
|
||||
// TODO: port testConnection
|
||||
return reply.code(501).send({
|
||||
ok: false,
|
||||
status: 'pending-migration',
|
||||
note: 'Connection test pending migration from Express adapters',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { getDb, getSqlite, schema, consolidateAllProviderApiSources } from '@cfdm/db'
|
||||
|
||||
export interface BackupPayload {
|
||||
providers?: unknown[]
|
||||
serverProjects?: unknown[]
|
||||
providerAccounts?: unknown[]
|
||||
settings?: unknown[] | unknown
|
||||
vps?: unknown[]
|
||||
payments?: unknown[]
|
||||
balanceLedger?: unknown[]
|
||||
activeTariffs?: unknown[]
|
||||
tariffSyncOptions?: unknown[]
|
||||
syncLog?: unknown[]
|
||||
}
|
||||
|
||||
function asObject(v: unknown): Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null ? (v as Record<string, unknown>) : {}
|
||||
}
|
||||
|
||||
export function importJsonSnapshot(data: BackupPayload): void {
|
||||
const db = getDb()
|
||||
const sqlite = getSqlite()
|
||||
|
||||
sqlite.exec('BEGIN')
|
||||
try {
|
||||
db.delete(schema.syncLog).run()
|
||||
db.delete(schema.tariffSyncOptions).run()
|
||||
db.delete(schema.activeTariffs).run()
|
||||
db.delete(schema.balanceLedger).run()
|
||||
db.delete(schema.payments).run()
|
||||
db.delete(schema.vps).run()
|
||||
db.delete(schema.providerAccounts).run()
|
||||
db.delete(schema.serverProjects).run()
|
||||
db.delete(schema.providers).run()
|
||||
db.delete(schema.settings).run()
|
||||
|
||||
for (const raw of Array.isArray(data.providers) ? data.providers : []) {
|
||||
const p = asObject(raw)
|
||||
db.insert(schema.providers)
|
||||
.values({
|
||||
id: String(p.id ?? ''),
|
||||
name: String(p.name ?? ''),
|
||||
website: String(p.website ?? ''),
|
||||
contact: String(p.contact ?? ''),
|
||||
baseCurrency: String(p.baseCurrency ?? ''),
|
||||
usdRate: String(p.usdRate ?? ''),
|
||||
eurRate: String(p.eurRate ?? ''),
|
||||
notes: String(p.notes ?? ''),
|
||||
apiType: String(p.apiType ?? ''),
|
||||
apiBaseUrl: String(p.apiBaseUrl ?? ''),
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
for (const raw of Array.isArray(data.serverProjects) ? data.serverProjects : []) {
|
||||
const sp = asObject(raw)
|
||||
db.insert(schema.serverProjects)
|
||||
.values({
|
||||
id: String(sp.id ?? ''),
|
||||
name: String(sp.name ?? ''),
|
||||
color: sp.color != null ? String(sp.color) : null,
|
||||
sortOrder: Number(sp.sortOrder) || 0,
|
||||
notes: sp.notes != null ? String(sp.notes) : null,
|
||||
createdAt: sp.createdAt != null ? String(sp.createdAt) : null,
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
for (const raw of Array.isArray(data.providerAccounts) ? data.providerAccounts : []) {
|
||||
const acc = asObject(raw)
|
||||
const alertRaw = acc.balance_alert_below ?? acc.balanceAlertBelow
|
||||
const alertBelow =
|
||||
alertRaw != null && alertRaw !== '' && Number.isFinite(Number(alertRaw)) ? Number(alertRaw) : null
|
||||
db.insert(schema.providerAccounts)
|
||||
.values({
|
||||
id: String(acc.id ?? ''),
|
||||
providerId: String(acc.providerId ?? ''),
|
||||
name: String(acc.name ?? ''),
|
||||
panelUrl: String(acc.panelUrl ?? ''),
|
||||
currency: String(acc.currency ?? ''),
|
||||
billingMode: String(acc.billingMode ?? ''),
|
||||
notes: String(acc.notes ?? ''),
|
||||
apiType: String(acc.apiType ?? ''),
|
||||
apiBaseUrl: String(acc.apiBaseUrl ?? ''),
|
||||
apiCredentials: String(acc.apiCredentials ?? ''),
|
||||
balanceApi: acc.balance_api != null ? Number(acc.balance_api) : acc.balanceApi != null ? Number(acc.balanceApi) : null,
|
||||
balanceCurrency:
|
||||
acc.balance_currency != null ? String(acc.balance_currency) : acc.balanceCurrency != null ? String(acc.balanceCurrency) : null,
|
||||
balanceUpdatedAt:
|
||||
acc.balance_updated_at != null ? String(acc.balance_updated_at) : acc.balanceUpdatedAt != null ? String(acc.balanceUpdatedAt) : null,
|
||||
enoughmoneyto: acc.enoughmoneyto != null ? String(acc.enoughmoneyto) : null,
|
||||
balanceAlertBelow: alertBelow,
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
consolidateAllProviderApiSources(sqlite)
|
||||
|
||||
const settingsList = Array.isArray(data.settings)
|
||||
? data.settings
|
||||
: data.settings
|
||||
? [data.settings]
|
||||
: []
|
||||
for (const raw of settingsList) {
|
||||
const s = asObject(raw)
|
||||
let customFields = s.customFields
|
||||
if (Array.isArray(customFields)) customFields = JSON.stringify(customFields)
|
||||
db.insert(schema.settings)
|
||||
.values({
|
||||
id: String(s.id ?? 'settings-main'),
|
||||
baseCurrency: String(s.baseCurrency ?? 'RUB'),
|
||||
ratesUrl: String(s.ratesUrl ?? ''),
|
||||
autoConvert: s.autoConvert !== false && s.autoConvert !== 0 ? 1 : 0,
|
||||
ratesUpdatedAt: String(s.ratesUpdatedAt ?? ''),
|
||||
syncEnabled: s.syncEnabled ? 1 : 0,
|
||||
syncIntervalMinutes: Number(s.syncIntervalMinutes) || 60,
|
||||
syncTariffsIntervalMinutes: Number(s.syncTariffsIntervalMinutes) || 1440,
|
||||
telegramBotToken: String(s.telegramBotToken ?? ''),
|
||||
telegramChatId: String(s.telegramChatId ?? ''),
|
||||
telegramMessageThreadId: String(s.telegramMessageThreadId ?? ''),
|
||||
notifyPaymentExpiryEnabled: s.notifyPaymentExpiryEnabled ? 1 : 0,
|
||||
notifyNewTariffsEnabled: s.notifyNewTariffsEnabled ? 1 : 0,
|
||||
customFields: customFields != null ? String(customFields) : null,
|
||||
notifyLowBalanceEnabled: s.notifyLowBalanceEnabled ? 1 : 0,
|
||||
notifySyncDigestEnabled: s.notifySyncDigestEnabled ? 1 : 0,
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
for (const raw of Array.isArray(data.vps) ? data.vps : []) {
|
||||
const v = asObject(raw)
|
||||
const additionalIps = Array.isArray(v.additionalIps) ? JSON.stringify(v.additionalIps) : '[]'
|
||||
const dailyRate = v.dailyRate === '' || v.dailyRate == null ? null : Number(v.dailyRate)
|
||||
const monthlyRate = v.monthlyRate === '' || v.monthlyRate == null ? null : Number(v.monthlyRate)
|
||||
const userOverrides = Array.isArray(v.userOverrides)
|
||||
? JSON.stringify(v.userOverrides)
|
||||
: String(v.userOverrides ?? '[]')
|
||||
db.insert(schema.vps)
|
||||
.values({
|
||||
id: String(v.id ?? ''),
|
||||
ip: String(v.ip ?? ''),
|
||||
ipv6: String(v.ipv6 ?? ''),
|
||||
additionalIps,
|
||||
dns: String(v.dns ?? ''),
|
||||
providerId: String(v.providerId ?? ''),
|
||||
providerAccountId: String(v.providerAccountId ?? ''),
|
||||
country: String(v.country ?? ''),
|
||||
city: String(v.city ?? ''),
|
||||
datacenter: String(v.datacenter ?? ''),
|
||||
os: String(v.os ?? ''),
|
||||
vcpu: Number(v.vcpu) || 0,
|
||||
ramGb: Number(v.ramGb) || 0,
|
||||
diskGb: Number(v.diskGb) || 0,
|
||||
diskType: String(v.diskType ?? ''),
|
||||
virtualization: String(v.virtualization ?? ''),
|
||||
bandwidthTb: Number(v.bandwidthTb) || 0,
|
||||
sshPort: Number(v.sshPort) || 22,
|
||||
rootUser: String(v.rootUser ?? ''),
|
||||
purpose: String(v.purpose ?? ''),
|
||||
environment: String(v.environment ?? ''),
|
||||
project: String(v.project ?? ''),
|
||||
projectId: v.projectId != null && v.projectId !== '' ? String(v.projectId) : null,
|
||||
monitoringEnabled: v.monitoringEnabled ? 1 : 0,
|
||||
backupEnabled: v.backupEnabled ? 1 : 0,
|
||||
status: String(v.status ?? 'active'),
|
||||
tariffType: String(v.tariffType ?? ''),
|
||||
currency: String(v.currency ?? ''),
|
||||
dailyRate,
|
||||
monthlyRate,
|
||||
createdAt: String(v.createdAt ?? ''),
|
||||
paidUntil: String(v.paidUntil ?? ''),
|
||||
notes: String(v.notes ?? ''),
|
||||
userOverrides,
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
for (const raw of Array.isArray(data.payments) ? data.payments : []) {
|
||||
const pm = asObject(raw)
|
||||
db.insert(schema.payments)
|
||||
.values({
|
||||
id: String(pm.id ?? ''),
|
||||
type: String(pm.type ?? ''),
|
||||
date: String(pm.date ?? ''),
|
||||
amount: Number(pm.amount) || 0,
|
||||
currency: String(pm.currency ?? ''),
|
||||
providerAccountId: String(pm.providerAccountId ?? ''),
|
||||
vpsId: pm.vpsId != null ? String(pm.vpsId) : null,
|
||||
note: String(pm.note ?? ''),
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
for (const raw of Array.isArray(data.balanceLedger) ? data.balanceLedger : []) {
|
||||
const bl = asObject(raw)
|
||||
db.insert(schema.balanceLedger)
|
||||
.values({
|
||||
id: String(bl.id ?? ''),
|
||||
type: String(bl.type ?? ''),
|
||||
date: String(bl.date ?? ''),
|
||||
amount: Number(bl.amount) || 0,
|
||||
currency: String(bl.currency ?? ''),
|
||||
direction: String(bl.direction ?? ''),
|
||||
providerAccountId: String(bl.providerAccountId ?? ''),
|
||||
vpsId: bl.vpsId != null ? String(bl.vpsId) : null,
|
||||
note: String(bl.note ?? ''),
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
for (const raw of Array.isArray(data.activeTariffs) ? data.activeTariffs : []) {
|
||||
const t = asObject(raw)
|
||||
db.insert(schema.activeTariffs)
|
||||
.values({
|
||||
id: String(t.id ?? ''),
|
||||
providerAccountId: String(t.providerAccountId ?? ''),
|
||||
providerId: String(t.providerId ?? ''),
|
||||
externalId: String(t.externalId ?? ''),
|
||||
datacenterKey: String(t.datacenterKey ?? ''),
|
||||
datacenterName: String(t.datacenterName ?? ''),
|
||||
name: String(t.name ?? ''),
|
||||
desc: String(t.desc ?? ''),
|
||||
vcpu: Number(t.vcpu) || 0,
|
||||
ramGb: Number(t.ramGb) || 0,
|
||||
diskGb: Number(t.diskGb) || 0,
|
||||
diskType: String(t.diskType ?? ''),
|
||||
virtualization: String(t.virtualization ?? ''),
|
||||
channel: String(t.channel ?? ''),
|
||||
location: String(t.location ?? ''),
|
||||
country: String(t.country ?? ''),
|
||||
cpuModel: String(t.cpuModel ?? ''),
|
||||
orderAvailable: t.orderAvailable ? 1 : 0,
|
||||
price: String(t.price ?? ''),
|
||||
syncedAt: String(t.syncedAt ?? ''),
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
for (const raw of Array.isArray(data.tariffSyncOptions) ? data.tariffSyncOptions : []) {
|
||||
const o = asObject(raw)
|
||||
const dcs = typeof o.datacenters === 'string' ? o.datacenters : JSON.stringify(o.datacenters || [])
|
||||
const pers = typeof o.periods === 'string' ? o.periods : JSON.stringify(o.periods || [])
|
||||
db.insert(schema.tariffSyncOptions)
|
||||
.values({
|
||||
providerAccountId: String(o.providerAccountId ?? ''),
|
||||
datacenters: dcs,
|
||||
periods: pers,
|
||||
syncedAt: String(o.syncedAt ?? ''),
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
for (const raw of Array.isArray(data.syncLog) ? data.syncLog : []) {
|
||||
const log = asObject(raw)
|
||||
db.insert(schema.syncLog)
|
||||
.values({
|
||||
id: String(log.id ?? ''),
|
||||
accountId: String(log.accountId ?? ''),
|
||||
startedAt: String(log.startedAt ?? ''),
|
||||
finishedAt: log.finishedAt != null ? String(log.finishedAt) : null,
|
||||
status: log.status != null ? String(log.status) : null,
|
||||
vpsCount: log.vpsCount != null ? Number(log.vpsCount) : null,
|
||||
paymentsCount: log.paymentsCount != null ? Number(log.paymentsCount) : null,
|
||||
error: log.error != null ? String(log.error) : null,
|
||||
summary:
|
||||
typeof log.summary === 'string'
|
||||
? log.summary
|
||||
: log.summary
|
||||
? JSON.stringify(log.summary)
|
||||
: null,
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
sqlite.exec('COMMIT')
|
||||
} catch (err) {
|
||||
sqlite.exec('ROLLBACK')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* BILLmanager 6 API HTTP client
|
||||
* @see https://www.ispsystem.com/docs/b6c/developer-section/working-with-api/guide-to-ispsystem-software-api
|
||||
*/
|
||||
|
||||
interface BillmanagerErrorResponse {
|
||||
error?: { msg?: string; $t?: string }
|
||||
}
|
||||
|
||||
export async function billmanagerRequest(
|
||||
baseUrl: string,
|
||||
authinfo: string,
|
||||
func: string,
|
||||
params: Record<string, string | number | undefined | null> = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const url = new URL(baseUrl)
|
||||
url.searchParams.set('authinfo', authinfo)
|
||||
url.searchParams.set('out', 'bjson')
|
||||
url.searchParams.set('func', func)
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v != null && v !== '') url.searchParams.set(k, String(v))
|
||||
}
|
||||
const res = await fetch(url.toString(), { method: 'GET' })
|
||||
if (!res.ok) {
|
||||
throw new Error(`BILLmanager API HTTP ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
const data = (await res.json()) as BillmanagerErrorResponse & Record<string, unknown>
|
||||
if (data.error) {
|
||||
throw new Error(data.error.msg || data.error.$t || 'BILLmanager API error')
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { schema } from '@cfdm/db'
|
||||
|
||||
type AccountRow = typeof schema.providerAccounts.$inferSelect
|
||||
type ProviderRow = typeof schema.providers.$inferSelect
|
||||
|
||||
export interface BillmanagerSyncAccount extends AccountRow {
|
||||
apiType: 'billmanager'
|
||||
apiBaseUrl: string
|
||||
}
|
||||
|
||||
export function resolveBillmanagerApi(
|
||||
accountRow: AccountRow | null | undefined,
|
||||
providerRow: ProviderRow | null | undefined,
|
||||
): { apiType: string; apiBaseUrl: string } {
|
||||
const apiType = String(providerRow?.apiType || accountRow?.apiType || '').trim()
|
||||
const apiBaseUrl = String(providerRow?.apiBaseUrl || accountRow?.apiBaseUrl || '').trim()
|
||||
return { apiType, apiBaseUrl }
|
||||
}
|
||||
|
||||
export function billmanagerAccountRowForSync(
|
||||
accountRow: AccountRow | null | undefined,
|
||||
providerRow: ProviderRow | null | undefined,
|
||||
): BillmanagerSyncAccount | null {
|
||||
if (!accountRow) return null
|
||||
const { apiType, apiBaseUrl } = resolveBillmanagerApi(accountRow, providerRow)
|
||||
const cred = String(accountRow.apiCredentials || '').trim()
|
||||
if (apiType !== 'billmanager' || !apiBaseUrl || !cred) return null
|
||||
return { ...accountRow, apiType: 'billmanager', apiBaseUrl }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* BILLmanager 6 API adapter
|
||||
* @see https://www.ispsystem.com/docs/b6c/developer-section/working-with-api/guide-to-ispsystem-software-api
|
||||
*/
|
||||
|
||||
export {
|
||||
testConnection,
|
||||
fetchVds,
|
||||
fetchDashboardInfo,
|
||||
fetchPayments,
|
||||
fetchVdsOrderPricelist,
|
||||
fetchVdsOrderPricelistAllDatacenters,
|
||||
} from './operations.js'
|
||||
export { syncFromBillmanager } from './sync.js'
|
||||
export { runBillmanagerAccountSync } from './sync-job.js'
|
||||
export { billmanagerAccountRowForSync, resolveBillmanagerApi } from './context.js'
|
||||
export type { BillmanagerSyncAccount } from './context.js'
|
||||
export type {
|
||||
SyncFromBillmanagerOptions,
|
||||
SyncFromBillmanagerResult,
|
||||
SyncSummary,
|
||||
} from './sync.js'
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* BILLmanager API response → vps-tracker model mappers
|
||||
*/
|
||||
|
||||
import { parsePricelist } from './parsers.js'
|
||||
|
||||
const VDS_STATUS_MAP: Record<number, string> = {
|
||||
1: 'active',
|
||||
2: 'active',
|
||||
3: 'paused',
|
||||
4: 'archived',
|
||||
5: 'active',
|
||||
}
|
||||
|
||||
export interface MappedVps {
|
||||
externalId: string
|
||||
ip: string
|
||||
dns: string
|
||||
ipv6: string
|
||||
additionalIps: string[]
|
||||
providerId: string
|
||||
providerAccountId: string
|
||||
country: string
|
||||
city: string
|
||||
datacenter: string
|
||||
os: string
|
||||
vcpu: number
|
||||
ramGb: number
|
||||
diskGb: number
|
||||
diskType: string
|
||||
virtualization: string
|
||||
bandwidthTb: number
|
||||
sshPort: number
|
||||
rootUser: string
|
||||
purpose: string
|
||||
environment: string
|
||||
project: string
|
||||
monitoringEnabled: boolean
|
||||
backupEnabled: boolean
|
||||
status: string
|
||||
tariffType: string
|
||||
currency: string
|
||||
dailyRate: null
|
||||
monthlyRate: number | null
|
||||
createdAt: string
|
||||
paidUntil: string
|
||||
notes: string
|
||||
}
|
||||
|
||||
export interface MappedPayment {
|
||||
externalId: string
|
||||
type: string
|
||||
date: string
|
||||
amount: number
|
||||
currency: string
|
||||
providerAccountId: string
|
||||
vpsId: null
|
||||
note: string
|
||||
}
|
||||
|
||||
export function mapVdsToVps(
|
||||
item: Record<string, string>,
|
||||
providerId: string,
|
||||
providerAccountId: string,
|
||||
): MappedVps {
|
||||
const status =
|
||||
VDS_STATUS_MAP[Number(item.item_status_orig ?? item.item_status)] ?? 'active'
|
||||
const costStr = String(item.cost || '').replace(/[^\d.-]/g, '')
|
||||
const cost = parseFloat(costStr) || parseFloat(item.item_cost) || 0
|
||||
const createdate = item.createdate || ''
|
||||
const expiredate = item.real_expiredate || item.expiredate || ''
|
||||
const ip = (item.ip || '').trim()
|
||||
const domain = (item.domain || '').trim()
|
||||
const datacenter = (item.datacentername || item.datacenter || '').trim()
|
||||
const ostempl = (item.ostempl || '').trim()
|
||||
const currency = (item.currency_str || 'RUB').toString().trim() || 'RUB'
|
||||
|
||||
const pricelist = item.pricelist || item.tariff || item.plan || ''
|
||||
const parsed = parsePricelist(pricelist)
|
||||
|
||||
return {
|
||||
externalId: String(item.id || ''),
|
||||
ip: ip || domain || `bm-${item.id}`,
|
||||
dns: domain || ip || '',
|
||||
ipv6: '',
|
||||
additionalIps: [],
|
||||
providerId,
|
||||
providerAccountId,
|
||||
country: '',
|
||||
city: '',
|
||||
datacenter,
|
||||
os: ostempl,
|
||||
vcpu: parsed.vcpu || 0,
|
||||
ramGb: parsed.ramGb || 0,
|
||||
diskGb: parsed.diskGb || 0,
|
||||
diskType: parsed.diskType || 'NVMe',
|
||||
virtualization: parsed.virtualization || 'KVM',
|
||||
bandwidthTb: 0,
|
||||
sshPort: 22,
|
||||
rootUser: 'root',
|
||||
purpose: '',
|
||||
environment: 'prod',
|
||||
project: '',
|
||||
monitoringEnabled: false,
|
||||
backupEnabled: false,
|
||||
status,
|
||||
tariffType: 'monthly',
|
||||
currency: currency || 'RUB',
|
||||
dailyRate: null,
|
||||
monthlyRate: cost || null,
|
||||
createdAt: createdate ? createdate.slice(0, 10) : new Date().toISOString().slice(0, 10),
|
||||
paidUntil: expiredate ? expiredate.slice(0, 10) : '',
|
||||
notes: `bm-${item.id}`,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapPaymentToPayment(
|
||||
item: Record<string, string>,
|
||||
providerAccountId: string,
|
||||
): MappedPayment | null {
|
||||
const rawAmount = item.subaccountamount_iso || item.paymethodamount_iso || '0'
|
||||
const amount = parseFloat(String(rawAmount).replace(/[^\d.-]/g, '')) || 0
|
||||
const createDate = item.create_date || item.createdate || ''
|
||||
const dateStr = createDate ? String(createDate).slice(0, 10) : new Date().toISOString().slice(0, 10)
|
||||
const statusNum = Number(item.status_orig ?? item.real_status ?? item.status)
|
||||
if (statusNum !== 4) return null
|
||||
return {
|
||||
externalId: String(item.id || ''),
|
||||
type: 'provider_balance_topup',
|
||||
date: dateStr,
|
||||
amount,
|
||||
currency:
|
||||
(String(item.subaccountamount_iso || item.paymethodamount_iso || '').match(/([A-Z]{3})\b/) || [])[1] ||
|
||||
'USD',
|
||||
providerAccountId,
|
||||
vpsId: null,
|
||||
note: `BILLmanager #${item.number || item.id}`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* BILLmanager API operations — fetch VDS, payments, dashboard, tariffs
|
||||
*/
|
||||
|
||||
import { billmanagerRequest } from './client.js'
|
||||
import {
|
||||
elemToObject,
|
||||
extractList,
|
||||
extractTariflist,
|
||||
parseDatacenterName,
|
||||
parseTariffDesc,
|
||||
} from './parsers.js'
|
||||
|
||||
export interface DashboardInfo {
|
||||
balance: number
|
||||
currency: string
|
||||
enoughmoneyto: string
|
||||
realbalance: string
|
||||
}
|
||||
|
||||
export interface TariffItem {
|
||||
externalId: string
|
||||
name: string
|
||||
desc: string
|
||||
vcpu: number
|
||||
ramGb: number
|
||||
diskGb: number
|
||||
diskType: string
|
||||
virtualization: string
|
||||
channel: string
|
||||
location: string
|
||||
cpuModel: string
|
||||
orderAvailable: boolean
|
||||
price: string
|
||||
datacenterKey?: string
|
||||
datacenterName?: string
|
||||
country?: string
|
||||
}
|
||||
|
||||
export async function fetchVds(baseUrl: string, authinfo: string): Promise<Record<string, string>[]> {
|
||||
const data = await billmanagerRequest(baseUrl, authinfo, 'vds')
|
||||
const elems = extractList(data, 'vds')
|
||||
return elems.map((e) => elemToObject(e))
|
||||
}
|
||||
|
||||
export async function fetchDashboardInfo(
|
||||
baseUrl: string,
|
||||
authinfo: string,
|
||||
opts: { fallbackCurrency?: string | null } = {},
|
||||
): Promise<DashboardInfo> {
|
||||
const data = await billmanagerRequest(baseUrl, authinfo, 'dashboard.info', {
|
||||
dashboard: 'info',
|
||||
sfrom: 'ajax',
|
||||
})
|
||||
const elems =
|
||||
extractList(data, 'dashboard') ||
|
||||
(Array.isArray(data.elem) ? (data.elem as unknown[]) : [])
|
||||
const item = elems.length > 0 ? elemToObject(elems[0] as never) : {}
|
||||
const balanceStr = String(item.realbalance || item.balance || item.available || '0')
|
||||
const amount = parseFloat(balanceStr.replace(/[^\d.,-]/g, '').replace(',', '.')) || 0
|
||||
let currency = (balanceStr.match(/([A-Z]{3})\b/) || [])[1]
|
||||
if (!currency) {
|
||||
if (balanceStr.includes('€') || balanceStr.includes('EUR')) currency = 'EUR'
|
||||
else if (balanceStr.includes('$') || balanceStr.includes('USD')) currency = 'USD'
|
||||
else if (balanceStr.includes('₽') || balanceStr.includes('RUB')) currency = 'RUB'
|
||||
else if (balanceStr.includes('£')) currency = 'GBP'
|
||||
else currency = opts.fallbackCurrency || 'RUB'
|
||||
}
|
||||
return {
|
||||
balance: amount,
|
||||
currency: currency || opts.fallbackCurrency || 'RUB',
|
||||
enoughmoneyto: item.enoughmoneyto || '',
|
||||
realbalance: item.realbalance || '',
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchPayments(
|
||||
baseUrl: string,
|
||||
authinfo: string,
|
||||
opts: {
|
||||
createdatestart?: string
|
||||
createdateend?: string
|
||||
createdate?: string
|
||||
filter?: string
|
||||
status?: string | number
|
||||
} = {},
|
||||
): Promise<Record<string, string>[]> {
|
||||
const params: Record<string, string> = {}
|
||||
if (opts.createdatestart) params.createdatestart = opts.createdatestart
|
||||
if (opts.createdateend) params.createdateend = opts.createdateend
|
||||
if (opts.createdate === 'other') params.createdate = 'other'
|
||||
if (opts.filter === 'on') params.filter = 'on'
|
||||
if (opts.status != null) params.status = String(opts.status)
|
||||
const data = await billmanagerRequest(baseUrl, authinfo, 'payment', params)
|
||||
const elems = extractList(data, 'payment')
|
||||
return elems.map((e) => elemToObject(e))
|
||||
}
|
||||
|
||||
export async function fetchVdsOrderPricelist(
|
||||
baseUrl: string,
|
||||
authinfo: string,
|
||||
opts: { plid?: string; period?: string; datacenter?: string } = {},
|
||||
): Promise<{ tariffItems: TariffItem[]; slist: Record<string, unknown> }> {
|
||||
const params: Record<string, string> = {
|
||||
plid: opts.plid || '',
|
||||
sfrom: 'ajax',
|
||||
}
|
||||
if (opts.period) params.period = opts.period
|
||||
if (opts.datacenter) params.datacenter = opts.datacenter
|
||||
|
||||
const data = await billmanagerRequest(baseUrl, authinfo, 'vds.order', params)
|
||||
|
||||
const tariflist = extractTariflist(data)
|
||||
const listNode = (data.list as Record<string, unknown>) ?? (data.doc as Record<string, unknown>)?.list
|
||||
const slist = ((listNode as Record<string, unknown>)?.slist ?? data.slist ?? {}) as Record<string, unknown>
|
||||
if (tariflist.length === 0) return { tariffItems: [], slist }
|
||||
|
||||
const tariffItems = tariflist.map((rawItem) => {
|
||||
const item = elemToObject(rawItem)
|
||||
const parsed = parseTariffDesc(item.desc || '')
|
||||
const descClean = (item.desc || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
const name = parsed.name || parsed.cpuModel || item.price?.split(' ')[0] || '—'
|
||||
return {
|
||||
externalId: String(item.pricelist || ''),
|
||||
name,
|
||||
desc: descClean,
|
||||
vcpu: parsed.vcpu,
|
||||
ramGb: parsed.ramGb,
|
||||
diskGb: parsed.diskGb,
|
||||
diskType: parsed.diskType,
|
||||
virtualization: parsed.virtualization,
|
||||
channel: parsed.channel,
|
||||
location: parsed.location,
|
||||
cpuModel: parsed.cpuModel,
|
||||
orderAvailable: (item.order_available || '').toLowerCase() === 'on',
|
||||
price: item.price || '',
|
||||
}
|
||||
})
|
||||
|
||||
return { tariffItems, slist }
|
||||
}
|
||||
|
||||
export async function fetchVdsOrderPricelistAllDatacenters(
|
||||
baseUrl: string,
|
||||
authinfo: string,
|
||||
): Promise<{ tariffItems: TariffItem[]; slist: Record<string, unknown> }> {
|
||||
const initial = await fetchVdsOrderPricelist(baseUrl, authinfo)
|
||||
const slist = initial.slist || {}
|
||||
const datacenters = Array.isArray(slist.datacenter) ? slist.datacenter : []
|
||||
|
||||
if (datacenters.length === 0) {
|
||||
return { tariffItems: initial.tariffItems, slist }
|
||||
}
|
||||
|
||||
const allTariffItems: TariffItem[] = []
|
||||
|
||||
for (let i = 0; i < datacenters.length; i++) {
|
||||
const dc = datacenters[i] as Record<string, unknown>
|
||||
const dcKey = String(dc.k ?? dc.key ?? '')
|
||||
const dcName = String(dc.v ?? dc.value ?? dc.name ?? '')
|
||||
const { country, location } = parseDatacenterName(dcName)
|
||||
|
||||
const result =
|
||||
i === 0 ? initial : await fetchVdsOrderPricelist(baseUrl, authinfo, { datacenter: dcKey })
|
||||
for (const t of result.tariffItems) {
|
||||
allTariffItems.push({
|
||||
...t,
|
||||
datacenterKey: dcKey,
|
||||
datacenterName: dcName,
|
||||
country,
|
||||
location: location || dcName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { tariffItems: allTariffItems, slist }
|
||||
}
|
||||
|
||||
export async function testConnection(
|
||||
baseUrl: string,
|
||||
authinfo: string,
|
||||
): Promise<{ ok: boolean; error?: string; vdsCount?: number }> {
|
||||
if (!baseUrl?.trim() || !authinfo?.trim()) {
|
||||
return { ok: false, error: 'Укажите URL и учётные данные' }
|
||||
}
|
||||
try {
|
||||
const items = await fetchVds(baseUrl.trim(), authinfo.trim())
|
||||
return { ok: true, vdsCount: items?.length ?? 0 }
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Ошибка подключения'
|
||||
return { ok: false, error: message }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { mapVdsToVps } from './mappers.js'
|
||||
import { parsePricelist, parseTariffDesc } from './parsers.js'
|
||||
|
||||
describe('parsePricelist', () => {
|
||||
it('parses CPU/RAM/disk from pricelist string', () => {
|
||||
expect(parsePricelist('KVM SSD Start (1 CPU/768 MB RAM/7 GB SSD)')).toEqual({
|
||||
vcpu: 1,
|
||||
ramGb: 1,
|
||||
diskGb: 7,
|
||||
diskType: 'SSD',
|
||||
virtualization: 'KVM',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseTariffDesc', () => {
|
||||
it('parses Selectel-style HTML description', () => {
|
||||
const result = parseTariffDesc('Start<br/>Процессор: 2 ядра; Память: 4 GB; Диск: 40 GB NVMe')
|
||||
expect(result.vcpu).toBe(2)
|
||||
expect(result.ramGb).toBe(4)
|
||||
expect(result.diskGb).toBe(40)
|
||||
expect(result.diskType).toBe('NVMe')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapVdsToVps', () => {
|
||||
it('maps active VDS with monthly cost', () => {
|
||||
const vps = mapVdsToVps(
|
||||
{
|
||||
id: '42',
|
||||
ip: '203.0.113.10',
|
||||
domain: 'vps.example.com',
|
||||
item_status: '2',
|
||||
cost: '500.00 RUB / Месяц',
|
||||
pricelist: 'KVM (2 CPU/2048 MB RAM/20 GB NVMe)',
|
||||
createdate: '2024-01-15',
|
||||
expiredate: '2025-01-15',
|
||||
currency_str: 'RUB',
|
||||
},
|
||||
'prov-1',
|
||||
'acc-1',
|
||||
)
|
||||
expect(vps.externalId).toBe('42')
|
||||
expect(vps.ip).toBe('203.0.113.10')
|
||||
expect(vps.status).toBe('active')
|
||||
expect(vps.monthlyRate).toBe(500)
|
||||
expect(vps.vcpu).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* BILLmanager API response parsers
|
||||
*/
|
||||
|
||||
type BillmanagerElem = Record<string, unknown> | Array<Record<string, unknown>>
|
||||
|
||||
export function extractList(data: Record<string, unknown>, key: string): BillmanagerElem[] {
|
||||
if (Array.isArray(data.elem)) return data.elem as BillmanagerElem[]
|
||||
const dataNode = data.data as Record<string, unknown> | undefined
|
||||
if (dataNode?.elem) {
|
||||
return Array.isArray(dataNode.elem) ? (dataNode.elem as BillmanagerElem[]) : [dataNode.elem as BillmanagerElem]
|
||||
}
|
||||
const doc = (data.doc as Record<string, unknown>) || data
|
||||
let list = doc[key] as Record<string, unknown> | BillmanagerElem[] | undefined
|
||||
if (!list) return []
|
||||
if (Array.isArray(list)) return list
|
||||
if (list.elem) {
|
||||
return Array.isArray(list.elem) ? (list.elem as BillmanagerElem[]) : [list.elem as BillmanagerElem]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function elemToObject(elem: BillmanagerElem | null | undefined): Record<string, string> {
|
||||
if (!elem) return {}
|
||||
if (Array.isArray(elem)) {
|
||||
const obj: Record<string, string> = {}
|
||||
for (const e of elem) {
|
||||
const name = (e.$name || e.name) as string | undefined
|
||||
const val = e.$t ?? e.$
|
||||
if (name) {
|
||||
obj[name] =
|
||||
typeof val === 'object' && val !== null
|
||||
? String((val as Record<string, unknown>).$t ?? (val as Record<string, unknown>).$ ?? JSON.stringify(val))
|
||||
: String(val ?? '')
|
||||
}
|
||||
}
|
||||
return obj
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(elem).map(([k, v]) => [k, v == null ? '' : String(v)]),
|
||||
)
|
||||
}
|
||||
|
||||
export function parsePricelist(pricelist: unknown): {
|
||||
vcpu: number
|
||||
ramGb: number
|
||||
diskGb: number
|
||||
diskType: string
|
||||
virtualization: string
|
||||
} {
|
||||
const s = String(pricelist || '')
|
||||
let vcpu = 0
|
||||
let ramGb = 0
|
||||
let diskGb = 0
|
||||
let diskType = 'NVMe'
|
||||
let virtualization = 'KVM'
|
||||
|
||||
const cpuMatch = s.match(/(\d+)\s*(?:CPU|СPU)/i)
|
||||
if (cpuMatch) vcpu = parseInt(cpuMatch[1], 10) || 0
|
||||
|
||||
const ramMbMatch = s.match(/(\d+)\s*MB\s*RAM/i)
|
||||
if (ramMbMatch) ramGb = Math.max(1, Math.round((parseInt(ramMbMatch[1], 10) || 0) / 1024))
|
||||
else {
|
||||
const ramGbMatch = s.match(/(\d+)\s*GB\s*RAM/i)
|
||||
if (ramGbMatch) ramGb = parseInt(ramGbMatch[1], 10) || 0
|
||||
}
|
||||
|
||||
const diskMatch = s.match(/(\d+)\s*GB\s*(SSD|NVMe|HDD)/i)
|
||||
if (diskMatch) {
|
||||
diskGb = parseInt(diskMatch[1], 10) || 0
|
||||
diskType = diskMatch[2] || 'NVMe'
|
||||
}
|
||||
|
||||
if (/\bKVM\b/i.test(s)) virtualization = 'KVM'
|
||||
else if (/\bOpenVZ\b/i.test(s)) virtualization = 'OpenVZ'
|
||||
else if (/\bLXC\b/i.test(s)) virtualization = 'LXC'
|
||||
|
||||
return { vcpu, ramGb, diskGb, diskType, virtualization }
|
||||
}
|
||||
|
||||
export function parseTariffDesc(desc: unknown): {
|
||||
name: string
|
||||
vcpu: number
|
||||
ramGb: number
|
||||
diskGb: number
|
||||
diskType: string
|
||||
virtualization: string
|
||||
channel: string
|
||||
location: string
|
||||
cpuModel: string
|
||||
} {
|
||||
const s = String(desc || '')
|
||||
let name = ''
|
||||
let vcpu = 0
|
||||
let ramGb = 0
|
||||
let diskGb = 0
|
||||
let diskType = 'SSD'
|
||||
let virtualization = 'KVM'
|
||||
let channel = ''
|
||||
let location = ''
|
||||
let cpuModel = ''
|
||||
|
||||
const firstLine = s.split(/\r?\n|<br\s*\/?>/i)[0]?.trim() || ''
|
||||
name = firstLine.replace(/<[^>]+>/g, '').trim()
|
||||
|
||||
const cpuMatch = s.match(/Процессор:\s*(\d+)\s*(?:ядро|ядра|ядер)/i)
|
||||
if (cpuMatch) vcpu = parseInt(cpuMatch[1], 10) || 0
|
||||
|
||||
const ramMbMatch = s.match(/Память:\s*(\d+)\s*MB/i)
|
||||
if (ramMbMatch) ramGb = Math.max(0.5, Math.round(((parseInt(ramMbMatch[1], 10) || 0) / 1024) * 10) / 10)
|
||||
else {
|
||||
const ramGbMatch = s.match(/Память:\s*(\d+)\s*GB/i)
|
||||
if (ramGbMatch) ramGb = parseInt(ramGbMatch[1], 10) || 0
|
||||
}
|
||||
|
||||
const diskMatch = s.match(/Диск:\s*(\d+)\s*GB\s*(SSD|SAS|HDD|NVMe)/i)
|
||||
if (diskMatch) {
|
||||
diskGb = parseInt(diskMatch[1], 10) || 0
|
||||
diskType = diskMatch[2] || 'SSD'
|
||||
}
|
||||
|
||||
const channelMatch = s.match(/Канал:\s*(\d+Mb\/s)/i)
|
||||
if (channelMatch) channel = channelMatch[1]
|
||||
|
||||
if (/\bKVM\b/i.test(s)) virtualization = 'KVM'
|
||||
else if (/\bOpenVZ\b/i.test(s)) virtualization = 'OpenVZ'
|
||||
else if (/\bLXC\b/i.test(s)) virtualization = 'LXC'
|
||||
|
||||
if (!channel) {
|
||||
const netMatch = s.match(/(?:Публичная сеть|Канал)[:\s]*\*?\*?(\d+)\s*Мбит/i)
|
||||
if (netMatch) channel = `${netMatch[1]} Мбит/с`
|
||||
}
|
||||
if (!location) {
|
||||
const locMatch = s.match(/Локация[:\s]*([^;]+)/i)
|
||||
if (locMatch) location = locMatch[1].replace(/<[^>]+>/g, '').trim()
|
||||
}
|
||||
if (!cpuModel) {
|
||||
const procMatch = s.match(/Процессор[:\s]*([^;]+?)(?:\s+до\s|$)/i)
|
||||
if (procMatch) cpuModel = procMatch[1].replace(/<[^>]+>/g, '').trim()
|
||||
}
|
||||
if (!diskType || diskType === 'SSD') {
|
||||
if (/\bNVMe\b/i.test(s)) diskType = 'NVMe'
|
||||
else if (/\bSAS\b/i.test(s)) diskType = 'SAS'
|
||||
else if (/\bHDD\b/i.test(s)) diskType = 'HDD'
|
||||
else if (/\bSSD\b/i.test(s)) diskType = 'SSD'
|
||||
}
|
||||
|
||||
return { name, vcpu, ramGb, diskGb, diskType, virtualization, channel, location, cpuModel }
|
||||
}
|
||||
|
||||
export function parseDatacenterName(dcName: unknown): { country: string; location: string } {
|
||||
const s = String(dcName || '').trim()
|
||||
if (!s) return { country: '', location: '' }
|
||||
|
||||
const COUNTRY_CODE_MAP: Record<string, string> = {
|
||||
DE: 'Германия',
|
||||
FI: 'Финляндия',
|
||||
RU: 'Россия',
|
||||
FR: 'Франция',
|
||||
GB: 'Великобритания',
|
||||
NL: 'Нидерланды',
|
||||
US: 'США',
|
||||
SE: 'Швеция',
|
||||
NO: 'Норвегия',
|
||||
BE: 'Бельгия',
|
||||
CH: 'Швейцария',
|
||||
CZ: 'Чехия',
|
||||
CA: 'Канада',
|
||||
LV: 'Латвия',
|
||||
LT: 'Литва',
|
||||
EE: 'Эстония',
|
||||
PL: 'Польша',
|
||||
IT: 'Италия',
|
||||
DK: 'Дания',
|
||||
AU: 'Австралия',
|
||||
ES: 'Испания',
|
||||
SG: 'Сингапур',
|
||||
}
|
||||
|
||||
const codeMatch = s.match(/\[([A-Z]{2})\]\s*([^|]+)/)
|
||||
if (codeMatch) {
|
||||
const code = codeMatch[1]
|
||||
const loc = codeMatch[2].trim()
|
||||
return { country: COUNTRY_CODE_MAP[code] || code, location: loc }
|
||||
}
|
||||
|
||||
const dcMatch = s.match(/(?:\d+\s+)?Датацентр\s+([^,]+),\s*(.+)/i)
|
||||
if (dcMatch) {
|
||||
return { country: dcMatch[1].trim(), location: dcMatch[2].trim() }
|
||||
}
|
||||
|
||||
const commaMatch = s.match(/^([^,]+),\s*(.+)$/)
|
||||
if (commaMatch) {
|
||||
return { country: commaMatch[1].trim(), location: commaMatch[2].trim() }
|
||||
}
|
||||
|
||||
const countryOnly = [
|
||||
'Россия', 'Чехия', 'Нидерланды', 'Франция', 'Великобритания', 'Германия',
|
||||
'Финляндия', 'Швеция', 'Норвегия', 'Бельгия', 'Швейцария', 'Канада',
|
||||
'Латвия', 'Литва', 'Эстония', 'Польша', 'Италия', 'Дания', 'Австралия',
|
||||
'Испания', 'Сингапур', 'США', 'Азия', 'Европа',
|
||||
]
|
||||
for (const c of countryOnly) {
|
||||
if (s === c || s.startsWith(c + ',') || s.startsWith(c + ' ')) {
|
||||
const rest = s.slice(c.length).replace(/^[,\s]+/, '')
|
||||
return { country: c, location: rest }
|
||||
}
|
||||
}
|
||||
|
||||
if (/ММТС|Adman|Москва/i.test(s)) {
|
||||
return { country: 'Россия', location: s }
|
||||
}
|
||||
if (/Европа/i.test(s)) {
|
||||
return { country: 'Европа', location: s.replace(/Европа\s*/i, '').trim() || s }
|
||||
}
|
||||
|
||||
const pipeMatch = s.match(/^([^|]+)\s*\|/)
|
||||
if (pipeMatch) {
|
||||
const part = pipeMatch[1].trim()
|
||||
for (const c of countryOnly) {
|
||||
if (part.includes(c)) return { country: c, location: '' }
|
||||
}
|
||||
return { country: part, location: '' }
|
||||
}
|
||||
|
||||
return { country: s, location: '' }
|
||||
}
|
||||
|
||||
export function extractTariflist(data: Record<string, unknown>): BillmanagerElem[] {
|
||||
if (!data) return []
|
||||
const doc = data.doc as Record<string, unknown> | undefined
|
||||
const listNode = (data.list as Record<string, unknown>) ?? doc?.list ?? doc
|
||||
if (!listNode || typeof listNode !== 'object') return []
|
||||
|
||||
let list = (listNode as Record<string, unknown>).tariflist
|
||||
?? (listNode as Record<string, unknown>).tarifflist
|
||||
?? (listNode as Record<string, unknown>).pricelist
|
||||
if (Array.isArray(list)) return list as BillmanagerElem[]
|
||||
|
||||
const elems = (listNode as Record<string, unknown>).elem
|
||||
if (elems) return Array.isArray(elems) ? (elems as BillmanagerElem[]) : [elems as BillmanagerElem]
|
||||
|
||||
return []
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Запуск синхронизации BILLmanager с записью в sync_log
|
||||
*/
|
||||
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { getDb, schema } from '@cfdm/db'
|
||||
|
||||
import type { BillmanagerSyncAccount } from './context.js'
|
||||
import { syncFromBillmanager, type SyncFromBillmanagerOptions, type SyncFromBillmanagerResult } from './sync.js'
|
||||
|
||||
export interface RunBillmanagerAccountSyncResult extends SyncFromBillmanagerResult {
|
||||
ok: true
|
||||
logId: string
|
||||
}
|
||||
|
||||
export async function runBillmanagerAccountSync(
|
||||
account: BillmanagerSyncAccount,
|
||||
opts: SyncFromBillmanagerOptions = {},
|
||||
): Promise<RunBillmanagerAccountSyncResult> {
|
||||
const db = getDb()
|
||||
const logId = `sync-${account.id}-${Date.now()}`
|
||||
|
||||
db.insert(schema.syncLog)
|
||||
.values({
|
||||
id: logId,
|
||||
accountId: account.id,
|
||||
startedAt: new Date().toISOString(),
|
||||
status: 'running',
|
||||
})
|
||||
.run()
|
||||
|
||||
try {
|
||||
const result = await syncFromBillmanager(account, opts)
|
||||
const summaryPayload = {
|
||||
...(result.syncSummary || {}),
|
||||
vpsCount: result.vpsCount,
|
||||
paymentsCount: result.paymentsCount,
|
||||
tariffsCount: result.tariffsCount ?? 0,
|
||||
}
|
||||
db.update(schema.syncLog)
|
||||
.set({
|
||||
finishedAt: new Date().toISOString(),
|
||||
status: 'ok',
|
||||
vpsCount: result.vpsCount,
|
||||
paymentsCount: result.paymentsCount,
|
||||
summary: JSON.stringify(summaryPayload),
|
||||
})
|
||||
.where(eq(schema.syncLog.id, logId))
|
||||
.run()
|
||||
return { ok: true, logId, ...result }
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
db.update(schema.syncLog)
|
||||
.set({
|
||||
finishedAt: new Date().toISOString(),
|
||||
status: 'error',
|
||||
error: message,
|
||||
summary: JSON.stringify({ error: message }),
|
||||
})
|
||||
.where(eq(schema.syncLog.id, logId))
|
||||
.run()
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* Sync BILLmanager data into vps-tracker DB (Drizzle / @cfdm/db)
|
||||
*/
|
||||
|
||||
import { and, eq, like, or } from 'drizzle-orm'
|
||||
import { getDb, schema } from '@cfdm/db'
|
||||
|
||||
import type { BillmanagerSyncAccount } from './context.js'
|
||||
import { mapPaymentToPayment, mapVdsToVps } from './mappers.js'
|
||||
import {
|
||||
fetchDashboardInfo,
|
||||
fetchPayments,
|
||||
fetchVds,
|
||||
fetchVdsOrderPricelistAllDatacenters,
|
||||
type DashboardInfo,
|
||||
type TariffItem,
|
||||
} from './operations.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
|
||||
|
||||
function normVal(v: unknown): string {
|
||||
if (v == null || v === '') return ''
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? String(v) : ''
|
||||
return String(v)
|
||||
}
|
||||
|
||||
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 fetchVpsPayments = !skipVpsPayments
|
||||
const fetchTariffs = !skipTariffs
|
||||
|
||||
const [vdsItems, paymentItems, dashboardInfo, tariffResult] = await Promise.all([
|
||||
fetchVpsPayments ? fetchVds(apiBaseUrl, authinfo) : [],
|
||||
fetchVpsPayments ? fetchPayments(apiBaseUrl, authinfo, {}) : [],
|
||||
fetchVpsPayments
|
||||
? fetchDashboardInfo(apiBaseUrl, authinfo, { fallbackCurrency: account.currency }).catch(() => null)
|
||||
: null,
|
||||
fetchTariffs
|
||||
? fetchVdsOrderPricelistAllDatacenters(apiBaseUrl, authinfo).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) {
|
||||
for (const item of vdsItems) {
|
||||
const vps = mapVdsToVps(item, providerId, accountId)
|
||||
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 = db
|
||||
.select()
|
||||
.from(schema.vps)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.vps.providerAccountId, accountId),
|
||||
or(eq(schema.vps.ip, vps.ip), like(schema.vps.notes, `%bm-${vps.externalId}%`)),
|
||||
),
|
||||
)
|
||||
.get()
|
||||
|
||||
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,
|
||||
}
|
||||
for (const f of SYNC_UPDATE_FIELDS) {
|
||||
if (userOverrides.includes(f)) {
|
||||
merged[f] = existing[f as keyof typeof existing] as never
|
||||
}
|
||||
}
|
||||
const compareFields = ['ip', 'ipv6', 'dns', ...SYNC_UPDATE_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,
|
||||
})
|
||||
.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({
|
||||
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 = mapPaymentToPayment(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({
|
||||
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 || 'RUB',
|
||||
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 }[] = []
|
||||
if (fetchTariffs) {
|
||||
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({
|
||||
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({
|
||||
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 }
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { and, desc, eq, sql } from 'drizzle-orm'
|
||||
import { getDb, schema } from '@cfdm/db'
|
||||
import { settingsRepository } from '@cfdm/db/repositories/settings'
|
||||
|
||||
import { billmanagerAccountRowForSync } from './billmanager/context.js'
|
||||
import { runBillmanagerAccountSync } from './billmanager/sync-job.js'
|
||||
import { sendTelegramMessage } from './telegram.js'
|
||||
|
||||
let syncIntervalId: ReturnType<typeof setInterval> | null = null
|
||||
let syncTariffsIntervalId: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const UPCOMING_DAYS = 7
|
||||
const SETTINGS_ID = 'settings-main'
|
||||
|
||||
type AccountRow = typeof schema.providerAccounts.$inferSelect
|
||||
type VpsRow = typeof schema.vps.$inferSelect
|
||||
type PaymentRow = typeof schema.payments.$inferSelect
|
||||
type LedgerRow = typeof schema.balanceLedger.$inferSelect
|
||||
|
||||
function getBillmanagerAccounts(): NonNullable<ReturnType<typeof billmanagerAccountRowForSync>>[] {
|
||||
const db = getDb()
|
||||
const rows = db
|
||||
.all<AccountRow>(sql`
|
||||
SELECT pa.* FROM provider_accounts pa
|
||||
INNER JOIN providers p ON p.id = pa.providerId
|
||||
WHERE lower(trim(COALESCE(p.apiType, ''))) = 'billmanager'
|
||||
AND length(trim(COALESCE(p.apiBaseUrl, ''))) > 0
|
||||
AND pa.apiCredentials IS NOT NULL AND length(trim(pa.apiCredentials)) > 0
|
||||
`)
|
||||
const providers = db.select().from(schema.providers).all()
|
||||
const providerById = new Map(providers.map((p) => [p.id, p]))
|
||||
return rows
|
||||
.map((a) => billmanagerAccountRowForSync(a, providerById.get(a.providerId)))
|
||||
.filter((a): a is NonNullable<typeof a> => a != null)
|
||||
}
|
||||
|
||||
function getAccountBalance(
|
||||
accountId: string,
|
||||
providerAccounts: AccountRow[],
|
||||
balanceLedger: LedgerRow[],
|
||||
): number {
|
||||
const account = providerAccounts.find((a) => a.id === accountId)
|
||||
if (account?.balanceApi != null && Number.isFinite(Number(account.balanceApi))) {
|
||||
return Number(account.balanceApi)
|
||||
}
|
||||
const rows = balanceLedger.filter((row) => row.providerAccountId === accountId)
|
||||
const credits = rows
|
||||
.filter((row) => row.direction === 'credit')
|
||||
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
|
||||
const debits = rows
|
||||
.filter((row) => row.direction === 'debit')
|
||||
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
|
||||
return credits - debits
|
||||
}
|
||||
|
||||
function getPaidUntilDate(
|
||||
vps: VpsRow,
|
||||
providerAccounts: AccountRow[],
|
||||
payments: PaymentRow[],
|
||||
balanceLedger: LedgerRow[],
|
||||
now: Date,
|
||||
): Date | null {
|
||||
if (vps.status !== 'active') return null
|
||||
const account = providerAccounts.find((a) => a.id === vps.providerAccountId)
|
||||
const tariffType = vps.tariffType || (Number(vps.dailyRate || 0) > 0 ? 'daily' : 'monthly')
|
||||
const isDailyBilling = tariffType === 'daily' || account?.billingMode === 'daily'
|
||||
|
||||
let paidUntilFromApi: Date | null = null
|
||||
if (vps.paidUntil) {
|
||||
const d = new Date(vps.paidUntil)
|
||||
paidUntilFromApi = Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
const isPaidUntilNextDay =
|
||||
paidUntilFromApi &&
|
||||
(() => {
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const diffDays = Math.round((paidUntilFromApi.getTime() - today.getTime()) / (24 * 60 * 60 * 1000))
|
||||
return diffDays >= 0 && diffDays <= 2
|
||||
})()
|
||||
|
||||
const shouldCalculateFromBalance = isDailyBilling || isPaidUntilNextDay
|
||||
if (!shouldCalculateFromBalance && paidUntilFromApi) return paidUntilFromApi
|
||||
|
||||
const dailyRate = Number(vps.dailyRate || 0)
|
||||
const monthlyRate = Number(vps.monthlyRate || 0)
|
||||
const burnRate = tariffType === 'daily' ? dailyRate : monthlyRate / 30
|
||||
if (!Number.isFinite(burnRate) || burnRate <= 0) return paidUntilFromApi
|
||||
|
||||
const accountBalance = getAccountBalance(vps.providerAccountId ?? '', providerAccounts, balanceLedger)
|
||||
const activeInAccount = getDb()
|
||||
.select({ id: schema.vps.id })
|
||||
.from(schema.vps)
|
||||
.where(
|
||||
and(eq(schema.vps.providerAccountId, vps.providerAccountId ?? ''), eq(schema.vps.status, 'active')),
|
||||
)
|
||||
.all().length
|
||||
const allocatedBalance = activeInAccount > 0 ? Math.max(0, accountBalance) / activeInAccount : 0
|
||||
const directPayments = payments
|
||||
.filter((p) => p.vpsId === vps.id && p.type === 'direct_vps_payment')
|
||||
.reduce((acc, p) => acc + Number(p.amount || 0), 0)
|
||||
const funds = directPayments + allocatedBalance
|
||||
const coveredDays = Math.floor(funds / burnRate)
|
||||
if (!Number.isFinite(coveredDays) || coveredDays <= 0) return paidUntilFromApi
|
||||
|
||||
const paidUntil = new Date(now)
|
||||
paidUntil.setDate(paidUntil.getDate() + coveredDays)
|
||||
return paidUntil
|
||||
}
|
||||
|
||||
async function sendPaymentExpiryNotifications(): Promise<void> {
|
||||
const settings = settingsRepository.getRow(SETTINGS_ID)
|
||||
if (
|
||||
!settings?.notifyPaymentExpiryEnabled ||
|
||||
!settings.telegramBotToken?.trim() ||
|
||||
!settings.telegramChatId?.trim()
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
const vpsList = db.select().from(schema.vps).orderBy(desc(schema.vps.createdAt)).all()
|
||||
const providerAccounts = db.select().from(schema.providerAccounts).all()
|
||||
const payments = db.select().from(schema.payments).all()
|
||||
const balanceLedger = db.select().from(schema.balanceLedger).all()
|
||||
const providers = db.select().from(schema.providers).all()
|
||||
|
||||
const now = new Date()
|
||||
const threshold = new Date(now)
|
||||
threshold.setDate(threshold.getDate() + UPCOMING_DAYS)
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
|
||||
const upcoming: { vps: VpsRow; paidUntil: Date; provider: string }[] = []
|
||||
for (const vps of vpsList) {
|
||||
if (vps.status !== 'active') continue
|
||||
const paidUntil = getPaidUntilDate(vps, providerAccounts, payments, balanceLedger, now)
|
||||
if (!paidUntil || paidUntil > threshold || paidUntil < todayStart) continue
|
||||
const provider = providers.find((p) => p.id === vps.providerId)
|
||||
upcoming.push({ vps, paidUntil, provider: provider?.name || '-' })
|
||||
}
|
||||
upcoming.sort((a, b) => a.paidUntil.getTime() - b.paidUntil.getTime())
|
||||
if (upcoming.length === 0) return
|
||||
|
||||
const lines = upcoming.slice(0, 10).map(({ vps, paidUntil, provider }) => {
|
||||
const dateStr = paidUntil.toLocaleDateString('ru-RU')
|
||||
return `• ${vps.dns || vps.ip} (${provider}) — до ${dateStr}`
|
||||
})
|
||||
const text = `⚠️ <b>Истекает оплата</b> (ближайшие ${UPCOMING_DAYS} дней):\n\n${lines.join('\n')}`
|
||||
await sendTelegramMessage(
|
||||
settings.telegramBotToken,
|
||||
settings.telegramChatId,
|
||||
text,
|
||||
settings.telegramMessageThreadId,
|
||||
)
|
||||
}
|
||||
|
||||
export async function runScheduledSync(): Promise<void> {
|
||||
try {
|
||||
const settings = settingsRepository.getRow(SETTINGS_ID)
|
||||
if (!settings?.syncEnabled) return
|
||||
|
||||
const accounts = getBillmanagerAccounts()
|
||||
const digestLines: string[] = []
|
||||
const lowBalanceLines: string[] = []
|
||||
const token = settings.telegramBotToken?.trim()
|
||||
const chatId = settings.telegramChatId?.trim()
|
||||
const canTg = Boolean(token && chatId)
|
||||
|
||||
for (const account of accounts) {
|
||||
try {
|
||||
const result = await runBillmanagerAccountSync(account, { skipTariffs: true })
|
||||
const s = result.syncSummary
|
||||
const parts: string[] = []
|
||||
if (s.added?.length) parts.push(`+${s.added.length} VPS`)
|
||||
if (s.updated?.length) parts.push(`изм. ${s.updated.length}`)
|
||||
if (result.paymentsCount) parts.push(`платежи +${result.paymentsCount}`)
|
||||
digestLines.push(`✓ ${account.name}: ${parts.length ? parts.join(', ') : 'без изменений'}`)
|
||||
|
||||
const apiBal = result.balance?.balance
|
||||
const threshold = account.balanceAlertBelow
|
||||
if (
|
||||
canTg &&
|
||||
settings.notifyLowBalanceEnabled &&
|
||||
threshold != null &&
|
||||
Number.isFinite(Number(threshold)) &&
|
||||
apiBal != null &&
|
||||
Number.isFinite(Number(apiBal)) &&
|
||||
Number(apiBal) < Number(threshold)
|
||||
) {
|
||||
const cur = result.balance?.currency || account.balanceCurrency || account.currency || ''
|
||||
lowBalanceLines.push(`• ${account.name}: ${apiBal} ${cur} (порог ${threshold})`)
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'ошибка'
|
||||
digestLines.push(`✗ ${account.name}: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (canTg && settings.notifySyncDigestEnabled && digestLines.length > 0) {
|
||||
await sendTelegramMessage(token!, chatId!, `📋 <b>Синхронизация VPS</b>\n\n${digestLines.join('\n')}`, settings.telegramMessageThreadId)
|
||||
}
|
||||
if (canTg && settings.notifyLowBalanceEnabled && lowBalanceLines.length > 0) {
|
||||
await sendTelegramMessage(token!, chatId!, `💰 <b>Низкий баланс</b>\n\n${lowBalanceLines.join('\n')}`, settings.telegramMessageThreadId)
|
||||
}
|
||||
|
||||
if (settings.notifyPaymentExpiryEnabled) {
|
||||
await sendPaymentExpiryNotifications()
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Scheduled sync error:', err instanceof Error ? err.message : err)
|
||||
}
|
||||
}
|
||||
|
||||
export async function runScheduledSyncTariffs(): Promise<void> {
|
||||
try {
|
||||
const settings = settingsRepository.getRow(SETTINGS_ID)
|
||||
if (!settings?.syncEnabled) return
|
||||
|
||||
const accounts = getBillmanagerAccounts()
|
||||
const providers = getDb().select().from(schema.providers).all()
|
||||
|
||||
for (const account of accounts) {
|
||||
try {
|
||||
const result = await runBillmanagerAccountSync(account, { skipVpsPayments: true })
|
||||
const newTariffs = result.newTariffs || []
|
||||
if (
|
||||
newTariffs.length > 0 &&
|
||||
settings.notifyNewTariffsEnabled &&
|
||||
settings.telegramBotToken?.trim() &&
|
||||
settings.telegramChatId?.trim()
|
||||
) {
|
||||
const provider = providers.find((p) => p.id === account.providerId)
|
||||
const providerName = provider?.name || account.name || '-'
|
||||
const lines = newTariffs.slice(0, 15).map((t) => `• ${t.name || '—'} — ${t.price || '—'}`)
|
||||
await sendTelegramMessage(
|
||||
settings.telegramBotToken,
|
||||
settings.telegramChatId,
|
||||
`🆕 <b>Новые тарифы</b> (${providerName}):\n\n${lines.join('\n')}`,
|
||||
settings.telegramMessageThreadId,
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Sync tariffs failed for account ${account.id}:`, err instanceof Error ? err.message : err)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Scheduled sync tariffs error:', err instanceof Error ? err.message : err)
|
||||
}
|
||||
}
|
||||
|
||||
export function startScheduler(): void {
|
||||
if (syncIntervalId) clearInterval(syncIntervalId)
|
||||
syncIntervalId = null
|
||||
if (syncTariffsIntervalId) clearInterval(syncTariffsIntervalId)
|
||||
syncTariffsIntervalId = null
|
||||
|
||||
try {
|
||||
const settings = settingsRepository.getRow(SETTINGS_ID)
|
||||
if (!settings?.syncEnabled) return
|
||||
|
||||
const interval = Math.max(15, Number(settings.syncIntervalMinutes) || 60)
|
||||
const tariffsInterval = Math.max(60, Number(settings.syncTariffsIntervalMinutes) || 1440)
|
||||
syncIntervalId = setInterval(() => void runScheduledSync(), interval * 60 * 1000)
|
||||
syncTariffsIntervalId = setInterval(() => void runScheduledSyncTariffs(), tariffsInterval * 60 * 1000)
|
||||
console.log(
|
||||
`Scheduled sync enabled: VPS/payments every ${interval} min, tariffs every ${tariffsInterval} min`,
|
||||
)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function stopScheduler(): void {
|
||||
if (syncIntervalId) clearInterval(syncIntervalId)
|
||||
syncIntervalId = null
|
||||
if (syncTariffsIntervalId) clearInterval(syncTariffsIntervalId)
|
||||
syncTariffsIntervalId = null
|
||||
}
|
||||
|
||||
export function restartScheduler(): void {
|
||||
stopScheduler()
|
||||
startScheduler()
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Telegram Bot API — отправка уведомлений
|
||||
*/
|
||||
|
||||
export async function sendTelegramMessage(
|
||||
token: string,
|
||||
chatIds: string | string[],
|
||||
text: string,
|
||||
messageThreadId?: string | number | null,
|
||||
): Promise<void> {
|
||||
if (!token?.trim() || !text?.trim()) return
|
||||
const ids = Array.isArray(chatIds)
|
||||
? chatIds
|
||||
: String(chatIds || '')
|
||||
.split(',')
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean)
|
||||
if (ids.length === 0) return
|
||||
|
||||
const threadId = messageThreadId != null && messageThreadId !== '' ? Number(messageThreadId) : null
|
||||
const payload: Record<string, unknown> = {
|
||||
text,
|
||||
parse_mode: 'HTML',
|
||||
disable_web_page_preview: true,
|
||||
}
|
||||
if (Number.isFinite(threadId)) payload.message_thread_id = threadId
|
||||
|
||||
const url = `https://api.telegram.org/bot${token.trim()}/sendMessage`
|
||||
for (const chatId of ids) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...payload, chat_id: chatId }),
|
||||
})
|
||||
const data = (await res.json().catch(() => ({}))) as { ok?: boolean; description?: string }
|
||||
if (!data.ok) {
|
||||
console.warn(`Telegram sendMessage failed for chat ${chatId}:`, data.description || res.statusText)
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.warn(`Telegram sendMessage error for chat ${chatId}:`, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -7,8 +7,7 @@ services:
|
||||
- "3001:3001"
|
||||
environment:
|
||||
PORT: "3001"
|
||||
# RUNTIME: "express" (по умолчанию, legacy) | "fastify" (новый стек)
|
||||
RUNTIME: "express"
|
||||
RUNTIME: "fastify"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import { join } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { dirname } from 'path'
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
|
||||
const dbPath = join(root, 'data', 'vps-tracker.db')
|
||||
const db = new Database(dbPath, { readonly: true })
|
||||
|
||||
console.log('path:', dbPath)
|
||||
console.log('integrity_check:', db.pragma('integrity_check', { simple: true }))
|
||||
|
||||
const tables = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
||||
.all()
|
||||
console.log('tables:', tables.map((t) => t.name).join(', '))
|
||||
|
||||
for (const t of [
|
||||
'providers',
|
||||
'provider_accounts',
|
||||
'vps',
|
||||
'payments',
|
||||
'settings',
|
||||
'sync_log',
|
||||
'active_tariffs',
|
||||
]) {
|
||||
try {
|
||||
const r = db.prepare(`SELECT COUNT(*) as c FROM ${t}`).get()
|
||||
console.log(`${t}:`, r.c)
|
||||
} catch (e) {
|
||||
console.log(`${t}: ERROR`, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
db.close()
|
||||
@@ -1,6 +1,6 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
||||
import { existsSync, mkdirSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import * as schema from './schema/index.js'
|
||||
|
||||
@@ -17,6 +17,11 @@ export function getDbPath(): string {
|
||||
|
||||
export function getDb(): Db {
|
||||
if (_db) return _db
|
||||
openDatabase()
|
||||
return _db!
|
||||
}
|
||||
|
||||
function openDatabase(): void {
|
||||
const dbPath = getDbPath()
|
||||
const dir = dirname(dbPath)
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||
@@ -24,7 +29,11 @@ export function getDb(): Db {
|
||||
_sqlite.pragma('journal_mode = WAL')
|
||||
_sqlite.pragma('foreign_keys = ON')
|
||||
_db = drizzle(_sqlite, { schema })
|
||||
return _db
|
||||
}
|
||||
|
||||
export function getSqlite(): Database.Database {
|
||||
getDb()
|
||||
return _sqlite!
|
||||
}
|
||||
|
||||
export function closeDb(): void {
|
||||
@@ -35,4 +44,16 @@ export function closeDb(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Заменить файл SQLite и переоткрыть соединение. */
|
||||
export function reloadDatabaseFromBuffer(buffer: Buffer): void {
|
||||
closeDb()
|
||||
writeFileSync(getDbPath(), buffer)
|
||||
openDatabase()
|
||||
}
|
||||
|
||||
export { schema }
|
||||
export {
|
||||
consolidateAllProviderApiSources,
|
||||
consolidateProviderApiFromAccounts,
|
||||
heuristicBillmanagerProviderApi,
|
||||
} from './maintenance/consolidate-api.js'
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import type Database from 'better-sqlite3'
|
||||
|
||||
function tryBillmgrUrl(raw: unknown): string {
|
||||
const t = String(raw || '').trim()
|
||||
if (!t || !/^https?:\/\//i.test(t) || !/billmgr/i.test(t)) return ''
|
||||
return t.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
export function consolidateProviderApiFromAccounts(sqlite: Database.Database): void {
|
||||
const provRows = sqlite.prepare('SELECT id, apiType, apiBaseUrl FROM providers').all() as {
|
||||
id: string
|
||||
apiType: string | null
|
||||
apiBaseUrl: string | null
|
||||
}[]
|
||||
|
||||
const accStmt = sqlite.prepare(
|
||||
`SELECT apiBaseUrl FROM provider_accounts
|
||||
WHERE providerId = ?
|
||||
AND length(trim(COALESCE(apiBaseUrl, ''))) > 0
|
||||
AND (
|
||||
lower(trim(COALESCE(apiType, ''))) = 'billmanager'
|
||||
OR instr(lower(trim(COALESCE(apiBaseUrl, ''))), 'billmgr') > 0
|
||||
)
|
||||
ORDER BY id`,
|
||||
)
|
||||
const updateProv = sqlite.prepare('UPDATE providers SET apiType = ?, apiBaseUrl = ? WHERE id = ?')
|
||||
const clearAcc = sqlite.prepare(
|
||||
`UPDATE provider_accounts SET apiType = '', apiBaseUrl = ''
|
||||
WHERE providerId = ?
|
||||
AND length(trim(COALESCE(apiBaseUrl, ''))) > 0
|
||||
AND (
|
||||
lower(trim(COALESCE(apiType, ''))) = 'billmanager'
|
||||
OR instr(lower(trim(COALESCE(apiBaseUrl, ''))), 'billmgr') > 0
|
||||
)`,
|
||||
)
|
||||
|
||||
for (const prov of provRows) {
|
||||
if (String(prov.apiType || '').trim() || String(prov.apiBaseUrl || '').trim()) continue
|
||||
const accRows = accStmt.all(prov.id) as { apiBaseUrl: string | null }[]
|
||||
if (!accRows.length) continue
|
||||
const urls = [...new Set(accRows.map((r) => String(r.apiBaseUrl || '').trim()).filter(Boolean))]
|
||||
if (urls.length > 1) {
|
||||
console.warn(
|
||||
`[vps-tracker] У хостера ${prov.id} у нескольких аккаунтов разный URL BILLmanager — в настройках хостера взят первый.`,
|
||||
)
|
||||
}
|
||||
updateProv.run('billmanager', urls[0], prov.id)
|
||||
clearAcc.run(prov.id)
|
||||
}
|
||||
}
|
||||
|
||||
export function heuristicBillmanagerProviderApi(sqlite: Database.Database): void {
|
||||
const provRows = sqlite.prepare('SELECT id, website, apiType, apiBaseUrl FROM providers').all() as {
|
||||
id: string
|
||||
website: string | null
|
||||
apiType: string | null
|
||||
apiBaseUrl: string | null
|
||||
}[]
|
||||
const accStmt = sqlite.prepare(
|
||||
`SELECT panelUrl FROM provider_accounts
|
||||
WHERE providerId = ? AND length(trim(COALESCE(panelUrl, ''))) > 0
|
||||
ORDER BY id`,
|
||||
)
|
||||
const updateProv = sqlite.prepare('UPDATE providers SET apiType = ?, apiBaseUrl = ? WHERE id = ?')
|
||||
|
||||
for (const prov of provRows) {
|
||||
if (String(prov.apiType || '').trim() || String(prov.apiBaseUrl || '').trim()) continue
|
||||
let url = tryBillmgrUrl(prov.website)
|
||||
if (!url) {
|
||||
const accRows = accStmt.all(prov.id) as { panelUrl: string | null }[]
|
||||
for (const row of accRows) {
|
||||
url = tryBillmgrUrl(row.panelUrl)
|
||||
if (url) break
|
||||
}
|
||||
}
|
||||
if (url) updateProv.run('billmanager', url, prov.id)
|
||||
}
|
||||
}
|
||||
|
||||
export function consolidateAllProviderApiSources(sqlite: Database.Database): void {
|
||||
consolidateProviderApiFromAccounts(sqlite)
|
||||
heuristicBillmanagerProviderApi(sqlite)
|
||||
}
|
||||
Reference in New Issue
Block a user