优化监控任务多账号配置
This commit is contained in:
@@ -1,58 +1,296 @@
|
||||
import { getCloudtentaclesAsset } from '../platforms/cloudtentacles/catalog-service.js'
|
||||
import { getCloudtentaclesSourceConfig } from '../platforms/cloudtentacles/source-config-service.js'
|
||||
import { getCloudtentaclesSessionState } from '../platforms/cloudtentacles/session-state-service.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 source = getCloudtentaclesSourceConfig()
|
||||
const session = getCloudtentaclesSessionState()
|
||||
const token = String(session.token || '').trim()
|
||||
const threshold = Number(job.config?.assetThreshold || 500)
|
||||
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 = 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: '当前 cloudtentacles 没有可用 token,请到后台重新登录',
|
||||
message: `账号 ${label} 没有可用 token,请到后台重新登录`,
|
||||
cooldownSeconds,
|
||||
sourceKey,
|
||||
accountLabel: label,
|
||||
})
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
status: 'auth_missing',
|
||||
message: 'cloudtentacles token 缺失',
|
||||
message: `账号 ${label} token 缺失`,
|
||||
asset: null,
|
||||
threshold,
|
||||
sourceKey,
|
||||
label,
|
||||
enabled: true,
|
||||
hasToken: false,
|
||||
skipped: false,
|
||||
checkedAt,
|
||||
}
|
||||
}
|
||||
|
||||
const assetResult = await getCloudtentaclesAsset({
|
||||
baseUrl: String(session.baseUrl || source.baseUrl || '').trim() || 'https://123.207.217.176',
|
||||
token,
|
||||
deviceId: String(session.deviceId || source.deviceId || '-').trim() || '-',
|
||||
deviceType: Number(session.deviceType ?? source.deviceType ?? 0),
|
||||
})
|
||||
const asset = Number(assetResult.asset || 0)
|
||||
try {
|
||||
const assetResult = await getCloudtentaclesAsset({
|
||||
sourceKey,
|
||||
accountLabel: label,
|
||||
baseUrl: String(session.baseUrl || source.baseUrl || '').trim() || 'https://123.207.217.176',
|
||||
token,
|
||||
deviceId: String(session.deviceId || source.deviceId || '-').trim() || '-',
|
||||
deviceType: Number(session.deviceType ?? source.deviceType ?? 0),
|
||||
})
|
||||
const asset = Number(assetResult.asset || 0)
|
||||
|
||||
if (asset < threshold) {
|
||||
await notifyCloudtentaclesAssetLow({
|
||||
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,
|
||||
cooldownSeconds,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: asset < threshold ? 'asset_low' : 'ok',
|
||||
message: asset < threshold ? `余额 ${asset} 低于阈值 ${threshold}` : `余额 ${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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { normalizeScheduledJobsConfig } from './config-service.js'
|
||||
|
||||
test('normalizeScheduledJobsConfig 将旧版 cloudtentacles 阈值迁移为默认账号配置', () => {
|
||||
const config = normalizeScheduledJobsConfig({
|
||||
enabled: true,
|
||||
jobs: [
|
||||
{
|
||||
id: 'cloudtentacles-health',
|
||||
type: 'cloudtentacles_health',
|
||||
enabled: true,
|
||||
intervalSeconds: 18000,
|
||||
cooldownSeconds: 1800,
|
||||
config: {
|
||||
assetThreshold: 100,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
assert.deepEqual(config.jobs[0], {
|
||||
id: 'cloudtentacles-health',
|
||||
type: 'cloudtentacles_health',
|
||||
enabled: true,
|
||||
intervalSeconds: 18000,
|
||||
cooldownSeconds: 1800,
|
||||
config: {
|
||||
assetThreshold: 100,
|
||||
accounts: [
|
||||
{
|
||||
sourceKey: 'default',
|
||||
label: '',
|
||||
enabled: true,
|
||||
assetThreshold: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('normalizeScheduledJobsConfig 支持多账号去重并保留 0 阈值', () => {
|
||||
const config = normalizeScheduledJobsConfig({
|
||||
jobs: [
|
||||
{
|
||||
id: 'cloudtentacles-health',
|
||||
type: 'cloudtentacles_health',
|
||||
enabled: true,
|
||||
intervalSeconds: 30,
|
||||
cooldownSeconds: 10,
|
||||
config: {
|
||||
assetThreshold: 500,
|
||||
accounts: [
|
||||
{
|
||||
sourceKey: 'default',
|
||||
label: '默认账号',
|
||||
enabled: true,
|
||||
assetThreshold: 0,
|
||||
},
|
||||
{
|
||||
sourceKey: 'default',
|
||||
label: '重复账号',
|
||||
enabled: false,
|
||||
assetThreshold: 999,
|
||||
},
|
||||
{
|
||||
sourceKey: 'account2',
|
||||
enabled: false,
|
||||
threshold: 200,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
assert.deepEqual(config.jobs[0].config.accounts, [
|
||||
{
|
||||
sourceKey: 'default',
|
||||
label: '默认账号',
|
||||
enabled: true,
|
||||
assetThreshold: 0,
|
||||
},
|
||||
{
|
||||
sourceKey: 'account2',
|
||||
label: '',
|
||||
enabled: false,
|
||||
assetThreshold: 200,
|
||||
},
|
||||
])
|
||||
assert.equal(config.jobs[0].intervalSeconds, 60)
|
||||
assert.equal(config.jobs[0].cooldownSeconds, 60)
|
||||
})
|
||||
@@ -34,7 +34,7 @@ function loadScheduledJobsConfigFromFile() {
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeScheduledJobsConfig(rawValue: unknown) {
|
||||
export function normalizeScheduledJobsConfig(rawValue: unknown) {
|
||||
const source = isPlainObject(rawValue) ? rawValue : {}
|
||||
const rawJobs = Array.isArray(source.jobs) ? source.jobs : []
|
||||
const jobs = rawJobs.map((item) => normalizeScheduledJob(item)).filter(Boolean)
|
||||
@@ -65,6 +65,7 @@ function normalizeScheduledJob(rawValue: unknown) {
|
||||
|
||||
function normalizeCloudtentaclesHealthJob(rawValue: JsonObject) {
|
||||
const config = isPlainObject(rawValue.config) ? rawValue.config : {}
|
||||
const defaultAssetThreshold = normalizeRangeInteger(config.assetThreshold, 500, 0, 999999)
|
||||
|
||||
return {
|
||||
id: CLOUDTENTACLES_HEALTH_JOB_ID,
|
||||
@@ -73,11 +74,57 @@ function normalizeCloudtentaclesHealthJob(rawValue: JsonObject) {
|
||||
intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 300, 60, 86400),
|
||||
cooldownSeconds: normalizeRangeInteger(rawValue.cooldownSeconds, 1800, 60, 86400),
|
||||
config: {
|
||||
assetThreshold: normalizeRangeInteger(config.assetThreshold, 500, 0, 999999),
|
||||
assetThreshold: defaultAssetThreshold,
|
||||
accounts: normalizeCloudtentaclesHealthAccounts(config, defaultAssetThreshold),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCloudtentaclesHealthAccounts(config: JsonObject, defaultAssetThreshold: number) {
|
||||
const rawAccounts = Array.isArray(config.accounts)
|
||||
? config.accounts
|
||||
: Array.isArray(config.sourceKeys)
|
||||
? config.sourceKeys.map((sourceKey) => ({ sourceKey }))
|
||||
: []
|
||||
const accounts = rawAccounts
|
||||
.map((item) => normalizeCloudtentaclesHealthAccount(item, defaultAssetThreshold))
|
||||
.filter(Boolean) as ReturnType<typeof normalizeCloudtentaclesHealthAccount>[]
|
||||
|
||||
if (accounts.length === 0) {
|
||||
accounts.push(normalizeCloudtentaclesHealthAccount({
|
||||
sourceKey: config.sourceKey || 'default',
|
||||
assetThreshold: defaultAssetThreshold,
|
||||
}, defaultAssetThreshold))
|
||||
}
|
||||
|
||||
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 normalizeCloudtentaclesHealthAccount(rawValue: unknown, defaultAssetThreshold: number) {
|
||||
const source = isPlainObject(rawValue) ? rawValue : {}
|
||||
const sourceKey = String(source.sourceKey || source.key || '').trim() || 'default'
|
||||
|
||||
return {
|
||||
sourceKey,
|
||||
label: String(source.label || '').trim(),
|
||||
enabled: source.enabled !== false,
|
||||
assetThreshold: normalizeRangeInteger(
|
||||
source.assetThreshold ?? source.threshold,
|
||||
defaultAssetThreshold,
|
||||
0,
|
||||
999999,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function createDefaultScheduledJobsConfig() {
|
||||
return {
|
||||
enabled: true,
|
||||
@@ -96,6 +143,14 @@ function createDefaultCloudtentaclesHealthJob() {
|
||||
cooldownSeconds: 1800,
|
||||
config: {
|
||||
assetThreshold: 500,
|
||||
accounts: [
|
||||
{
|
||||
sourceKey: 'default',
|
||||
label: '默认账号',
|
||||
enabled: true,
|
||||
assetThreshold: 500,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +130,12 @@ async function runJob(job: JsonObject, { manual = false }: { manual?: boolean }
|
||||
lastMessage: result?.message || '执行完成',
|
||||
lastAsset: typeof result?.asset === 'number' ? result.asset : null,
|
||||
lastThreshold: typeof result?.threshold === 'number' ? result.threshold : null,
|
||||
lastAccounts: Array.isArray(result?.accounts) ? result.accounts : [],
|
||||
lastAccountCount: Number(result?.accountCount || 0),
|
||||
lastCheckedCount: Number(result?.checkedCount || 0),
|
||||
lastOkCount: Number(result?.okCount || 0),
|
||||
lastLowAssetCount: Number(result?.lowAssetCount || 0),
|
||||
lastFailedCount: Number(result?.failedCount || 0),
|
||||
lastManual: manual,
|
||||
})
|
||||
logInfo('[scheduler]', '定时任务执行完成', {
|
||||
@@ -180,6 +186,12 @@ function updateJobState(jobId: string, patch: JsonObject) {
|
||||
lastMessage: '',
|
||||
lastAsset: null,
|
||||
lastThreshold: null,
|
||||
lastAccounts: [],
|
||||
lastAccountCount: 0,
|
||||
lastCheckedCount: 0,
|
||||
lastOkCount: 0,
|
||||
lastLowAssetCount: 0,
|
||||
lastFailedCount: 0,
|
||||
lastManual: false,
|
||||
}
|
||||
jobStates.set(jobId, {
|
||||
|
||||
Reference in New Issue
Block a user