feat(api, web): интеграция хостера 4VPS.SU — синк VPS, баланса и тарифов
Docker / build (push) Has been cancelled
Docker / build (push) Has been cancelled
Добавлен адаптер 4vps, обобщён pipeline синхронизации через ProviderAdapter и обновлён UI для Panel ID и API Key. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { closeDb } from '@cfdm/db'
|
||||
import { resetTestDb, seedTestProvider } from '@cfdm/db/test-setup'
|
||||
import { providerAccountsRepository } from '@cfdm/db/repositories/provider-accounts'
|
||||
import { providersRepository } from '@cfdm/db/repositories/providers'
|
||||
|
||||
import { buildApp } from '../index.js'
|
||||
|
||||
vi.mock('../services/fourvps/sync.js', () => ({
|
||||
syncFromFourvps: vi.fn().mockResolvedValue({
|
||||
vpsCount: 2,
|
||||
paymentsCount: 0,
|
||||
tariffsCount: 3,
|
||||
newTariffs: [],
|
||||
balance: { balance: 1000, currency: 'RUB' },
|
||||
syncSummary: { added: [], updated: [], paymentsAdded: 0 },
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('sync routes — 4vps', () => {
|
||||
let app: Awaited<ReturnType<typeof buildApp>>
|
||||
|
||||
beforeEach(async () => {
|
||||
resetTestDb()
|
||||
seedTestProvider('prov-4vps')
|
||||
providersRepository.update('prov-4vps', {
|
||||
apiType: '4vps',
|
||||
apiBaseUrl: 'https://4vps.su/api',
|
||||
})
|
||||
providerAccountsRepository.create({
|
||||
id: 'acc-4vps',
|
||||
providerId: 'prov-4vps',
|
||||
name: '4VPS',
|
||||
apiCredentials: '1:secret-key',
|
||||
})
|
||||
app = await buildApp()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close()
|
||||
closeDb()
|
||||
})
|
||||
|
||||
it('POST /api/sync/:accountId syncs 4vps account', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sync/acc-4vps',
|
||||
payload: {},
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
const body = res.json() as { ok?: boolean; synced?: { vpsCount?: number } }
|
||||
expect(body.ok).toBe(true)
|
||||
expect(body.synced?.vpsCount).toBe(2)
|
||||
})
|
||||
|
||||
it('POST /api/sync/test-connection uses apiType 4vps', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ error: false, data: { userBalance: 500, serverlist: [] } }),
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sync/test-connection',
|
||||
payload: {
|
||||
apiBaseUrl: 'https://4vps.su/api',
|
||||
apiCredentials: '1:key',
|
||||
apiType: '4vps',
|
||||
},
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
const body = res.json() as { ok?: boolean }
|
||||
expect(body.ok).toBe(true)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
+28
-19
@@ -5,11 +5,11 @@ import { providerAccountsRepository } from '@cfdm/db/repositories/provider-accou
|
||||
import { providersRepository } from '@cfdm/db/repositories/providers'
|
||||
|
||||
import {
|
||||
billmanagerAccountRowForSync,
|
||||
fetchDashboardInfo,
|
||||
runBillmanagerAccountSync,
|
||||
testConnection,
|
||||
} from '../services/billmanager/index.js'
|
||||
getProviderAdapter,
|
||||
resolveSyncAccount,
|
||||
SYNC_SETUP_ERRORS,
|
||||
} from '../services/providers/index.js'
|
||||
import { runAccountSync } from '../services/providers/sync-job.js'
|
||||
|
||||
interface SyncLogRow {
|
||||
id: string
|
||||
@@ -45,8 +45,9 @@ function mapSyncLog(row: typeof schema.syncLog.$inferSelect): SyncLogRow {
|
||||
}
|
||||
}
|
||||
|
||||
const BILLMANAGER_SETUP_ERROR =
|
||||
'Укажите в настройках хостера тип API BILLmanager и URL; в аккаунте — логин и пароль API'
|
||||
function setupErrorMessage(apiType: string): string {
|
||||
return SYNC_SETUP_ERRORS[apiType] ?? 'Настройте API хостера и учётные данные аккаунта'
|
||||
}
|
||||
|
||||
export const syncRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get('/api/sync/status', async () => {
|
||||
@@ -67,16 +68,18 @@ export const syncRoutes: FastifyPluginAsync = async (app) => {
|
||||
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 resolved = resolveSyncAccount(account, provider)
|
||||
if (!resolved) {
|
||||
const apiType = String(provider?.apiType || 'billmanager').toLowerCase()
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: setupErrorMessage(apiType) } })
|
||||
}
|
||||
|
||||
const onlyTariffs = Boolean(req.body?.onlyTariffs)
|
||||
const opts = onlyTariffs ? { skipVpsPayments: true } : {}
|
||||
const adapter = getProviderAdapter(resolved.apiType)
|
||||
|
||||
try {
|
||||
const result = await runBillmanagerAccountSync(syncRow, opts)
|
||||
const result = await runAccountSync(adapter, resolved.account, opts)
|
||||
return {
|
||||
ok: true,
|
||||
synced: {
|
||||
@@ -99,15 +102,19 @@ export const syncRoutes: FastifyPluginAsync = async (app) => {
|
||||
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 resolved = resolveSyncAccount(account, provider)
|
||||
if (!resolved) {
|
||||
const apiType = String(provider?.apiType || 'billmanager').toLowerCase()
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: setupErrorMessage(apiType) } })
|
||||
}
|
||||
|
||||
const adapter = getProviderAdapter(resolved.apiType)
|
||||
if (!adapter.fetchBalance) {
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Баланс через API недоступен для этого типа хостера' } })
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await fetchDashboardInfo(syncRow.apiBaseUrl, String(syncRow.apiCredentials).trim(), {
|
||||
fallbackCurrency: account.currency,
|
||||
})
|
||||
const info = await adapter.fetchBalance(resolved.account)
|
||||
getDb()
|
||||
.update(schema.providerAccounts)
|
||||
.set({
|
||||
@@ -127,15 +134,17 @@ export const syncRoutes: FastifyPluginAsync = async (app) => {
|
||||
})
|
||||
|
||||
app.post('/api/sync/test-connection', async (req, reply) => {
|
||||
const { apiBaseUrl, apiCredentials } = (req.body ?? {}) as {
|
||||
const { apiBaseUrl, apiCredentials, apiType } = (req.body ?? {}) as {
|
||||
apiBaseUrl?: string
|
||||
apiCredentials?: string
|
||||
apiType?: string
|
||||
}
|
||||
if (!apiBaseUrl?.trim() || !apiCredentials?.trim()) {
|
||||
return reply.code(400).send({ ok: false, error: 'Укажите URL и учётные данные' })
|
||||
}
|
||||
try {
|
||||
const result = await testConnection(apiBaseUrl.trim(), apiCredentials.trim())
|
||||
const adapter = getProviderAdapter(apiType || 'billmanager')
|
||||
const result = await adapter.testConnection(apiBaseUrl.trim(), apiCredentials.trim())
|
||||
return result
|
||||
} catch (err) {
|
||||
req.log.error(err)
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
* Запуск синхронизации 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'
|
||||
import type { SyncFromBillmanagerOptions, SyncFromBillmanagerResult } from './sync.js'
|
||||
import { billmanagerAdapter } from '../providers/billmanager-adapter.js'
|
||||
import { runAccountSync } from '../providers/sync-job.js'
|
||||
|
||||
export interface RunBillmanagerAccountSyncResult extends SyncFromBillmanagerResult {
|
||||
ok: true
|
||||
@@ -17,48 +16,5 @@ 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
|
||||
}
|
||||
return runAccountSync(billmanagerAdapter, account, opts)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getSnapshot } from '@cfdm/db/repositories/snapshot'
|
||||
import { countExpiringWithin7Days, countInventoryIssues } from '@cfdm/shared/utils/inventory-health'
|
||||
import { accountBalanceApi } from '@cfdm/shared/utils/account-balance'
|
||||
import { isSyncApiType } from '@cfdm/shared/contracts/provider'
|
||||
|
||||
const STALE_SYNC_HOURS = 48
|
||||
|
||||
@@ -84,11 +85,11 @@ export function computeDashboardStats(): DashboardStats {
|
||||
|
||||
const providerById = new Map(snap.providers.map((p) => [p.id, p]))
|
||||
const staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000
|
||||
const bmAccounts = snap.providerAccounts.filter((a) => {
|
||||
const syncAccounts = snap.providerAccounts.filter((a) => {
|
||||
const p = providerById.get(a.providerId)
|
||||
return p?.apiType === 'billmanager' && Boolean((p.apiBaseUrl || '').trim()) && a.apiCredentialsSet
|
||||
return isSyncApiType(p?.apiType) && Boolean((p?.apiBaseUrl || '').trim()) && a.apiCredentialsSet
|
||||
})
|
||||
const staleSyncAccountCount = bmAccounts.filter((a) => {
|
||||
const staleSyncAccountCount = syncAccounts.filter((a) => {
|
||||
const t = lastOkSyncAt(a.id, snap.syncLog)
|
||||
if (t == null) return true
|
||||
return now.getTime() - t > staleMs
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { fourvpsRequest, FourVpsApiError } from './client.js'
|
||||
|
||||
describe('fourvpsRequest', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('sends Bearer auth and panel_id query param', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ error: false, data: { userBalance: 100 } }),
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const data = await fourvpsRequest('https://4vps.su/api', 'test-key', '/userBalance', {
|
||||
panelId: 2,
|
||||
})
|
||||
|
||||
expect(data).toEqual({ userBalance: 100 })
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toContain('panel_id=2')
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer test-key')
|
||||
})
|
||||
|
||||
it('throws FourVpsApiError on API error flag', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
error: true,
|
||||
errorMessage: 'Authentication error',
|
||||
data: false,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
await expect(fourvpsRequest('https://4vps.su/api', 'bad', '/userBalance')).rejects.toThrow(
|
||||
FourVpsApiError,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 4VPS.SU REST API HTTP client
|
||||
* @see https://4vps.su/page/api
|
||||
*/
|
||||
|
||||
export interface FourVpsResponse<T = unknown> {
|
||||
error: boolean
|
||||
errorMessage?: string | Record<string, unknown>
|
||||
data: T
|
||||
}
|
||||
|
||||
export class FourVpsApiError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'FourVpsApiError'
|
||||
}
|
||||
}
|
||||
|
||||
function formatErrorMessage(errorMessage: FourVpsResponse['errorMessage']): string {
|
||||
if (!errorMessage) return '4VPS API error'
|
||||
if (typeof errorMessage === 'string') return errorMessage
|
||||
const msg = errorMessage.message
|
||||
if (typeof msg === 'string') return msg
|
||||
return JSON.stringify(errorMessage)
|
||||
}
|
||||
|
||||
function joinUrl(baseUrl: string, path: string): string {
|
||||
const base = baseUrl.replace(/\/+$/, '')
|
||||
const p = path.startsWith('/') ? path : `/${path}`
|
||||
return `${base}${p}`
|
||||
}
|
||||
|
||||
export interface FourVpsRequestOptions {
|
||||
method?: 'GET' | 'POST'
|
||||
panelId?: number | null
|
||||
body?: Record<string, string | number>
|
||||
}
|
||||
|
||||
export async function fourvpsRequest<T = unknown>(
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
path: string,
|
||||
opts: FourVpsRequestOptions = {},
|
||||
): Promise<T> {
|
||||
const { method = 'GET', panelId, body } = opts
|
||||
const url = new URL(joinUrl(baseUrl, path))
|
||||
if (panelId != null) {
|
||||
url.searchParams.set('panel_id', String(panelId))
|
||||
}
|
||||
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey.trim()}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
}
|
||||
|
||||
if (method === 'POST') {
|
||||
const payload: Record<string, string | number> = { ...(body ?? {}) }
|
||||
if (panelId != null && payload.panel_id == null) {
|
||||
payload.panel_id = panelId
|
||||
}
|
||||
init.headers = { ...init.headers, 'Content-Type': 'application/json' }
|
||||
init.body = JSON.stringify(payload)
|
||||
}
|
||||
|
||||
const res = await fetch(url.toString(), init)
|
||||
if (!res.ok) {
|
||||
throw new FourVpsApiError(`4VPS API HTTP ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
|
||||
const json = (await res.json()) as FourVpsResponse<T>
|
||||
if (json.error) {
|
||||
throw new FourVpsApiError(formatErrorMessage(json.errorMessage))
|
||||
}
|
||||
return json.data
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { schema } from '@cfdm/db'
|
||||
import { parseFourVpsCredentials } from '@cfdm/shared/utils/api-credentials'
|
||||
|
||||
type AccountRow = typeof schema.providerAccounts.$inferSelect
|
||||
type ProviderRow = typeof schema.providers.$inferSelect
|
||||
|
||||
export interface FourvpsSyncAccount extends AccountRow {
|
||||
apiType: '4vps'
|
||||
apiBaseUrl: string
|
||||
panelId: number | null
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
export function resolveFourvpsApi(
|
||||
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 fourvpsAccountRowForSync(
|
||||
accountRow: AccountRow | null | undefined,
|
||||
providerRow: ProviderRow | null | undefined,
|
||||
): FourvpsSyncAccount | null {
|
||||
if (!accountRow) return null
|
||||
const { apiType, apiBaseUrl } = resolveFourvpsApi(accountRow, providerRow)
|
||||
const cred = String(accountRow.apiCredentials || '').trim()
|
||||
const { panelId, apiKey } = parseFourVpsCredentials(cred)
|
||||
if (apiType !== '4vps' || !apiBaseUrl || !apiKey) return null
|
||||
return { ...accountRow, apiType: '4vps', apiBaseUrl, panelId, apiKey }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export { fourvpsRequest, FourVpsApiError } from './client.js'
|
||||
export type { FourvpsSyncAccount } from './context.js'
|
||||
export { fourvpsAccountRowForSync, resolveFourvpsApi } from './context.js'
|
||||
export { mapServerToVps } from './mappers.js'
|
||||
export {
|
||||
fetchDcList,
|
||||
fetchMyServers,
|
||||
fetchTarifList,
|
||||
fetchUserBalance,
|
||||
testConnection,
|
||||
} from './operations.js'
|
||||
export { syncFromFourvps } from './sync.js'
|
||||
export type { SyncFromFourvpsOptions, SyncFromFourvpsResult } from './sync.js'
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { mapServerToVps } from './mappers.js'
|
||||
import type { FourVpsServer } from './operations.js'
|
||||
|
||||
const sampleServer: FourVpsServer = {
|
||||
id: 4140,
|
||||
name: 'MyFirstServer',
|
||||
price: 420,
|
||||
dc: 7,
|
||||
image: 'Alma Linux 8',
|
||||
mem: 1,
|
||||
cpu: 1,
|
||||
disk: 10,
|
||||
ipv4: '185.143.223.29',
|
||||
status: 'active',
|
||||
tname: 'USA-cx01',
|
||||
time: 1664275501,
|
||||
expired: 1666781101,
|
||||
}
|
||||
|
||||
describe('mapServerToVps', () => {
|
||||
it('maps myservers fields to VPS model', () => {
|
||||
const dcMap = new Map([
|
||||
[7, { id: 7, dc_name: 'USA DC1', flag: 'us', cpu_name: 'E5' }],
|
||||
])
|
||||
const vps = mapServerToVps(sampleServer, 'prov-1', 'acc-1', dcMap)
|
||||
|
||||
expect(vps.externalId).toBe('4140')
|
||||
expect(vps.ip).toBe('185.143.223.29')
|
||||
expect(vps.vcpu).toBe(1)
|
||||
expect(vps.ramGb).toBe(1)
|
||||
expect(vps.diskGb).toBe(10)
|
||||
expect(vps.os).toBe('Alma Linux 8')
|
||||
expect(vps.status).toBe('active')
|
||||
expect(vps.monthlyRate).toBe(420)
|
||||
expect(vps.datacenter).toBe('USA DC1')
|
||||
expect(vps.country).toBe('US')
|
||||
expect(vps.paidUntil).toBe('2022-10-26')
|
||||
expect(vps.notes).toContain('4vps-4140')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 4VPS API response → vps-tracker model mappers
|
||||
*/
|
||||
|
||||
import type { FourVpsDatacenter, FourVpsServer } from './operations.js'
|
||||
|
||||
const STATUS_MAP: Record<string, string> = {
|
||||
active: 'active',
|
||||
paused: 'paused',
|
||||
suspended: 'paused',
|
||||
deleted: 'archived',
|
||||
}
|
||||
|
||||
function unixToIso(ts: number | null | undefined): string {
|
||||
if (ts == null || !Number.isFinite(ts) || ts <= 0) return ''
|
||||
return new Date(ts * 1000).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
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 function mapServerToVps(
|
||||
server: FourVpsServer,
|
||||
providerId: string,
|
||||
providerAccountId: string,
|
||||
dcMap: Map<number, FourVpsDatacenter>,
|
||||
): MappedVps {
|
||||
const dc = dcMap.get(server.dc)
|
||||
const datacenter = dc?.dc_name ?? String(server.dc)
|
||||
const country = (dc?.flag ?? '').toUpperCase()
|
||||
const status = STATUS_MAP[String(server.status).toLowerCase()] ?? 'active'
|
||||
const monthlyRate = Number.isFinite(server.price) ? server.price : null
|
||||
const name = String(server.name || '').trim()
|
||||
|
||||
return {
|
||||
externalId: String(server.id),
|
||||
ip: String(server.ipv4 || '').trim(),
|
||||
dns: name,
|
||||
ipv6: '',
|
||||
additionalIps: [],
|
||||
providerId,
|
||||
providerAccountId,
|
||||
country,
|
||||
city: '',
|
||||
datacenter,
|
||||
os: String(server.image || '').trim(),
|
||||
vcpu: server.cpu ?? 0,
|
||||
ramGb: server.mem ?? 0,
|
||||
diskGb: server.disk ?? 0,
|
||||
diskType: 'NVMe',
|
||||
virtualization: 'KVM',
|
||||
bandwidthTb: 0,
|
||||
sshPort: 22,
|
||||
rootUser: 'root',
|
||||
purpose: '',
|
||||
environment: '',
|
||||
project: '',
|
||||
monitoringEnabled: false,
|
||||
backupEnabled: false,
|
||||
status,
|
||||
tariffType: 'monthly',
|
||||
currency: 'RUB',
|
||||
dailyRate: null,
|
||||
monthlyRate,
|
||||
createdAt: unixToIso(server.time),
|
||||
paidUntil: unixToIso(server.expired),
|
||||
notes: name ? `${name} [4vps-${server.id}]` : `4vps-${server.id}`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* 4VPS.SU API operations
|
||||
*/
|
||||
|
||||
import { parseFourVpsCredentials } from '@cfdm/shared/utils/api-credentials'
|
||||
|
||||
import { fourvpsRequest } from './client.js'
|
||||
|
||||
export interface FourVpsServer {
|
||||
id: number
|
||||
name: string
|
||||
price: number
|
||||
dc: number
|
||||
image: string
|
||||
mem: number
|
||||
cpu: number
|
||||
disk: number
|
||||
ipv4: string
|
||||
status: string
|
||||
tname: string
|
||||
time: number
|
||||
expired: number
|
||||
autoprolong?: number
|
||||
}
|
||||
|
||||
export interface FourVpsDatacenter {
|
||||
id: number
|
||||
dc_name: string
|
||||
flag: string
|
||||
cpu_name?: string
|
||||
t_name?: string
|
||||
}
|
||||
|
||||
export interface FourVpsTariffPreset {
|
||||
id: number
|
||||
name: string
|
||||
nameFull?: string
|
||||
cpu_number: number
|
||||
ram_mib: number
|
||||
rom?: number
|
||||
commentParsed?: { price?: string | number; eth?: string }
|
||||
disks?: { size_mib?: number; tags?: { name?: string }[] }[]
|
||||
}
|
||||
|
||||
export interface FourVpsTariffItem {
|
||||
externalId: string
|
||||
datacenterKey: string
|
||||
datacenterName: string
|
||||
name: string
|
||||
desc: string
|
||||
vcpu: number
|
||||
ramGb: number
|
||||
diskGb: number
|
||||
diskType: string
|
||||
virtualization: string
|
||||
channel: string
|
||||
location: string
|
||||
country: string
|
||||
cpuModel: string
|
||||
orderAvailable: boolean
|
||||
price: string
|
||||
}
|
||||
|
||||
export interface FourVpsBalanceInfo {
|
||||
balance: number
|
||||
currency: string
|
||||
}
|
||||
|
||||
function parseCredentials(baseUrl: string, credentials: string) {
|
||||
const url = baseUrl.trim()
|
||||
const { panelId, apiKey } = parseFourVpsCredentials(credentials)
|
||||
if (!url || !apiKey) {
|
||||
throw new Error('API URL and credentials are required')
|
||||
}
|
||||
return { baseUrl: url, panelId, apiKey }
|
||||
}
|
||||
|
||||
export async function fetchUserBalance(
|
||||
baseUrl: string,
|
||||
credentials: string,
|
||||
fallbackCurrency = 'RUB',
|
||||
): Promise<FourVpsBalanceInfo> {
|
||||
const { baseUrl: url, apiKey } = parseCredentials(baseUrl, credentials)
|
||||
const data = await fourvpsRequest<{ userBalance?: number }>(url, apiKey, '/userBalance')
|
||||
const raw = data?.userBalance
|
||||
const balance = typeof raw === 'number' ? raw : Number.parseFloat(String(raw ?? ''))
|
||||
return {
|
||||
balance: Number.isFinite(balance) ? balance : 0,
|
||||
currency: fallbackCurrency || 'RUB',
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMyServers(baseUrl: string, credentials: string): Promise<FourVpsServer[]> {
|
||||
const { baseUrl: url, apiKey } = parseCredentials(baseUrl, credentials)
|
||||
const data = await fourvpsRequest<{ serverlist?: FourVpsServer[] }>(url, apiKey, '/myservers')
|
||||
return Array.isArray(data?.serverlist) ? data.serverlist : []
|
||||
}
|
||||
|
||||
export async function fetchDcList(
|
||||
baseUrl: string,
|
||||
credentials: string,
|
||||
): Promise<Map<number, FourVpsDatacenter>> {
|
||||
const { baseUrl: url, panelId, apiKey } = parseCredentials(baseUrl, credentials)
|
||||
if (panelId == null) return new Map()
|
||||
|
||||
const data = await fourvpsRequest<{ dcList?: Record<string, FourVpsDatacenter> }>(
|
||||
url,
|
||||
apiKey,
|
||||
'/getDcList',
|
||||
{ panelId },
|
||||
)
|
||||
const map = new Map<number, FourVpsDatacenter>()
|
||||
const dcList = data?.dcList ?? {}
|
||||
for (const [key, dc] of Object.entries(dcList)) {
|
||||
const id = dc?.id ?? Number.parseInt(key, 10)
|
||||
if (Number.isFinite(id)) {
|
||||
map.set(id, { ...dc, id })
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
function mapPresetToTariffItem(
|
||||
preset: FourVpsTariffPreset,
|
||||
dcId: string,
|
||||
dcName: string,
|
||||
country: string,
|
||||
cpuModel: string,
|
||||
): FourVpsTariffItem {
|
||||
const diskMib = preset.disks?.[0]?.size_mib ?? (preset.rom ?? 0)
|
||||
const diskGb = diskMib > 0 ? Math.round(diskMib / 1024) : preset.rom ?? 0
|
||||
const diskTag = preset.disks?.[0]?.tags?.find((t) => t.name)?.name ?? 'nvme'
|
||||
const priceRaw = preset.commentParsed?.price
|
||||
const price = priceRaw != null ? String(priceRaw) : ''
|
||||
|
||||
return {
|
||||
externalId: String(preset.id),
|
||||
datacenterKey: dcId,
|
||||
datacenterName: dcName,
|
||||
name: preset.nameFull || preset.name || '',
|
||||
desc: preset.commentParsed?.eth ? `Канал: ${preset.commentParsed.eth}` : '',
|
||||
vcpu: preset.cpu_number ?? 0,
|
||||
ramGb: preset.ram_mib ? Math.round((preset.ram_mib / 1024) * 10) / 10 : 0,
|
||||
diskGb: typeof diskGb === 'number' ? diskGb : 0,
|
||||
diskType: diskTag.toUpperCase(),
|
||||
virtualization: 'KVM',
|
||||
channel: preset.commentParsed?.eth ?? '',
|
||||
location: dcName,
|
||||
country,
|
||||
cpuModel,
|
||||
orderAvailable: true,
|
||||
price,
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchTarifList(
|
||||
baseUrl: string,
|
||||
credentials: string,
|
||||
): Promise<FourVpsTariffItem[]> {
|
||||
const { baseUrl: url, panelId, apiKey } = parseCredentials(baseUrl, credentials)
|
||||
if (panelId == null) return []
|
||||
|
||||
const [tarifData, dcMap] = await Promise.all([
|
||||
fourvpsRequest<{
|
||||
tarifList?: Record<
|
||||
string,
|
||||
{
|
||||
clusterInfo?: {
|
||||
id?: number
|
||||
dc_name?: string
|
||||
flag?: string
|
||||
cpu_name?: string
|
||||
presets?: number[]
|
||||
}
|
||||
presets?: Record<string, FourVpsTariffPreset>
|
||||
}
|
||||
>
|
||||
}>(url, apiKey, '/getTarifList', { panelId }),
|
||||
fetchDcList(baseUrl, credentials),
|
||||
])
|
||||
|
||||
const items: FourVpsTariffItem[] = []
|
||||
const tarifList = tarifData?.tarifList ?? {}
|
||||
|
||||
for (const [dcKey, cluster] of Object.entries(tarifList)) {
|
||||
const clusterInfo = cluster.clusterInfo ?? {}
|
||||
const dcId = String(clusterInfo.id ?? dcKey)
|
||||
const dcFromList = dcMap.get(Number(clusterInfo.id ?? dcKey))
|
||||
const dcName = clusterInfo.dc_name ?? dcFromList?.dc_name ?? ''
|
||||
const country = (clusterInfo.flag ?? dcFromList?.flag ?? '').toUpperCase()
|
||||
const cpuModel = clusterInfo.cpu_name ?? dcFromList?.cpu_name ?? ''
|
||||
const presets = cluster.presets ?? {}
|
||||
|
||||
for (const preset of Object.values(presets)) {
|
||||
if (!preset?.id) continue
|
||||
items.push(mapPresetToTariffItem(preset, dcId, dcName, country, cpuModel))
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
export async function testConnection(
|
||||
baseUrl: string,
|
||||
credentials: string,
|
||||
): Promise<{ ok: boolean; error?: string; vdsCount?: number; balance?: number }> {
|
||||
if (!baseUrl?.trim() || !credentials?.trim()) {
|
||||
return { ok: false, error: 'Укажите URL и учётные данные' }
|
||||
}
|
||||
try {
|
||||
const [balanceInfo, servers] = await Promise.all([
|
||||
fetchUserBalance(baseUrl, credentials),
|
||||
fetchMyServers(baseUrl, credentials),
|
||||
])
|
||||
return { ok: true, vdsCount: servers.length, balance: balanceInfo.balance }
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Ошибка подключения'
|
||||
return { ok: false, error: message }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { closeDb, getSqlite } from '@cfdm/db'
|
||||
import { resetTestDb } from '@cfdm/db/test-setup'
|
||||
import { providerAccountsRepository } from '@cfdm/db/repositories/provider-accounts'
|
||||
|
||||
import { syncFromFourvps } from './sync.js'
|
||||
import type { FourvpsSyncAccount } from './context.js'
|
||||
|
||||
vi.mock('./operations.js', () => ({
|
||||
fetchMyServers: vi.fn(),
|
||||
fetchUserBalance: vi.fn(),
|
||||
fetchTarifList: vi.fn(),
|
||||
fetchDcList: vi.fn(),
|
||||
}))
|
||||
|
||||
import {
|
||||
fetchDcList,
|
||||
fetchMyServers,
|
||||
fetchTarifList,
|
||||
fetchUserBalance,
|
||||
} from './operations.js'
|
||||
|
||||
const account: FourvpsSyncAccount = {
|
||||
id: 'acc-4vps',
|
||||
providerId: 'prov-4vps',
|
||||
name: '4VPS Account',
|
||||
panelUrl: '',
|
||||
currency: 'RUB',
|
||||
billingMode: 'monthly',
|
||||
notes: '',
|
||||
apiType: '4vps',
|
||||
apiBaseUrl: 'https://4vps.su/api',
|
||||
apiCredentials: '1:secret',
|
||||
panelId: 1,
|
||||
apiKey: 'secret',
|
||||
balanceApi: null,
|
||||
balanceCurrency: null,
|
||||
balanceUpdatedAt: null,
|
||||
enoughmoneyto: '',
|
||||
balanceAlertBelow: null,
|
||||
}
|
||||
|
||||
describe('syncFromFourvps', () => {
|
||||
beforeEach(() => {
|
||||
resetTestDb()
|
||||
getSqlite()
|
||||
.prepare(
|
||||
`INSERT INTO providers (id, name, apiType, apiBaseUrl) VALUES ('prov-4vps', '4VPS', '4vps', 'https://4vps.su/api')`,
|
||||
)
|
||||
.run()
|
||||
providerAccountsRepository.create({
|
||||
id: 'acc-4vps',
|
||||
providerId: 'prov-4vps',
|
||||
name: '4VPS Account',
|
||||
apiCredentials: '1:secret',
|
||||
})
|
||||
|
||||
vi.mocked(fetchMyServers).mockResolvedValue([
|
||||
{
|
||||
id: 100,
|
||||
name: 'srv1',
|
||||
price: 420,
|
||||
dc: 1,
|
||||
image: 'Debian 11',
|
||||
mem: 2,
|
||||
cpu: 2,
|
||||
disk: 20,
|
||||
ipv4: '1.2.3.4',
|
||||
status: 'active',
|
||||
tname: 'cx01',
|
||||
time: 1664275501,
|
||||
expired: 1666781101,
|
||||
},
|
||||
])
|
||||
vi.mocked(fetchUserBalance).mockResolvedValue({ balance: 77825, currency: 'RUB' })
|
||||
vi.mocked(fetchDcList).mockResolvedValue(
|
||||
new Map([[1, { id: 1, dc_name: 'AE DC1', flag: 'ae' }]]),
|
||||
)
|
||||
vi.mocked(fetchTarifList).mockResolvedValue([
|
||||
{
|
||||
externalId: '13',
|
||||
datacenterKey: '1',
|
||||
datacenterName: 'AE DC1',
|
||||
name: 'AE-cx01',
|
||||
desc: '',
|
||||
vcpu: 1,
|
||||
ramGb: 1,
|
||||
diskGb: 10,
|
||||
diskType: 'NVME',
|
||||
virtualization: 'KVM',
|
||||
channel: '1Gbit/s',
|
||||
location: 'AE DC1',
|
||||
country: 'AE',
|
||||
cpuModel: 'E5',
|
||||
orderAvailable: true,
|
||||
price: '420',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
closeDb()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('upserts VPS, balance and tariffs', async () => {
|
||||
const result = await syncFromFourvps(account)
|
||||
|
||||
expect(result.vpsCount).toBe(1)
|
||||
expect(result.paymentsCount).toBe(0)
|
||||
expect(result.tariffsCount).toBe(1)
|
||||
expect(result.balance?.balance).toBe(77825)
|
||||
|
||||
const vps = getSqlite()
|
||||
.prepare(`SELECT id, ip FROM vps WHERE providerAccountId = ?`)
|
||||
.get('acc-4vps') as { id: string; ip: string }
|
||||
expect(vps.id).toBe('vps-4vps-acc-4vps-100')
|
||||
expect(vps.ip).toBe('1.2.3.4')
|
||||
|
||||
const tariffs = getSqlite()
|
||||
.prepare(`SELECT COUNT(*) as c FROM active_tariffs WHERE providerAccountId = ?`)
|
||||
.get('acc-4vps') as { c: number }
|
||||
expect(tariffs.c).toBe(1)
|
||||
|
||||
const bal = getSqlite()
|
||||
.prepare(`SELECT balance_api FROM provider_accounts WHERE id = ?`)
|
||||
.get('acc-4vps') as { balance_api: number }
|
||||
expect(bal.balance_api).toBe(77825)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* Sync 4VPS data into vps-tracker DB (Drizzle / @cfdm/db)
|
||||
*/
|
||||
|
||||
import { and, eq, like, or } from 'drizzle-orm'
|
||||
import { getDb, schema } from '@cfdm/db'
|
||||
|
||||
import type { FourvpsSyncAccount } from './context.js'
|
||||
import { mapServerToVps } from './mappers.js'
|
||||
import {
|
||||
fetchDcList,
|
||||
fetchMyServers,
|
||||
fetchTarifList,
|
||||
fetchUserBalance,
|
||||
type FourVpsBalanceInfo,
|
||||
} from './operations.js'
|
||||
|
||||
export interface SyncFromFourvpsOptions {
|
||||
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 SyncFromFourvpsResult {
|
||||
vpsCount: number
|
||||
paymentsCount: number
|
||||
tariffsCount: number
|
||||
newTariffs: { name: string; price: string; providerId: string }[]
|
||||
balance: FourVpsBalanceInfo | 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)
|
||||
}
|
||||
|
||||
function buildCredentials(account: FourvpsSyncAccount): string {
|
||||
const { panelId, apiKey } = account
|
||||
if (panelId != null) return `${panelId}:${apiKey}`
|
||||
return apiKey
|
||||
}
|
||||
|
||||
export async function syncFromFourvps(
|
||||
account: FourvpsSyncAccount,
|
||||
opts: SyncFromFourvpsOptions = {},
|
||||
): Promise<SyncFromFourvpsResult> {
|
||||
const { skipTariffs = false, skipVpsPayments = false } = opts
|
||||
const { apiBaseUrl, providerId, id: accountId } = account
|
||||
const credentials = buildCredentials(account)
|
||||
if (!apiBaseUrl?.trim() || !account.apiKey?.trim()) {
|
||||
throw new Error('API URL and credentials are required')
|
||||
}
|
||||
const db = getDb()
|
||||
|
||||
const fetchVpsData = !skipVpsPayments
|
||||
const fetchTariffs = !skipTariffs
|
||||
|
||||
const [servers, balanceInfo, tariffItems, dcMap] = await Promise.all([
|
||||
fetchVpsData ? fetchMyServers(apiBaseUrl, credentials) : [],
|
||||
fetchVpsData
|
||||
? fetchUserBalance(apiBaseUrl, credentials, account.currency || 'RUB').catch(() => null)
|
||||
: null,
|
||||
fetchTariffs ? fetchTarifList(apiBaseUrl, credentials).catch(() => []) : [],
|
||||
fetchVpsData || fetchTariffs ? fetchDcList(apiBaseUrl, credentials).catch(() => new Map()) : new Map(),
|
||||
])
|
||||
|
||||
let vpsCount = 0
|
||||
const syncSummary: SyncSummary = { added: [], updated: [], paymentsAdded: 0 }
|
||||
|
||||
if (fetchVpsData) {
|
||||
for (const server of servers) {
|
||||
const vps = mapServerToVps(server, providerId, accountId, dcMap)
|
||||
const id = `vps-4vps-${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
|
||||
|
||||
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, `%4vps-${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 || new Date().toISOString().slice(0, 10),
|
||||
paidUntil,
|
||||
notes,
|
||||
userOverrides: '[]',
|
||||
})
|
||||
.run()
|
||||
}
|
||||
vpsCount++
|
||||
}
|
||||
}
|
||||
|
||||
if (fetchVpsData && balanceInfo) {
|
||||
db.update(schema.providerAccounts)
|
||||
.set({
|
||||
balanceApi: balanceInfo.balance,
|
||||
balanceCurrency: balanceInfo.currency || 'RUB',
|
||||
balanceUpdatedAt: new Date().toISOString(),
|
||||
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-4vps-${accountId}-${t.externalId}-${dcKey}`
|
||||
: `tariff-4vps-${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 || 'NVMe',
|
||||
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 (!fetchVpsData) {
|
||||
syncSummary.tariffsOnly = true
|
||||
}
|
||||
|
||||
return {
|
||||
vpsCount,
|
||||
paymentsCount: 0,
|
||||
tariffsCount,
|
||||
newTariffs,
|
||||
balance: balanceInfo,
|
||||
syncSummary,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { syncFromBillmanager } from '../billmanager/sync.js'
|
||||
import { testConnection as bmTestConnection, fetchDashboardInfo } from '../billmanager/operations.js'
|
||||
import type { BillmanagerSyncAccount } from '../billmanager/context.js'
|
||||
|
||||
import type { ProviderAdapter, SyncResult } from './types.js'
|
||||
|
||||
export const billmanagerAdapter: ProviderAdapter = {
|
||||
type: 'billmanager',
|
||||
|
||||
async testConnection(apiBaseUrl: string, apiCredentials: string) {
|
||||
const result = await bmTestConnection(apiBaseUrl, apiCredentials)
|
||||
return { ok: result.ok, message: result.error }
|
||||
},
|
||||
|
||||
async syncAccount(
|
||||
account: BillmanagerSyncAccount,
|
||||
options?: { skipTariffs?: boolean; skipVpsPayments?: boolean },
|
||||
): Promise<SyncResult> {
|
||||
const result = await syncFromBillmanager(account, options)
|
||||
return {
|
||||
vpsCount: result.vpsCount,
|
||||
paymentsCount: result.paymentsCount,
|
||||
tariffsCount: result.tariffsCount,
|
||||
balance: result.balance,
|
||||
syncSummary: result.syncSummary,
|
||||
newTariffs: result.newTariffs,
|
||||
}
|
||||
},
|
||||
|
||||
async fetchBalance(account: BillmanagerSyncAccount) {
|
||||
const info = await fetchDashboardInfo(account.apiBaseUrl, String(account.apiCredentials).trim(), {
|
||||
fallbackCurrency: account.currency,
|
||||
})
|
||||
return {
|
||||
balance: info.balance,
|
||||
currency: info.currency || 'RUB',
|
||||
enoughmoneyto: info.enoughmoneyto || '',
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { syncFromFourvps } from '../fourvps/sync.js'
|
||||
import {
|
||||
fetchUserBalance as fetchFourvpsBalance,
|
||||
testConnection as fourvpsTestConnection,
|
||||
} from '../fourvps/operations.js'
|
||||
import type { FourvpsSyncAccount } from '../fourvps/context.js'
|
||||
|
||||
import type { ProviderAdapter, SyncResult } from './types.js'
|
||||
|
||||
export const fourvpsAdapter: ProviderAdapter = {
|
||||
type: '4vps',
|
||||
|
||||
async testConnection(apiBaseUrl: string, apiCredentials: string) {
|
||||
const result = await fourvpsTestConnection(apiBaseUrl, apiCredentials)
|
||||
return { ok: result.ok, message: result.error }
|
||||
},
|
||||
|
||||
async syncAccount(
|
||||
account: FourvpsSyncAccount,
|
||||
options?: { skipTariffs?: boolean; skipVpsPayments?: boolean },
|
||||
): Promise<SyncResult> {
|
||||
const result = await syncFromFourvps(account, options)
|
||||
return {
|
||||
vpsCount: result.vpsCount,
|
||||
paymentsCount: result.paymentsCount,
|
||||
tariffsCount: result.tariffsCount,
|
||||
balance: result.balance,
|
||||
syncSummary: result.syncSummary,
|
||||
newTariffs: result.newTariffs,
|
||||
}
|
||||
},
|
||||
|
||||
async fetchBalance(account: FourvpsSyncAccount) {
|
||||
const cred =
|
||||
account.panelId != null ? `${account.panelId}:${account.apiKey}` : account.apiKey
|
||||
const info = await fetchFourvpsBalance(account.apiBaseUrl, cred, account.currency || 'RUB')
|
||||
return {
|
||||
balance: info.balance,
|
||||
currency: info.currency || 'RUB',
|
||||
enoughmoneyto: '',
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,28 +1,16 @@
|
||||
import type { ProviderAdapter, SyncResult } from './types.js'
|
||||
import { syncFromBillmanager } from '../billmanager/sync.js'
|
||||
import type { schema } from '@cfdm/db'
|
||||
|
||||
import { billmanagerAccountRowForSync } from '../billmanager/context.js'
|
||||
import { fourvpsAccountRowForSync } from '../fourvps/context.js'
|
||||
import type { BillmanagerSyncAccount } from '../billmanager/context.js'
|
||||
import { testConnection as bmTestConnection } from '../billmanager/operations.js'
|
||||
import type { FourvpsSyncAccount } from '../fourvps/context.js'
|
||||
|
||||
export const billmanagerAdapter: ProviderAdapter = {
|
||||
type: 'billmanager',
|
||||
import { billmanagerAdapter } from './billmanager-adapter.js'
|
||||
import { fourvpsAdapter } from './fourvps-adapter.js'
|
||||
import type { ProviderAdapter } from './types.js'
|
||||
|
||||
async testConnection(apiBaseUrl: string, apiCredentials: string) {
|
||||
const result = await bmTestConnection(apiBaseUrl, apiCredentials)
|
||||
return { ok: result.ok, message: result.error }
|
||||
},
|
||||
|
||||
async syncAccount(account: BillmanagerSyncAccount, options?: { skipTariffs?: boolean; skipVpsPayments?: boolean }): Promise<SyncResult> {
|
||||
const result = await syncFromBillmanager(account, options)
|
||||
return {
|
||||
vpsCount: result.vpsCount,
|
||||
paymentsCount: result.paymentsCount,
|
||||
tariffsCount: result.tariffsCount,
|
||||
balance: result.balance,
|
||||
syncSummary: result.syncSummary,
|
||||
newTariffs: result.newTariffs,
|
||||
}
|
||||
},
|
||||
}
|
||||
export { billmanagerAdapter } from './billmanager-adapter.js'
|
||||
export { fourvpsAdapter } from './fourvps-adapter.js'
|
||||
|
||||
export const manualAdapter: ProviderAdapter = {
|
||||
type: 'manual',
|
||||
@@ -43,6 +31,7 @@ export const manualAdapter: ProviderAdapter = {
|
||||
|
||||
const adapters: Record<string, ProviderAdapter> = {
|
||||
billmanager: billmanagerAdapter,
|
||||
'4vps': fourvpsAdapter,
|
||||
manual: manualAdapter,
|
||||
none: manualAdapter,
|
||||
}
|
||||
@@ -51,3 +40,33 @@ export function getProviderAdapter(apiType: string | null | undefined): Provider
|
||||
const key = (apiType || 'none').toLowerCase().trim()
|
||||
return adapters[key] ?? manualAdapter
|
||||
}
|
||||
|
||||
type AccountRow = typeof schema.providerAccounts.$inferSelect
|
||||
type ProviderRow = typeof schema.providers.$inferSelect
|
||||
|
||||
export type SyncReadyAccount = BillmanagerSyncAccount | FourvpsSyncAccount
|
||||
|
||||
export function resolveSyncAccount(
|
||||
accountRow: AccountRow | null | undefined,
|
||||
providerRow: ProviderRow | null | undefined,
|
||||
): { apiType: string; account: SyncReadyAccount } | null {
|
||||
if (!accountRow) return null
|
||||
const apiType = String(providerRow?.apiType || accountRow.apiType || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
|
||||
if (apiType === 'billmanager') {
|
||||
const account = billmanagerAccountRowForSync(accountRow, providerRow)
|
||||
return account ? { apiType, account } : null
|
||||
}
|
||||
if (apiType === '4vps') {
|
||||
const account = fourvpsAccountRowForSync(accountRow, providerRow)
|
||||
return account ? { apiType, account } : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const SYNC_SETUP_ERRORS: Record<string, string> = {
|
||||
billmanager: 'Укажите в настройках хостера тип API BILLmanager и URL; в аккаунте — логин и пароль API',
|
||||
'4vps': 'Укажите в настройках хостера тип API 4VPS и URL; в аккаунте — Panel ID и API Key',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Generic account sync with sync_log recording
|
||||
*/
|
||||
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { getDb, schema } from '@cfdm/db'
|
||||
|
||||
import type { ProviderAdapter, SyncResult } from './types.js'
|
||||
|
||||
export interface RunAccountSyncResult extends SyncResult {
|
||||
ok: true
|
||||
logId: string
|
||||
}
|
||||
|
||||
export async function runAccountSync(
|
||||
adapter: ProviderAdapter,
|
||||
account: unknown,
|
||||
opts: { skipTariffs?: boolean; skipVpsPayments?: boolean } = {},
|
||||
): Promise<RunAccountSyncResult> {
|
||||
const db = getDb()
|
||||
const accountRow = account as { id: string }
|
||||
const logId = `sync-${accountRow.id}-${Date.now()}`
|
||||
|
||||
db.insert(schema.syncLog)
|
||||
.values({
|
||||
id: logId,
|
||||
accountId: accountRow.id,
|
||||
startedAt: new Date().toISOString(),
|
||||
status: 'running',
|
||||
})
|
||||
.run()
|
||||
|
||||
try {
|
||||
const result = await adapter.syncAccount(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
|
||||
}
|
||||
}
|
||||
@@ -21,4 +21,9 @@ export interface ProviderAdapter {
|
||||
account: unknown,
|
||||
options?: { skipTariffs?: boolean; skipVpsPayments?: boolean },
|
||||
): Promise<SyncResult>
|
||||
fetchBalance?(account: unknown): Promise<{
|
||||
balance?: number
|
||||
currency?: string
|
||||
enoughmoneyto?: string
|
||||
}>
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import { 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 { resolveSyncAccount, getProviderAdapter, type SyncReadyAccount } from './providers/index.js'
|
||||
import { runAccountSync } from './providers/sync-job.js'
|
||||
import { runVpsUptimeChecks } from './uptime-check.js'
|
||||
import { publishMany, publishNotification } from './notifications/engine.js'
|
||||
import {
|
||||
@@ -23,21 +23,29 @@ const SETTINGS_ID = 'settings-main'
|
||||
|
||||
type AccountRow = typeof schema.providerAccounts.$inferSelect
|
||||
|
||||
function getBillmanagerAccounts(): NonNullable<ReturnType<typeof billmanagerAccountRowForSync>>[] {
|
||||
interface SyncableAccountEntry {
|
||||
account: SyncReadyAccount
|
||||
apiType: string
|
||||
}
|
||||
|
||||
function getSyncableAccounts(): SyncableAccountEntry[] {
|
||||
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'
|
||||
WHERE lower(trim(COALESCE(p.apiType, ''))) IN ('billmanager', '4vps')
|
||||
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)
|
||||
.map((a) => {
|
||||
const resolved = resolveSyncAccount(a, providerById.get(a.providerId))
|
||||
return resolved ? { account: resolved.account, apiType: resolved.apiType } : null
|
||||
})
|
||||
.filter((e): e is SyncableAccountEntry => e != null)
|
||||
}
|
||||
|
||||
export async function runNotificationTick(): Promise<void> {
|
||||
@@ -56,13 +64,14 @@ export async function runScheduledSync(): Promise<void> {
|
||||
const settings = settingsRepository.getRow(SETTINGS_ID)
|
||||
if (!settings?.syncEnabled) return
|
||||
|
||||
const accounts = getBillmanagerAccounts()
|
||||
const entries = getSyncableAccounts()
|
||||
const digestLines: string[] = []
|
||||
const lowBalanceLines: string[] = []
|
||||
|
||||
for (const account of accounts) {
|
||||
for (const { account, apiType } of entries) {
|
||||
try {
|
||||
const result = await runBillmanagerAccountSync(account, { skipTariffs: true })
|
||||
const adapter = getProviderAdapter(apiType)
|
||||
const result = await runAccountSync(adapter, account, { skipTariffs: true })
|
||||
const s = result.syncSummary
|
||||
const parts: string[] = []
|
||||
if (s.added?.length) parts.push(`+${s.added.length} VPS`)
|
||||
@@ -103,12 +112,13 @@ export async function runScheduledSyncTariffs(): Promise<void> {
|
||||
const settings = settingsRepository.getRow(SETTINGS_ID)
|
||||
if (!settings?.syncEnabled) return
|
||||
|
||||
const accounts = getBillmanagerAccounts()
|
||||
const entries = getSyncableAccounts()
|
||||
const providers = getDb().select().from(schema.providers).all()
|
||||
|
||||
for (const account of accounts) {
|
||||
for (const { account, apiType } of entries) {
|
||||
try {
|
||||
const result = await runBillmanagerAccountSync(account, { skipVpsPayments: true })
|
||||
const adapter = getProviderAdapter(apiType)
|
||||
const result = await runAccountSync(adapter, account, { skipVpsPayments: true })
|
||||
const newTariffs = result.newTariffs || []
|
||||
if (newTariffs.length > 0 && settings.notifyNewTariffsEnabled) {
|
||||
const provider = providers.find((p) => p.id === account.providerId)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildFourVpsCredentials,
|
||||
parseFourVpsCredentials,
|
||||
} from '@cfdm/shared/utils/api-credentials'
|
||||
|
||||
describe('parseFourVpsCredentials', () => {
|
||||
it('parses panelId:apiKey', () => {
|
||||
expect(parseFourVpsCredentials('1:secret-key')).toEqual({
|
||||
panelId: 1,
|
||||
apiKey: 'secret-key',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns apiKey only when no colon', () => {
|
||||
expect(parseFourVpsCredentials('secret-only')).toEqual({
|
||||
panelId: null,
|
||||
apiKey: 'secret-only',
|
||||
})
|
||||
})
|
||||
|
||||
it('handles empty', () => {
|
||||
expect(parseFourVpsCredentials('')).toEqual({ panelId: null, apiKey: '' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildFourVpsCredentials', () => {
|
||||
it('builds panelId:apiKey', () => {
|
||||
expect(buildFourVpsCredentials('1', 'key')).toBe('1:key')
|
||||
})
|
||||
|
||||
it('returns key only without panel', () => {
|
||||
expect(buildFourVpsCredentials('', 'key')).toBe('key')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user