feat(api, web): интеграция Macloud и VDSina через унифицированный UserAPI
Docker / build (push) Has been cancelled

Один модуль userapi для идентичного API обоих хостеров: синк VPS, баланса, тарифов и пополнений.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-06-29 02:02:03 +07:00
co-authored by Cursor
parent 9be50aa03b
commit f2c4c7a412
18 changed files with 1469 additions and 32 deletions
+112
View File
@@ -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)
})
})
+13 -1
View File
@@ -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 || '',
}
},
}
+1 -1
View File
@@ -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')
})
})
+71
View File
@@ -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
}
+35
View File
@@ -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 }
}
+17
View File
@@ -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()
})
})
+166
View File
@@ -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}`,
}
}
+323
View File
@@ -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 }
}
}
+151
View File
@@ -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()
})
})
+326
View File
@@ -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">
+9 -2
View File
@@ -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',
+15 -2
View File
@@ -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()