352 lines
11 KiB
TypeScript
352 lines
11 KiB
TypeScript
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)
|
||
}
|