feat(accounts): доработать управление аккаунтами хостеров
Docker / build (push) Has been cancelled

Добавить apiLogin из credentials, сводку и health-индикаторы на /accounts, фильтры, раздельную форму логина и пароля, безопасное удаление с 409 и тесты API/repository.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-06-28 16:53:51 +07:00
co-authored by Cursor
parent 06f5b27e53
commit c80090ed08
21 changed files with 844 additions and 84 deletions
+103
View File
@@ -0,0 +1,103 @@
import type {
ProviderAccount,
Provider,
SyncLogRow,
BalanceLedgerRow,
} from '@/types/entities'
import { accountBalanceApi } from '@/lib/account'
import { accountBillmanagerUiReady } from '@/lib/billmanager'
import {
accountHasApiLedgerMismatch,
getStaleSyncAccountIds,
} from '@/lib/inventory-health'
export type AccountHealthFlag = 'stale-sync' | 'low-balance' | 'balance-mismatch' | 'no-creds'
export const ACCOUNT_HEALTH_LABELS: Record<AccountHealthFlag, string> = {
'stale-sync': 'Устаревший синк',
'low-balance': 'Низкий баланс',
'balance-mismatch': 'Расхождение ledger',
'no-creds': 'Нет API-доступа',
}
export interface AccountHealthContext {
providers: Provider[]
syncLog?: SyncLogRow[]
balanceLedger?: BalanceLedgerRow[]
}
export interface AtRiskAccount {
id: string
name: string
reason: string
severity: 'warning' | 'destructive'
}
export function getAccountHealthFlags(
account: ProviderAccount,
ctx: AccountHealthContext,
): AccountHealthFlag[] {
const provider = ctx.providers.find((p) => p.id === account.providerId)
const flags: AccountHealthFlag[] = []
if (provider?.apiType === 'billmanager' && !account.apiCredentialsSet) {
flags.push('no-creds')
}
const staleIds = new Set(getStaleSyncAccountIds([account], ctx.providers, ctx.syncLog ?? []))
if (staleIds.has(account.id)) flags.push('stale-sync')
const ext = account as ProviderAccount & { balanceAlertBelow?: number | null }
const threshold = Number(ext.balanceAlertBelow ?? 0)
const balance = accountBalanceApi(account)
if (Number.isFinite(threshold) && threshold > 0 && balance != null && balance < threshold) {
flags.push('low-balance')
}
if (ctx.balanceLedger && accountHasApiLedgerMismatch(account, ctx.balanceLedger)) {
flags.push('balance-mismatch')
}
return flags
}
export function accountHasHealthIssues(account: ProviderAccount, ctx: AccountHealthContext): boolean {
return getAccountHealthFlags(account, ctx).length > 0
}
export function buildAtRiskAccounts(
accounts: ProviderAccount[],
providers: Provider[],
syncLog: SyncLogRow[] = [],
): AtRiskAccount[] {
const ctx: AccountHealthContext = { providers, syncLog }
const rows: AtRiskAccount[] = []
for (const a of accounts) {
const flags = getAccountHealthFlags(a, ctx)
if (flags.includes('low-balance')) {
rows.push({ id: a.id, name: a.name, reason: ACCOUNT_HEALTH_LABELS['low-balance'], severity: 'destructive' })
} else if (flags.includes('stale-sync')) {
rows.push({ id: a.id, name: a.name, reason: ACCOUNT_HEALTH_LABELS['stale-sync'], severity: 'warning' })
}
}
return rows
}
export function countAccountsWithIssues(
accounts: ProviderAccount[],
ctx: AccountHealthContext,
): number {
return accounts.filter((a) => accountHasHealthIssues(a, ctx)).length
}
export function countLowBalanceAccounts(
accounts: ProviderAccount[],
ctx: AccountHealthContext,
): number {
return accounts.filter((a) => getAccountHealthFlags(a, ctx).includes('low-balance')).length
}
export function isAccountSyncable(account: ProviderAccount, providers: Provider[]): boolean {
const provider = providers.find((p) => p.id === account.providerId)
return accountBillmanagerUiReady(account, provider)
}
+5 -2
View File
@@ -29,8 +29,11 @@ async function fetchApi<T>(path: string, options: RequestInit = {}): Promise<T>
if (!res.ok) {
let message = res.statusText || 'API error'
try {
const data = (await res.json()) as { error?: string }
if (data?.error) message = data.error
const data = (await res.json()) as {
error?: string | { message?: string; code?: string }
}
if (typeof data?.error === 'string') message = data.error
else if (data?.error?.message) message = data.error.message
} catch {
/* ignore */
}
+16
View File
@@ -96,6 +96,22 @@ export function billingModeLabel(mode: string): string {
return BILLING_MODE_LABELS[mode] ?? mode
}
/** Относительное время для дат синка и обновлений. */
export function formatRelativeTime(isoOrMs: string | number | null | undefined): string {
if (isoOrMs == null) return '—'
const t = typeof isoOrMs === 'number' ? isoOrMs : new Date(isoOrMs).getTime()
if (Number.isNaN(t)) return '—'
const diffMs = Date.now() - t
if (diffMs < 0) return 'только что'
const mins = Math.floor(diffMs / 60_000)
if (mins < 1) return 'только что'
if (mins < 60) return `${mins} мин назад`
const hours = Math.floor(mins / 60)
if (hours < 48) return `${hours} ч назад`
const days = Math.floor(hours / 24)
return `${days} дн назад`
}
const TARIFF_TYPE_LABELS: Record<string, string> = {
daily: 'Суточный', monthly: 'Месячный',
}
+4 -3
View File
@@ -1,8 +1,9 @@
import { z } from 'zod'
import { billingModeSchema as sharedBillingModeSchema } from '@cfdm/shared/contracts/provider-account'
export const vpsStatusSchema = z.enum(['active', 'paused', 'archived'])
export const tariffTypeSchema = z.enum(['daily', 'monthly'])
export const billingModeSchema = z.enum(['daily', 'monthly'])
export const billingModeSchema = sharedBillingModeSchema
export const paymentTypeSchema = z.enum([
'direct_vps_payment',
'provider_balance_topup',
@@ -30,8 +31,8 @@ export const providerAccountSchema = z.object({
id: z.string().min(1).optional(),
providerId: z.string().min(1, 'Выберите хостера'),
name: z.string().min(1, 'Название обязательно'),
login: z.string().optional().default(''),
apiCredentials: z.string().optional().default(''),
apiLogin: z.string().optional().default(''),
apiPassword: z.string().optional().default(''),
billingMode: billingModeSchema.default('monthly'),
balanceAlertBelow: z.union([z.coerce.number().min(0), z.literal('')]).optional(),
notes: z.string().optional().default(''),