feat(api, web): интеграция хостера 4VPS.SU — синк VPS, баланса и тарифов
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:
Denozordec
2026-06-29 01:13:50 +07:00
co-authored by Cursor
parent 05e7bf829e
commit 9be50aa03b
33 changed files with 1564 additions and 174 deletions
+79
View File
@@ -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
View File
@@ -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)
+4 -48
View File
@@ -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)
}
+4 -3
View File
@@ -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,
)
})
})
+78
View File
@@ -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
}
+33
View File
@@ -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 }
}
+13
View File
@@ -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')
})
})
+101
View File
@@ -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}`,
}
}
+220
View File
@@ -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 }
}
}
+130
View File
@@ -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)
})
})
+293
View File
@@ -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: '',
}
},
}
+41 -22
View File
@@ -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
}
}
+5
View File
@@ -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
}>
}
+22 -12
View File
@@ -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')
})
})
@@ -14,6 +14,7 @@ import { providerAccountSchema, type ProviderAccountFormValues } from '@/lib/sch
import type { BillingMode, Provider } from '@/types/entities'
import { billingModeLabel } from '@/lib/format'
import { api, ApiError } from '@/lib/api-client'
import { accountCredentialLabels } from '@/lib/provider-sync'
const EMPTY: ProviderAccountFormValues = {
providerId: '',
@@ -59,10 +60,11 @@ export function ProviderAccountEditSheet({
onBalanceRefreshed,
}: ProviderAccountEditSheetProps) {
const isEdit = Boolean(defaultValues.id)
const initialProvider = providers.find((p) => p.id === defaultValues.providerId)
const testMut = useMutation({
mutationFn: async (values: { apiBaseUrl: string; apiCredentials: string }) =>
api.testConnection(values.apiBaseUrl, values.apiCredentials),
mutationFn: async (values: { apiBaseUrl: string; apiCredentials: string; apiType: string }) =>
api.testConnection(values.apiBaseUrl, values.apiCredentials, values.apiType),
onSuccess: () => toast.success('Подключение успешно'),
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка подключения'),
})
@@ -81,7 +83,11 @@ export function ProviderAccountEditSheet({
open={open}
onOpenChange={onOpenChange}
title={isEdit ? 'Редактировать аккаунт' : 'Новый аккаунт'}
description="API-креды хранятся на сервере и используются для синка с BILLmanager"
description={
initialProvider?.apiType === '4vps'
? 'Panel ID и API Key хранятся на сервере и используются для синка с 4VPS'
: 'API-креды хранятся на сервере и используются для синка с BILLmanager'
}
schema={providerAccountSchema as unknown as ZodType<ProviderAccountFormValues>}
defaultValues={defaultValues}
onSubmit={onSubmit}
@@ -96,6 +102,7 @@ export function ProviderAccountEditSheet({
const apiPassword = watch('apiPassword')?.trim() ?? ''
const apiCredentials = buildApiCredentials(apiLogin, apiPassword)
const canTest = Boolean(apiBaseUrl && apiCredentials)
const credLabels = accountCredentialLabels(provider?.apiType)
return (
<>
@@ -111,13 +118,18 @@ export function ProviderAccountEditSheet({
<FormField label="Название" htmlFor="acc-name" error={errors.name?.message} invalid={!!errors.name}>
<Input id="acc-name" aria-invalid={!!errors.name} {...register('name')} />
</FormField>
<FormField label="Логин API" htmlFor="acc-login">
<Input id="acc-login" autoComplete="off" {...register('apiLogin')} />
<FormField label={credLabels.loginLabel} htmlFor="acc-login">
<Input
id="acc-login"
autoComplete="off"
placeholder={credLabels.loginPlaceholder || undefined}
{...register('apiLogin')}
/>
</FormField>
<FormField
label={isEdit ? 'Пароль API (необязательно)' : 'Пароль API'}
label={isEdit ? `${credLabels.passwordLabel} (необязательно)` : credLabels.passwordLabel}
htmlFor="acc-password"
description={isEdit ? 'Оставьте пустым, чтобы сохранить существующий пароль' : undefined}
description={isEdit ? 'Оставьте пустым, чтобы сохранить существующий ключ' : undefined}
>
<Input id="acc-password" type="password" autoComplete="new-password" {...register('apiPassword')} />
</FormField>
@@ -128,7 +140,13 @@ export function ProviderAccountEditSheet({
size="sm"
disabled={!canTest}
loading={testMut.isPending}
onClick={() => testMut.mutate({ apiBaseUrl, apiCredentials })}
onClick={() =>
testMut.mutate({
apiBaseUrl,
apiCredentials,
apiType: provider?.apiType ?? 'billmanager',
})
}
>
<PlugIcon data-icon="inline-start" />
Проверить подключение
@@ -68,12 +68,25 @@ export function ProviderEditSheet({
onValueChange={(v) => setValue('apiType', (v ?? 'none') as ApiType, { shouldValidate: true })}
options={[
{ value: 'billmanager', label: 'BILLmanager' },
{ value: '4vps', label: '4VPS.SU' },
{ value: 'none', label: 'Нет' },
]}
/>
</FormField>
<FormField label="API URL" htmlFor="pr-apiurl" description="Один URL на хостера для BILLmanager">
<Input id="pr-apiurl" {...register('apiBaseUrl')} />
<FormField
label="API URL"
htmlFor="pr-apiurl"
description={
watch('apiType') === '4vps'
? 'Базовый URL API, например https://4vps.su/api'
: 'Один URL на хостера для BILLmanager'
}
>
<Input
id="pr-apiurl"
placeholder={watch('apiType') === '4vps' ? 'https://4vps.su/api' : undefined}
{...register('apiBaseUrl')}
/>
</FormField>
<div className="grid grid-cols-3 gap-3">
<FormField label="Валюта" htmlFor="pr-cur">
+4 -3
View File
@@ -5,7 +5,8 @@ import type {
BalanceLedgerRow,
} from '@/types/entities'
import { accountBalanceApi } from '@/lib/account'
import { accountBillmanagerUiReady } from '@/lib/billmanager'
import { isSyncApiType } from '@cfdm/shared/contracts/provider'
import { accountSyncUiReady } from '@/lib/provider-sync'
import {
accountHasApiLedgerMismatch,
getStaleSyncAccountIds,
@@ -40,7 +41,7 @@ export function getAccountHealthFlags(
const provider = ctx.providers.find((p) => p.id === account.providerId)
const flags: AccountHealthFlag[] = []
if (provider?.apiType === 'billmanager' && !account.apiCredentialsSet) {
if (isSyncApiType(provider?.apiType) && !account.apiCredentialsSet) {
flags.push('no-creds')
}
@@ -99,5 +100,5 @@ export function countLowBalanceAccounts(
export function isAccountSyncable(account: ProviderAccount, providers: Provider[]): boolean {
const provider = providers.find((p) => p.id === account.providerId)
return accountBillmanagerUiReady(account, provider)
return accountSyncUiReady(account, provider)
}
+2 -2
View File
@@ -101,10 +101,10 @@ export const api = {
`/api/sync/${encodeURIComponent(accountId)}/balance`,
),
testConnection: (apiBaseUrl: string, apiCredentials: string) =>
testConnection: (apiBaseUrl: string, apiCredentials: string, apiType?: string) =>
fetchApi('/api/sync/test-connection', {
method: 'POST',
body: JSON.stringify({ apiBaseUrl, apiCredentials }),
body: JSON.stringify({ apiBaseUrl, apiCredentials, apiType }),
}),
fetchSyncStatus: () => fetchApi('/api/sync/status'),
+12 -45
View File
@@ -1,45 +1,12 @@
import type { ProviderAccount, Provider } from '@/types/entities'
export function providerByIdMap(providers: Provider[]): Map<string, Provider> {
return new Map(providers.map((p) => [p.id, p]))
}
export function billmanagerSyncableAccounts(
providerAccounts: ProviderAccount[],
providers: Provider[],
): ProviderAccount[] {
const pmap = providerByIdMap(providers)
return providerAccounts.filter((a) => {
const p = pmap.get(a.providerId)
return p?.apiType === 'billmanager' && Boolean((p.apiBaseUrl || '').trim()) && a.apiCredentialsSet
})
}
export function accountBillmanagerUiReady(
account: ProviderAccount,
provider?: Provider | null,
): boolean {
return (
provider?.apiType === 'billmanager' &&
Boolean((provider.apiBaseUrl || '').trim()) &&
Boolean(account.apiCredentialsSet)
)
}
export function accountUsesBillmanagerBalanceApi(
account: ProviderAccount,
provider?: Provider | null,
): boolean {
return provider?.apiType === 'billmanager' && account.balance_api != null
}
export function accountSelectLabel(
account: ProviderAccount,
providerById: Map<string, Provider>,
scopedProviderId?: string,
): string {
const name = account.name?.trim() || '—'
if (scopedProviderId) return name
const providerName = providerById.get(account.providerId)?.name ?? '—'
return `${providerName} / ${name}`
}
export {
providerByIdMap,
syncableAccounts,
billmanagerSyncableAccounts,
accountSyncUiReady,
accountBillmanagerUiReady,
accountUsesApiBalance,
accountUsesBillmanagerBalanceApi,
accountSelectLabel,
isSyncApiType,
accountCredentialLabels,
} from './provider-sync'
+77
View File
@@ -0,0 +1,77 @@
import { isSyncApiType } from '@cfdm/shared/contracts/provider'
import type { ProviderAccount, Provider } from '@/types/entities'
export { isSyncApiType }
export function providerByIdMap(providers: Provider[]): Map<string, Provider> {
return new Map(providers.map((p) => [p.id, p]))
}
export function syncableAccounts(
providerAccounts: ProviderAccount[],
providers: Provider[],
): ProviderAccount[] {
const pmap = providerByIdMap(providers)
return providerAccounts.filter((a) => {
const p = pmap.get(a.providerId)
return isSyncApiType(p?.apiType) && Boolean((p?.apiBaseUrl || '').trim()) && a.apiCredentialsSet
})
}
/** @deprecated Используйте syncableAccounts */
export const billmanagerSyncableAccounts = syncableAccounts
export function accountSyncUiReady(
account: ProviderAccount,
provider?: Provider | null,
): boolean {
return (
isSyncApiType(provider?.apiType) &&
Boolean((provider?.apiBaseUrl || '').trim()) &&
Boolean(account.apiCredentialsSet)
)
}
/** @deprecated Используйте accountSyncUiReady */
export const accountBillmanagerUiReady = accountSyncUiReady
export function accountUsesApiBalance(
account: ProviderAccount,
provider?: Provider | null,
): boolean {
return isSyncApiType(provider?.apiType) && account.balance_api != null
}
/** @deprecated Используйте accountUsesApiBalance */
export const accountUsesBillmanagerBalanceApi = accountUsesApiBalance
export function accountSelectLabel(
account: ProviderAccount,
providerById: Map<string, Provider>,
scopedProviderId?: string,
): string {
const name = account.name?.trim() || '—'
if (scopedProviderId) return name
const providerName = providerById.get(account.providerId)?.name ?? '—'
return `${providerName} / ${name}`
}
export function accountCredentialLabels(apiType?: string | null): {
loginLabel: string
passwordLabel: string
loginPlaceholder: string
} {
if (String(apiType).toLowerCase() === '4vps') {
return {
loginLabel: 'Panel ID',
passwordLabel: 'API Key',
loginPlaceholder: '1',
}
}
return {
loginLabel: 'Логин API',
passwordLabel: 'Пароль API',
loginPlaceholder: '',
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { z } from 'zod'
import { apiTypeSchema as sharedApiTypeSchema } from '@cfdm/shared/contracts/provider'
import { billingModeSchema as sharedBillingModeSchema } from '@cfdm/shared/contracts/provider-account'
import { customFieldsSchema } from '@cfdm/shared/contracts/custom-fields'
@@ -12,7 +13,7 @@ export const paymentTypeSchema = z.enum([
'monthly_debit',
])
export const ledgerDirectionSchema = z.enum(['credit', 'debit'])
export const apiTypeSchema = z.enum(['billmanager', 'none'])
export const apiTypeSchema = sharedApiTypeSchema
export const providerSchema = z.object({
id: z.string().min(1).optional(),
+3 -1
View File
@@ -1,5 +1,7 @@
import type { CustomFieldDef } from '@cfdm/shared/contracts/custom-fields'
import type { ApiType as SharedApiType } from '@cfdm/shared/contracts/provider'
export type VpsStatus = 'active' | 'paused' | 'archived'
export type TariffType = 'daily' | 'monthly'
export type BillingMode = 'daily' | 'monthly'
@@ -9,7 +11,7 @@ export type PaymentType =
| 'daily_debit'
| 'monthly_debit'
export type LedgerDirection = 'credit' | 'debit'
export type ApiType = 'billmanager' | 'none'
export type ApiType = SharedApiType
export interface Provider {
id: string
+52
View File
@@ -37,9 +37,41 @@ CREATE TABLE IF NOT EXISTS provider_accounts (
CREATE TABLE IF NOT EXISTS vps (
id TEXT PRIMARY KEY,
ip TEXT,
ipv6 TEXT,
additionalIps TEXT,
dns TEXT,
providerId TEXT,
providerAccountId TEXT,
country TEXT,
city TEXT,
datacenter TEXT,
os TEXT,
vcpu INTEGER,
ramGb REAL,
diskGb INTEGER,
diskType TEXT,
virtualization TEXT,
bandwidthTb INTEGER,
sshPort INTEGER,
rootUser TEXT,
purpose TEXT,
environment TEXT,
project TEXT,
projectId TEXT,
monitoringEnabled INTEGER,
backupEnabled INTEGER,
status TEXT,
tariffType TEXT,
currency TEXT,
dailyRate REAL,
monthlyRate REAL,
createdAt TEXT,
paidUntil TEXT,
notes TEXT,
userOverrides TEXT,
customData TEXT,
last_health_status TEXT,
last_health_checked_at TEXT,
FOREIGN KEY (providerId) REFERENCES providers(id),
FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id)
);
@@ -75,6 +107,10 @@ CREATE TABLE IF NOT EXISTS sync_log (
startedAt TEXT NOT NULL,
finishedAt TEXT,
status TEXT,
vpsCount INTEGER,
paymentsCount INTEGER,
error TEXT,
summary TEXT,
FOREIGN KEY (accountId) REFERENCES provider_accounts(id)
);
@@ -83,6 +119,22 @@ CREATE TABLE IF NOT EXISTS active_tariffs (
providerAccountId TEXT NOT NULL,
providerId TEXT NOT NULL,
externalId TEXT NOT NULL,
datacenterKey TEXT,
datacenterName TEXT,
name TEXT,
desc TEXT,
vcpu INTEGER,
ramGb REAL,
diskGb INTEGER,
diskType TEXT,
virtualization TEXT,
channel TEXT,
location TEXT,
country TEXT,
cpuModel TEXT,
orderAvailable INTEGER,
price TEXT,
syncedAt TEXT,
FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id),
FOREIGN KEY (providerId) REFERENCES providers(id)
);
+13 -1
View File
@@ -1,5 +1,17 @@
import { z } from 'zod'
export const API_TYPES = ['billmanager', '4vps', 'none'] as const
export type ApiType = (typeof API_TYPES)[number]
export const apiTypeSchema = z.enum(API_TYPES).optional().default('none')
export const SYNC_API_TYPES = ['billmanager', '4vps'] as const
export type SyncApiType = (typeof SYNC_API_TYPES)[number]
export function isSyncApiType(apiType?: string | null): apiType is SyncApiType {
const key = String(apiType || '').toLowerCase()
return (SYNC_API_TYPES as readonly string[]).includes(key)
}
export const providerSchema = z.object({
id: z.string().optional(),
name: z.string().min(1, 'Name is required'),
@@ -9,7 +21,7 @@ export const providerSchema = z.object({
usdRate: z.string().optional().default(''),
eurRate: z.string().optional().default(''),
notes: z.string().optional().default(''),
apiType: z.string().optional().default(''),
apiType: apiTypeSchema,
apiBaseUrl: z.string().optional().default(''),
})
@@ -13,3 +13,32 @@ export function buildApiCredentials(login: string, password: string): string {
if (!l) return p
return p ? `${l}:${p}` : ''
}
export interface FourVpsCredentials {
panelId: number | null
apiKey: string
}
/** Разбор 4VPS-кредов формата `panelId:apiKey` (panelId опционален). */
export function parseFourVpsCredentials(credentials: string | null | undefined): FourVpsCredentials {
const cred = String(credentials ?? '').trim()
if (!cred) return { panelId: null, apiKey: '' }
const idx = cred.indexOf(':')
if (idx <= 0) return { panelId: null, apiKey: cred }
const panelPart = cred.slice(0, idx).trim()
const apiKey = cred.slice(idx + 1)
const panelId = panelPart ? Number.parseInt(panelPart, 10) : null
return {
panelId: panelId != null && Number.isFinite(panelId) ? panelId : null,
apiKey,
}
}
/** Собрать 4VPS-креды: panelId + API key. */
export function buildFourVpsCredentials(panelId: string, apiKey: string): string {
const pid = panelId.trim()
const key = apiKey
if (!pid && !key) return ''
if (!pid) return key
return key ? `${pid}:${key}` : pid
}
@@ -1,4 +1,5 @@
import { accountBalanceApi } from './account-balance.js'
import { isSyncApiType } from '../contracts/provider.js'
import { getPaidUntilDate, type PaidUntilContext } from './paid-until.js'
export const STALE_SYNC_HOURS = 48
@@ -166,12 +167,12 @@ export function computeInventoryHealth(input: InventoryHealthInput): InventoryIs
})
}
const bmAccounts = providerAccounts.filter((a) => {
const syncAccounts = 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 staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000
const staleAccounts = bmAccounts.filter((a) => {
const staleAccounts = syncAccounts.filter((a) => {
const t = lastOkSyncFinishedAt(a.id, syncLog)
if (t == null) return true
return now.getTime() - t > staleMs
@@ -226,12 +227,12 @@ export function getStaleSyncAccountIds(
now = new Date(),
): string[] {
const providerById = new Map(providers.map((p) => [p.id, p]))
const bmAccounts = providerAccounts.filter((a) => {
const syncAccounts = 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 staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000
return bmAccounts
return syncAccounts
.filter((a) => {
const t = lastOkSyncFinishedAt(a.id, syncLog)
if (t == null) return true
+2 -1
View File
@@ -11,5 +11,6 @@
"declarationMap": true,
"verbatimModuleSyntax": false
},
"include": ["src/**/*.ts"]
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts"]
}