Skip to content

Commit fd6bcdd

Browse files
committed
per(scheduler): 将内联的cron-parser替换为基于服务的nextTick计算
- 提取cron间隔/下次触发逻辑到可重用的服务函数中 - 计算scheduledAt(纯cron)和estimatedAt(上次tick + 间隔)
1 parent 1eeba8c commit fd6bcdd

File tree

2 files changed

+95
-31
lines changed

2 files changed

+95
-31
lines changed

backend/src/routes/scheduledRoutes.js

Lines changed: 8 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -16,32 +16,13 @@ import {
1616
getScheduledJobsHourlyAnalytics,
1717
} from "../services/scheduledJobRunService.js";
1818
import { scheduledTaskRegistry } from "../scheduled/ScheduledTaskRegistry.js";
19-
import { CronExpressionParser } from "cron-parser";
2019
import { isCloudflareWorkerEnvironment } from "../utils/environmentUtils.js";
21-
import { getSchedulerTickState } from "../services/schedulerTickerStateService.js";
20+
import { computeSchedulerTickerNextTick, getSchedulerTickState } from "../services/schedulerTickerStateService.js";
2221

2322
// 调度任务相关路由(仅 Docker/Node 环境使用 + 管理员配置)
2423
const scheduledRoutes = new Hono();
2524
const requireAdmin = usePolicy("admin.all");
2625

27-
/**
28-
* 从 cron 规则计算“下一次触发时间”(UTC ISO)
29-
* @param {string} cron
30-
* @param {string} nowIso
31-
* @returns {{ nextFireAt: string | null, error: string | null }}
32-
*/
33-
function computeNextFireAtFromCron(cron, nowIso) {
34-
if (!cron || typeof cron !== "string") {
35-
return { nextFireAt: null, error: "cron 为空" };
36-
}
37-
try {
38-
const expr = CronExpressionParser.parse(cron, { currentDate: nowIso });
39-
return { nextFireAt: expr.next().toDate().toISOString(), error: null };
40-
} catch (e) {
41-
return { nextFireAt: null, error: e?.message || String(e) };
42-
}
43-
}
44-
4526
// ==================== Handler 类型 API ====================
4627

4728
// 获取所有 handler 类型列表(管理员)
@@ -240,23 +221,16 @@ scheduledRoutes.get("/api/admin/scheduled/ticker", requireAdmin, async (c) => {
240221
? "default"
241222
: "missing";
242223

243-
// 计算“下一次触发时间”
244-
let nextFireAt = null;
245-
let cronParseError = null;
246224
const tickState = await getSchedulerTickState(c.env.DB);
247225
const observedCron = tickState.lastCron || null;
248226
// 1) lastCron(来自“真实触发”的 cron)
249227
// 2) Docker 环境回退到 configuredCron(因为 Docker 的触发器就是靠它配置的)
250228
// 3) Workers 环境没有 lastCron 前,无法计算 next(只能等待首次真实触发)
251229
const activeCron = observedCron || (runtime === "docker" ? configuredCron : null);
252-
if (activeCron) {
253-
const res = computeNextFireAtFromCron(activeCron, nowIso);
254-
nextFireAt = res.nextFireAt;
255-
cronParseError = res.error;
256-
}
257230

258231
const lastTickMs = tickState.lastMs;
259232
const lastTickAt = lastTickMs ? new Date(lastTickMs).toISOString() : null;
233+
const nextTick = computeSchedulerTickerNextTick({ activeCron, nowIso, lastTickMs });
260234

261235
return jsonOk(
262236
c,
@@ -276,13 +250,16 @@ scheduledRoutes.get("/api/admin/scheduled/ticker", requireAdmin, async (c) => {
276250
source: lastTickMs ? "system_settings" : null,
277251
},
278252
nextTick: {
279-
at: nextFireAt,
280-
cronParseError,
253+
at: nextTick.at,
254+
scheduledAt: nextTick.scheduledAt,
255+
estimatedAt: nextTick.estimatedAt,
256+
intervalSec: nextTick.intervalSec,
257+
cronParseError: nextTick.cronParseError,
281258
},
282259
note:
283260
runtime === "cloudflare" && !observedCron
284261
? "尚未观察到平台触发器的首次真实触发:暂时无法计算预计下次触发时间;首次触发后会自动显示。"
285-
: "提示:这里显示的是“预计时间”(按 cron 规则计算),实际触发可能存在延迟;到点后可点右下角刷新校准。",
262+
: "提示:at 优先按“上次真实触发 + 间隔”估算(可能包含延迟);scheduledAt 是 cron 的计划时间(通常是整分/整 5 分钟)。到点后可点右下角刷新校准。",
286263
},
287264
"获取平台触发器状态成功",
288265
);

backend/src/services/schedulerTickerStateService.js

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { DbTables } from "../constants/index.js";
22
import { SETTING_FLAGS, SETTING_GROUPS, SETTING_TYPES } from "../constants/settings.js";
3+
import { CronExpressionParser } from "cron-parser";
34

45
// 用于存储“外部触发器(CF/Docker tick)真实触发状态”的固定 key
56
// - 只维护 1 行(system_settings.key 为主键)
@@ -97,3 +98,89 @@ export async function upsertSchedulerTickState(db, state) {
9798
});
9899
}
99100
}
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

Comments
 (0)