feat(api, web): интеграция Macloud и VDSina через унифицированный UserAPI
Docker / build (push) Has been cancelled
Docker / build (push) Has been cancelled
Один модуль userapi для идентичного API обоих хостеров: синк VPS, баланса, тарифов и пополнений. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -17,6 +17,17 @@ vi.mock('../services/fourvps/sync.js', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../services/userapi/sync.js', () => ({
|
||||
syncFromUserApi: vi.fn().mockResolvedValue({
|
||||
vpsCount: 1,
|
||||
paymentsCount: 1,
|
||||
tariffsCount: 2,
|
||||
newTariffs: [],
|
||||
balance: { balance: 500, currency: 'RUB', enoughmoneyto: '2029-01-01' },
|
||||
syncSummary: { added: [], updated: [], paymentsAdded: 1 },
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('sync routes — 4vps', () => {
|
||||
let app: Awaited<ReturnType<typeof buildApp>>
|
||||
|
||||
@@ -77,3 +88,104 @@ describe('sync routes — 4vps', () => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sync routes — macloud', () => {
|
||||
let app: Awaited<ReturnType<typeof buildApp>>
|
||||
|
||||
beforeEach(async () => {
|
||||
resetTestDb()
|
||||
seedTestProvider('prov-macloud')
|
||||
providersRepository.update('prov-macloud', {
|
||||
apiType: 'macloud',
|
||||
apiBaseUrl: 'https://userapi.macloud.ru/v1',
|
||||
})
|
||||
providerAccountsRepository.create({
|
||||
id: 'acc-macloud',
|
||||
providerId: 'prov-macloud',
|
||||
name: 'Macloud',
|
||||
apiCredentials: 'secret-token',
|
||||
})
|
||||
app = await buildApp()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close()
|
||||
closeDb()
|
||||
})
|
||||
|
||||
it('POST /api/sync/:accountId syncs macloud account', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sync/acc-macloud',
|
||||
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(1)
|
||||
})
|
||||
|
||||
it('POST /api/sync/test-connection uses apiType macloud', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
status: 'ok',
|
||||
status_msg: 'Account information',
|
||||
data: { forecast: '2029-01-01' },
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sync/test-connection',
|
||||
payload: {
|
||||
apiBaseUrl: 'https://userapi.macloud.ru/v1',
|
||||
apiCredentials: 'token',
|
||||
apiType: 'macloud',
|
||||
},
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect((res.json() as { ok?: boolean }).ok).toBe(true)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sync routes — vdsina', () => {
|
||||
let app: Awaited<ReturnType<typeof buildApp>>
|
||||
|
||||
beforeEach(async () => {
|
||||
resetTestDb()
|
||||
seedTestProvider('prov-vdsina')
|
||||
providersRepository.update('prov-vdsina', {
|
||||
apiType: 'vdsina',
|
||||
apiBaseUrl: 'https://userapi.vdsina.com/v1',
|
||||
})
|
||||
providerAccountsRepository.create({
|
||||
id: 'acc-vdsina',
|
||||
providerId: 'prov-vdsina',
|
||||
name: 'VDSina',
|
||||
apiCredentials: 'secret-token',
|
||||
})
|
||||
app = await buildApp()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close()
|
||||
closeDb()
|
||||
})
|
||||
|
||||
it('POST /api/sync/:accountId syncs vdsina account', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sync/acc-vdsina',
|
||||
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(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,15 +2,19 @@ import type { schema } from '@cfdm/db'
|
||||
|
||||
import { billmanagerAccountRowForSync } from '../billmanager/context.js'
|
||||
import { fourvpsAccountRowForSync } from '../fourvps/context.js'
|
||||
import { userApiAccountRowForSync } from '../userapi/context.js'
|
||||
import type { BillmanagerSyncAccount } from '../billmanager/context.js'
|
||||
import type { FourvpsSyncAccount } from '../fourvps/context.js'
|
||||
import type { UserApiSyncAccount } from '../userapi/context.js'
|
||||
|
||||
import { billmanagerAdapter } from './billmanager-adapter.js'
|
||||
import { fourvpsAdapter } from './fourvps-adapter.js'
|
||||
import { userapiAdapter } from './userapi-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 const manualAdapter: ProviderAdapter = {
|
||||
type: 'manual',
|
||||
@@ -32,6 +36,8 @@ export const manualAdapter: ProviderAdapter = {
|
||||
const adapters: Record<string, ProviderAdapter> = {
|
||||
billmanager: billmanagerAdapter,
|
||||
'4vps': fourvpsAdapter,
|
||||
macloud: userapiAdapter,
|
||||
vdsina: userapiAdapter,
|
||||
manual: manualAdapter,
|
||||
none: manualAdapter,
|
||||
}
|
||||
@@ -44,7 +50,7 @@ export function getProviderAdapter(apiType: string | null | undefined): Provider
|
||||
type AccountRow = typeof schema.providerAccounts.$inferSelect
|
||||
type ProviderRow = typeof schema.providers.$inferSelect
|
||||
|
||||
export type SyncReadyAccount = BillmanagerSyncAccount | FourvpsSyncAccount
|
||||
export type SyncReadyAccount = BillmanagerSyncAccount | FourvpsSyncAccount | UserApiSyncAccount
|
||||
|
||||
export function resolveSyncAccount(
|
||||
accountRow: AccountRow | null | undefined,
|
||||
@@ -63,10 +69,16 @@ export function resolveSyncAccount(
|
||||
const account = fourvpsAccountRowForSync(accountRow, providerRow)
|
||||
return account ? { apiType, account } : null
|
||||
}
|
||||
if (apiType === 'macloud' || apiType === 'vdsina') {
|
||||
const account = userApiAccountRowForSync(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',
|
||||
macloud: 'Укажите тип API Маклауд и URL; в аккаунте — API Token',
|
||||
vdsina: 'Укажите тип API VDSina и URL; в аккаунте — API Token',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { syncFromUserApi } from '../userapi/sync.js'
|
||||
import { fetchBalance, testConnection } from '../userapi/operations.js'
|
||||
import type { UserApiSyncAccount } from '../userapi/context.js'
|
||||
|
||||
import type { ProviderAdapter, SyncResult } from './types.js'
|
||||
|
||||
export const userapiAdapter: ProviderAdapter = {
|
||||
type: 'userapi',
|
||||
|
||||
async testConnection(apiBaseUrl: string, apiCredentials: string) {
|
||||
const result = await testConnection(apiBaseUrl, apiCredentials)
|
||||
return { ok: result.ok, message: result.error }
|
||||
},
|
||||
|
||||
async syncAccount(
|
||||
account: UserApiSyncAccount,
|
||||
options?: { skipTariffs?: boolean; skipVpsPayments?: boolean },
|
||||
): Promise<SyncResult> {
|
||||
const result = await syncFromUserApi(account, options)
|
||||
return {
|
||||
vpsCount: result.vpsCount,
|
||||
paymentsCount: result.paymentsCount,
|
||||
tariffsCount: result.tariffsCount,
|
||||
balance: result.balance,
|
||||
syncSummary: result.syncSummary,
|
||||
newTariffs: result.newTariffs,
|
||||
}
|
||||
},
|
||||
|
||||
async fetchBalance(account: UserApiSyncAccount) {
|
||||
const info = await fetchBalance(
|
||||
account.apiBaseUrl,
|
||||
account.apiToken,
|
||||
account.currency || 'RUB',
|
||||
)
|
||||
return {
|
||||
balance: info.balance,
|
||||
currency: info.currency || 'RUB',
|
||||
enoughmoneyto: info.enoughmoneyto || '',
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -34,7 +34,7 @@ function getSyncableAccounts(): SyncableAccountEntry[] {
|
||||
.all<AccountRow>(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')
|
||||
WHERE lower(trim(COALESCE(p.apiType, ''))) IN ('billmanager', '4vps', 'macloud', 'vdsina')
|
||||
AND length(trim(COALESCE(p.apiBaseUrl, ''))) > 0
|
||||
AND pa.apiCredentials IS NOT NULL AND length(trim(pa.apiCredentials)) > 0
|
||||
`)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { userApiRequest, UserApiError } from './client.js'
|
||||
|
||||
describe('userApiRequest', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('sends Bearer auth and parses ok envelope', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
status: 'ok',
|
||||
status_msg: 'Balance information',
|
||||
data: { real: '105.00' },
|
||||
}),
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const data = await userApiRequest('https://userapi.macloud.ru/v1', 'test-token', '/account.balance')
|
||||
|
||||
expect(data).toEqual({ real: '105.00' })
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toBe('https://userapi.macloud.ru/v1/account.balance')
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer test-token')
|
||||
})
|
||||
|
||||
it('throws UserApiError on API error status', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
status: 'error',
|
||||
status_msg: 'Unauthorized',
|
||||
description: 'Incorrect token',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
await expect(
|
||||
userApiRequest('https://userapi.vdsina.com/v1', 'bad', '/account'),
|
||||
).rejects.toThrow(UserApiError)
|
||||
})
|
||||
|
||||
it('throws UserApiError on HTTP error', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: async () => ({
|
||||
status: 'error',
|
||||
status_msg: 'Forbidden',
|
||||
description: 'The API requests limits were exceeded',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
await expect(
|
||||
userApiRequest('https://userapi.macloud.ru/v1', 'token', '/server'),
|
||||
).rejects.toThrow('Forbidden')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Macloud / VDSina UserAPI HTTP client (OpenAPI v1.2.3.1)
|
||||
*/
|
||||
|
||||
export interface UserApiEnvelope<T = unknown> {
|
||||
status: string
|
||||
status_msg: string
|
||||
data: T | null
|
||||
description?: string
|
||||
}
|
||||
|
||||
export class UserApiError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'UserApiError'
|
||||
}
|
||||
}
|
||||
|
||||
function joinUrl(baseUrl: string, path: string): string {
|
||||
const base = baseUrl.replace(/\/+$/, '')
|
||||
const p = path.startsWith('/') ? path : `/${path}`
|
||||
return `${base}${p}`
|
||||
}
|
||||
|
||||
export interface UserApiRequestOptions {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||
query?: Record<string, string | number | undefined>
|
||||
body?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export async function userApiRequest<T = unknown>(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
path: string,
|
||||
opts: UserApiRequestOptions = {},
|
||||
): Promise<T> {
|
||||
const { method = 'GET', query, body } = opts
|
||||
const url = new URL(joinUrl(baseUrl, path))
|
||||
if (query) {
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value != null && value !== '') {
|
||||
url.searchParams.set(key, String(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.trim()}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
}
|
||||
|
||||
if (body && method !== 'GET') {
|
||||
init.headers = { ...init.headers, 'Content-Type': 'application/json' }
|
||||
init.body = JSON.stringify(body)
|
||||
}
|
||||
|
||||
const res = await fetch(url.toString(), init)
|
||||
const json = (await res.json()) as UserApiEnvelope<T>
|
||||
|
||||
if (!res.ok) {
|
||||
throw new UserApiError(json.status_msg || json.description || `HTTP ${res.status}`)
|
||||
}
|
||||
if (json.status !== 'ok') {
|
||||
throw new UserApiError(json.status_msg || json.description || 'UserAPI error')
|
||||
}
|
||||
|
||||
return json.data as T
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { schema } from '@cfdm/db'
|
||||
import { isUserApiType, type UserApiType } from '@cfdm/shared/contracts/provider'
|
||||
import { parseUserApiToken } from '@cfdm/shared/utils/api-credentials'
|
||||
|
||||
type AccountRow = typeof schema.providerAccounts.$inferSelect
|
||||
type ProviderRow = typeof schema.providers.$inferSelect
|
||||
|
||||
export interface UserApiSyncAccount extends AccountRow {
|
||||
apiType: UserApiType
|
||||
apiBaseUrl: string
|
||||
apiToken: string
|
||||
}
|
||||
|
||||
export function resolveUserApi(
|
||||
accountRow: AccountRow | null | undefined,
|
||||
providerRow: ProviderRow | null | undefined,
|
||||
): { apiType: UserApiType; apiBaseUrl: string } | null {
|
||||
const rawType = String(providerRow?.apiType || accountRow?.apiType || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (!isUserApiType(rawType)) return null
|
||||
const apiBaseUrl = String(providerRow?.apiBaseUrl || accountRow?.apiBaseUrl || '').trim()
|
||||
return { apiType: rawType, apiBaseUrl }
|
||||
}
|
||||
|
||||
export function userApiAccountRowForSync(
|
||||
accountRow: AccountRow | null | undefined,
|
||||
providerRow: ProviderRow | null | undefined,
|
||||
): UserApiSyncAccount | null {
|
||||
if (!accountRow) return null
|
||||
const resolved = resolveUserApi(accountRow, providerRow)
|
||||
const apiToken = parseUserApiToken(accountRow.apiCredentials)
|
||||
if (!resolved || !resolved.apiBaseUrl || !apiToken) return null
|
||||
return { ...accountRow, apiType: resolved.apiType, apiBaseUrl: resolved.apiBaseUrl, apiToken }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export { userApiRequest, UserApiError, type UserApiEnvelope } from './client.js'
|
||||
export { userApiAccountRowForSync, resolveUserApi, type UserApiSyncAccount } from './context.js'
|
||||
export {
|
||||
fetchAccount,
|
||||
fetchBalance,
|
||||
fetchServers,
|
||||
fetchServerDetail,
|
||||
fetchServersWithDetails,
|
||||
fetchDatacenters,
|
||||
fetchServerGroups,
|
||||
fetchServerPlans,
|
||||
fetchTariffList,
|
||||
fetchOperations,
|
||||
testConnection,
|
||||
} from './operations.js'
|
||||
export { mapServerToVps, mapOperationToPayment } from './mappers.js'
|
||||
export { syncFromUserApi } from './sync.js'
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { mapOperationToPayment, mapServerToVps } from './mappers.js'
|
||||
import type { UserApiOperation, UserApiServerDetail } from './operations.js'
|
||||
|
||||
const server: UserApiServerDetail = {
|
||||
id: 12345,
|
||||
name: 'Server #12345',
|
||||
full_name: 'Server 2 RAM / 1 CPU / 40 NVMe #12345',
|
||||
created: '2022-02-24',
|
||||
end: '2029-02-20',
|
||||
status: 'active',
|
||||
host: 'super.server.host',
|
||||
ip: { id: 1, ip: '91.84.101.78', type: '4' },
|
||||
template: { id: 23, name: 'Ubuntu 24.04' },
|
||||
datacenter: { id: 1, name: 'Amsterdam 1', country: 'nl' },
|
||||
'server-plan': { id: 1, name: '2 RAM / 1 CPU / 40 NVMe' },
|
||||
data: {
|
||||
cpu: { value: 1 },
|
||||
ram: { value: 2 },
|
||||
disk: { value: 40 },
|
||||
traff: { value: 32, for: 'Tb' },
|
||||
},
|
||||
}
|
||||
|
||||
describe('mapServerToVps', () => {
|
||||
it('maps macloud server with apiType prefix in notes', () => {
|
||||
const vps = mapServerToVps(server, 'macloud', 'prov-1', 'acc-1')
|
||||
expect(vps.externalId).toBe('12345')
|
||||
expect(vps.ip).toBe('91.84.101.78')
|
||||
expect(vps.os).toBe('Ubuntu 24.04')
|
||||
expect(vps.vcpu).toBe(1)
|
||||
expect(vps.ramGb).toBe(2)
|
||||
expect(vps.diskGb).toBe(40)
|
||||
expect(vps.bandwidthTb).toBe(32)
|
||||
expect(vps.country).toBe('NL')
|
||||
expect(vps.paidUntil).toBe('2029-02-20')
|
||||
expect(vps.notes).toContain('macloud-12345')
|
||||
expect(vps.tariffType).toBe('daily')
|
||||
})
|
||||
|
||||
it('maps vdsina server with vdsina prefix in notes', () => {
|
||||
const vps = mapServerToVps(server, 'vdsina', 'prov-2', 'acc-2')
|
||||
expect(vps.notes).toContain('vdsina-12345')
|
||||
})
|
||||
|
||||
it('maps paused status for notpaid', () => {
|
||||
const vps = mapServerToVps({ ...server, status: 'notpaid' }, 'macloud', 'p', 'a')
|
||||
expect(vps.status).toBe('paused')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapOperationToPayment', () => {
|
||||
const topup: UserApiOperation = {
|
||||
id: 4290676,
|
||||
purse: 'real',
|
||||
type: 1,
|
||||
status: 1,
|
||||
summ: '100',
|
||||
created: '2025-02-22 12:28:23',
|
||||
comment: 'Balance replenishment',
|
||||
}
|
||||
|
||||
it('maps paid credit to provider_balance_topup', () => {
|
||||
const payment = mapOperationToPayment(topup, 'macloud', 'acc-1')
|
||||
expect(payment).toMatchObject({
|
||||
externalId: '4290676',
|
||||
type: 'provider_balance_topup',
|
||||
amount: 100,
|
||||
date: '2025-02-22',
|
||||
note: 'Balance replenishment',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for debit operations', () => {
|
||||
expect(mapOperationToPayment({ ...topup, type: -1 }, 'vdsina', 'acc-1')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for unpaid operations', () => {
|
||||
expect(mapOperationToPayment({ ...topup, status: 0 }, 'macloud', 'acc-1')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* UserAPI response → vps-tracker model mappers
|
||||
*/
|
||||
|
||||
import type { UserApiType } from '@cfdm/shared/contracts/provider'
|
||||
|
||||
import type { UserApiOperation, UserApiServerDetail } from './operations.js'
|
||||
|
||||
const STATUS_MAP: Record<string, string> = {
|
||||
active: 'active',
|
||||
new: 'active',
|
||||
block: 'paused',
|
||||
notpaid: 'paused',
|
||||
deleted: 'archived',
|
||||
}
|
||||
|
||||
function dateToIso(value: string | undefined | null): string {
|
||||
if (!value) return ''
|
||||
return String(value).slice(0, 10)
|
||||
}
|
||||
|
||||
function inferDiskType(name: string): string {
|
||||
const upper = name.toUpperCase()
|
||||
if (upper.includes('NVME')) return 'NVMe'
|
||||
if (upper.includes('SSD')) return 'SSD'
|
||||
if (upper.includes('HDD')) return 'HDD'
|
||||
return 'NVMe'
|
||||
}
|
||||
|
||||
function extractIp(server: UserApiServerDetail): { ip: string; ipv6: string } {
|
||||
const ipObj = server.ip
|
||||
if (!ipObj?.ip) return { ip: '', ipv6: '' }
|
||||
if (String(ipObj.type) === '6') return { ip: '', ipv6: String(ipObj.ip).trim() }
|
||||
return { ip: String(ipObj.ip).trim(), ipv6: '' }
|
||||
}
|
||||
|
||||
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: 'provider_balance_topup'
|
||||
date: string
|
||||
amount: number
|
||||
currency: string
|
||||
providerAccountId: string
|
||||
vpsId: null
|
||||
note: string
|
||||
}
|
||||
|
||||
export function mapServerToVps(
|
||||
server: UserApiServerDetail,
|
||||
apiType: UserApiType,
|
||||
providerId: string,
|
||||
providerAccountId: string,
|
||||
): MappedVps {
|
||||
const { ip, ipv6 } = extractIp(server)
|
||||
const data = server.data ?? {}
|
||||
const planName = server['server-plan']?.name ?? server.full_name ?? ''
|
||||
const datacenter = server.datacenter?.name ?? ''
|
||||
const country = (server.datacenter?.country ?? '').toUpperCase()
|
||||
const status = STATUS_MAP[String(server.status).toLowerCase()] ?? 'active'
|
||||
const name = String(server.name || '').trim()
|
||||
const host = String(server.host || '').trim()
|
||||
const traffGb = data.traff?.value ?? 0
|
||||
const bandwidthTb =
|
||||
data.traff?.for?.toLowerCase() === 'tb'
|
||||
? traffGb
|
||||
: traffGb > 0
|
||||
? Math.round((traffGb / 1024) * 100) / 100
|
||||
: 0
|
||||
|
||||
return {
|
||||
externalId: String(server.id),
|
||||
ip,
|
||||
dns: host || name,
|
||||
ipv6,
|
||||
additionalIps: [],
|
||||
providerId,
|
||||
providerAccountId,
|
||||
country,
|
||||
city: '',
|
||||
datacenter,
|
||||
os: String(server.template?.name || '').trim(),
|
||||
vcpu: data.cpu?.value ?? 0,
|
||||
ramGb: data.ram?.value ?? 0,
|
||||
diskGb: data.disk?.value ?? 0,
|
||||
diskType: inferDiskType(planName),
|
||||
virtualization: 'KVM',
|
||||
bandwidthTb,
|
||||
sshPort: 22,
|
||||
rootUser: 'root',
|
||||
purpose: '',
|
||||
environment: '',
|
||||
project: '',
|
||||
monitoringEnabled: false,
|
||||
backupEnabled: false,
|
||||
status,
|
||||
tariffType: 'daily',
|
||||
currency: 'RUB',
|
||||
dailyRate: null,
|
||||
monthlyRate: null,
|
||||
createdAt: dateToIso(server.created),
|
||||
paidUntil: dateToIso(server.end),
|
||||
notes: name ? `${name} [${apiType}-${server.id}]` : `${apiType}-${server.id}`,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapOperationToPayment(
|
||||
op: UserApiOperation,
|
||||
apiType: UserApiType,
|
||||
providerAccountId: string,
|
||||
currency = 'RUB',
|
||||
): MappedPayment | null {
|
||||
if (op.type !== 1 || op.status !== 1 || op.purse !== 'real') return null
|
||||
const raw = op.summ
|
||||
const amount =
|
||||
typeof raw === 'number'
|
||||
? raw
|
||||
: Number.parseFloat(String(raw ?? '').replace(/[^\d.-]/g, '')) || 0
|
||||
if (amount <= 0) return null
|
||||
const dateStr = op.created ? String(op.created).slice(0, 10) : new Date().toISOString().slice(0, 10)
|
||||
const label = apiType === 'vdsina' ? 'VDSina' : 'Macloud'
|
||||
return {
|
||||
externalId: String(op.id),
|
||||
type: 'provider_balance_topup',
|
||||
date: dateStr,
|
||||
amount,
|
||||
currency,
|
||||
providerAccountId,
|
||||
vpsId: null,
|
||||
note: op.comment?.trim() || `${label} #${op.id}`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* Macloud / VDSina UserAPI operations
|
||||
*/
|
||||
|
||||
import { parseUserApiToken } from '@cfdm/shared/utils/api-credentials'
|
||||
|
||||
import { userApiRequest } from './client.js'
|
||||
|
||||
export type ServiceStatus = 'new' | 'active' | 'block' | 'notpaid' | 'deleted'
|
||||
|
||||
export interface UserApiDatacenter {
|
||||
id: number
|
||||
name: string
|
||||
country: string
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
export interface UserApiTariffSpec {
|
||||
cpu?: { value?: number; for?: string }
|
||||
ram?: { value?: number; for?: string }
|
||||
disk?: { value?: number; for?: string }
|
||||
gpu?: { value?: number; for?: string } | null
|
||||
traff?: { value?: number; for?: string }
|
||||
}
|
||||
|
||||
export interface UserApiServerListItem {
|
||||
id: number
|
||||
name: string
|
||||
full_name?: string
|
||||
created?: string
|
||||
updated?: string
|
||||
end?: string
|
||||
status: ServiceStatus
|
||||
status_text?: string
|
||||
ip?: { id?: number; ip?: string; type?: string } | null
|
||||
'server-plan'?: { id?: number; name?: string }
|
||||
template?: { id?: number; name?: string }
|
||||
datacenter?: UserApiDatacenter | null
|
||||
}
|
||||
|
||||
export interface UserApiServerDetail extends UserApiServerListItem {
|
||||
host?: string
|
||||
data?: UserApiTariffSpec | null
|
||||
autoprolong?: boolean
|
||||
bandwidth?: { current_month?: number; last_month?: number }
|
||||
}
|
||||
|
||||
export interface UserApiServerGroup {
|
||||
id: number
|
||||
name: string
|
||||
active?: boolean
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface UserApiServerPlan {
|
||||
id: number
|
||||
name: string
|
||||
cost: number
|
||||
full_cost?: number
|
||||
period?: string
|
||||
description?: string
|
||||
active?: boolean
|
||||
enable?: boolean
|
||||
has_params?: boolean
|
||||
data?: UserApiTariffSpec | null
|
||||
}
|
||||
|
||||
export interface UserApiOperation {
|
||||
id: number
|
||||
purse?: 'real' | 'bonus' | 'partner'
|
||||
type?: -1 | 1
|
||||
status?: 0 | 1
|
||||
summ?: string | number
|
||||
created?: string
|
||||
comment?: string
|
||||
}
|
||||
|
||||
export interface UserApiAccountInfo {
|
||||
account?: { id?: number; name?: string }
|
||||
created?: string
|
||||
forecast?: string | null
|
||||
}
|
||||
|
||||
export interface UserApiBalanceInfo {
|
||||
real?: string | number
|
||||
bonus?: string | number
|
||||
partner?: string | number
|
||||
}
|
||||
|
||||
export interface UserApiBalanceResult {
|
||||
balance: number
|
||||
currency: string
|
||||
enoughmoneyto: string
|
||||
}
|
||||
|
||||
export interface UserApiTariffItem {
|
||||
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
|
||||
}
|
||||
|
||||
function parseCredentials(baseUrl: string, credentials: string) {
|
||||
const url = baseUrl.trim()
|
||||
const token = parseUserApiToken(credentials)
|
||||
if (!url || !token) {
|
||||
throw new Error('API URL and credentials are required')
|
||||
}
|
||||
return { baseUrl: url, token }
|
||||
}
|
||||
|
||||
function parseBalanceAmount(raw: string | number | undefined): number {
|
||||
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : 0
|
||||
const n = Number.parseFloat(String(raw ?? '').replace(/[^\d.-]/g, ''))
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
function inferDiskType(name: string): string {
|
||||
const upper = name.toUpperCase()
|
||||
if (upper.includes('NVME')) return 'NVMe'
|
||||
if (upper.includes('SSD')) return 'SSD'
|
||||
if (upper.includes('HDD')) return 'HDD'
|
||||
return 'NVMe'
|
||||
}
|
||||
|
||||
function mapPlanToTariffItem(
|
||||
plan: UserApiServerPlan,
|
||||
groupId: string,
|
||||
groupName: string,
|
||||
): UserApiTariffItem {
|
||||
const data = plan.data ?? {}
|
||||
const diskGb = data.disk?.value ?? 0
|
||||
const priceSuffix = plan.period === 'day' ? ' ₽/день' : ' ₽'
|
||||
const descParts = [plan.description || '']
|
||||
if (plan.has_params) descParts.push('конструктор')
|
||||
return {
|
||||
externalId: String(plan.id),
|
||||
datacenterKey: groupId,
|
||||
datacenterName: groupName,
|
||||
name: plan.name || '',
|
||||
desc: descParts.filter(Boolean).join('; '),
|
||||
vcpu: data.cpu?.value ?? 0,
|
||||
ramGb: data.ram?.value ?? 0,
|
||||
diskGb: typeof diskGb === 'number' ? diskGb : 0,
|
||||
diskType: inferDiskType(plan.name || ''),
|
||||
virtualization: 'KVM',
|
||||
channel: '',
|
||||
location: groupName,
|
||||
country: '',
|
||||
cpuModel: '',
|
||||
orderAvailable: Boolean(plan.active && plan.enable),
|
||||
price: plan.cost != null ? `${plan.cost}${priceSuffix}` : '',
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAccount(baseUrl: string, credentials: string): Promise<UserApiAccountInfo> {
|
||||
const { baseUrl: url, token } = parseCredentials(baseUrl, credentials)
|
||||
const data = await userApiRequest<UserApiAccountInfo>(url, token, '/account')
|
||||
return data ?? {}
|
||||
}
|
||||
|
||||
export async function fetchBalance(
|
||||
baseUrl: string,
|
||||
credentials: string,
|
||||
fallbackCurrency = 'RUB',
|
||||
): Promise<UserApiBalanceResult> {
|
||||
const { baseUrl: url, token } = parseCredentials(baseUrl, credentials)
|
||||
const [balanceData, accountData] = await Promise.all([
|
||||
userApiRequest<UserApiBalanceInfo>(url, token, '/account.balance'),
|
||||
fetchAccount(url, token).catch(() => ({} as UserApiAccountInfo)),
|
||||
])
|
||||
return {
|
||||
balance: parseBalanceAmount(balanceData?.real),
|
||||
currency: fallbackCurrency || 'RUB',
|
||||
enoughmoneyto: accountData.forecast ? String(accountData.forecast).slice(0, 10) : '',
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchServers(
|
||||
baseUrl: string,
|
||||
credentials: string,
|
||||
): Promise<UserApiServerListItem[]> {
|
||||
const { baseUrl: url, token } = parseCredentials(baseUrl, credentials)
|
||||
const data = await userApiRequest<UserApiServerListItem[]>(url, token, '/server')
|
||||
return Array.isArray(data) ? data : []
|
||||
}
|
||||
|
||||
export async function fetchServerDetail(
|
||||
baseUrl: string,
|
||||
credentials: string,
|
||||
serverId: number,
|
||||
): Promise<UserApiServerDetail | null> {
|
||||
const { baseUrl: url, token } = parseCredentials(baseUrl, credentials)
|
||||
const data = await userApiRequest<UserApiServerDetail>(url, token, `/server/${serverId}`)
|
||||
return data ?? null
|
||||
}
|
||||
|
||||
export async function fetchServersWithDetails(
|
||||
baseUrl: string,
|
||||
credentials: string,
|
||||
concurrency = 10,
|
||||
): Promise<UserApiServerDetail[]> {
|
||||
const list = await fetchServers(baseUrl, credentials)
|
||||
const results: UserApiServerDetail[] = []
|
||||
|
||||
for (let i = 0; i < list.length; i += concurrency) {
|
||||
const batch = list.slice(i, i + concurrency)
|
||||
const details = await Promise.all(
|
||||
batch.map(async (item) => {
|
||||
try {
|
||||
const detail = await fetchServerDetail(baseUrl, credentials, item.id)
|
||||
return detail ?? item
|
||||
} catch {
|
||||
return item
|
||||
}
|
||||
}),
|
||||
)
|
||||
results.push(...details)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
export async function fetchDatacenters(
|
||||
baseUrl: string,
|
||||
credentials: string,
|
||||
): Promise<Map<number, UserApiDatacenter>> {
|
||||
const { baseUrl: url, token } = parseCredentials(baseUrl, credentials)
|
||||
const data = await userApiRequest<UserApiDatacenter[]>(url, token, '/datacenter')
|
||||
const map = new Map<number, UserApiDatacenter>()
|
||||
for (const dc of Array.isArray(data) ? data : []) {
|
||||
if (dc?.id != null) map.set(dc.id, dc)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
export async function fetchServerGroups(
|
||||
baseUrl: string,
|
||||
credentials: string,
|
||||
): Promise<UserApiServerGroup[]> {
|
||||
const { baseUrl: url, token } = parseCredentials(baseUrl, credentials)
|
||||
const data = await userApiRequest<UserApiServerGroup[]>(url, token, '/server-group')
|
||||
return Array.isArray(data) ? data.filter((g) => g.active !== false) : []
|
||||
}
|
||||
|
||||
export async function fetchServerPlans(
|
||||
baseUrl: string,
|
||||
credentials: string,
|
||||
groupId: number,
|
||||
): Promise<UserApiServerPlan[]> {
|
||||
const { baseUrl: url, token } = parseCredentials(baseUrl, credentials)
|
||||
const data = await userApiRequest<UserApiServerPlan[]>(url, token, `/server-plan/${groupId}`)
|
||||
return Array.isArray(data) ? data : []
|
||||
}
|
||||
|
||||
export async function fetchTariffList(
|
||||
baseUrl: string,
|
||||
credentials: string,
|
||||
): Promise<UserApiTariffItem[]> {
|
||||
const groups = await fetchServerGroups(baseUrl, credentials)
|
||||
const items: UserApiTariffItem[] = []
|
||||
|
||||
for (const group of groups) {
|
||||
const plans = await fetchServerPlans(baseUrl, credentials, group.id)
|
||||
const groupKey = String(group.id)
|
||||
for (const plan of plans) {
|
||||
if (plan.active === false || plan.enable === false) continue
|
||||
items.push(mapPlanToTariffItem(plan, groupKey, group.name || ''))
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
export async function fetchOperations(
|
||||
baseUrl: string,
|
||||
credentials: string,
|
||||
fromDate?: string,
|
||||
): Promise<UserApiOperation[]> {
|
||||
const { baseUrl: url, token } = parseCredentials(baseUrl, credentials)
|
||||
const from =
|
||||
fromDate ||
|
||||
(() => {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 365)
|
||||
return d.toISOString().slice(0, 10)
|
||||
})()
|
||||
const data = await userApiRequest<UserApiOperation[]>(url, token, '/operation', {
|
||||
query: { from },
|
||||
})
|
||||
return Array.isArray(data) ? data : []
|
||||
}
|
||||
|
||||
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([
|
||||
fetchBalance(baseUrl, credentials),
|
||||
fetchServers(baseUrl, credentials),
|
||||
])
|
||||
return { ok: true, vdsCount: servers.length, balance: balanceInfo.balance }
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Ошибка подключения'
|
||||
return { ok: false, error: message }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
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 { syncFromUserApi } from './sync.js'
|
||||
import type { UserApiSyncAccount } from './context.js'
|
||||
|
||||
vi.mock('./operations.js', () => ({
|
||||
fetchServersWithDetails: vi.fn(),
|
||||
fetchBalance: vi.fn(),
|
||||
fetchTariffList: vi.fn(),
|
||||
fetchOperations: vi.fn(),
|
||||
}))
|
||||
|
||||
import {
|
||||
fetchBalance,
|
||||
fetchOperations,
|
||||
fetchServersWithDetails,
|
||||
fetchTariffList,
|
||||
} from './operations.js'
|
||||
|
||||
function makeAccount(apiType: 'macloud' | 'vdsina'): UserApiSyncAccount {
|
||||
return {
|
||||
id: `acc-${apiType}`,
|
||||
providerId: `prov-${apiType}`,
|
||||
name: `${apiType} Account`,
|
||||
panelUrl: '',
|
||||
currency: 'RUB',
|
||||
billingMode: 'daily',
|
||||
notes: '',
|
||||
apiType,
|
||||
apiBaseUrl:
|
||||
apiType === 'macloud'
|
||||
? 'https://userapi.macloud.ru/v1'
|
||||
: 'https://userapi.vdsina.com/v1',
|
||||
apiCredentials: 'secret-token',
|
||||
apiToken: 'secret-token',
|
||||
balanceApi: null,
|
||||
balanceCurrency: null,
|
||||
balanceUpdatedAt: null,
|
||||
enoughmoneyto: '',
|
||||
balanceAlertBelow: null,
|
||||
}
|
||||
}
|
||||
|
||||
describe('syncFromUserApi', () => {
|
||||
beforeEach(() => {
|
||||
resetTestDb()
|
||||
vi.mocked(fetchServersWithDetails).mockResolvedValue([
|
||||
{
|
||||
id: 100,
|
||||
name: 'Server #100',
|
||||
status: 'active',
|
||||
end: '2029-01-01',
|
||||
ip: { ip: '1.2.3.4', type: '4' },
|
||||
template: { name: 'Debian 12' },
|
||||
datacenter: { id: 1, name: 'DC1', country: 'ru' },
|
||||
'server-plan': { name: '2 RAM / 1 CPU / 40 NVMe' },
|
||||
data: { cpu: { value: 1 }, ram: { value: 2 }, disk: { value: 40 } },
|
||||
},
|
||||
])
|
||||
vi.mocked(fetchBalance).mockResolvedValue({
|
||||
balance: 500,
|
||||
currency: 'RUB',
|
||||
enoughmoneyto: '2029-12-01',
|
||||
})
|
||||
vi.mocked(fetchTariffList).mockResolvedValue([
|
||||
{
|
||||
externalId: '13',
|
||||
datacenterKey: '11',
|
||||
datacenterName: 'Cloud',
|
||||
name: '2 RAM / 1 CPU / 40 NVMe',
|
||||
desc: '',
|
||||
vcpu: 1,
|
||||
ramGb: 2,
|
||||
diskGb: 40,
|
||||
diskType: 'NVMe',
|
||||
virtualization: 'KVM',
|
||||
channel: '',
|
||||
location: 'Cloud',
|
||||
country: '',
|
||||
cpuModel: '',
|
||||
orderAvailable: true,
|
||||
price: '1.55 ₽/день',
|
||||
},
|
||||
])
|
||||
vi.mocked(fetchOperations).mockResolvedValue([
|
||||
{
|
||||
id: 999,
|
||||
purse: 'real',
|
||||
type: 1,
|
||||
status: 1,
|
||||
summ: '200',
|
||||
created: '2025-03-01 10:00:00',
|
||||
comment: 'Top up',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
closeDb()
|
||||
})
|
||||
|
||||
it('syncs macloud account with correct id prefix', async () => {
|
||||
getSqlite()
|
||||
.prepare(
|
||||
`INSERT INTO providers (id, name, apiType, apiBaseUrl) VALUES ('prov-macloud', 'Macloud', 'macloud', 'https://userapi.macloud.ru/v1')`,
|
||||
)
|
||||
.run()
|
||||
providerAccountsRepository.create({
|
||||
id: 'acc-macloud',
|
||||
providerId: 'prov-macloud',
|
||||
name: 'Macloud',
|
||||
apiCredentials: 'secret-token',
|
||||
})
|
||||
|
||||
const result = await syncFromUserApi(makeAccount('macloud'))
|
||||
|
||||
expect(result.vpsCount).toBe(1)
|
||||
expect(result.paymentsCount).toBe(1)
|
||||
expect(result.tariffsCount).toBe(1)
|
||||
expect(result.balance?.balance).toBe(500)
|
||||
|
||||
const vps = getSqlite().prepare('SELECT id, notes FROM vps WHERE id = ?').get('vps-macloud-acc-macloud-100') as {
|
||||
id: string
|
||||
notes: string
|
||||
}
|
||||
expect(vps.notes).toContain('macloud-100')
|
||||
})
|
||||
|
||||
it('syncs vdsina account with vdsina id prefix', async () => {
|
||||
getSqlite()
|
||||
.prepare(
|
||||
`INSERT INTO providers (id, name, apiType, apiBaseUrl) VALUES ('prov-vdsina', 'VDSina', 'vdsina', 'https://userapi.vdsina.com/v1')`,
|
||||
)
|
||||
.run()
|
||||
providerAccountsRepository.create({
|
||||
id: 'acc-vdsina',
|
||||
providerId: 'prov-vdsina',
|
||||
name: 'VDSina',
|
||||
apiCredentials: 'secret-token',
|
||||
})
|
||||
|
||||
const result = await syncFromUserApi(makeAccount('vdsina'))
|
||||
|
||||
expect(result.vpsCount).toBe(1)
|
||||
const vps = getSqlite().prepare('SELECT id FROM vps WHERE id = ?').get('vps-vdsina-acc-vdsina-100')
|
||||
expect(vps).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* Sync Macloud / VDSina UserAPI data into vps-tracker DB
|
||||
*/
|
||||
|
||||
import { and, eq, like, or } from 'drizzle-orm'
|
||||
import { getDb, schema } from '@cfdm/db'
|
||||
|
||||
import type { UserApiSyncAccount } from './context.js'
|
||||
import { mapOperationToPayment, mapServerToVps } from './mappers.js'
|
||||
import {
|
||||
fetchBalance,
|
||||
fetchOperations,
|
||||
fetchServersWithDetails,
|
||||
fetchTariffList,
|
||||
type UserApiBalanceResult,
|
||||
} from './operations.js'
|
||||
|
||||
export interface SyncFromUserApiOptions {
|
||||
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 SyncFromUserApiResult {
|
||||
vpsCount: number
|
||||
paymentsCount: number
|
||||
tariffsCount: number
|
||||
newTariffs: { name: string; price: string; providerId: string }[]
|
||||
balance: UserApiBalanceResult | null
|
||||
syncSummary: SyncSummary
|
||||
}
|
||||
|
||||
const SYNC_UPDATE_FIELDS = [
|
||||
'country',
|
||||
'city',
|
||||
'datacenter',
|
||||
'os',
|
||||
'notes',
|
||||
'status',
|
||||
'tariffType',
|
||||
'currency',
|
||||
'dailyRate',
|
||||
'monthlyRate',
|
||||
'paidUntil',
|
||||
] as const
|
||||
|
||||
function normVal(v: unknown): string {
|
||||
if (v == null || v === '') return ''
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? String(v) : ''
|
||||
return String(v)
|
||||
}
|
||||
|
||||
export async function syncFromUserApi(
|
||||
account: UserApiSyncAccount,
|
||||
opts: SyncFromUserApiOptions = {},
|
||||
): Promise<SyncFromUserApiResult> {
|
||||
const { skipTariffs = false, skipVpsPayments = false } = opts
|
||||
const { apiBaseUrl, apiType, providerId, id: accountId, apiToken } = account
|
||||
if (!apiBaseUrl?.trim() || !apiToken?.trim()) {
|
||||
throw new Error('API URL and credentials are required')
|
||||
}
|
||||
const db = getDb()
|
||||
const credentials = apiToken
|
||||
const idPrefix = apiType
|
||||
|
||||
const fetchVpsData = !skipVpsPayments
|
||||
const fetchTariffs = !skipTariffs
|
||||
|
||||
const [servers, balanceInfo, tariffItems, operations] = await Promise.all([
|
||||
fetchVpsData ? fetchServersWithDetails(apiBaseUrl, credentials) : [],
|
||||
fetchVpsData
|
||||
? fetchBalance(apiBaseUrl, credentials, account.currency || 'RUB').catch(() => null)
|
||||
: null,
|
||||
fetchTariffs ? fetchTariffList(apiBaseUrl, credentials).catch(() => []) : [],
|
||||
fetchVpsData ? fetchOperations(apiBaseUrl, credentials).catch(() => []) : [],
|
||||
])
|
||||
|
||||
let vpsCount = 0
|
||||
const syncSummary: SyncSummary = { added: [], updated: [], paymentsAdded: 0 }
|
||||
|
||||
if (fetchVpsData) {
|
||||
for (const server of servers) {
|
||||
const vps = mapServerToVps(server, apiType, providerId, accountId)
|
||||
const id = `vps-${idPrefix}-${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, `%${idPrefix}-${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++
|
||||
}
|
||||
}
|
||||
|
||||
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 operations) {
|
||||
const payment = mapOperationToPayment(item, apiType, accountId, account.currency || 'RUB')
|
||||
if (!payment) continue
|
||||
const note = payment.note
|
||||
if (existingPayments.has(note)) continue
|
||||
const payId = `pay-${idPrefix}-${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 || 'RUB',
|
||||
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-${idPrefix}-${accountId}-${t.externalId}-${dcKey}`
|
||||
: `tariff-${idPrefix}-${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,
|
||||
}
|
||||
}
|
||||
@@ -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 } from '@/lib/provider-sync'
|
||||
import { accountCredentialLabels, isUserApiType } from '@/lib/provider-sync'
|
||||
|
||||
const EMPTY: ProviderAccountFormValues = {
|
||||
providerId: '',
|
||||
@@ -84,9 +84,11 @@ export function ProviderAccountEditSheet({
|
||||
onOpenChange={onOpenChange}
|
||||
title={isEdit ? 'Редактировать аккаунт' : 'Новый аккаунт'}
|
||||
description={
|
||||
initialProvider?.apiType === '4vps'
|
||||
? 'Panel ID и API Key хранятся на сервере и используются для синка с 4VPS'
|
||||
: 'API-креды хранятся на сервере и используются для синка с BILLmanager'
|
||||
isUserApiType(initialProvider?.apiType)
|
||||
? 'API Token хранится на сервере и используется для синка с UserAPI'
|
||||
: initialProvider?.apiType === '4vps'
|
||||
? 'Panel ID и API Key хранятся на сервере и используются для синка с 4VPS'
|
||||
: 'API-креды хранятся на сервере и используются для синка с BILLmanager'
|
||||
}
|
||||
schema={providerAccountSchema as unknown as ZodType<ProviderAccountFormValues>}
|
||||
defaultValues={defaultValues}
|
||||
@@ -103,6 +105,7 @@ export function ProviderAccountEditSheet({
|
||||
const apiCredentials = buildApiCredentials(apiLogin, apiPassword)
|
||||
const canTest = Boolean(apiBaseUrl && apiCredentials)
|
||||
const credLabels = accountCredentialLabels(provider?.apiType)
|
||||
const tokenOnly = isUserApiType(provider?.apiType)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -118,14 +121,16 @@ 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={credLabels.loginLabel} htmlFor="acc-login">
|
||||
<Input
|
||||
id="acc-login"
|
||||
autoComplete="off"
|
||||
placeholder={credLabels.loginPlaceholder || undefined}
|
||||
{...register('apiLogin')}
|
||||
/>
|
||||
</FormField>
|
||||
{!tokenOnly ? (
|
||||
<FormField label={credLabels.loginLabel} htmlFor="acc-login">
|
||||
<Input
|
||||
id="acc-login"
|
||||
autoComplete="off"
|
||||
placeholder={credLabels.loginPlaceholder || undefined}
|
||||
{...register('apiLogin')}
|
||||
/>
|
||||
</FormField>
|
||||
) : null}
|
||||
<FormField
|
||||
label={isEdit ? `${credLabels.passwordLabel} (необязательно)` : credLabels.passwordLabel}
|
||||
htmlFor="acc-password"
|
||||
|
||||
@@ -6,6 +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 } from '@cfdm/shared/contracts/provider'
|
||||
|
||||
const EMPTY: ProviderFormValues = {
|
||||
name: '',
|
||||
@@ -53,6 +54,20 @@ export function ProviderEditSheet({
|
||||
>
|
||||
{(form) => {
|
||||
const { register, formState: { errors }, watch, setValue } = form
|
||||
const apiType = watch('apiType')
|
||||
const apiUrlHint =
|
||||
apiType === '4vps'
|
||||
? 'Базовый URL API, например https://4vps.su/api'
|
||||
: isUserApiType(apiType)
|
||||
? `Базовый URL UserAPI, например ${USER_API_DEFAULT_BASE_URL[apiType]}`
|
||||
: 'Один URL на хостера для BILLmanager'
|
||||
const apiUrlPlaceholder =
|
||||
apiType === '4vps'
|
||||
? 'https://4vps.su/api'
|
||||
: isUserApiType(apiType)
|
||||
? USER_API_DEFAULT_BASE_URL[apiType]
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField label="Название" htmlFor="pr-name" error={errors.name?.message} invalid={!!errors.name}>
|
||||
@@ -69,24 +84,14 @@ export function ProviderEditSheet({
|
||||
options={[
|
||||
{ value: 'billmanager', label: 'BILLmanager' },
|
||||
{ value: '4vps', label: '4VPS.SU' },
|
||||
{ value: 'macloud', label: 'Маклауд' },
|
||||
{ value: 'vdsina', label: 'VDSina' },
|
||||
{ value: 'none', label: 'Нет' },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
<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 label="API URL" htmlFor="pr-apiurl" description={apiUrlHint}>
|
||||
<Input id="pr-apiurl" placeholder={apiUrlPlaceholder} {...register('apiBaseUrl')} />
|
||||
</FormField>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<FormField label="Валюта" htmlFor="pr-cur">
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { isSyncApiType } from '@cfdm/shared/contracts/provider'
|
||||
import { isSyncApiType, isUserApiType } from '@cfdm/shared/contracts/provider'
|
||||
|
||||
import type { ProviderAccount, Provider } from '@/types/entities'
|
||||
|
||||
export { isSyncApiType }
|
||||
export { isSyncApiType, isUserApiType }
|
||||
|
||||
export function providerByIdMap(providers: Provider[]): Map<string, Provider> {
|
||||
return new Map(providers.map((p) => [p.id, p]))
|
||||
@@ -69,6 +69,13 @@ export function accountCredentialLabels(apiType?: string | null): {
|
||||
loginPlaceholder: '1',
|
||||
}
|
||||
}
|
||||
if (isUserApiType(apiType)) {
|
||||
return {
|
||||
loginLabel: '',
|
||||
passwordLabel: 'API Token',
|
||||
loginPlaceholder: '',
|
||||
}
|
||||
}
|
||||
return {
|
||||
loginLabel: 'Логин API',
|
||||
passwordLabel: 'Пароль API',
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const API_TYPES = ['billmanager', '4vps', 'none'] as const
|
||||
export const API_TYPES = ['billmanager', '4vps', 'macloud', 'vdsina', '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 const SYNC_API_TYPES = ['billmanager', '4vps', 'macloud', 'vdsina'] as const
|
||||
export type SyncApiType = (typeof SYNC_API_TYPES)[number]
|
||||
|
||||
export const USER_API_TYPES = ['macloud', 'vdsina'] as const
|
||||
export type UserApiType = (typeof USER_API_TYPES)[number]
|
||||
|
||||
export const USER_API_DEFAULT_BASE_URL: Record<UserApiType, string> = {
|
||||
macloud: 'https://userapi.macloud.ru/v1',
|
||||
vdsina: 'https://userapi.vdsina.com/v1',
|
||||
}
|
||||
|
||||
export function isUserApiType(apiType?: string | null): apiType is UserApiType {
|
||||
const key = String(apiType || '').toLowerCase()
|
||||
return (USER_API_TYPES as readonly string[]).includes(key)
|
||||
}
|
||||
|
||||
export function isSyncApiType(apiType?: string | null): apiType is SyncApiType {
|
||||
const key = String(apiType || '').toLowerCase()
|
||||
return (SYNC_API_TYPES as readonly string[]).includes(key)
|
||||
|
||||
@@ -34,6 +34,11 @@ export function parseFourVpsCredentials(credentials: string | null | undefined):
|
||||
}
|
||||
}
|
||||
|
||||
/** Bearer token для Macloud / VDSina UserAPI. */
|
||||
export function parseUserApiToken(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()
|
||||
|
||||
Reference in New Issue
Block a user