|
1 | 1 | import { DbTables } from "../constants/index.js"; |
2 | 2 | import { SETTING_FLAGS, SETTING_GROUPS, SETTING_TYPES } from "../constants/settings.js"; |
| 3 | +import { CronExpressionParser } from "cron-parser"; |
3 | 4 |
|
4 | 5 | // 用于存储“外部触发器(CF/Docker tick)真实触发状态”的固定 key |
5 | 6 | // - 只维护 1 行(system_settings.key 为主键) |
@@ -97,3 +98,89 @@ export async function upsertSchedulerTickState(db, state) { |
97 | 98 | }); |
98 | 99 | } |
99 | 100 | } |
| 101 | + |
| 102 | +/** |
| 103 | + * 从 cron 估算“触发间隔秒数” |
| 104 | + * |
| 105 | + * |
| 106 | + * 用 cron-parser 连续取两次 next,做差值。 |
| 107 | + * |
| 108 | + * @param {string|null} cron |
| 109 | + * @param {string} nowIso |
| 110 | + * @returns {{ intervalSec: number|null, error: string|null }} |
| 111 | + */ |
| 112 | +export function computeCronIntervalSec(cron, nowIso) { |
| 113 | + const raw = typeof cron === "string" ? cron.trim() : ""; |
| 114 | + if (!raw) return { intervalSec: null, error: "cron 为空" }; |
| 115 | + try { |
| 116 | + const expr = CronExpressionParser.parse(raw, { currentDate: nowIso }); |
| 117 | + const next1 = expr.next().toDate(); |
| 118 | + const next2 = expr.next().toDate(); |
| 119 | + const diffMs = next2.getTime() - next1.getTime(); |
| 120 | + const intervalSec = diffMs > 0 ? Math.floor(diffMs / 1000) : null; |
| 121 | + return { intervalSec, error: null }; |
| 122 | + } catch (e) { |
| 123 | + return { intervalSec: null, error: e?.message || String(e) }; |
| 124 | + } |
| 125 | +} |
| 126 | + |
| 127 | +/** |
| 128 | + * 从 cron 计算下一次“计划触发时间”(UTC ISO) |
| 129 | + * @param {string|null} cron |
| 130 | + * @param {string} nowIso |
| 131 | + * @returns {{ scheduledAt: string|null, error: string|null }} |
| 132 | + */ |
| 133 | +export function computeNextScheduledAtFromCron(cron, nowIso) { |
| 134 | + const raw = typeof cron === "string" ? cron.trim() : ""; |
| 135 | + if (!raw) return { scheduledAt: null, error: "cron 为空" }; |
| 136 | + try { |
| 137 | + const expr = CronExpressionParser.parse(raw, { currentDate: nowIso }); |
| 138 | + const scheduledAt = expr.next().toDate().toISOString(); |
| 139 | + return { scheduledAt, error: null }; |
| 140 | + } catch (e) { |
| 141 | + return { scheduledAt: null, error: e?.message || String(e) }; |
| 142 | + } |
| 143 | +} |
| 144 | + |
| 145 | +/** |
| 146 | + * 计算“平台触发器 ticker”的 nextTick(给 /api/admin/scheduled/ticker 用) |
| 147 | + * |
| 148 | + * - at:给前端倒计时用的预计时间(优先 estimatedAt) |
| 149 | + * - scheduledAt:按 cron 规则算出来的“计划时间”(经常是整分/整 5 分钟) |
| 150 | + * - estimatedAt:按 lastTickMs + intervalSec 推算出来的“体感时间” |
| 151 | + * - intervalSec:从 cron 估算出来的间隔 |
| 152 | + * |
| 153 | + * @param {{ activeCron: string|null, nowIso: string, lastTickMs: number|null }} params |
| 154 | + */ |
| 155 | +export function computeSchedulerTickerNextTick({ activeCron, nowIso, lastTickMs }) { |
| 156 | + const cron = typeof activeCron === "string" && activeCron.trim() ? activeCron.trim() : null; |
| 157 | + |
| 158 | + const scheduledRes = computeNextScheduledAtFromCron(cron, nowIso); |
| 159 | + const intervalRes = computeCronIntervalSec(cron, nowIso); |
| 160 | + |
| 161 | + const intervalSec = intervalRes.intervalSec; |
| 162 | + const hasLastTick = |
| 163 | + typeof lastTickMs === "number" && Number.isFinite(lastTickMs) && lastTickMs > 0; |
| 164 | + |
| 165 | + const canEstimate = |
| 166 | + hasLastTick && |
| 167 | + typeof intervalSec === "number" && |
| 168 | + Number.isFinite(intervalSec) && |
| 169 | + intervalSec > 0; |
| 170 | + |
| 171 | + const estimatedAt = canEstimate |
| 172 | + ? new Date(lastTickMs + intervalSec * 1000).toISOString() |
| 173 | + : null; |
| 174 | + |
| 175 | + const at = estimatedAt || scheduledRes.scheduledAt || null; |
| 176 | + |
| 177 | + const cronParseError = scheduledRes.error || intervalRes.error || null; |
| 178 | + |
| 179 | + return { |
| 180 | + at, |
| 181 | + scheduledAt: scheduledRes.scheduledAt, |
| 182 | + estimatedAt, |
| 183 | + intervalSec, |
| 184 | + cronParseError, |
| 185 | + }; |
| 186 | +} |
0 commit comments