feat(spaces): добавить изолированные пространства и multi-user
Docker / build (push) Failing after 20s
Docker / build (push) Failing after 20s
Полная изоляция данных по space, Share (ACL) и Assign, switcher и участники в UI. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
f26b2c8777
commit
e360efb885
47 files changed
+2675
-237
No files matched your search
@@ -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: '',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
function makeAccount(): RuvdsSyncAccount {
|
||||
return {
|
||||
id: 'acc-ruvds',
|
||||
spaceId: 'space-main',
|
||||
providerId: 'prov-ruvds',
|
||||
name: 'RuVDS',
|
||||
panelUrl: '',
|
||||
|
||||
+184
-111
@@ -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: '',
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
Reference in new issue
Block a user