mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-28 09:02:12 +08:00
fix: restore legacy cron schedules and shell diagnostics (#3078)
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { ServerUnaryCall, sendUnaryData, status } from '@grpc/grpc-js';
|
||||
import { AddCronRequest, AddCronResponse } from '../protos/cron';
|
||||
import nodeSchedule from 'node-schedule';
|
||||
import CronExpressionParser from 'cron-parser';
|
||||
import { scheduleStacks } from './data';
|
||||
import { runCron } from '../shared/runCron';
|
||||
import Logger from '../loaders/logger';
|
||||
@@ -17,12 +18,13 @@ const isValidCronField = (cron: string): boolean => {
|
||||
if (/\s\/\d/.test(cron) || /^\/\d/.test(cron)) {
|
||||
return false;
|
||||
}
|
||||
// 检测 ? 字符:Quartz cron 语法,node-schedule 在大多数位置返回 null
|
||||
// cron-parser 接受但 node-schedule 拒绝,提前拦截
|
||||
if (/\?/.test(cron)) {
|
||||
// 日期和星期字段的 ? 是合法通配符。解析完整表达式,避免将单独的
|
||||
// ? 等无效规则放行后,在替换恢复快照时清除已有任务。
|
||||
try {
|
||||
return CronExpressionParser.parse(cron).hasNext();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const addCron = (
|
||||
@@ -38,7 +40,7 @@ const addCron = (
|
||||
if (!isValidCronField(schedule)) {
|
||||
validationErrors.push(
|
||||
tf(
|
||||
'任务ID %s: 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)',
|
||||
'任务ID %s: 无效的 cron 表达式 "%s"',
|
||||
String(id),
|
||||
schedule,
|
||||
),
|
||||
@@ -50,7 +52,7 @@ const addCron = (
|
||||
if (!isValidCronField(x.schedule)) {
|
||||
validationErrors.push(
|
||||
tf(
|
||||
'任务ID %s (extra_schedule): 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)',
|
||||
'任务ID %s (extra_schedule): 无效的 cron 表达式 "%s"',
|
||||
String(id),
|
||||
x.schedule,
|
||||
),
|
||||
|
||||
@@ -1102,7 +1102,7 @@ export default class CronService {
|
||||
await cronClient.addCron(regularCrons, requireScheduler);
|
||||
} catch (error: any) {
|
||||
this.logger.warn(
|
||||
'[crontab] Failed to register cron job in scheduler:',
|
||||
'[crontab] Failed to register cron job in scheduler: %s',
|
||||
error?.message || error,
|
||||
);
|
||||
if (requireScheduler) throw error;
|
||||
|
||||
+4
-4
@@ -142,10 +142,10 @@ const messages: Record<string, Record<string, string>> = {
|
||||
'Scheduler registration failed, task update rolled back',
|
||||
'调度器注册失败,任务启用已回滚':
|
||||
'Scheduler registration failed, task enable rolled back',
|
||||
'任务ID %s: 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)':
|
||||
'Task ID %s: invalid cron expression "%s" (bare /N steps and ? character not supported)',
|
||||
'任务ID %s (extra_schedule): 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)':
|
||||
'Task ID %s (extra_schedule): invalid cron expression "%s" (bare /N steps and ? character not supported)',
|
||||
'任务ID %s: 无效的 cron 表达式 "%s"':
|
||||
'Task ID %s: invalid cron expression "%s"',
|
||||
'任务ID %s (extra_schedule): 无效的 cron 表达式 "%s"':
|
||||
'Task ID %s (extra_schedule): invalid cron expression "%s"',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -17,11 +17,6 @@ const validateSchedule = (value: string, helpers: any) => {
|
||||
if (/\s\/\d/.test(value) || /^\/\d/.test(value)) {
|
||||
return helpers.error('any.invalid');
|
||||
}
|
||||
// 检测 ? 字符:Quartz cron 语法,node-schedule 在大多数字段上返回 null
|
||||
if (/\?/.test(value)) {
|
||||
return helpers.error('any.invalid');
|
||||
}
|
||||
|
||||
try {
|
||||
if (CronExpressionParser.parse(value).hasNext()) {
|
||||
return value;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 青龙 2.22.0
|
||||
|
||||
## 2026-09-27 覆盖更新
|
||||
|
||||
- 修复历史任务中合法的 `?` 定时表达式被拒绝,导致启动恢复失败、面板健康检查返回 503 的问题(#3078)。主规则和附加规则均保留兼容性,无效规则仍在替换现有任务前拒绝。
|
||||
- 修复 `ql check` 等 Shell 诊断输出以 `--` 开头时的 `printf: --: invalid option`,补全调度注册失败日志中的错误详情。
|
||||
- 已使用真实调度任务验证恢复流程;本地回归 237 项,234 通过、3 项平台相关跳过,后端构建通过。
|
||||
|
||||
## 新增功能
|
||||
|
||||
- 独立远程命令行包 `@whyour/qinglong-cli`:覆盖任务、订阅、环境变量、配置、脚本、日志、依赖、应用、系统等 OpenAPI,支持认证、JSON 输出和中英文帮助。
|
||||
|
||||
+1
-1
@@ -110,7 +110,7 @@ t() {
|
||||
fi
|
||||
[[ -z $msg ]] && msg="$key"
|
||||
# shellcheck disable=SC2059
|
||||
printf "$msg\n" "$@"
|
||||
printf -- "$msg\n" "$@"
|
||||
}
|
||||
|
||||
set_proxy() {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { format } = require('node:util');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const { SchedulerReadiness } = require('../../back/shared/schedulerReadiness');
|
||||
|
||||
const legacySchedules = [
|
||||
'* * * * *',
|
||||
'*/5 * * * *',
|
||||
'0 */10 * * * ?',
|
||||
'0 */2 * * * ?',
|
||||
'0 */5 * * * ?',
|
||||
'0 0/30 * * * ?',
|
||||
'0 0/5 * * * ?',
|
||||
'0 0 12 ? * MON',
|
||||
];
|
||||
|
||||
test('API accepts legacy question-mark schedules but rejects malformed cron', () => {
|
||||
const { scheduleSchema } = load('back/validation/schedule.ts', {
|
||||
'../config': { logPath: '/ql/data/log' },
|
||||
});
|
||||
for (const schedule of [...legacySchedules, '@once', '@boot']) {
|
||||
assert.equal(scheduleSchema.validate(schedule).error, undefined, schedule);
|
||||
}
|
||||
for (const schedule of ['?', '0 /5 * * * ?', '0 70 * * * ?', 'not a cron']) {
|
||||
assert.ok(scheduleSchema.validate(schedule).error, schedule);
|
||||
}
|
||||
});
|
||||
|
||||
test('legacy main and extra schedules restore real jobs and healthy readiness', async (t) => {
|
||||
const stacks = new Map();
|
||||
t.after(() => {
|
||||
for (const jobs of stacks.values()) for (const job of jobs) job.cancel();
|
||||
});
|
||||
const { addCron } = load('back/schedule/addCron.ts', {
|
||||
'./data': { scheduleStacks: stacks },
|
||||
'../shared/runCron': { runCron() {} },
|
||||
'../loaders/logger': { info() {}, warn() {} },
|
||||
'../shared/i18n': { tf: format },
|
||||
});
|
||||
const crons = legacySchedules.map((schedule, index) => ({
|
||||
id: `legacy-${index}`, name: 'legacy', schedule, command: 'true',
|
||||
extra_schedules: [{ schedule: '0 0/30 * * * ?' }],
|
||||
}));
|
||||
const register = (rows) => new Promise((resolve, reject) => {
|
||||
addCron({ request: { replace: true, crons: rows } }, (error) =>
|
||||
error ? reject(error) : resolve());
|
||||
});
|
||||
const readiness = new SchedulerReadiness(async () => {}, 60000);
|
||||
t.after(() => clearTimeout(readiness.retry));
|
||||
readiness.configure(() => register(crons));
|
||||
assert.equal(await readiness.recover(), true);
|
||||
assert.equal(await readiness.check(), true);
|
||||
assert.equal(stacks.size, crons.length);
|
||||
for (const jobs of stacks.values()) {
|
||||
assert.equal(jobs.length, 2);
|
||||
assert.ok(jobs.every((job) => job.nextInvocation()));
|
||||
}
|
||||
const previousJobs = [...stacks.values()].flat();
|
||||
for (const schedule of ['?', '0 /5 * * * ?', '0 70 * * * ?']) {
|
||||
for (const invalid of [
|
||||
{ ...crons[0], schedule },
|
||||
{ ...crons[0], extra_schedules: [{ schedule }] },
|
||||
]) {
|
||||
await assert.rejects(register([invalid]), (error) => error.code === 3);
|
||||
assert.deepEqual([...stacks.values()].flat(), previousJobs);
|
||||
assert.ok(previousJobs.every((job) => job.nextInvocation()));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
test('shell translations print leading dashes, placeholders and newlines', () => {
|
||||
const result = spawnSync('bash', ['-c', String.raw`
|
||||
source shell/share.sh
|
||||
t '---> 服务诊断信息'
|
||||
t '---> 最近的系统日志: %s' '/tmp/test.log'
|
||||
t 'first\nsecond'
|
||||
`], { encoding: 'utf8', env: { ...process.env, QL_DIR: process.cwd() } });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stderr, '');
|
||||
assert.equal(result.stdout,
|
||||
'---> 服务诊断信息\n---> 最近的系统日志: /tmp/test.log\nfirst\nsecond\n');
|
||||
});
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
version: 2.22.0
|
||||
changeLogLink: https://github.com/whyour/qinglong/blob/master/docs/releases/2.22.0.md
|
||||
publishTime: 2026-09-26 0000
|
||||
publishTime: 2026-09-27 1615
|
||||
changeLog: |
|
||||
修复更新后含 ? 的历史定时规则导致面板 503,以及 ql check 的 printf 报错(#3078)
|
||||
1. 新增独立远程 CLI,覆盖任务、订阅、环境变量、配置、脚本等 OpenAPI
|
||||
2. 新增可选面板内部 TypeScript 工具,默认保留 Shell 入口
|
||||
3. 仪表盘支持查看今日成功和失败任务明细
|
||||
|
||||
Reference in New Issue
Block a user