From 9a29d88b95851bb75e7b5ae66e3f700fac931726 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 29 Jun 2026 14:10:15 +0700 Subject: [PATCH] =?UTF-8?q?feat(api,=20web):=20=D0=B8=D0=BD=D1=82=D0=B5?= =?UTF-8?q?=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20RuVDS=20API=20v2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Синк VPS, баланса, платежей и тарифов через Bearer-токен из личного кабинета. Co-authored-by: Cursor --- apps/api/src/routes/sync.test.ts | 71 ++++ apps/api/src/services/providers/index.ts | 11 + .../src/services/providers/ruvds-adapter.ts | 45 +++ apps/api/src/services/ruvds/client.test.ts | 41 ++ apps/api/src/services/ruvds/client.ts | 104 +++++ apps/api/src/services/ruvds/context.ts | 42 ++ apps/api/src/services/ruvds/index.ts | 11 + apps/api/src/services/ruvds/mappers.test.ts | 69 ++++ apps/api/src/services/ruvds/mappers.ts | 224 +++++++++++ apps/api/src/services/ruvds/operations.ts | 229 +++++++++++ apps/api/src/services/ruvds/sync.test.ts | 160 ++++++++ apps/api/src/services/ruvds/sync.ts | 361 ++++++++++++++++++ apps/api/src/services/ruvds/types.ts | 115 ++++++ apps/api/src/services/scheduler.ts | 2 +- .../components/domain/account-edit-sheet.tsx | 16 +- .../components/domain/provider-edit-sheet.tsx | 7 +- apps/web/src/lib/provider-sync.ts | 11 +- packages/shared/src/contracts/provider.ts | 14 +- packages/shared/src/utils/api-credentials.ts | 5 + 19 files changed, 1526 insertions(+), 12 deletions(-) create mode 100644 apps/api/src/services/providers/ruvds-adapter.ts create mode 100644 apps/api/src/services/ruvds/client.test.ts create mode 100644 apps/api/src/services/ruvds/client.ts create mode 100644 apps/api/src/services/ruvds/context.ts create mode 100644 apps/api/src/services/ruvds/index.ts create mode 100644 apps/api/src/services/ruvds/mappers.test.ts create mode 100644 apps/api/src/services/ruvds/mappers.ts create mode 100644 apps/api/src/services/ruvds/operations.ts create mode 100644 apps/api/src/services/ruvds/sync.test.ts create mode 100644 apps/api/src/services/ruvds/sync.ts create mode 100644 apps/api/src/services/ruvds/types.ts diff --git a/apps/api/src/routes/sync.test.ts b/apps/api/src/routes/sync.test.ts index c813799..158d81b 100644 --- a/apps/api/src/routes/sync.test.ts +++ b/apps/api/src/routes/sync.test.ts @@ -39,6 +39,17 @@ vi.mock('../services/veesp/sync.js', () => ({ }), })) +vi.mock('../services/ruvds/sync.js', () => ({ + syncFromRuvds: vi.fn().mockResolvedValue({ + vpsCount: 3, + paymentsCount: 2, + tariffsCount: 2, + newTariffs: [], + balance: { balance: 2000, currency: 'RUB', enoughmoneyto: '' }, + syncSummary: { added: [], updated: [], paymentsAdded: 2 }, + }), +})) + describe('sync routes — 4vps', () => { let app: Awaited> @@ -277,3 +288,63 @@ describe('sync routes — veesp', () => { vi.unstubAllGlobals() }) }) + +describe('sync routes — ruvds', () => { + let app: Awaited> + + beforeEach(async () => { + resetTestDb() + seedTestProvider('prov-ruvds') + providersRepository.update('prov-ruvds', { + apiType: 'ruvds', + apiBaseUrl: 'https://api.ruvds.com', + }) + providerAccountsRepository.create({ + id: 'acc-ruvds', + providerId: 'prov-ruvds', + name: 'RuVDS', + apiCredentials: 'apiv2.test-token', + }) + app = await buildApp() + }) + + afterEach(async () => { + await app.close() + closeDb() + }) + + it('POST /api/sync/:accountId syncs ruvds account', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/sync/acc-ruvds', + 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(3) + }) + + it('POST /api/sync/test-connection uses apiType ruvds', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ amount: 100, currency: 1, type: 'default' }), + }), + ) + + const res = await app.inject({ + method: 'POST', + url: '/api/sync/test-connection', + payload: { + apiBaseUrl: 'https://api.ruvds.com', + apiCredentials: 'apiv2.test-token', + apiType: 'ruvds', + }, + }) + expect(res.statusCode).toBe(200) + expect((res.json() as { ok?: boolean }).ok).toBe(true) + vi.unstubAllGlobals() + }) +}) diff --git a/apps/api/src/services/providers/index.ts b/apps/api/src/services/providers/index.ts index 2a24514..46fd79c 100644 --- a/apps/api/src/services/providers/index.ts +++ b/apps/api/src/services/providers/index.ts @@ -4,21 +4,25 @@ import { billmanagerAccountRowForSync } from '../billmanager/context.js' import { fourvpsAccountRowForSync } from '../fourvps/context.js' import { userApiAccountRowForSync } from '../userapi/context.js' import { veespAccountRowForSync } from '../veesp/context.js' +import { ruvdsAccountRowForSync } from '../ruvds/context.js' import type { BillmanagerSyncAccount } from '../billmanager/context.js' import type { FourvpsSyncAccount } from '../fourvps/context.js' import type { UserApiSyncAccount } from '../userapi/context.js' import type { VeespSyncAccount } from '../veesp/context.js' +import type { RuvdsSyncAccount } from '../ruvds/context.js' import { billmanagerAdapter } from './billmanager-adapter.js' import { fourvpsAdapter } from './fourvps-adapter.js' import { userapiAdapter } from './userapi-adapter.js' import { veespAdapter } from './veesp-adapter.js' +import { ruvdsAdapter } from './ruvds-adapter.js' import type { ProviderAdapter } from './types.js' export { billmanagerAdapter } from './billmanager-adapter.js' export { fourvpsAdapter } from './fourvps-adapter.js' export { userapiAdapter } from './userapi-adapter.js' export { veespAdapter } from './veesp-adapter.js' +export { ruvdsAdapter } from './ruvds-adapter.js' export const manualAdapter: ProviderAdapter = { type: 'manual', @@ -43,6 +47,7 @@ const adapters: Record = { macloud: userapiAdapter, vdsina: userapiAdapter, veesp: veespAdapter, + ruvds: ruvdsAdapter, manual: manualAdapter, none: manualAdapter, } @@ -60,6 +65,7 @@ export type SyncReadyAccount = | FourvpsSyncAccount | UserApiSyncAccount | VeespSyncAccount + | RuvdsSyncAccount export function resolveSyncAccount( accountRow: AccountRow | null | undefined, @@ -86,6 +92,10 @@ export function resolveSyncAccount( const account = veespAccountRowForSync(accountRow, providerRow) return account ? { apiType, account } : null } + if (apiType === 'ruvds') { + const account = ruvdsAccountRowForSync(accountRow, providerRow) + return account ? { apiType, account } : null + } return null } @@ -95,4 +105,5 @@ export const SYNC_SETUP_ERRORS: Record = { macloud: 'Укажите тип API Маклауд и URL; в аккаунте — API Token', vdsina: 'Укажите тип API VDSina и URL; в аккаунте — API Token', veesp: 'Укажите тип API Veesp и URL; в аккаунте — email и пароль client area', + ruvds: 'Укажите тип API RuVDS и URL; в аккаунте — API-токен из настроек ЛК', } diff --git a/apps/api/src/services/providers/ruvds-adapter.ts b/apps/api/src/services/providers/ruvds-adapter.ts new file mode 100644 index 0000000..666aabd --- /dev/null +++ b/apps/api/src/services/providers/ruvds-adapter.ts @@ -0,0 +1,45 @@ +import { syncFromRuvds } from '../ruvds/sync.js' +import { fetchBalance, testConnection } from '../ruvds/operations.js' +import type { RuvdsSyncAccount } from '../ruvds/context.js' +import { ruvdsCredentialsString } from '../ruvds/context.js' +import { syncFallbackCurrency } from '@cfdm/shared/utils/account-balance' + +import type { ProviderAdapter, SyncResult } from './types.js' + +export const ruvdsAdapter: ProviderAdapter = { + type: 'ruvds', + + async testConnection(apiBaseUrl: string, apiCredentials: string) { + const result = await testConnection(apiBaseUrl, apiCredentials) + return { ok: result.ok, message: result.error } + }, + + async syncAccount( + account: RuvdsSyncAccount, + options?: { skipTariffs?: boolean; skipVpsPayments?: boolean }, + ): Promise { + const result = await syncFromRuvds(account, options) + return { + vpsCount: result.vpsCount, + paymentsCount: result.paymentsCount, + tariffsCount: result.tariffsCount, + balance: result.balance, + syncSummary: result.syncSummary, + newTariffs: result.newTariffs, + } + }, + + async fetchBalance(account: RuvdsSyncAccount) { + const fallbackCurrency = syncFallbackCurrency(account) + const info = await fetchBalance( + account.apiBaseUrl, + ruvdsCredentialsString(account), + fallbackCurrency, + ) + return { + balance: info.balance, + currency: info.currency || fallbackCurrency, + enoughmoneyto: info.enoughmoneyto || '', + } + }, +} diff --git a/apps/api/src/services/ruvds/client.test.ts b/apps/api/src/services/ruvds/client.test.ts new file mode 100644 index 0000000..2500262 --- /dev/null +++ b/apps/api/src/services/ruvds/client.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { RuvdsClient } from './client.js' + +describe('RuvdsClient', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('sends Bearer authorization header', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ amount: 100, currency: 1 }), + }) + vi.stubGlobal('fetch', fetchMock) + + const client = new RuvdsClient('https://api.ruvds.com', 'test-token') + await client.request('/v2/balance') + + expect(fetchMock).toHaveBeenCalledOnce() + const init = fetchMock.mock.calls[0][1] as RequestInit + expect(init.headers).toMatchObject({ + Authorization: 'Bearer test-token', + Accept: 'application/json', + }) + }) + + it('throws on 401 with message from API', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 401, + json: async () => ({ id: 'unauthorized', message: 'Invalid token' }), + }), + ) + + const client = new RuvdsClient('https://api.ruvds.com', 'bad-token') + await expect(client.request('/v2/balance')).rejects.toThrow('Invalid token') + }) +}) diff --git a/apps/api/src/services/ruvds/client.ts b/apps/api/src/services/ruvds/client.ts new file mode 100644 index 0000000..b300874 --- /dev/null +++ b/apps/api/src/services/ruvds/client.ts @@ -0,0 +1,104 @@ +/** + * RuVDS API v2 HTTP client + * @see https://ruvds.com/api-docs + */ + +import type { RuvdsApiErrorBody } from './types.js' + +export class RuvdsApiError extends Error { + constructor(message: string) { + super(message) + this.name = 'RuvdsApiError' + } +} + +function joinUrl(baseUrl: string, path: string): string { + const base = baseUrl.replace(/\/+$/, '') + const p = path.startsWith('/') ? path : `/${path}` + return `${base}${p}` +} + +export interface RuvdsRequestOptions { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' + query?: Record + body?: Record +} + +function extractErrorMessage(json: unknown, status: number): string { + if (json && typeof json === 'object') { + const obj = json as RuvdsApiErrorBody + if (typeof obj.message === 'string' && obj.message.trim()) return obj.message + if (typeof obj.id === 'string' && obj.id.trim()) return obj.id + } + return `RuVDS API HTTP ${status}` +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)) +} + +export class RuvdsClient { + constructor( + readonly baseUrl: string, + readonly token: string, + ) {} + + async request(path: string, opts: RuvdsRequestOptions = {}): Promise { + const { method = 'GET', query, body } = opts + const url = new URL(joinUrl(this.baseUrl, path)) + if (query) { + for (const [key, value] of Object.entries(query)) { + if (value != null && value !== '') { + url.searchParams.set(key, String(value)) + } + } + } + + const headers: Record = { + Accept: 'application/json', + Authorization: `Bearer ${this.token.trim()}`, + } + if (body && method !== 'GET') { + headers['Content-Type'] = 'application/json' + } + + const init: RequestInit = { method, headers } + if (body && method !== 'GET') { + init.body = JSON.stringify(body) + } + + let lastError: Error | null = null + for (let attempt = 0; attempt < 3; attempt++) { + if (attempt > 0) await sleep(500 * attempt) + + const res = await fetch(url.toString(), init) + + let json: unknown + try { + json = await res.json() + } catch { + if (!res.ok) throw new RuvdsApiError(`RuVDS API HTTP ${res.status}`) + return undefined as T + } + + if (res.status === 429 && attempt < 2) { + lastError = new RuvdsApiError('Превышен лимит запросов RuVDS API') + continue + } + + if (!res.ok) { + throw new RuvdsApiError(extractErrorMessage(json, res.status)) + } + + return json as T + } + + throw lastError ?? new RuvdsApiError('RuVDS API request failed') + } +} + +export function createRuvdsClient(baseUrl: string, token: string): RuvdsClient { + const trimmed = token.trim() + if (!trimmed) throw new RuvdsApiError('Укажите API-токен RuVDS') + return new RuvdsClient(baseUrl.trim(), trimmed) +} diff --git a/apps/api/src/services/ruvds/context.ts b/apps/api/src/services/ruvds/context.ts new file mode 100644 index 0000000..57f9ab5 --- /dev/null +++ b/apps/api/src/services/ruvds/context.ts @@ -0,0 +1,42 @@ +import type { schema } from '@cfdm/db' +import { parseRuvdsToken } from '@cfdm/shared/utils/api-credentials' + +type AccountRow = typeof schema.providerAccounts.$inferSelect +type ProviderRow = typeof schema.providers.$inferSelect + +export interface RuvdsSyncAccount extends AccountRow { + apiType: 'ruvds' + apiBaseUrl: string + apiToken: string + providerBaseCurrency?: string | null +} + +export function resolveRuvdsApi( + 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 ruvdsAccountRowForSync( + accountRow: AccountRow | null | undefined, + providerRow: ProviderRow | null | undefined, +): RuvdsSyncAccount | null { + if (!accountRow) return null + const { apiType, apiBaseUrl } = resolveRuvdsApi(accountRow, providerRow) + const apiToken = parseRuvdsToken(accountRow.apiCredentials) + if (apiType !== 'ruvds' || !apiBaseUrl || !apiToken) return null + return { + ...accountRow, + apiType: 'ruvds', + apiBaseUrl, + apiToken, + providerBaseCurrency: providerRow?.baseCurrency ?? null, + } +} + +export function ruvdsCredentialsString(account: RuvdsSyncAccount): string { + return account.apiToken +} diff --git a/apps/api/src/services/ruvds/index.ts b/apps/api/src/services/ruvds/index.ts new file mode 100644 index 0000000..5bdbe8f --- /dev/null +++ b/apps/api/src/services/ruvds/index.ts @@ -0,0 +1,11 @@ +export { createRuvdsClient, RuvdsApiError } from './client.js' +export type { RuvdsSyncAccount } from './context.js' +export { ruvdsAccountRowForSync, ruvdsCredentialsString } from './context.js' +export { + fetchAllPayments, + fetchAllServers, + fetchBalance, + fetchTariffList, + testConnection, +} from './operations.js' +export { syncFromRuvds } from './sync.js' diff --git a/apps/api/src/services/ruvds/mappers.test.ts b/apps/api/src/services/ruvds/mappers.test.ts new file mode 100644 index 0000000..c434ea2 --- /dev/null +++ b/apps/api/src/services/ruvds/mappers.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' + +import { buildLookupMaps, mapPaymentToPayment, mapServerToVps } from './mappers.js' +import type { RuvdsPayment, RuvdsServer } from './types.js' + +const server: RuvdsServer = { + virtual_server_id: 38420, + status: 'active', + datacenter: 1, + cpu: 2, + ram: 4, + drive: 40, + payment_period: 3, + os_id: 18, + paid_till: '2027-06-15T12:00:00Z', + user_comment: 'Prod VPS', + network_v4: [{ ip_address: '198.51.100.10' }, { ip_address: '198.51.100.11' }], +} + +const lookups = buildLookupMaps( + [{ id: 1, name: 'Rucloud: Россия, Москва', country: 'RU' }], + [{ id: 18, name: 'Ubuntu 22.04' }], + new Map([[38420, 900]]), +) + +describe('mapServerToVps', () => { + it('maps server fields and notes marker', () => { + const vps = mapServerToVps(server, 'prov-ruvds', 'acc-ruvds', 'RUB', lookups) + expect(vps.externalId).toBe('38420') + expect(vps.ip).toBe('198.51.100.10') + expect(vps.additionalIps).toEqual(['198.51.100.11']) + expect(vps.vcpu).toBe(2) + expect(vps.ramGb).toBe(4) + expect(vps.diskGb).toBe(40) + expect(vps.paidUntil).toBe('2027-06-15') + expect(vps.os).toBe('Ubuntu 22.04') + expect(vps.monthlyRate).toBe(300) + expect(vps.notes).toContain('ruvds-38420') + expect(vps.datacenter).toContain('Rucloud') + }) +}) + +describe('mapPaymentToPayment', () => { + it('maps income payments only', () => { + const payment: RuvdsPayment = { + dt: '2026-01-10T10:00:00Z', + direction: 1, + amount: 500, + currency: 1, + pay_source: 'card', + } + const mapped = mapPaymentToPayment(payment, 'acc-ruvds', 'RUB') + expect(mapped).not.toBeNull() + expect(mapped?.type).toBe('topup') + expect(mapped?.amount).toBe(500) + expect(mapped?.currency).toBe('RUB') + expect(mapped?.note).toContain('ruvds-') + }) + + it('skips debit payments', () => { + const payment: RuvdsPayment = { + dt: '2026-01-10T10:00:00Z', + direction: 2, + amount: 100, + currency: 1, + } + expect(mapPaymentToPayment(payment, 'acc-ruvds', 'RUB')).toBeNull() + }) +}) diff --git a/apps/api/src/services/ruvds/mappers.ts b/apps/api/src/services/ruvds/mappers.ts new file mode 100644 index 0000000..fb030a1 --- /dev/null +++ b/apps/api/src/services/ruvds/mappers.ts @@ -0,0 +1,224 @@ +/** + * RuVDS API v2 → vps-tracker model mappers + */ + +import { createHash } from 'node:crypto' + +import type { RuvdsDatacenter, RuvdsOsItem, RuvdsPayment, RuvdsServer } from './types.js' +import { ruvdsCurrencyCode } from './operations.js' + +const STATUS_MAP: Record = { + active: 'active', + new: 'active', + notpaid: 'paused', + blocked: 'paused', + deleted: 'archived', + removed: 'archived', +} + +function dateToIso(value: string | undefined | null): string { + if (!value) return '' + return String(value).slice(0, 10) +} + +function roundRate(n: number): number { + return Math.round(n * 100) / 100 +} + +function paymentPeriodMonths(period: number | undefined): number { + switch (period) { + case 2: + return 1 + case 3: + return 3 + case 4: + return 6 + case 5: + return 12 + default: + return 1 + } +} + +function ratesFromCost( + costRub: number | null | undefined, + paymentPeriod: number | undefined, +): { tariffType: string; dailyRate: number | null; monthlyRate: number | null } { + if (costRub == null || !Number.isFinite(costRub) || costRub <= 0) { + return { tariffType: 'monthly', dailyRate: null, monthlyRate: null } + } + const months = paymentPeriodMonths(paymentPeriod) + const monthlyRate = roundRate(costRub / months) + return { tariffType: 'monthly', dailyRate: null, monthlyRate } +} + +function parseDatacenterName(name: string): { city: string; country: string; datacenter: string } { + const trimmed = name.trim() + if (!trimmed) return { city: '', country: 'RU', datacenter: '' } + const parts = trimmed.split(':').map((p) => p.trim()) + if (parts.length >= 2) { + const location = parts[1] + const comma = location.split(',').map((p) => p.trim()) + const country = comma.length >= 2 ? comma[0] : 'RU' + const city = comma.length >= 2 ? comma[1] : location + return { city, country: country.length <= 3 ? country.toUpperCase() : 'RU', datacenter: trimmed } + } + return { city: '', country: 'RU', datacenter: trimmed } +} + +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: number | null + monthlyRate: number | null + createdAt: string + paidUntil: string + notes: string +} + +export interface MappedPayment { + externalId: string + type: string + date: string + amount: number + currency: string + providerAccountId: string + vpsId: null + note: string +} + +export interface RuvdsLookupMaps { + datacenters: Map + os: Map + costs: Map +} + +export function buildLookupMaps( + datacenters: RuvdsDatacenter[], + osList: RuvdsOsItem[], + costs: Map, +): RuvdsLookupMaps { + return { + datacenters: new Map(datacenters.map((d) => [d.id, d])), + os: new Map(osList.map((o) => [o.id, o])), + costs, + } +} + +export function mapServerToVps( + server: RuvdsServer, + providerId: string, + providerAccountId: string, + fallbackCurrency: string, + lookups: RuvdsLookupMaps, +): MappedVps { + const externalId = String(server.virtual_server_id) + const networks = server.network_v4 ?? [] + const ipv4List = networks.map((n) => String(n.ip_address || '').trim()).filter(Boolean) + const ip = ipv4List[0] ?? '' + const additionalIps = ipv4List.slice(1) + + const dc = server.datacenter != null ? lookups.datacenters.get(server.datacenter) : undefined + const dcParsed = parseDatacenterName(dc?.name ?? '') + const osItem = server.os_id != null ? lookups.os.get(server.os_id) : undefined + const status = STATUS_MAP[String(server.status ?? '').toLowerCase()] ?? 'active' + + const diskGb = + (Number(server.drive) || 0) + (Number(server.additional_drive) || 0) || Number(server.drive) || 0 + const costRub = lookups.costs.get(server.virtual_server_id) + const { tariffType, dailyRate, monthlyRate } = ratesFromCost(costRub, server.payment_period) + + const comment = String(server.user_comment ?? '').trim() + const marker = `ruvds-${externalId}` + const notes = comment ? `${comment} [${marker}]` : `[${marker}]` + const dns = comment || `RU${externalId}` + + return { + externalId, + ip, + dns, + ipv6: '', + additionalIps, + providerId, + providerAccountId, + country: (dc?.country ?? dcParsed.country).toUpperCase() || 'RU', + city: dcParsed.city, + datacenter: dcParsed.datacenter, + os: osItem?.name ?? '', + vcpu: Number(server.cpu) || 0, + ramGb: Number(server.ram) || 0, + diskGb, + diskType: 'SSD', + virtualization: 'KVM', + bandwidthTb: 0, + sshPort: 22, + rootUser: 'root', + purpose: '', + environment: '', + project: '', + monitoringEnabled: false, + backupEnabled: false, + status, + tariffType, + currency: fallbackCurrency, + dailyRate, + monthlyRate, + createdAt: '', + paidUntil: dateToIso(server.paid_till), + notes, + } +} + +function paymentExternalId(payment: RuvdsPayment): string { + const raw = `${payment.dt}|${payment.direction}|${payment.amount}|${payment.pay_source ?? ''}` + return createHash('sha256').update(raw).digest('hex').slice(0, 16) +} + +export function mapPaymentToPayment( + payment: RuvdsPayment, + providerAccountId: string, + fallbackCurrency: string, +): MappedPayment | null { + if (payment.direction !== 1) return null + const amount = Number(payment.amount) + if (!Number.isFinite(amount) || amount <= 0) return null + const externalId = paymentExternalId(payment) + const currency = ruvdsCurrencyCode(payment.currency, fallbackCurrency) + const date = dateToIso(payment.dt) || new Date().toISOString().slice(0, 10) + const source = String(payment.pay_source ?? '').trim() + return { + externalId, + type: 'topup', + date, + amount, + currency, + providerAccountId, + vpsId: null, + note: source ? `ruvds-${externalId} ${source}` : `ruvds-${externalId}`, + } +} diff --git a/apps/api/src/services/ruvds/operations.ts b/apps/api/src/services/ruvds/operations.ts new file mode 100644 index 0000000..a150094 --- /dev/null +++ b/apps/api/src/services/ruvds/operations.ts @@ -0,0 +1,229 @@ +/** + * RuVDS API v2 operations + */ + +import { parseRuvdsToken } from '@cfdm/shared/utils/api-credentials' + +import { createRuvdsClient, RuvdsApiError, type RuvdsClient } from './client.js' +import type { + RuvdsBalanceResponse, + RuvdsDatacenter, + RuvdsOsItem, + RuvdsPagination, + RuvdsPayment, + RuvdsServer, + RuvdsTariffsResponse, +} from './types.js' + +const CURRENCY_MAP: Record = { + 1: 'RUB', + 3: 'USD', + 4: 'EUR', +} + +export function ruvdsCurrencyCode(currencyId: number | undefined | null, fallback = 'RUB'): string { + if (currencyId == null) return fallback + return CURRENCY_MAP[currencyId] ?? fallback +} + +function clientFor(baseUrl: string, credentials: string): RuvdsClient { + return createRuvdsClient(baseUrl, parseRuvdsToken(credentials)) +} + +async function fetchAllPages( + client: RuvdsClient, + path: string, + extract: (json: Record) => T[], + query: Record = {}, +): Promise { + const items: T[] = [] + let page = 1 + let lastPage = 1 + + while (page <= lastPage) { + const json = (await client.request>(path, { + query: { ...query, page, per_page: 100 }, + })) as Record + items.push(...extract(json)) + const pagination = json.pagination as RuvdsPagination | undefined + lastPage = pagination?.last_page ?? page + if (!pagination?.next_page) break + page = pagination.next_page + } + + return items +} + +export interface RuvdsBalanceResult { + balance: number + currency: string + enoughmoneyto: string +} + +export interface RuvdsTariffItem { + 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 async function testConnection( + baseUrl: string, + credentials: string, +): Promise<{ ok: boolean; error?: string }> { + try { + await fetchBalance(baseUrl, credentials) + return { ok: true } + } catch (err) { + const message = err instanceof RuvdsApiError ? err.message : String(err) + return { ok: false, error: message } + } +} + +export async function fetchBalance( + baseUrl: string, + credentials: string, + fallbackCurrency = 'RUB', +): Promise { + const client = clientFor(baseUrl, credentials) + const json = await client.request('/v2/balance') + return { + balance: Number(json.amount) || 0, + currency: ruvdsCurrencyCode(json.currency, fallbackCurrency), + enoughmoneyto: '', + } +} + +export async function fetchAllServers(baseUrl: string, credentials: string): Promise { + const client = clientFor(baseUrl, credentials) + return fetchAllPages( + client, + '/v2/servers', + (json) => (Array.isArray(json.servers) ? (json.servers as RuvdsServer[]) : []), + { get_paid_till: true, get_network: true }, + ) +} + +export async function fetchServerCost( + baseUrl: string, + credentials: string, + serverId: number, +): Promise { + const client = clientFor(baseUrl, credentials) + try { + const json = await client.request<{ cost_rub?: number }>(`/v2/servers/${serverId}/cost`) + const cost = json.cost_rub + return cost != null && Number.isFinite(cost) ? cost : null + } catch { + return null + } +} + +export async function fetchAllPayments(baseUrl: string, credentials: string): Promise { + const client = clientFor(baseUrl, credentials) + return fetchAllPages( + client, + '/v2/payments', + (json) => (Array.isArray(json.payments) ? (json.payments as RuvdsPayment[]) : []), + ) +} + +export async function fetchTariffs(baseUrl: string, credentials: string): Promise { + const client = clientFor(baseUrl, credentials) + return client.request('/v2/tariffs') +} + +export async function fetchDatacenters(baseUrl: string, credentials: string): Promise { + const client = clientFor(baseUrl, credentials) + const json = await client.request<{ datacenters?: RuvdsDatacenter[] }>('/v2/datacenters') + return Array.isArray(json.datacenters) ? json.datacenters : [] +} + +export async function fetchOsList(baseUrl: string, credentials: string): Promise { + const client = clientFor(baseUrl, credentials) + const json = await client.request<{ os?: RuvdsOsItem[] }>('/v2/os') + return Array.isArray(json.os) ? json.os : [] +} + +export async function fetchTariffList( + baseUrl: string, + credentials: string, +): Promise { + const [tariffs, datacenters] = await Promise.all([ + fetchTariffs(baseUrl, credentials), + fetchDatacenters(baseUrl, credentials).catch(() => [] as RuvdsDatacenter[]), + ]) + + const dc = datacenters[0] + const dcKey = dc ? String(dc.id) : '' + const dcName = dc?.name ?? 'RuVDS' + + const items: RuvdsTariffItem[] = [] + for (const t of tariffs.vps ?? []) { + if (t.is_active === false) continue + const cpuPrice = t.cpu ?? 0 + const ramPrice = t.ram ?? 0 + const sampleCpu = 2 + const sampleRam = 2 + const sampleDisk = 40 + const drivePrice = tariffs.drive?.find((d) => d.is_active !== false)?.price ?? 5.5 + const monthly = Math.round((cpuPrice * sampleCpu + ramPrice * sampleRam + drivePrice * sampleDisk) * 100) / 100 + items.push({ + externalId: String(t.id), + datacenterKey: dcKey, + datacenterName: dcName, + name: t.name || `Tariff ${t.id}`, + desc: `CPU ${cpuPrice} RUB/core, RAM ${ramPrice} RUB/GB`, + vcpu: sampleCpu, + ramGb: sampleRam, + diskGb: sampleDisk, + diskType: 'SSD', + virtualization: 'KVM', + channel: '', + location: dcName, + country: (dc?.country ?? 'RU').toUpperCase(), + cpuModel: '', + orderAvailable: true, + price: monthly > 0 ? `${monthly} RUB/mo` : '', + }) + } + return items +} + +const COST_CONCURRENCY = 4 + +export async function enrichServersWithCost( + baseUrl: string, + credentials: string, + servers: RuvdsServer[], +): Promise> { + const costById = new Map() + const ids = servers.map((s) => s.virtual_server_id).filter((id) => id > 0) + if (ids.length === 0) return costById + + for (let i = 0; i < ids.length; i += COST_CONCURRENCY) { + const batch = ids.slice(i, i + COST_CONCURRENCY) + const results = await Promise.all( + batch.map(async (id) => { + const cost = await fetchServerCost(baseUrl, credentials, id) + return { id, cost } + }), + ) + for (const { id, cost } of results) { + if (cost != null) costById.set(id, cost) + } + } + return costById +} diff --git a/apps/api/src/services/ruvds/sync.test.ts b/apps/api/src/services/ruvds/sync.test.ts new file mode 100644 index 0000000..be8ff3c --- /dev/null +++ b/apps/api/src/services/ruvds/sync.test.ts @@ -0,0 +1,160 @@ +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 { syncFromRuvds } from './sync.js' +import type { RuvdsSyncAccount } from './context.js' + +vi.mock('./operations.js', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + fetchAllServers: vi.fn(), + fetchBalance: vi.fn(), + fetchTariffList: vi.fn(), + fetchAllPayments: vi.fn(), + fetchDatacenters: vi.fn(), + fetchOsList: vi.fn(), + enrichServersWithCost: vi.fn(), + } +}) + +import { + enrichServersWithCost, + fetchAllPayments, + fetchAllServers, + fetchBalance, + fetchDatacenters, + fetchOsList, + fetchTariffList, +} from './operations.js' + +function makeAccount(): RuvdsSyncAccount { + return { + id: 'acc-ruvds', + providerId: 'prov-ruvds', + name: 'RuVDS', + panelUrl: '', + currency: '', + billingMode: 'monthly', + notes: '', + apiType: 'ruvds', + apiBaseUrl: 'https://api.ruvds.com', + apiCredentials: 'apiv2.test-token', + apiToken: 'apiv2.test-token', + balanceApi: null, + balanceCurrency: null, + balanceUpdatedAt: null, + enoughmoneyto: '', + balanceAlertBelow: null, + providerBaseCurrency: 'RUB', + } +} + +describe('syncFromRuvds', () => { + beforeEach(() => { + resetTestDb() + getSqlite().exec( + `INSERT INTO providers (id, name, apiType, apiBaseUrl, baseCurrency) VALUES ('prov-ruvds', 'RuVDS', 'ruvds', 'https://api.ruvds.com', 'RUB')`, + ) + providerAccountsRepository.create({ + id: 'acc-ruvds', + providerId: 'prov-ruvds', + name: 'RuVDS', + apiCredentials: 'apiv2.test-token', + }) + + vi.mocked(fetchAllServers).mockResolvedValue([ + { + virtual_server_id: 1001, + status: 'active', + datacenter: 1, + cpu: 2, + ram: 2, + drive: 40, + payment_period: 2, + os_id: 18, + paid_till: '2027-03-01T00:00:00Z', + user_comment: 'web', + network_v4: [{ ip_address: '203.0.113.5' }], + }, + ]) + vi.mocked(fetchBalance).mockResolvedValue({ + balance: 1500, + currency: 'RUB', + enoughmoneyto: '', + }) + vi.mocked(fetchTariffList).mockResolvedValue([ + { + externalId: '14', + datacenterKey: '1', + datacenterName: 'Moscow', + name: 'Regular', + desc: 'CPU 79 RUB/core', + vcpu: 2, + ramGb: 2, + diskGb: 40, + diskType: 'SSD', + virtualization: 'KVM', + channel: '', + location: 'Moscow', + country: 'RU', + cpuModel: '', + orderAvailable: true, + price: '500 RUB/mo', + }, + ]) + vi.mocked(fetchAllPayments).mockResolvedValue([ + { + dt: '2026-01-01T00:00:00Z', + direction: 1, + amount: 1000, + currency: 1, + pay_source: 'card', + }, + ]) + vi.mocked(fetchDatacenters).mockResolvedValue([ + { id: 1, name: 'Rucloud: Россия, Москва', country: 'RU' }, + ]) + vi.mocked(fetchOsList).mockResolvedValue([{ id: 18, name: 'Ubuntu 22.04' }]) + vi.mocked(enrichServersWithCost).mockResolvedValue(new Map([[1001, 500]])) + }) + + afterEach(() => { + closeDb() + }) + + it('syncs vps, balance, payments and tariffs', async () => { + const result = await syncFromRuvds(makeAccount()) + expect(result.vpsCount).toBe(1) + expect(result.paymentsCount).toBe(1) + expect(result.tariffsCount).toBe(1) + expect(result.balance?.balance).toBe(1500) + expect(result.syncSummary.added).toHaveLength(1) + expect(result.syncSummary.added[0].id).toBe('vps-ruvds-acc-ruvds-1001') + }) + + it('updates existing vps by notes marker', async () => { + await syncFromRuvds(makeAccount()) + vi.mocked(fetchAllServers).mockResolvedValue([ + { + virtual_server_id: 1001, + status: 'active', + datacenter: 1, + cpu: 4, + ram: 4, + drive: 80, + payment_period: 2, + os_id: 18, + paid_till: '2028-01-01T00:00:00Z', + network_v4: [{ ip_address: '203.0.113.5' }], + }, + ]) + vi.mocked(enrichServersWithCost).mockResolvedValue(new Map([[1001, 800]])) + + const result = await syncFromRuvds(makeAccount()) + expect(result.vpsCount).toBe(1) + expect(result.syncSummary.updated.length).toBeGreaterThan(0) + }) +}) diff --git a/apps/api/src/services/ruvds/sync.ts b/apps/api/src/services/ruvds/sync.ts new file mode 100644 index 0000000..6654217 --- /dev/null +++ b/apps/api/src/services/ruvds/sync.ts @@ -0,0 +1,361 @@ +/** + * Sync RuVDS API v2 data into vps-tracker DB + */ + +import { and, eq, like, or } from 'drizzle-orm' +import { getDb, schema } from '@cfdm/db' + +import type { RuvdsSyncAccount } from './context.js' +import { ruvdsCredentialsString } from './context.js' +import { syncFallbackCurrency } from '@cfdm/shared/utils/account-balance' +import { buildLookupMaps, mapPaymentToPayment, mapServerToVps } from './mappers.js' +import { + enrichServersWithCost, + fetchAllPayments, + fetchAllServers, + fetchDatacenters, + fetchOsList, + fetchTariffList, + fetchBalance, + type RuvdsBalanceResult, +} from './operations.js' + +export interface SyncFromRuvdsOptions { + 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 SyncFromRuvdsResult { + vpsCount: number + paymentsCount: number + tariffsCount: number + newTariffs: { name: string; price: string; providerId: string }[] + balance: RuvdsBalanceResult | null + syncSummary: SyncSummary +} + +const SYNC_UPDATE_FIELDS = [ + 'country', + 'city', + 'datacenter', + 'os', + 'notes', + 'status', + 'tariffType', + 'currency', + 'dailyRate', + 'monthlyRate', + 'paidUntil', +] as const + +const SYNC_SPEC_FIELDS = ['vcpu', 'ramGb', 'diskGb'] as const + +function normVal(v: unknown): string { + if (v == null || v === '') return '' + if (typeof v === 'number') return Number.isFinite(v) ? String(v) : '' + return String(v) +} + +export async function syncFromRuvds( + account: RuvdsSyncAccount, + opts: SyncFromRuvdsOptions = {}, +): Promise { + const { skipTariffs = false, skipVpsPayments = false } = opts + const { apiBaseUrl, providerId, id: accountId } = account + const credentials = ruvdsCredentialsString(account) + if (!apiBaseUrl?.trim() || !credentials) { + throw new Error('API URL and token are required') + } + const db = getDb() + + const fetchVpsData = !skipVpsPayments + const fetchTariffs = !skipTariffs + + const [servers, balanceInfo, tariffItems, payments, datacenters, osList] = await Promise.all([ + fetchVpsData ? fetchAllServers(apiBaseUrl, credentials) : [], + fetchVpsData + ? fetchBalance(apiBaseUrl, credentials, syncFallbackCurrency(account)).catch(() => null) + : null, + fetchTariffs ? fetchTariffList(apiBaseUrl, credentials).catch(() => []) : [], + fetchVpsData ? fetchAllPayments(apiBaseUrl, credentials).catch(() => []) : [], + fetchVpsData ? fetchDatacenters(apiBaseUrl, credentials).catch(() => []) : [], + fetchVpsData ? fetchOsList(apiBaseUrl, credentials).catch(() => []) : [], + ]) + + const costs = fetchVpsData + ? await enrichServersWithCost(apiBaseUrl, credentials, servers) + : new Map() + + const lookups = buildLookupMaps(datacenters, osList, costs) + + const fallbackCurrency = syncFallbackCurrency(account, { + balanceCurrency: balanceInfo?.currency, + }) + + let vpsCount = 0 + const syncSummary: SyncSummary = { added: [], updated: [], paymentsAdded: 0 } + + if (fetchVpsData) { + for (const server of servers) { + const vps = mapServerToVps(server, providerId, accountId, fallbackCurrency, lookups) + const id = `vps-ruvds-${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( + ...(vps.ip ? [eq(schema.vps.ip, vps.ip)] : []), + like(schema.vps.notes, `%ruvds-${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, + vcpu: vps.vcpu, + ramGb: vps.ramGb, + diskGb: vps.diskGb, + 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 + } + } + for (const f of SYNC_SPEC_FIELDS) { + if (userOverrides.includes(f)) { + merged[f] = existing[f as keyof typeof existing] as never + } + } + const compareFields = [ + 'ip', + 'ipv6', + 'dns', + ...SYNC_SPEC_FIELDS, + ...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, + vcpu: merged.vcpu, + ramGb: merged.ramGb, + diskGb: merged.diskGb, + 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++ + } + } + + let paymentsCount = 0 + if (fetchVpsData) { + const existingPaymentRows = db + .select({ note: schema.payments.note }) + .from(schema.payments) + .where(eq(schema.payments.providerAccountId, accountId)) + .all() + const existingPayments = new Set( + existingPaymentRows.map((r) => r.note).filter((n): n is string => Boolean(n)), + ) + + for (const item of payments) { + const payment = mapPaymentToPayment(item, accountId, fallbackCurrency) + if (!payment) continue + const note = payment.note + if (existingPayments.has(note)) continue + const payId = `pay-ruvds-${accountId}-${payment.externalId}` + db.insert(schema.payments) + .values({ + id: payId, + type: payment.type, + date: payment.date, + amount: payment.amount, + currency: payment.currency, + providerAccountId: payment.providerAccountId, + vpsId: payment.vpsId, + note, + }) + .run() + existingPayments.add(note) + paymentsCount++ + syncSummary.paymentsAdded += 1 + } + } + + if (fetchVpsData && balanceInfo) { + db.update(schema.providerAccounts) + .set({ + balanceApi: balanceInfo.balance, + balanceCurrency: balanceInfo.currency || fallbackCurrency, + balanceUpdatedAt: new Date().toISOString(), + enoughmoneyto: balanceInfo.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-ruvds-${accountId}-${t.externalId}-${dcKey}` + : `tariff-ruvds-${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, + tariffsCount, + newTariffs, + balance: balanceInfo, + syncSummary, + } +} diff --git a/apps/api/src/services/ruvds/types.ts b/apps/api/src/services/ruvds/types.ts new file mode 100644 index 0000000..d567f08 --- /dev/null +++ b/apps/api/src/services/ruvds/types.ts @@ -0,0 +1,115 @@ +export interface RuvdsPagination { + page: number + per_page: number + previous_page: number | null + next_page: number | null + last_page: number + total_entries: number +} + +export interface RuvdsNetworkV4 { + ip_address: string + netmask?: string + gateway?: string +} + +export interface RuvdsServer { + virtual_server_id: number + status?: string + create_progress?: number + datacenter?: number + tariff_id?: number + payment_period?: number + os_id?: number + template_id?: string | null + cpu?: number + ram?: number + vram?: number + drive?: number + drive_tariff_id?: number + additional_drive?: number | null + additional_drive_tariff_id?: number | null + ip?: number + ddos_protection?: number + user_comment?: string + paid_till?: string + network_v4?: RuvdsNetworkV4[] +} + +export interface RuvdsServersResponse { + servers: RuvdsServer[] + pagination?: RuvdsPagination +} + +export interface RuvdsBalanceResponse { + amount: number + currency: number + type?: string +} + +export interface RuvdsPayment { + dt: string + direction: number + pay_source?: string + amount: number + currency: number + type?: string +} + +export interface RuvdsPaymentsResponse { + payments: RuvdsPayment[] + pagination?: RuvdsPagination +} + +export interface RuvdsVpsTariff { + id: number + name: string + cpu?: number + ram?: number + vram?: number + ip?: number + is_active?: boolean +} + +export interface RuvdsDriveTariff { + id: number + name: string + price?: number + is_active?: boolean +} + +export interface RuvdsTariffsResponse { + vps?: RuvdsVpsTariff[] + drive?: RuvdsDriveTariff[] + additional_drive?: RuvdsDriveTariff[] + additional_service?: { id: number; name: string; price?: number; is_active?: boolean }[] + payment_period_discount?: { payment_period: number; discount: number }[] +} + +export interface RuvdsDatacenter { + id: number + name: string + country?: string +} + +export interface RuvdsDatacentersResponse { + datacenters?: RuvdsDatacenter[] +} + +export interface RuvdsOsItem { + id: number + name: string +} + +export interface RuvdsOsResponse { + os?: RuvdsOsItem[] +} + +export interface RuvdsServerCostResponse { + cost_rub?: number +} + +export interface RuvdsApiErrorBody { + id?: string + message?: string +} diff --git a/apps/api/src/services/scheduler.ts b/apps/api/src/services/scheduler.ts index adcce8d..ccfcaaf 100644 --- a/apps/api/src/services/scheduler.ts +++ b/apps/api/src/services/scheduler.ts @@ -34,7 +34,7 @@ function getSyncableAccounts(): SyncableAccountEntry[] { .all(sql` SELECT pa.* FROM provider_accounts pa INNER JOIN providers p ON p.id = pa.providerId - WHERE lower(trim(COALESCE(p.apiType, ''))) IN ('billmanager', '4vps', 'macloud', 'vdsina', 'veesp') + WHERE lower(trim(COALESCE(p.apiType, ''))) IN ('billmanager', '4vps', 'macloud', 'vdsina', 'veesp', 'ruvds') AND length(trim(COALESCE(p.apiBaseUrl, ''))) > 0 AND pa.apiCredentials IS NOT NULL AND length(trim(pa.apiCredentials)) > 0 `) diff --git a/apps/web/src/components/domain/account-edit-sheet.tsx b/apps/web/src/components/domain/account-edit-sheet.tsx index a912dcd..59bc57c 100644 --- a/apps/web/src/components/domain/account-edit-sheet.tsx +++ b/apps/web/src/components/domain/account-edit-sheet.tsx @@ -14,7 +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, isUserApiType } from '@/lib/provider-sync' +import { accountCredentialLabels, isTokenApiType } from '@/lib/provider-sync' const EMPTY: ProviderAccountFormValues = { providerId: '', @@ -85,8 +85,10 @@ export function ProviderAccountEditSheet({ onOpenChange={onOpenChange} title={isEdit ? 'Редактировать аккаунт' : 'Новый аккаунт'} description={ - isUserApiType(initialProvider?.apiType) - ? 'API Token хранится на сервере и используется для синка с UserAPI' + isTokenApiType(initialProvider?.apiType) + ? initialProvider?.apiType === 'ruvds' + ? 'API-токен RuVDS хранится на сервере (создаётся в настройках ЛК RuVDS)' + : 'API Token хранится на сервере и используется для синка с UserAPI' : initialProvider?.apiType === '4vps' ? 'Panel ID и API Key хранятся на сервере и используются для синка с 4VPS' : initialProvider?.apiType === 'veesp' @@ -105,10 +107,12 @@ export function ProviderAccountEditSheet({ const apiBaseUrl = (provider?.apiBaseUrl ?? '').trim() const apiLogin = watch('apiLogin')?.trim() ?? '' const apiPassword = watch('apiPassword')?.trim() ?? '' - const apiCredentials = buildApiCredentials(apiLogin, apiPassword) - const canTest = Boolean(apiBaseUrl && apiCredentials) const credLabels = accountCredentialLabels(provider?.apiType) - const tokenOnly = isUserApiType(provider?.apiType) + const tokenOnly = isTokenApiType(provider?.apiType) + const apiCredentials = tokenOnly + ? apiPassword + : buildApiCredentials(apiLogin, apiPassword) + const canTest = Boolean(apiBaseUrl && (tokenOnly ? apiPassword : apiCredentials)) return ( <> diff --git a/apps/web/src/components/domain/provider-edit-sheet.tsx b/apps/web/src/components/domain/provider-edit-sheet.tsx index 80f6d63..7656deb 100644 --- a/apps/web/src/components/domain/provider-edit-sheet.tsx +++ b/apps/web/src/components/domain/provider-edit-sheet.tsx @@ -6,7 +6,7 @@ import { SelectField } from '@/components/select-field' import type { ZodType } from 'zod' import { providerSchema, type ProviderFormValues } from '@/lib/schemas' import type { ApiType } from '@/types/entities' -import { isUserApiType, USER_API_DEFAULT_BASE_URL, VEESP_DEFAULT_BASE_URL } from '@cfdm/shared/contracts/provider' +import { isUserApiType, RUVDS_DEFAULT_BASE_URL, USER_API_DEFAULT_BASE_URL, VEESP_DEFAULT_BASE_URL } from '@cfdm/shared/contracts/provider' const EMPTY: ProviderFormValues = { name: '', @@ -60,6 +60,8 @@ export function ProviderEditSheet({ ? 'Базовый URL API, например https://4vps.su/api' : apiType === 'veesp' ? 'Базовый URL Veesp API, например https://secure.veesp.com/api' + : apiType === 'ruvds' + ? 'Базовый URL RuVDS API v2, например https://api.ruvds.com' : isUserApiType(apiType) ? `Базовый URL UserAPI, например ${USER_API_DEFAULT_BASE_URL[apiType]}` : 'Один URL на хостера для BILLmanager' @@ -68,6 +70,8 @@ export function ProviderEditSheet({ ? 'https://4vps.su/api' : apiType === 'veesp' ? VEESP_DEFAULT_BASE_URL + : apiType === 'ruvds' + ? RUVDS_DEFAULT_BASE_URL : isUserApiType(apiType) ? USER_API_DEFAULT_BASE_URL[apiType] : undefined @@ -91,6 +95,7 @@ export function ProviderEditSheet({ { value: 'macloud', label: 'Маклауд' }, { value: 'vdsina', label: 'VDSina' }, { value: 'veesp', label: 'Veesp' }, + { value: 'ruvds', label: 'RuVDS' }, { value: 'none', label: 'Нет' }, ]} /> diff --git a/apps/web/src/lib/provider-sync.ts b/apps/web/src/lib/provider-sync.ts index d11a9f8..abf8263 100644 --- a/apps/web/src/lib/provider-sync.ts +++ b/apps/web/src/lib/provider-sync.ts @@ -1,8 +1,8 @@ -import { isSyncApiType, isUserApiType } from '@cfdm/shared/contracts/provider' +import { isSyncApiType, isTokenApiType, isUserApiType } from '@cfdm/shared/contracts/provider' import type { ProviderAccount, Provider } from '@/types/entities' -export { isSyncApiType, isUserApiType } +export { isSyncApiType, isTokenApiType, isUserApiType } export function providerByIdMap(providers: Provider[]): Map { return new Map(providers.map((p) => [p.id, p])) @@ -76,6 +76,13 @@ export function accountCredentialLabels(apiType?: string | null): { loginPlaceholder: 'user@example.com', } } + if (String(apiType).toLowerCase() === 'ruvds') { + return { + loginLabel: '', + passwordLabel: 'API Token', + loginPlaceholder: '', + } + } if (isUserApiType(apiType)) { return { loginLabel: '', diff --git a/packages/shared/src/contracts/provider.ts b/packages/shared/src/contracts/provider.ts index b61699f..89fa057 100644 --- a/packages/shared/src/contracts/provider.ts +++ b/packages/shared/src/contracts/provider.ts @@ -1,10 +1,10 @@ import { z } from 'zod' -export const API_TYPES = ['billmanager', '4vps', 'macloud', 'vdsina', 'veesp', 'none'] as const +export const API_TYPES = ['billmanager', '4vps', 'macloud', 'vdsina', 'veesp', 'ruvds', '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', 'macloud', 'vdsina', 'veesp'] as const +export const SYNC_API_TYPES = ['billmanager', '4vps', 'macloud', 'vdsina', 'veesp', 'ruvds'] as const export type SyncApiType = (typeof SYNC_API_TYPES)[number] export const USER_API_TYPES = ['macloud', 'vdsina'] as const @@ -17,6 +17,16 @@ export const USER_API_DEFAULT_BASE_URL: Record = { export const VEESP_DEFAULT_BASE_URL = 'https://secure.veesp.com/api' +export const RUVDS_DEFAULT_BASE_URL = 'https://api.ruvds.com' + +export const TOKEN_API_TYPES = ['macloud', 'vdsina', 'ruvds'] as const +export type TokenApiType = (typeof TOKEN_API_TYPES)[number] + +export function isTokenApiType(apiType?: string | null): apiType is TokenApiType { + const key = String(apiType || '').toLowerCase() + return (TOKEN_API_TYPES as readonly string[]).includes(key) +} + export function isUserApiType(apiType?: string | null): apiType is UserApiType { const key = String(apiType || '').toLowerCase() return (USER_API_TYPES as readonly string[]).includes(key) diff --git a/packages/shared/src/utils/api-credentials.ts b/packages/shared/src/utils/api-credentials.ts index bb8e1a1..88f0c80 100644 --- a/packages/shared/src/utils/api-credentials.ts +++ b/packages/shared/src/utils/api-credentials.ts @@ -39,6 +39,11 @@ export function parseUserApiToken(credentials: string | null | undefined): strin return String(credentials ?? '').trim() } +/** Bearer token для RuVDS API v2. */ +export function parseRuvdsToken(credentials: string | null | undefined): string { + return String(credentials ?? '').trim() +} + /** Собрать 4VPS-креды: panelId + API key. */ export function buildFourVpsCredentials(panelId: string, apiKey: string): string { const pid = panelId.trim()