优化监控任务多账号配置

This commit is contained in:
yml
2026-05-25 17:44:42 +08:00
parent 30728c40be
commit d58d483944
17 changed files with 1062 additions and 182 deletions
@@ -4,6 +4,8 @@ import {
saveNotificationConfig,
} from '../../notification/config-service.js'
import { sendInternalNotification } from '../../notification/notification-service.js'
import { listCloudtentaclesSources } from '../../platforms/cloudtentacles/source-config-service.js'
import { getAllCloudtentaclesSessionStates } from '../../platforms/cloudtentacles/session-state-service.js'
import {
getScheduledJobsConfig,
getScheduledJobsFilePath,
@@ -14,7 +16,7 @@ import {
reloadScheduledJobs,
runScheduledJobNow,
} from '../../scheduler/scheduler-service.js'
import { maskSecret } from './mappers.js'
import { maskPhone, maskSecret } from './mappers.js'
import type {
AdminNotificationConfigInput,
@@ -58,6 +60,7 @@ export function getAdminScheduledJobsConfig() {
filePath: getScheduledJobsFilePath(),
source: mapAdminScheduledJobsConfig(config),
runtime: getScheduledJobRuntimeStates(),
cloudtentaclesAccounts: listAdminCloudtentaclesMonitorAccounts(),
}
}
@@ -69,6 +72,7 @@ export function updateAdminScheduledJobsConfig(payload: AdminScheduledJobsConfig
filePath: getScheduledJobsFilePath(),
source: mapAdminScheduledJobsConfig(saved),
runtime: getScheduledJobRuntimeStates(),
cloudtentaclesAccounts: listAdminCloudtentaclesMonitorAccounts(),
}
}
@@ -114,6 +118,11 @@ function mapAdminNotificationConfig(config: JsonObject = {}) {
}
function mapAdminScheduledJobsConfig(config: JsonObject = {}) {
const cloudtentaclesAccountMap = new Map(
listAdminCloudtentaclesMonitorAccounts()
.map((item) => [item.sourceKey, item]),
)
return {
enabled: config.enabled !== false,
jobs: (Array.isArray(config.jobs) ? config.jobs : []).map((item) => ({
@@ -123,8 +132,67 @@ function mapAdminScheduledJobsConfig(config: JsonObject = {}) {
intervalSeconds: Number(item.intervalSeconds || 300),
cooldownSeconds: Number(item.cooldownSeconds || 1800),
config: {
assetThreshold: Number(item.config?.assetThreshold || 500),
assetThreshold: normalizeNonNegativeNumber(item.config?.assetThreshold, 500),
accounts: mapScheduledJobCloudtentaclesAccounts(
item.config?.accounts,
normalizeNonNegativeNumber(item.config?.assetThreshold, 500),
cloudtentaclesAccountMap,
),
},
})),
}
}
function listAdminCloudtentaclesMonitorAccounts() {
const sourcesConfig = listCloudtentaclesSources()
const sessionsConfig = getAllCloudtentaclesSessionStates()
const sessions = sessionsConfig.sessions || {}
return (Array.isArray(sourcesConfig.sources) ? sourcesConfig.sources : []).map((source) => {
const sourceKey = String(source.key || '').trim()
const session = sessions[sourceKey] || {}
const label = String(source.label || source.username || sourceKey).trim() || sourceKey
return {
sourceKey,
label,
enabled: source.enabled !== false,
username: String(source.username || '').trim(),
phoneMasked: maskPhone(source.phone || session.phone),
hasToken: Boolean(String(session.token || '').trim()),
loggedInAt: String(session.loggedInAt || '').trim(),
}
}).filter((item) => item.sourceKey)
}
function mapScheduledJobCloudtentaclesAccounts(
rawAccounts: unknown,
defaultAssetThreshold: number,
cloudtentaclesAccountMap: Map<string, JsonObject>,
) {
const accounts = Array.isArray(rawAccounts) ? rawAccounts : []
return accounts.map((item) => {
const source = isPlainObject(item) ? item : {}
const sourceKey = String(source.sourceKey || source.key || '').trim()
const option = cloudtentaclesAccountMap.get(sourceKey) || {}
return {
sourceKey,
label: String(source.label || option.label || sourceKey).trim(),
enabled: source.enabled !== false,
assetThreshold: normalizeNonNegativeNumber(
source.assetThreshold ?? source.threshold,
defaultAssetThreshold,
),
}
}).filter((item) => item.sourceKey)
}
function normalizeNonNegativeNumber(value: unknown, fallback: number) {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback
}
function isPlainObject(value: unknown): value is JsonObject {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
@@ -79,16 +79,24 @@ export function notifyCloudtentaclesAuthExpired({
errorCode = '',
message = '',
cooldownSeconds = 600,
sourceKey = '',
accountLabel = '',
}: JsonObject = {}) {
const normalizedSourceKey = String(sourceKey || '').trim()
const normalizedAccountLabel = String(accountLabel || '').trim()
return notifyInternalSafely({
title: 'cloudtentacles 登录已过期',
title: normalizedAccountLabel
? `cloudtentacles 登录已过期:${normalizedAccountLabel}`
: 'cloudtentacles 登录已过期',
body: [
normalizedSourceKey ? `账号:${normalizedAccountLabel || normalizedSourceKey}` : '',
`接口:${String(pathname || '').trim() || '-'}`,
`错误码:${String(errorCode || '').trim() || '-'}`,
`原因:${String(message || '').trim() || '登录态已失效,请到后台重新登录'}`,
].join('\n'),
].filter(Boolean).join('\n'),
category: 'cloudtentacles_auth_expired',
cooldownKey: ['cloudtentacles_auth_expired', pathname, errorCode].join(':'),
cooldownKey: ['cloudtentacles_auth_expired', normalizedSourceKey || 'default', pathname, errorCode].join(':'),
cooldownMs: Number(cooldownSeconds || 600) * 1000,
})
}
@@ -97,16 +105,24 @@ export function notifyCloudtentaclesAssetLow({
asset = 0,
threshold = 500,
cooldownSeconds = 1800,
sourceKey = '',
accountLabel = '',
}: JsonObject = {}) {
const normalizedSourceKey = String(sourceKey || '').trim()
const normalizedAccountLabel = String(accountLabel || '').trim()
return notifyInternalSafely({
title: '快手 Cloud 余额低于阈值',
title: normalizedAccountLabel
? `快手 Cloud 余额低于阈值:${normalizedAccountLabel}`
: '快手 Cloud 余额低于阈值',
body: [
normalizedSourceKey ? `账号:${normalizedAccountLabel || normalizedSourceKey}` : '',
`当前余额:${Number(asset || 0)}`,
`提醒阈值:${Number(threshold || 0)}`,
'请及时补充 cloudtentacles 余额,避免自动履约失败。',
].join('\n'),
].filter(Boolean).join('\n'),
category: 'cloudtentacles_asset_low',
cooldownKey: ['cloudtentacles_asset_low', threshold].join(':'),
cooldownKey: ['cloudtentacles_asset_low', normalizedSourceKey || 'default', threshold].join(':'),
cooldownMs: Number(cooldownSeconds || 1800) * 1000,
})
}
@@ -61,6 +61,8 @@ export async function cloudtentaclesRequest(pathname: unknown, options: JsonObje
pathname: normalizedPathname,
errorCode,
message,
sourceKey: options.sourceKey,
accountLabel: options.accountLabel,
})
}
@@ -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, {
@@ -139,6 +139,14 @@ export type AdminScheduledJobInput = {
cooldownSeconds?: number | string
config?: {
assetThreshold?: number | string
accounts?: Array<{
sourceKey?: string
key?: string
label?: string
enabled?: boolean
assetThreshold?: number | string
threshold?: number | string
}>
}
}