From ce6414c65f1f50b6a62ff5f8cd590c596f38cb9f Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 29 Jun 2026 12:10:47 +0700 Subject: [PATCH] =?UTF-8?q?fix(veesp):=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20IP,=20=D1=85=D0=B0=D1=80=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D0=B5=D1=80=D0=B8=D1=81=D1=82=D0=B8=D0=BA=20=D0=B8?= =?UTF-8?q?=20=D0=B2=D0=B0=D0=BB=D1=8E=D1=82=D1=8B=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B2=D1=82=D0=BE=D1=80=D0=BD=D0=BE=D0=BC=20=D1=81?= =?UTF-8?q?=D0=B8=D0=BD=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Перезапись vcpu/ram/disk/ip на update, fetch VM ips, приоритет валюты из balance API. Co-authored-by: Cursor --- .../src/services/currency-priority.test.ts | 22 +++++- apps/api/src/services/veesp/mappers.ts | 9 ++- apps/api/src/services/veesp/operations.ts | 45 +++++++++-- apps/api/src/services/veesp/sync.test.ts | 79 +++++++++++++++++++ apps/api/src/services/veesp/sync.ts | 31 +++++++- .../db/src/repositories/provider-accounts.ts | 2 +- packages/shared/src/utils/account-balance.ts | 14 ++-- 7 files changed, 185 insertions(+), 17 deletions(-) diff --git a/apps/api/src/services/currency-priority.test.ts b/apps/api/src/services/currency-priority.test.ts index ec9e6b7..cde2f5a 100644 --- a/apps/api/src/services/currency-priority.test.ts +++ b/apps/api/src/services/currency-priority.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { resolveProviderCurrency } from '@cfdm/shared/utils/currency' -import { effectiveAccountBalanceCurrency } from '@cfdm/shared/utils/account-balance' +import { effectiveAccountBalanceCurrency, syncFallbackCurrency } from '@cfdm/shared/utils/account-balance' describe('resolveProviderCurrency', () => { it('prefers account currency over provider baseCurrency', () => { @@ -12,6 +12,26 @@ describe('resolveProviderCurrency', () => { }) }) +describe('syncFallbackCurrency', () => { + it('uses fresh balance currency when account currency is empty', () => { + expect( + syncFallbackCurrency( + { currency: '', providerBaseCurrency: 'EUR', balanceCurrency: 'EUR' }, + { balanceCurrency: 'RUB' }, + ), + ).toBe('RUB') + }) + + it('prefers explicit account currency over balance', () => { + expect( + syncFallbackCurrency( + { currency: 'RUB', providerBaseCurrency: 'EUR', balanceCurrency: 'EUR' }, + { balanceCurrency: 'EUR' }, + ), + ).toBe('RUB') + }) +}) + describe('effectiveAccountBalanceCurrency', () => { it('uses account currency for balance display', () => { expect( diff --git a/apps/api/src/services/veesp/mappers.ts b/apps/api/src/services/veesp/mappers.ts index 82a7989..92df922 100644 --- a/apps/api/src/services/veesp/mappers.ts +++ b/apps/api/src/services/veesp/mappers.ts @@ -67,14 +67,21 @@ function isIpv4(value: string): boolean { return /^\d{1,3}(\.\d{1,3}){3}$/.test(value) } +function isIpv6(value: string): boolean { + return value.includes(':') +} + function normalizeIpField(raw: string | string[] | undefined | null): string { if (raw == null) return '' if (Array.isArray(raw)) { + let fallback = '' for (const item of raw) { const ip = String(item).trim() + if (!ip) continue if (isIpv4(ip)) return ip + if (!fallback && isIpv6(ip)) fallback = ip } - return String(raw[0] ?? '').trim() + return fallback } return String(raw).trim() } diff --git a/apps/api/src/services/veesp/operations.ts b/apps/api/src/services/veesp/operations.ts index 33ff263..6561ff6 100644 --- a/apps/api/src/services/veesp/operations.ts +++ b/apps/api/src/services/veesp/operations.ts @@ -305,6 +305,35 @@ export async function fetchServiceIps( } } +export async function fetchVmIps( + baseUrl: string, + credentials: string, + serviceId: string | number, + vmId: string | number, +): Promise { + const client = clientFor(baseUrl, credentials) + try { + const json = await client.request(`/service/${serviceId}/vms/${vmId}/ips`) + return unwrapList(json, 'ips') + } catch { + return [] + } +} + +function mergeIpLists(...lists: VeespIpItem[][]): VeespIpItem[] { + const seen = new Set() + const out: VeespIpItem[] = [] + for (const list of lists) { + for (const item of list) { + const ip = String(item.ip ?? item.address ?? item.ipaddress ?? '').trim() + if (!ip || seen.has(ip)) continue + seen.add(ip) + out.push(item) + } + } + return out +} + export async function fetchServiceInfo( baseUrl: string, credentials: string, @@ -478,19 +507,25 @@ export async function fetchVpsRecords( const vmDetails = await Promise.all( vms.map(async (vm) => { const vmId = vm.id ?? vm.vmid - if (vmId == null) return vm as VeespVmDetail - const detail = await fetchVmDetail(baseUrl, credentials, serviceId, vmId) - return { ...vm, ...detail } as VeespVmDetail + if (vmId == null) return { vm: vm as VeespVmDetail, vmIps: [] as VeespIpItem[] } + const [detail, vmIps] = await Promise.all([ + fetchVmDetail(baseUrl, credentials, serviceId, vmId), + fetchVmIps(baseUrl, credentials, serviceId, vmId), + ]) + return { + vm: { ...vm, ...detail } as VeespVmDetail, + vmIps, + } }), ) - return vmDetails.map((vm) => ({ + return vmDetails.map(({ vm, vmIps }) => ({ serviceId, vmId: String(vm.id ?? vm.vmid ?? vmExternalId(serviceId, vm)), service, serviceDetail, vm, - ips, + ips: mergeIpLists(ips, vmIps), info, })) }), diff --git a/apps/api/src/services/veesp/sync.test.ts b/apps/api/src/services/veesp/sync.test.ts index 755462d..6befb8f 100644 --- a/apps/api/src/services/veesp/sync.test.ts +++ b/apps/api/src/services/veesp/sync.test.ts @@ -157,4 +157,83 @@ describe('syncFromVeesp', () => { .get('vps-veesp-acc-veesp-100-200') as { currency: string } expect(vps.currency).toBe('USD') }) + + it('updates specs and currency on existing VPS during re-sync', async () => { + getSqlite().exec(` + INSERT INTO vps ( + id, ip, ipv6, additionalIps, dns, providerId, providerAccountId, + country, city, datacenter, os, vcpu, ramGb, diskGb, diskType, virtualization, + bandwidthTb, sshPort, rootUser, purpose, environment, project, projectId, + monitoringEnabled, backupEnabled, status, tariffType, currency, dailyRate, + monthlyRate, createdAt, paidUntil, notes, userOverrides + ) VALUES ( + 'vps-veesp-acc-veesp-100-200', '', '', '[]', 'rkn0', 'prov-veesp', 'acc-veesp', + '', '', 'Proxmox', '', 0, 0, 0, 'NVMe', 'KVM', + 0, 22, 'root', '', '', '', NULL, + 0, 0, 'active', 'monthly', 'EUR', NULL, + 500, '2026-01-01', '2027-01-01', 'rkn0 [veesp-100]', '[]' + ) + `) + + vi.mocked(fetchVpsRecords).mockResolvedValue([ + { + serviceId: '100', + vmId: '200', + service: { + id: '100', + domain: 'rkn0', + total: '500.00', + status: 'Active', + billingcycle: 'Monthly', + next_due: '2027-01-01', + category: 'Proxmox', + category_url: 'virtual-private-servers', + name: 'VPS', + }, + serviceDetail: { + id: '100', + total: '500.00', + billingcycle: 'Monthly', + next_due: '2027-01-01', + status: 'Active', + domain: 'rkn0', + date_created: '2026-01-01', + }, + vm: { + id: '200', + label: 'rkn0', + hostname: 'rkn0', + cpus: '2', + memory: 2048, + disk: 40, + ip: ['198.51.100.5'], + template_label: 'Debian 12', + status: 'active', + }, + ips: [{ ip: '198.51.100.5', main: true }], + info: null, + }, + ]) + + await syncFromVeesp({ + ...makeAccount(), + currency: 'RUB', + providerBaseCurrency: 'EUR', + }) + + const vps = getSqlite() + .prepare('SELECT ip, vcpu, ramGb, diskGb, currency FROM vps WHERE id = ?') + .get('vps-veesp-acc-veesp-100-200') as { + ip: string + vcpu: number + ramGb: number + diskGb: number + currency: string + } + expect(vps.ip).toBe('198.51.100.5') + expect(vps.vcpu).toBe(2) + expect(vps.ramGb).toBe(2) + expect(vps.diskGb).toBe(40) + expect(vps.currency).toBe('RUB') + }) }) diff --git a/apps/api/src/services/veesp/sync.ts b/apps/api/src/services/veesp/sync.ts index 44cbb92..4599efc 100644 --- a/apps/api/src/services/veesp/sync.ts +++ b/apps/api/src/services/veesp/sync.ts @@ -52,6 +52,8 @@ const SYNC_UPDATE_FIELDS = [ 'paidUntil', ] as const +const SYNC_SPEC_FIELDS = ['vcpu', 'ramGb', 'diskGb'] as const + function normVal(v: unknown): string { if (v == null || v === '') return '' if (typeof v === 'number') return Number.isFinite(v) ? String(v) : '' @@ -72,17 +74,20 @@ export async function syncFromVeesp( const fetchVpsData = !skipVpsPayments const fetchTariffs = !skipTariffs - const fallbackCurrency = syncFallbackCurrency(account) const [records, balanceInfo, tariffItems, invoices] = await Promise.all([ fetchVpsData ? fetchVpsRecords(apiBaseUrl, credentials) : [], fetchVpsData - ? fetchBalance(apiBaseUrl, credentials, fallbackCurrency).catch(() => null) + ? fetchBalance(apiBaseUrl, credentials, syncFallbackCurrency(account)).catch(() => null) : null, - fetchTariffs ? fetchTariffList(apiBaseUrl, credentials, fallbackCurrency).catch(() => []) : [], + fetchTariffs ? fetchTariffList(apiBaseUrl, credentials, syncFallbackCurrency(account)).catch(() => []) : [], fetchVpsData ? fetchInvoices(apiBaseUrl, credentials).catch(() => []) : [], ]) + const fallbackCurrency = syncFallbackCurrency(account, { + balanceCurrency: balanceInfo?.currency, + }) + let vpsCount = 0 const syncSummary: SyncSummary = { added: [], updated: [], paymentsAdded: 0 } @@ -105,6 +110,7 @@ export async function syncFromVeesp( or( ...(vps.ip ? [eq(schema.vps.ip, vps.ip)] : []), like(schema.vps.notes, `%veesp-${vps.externalId}%`), + like(schema.vps.notes, `%veesp-${record.serviceId}%`), ), ), ) @@ -126,6 +132,9 @@ export async function syncFromVeesp( city: vps.city, datacenter: vps.datacenter, os: vps.os, + vcpu: vps.vcpu, + ramGb: vps.ramGb, + diskGb: vps.diskGb, status: vps.status, tariffType: vps.tariffType, currency: vps.currency, @@ -139,7 +148,18 @@ export async function syncFromVeesp( merged[f] = existing[f as keyof typeof existing] as never } } - const compareFields = ['ip', 'ipv6', 'dns', ...SYNC_UPDATE_FIELDS] as const + for (const f of SYNC_SPEC_FIELDS) { + if (userOverrides.includes(f)) { + merged[f] = existing[f as keyof typeof existing] as never + } + } + const compareFields = [ + 'ip', + 'ipv6', + 'dns', + ...SYNC_SPEC_FIELDS, + ...SYNC_UPDATE_FIELDS, + ] as const const changedFields = compareFields.filter( (f) => normVal(merged[f as keyof typeof merged]) !== normVal(existing[f as keyof typeof existing]), ) @@ -157,6 +177,9 @@ export async function syncFromVeesp( city: merged.city, datacenter: merged.datacenter, os: merged.os, + vcpu: merged.vcpu, + ramGb: merged.ramGb, + diskGb: merged.diskGb, status: merged.status, tariffType: merged.tariffType, currency: merged.currency, diff --git a/packages/db/src/repositories/provider-accounts.ts b/packages/db/src/repositories/provider-accounts.ts index 01df203..77dd993 100644 --- a/packages/db/src/repositories/provider-accounts.ts +++ b/packages/db/src/repositories/provider-accounts.ts @@ -145,7 +145,7 @@ export const providerAccountsRepository = { providerId: input.providerId ?? existing.providerId, name: input.name ?? existing.name, panelUrl: input.panelUrl ?? existing.panelUrl, - currency: input.currency ?? existing.currency, + currency: input.currency !== undefined ? input.currency : existing.currency, billingMode: input.billingMode ?? existing.billingMode, notes: input.notes ?? existing.notes, apiType: '', diff --git a/packages/shared/src/utils/account-balance.ts b/packages/shared/src/utils/account-balance.ts index 7999c7a..365c3e1 100644 --- a/packages/shared/src/utils/account-balance.ts +++ b/packages/shared/src/utils/account-balance.ts @@ -32,10 +32,14 @@ export function effectiveAccountBalanceCurrency( } export function syncFallbackCurrency( - account: { currency?: string | null; providerBaseCurrency?: string | null }, + account: { currency?: string | null; providerBaseCurrency?: string | null; balanceCurrency?: string | null }, + options?: { balanceCurrency?: string | null }, ): string { - return resolveProviderCurrency( - { baseCurrency: account.providerBaseCurrency }, - account.currency, - ) + const accountCur = (account.currency ?? '').trim() + if (accountCur) return accountCur + const freshBalanceCur = (options?.balanceCurrency ?? '').trim() + if (freshBalanceCur) return freshBalanceCur + const storedBalanceCur = (account.balanceCurrency ?? '').trim() + if (storedBalanceCur) return storedBalanceCur + return resolveProviderCurrency({ baseCurrency: account.providerBaseCurrency }, null) }