import path from 'node:path' import type { JsonObject } from '../../types/json.js' import { APP_CONFIG_KEYS } from '../../config/app-config-keys.js' import { PROJECT_ROOT } from '../../config/runtime.js' import { readAppConfigEntry, saveAppConfigEntry, } from '../config/app-config-store.js' const SCHEDULED_JOBS_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'scheduled-jobs.json') const CLOUDTENTACLES_HEALTH_JOB_ID = 'cloudtentacles-health' const WORK_ORDER_TIMEOUT_JOB_ID = 'work-order-timeout' const DEPOSIT_UNFREEZE_JOB_ID = 'deposit-unfreeze' export function getScheduledJobsFilePath() { return SCHEDULED_JOBS_FILE_PATH } export function getScheduledJobsConfig() { return readAppConfigEntry({ configKey: APP_CONFIG_KEYS.scheduledJobs, legacyFilePath: SCHEDULED_JOBS_FILE_PATH, fallback: createDefaultScheduledJobsConfig, normalize: normalizeScheduledJobsConfig, }) } export function saveScheduledJobsConfig(rawValue: unknown) { return saveAppConfigEntry({ configKey: APP_CONFIG_KEYS.scheduledJobs, legacyFilePath: SCHEDULED_JOBS_FILE_PATH, value: rawValue, normalize: normalizeScheduledJobsConfig, }) } export function getCloudtentaclesHealthJob(config: JsonObject = getScheduledJobsConfig()) { return (Array.isArray(config.jobs) ? config.jobs : []) .find((item) => String(item.id || '').trim() === CLOUDTENTACLES_HEALTH_JOB_ID) || createDefaultCloudtentaclesHealthJob() } export function getWorkOrderTimeoutJob(config: JsonObject = getScheduledJobsConfig()) { return (Array.isArray(config.jobs) ? config.jobs : []) .find((item) => String(item.id || '').trim() === WORK_ORDER_TIMEOUT_JOB_ID) || createDefaultWorkOrderTimeoutJob() } export function getDepositUnfreezeJob(config: JsonObject = getScheduledJobsConfig()) { return (Array.isArray(config.jobs) ? config.jobs : []) .find((item) => String(item.id || '').trim() === DEPOSIT_UNFREEZE_JOB_ID) || createDefaultDepositUnfreezeJob() } export function getScheduledJobById(jobId: unknown, config: JsonObject = getScheduledJobsConfig()) { return (Array.isArray(config.jobs) ? config.jobs : []) .find((item) => String(item.id || '').trim() === String(jobId || '').trim()) || null } 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((item): item is NonNullable> => Boolean(item)) const hasCloudtentaclesHealth = jobs.some((item) => item.id === CLOUDTENTACLES_HEALTH_JOB_ID) const hasWorkOrderTimeout = jobs.some((item) => item.id === WORK_ORDER_TIMEOUT_JOB_ID) const hasDepositUnfreeze = jobs.some((item) => item.id === DEPOSIT_UNFREEZE_JOB_ID) if (!hasCloudtentaclesHealth) { jobs.push(createDefaultCloudtentaclesHealthJob()) } if (!hasWorkOrderTimeout) { jobs.push(createDefaultWorkOrderTimeoutJob()) } if (!hasDepositUnfreeze) { jobs.push(createDefaultDepositUnfreezeJob()) } return { enabled: typeof source.enabled === 'boolean' ? source.enabled : true, jobs, } } function normalizeScheduledJob(rawValue: unknown) { if (!isPlainObject(rawValue)) { return null } const type = String(rawValue.type || '').trim() if (type === 'cloudtentacles_health') { return normalizeCloudtentaclesHealthJob(rawValue) } if (type === 'work_order_timeout') { return normalizeWorkOrderTimeoutJob(rawValue) } if (type === 'deposit_unfreeze') { return normalizeDepositUnfreezeJob(rawValue) } return null } function normalizeDepositUnfreezeJob(rawValue: JsonObject) { const config = isPlainObject(rawValue.config) ? rawValue.config : {} return { id: DEPOSIT_UNFREEZE_JOB_ID, type: 'deposit_unfreeze', enabled: rawValue.enabled !== false, intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 60, 30, 3600), config: { scanLimit: normalizeRangeInteger(config.scanLimit, 100, 1, 2000), }, } } function normalizeWorkOrderTimeoutJob(rawValue: JsonObject) { const config = isPlainObject(rawValue.config) ? rawValue.config : {} return { id: WORK_ORDER_TIMEOUT_JOB_ID, type: 'work_order_timeout', enabled: rawValue.enabled !== false, intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 60, 30, 3600), config: { scanLimit: normalizeRangeInteger(config.scanLimit, 50, 1, 1000), }, } } 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, type: 'cloudtentacles_health', enabled: rawValue.enabled === true, intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 300, 60, 86400), cooldownSeconds: normalizeRangeInteger(rawValue.cooldownSeconds, 1800, 60, 86400), config: { 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[] if (accounts.length === 0) { accounts.push(normalizeCloudtentaclesHealthAccount({ sourceKey: config.sourceKey || 'default', assetThreshold: defaultAssetThreshold, }, defaultAssetThreshold)) } const seen = new Set() 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, jobs: [ createDefaultCloudtentaclesHealthJob(), createDefaultWorkOrderTimeoutJob(), createDefaultDepositUnfreezeJob(), ], } } function createDefaultDepositUnfreezeJob() { return { id: DEPOSIT_UNFREEZE_JOB_ID, type: 'deposit_unfreeze', enabled: true, intervalSeconds: 60, config: { scanLimit: 100, }, } } function createDefaultWorkOrderTimeoutJob() { return { id: WORK_ORDER_TIMEOUT_JOB_ID, type: 'work_order_timeout', enabled: true, intervalSeconds: 60, config: { scanLimit: 50, }, } } function createDefaultCloudtentaclesHealthJob() { return { id: CLOUDTENTACLES_HEALTH_JOB_ID, type: 'cloudtentacles_health', enabled: false, intervalSeconds: 300, cooldownSeconds: 1800, config: { assetThreshold: 500, accounts: [ { sourceKey: 'default', label: '默认账号', enabled: true, assetThreshold: 500, }, ], }, } } function normalizeRangeInteger(value: unknown, fallback: number, min: number, max: number) { const parsed = Number(value) if (!Number.isInteger(parsed)) { return fallback } return Math.min(max, Math.max(min, parsed)) } function isPlainObject(value: unknown): value is JsonObject { return Object.prototype.toString.call(value) === '[object Object]' }