feat(spaces): добавить изолированные пространства и multi-user
Docker / build (push) Failing after 20s

Полная изоляция данных по space, Share (ACL) и Assign, switcher и участники в UI.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-18 15:42:54 +07:00
co-authored by Cursor
parent f26b2c8777
commit e360efb885
47 changed files with 2675 additions and 237 deletions
+2 -4
View File
@@ -2,10 +2,8 @@ import { settingsRepository } from '@cfdm/db/repositories/settings'
import { vpsRepository } from '@cfdm/db/repositories/vps'
import type { VpsTrackerEvent } from '@cfdm/shared/contracts/integration-cfdm'
const SETTINGS_ID = 'settings-main'
function resolveCfdmApiBase(): string | null {
const row = settingsRepository.getRow(SETTINGS_ID)
const row = settingsRepository.getBySpace()
if (!row) return null
const explicit = row.cfdmApiUrl?.trim()
if (explicit) return explicit.replace(/\/$/, '')
@@ -19,7 +17,7 @@ export async function notifyCfdmVpsEvent(
): Promise<void> {
if (vpsIds.length === 0) return
const row = settingsRepository.getRow(SETTINGS_ID)
const row = settingsRepository.getBySpace()
if (!row?.integrationEnabled) return
const token = settingsRepository.getIntegrationToken()
@@ -22,6 +22,7 @@ import {
const account: FourvpsSyncAccount = {
id: 'acc-4vps',
spaceId: 'space-main',
providerId: 'prov-4vps',
name: '4VPS Account',
panelUrl: '',
+4 -6
View File
@@ -1,9 +1,5 @@
/**
* Generic account sync with sync_log recording
*/
import { eq } from 'drizzle-orm'
import { getDb, schema } from '@cfdm/db'
import { getDb, schema, getCurrentSpaceId } from '@cfdm/db'
import type { ProviderAdapter, SyncResult } from './types.js'
@@ -18,12 +14,14 @@ export async function runAccountSync(
opts: { skipTariffs?: boolean; skipVpsPayments?: boolean } = {},
): Promise<RunAccountSyncResult> {
const db = getDb()
const accountRow = account as { id: string }
const accountRow = account as { id: string; spaceId?: string }
const logId = `sync-${accountRow.id}-${Date.now()}`
const spaceId = accountRow.spaceId ?? getCurrentSpaceId()
db.insert(schema.syncLog)
.values({
id: logId,
spaceId,
accountId: accountRow.id,
startedAt: new Date().toISOString(),
status: 'running',
+1
View File
@@ -33,6 +33,7 @@ import {
function makeAccount(): RuvdsSyncAccount {
return {
id: 'acc-ruvds',
spaceId: 'space-main',
providerId: 'prov-ruvds',
name: 'RuVDS',
panelUrl: '',
+184 -111
View File
@@ -1,5 +1,5 @@
import { sql } from 'drizzle-orm'
import { getDb, schema } from '@cfdm/db'
import { eq, sql } from 'drizzle-orm'
import { getDb, schema, runWithSpaceAsync, MAIN_SPACE_ID } from '@cfdm/db'
import { settingsRepository } from '@cfdm/db/repositories/settings'
import { resolveSyncAccount, getProviderAdapter, type SyncReadyAccount } from './providers/index.js'
@@ -20,26 +20,30 @@ let syncTariffsIntervalId: ReturnType<typeof setInterval> | null = null
let notifyIntervalId: ReturnType<typeof setInterval> | null = null
let uptimeIntervalId: ReturnType<typeof setInterval> | null = null
const SETTINGS_ID = 'settings-main'
type AccountRow = typeof schema.providerAccounts.$inferSelect
type SettingsRow = typeof schema.settings.$inferSelect
interface SyncableAccountEntry {
account: SyncReadyAccount
apiType: string
}
function getSyncableAccounts(): SyncableAccountEntry[] {
function getSyncableAccounts(spaceId: string): SyncableAccountEntry[] {
const db = getDb()
const rows = db
.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', 'macloud', 'vdsina', 'veesp', 'ruvds')
WHERE pa.spaceId = ${spaceId}
AND lower(trim(COALESCE(p.apiType, ''))) IN ('billmanager', '4vps', 'macloud', 'vdsina', 'veesp', 'ruvds')
AND length(trim(COALESCE(p.apiBaseUrl, ''))) > 0
AND pa.apiCredentials IS NOT NULL AND length(trim(pa.apiCredentials)) > 0
`)
const providers = db.select().from(schema.providers).all()
const providers = db
.select()
.from(schema.providers)
.where(eq(schema.providers.spaceId, spaceId))
.all()
const providerById = new Map(providers.map((p) => [p.id, p]))
return rows
.map((a) => {
@@ -49,123 +53,184 @@ function getSyncableAccounts(): SyncableAccountEntry[] {
.filter((e): e is SyncableAccountEntry => e != null)
}
function allSettings(): SettingsRow[] {
return settingsRepository.listAllSpaces()
}
export async function runNotificationTick(): Promise<void> {
try {
const settings = settingsRepository.getRow(SETTINGS_ID)
if (!settings) return
const payload = buildPaymentExpiryNotification()
if (payload) await publishNotification(settings, payload)
} catch (err) {
console.warn('Notification tick error:', err instanceof Error ? err.message : err)
for (const settings of allSettings()) {
const spaceId = settings.spaceId || MAIN_SPACE_ID
try {
await runWithSpaceAsync(spaceId, async () => {
const payload = buildPaymentExpiryNotification()
if (payload) await publishNotification(settings, payload)
})
} catch (err) {
console.warn(
`Notification tick error [${spaceId}]:`,
err instanceof Error ? err.message : err,
)
}
}
}
export async function runScheduledSync(): Promise<void> {
try {
const settings = settingsRepository.getRow(SETTINGS_ID)
if (!settings?.syncEnabled) return
for (const settings of allSettings()) {
if (!settings.syncEnabled) continue
const spaceId = settings.spaceId || MAIN_SPACE_ID
try {
await runWithSpaceAsync(spaceId, async () => {
const entries = getSyncableAccounts(spaceId)
const digestLines: string[] = []
const lowBalanceLines: string[] = []
const entries = getSyncableAccounts()
const digestLines: string[] = []
const lowBalanceLines: string[] = []
for (const { account, apiType } of entries) {
try {
const adapter = getProviderAdapter(apiType)
const result = await runAccountSync(adapter, account, { skipTariffs: true })
const s = result.syncSummary
const parts: string[] = []
if (s.added?.length) parts.push(`+${s.added.length} VPS`)
if (s.updated?.length) parts.push(`изм. ${s.updated.length}`)
if (result.paymentsCount) parts.push(`платежи +${result.paymentsCount}`)
digestLines.push(
`${account.name}: ${parts.length ? parts.join(', ') : 'без изменений'}`,
)
for (const { account, apiType } of entries) {
try {
const adapter = getProviderAdapter(apiType)
const result = await runAccountSync(adapter, account, { skipTariffs: true })
const s = result.syncSummary
const parts: string[] = []
if (s.added?.length) parts.push(`+${s.added.length} VPS`)
if (s.updated?.length) parts.push(`изм. ${s.updated.length}`)
if (result.paymentsCount) parts.push(`платежи +${result.paymentsCount}`)
digestLines.push(`${account.name}: ${parts.length ? parts.join(', ') : 'без изменений'}`)
const apiBal = result.balance?.balance
const threshold = account.balanceAlertBelow
if (
settings.notifyLowBalanceEnabled &&
threshold != null &&
Number.isFinite(Number(threshold)) &&
apiBal != null &&
Number.isFinite(Number(apiBal)) &&
Number(apiBal) < Number(threshold)
) {
const cur = result.balance?.currency || account.balanceCurrency || account.currency || ''
lowBalanceLines.push(`${account.name}: ${apiBal} ${cur} (порог ${threshold})`)
const apiBal = result.balance?.balance
const threshold = account.balanceAlertBelow
if (
settings.notifyLowBalanceEnabled &&
threshold != null &&
Number.isFinite(Number(threshold)) &&
apiBal != null &&
Number.isFinite(Number(apiBal)) &&
Number(apiBal) < Number(threshold)
) {
const cur =
result.balance?.currency || account.balanceCurrency || account.currency || ''
lowBalanceLines.push(`${account.name}: ${apiBal} ${cur} (порог ${threshold})`)
}
} catch (err) {
const message = err instanceof Error ? err.message : 'ошибка'
digestLines.push(`${account.name}: ${message}`)
}
}
} catch (err) {
const message = err instanceof Error ? err.message : 'ошибка'
digestLines.push(`${account.name}: ${message}`)
}
}
await publishMany(settings, [
buildSyncDigestNotification(digestLines),
buildLowBalanceNotification(lowBalanceLines),
])
} catch (err) {
console.warn('Scheduled sync error:', err instanceof Error ? err.message : err)
await publishMany(settings, [
buildSyncDigestNotification(digestLines),
buildLowBalanceNotification(lowBalanceLines),
])
})
} catch (err) {
console.warn(`Scheduled sync error [${spaceId}]:`, err instanceof Error ? err.message : err)
}
}
}
export async function runScheduledSyncTariffs(): Promise<void> {
try {
const settings = settingsRepository.getRow(SETTINGS_ID)
if (!settings?.syncEnabled) return
for (const settings of allSettings()) {
if (!settings.syncEnabled) continue
const spaceId = settings.spaceId || MAIN_SPACE_ID
try {
await runWithSpaceAsync(spaceId, async () => {
const entries = getSyncableAccounts(spaceId)
const providers = getDb()
.select()
.from(schema.providers)
.where(eq(schema.providers.spaceId, spaceId))
.all()
const entries = getSyncableAccounts()
const providers = getDb().select().from(schema.providers).all()
for (const { account, apiType } of entries) {
try {
const adapter = getProviderAdapter(apiType)
const result = await runAccountSync(adapter, account, { skipVpsPayments: true })
const newTariffs = result.newTariffs || []
if (newTariffs.length > 0 && settings.notifyNewTariffsEnabled) {
const provider = providers.find((p) => p.id === account.providerId)
const providerName = provider?.name || account.name || '-'
const payload = buildNewTariffsNotification(
providerName,
newTariffs.map((t) => ({ name: t.name, price: t.price })),
)
if (payload) await publishNotification(settings, payload)
for (const { account, apiType } of entries) {
try {
const adapter = getProviderAdapter(apiType)
const result = await runAccountSync(adapter, account, { skipVpsPayments: true })
const newTariffs = result.newTariffs || []
if (newTariffs.length > 0 && settings.notifyNewTariffsEnabled) {
const provider = providers.find((p) => p.id === account.providerId)
const providerName = provider?.name || account.name || '-'
const payload = buildNewTariffsNotification(
providerName,
newTariffs.map((t) => ({ name: t.name, price: t.price })),
)
if (payload) await publishNotification(settings, payload)
}
} catch (err) {
console.warn(
`Sync tariffs failed for account ${account.id}:`,
err instanceof Error ? err.message : err,
)
}
}
} catch (err) {
console.warn(`Sync tariffs failed for account ${account.id}:`, err instanceof Error ? err.message : err)
}
})
} catch (err) {
console.warn(
`Scheduled sync tariffs error [${spaceId}]:`,
err instanceof Error ? err.message : err,
)
}
} catch (err) {
console.warn('Scheduled sync tariffs error:', err instanceof Error ? err.message : err)
}
}
export async function runScheduledUptimeChecks(): Promise<void> {
try {
const settings = settingsRepository.getRow(SETTINGS_ID)
if (!settings) return
const { newlyDown, newlyUp } = await runVpsUptimeChecks()
await publishMany(settings, [
buildVpsHealthNotification(
'vps_down',
newlyDown.map((h) => ({ id: h.id, label: h.label })),
),
buildVpsHealthNotification(
'vps_up',
newlyUp.map((h) => ({ id: h.id, label: h.label })),
),
])
if (newlyDown.length > 0) {
void notifyCfdmVpsEvent(
'vps_down',
newlyDown.map((h) => h.id),
)
for (const settings of allSettings()) {
const spaceId = settings.spaceId || MAIN_SPACE_ID
try {
await runWithSpaceAsync(spaceId, async () => {
const { newlyDown, newlyUp } = await runVpsUptimeChecks()
await publishMany(settings, [
buildVpsHealthNotification(
'vps_down',
newlyDown.map((h) => ({ id: h.id, label: h.label })),
),
buildVpsHealthNotification(
'vps_up',
newlyUp.map((h) => ({ id: h.id, label: h.label })),
),
])
if (newlyDown.length > 0) {
void notifyCfdmVpsEvent(
'vps_down',
newlyDown.map((h) => h.id),
)
}
})
} catch (err) {
console.warn(`Uptime check error [${spaceId}]:`, err instanceof Error ? err.message : err)
}
} catch (err) {
console.warn('Uptime check error:', err instanceof Error ? err.message : err)
}
}
function pickSchedulerIntervals(rows: SettingsRow[]): {
notifyInterval: number
uptimeInterval: number
syncInterval: number | null
tariffsInterval: number | null
} {
let notifyInterval = 60
let uptimeInterval = 5
let syncInterval: number | null = null
let tariffsInterval: number | null = null
for (const s of rows) {
notifyInterval = Math.min(
notifyInterval,
Math.max(15, Number(s.notifyIntervalMinutes) || 60),
)
uptimeInterval = Math.min(
uptimeInterval,
Math.max(1, Number(s.uptimeCheckIntervalMinutes) || 5),
)
if (s.syncEnabled) {
const si = Math.max(15, Number(s.syncIntervalMinutes) || 60)
const ti = Math.max(60, Number(s.syncTariffsIntervalMinutes) || 1440)
syncInterval = syncInterval == null ? si : Math.min(syncInterval, si)
tariffsInterval = tariffsInterval == null ? ti : Math.min(tariffsInterval, ti)
}
}
return { notifyInterval, uptimeInterval, syncInterval, tariffsInterval }
}
export function startScheduler(): void {
if (syncIntervalId) clearInterval(syncIntervalId)
syncIntervalId = null
@@ -177,28 +242,36 @@ export function startScheduler(): void {
uptimeIntervalId = null
try {
const settings = settingsRepository.getRow(SETTINGS_ID)
if (!settings) return
const rows = allSettings()
if (rows.length === 0) return
const notifyInterval = Math.max(15, Number(settings.notifyIntervalMinutes) || 60)
const uptimeInterval = Math.max(1, Number(settings.uptimeCheckIntervalMinutes) || 5)
const { notifyInterval, uptimeInterval, syncInterval, tariffsInterval } =
pickSchedulerIntervals(rows)
notifyIntervalId = setInterval(() => void runNotificationTick(), notifyInterval * 60 * 1000)
uptimeIntervalId = setInterval(() => void runScheduledUptimeChecks(), uptimeInterval * 60 * 1000)
uptimeIntervalId = setInterval(
() => void runScheduledUptimeChecks(),
uptimeInterval * 60 * 1000,
)
void runNotificationTick()
void runScheduledUptimeChecks()
const parts = [`notify every ${notifyInterval} min`, `uptime every ${uptimeInterval} min`]
const parts = [
`notify every ${notifyInterval} min`,
`uptime every ${uptimeInterval} min`,
`spaces=${rows.length}`,
]
if (settings.syncEnabled) {
const interval = Math.max(15, Number(settings.syncIntervalMinutes) || 60)
const tariffsInterval = Math.max(60, Number(settings.syncTariffsIntervalMinutes) || 1440)
syncIntervalId = setInterval(() => void runScheduledSync(), interval * 60 * 1000)
if (syncInterval != null) {
syncIntervalId = setInterval(() => void runScheduledSync(), syncInterval * 60 * 1000)
parts.unshift(`sync every ${syncInterval} min`)
}
if (tariffsInterval != null) {
syncTariffsIntervalId = setInterval(
() => void runScheduledSyncTariffs(),
tariffsInterval * 60 * 1000,
)
parts.unshift(`sync every ${interval} min`, `tariffs every ${tariffsInterval} min`)
parts.unshift(`tariffs every ${tariffsInterval} min`)
}
console.log(`Scheduler: ${parts.join(', ')}`)
@@ -29,6 +29,7 @@ import {
function makeAccount(apiType: 'macloud' | 'vdsina'): UserApiSyncAccount {
return {
id: `acc-${apiType}`,
spaceId: 'space-main',
providerId: `prov-${apiType}`,
name: `${apiType} Account`,
panelUrl: '',
+1
View File
@@ -22,6 +22,7 @@ import { fetchBalance, fetchInvoices, fetchTariffList, fetchVpsRecords } from '.
function makeAccount(): VeespSyncAccount {
return {
id: 'acc-veesp',
spaceId: 'space-main',
providerId: 'prov-veesp',
name: 'Veesp',
panelUrl: '',