diff --git a/apps/api/src/routes/sync.test.ts b/apps/api/src/routes/sync.test.ts index 7b569e5..9903309 100644 --- a/apps/api/src/routes/sync.test.ts +++ b/apps/api/src/routes/sync.test.ts @@ -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> @@ -77,3 +88,104 @@ describe('sync routes — 4vps', () => { vi.unstubAllGlobals() }) }) + +describe('sync routes — macloud', () => { + let app: Awaited> + + 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> + + 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) + }) +}) diff --git a/apps/api/src/services/providers/index.ts b/apps/api/src/services/providers/index.ts index 07dc70b..71fbfe6 100644 --- a/apps/api/src/services/providers/index.ts +++ b/apps/api/src/services/providers/index.ts @@ -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 = { 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 = { billmanager: 'Укажите в настройках хостера тип API BILLmanager и URL; в аккаунте — логин и пароль API', '4vps': 'Укажите в настройках хостера тип API 4VPS и URL; в аккаунте — Panel ID и API Key', + macloud: 'Укажите тип API Маклауд и URL; в аккаунте — API Token', + vdsina: 'Укажите тип API VDSina и URL; в аккаунте — API Token', } diff --git a/apps/api/src/services/providers/userapi-adapter.ts b/apps/api/src/services/providers/userapi-adapter.ts new file mode 100644 index 0000000..8829a22 --- /dev/null +++ b/apps/api/src/services/providers/userapi-adapter.ts @@ -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 { + 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 || '', + } + }, +} diff --git a/apps/api/src/services/scheduler.ts b/apps/api/src/services/scheduler.ts index 9efb045..bc31155 100644 --- a/apps/api/src/services/scheduler.ts +++ b/apps/api/src/services/scheduler.ts @@ -34,7 +34,7 @@ function getSyncableAccounts(): SyncableAccountEntry[] { .all(sql` SELECT pa.* FROM provider_accounts pa INNER JOIN providers p ON p.id = pa.providerId - WHERE lower(trim(COALESCE(p.apiType, ''))) IN ('billmanager', '4vps') + 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 `) diff --git a/apps/api/src/services/userapi/client.test.ts b/apps/api/src/services/userapi/client.test.ts new file mode 100644 index 0000000..ec24d22 --- /dev/null +++ b/apps/api/src/services/userapi/client.test.ts @@ -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).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') + }) +}) diff --git a/apps/api/src/services/userapi/client.ts b/apps/api/src/services/userapi/client.ts new file mode 100644 index 0000000..1649f3b --- /dev/null +++ b/apps/api/src/services/userapi/client.ts @@ -0,0 +1,71 @@ +/** + * Macloud / VDSina UserAPI HTTP client (OpenAPI v1.2.3.1) + */ + +export interface UserApiEnvelope { + 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 + body?: Record +} + +export async function userApiRequest( + baseUrl: string, + token: string, + path: string, + opts: UserApiRequestOptions = {}, +): Promise { + 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 + + 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 +} diff --git a/apps/api/src/services/userapi/context.ts b/apps/api/src/services/userapi/context.ts new file mode 100644 index 0000000..1b36048 --- /dev/null +++ b/apps/api/src/services/userapi/context.ts @@ -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 } +} diff --git a/apps/api/src/services/userapi/index.ts b/apps/api/src/services/userapi/index.ts new file mode 100644 index 0000000..e61846b --- /dev/null +++ b/apps/api/src/services/userapi/index.ts @@ -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' diff --git a/apps/api/src/services/userapi/mappers.test.ts b/apps/api/src/services/userapi/mappers.test.ts new file mode 100644 index 0000000..55690a4 --- /dev/null +++ b/apps/api/src/services/userapi/mappers.test.ts @@ -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() + }) +}) diff --git a/apps/api/src/services/userapi/mappers.ts b/apps/api/src/services/userapi/mappers.ts new file mode 100644 index 0000000..98f9154 --- /dev/null +++ b/apps/api/src/services/userapi/mappers.ts @@ -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 = { + 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}`, + } +} diff --git a/apps/api/src/services/userapi/operations.ts b/apps/api/src/services/userapi/operations.ts new file mode 100644 index 0000000..6a73e1a --- /dev/null +++ b/apps/api/src/services/userapi/operations.ts @@ -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 { + const { baseUrl: url, token } = parseCredentials(baseUrl, credentials) + const data = await userApiRequest(url, token, '/account') + return data ?? {} +} + +export async function fetchBalance( + baseUrl: string, + credentials: string, + fallbackCurrency = 'RUB', +): Promise { + const { baseUrl: url, token } = parseCredentials(baseUrl, credentials) + const [balanceData, accountData] = await Promise.all([ + userApiRequest(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 { + const { baseUrl: url, token } = parseCredentials(baseUrl, credentials) + const data = await userApiRequest(url, token, '/server') + return Array.isArray(data) ? data : [] +} + +export async function fetchServerDetail( + baseUrl: string, + credentials: string, + serverId: number, +): Promise { + const { baseUrl: url, token } = parseCredentials(baseUrl, credentials) + const data = await userApiRequest(url, token, `/server/${serverId}`) + return data ?? null +} + +export async function fetchServersWithDetails( + baseUrl: string, + credentials: string, + concurrency = 10, +): Promise { + 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> { + const { baseUrl: url, token } = parseCredentials(baseUrl, credentials) + const data = await userApiRequest(url, token, '/datacenter') + const map = new Map() + 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 { + const { baseUrl: url, token } = parseCredentials(baseUrl, credentials) + const data = await userApiRequest(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 { + const { baseUrl: url, token } = parseCredentials(baseUrl, credentials) + const data = await userApiRequest(url, token, `/server-plan/${groupId}`) + return Array.isArray(data) ? data : [] +} + +export async function fetchTariffList( + baseUrl: string, + credentials: string, +): Promise { + 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 { + 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(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 } + } +} diff --git a/apps/api/src/services/userapi/sync.test.ts b/apps/api/src/services/userapi/sync.test.ts new file mode 100644 index 0000000..ad5aab0 --- /dev/null +++ b/apps/api/src/services/userapi/sync.test.ts @@ -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() + }) +}) diff --git a/apps/api/src/services/userapi/sync.ts b/apps/api/src/services/userapi/sync.ts new file mode 100644 index 0000000..a4cf96d --- /dev/null +++ b/apps/api/src/services/userapi/sync.ts @@ -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 { + 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, + } +} diff --git a/apps/web/src/components/domain/account-edit-sheet.tsx b/apps/web/src/components/domain/account-edit-sheet.tsx index 43eef2d..fecc0a6 100644 --- a/apps/web/src/components/domain/account-edit-sheet.tsx +++ b/apps/web/src/components/domain/account-edit-sheet.tsx @@ -14,7 +14,7 @@ import { providerAccountSchema, type ProviderAccountFormValues } from '@/lib/sch import type { BillingMode, Provider } from '@/types/entities' import { billingModeLabel } from '@/lib/format' import { api, ApiError } from '@/lib/api-client' -import { accountCredentialLabels } 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} 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({ - - - + {!tokenOnly ? ( + + + + ) : null} {(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 ( <> @@ -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: 'Нет' }, ]} /> - - + +
diff --git a/apps/web/src/lib/provider-sync.ts b/apps/web/src/lib/provider-sync.ts index 24c4341..8d55d5a 100644 --- a/apps/web/src/lib/provider-sync.ts +++ b/apps/web/src/lib/provider-sync.ts @@ -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 { 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', diff --git a/packages/shared/src/contracts/provider.ts b/packages/shared/src/contracts/provider.ts index 7327cf8..d187116 100644 --- a/packages/shared/src/contracts/provider.ts +++ b/packages/shared/src/contracts/provider.ts @@ -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 = { + 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) diff --git a/packages/shared/src/utils/api-credentials.ts b/packages/shared/src/utils/api-credentials.ts index 7fd8d69..bb8e1a1 100644 --- a/packages/shared/src/utils/api-credentials.ts +++ b/packages/shared/src/utils/api-credentials.ts @@ -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()