Files
order_site/apps/backend/src/services/scheduler/cloudtentacles-health-job.ts
T
2026-05-28 13:05:44 +08:00

301 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, any>
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<CloudtentaclesHealthAccountResult> {
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<string, JsonObject>,
) {
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<string>()
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)
}