简单的定时任务--ok

This commit is contained in:
yml2213
2026-05-14 17:59:45 +08:00
parent b6c493d5e7
commit f7f3289d04
19 changed files with 954 additions and 5 deletions
@@ -0,0 +1,58 @@
// @ts-check
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 {
notifyCloudtentaclesAssetLow,
notifyCloudtentaclesAuthExpired,
} from '../notification/domain-notifications.js'
export async function runCloudtentaclesHealthJob(job) {
const source = getCloudtentaclesSourceConfig()
const session = getCloudtentaclesSessionState()
const token = String(session.token || '').trim()
const threshold = Number(job.config?.assetThreshold || 500)
const cooldownSeconds = Number(job.cooldownSeconds || 1800)
if (!token) {
await notifyCloudtentaclesAuthExpired({
pathname: '/user/get_asset',
errorCode: 'cloudtentacles_token_missing',
message: '当前 cloudtentacles 没有可用 token,请到后台重新登录',
cooldownSeconds,
})
return {
ok: false,
status: 'auth_missing',
message: 'cloudtentacles token 缺失',
asset: null,
threshold,
}
}
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)
if (asset < threshold) {
await notifyCloudtentaclesAssetLow({
asset,
threshold,
cooldownSeconds,
})
}
return {
ok: true,
status: asset < threshold ? 'asset_low' : 'ok',
message: asset < threshold ? `余额 ${asset} 低于阈值 ${threshold}` : `余额 ${asset} 正常`,
asset,
threshold,
}
}
@@ -0,0 +1,122 @@
// @ts-check
import fs from 'node:fs'
import path from 'node:path'
import { PROJECT_ROOT } from '../../config/runtime.js'
const SCHEDULED_JOBS_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'scheduled-jobs.json')
const CLOUDTENTACLES_HEALTH_JOB_ID = 'cloudtentacles-health'
export function getScheduledJobsFilePath() {
return SCHEDULED_JOBS_FILE_PATH
}
export function getScheduledJobsConfig() {
return loadScheduledJobsConfigFromFile()
}
export function saveScheduledJobsConfig(rawValue) {
const normalized = normalizeScheduledJobsConfig(rawValue)
fs.mkdirSync(path.dirname(SCHEDULED_JOBS_FILE_PATH), { recursive: true })
fs.writeFileSync(SCHEDULED_JOBS_FILE_PATH, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8')
return normalized
}
export function getCloudtentaclesHealthJob(config = getScheduledJobsConfig()) {
return (Array.isArray(config.jobs) ? config.jobs : [])
.find((item) => String(item.id || '').trim() === CLOUDTENTACLES_HEALTH_JOB_ID)
|| createDefaultCloudtentaclesHealthJob()
}
function loadScheduledJobsConfigFromFile() {
if (!fs.existsSync(SCHEDULED_JOBS_FILE_PATH)) {
return createDefaultScheduledJobsConfig()
}
try {
const rawText = fs.readFileSync(SCHEDULED_JOBS_FILE_PATH, 'utf8')
return normalizeScheduledJobsConfig(JSON.parse(rawText))
} catch {
return createDefaultScheduledJobsConfig()
}
}
function normalizeScheduledJobsConfig(rawValue) {
const source = isPlainObject(rawValue) ? rawValue : {}
const rawJobs = Array.isArray(source.jobs) ? source.jobs : []
const jobs = rawJobs.map((item) => normalizeScheduledJob(item)).filter(Boolean)
const hasCloudtentaclesHealth = jobs.some((item) => item.id === CLOUDTENTACLES_HEALTH_JOB_ID)
if (!hasCloudtentaclesHealth) {
jobs.push(createDefaultCloudtentaclesHealthJob())
}
return {
enabled: typeof source.enabled === 'boolean' ? source.enabled : true,
jobs,
}
}
function normalizeScheduledJob(rawValue) {
if (!isPlainObject(rawValue)) {
return null
}
const type = String(rawValue.type || '').trim()
if (type !== 'cloudtentacles_health') {
return null
}
return normalizeCloudtentaclesHealthJob(rawValue)
}
function normalizeCloudtentaclesHealthJob(rawValue) {
const config = isPlainObject(rawValue.config) ? rawValue.config : {}
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: normalizeRangeInteger(config.assetThreshold, 500, 0, 999999),
},
}
}
function createDefaultScheduledJobsConfig() {
return {
enabled: true,
jobs: [
createDefaultCloudtentaclesHealthJob(),
],
}
}
function createDefaultCloudtentaclesHealthJob() {
return {
id: CLOUDTENTACLES_HEALTH_JOB_ID,
type: 'cloudtentacles_health',
enabled: false,
intervalSeconds: 300,
cooldownSeconds: 1800,
config: {
assetThreshold: 500,
},
}
}
function normalizeRangeInteger(value, fallback, min, max) {
const parsed = Number(value)
if (!Number.isInteger(parsed)) {
return fallback
}
return Math.min(max, Math.max(min, parsed))
}
function isPlainObject(value) {
return Object.prototype.toString.call(value) === '[object Object]'
}
@@ -0,0 +1,189 @@
// @ts-check
import { logError, logInfo, logWarn } from '../../utils/logger.js'
import {
getCloudtentaclesHealthJob,
getScheduledJobsConfig,
} from './config-service.js'
import { runCloudtentaclesHealthJob } from './cloudtentacles-health-job.js'
const timers = new Map()
const jobStates = new Map()
export function startScheduledJobs() {
reloadScheduledJobs()
}
export function stopScheduledJobs() {
for (const timer of timers.values()) {
clearTimeout(timer)
}
timers.clear()
}
export function reloadScheduledJobs() {
stopScheduledJobs()
const config = getScheduledJobsConfig()
if (config.enabled === false) {
logInfo('[scheduler]', '定时任务系统已停用')
return
}
for (const job of config.jobs) {
if (job.enabled !== true) {
updateJobState(job.id, {
enabled: false,
running: false,
nextRunAt: '',
})
continue
}
scheduleJob(job, 1000)
}
}
export function getScheduledJobRuntimeStates() {
return Array.from(jobStates.values())
}
export async function runScheduledJobNow(jobId) {
const config = getScheduledJobsConfig()
const job = config.jobs.find((item) => String(item.id || '').trim() === String(jobId || '').trim())
if (!job) {
throw new Error('定时任务不存在')
}
const currentTimer = timers.get(job.id)
if (currentTimer) {
clearTimeout(currentTimer)
timers.delete(job.id)
}
const result = await runJob(job, { manual: true })
if (getScheduledJobsConfig().enabled !== false) {
const latestJob = getCloudtentaclesHealthJob(getScheduledJobsConfig())
if (latestJob.enabled === true) {
scheduleJob(latestJob, Math.max(60, Number(latestJob.intervalSeconds || 300)) * 1000)
}
}
return result
}
function scheduleJob(job, delayMs) {
const jobId = String(job.id || '').trim()
if (!jobId) {
return
}
const nextRunAt = new Date(Date.now() + delayMs).toISOString()
updateJobState(jobId, {
enabled: true,
nextRunAt,
})
const timer = setTimeout(() => {
timers.delete(jobId)
void runJob(job).finally(() => {
const latestJob = getCloudtentaclesHealthJob(getScheduledJobsConfig())
if (latestJob.enabled === true) {
scheduleJob(latestJob, Math.max(60, Number(latestJob.intervalSeconds || 300)) * 1000)
}
})
}, delayMs)
timers.set(jobId, timer)
}
async function runJob(job, { manual = false } = {}) {
const jobId = String(job.id || '').trim()
const startedAt = new Date().toISOString()
if (!jobId) {
return null
}
if (jobStates.get(jobId)?.running) {
logWarn('[scheduler]', '定时任务仍在运行,跳过本次执行', { jobId })
return {
skipped: true,
reason: 'running',
}
}
updateJobState(jobId, {
enabled: job.enabled === true,
running: true,
lastRunAt: startedAt,
lastStatus: 'running',
lastMessage: '执行中',
})
try {
const result = await dispatchJob(job)
updateJobState(jobId, {
running: false,
lastFinishedAt: new Date().toISOString(),
lastStatus: result?.status || 'ok',
lastMessage: result?.message || '执行完成',
lastAsset: typeof result?.asset === 'number' ? result.asset : null,
lastThreshold: typeof result?.threshold === 'number' ? result.threshold : null,
lastManual: manual,
})
logInfo('[scheduler]', '定时任务执行完成', {
jobId,
type: job.type,
status: result?.status || 'ok',
})
return result
} catch (error) {
const message = error instanceof Error ? error.message : String(error || '定时任务执行失败')
updateJobState(jobId, {
running: false,
lastFinishedAt: new Date().toISOString(),
lastStatus: 'failed',
lastMessage: message,
lastManual: manual,
})
logError('[scheduler]', '定时任务执行失败', {
jobId,
type: job.type,
error,
})
return {
ok: false,
status: 'failed',
message,
}
}
}
function dispatchJob(job) {
if (job.type === 'cloudtentacles_health') {
return runCloudtentaclesHealthJob(job)
}
throw new Error(`不支持的定时任务类型:${job.type}`)
}
function updateJobState(jobId, patch) {
const current = jobStates.get(jobId) || {
id: jobId,
enabled: false,
running: false,
lastRunAt: '',
lastFinishedAt: '',
nextRunAt: '',
lastStatus: 'pending',
lastMessage: '',
lastAsset: null,
lastThreshold: null,
lastManual: false,
}
jobStates.set(jobId, {
...current,
...patch,
})
}