fix: restore legacy cron schedules and shell diagnostics (#3078)

This commit is contained in:
whyour
2026-09-27 16:16:22 +08:00
parent a67d44cde5
commit a8310e7d51
9 changed files with 108 additions and 18 deletions
+8 -6
View File
@@ -1,6 +1,7 @@
import { ServerUnaryCall, sendUnaryData, status } from '@grpc/grpc-js'; import { ServerUnaryCall, sendUnaryData, status } from '@grpc/grpc-js';
import { AddCronRequest, AddCronResponse } from '../protos/cron'; import { AddCronRequest, AddCronResponse } from '../protos/cron';
import nodeSchedule from 'node-schedule'; import nodeSchedule from 'node-schedule';
import CronExpressionParser from 'cron-parser';
import { scheduleStacks } from './data'; import { scheduleStacks } from './data';
import { runCron } from '../shared/runCron'; import { runCron } from '../shared/runCron';
import Logger from '../loaders/logger'; import Logger from '../loaders/logger';
@@ -17,12 +18,13 @@ const isValidCronField = (cron: string): boolean => {
if (/\s\/\d/.test(cron) || /^\/\d/.test(cron)) { if (/\s\/\d/.test(cron) || /^\/\d/.test(cron)) {
return false; return false;
} }
// 检测 ? 字符:Quartz cron 语法,node-schedule 在大多数位置返回 null // 日期和星期字段的 ? 是合法通配符。解析完整表达式,避免将单独的
// cron-parser 接受但 node-schedule 拒绝,提前拦截 // ? 等无效规则放行后,在替换恢复快照时清除已有任务。
if (/\?/.test(cron)) { try {
return CronExpressionParser.parse(cron).hasNext();
} catch {
return false; return false;
} }
return true;
}; };
const addCron = ( const addCron = (
@@ -38,7 +40,7 @@ const addCron = (
if (!isValidCronField(schedule)) { if (!isValidCronField(schedule)) {
validationErrors.push( validationErrors.push(
tf( tf(
'任务ID %s: 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)', '任务ID %s: 无效的 cron 表达式 "%s"',
String(id), String(id),
schedule, schedule,
), ),
@@ -50,7 +52,7 @@ const addCron = (
if (!isValidCronField(x.schedule)) { if (!isValidCronField(x.schedule)) {
validationErrors.push( validationErrors.push(
tf( tf(
'任务ID %s (extra_schedule): 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)', '任务ID %s (extra_schedule): 无效的 cron 表达式 "%s"',
String(id), String(id),
x.schedule, x.schedule,
), ),
+1 -1
View File
@@ -1102,7 +1102,7 @@ export default class CronService {
await cronClient.addCron(regularCrons, requireScheduler); await cronClient.addCron(regularCrons, requireScheduler);
} catch (error: any) { } catch (error: any) {
this.logger.warn( this.logger.warn(
'[crontab] Failed to register cron job in scheduler:', '[crontab] Failed to register cron job in scheduler: %s',
error?.message || error, error?.message || error,
); );
if (requireScheduler) throw error; if (requireScheduler) throw error;
+4 -4
View File
@@ -142,10 +142,10 @@ const messages: Record<string, Record<string, string>> = {
'Scheduler registration failed, task update rolled back', 'Scheduler registration failed, task update rolled back',
'调度器注册失败,任务启用已回滚': '调度器注册失败,任务启用已回滚':
'Scheduler registration failed, task enable rolled back', 'Scheduler registration failed, task enable rolled back',
'任务ID %s: 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)': '任务ID %s: 无效的 cron 表达式 "%s"':
'Task ID %s: invalid cron expression "%s" (bare /N steps and ? character not supported)', 'Task ID %s: invalid cron expression "%s"',
'任务ID %s (extra_schedule): 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)': '任务ID %s (extra_schedule): 无效的 cron 表达式 "%s"':
'Task ID %s (extra_schedule): invalid cron expression "%s" (bare /N steps and ? character not supported)', 'Task ID %s (extra_schedule): invalid cron expression "%s"',
}, },
}; };
-5
View File
@@ -17,11 +17,6 @@ const validateSchedule = (value: string, helpers: any) => {
if (/\s\/\d/.test(value) || /^\/\d/.test(value)) { if (/\s\/\d/.test(value) || /^\/\d/.test(value)) {
return helpers.error('any.invalid'); return helpers.error('any.invalid');
} }
// 检测 ? 字符:Quartz cron 语法,node-schedule 在大多数字段上返回 null
if (/\?/.test(value)) {
return helpers.error('any.invalid');
}
try { try {
if (CronExpressionParser.parse(value).hasNext()) { if (CronExpressionParser.parse(value).hasNext()) {
return value; return value;
+6
View File
@@ -1,5 +1,11 @@
# 青龙 2.22.0 # 青龙 2.22.0
## 2026-09-27 覆盖更新
- 修复历史任务中合法的 `?` 定时表达式被拒绝,导致启动恢复失败、面板健康检查返回 503 的问题(#3078)。主规则和附加规则均保留兼容性,无效规则仍在替换现有任务前拒绝。
- 修复 `ql check` 等 Shell 诊断输出以 `--` 开头时的 `printf: --: invalid option`,补全调度注册失败日志中的错误详情。
- 已使用真实调度任务验证恢复流程;本地回归 237 项,234 通过、3 项平台相关跳过,后端构建通过。
## 新增功能 ## 新增功能
- 独立远程命令行包 `@whyour/qinglong-cli`:覆盖任务、订阅、环境变量、配置、脚本、日志、依赖、应用、系统等 OpenAPI,支持认证、JSON 输出和中英文帮助。 - 独立远程命令行包 `@whyour/qinglong-cli`:覆盖任务、订阅、环境变量、配置、脚本、日志、依赖、应用、系统等 OpenAPI,支持认证、JSON 输出和中英文帮助。
+1 -1
View File
@@ -110,7 +110,7 @@ t() {
fi fi
[[ -z $msg ]] && msg="$key" [[ -z $msg ]] && msg="$key"
# shellcheck disable=SC2059 # shellcheck disable=SC2059
printf "$msg\n" "$@" printf -- "$msg\n" "$@"
} }
set_proxy() { set_proxy() {
+70
View File
@@ -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
View File
@@ -1,7 +1,8 @@
version: 2.22.0 version: 2.22.0
changeLogLink: https://github.com/whyour/qinglong/blob/master/docs/releases/2.22.0.md 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: | changeLog: |
修复更新后含 ? 的历史定时规则导致面板 503,以及 ql check 的 printf 报错(#3078)
1. 新增独立远程 CLI,覆盖任务、订阅、环境变量、配置、脚本等 OpenAPI 1. 新增独立远程 CLI,覆盖任务、订阅、环境变量、配置、脚本等 OpenAPI
2. 新增可选面板内部 TypeScript 工具,默认保留 Shell 入口 2. 新增可选面板内部 TypeScript 工具,默认保留 Shell 入口
3. 仪表盘支持查看今日成功和失败任务明细 3. 仪表盘支持查看今日成功和失败任务明细