Files
order_site/apps/backend/src/services/scheduler/cloudtentacles-health-job.ts
T

352 lines
11 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 type { JsonObject } from '../../types/json.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'
import {
recycleCloudtentaclesStaleNumbers,
retryCloudtentaclesPendingReturns,
} from './cloudtentacles-number-recycle-service.js'
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, recycleResult, returnRetryResult] = await Promise.all([
Promise.all(
accounts.map((account) => runCloudtentaclesHealthAccount(account, cooldownSeconds)),
),
recycleCloudtentaclesStaleNumbers(),
retryCloudtentaclesPendingReturns(),
])
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,
numberRecycle: recycleResult,
pendingReturnRetry: returnRetryResult,
}
}
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 sources = Array.isArray(sourceConfig.sources) ? sourceConfig.sources : []
const sourceMap = new Map(
sources
.map((source) => [String(source.key || '').trim(), source] as const)
.filter(([sourceKey]) => Boolean(sourceKey)),
)
const configuredMap = new Map(
rawAccounts
.map((item) => normalizeCloudtentaclesHealthAccount(item, defaultThreshold, sourceMap))
.filter(Boolean)
.map((item) => [String(item!.sourceKey || '').trim(), item!] as const)
.filter(([sourceKey]) => Boolean(sourceKey)),
)
// 以平台配置中的全部账号为准,合并任务里已保存的阈值/开关;新增账号自动纳入监控。
const mergedAccounts = sources
.map((source) => {
const sourceKey = String(source.key || '').trim()
if (!sourceKey) {
return null
}
const configured = configuredMap.get(sourceKey)
return {
sourceKey,
label: String(
configured?.label || source.label || source.username || sourceKey,
).trim() || sourceKey,
enabled: configured
? configured.enabled !== false
: source.enabled !== false,
assetThreshold: normalizeNonNegativeInteger(
configured?.assetThreshold,
defaultThreshold,
),
}
})
.filter(Boolean) as JsonObject[]
// 保留已配置但源账号已删除的项,便于报 source_missing。
for (const [sourceKey, configured] of configuredMap.entries()) {
if (sourceMap.has(sourceKey)) {
continue
}
mergedAccounts.push(configured)
}
if (mergedAccounts.length > 0) {
return dedupeAccounts(mergedAccounts)
}
// 兼容旧配置:没有任何平台账号时,仍检查历史 default。
const fallback = configuredMap.get('default')
return [{
sourceKey: 'default',
label: String(fallback?.label || '默认账号').trim() || '默认账号',
enabled: fallback ? fallback.enabled !== false : true,
assetThreshold: normalizeNonNegativeInteger(fallback?.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 '未配置 kuaishou-lewan 监控账号'
}
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)
}