feat(api, web): интеграция хостера 4VPS.SU — синк VPS, баланса и тарифов
Docker / build (push) Has been cancelled

Добавлен адаптер 4vps, обобщён pipeline синхронизации через ProviderAdapter и обновлён UI для Panel ID и API Key.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-06-29 01:13:50 +07:00
co-authored by Cursor
parent 05e7bf829e
commit 9be50aa03b
33 changed files with 1564 additions and 174 deletions
@@ -14,6 +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'
const EMPTY: ProviderAccountFormValues = {
providerId: '',
@@ -59,10 +60,11 @@ export function ProviderAccountEditSheet({
onBalanceRefreshed,
}: ProviderAccountEditSheetProps) {
const isEdit = Boolean(defaultValues.id)
const initialProvider = providers.find((p) => p.id === defaultValues.providerId)
const testMut = useMutation({
mutationFn: async (values: { apiBaseUrl: string; apiCredentials: string }) =>
api.testConnection(values.apiBaseUrl, values.apiCredentials),
mutationFn: async (values: { apiBaseUrl: string; apiCredentials: string; apiType: string }) =>
api.testConnection(values.apiBaseUrl, values.apiCredentials, values.apiType),
onSuccess: () => toast.success('Подключение успешно'),
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка подключения'),
})
@@ -81,7 +83,11 @@ export function ProviderAccountEditSheet({
open={open}
onOpenChange={onOpenChange}
title={isEdit ? 'Редактировать аккаунт' : 'Новый аккаунт'}
description="API-креды хранятся на сервере и используются для синка с BILLmanager"
description={
initialProvider?.apiType === '4vps'
? 'Panel ID и API Key хранятся на сервере и используются для синка с 4VPS'
: 'API-креды хранятся на сервере и используются для синка с BILLmanager'
}
schema={providerAccountSchema as unknown as ZodType<ProviderAccountFormValues>}
defaultValues={defaultValues}
onSubmit={onSubmit}
@@ -96,6 +102,7 @@ export function ProviderAccountEditSheet({
const apiPassword = watch('apiPassword')?.trim() ?? ''
const apiCredentials = buildApiCredentials(apiLogin, apiPassword)
const canTest = Boolean(apiBaseUrl && apiCredentials)
const credLabels = accountCredentialLabels(provider?.apiType)
return (
<>
@@ -111,13 +118,18 @@ 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="Логин API" htmlFor="acc-login">
<Input id="acc-login" autoComplete="off" {...register('apiLogin')} />
<FormField label={credLabels.loginLabel} htmlFor="acc-login">
<Input
id="acc-login"
autoComplete="off"
placeholder={credLabels.loginPlaceholder || undefined}
{...register('apiLogin')}
/>
</FormField>
<FormField
label={isEdit ? 'Пароль API (необязательно)' : 'Пароль API'}
label={isEdit ? `${credLabels.passwordLabel} (необязательно)` : credLabels.passwordLabel}
htmlFor="acc-password"
description={isEdit ? 'Оставьте пустым, чтобы сохранить существующий пароль' : undefined}
description={isEdit ? 'Оставьте пустым, чтобы сохранить существующий ключ' : undefined}
>
<Input id="acc-password" type="password" autoComplete="new-password" {...register('apiPassword')} />
</FormField>
@@ -128,7 +140,13 @@ export function ProviderAccountEditSheet({
size="sm"
disabled={!canTest}
loading={testMut.isPending}
onClick={() => testMut.mutate({ apiBaseUrl, apiCredentials })}
onClick={() =>
testMut.mutate({
apiBaseUrl,
apiCredentials,
apiType: provider?.apiType ?? 'billmanager',
})
}
>
<PlugIcon data-icon="inline-start" />
Проверить подключение
@@ -68,12 +68,25 @@ export function ProviderEditSheet({
onValueChange={(v) => setValue('apiType', (v ?? 'none') as ApiType, { shouldValidate: true })}
options={[
{ value: 'billmanager', label: 'BILLmanager' },
{ value: '4vps', label: '4VPS.SU' },
{ value: 'none', label: 'Нет' },
]}
/>
</FormField>
<FormField label="API URL" htmlFor="pr-apiurl" description="Один URL на хостера для BILLmanager">
<Input id="pr-apiurl" {...register('apiBaseUrl')} />
<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>
<div className="grid grid-cols-3 gap-3">
<FormField label="Валюта" htmlFor="pr-cur">
+4 -3
View File
@@ -5,7 +5,8 @@ import type {
BalanceLedgerRow,
} from '@/types/entities'
import { accountBalanceApi } from '@/lib/account'
import { accountBillmanagerUiReady } from '@/lib/billmanager'
import { isSyncApiType } from '@cfdm/shared/contracts/provider'
import { accountSyncUiReady } from '@/lib/provider-sync'
import {
accountHasApiLedgerMismatch,
getStaleSyncAccountIds,
@@ -40,7 +41,7 @@ export function getAccountHealthFlags(
const provider = ctx.providers.find((p) => p.id === account.providerId)
const flags: AccountHealthFlag[] = []
if (provider?.apiType === 'billmanager' && !account.apiCredentialsSet) {
if (isSyncApiType(provider?.apiType) && !account.apiCredentialsSet) {
flags.push('no-creds')
}
@@ -99,5 +100,5 @@ export function countLowBalanceAccounts(
export function isAccountSyncable(account: ProviderAccount, providers: Provider[]): boolean {
const provider = providers.find((p) => p.id === account.providerId)
return accountBillmanagerUiReady(account, provider)
return accountSyncUiReady(account, provider)
}
+2 -2
View File
@@ -101,10 +101,10 @@ export const api = {
`/api/sync/${encodeURIComponent(accountId)}/balance`,
),
testConnection: (apiBaseUrl: string, apiCredentials: string) =>
testConnection: (apiBaseUrl: string, apiCredentials: string, apiType?: string) =>
fetchApi('/api/sync/test-connection', {
method: 'POST',
body: JSON.stringify({ apiBaseUrl, apiCredentials }),
body: JSON.stringify({ apiBaseUrl, apiCredentials, apiType }),
}),
fetchSyncStatus: () => fetchApi('/api/sync/status'),
+12 -45
View File
@@ -1,45 +1,12 @@
import type { ProviderAccount, Provider } from '@/types/entities'
export function providerByIdMap(providers: Provider[]): Map<string, Provider> {
return new Map(providers.map((p) => [p.id, p]))
}
export function billmanagerSyncableAccounts(
providerAccounts: ProviderAccount[],
providers: Provider[],
): ProviderAccount[] {
const pmap = providerByIdMap(providers)
return providerAccounts.filter((a) => {
const p = pmap.get(a.providerId)
return p?.apiType === 'billmanager' && Boolean((p.apiBaseUrl || '').trim()) && a.apiCredentialsSet
})
}
export function accountBillmanagerUiReady(
account: ProviderAccount,
provider?: Provider | null,
): boolean {
return (
provider?.apiType === 'billmanager' &&
Boolean((provider.apiBaseUrl || '').trim()) &&
Boolean(account.apiCredentialsSet)
)
}
export function accountUsesBillmanagerBalanceApi(
account: ProviderAccount,
provider?: Provider | null,
): boolean {
return provider?.apiType === 'billmanager' && account.balance_api != null
}
export function accountSelectLabel(
account: ProviderAccount,
providerById: Map<string, Provider>,
scopedProviderId?: string,
): string {
const name = account.name?.trim() || '—'
if (scopedProviderId) return name
const providerName = providerById.get(account.providerId)?.name ?? '—'
return `${providerName} / ${name}`
}
export {
providerByIdMap,
syncableAccounts,
billmanagerSyncableAccounts,
accountSyncUiReady,
accountBillmanagerUiReady,
accountUsesApiBalance,
accountUsesBillmanagerBalanceApi,
accountSelectLabel,
isSyncApiType,
accountCredentialLabels,
} from './provider-sync'
+77
View File
@@ -0,0 +1,77 @@
import { isSyncApiType } from '@cfdm/shared/contracts/provider'
import type { ProviderAccount, Provider } from '@/types/entities'
export { isSyncApiType }
export function providerByIdMap(providers: Provider[]): Map<string, Provider> {
return new Map(providers.map((p) => [p.id, p]))
}
export function syncableAccounts(
providerAccounts: ProviderAccount[],
providers: Provider[],
): ProviderAccount[] {
const pmap = providerByIdMap(providers)
return providerAccounts.filter((a) => {
const p = pmap.get(a.providerId)
return isSyncApiType(p?.apiType) && Boolean((p?.apiBaseUrl || '').trim()) && a.apiCredentialsSet
})
}
/** @deprecated Используйте syncableAccounts */
export const billmanagerSyncableAccounts = syncableAccounts
export function accountSyncUiReady(
account: ProviderAccount,
provider?: Provider | null,
): boolean {
return (
isSyncApiType(provider?.apiType) &&
Boolean((provider?.apiBaseUrl || '').trim()) &&
Boolean(account.apiCredentialsSet)
)
}
/** @deprecated Используйте accountSyncUiReady */
export const accountBillmanagerUiReady = accountSyncUiReady
export function accountUsesApiBalance(
account: ProviderAccount,
provider?: Provider | null,
): boolean {
return isSyncApiType(provider?.apiType) && account.balance_api != null
}
/** @deprecated Используйте accountUsesApiBalance */
export const accountUsesBillmanagerBalanceApi = accountUsesApiBalance
export function accountSelectLabel(
account: ProviderAccount,
providerById: Map<string, Provider>,
scopedProviderId?: string,
): string {
const name = account.name?.trim() || '—'
if (scopedProviderId) return name
const providerName = providerById.get(account.providerId)?.name ?? '—'
return `${providerName} / ${name}`
}
export function accountCredentialLabels(apiType?: string | null): {
loginLabel: string
passwordLabel: string
loginPlaceholder: string
} {
if (String(apiType).toLowerCase() === '4vps') {
return {
loginLabel: 'Panel ID',
passwordLabel: 'API Key',
loginPlaceholder: '1',
}
}
return {
loginLabel: 'Логин API',
passwordLabel: 'Пароль API',
loginPlaceholder: '',
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { z } from 'zod'
import { apiTypeSchema as sharedApiTypeSchema } from '@cfdm/shared/contracts/provider'
import { billingModeSchema as sharedBillingModeSchema } from '@cfdm/shared/contracts/provider-account'
import { customFieldsSchema } from '@cfdm/shared/contracts/custom-fields'
@@ -12,7 +13,7 @@ export const paymentTypeSchema = z.enum([
'monthly_debit',
])
export const ledgerDirectionSchema = z.enum(['credit', 'debit'])
export const apiTypeSchema = z.enum(['billmanager', 'none'])
export const apiTypeSchema = sharedApiTypeSchema
export const providerSchema = z.object({
id: z.string().min(1).optional(),
+3 -1
View File
@@ -1,5 +1,7 @@
import type { CustomFieldDef } from '@cfdm/shared/contracts/custom-fields'
import type { ApiType as SharedApiType } from '@cfdm/shared/contracts/provider'
export type VpsStatus = 'active' | 'paused' | 'archived'
export type TariffType = 'daily' | 'monthly'
export type BillingMode = 'daily' | 'monthly'
@@ -9,7 +11,7 @@ export type PaymentType =
| 'daily_debit'
| 'monthly_debit'
export type LedgerDirection = 'credit' | 'debit'
export type ApiType = 'billmanager' | 'none'
export type ApiType = SharedApiType
export interface Provider {
id: string