From a8310e7d510347d1e6796ae74754469ea01fed59 Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 27 Sep 2026 16:16:22 +0800 Subject: [PATCH] fix: restore legacy cron schedules and shell diagnostics (#3078) --- back/schedule/addCron.ts | 14 +++-- back/services/cron.ts | 2 +- back/shared/i18n.ts | 8 +-- back/validation/schedule.ts | 5 -- docs/releases/2.22.0.md | 6 ++ shell/share.sh | 2 +- test/back/legacy-cron-recovery.test.cjs | 70 +++++++++++++++++++++ test/back/shell-translation-output.test.cjs | 16 +++++ version.yaml | 3 +- 9 files changed, 108 insertions(+), 18 deletions(-) create mode 100644 test/back/legacy-cron-recovery.test.cjs create mode 100644 test/back/shell-translation-output.test.cjs diff --git a/back/schedule/addCron.ts b/back/schedule/addCron.ts index d3c2fdcf..7df2d9a1 100644 --- a/back/schedule/addCron.ts +++ b/back/schedule/addCron.ts @@ -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, ), diff --git a/back/services/cron.ts b/back/services/cron.ts index afc4289f..558c29c7 100644 --- a/back/services/cron.ts +++ b/back/services/cron.ts @@ -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; diff --git a/back/shared/i18n.ts b/back/shared/i18n.ts index 1afa6e88..6b05d86c 100644 --- a/back/shared/i18n.ts +++ b/back/shared/i18n.ts @@ -142,10 +142,10 @@ const messages: Record> = { '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"', }, }; diff --git a/back/validation/schedule.ts b/back/validation/schedule.ts index ac0632c5..c948b461 100644 --- a/back/validation/schedule.ts +++ b/back/validation/schedule.ts @@ -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; diff --git a/docs/releases/2.22.0.md b/docs/releases/2.22.0.md index 2bd67283..dfde248f 100644 --- a/docs/releases/2.22.0.md +++ b/docs/releases/2.22.0.md @@ -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 输出和中英文帮助。 diff --git a/shell/share.sh b/shell/share.sh index 8a38a746..575e8288 100755 --- a/shell/share.sh +++ b/shell/share.sh @@ -110,7 +110,7 @@ t() { fi [[ -z $msg ]] && msg="$key" # shellcheck disable=SC2059 - printf "$msg\n" "$@" + printf -- "$msg\n" "$@" } set_proxy() { diff --git a/test/back/legacy-cron-recovery.test.cjs b/test/back/legacy-cron-recovery.test.cjs new file mode 100644 index 00000000..c25d2a8d --- /dev/null +++ b/test/back/legacy-cron-recovery.test.cjs @@ -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())); + } + } +}); diff --git a/test/back/shell-translation-output.test.cjs b/test/back/shell-translation-output.test.cjs new file mode 100644 index 00000000..a44edb1f --- /dev/null +++ b/test/back/shell-translation-output.test.cjs @@ -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'); +}); diff --git a/version.yaml b/version.yaml index 64d24b9e..f477d7ef 100644 --- a/version.yaml +++ b/version.yaml @@ -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. 仪表盘支持查看今日成功和失败任务明细