import { getCloudtentaclesAsset } from '../platforms/cloudtentacles/catalog-service.js' import { normalizeCloudtentaclesDeviceId, normalizeCloudtentaclesDeviceType, } from '../platforms/cloudtentacles/defaults.js' import { getCloudtentaclesSourceByKey, listCloudtentaclesSources, } from '../platforms/cloudtentacles/source-config-service.js' import { getCloudtentaclesSessionStateByKey } from '../platforms/cloudtentacles/session-state-service.js' import { notifyCloudtentaclesAssetLow, notifyCloudtentaclesAuthExpired, } from '../notification/domain-notifications.js' type JsonObject = Record type CloudtentaclesHealthAccountResult = { sourceKey: string label: string enabled: boolean hasToken: boolean ok: boolean skipped: boolean status: string message: string asset: number | null threshold: number checkedAt: string } export async function runCloudtentaclesHealthJob(job: JsonObject) { const accounts = resolveCloudtentaclesHealthAccounts(job) const cooldownSeconds = Number(job.cooldownSeconds || 1800) const results = await Promise.all( accounts.map((account) => runCloudtentaclesHealthAccount(account, cooldownSeconds)), ) const checkedResults = results.filter((item) => !item.skipped) const failedResults = checkedResults.filter((item) => item.ok === false) const lowAssetResults = checkedResults.filter((item) => item.status === 'asset_low') const okResults = checkedResults.filter((item) => item.status === 'ok') const assetValues = checkedResults .map((item) => item.asset) .filter((value): value is number => typeof value === 'number' && Number.isFinite(value)) const summary = buildCloudtentaclesHealthSummary(results) const status = resolveCloudtentaclesHealthStatus(results) return { ok: failedResults.length === 0, status, message: summary, accountCount: results.length, checkedCount: checkedResults.length, okCount: okResults.length, lowAssetCount: lowAssetResults.length, failedCount: failedResults.length, asset: assetValues.length > 0 ? Math.min(...assetValues) : null, threshold: checkedResults.length > 0 ? Math.max(...checkedResults.map((item) => Number(item.threshold || 0))) : normalizeNonNegativeInteger(job.config?.assetThreshold, 500), accounts: results, } } async function runCloudtentaclesHealthAccount( account: JsonObject, cooldownSeconds: number, ): Promise { const sourceKey = String(account.sourceKey || '').trim() || 'default' const source = getCloudtentaclesSourceByKey(sourceKey) const session: JsonObject = getCloudtentaclesSessionStateByKey(sourceKey) || {} const label = String(account.label || source?.label || source?.username || sourceKey).trim() || sourceKey const threshold = normalizeNonNegativeInteger(account.assetThreshold, 500) const checkedAt = new Date().toISOString() if (!source) { return { sourceKey, label, enabled: account.enabled !== false, hasToken: false, ok: false, skipped: false, status: 'source_missing', message: `账号 ${label} 不存在`, asset: null, threshold, checkedAt, } } if (account.enabled === false || source.enabled === false) { return { sourceKey, label, enabled: false, hasToken: Boolean(String(session.token || '').trim()), ok: true, skipped: true, status: 'disabled', message: `账号 ${label} 已停用`, asset: null, threshold, checkedAt, } } const token = String(session.token || '').trim() if (!token) { await notifyCloudtentaclesAuthExpired({ pathname: '/user/get_asset', errorCode: 'cloudtentacles_token_missing', message: `账号 ${label} 没有可用 token,请到后台重新登录`, cooldownSeconds, sourceKey, accountLabel: label, }) return { ok: false, status: 'auth_missing', message: `账号 ${label} token 缺失`, asset: null, threshold, sourceKey, label, enabled: true, hasToken: false, skipped: false, checkedAt, } } try { const assetResult = await getCloudtentaclesAsset({ sourceKey, accountLabel: label, baseUrl: String(session.baseUrl || source.baseUrl || '').trim() || 'https://123.207.217.176', token, deviceId: normalizeCloudtentaclesDeviceId(session.deviceId || source.deviceId), deviceType: normalizeCloudtentaclesDeviceType(session.deviceType ?? source.deviceType), }) const asset = Number(assetResult.asset || 0) if (asset < threshold) { await notifyCloudtentaclesAssetLow({ asset, threshold, cooldownSeconds, sourceKey, accountLabel: label, }) } return { ok: true, status: asset < threshold ? 'asset_low' : 'ok', message: asset < threshold ? `账号 ${label} 余额 ${asset} 低于阈值 ${threshold}` : `账号 ${label} 余额 ${asset} 正常`, asset, threshold, sourceKey, label, enabled: true, hasToken: true, skipped: false, checkedAt, } } catch (error) { const message = error instanceof Error ? error.message : String(error || '余额检查失败') return { ok: false, status: 'failed', message: `账号 ${label} 检查失败:${message}`, asset: null, threshold, sourceKey, label, enabled: true, hasToken: true, skipped: false, checkedAt, } } } function resolveCloudtentaclesHealthAccounts(job: JsonObject) { const config = isPlainObject(job.config) ? job.config : {} const rawAccounts = Array.isArray(config.accounts) ? config.accounts : [] const defaultThreshold = normalizeNonNegativeInteger(config.assetThreshold, 500) const sourceConfig = listCloudtentaclesSources() const sourceMap = new Map( (Array.isArray(sourceConfig.sources) ? sourceConfig.sources : []) .map((source) => [String(source.key || '').trim(), source]), ) const normalizedAccounts = rawAccounts .map((item) => normalizeCloudtentaclesHealthAccount(item, defaultThreshold, sourceMap)) .filter(Boolean) as JsonObject[] if (normalizedAccounts.length > 0) { return dedupeAccounts(normalizedAccounts) } return [{ sourceKey: 'default', label: sourceMap.get('default')?.label || '默认账号', enabled: true, assetThreshold: defaultThreshold, }] } function normalizeCloudtentaclesHealthAccount( rawValue: unknown, defaultThreshold: number, sourceMap: Map, ) { const source = isPlainObject(rawValue) ? rawValue : {} const sourceKey = String(source.sourceKey || source.key || '').trim() if (!sourceKey) { return null } const sourceConfig = sourceMap.get(sourceKey) || {} return { sourceKey, label: String(source.label || sourceConfig.label || sourceConfig.username || sourceKey).trim(), enabled: source.enabled !== false, assetThreshold: normalizeNonNegativeInteger( source.assetThreshold ?? source.threshold, defaultThreshold, ), } } function dedupeAccounts(accounts: JsonObject[]) { const seen = new Set() return accounts.filter((item) => { const sourceKey = String(item.sourceKey || '').trim() if (!sourceKey || seen.has(sourceKey)) { return false } seen.add(sourceKey) return true }) } function buildCloudtentaclesHealthSummary(results: CloudtentaclesHealthAccountResult[]) { if (results.length === 0) { return '未配置 cloudtentacles 监控账号' } const checkedResults = results.filter((item) => !item.skipped) const lowAssetResults = checkedResults.filter((item) => item.status === 'asset_low') const failedResults = checkedResults.filter((item) => item.ok === false) const disabledCount = results.length - checkedResults.length if (checkedResults.length === 0) { return `所有账号均已停用,共 ${disabledCount} 个` } if (failedResults.length > 0 || lowAssetResults.length > 0) { return [ `检查 ${checkedResults.length} 个账号`, lowAssetResults.length > 0 ? `余额预警 ${lowAssetResults.length} 个` : '', failedResults.length > 0 ? `异常 ${failedResults.length} 个` : '', disabledCount > 0 ? `停用 ${disabledCount} 个` : '', ].filter(Boolean).join(',') } return `检查 ${checkedResults.length} 个账号,余额均正常${disabledCount > 0 ? `,停用 ${disabledCount} 个` : ''}` } function resolveCloudtentaclesHealthStatus(results: CloudtentaclesHealthAccountResult[]) { const checkedResults = results.filter((item) => !item.skipped) if (checkedResults.length === 0) { return 'skipped' } if (checkedResults.some((item) => ['auth_missing', 'source_missing', 'failed'].includes(item.status))) { return 'failed' } if (checkedResults.some((item) => item.status === 'asset_low')) { return 'asset_low' } return 'ok' } function normalizeNonNegativeInteger(value: unknown, fallback: number) { const parsed = Number(value) return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback } function isPlainObject(value: unknown): value is JsonObject { return Boolean(value) && typeof value === 'object' && !Array.isArray(value) }