Forráskód Böngészése

web: 新增配置驱动的进程内定时生成(node-cron + instrumentation)

- instrumentation 钩子在服务启动时启动 scheduler,按 config.schedules 到点 spawn CLI 渲染,复用 /api/render 同一链路(抽出的 run-render.startRenderJob),任务进 jobs 列表、走 OSS/飞书发布;单副本 + 单飞防重复/堆积
- config/default.yaml 增加 schedules 段(预置北京时间每天 07:50 github-trending/bilibili)
- SCHEDULES 环境变量整体覆盖、SCHEDULER_ENABLED=false 关闭;新增 node-cron 依赖;DEPLOYMENT.md/.env.example 同步

Co-Authored-By: Claude <noreply@anthropic.com>
lkatzey 5 napja
szülő
commit
6d06346038

+ 6 - 0
.env.example

@@ -66,6 +66,12 @@ FEISHU_WEBHOOK_URL=
 # the per-job "日志" panel. Default info.
 # LOG_LEVEL=info
 
+# --- Scheduled renders (in-process scheduler) ---
+# Override config/default.yaml `schedules` at deploy time without rebuilding.
+# JSON array, same shape as the YAML section, e.g.:
+# SCHEDULES=[{"cron":"50 7 * * *","template":"github-trending","platform":"bilibili","source":"github-trending","publish":true}]
+# SCHEDULER_ENABLED=false   # kill switch for the whole scheduler (default: on)
+
 # --- Container DNS ---
 # Public resolvers used by the container (the host's resolver can fail to
 # resolve some public domains, e.g. open.feishu.cn behind a VPN). Defaults to

+ 2 - 0
apps/web/package.json

@@ -11,6 +11,7 @@
     "@pipeline/shared": "workspace:*",
     "@pipeline/collect": "workspace:*",
     "next": "^15.3.0",
+    "node-cron": "^3.0.3",
     "react": "^19.0.0",
     "react-dom": "^19.0.0",
     "yaml": "^2.7.0",
@@ -18,6 +19,7 @@
   },
   "devDependencies": {
     "@types/node": "^22.0.0",
+    "@types/node-cron": "^3.0.11",
     "@types/react": "^19.0.0",
     "@types/react-dom": "^19.0.0",
     "typescript": "^5.8.0"

+ 11 - 154
apps/web/src/app/api/render/route.ts

@@ -1,11 +1,5 @@
 import { NextResponse } from "next/server";
-import { createJob, updateJob } from "@/lib/job-store";
-import { loadConfig } from "@/lib/config";
-import { resolveOutputDir } from "@pipeline/shared/node";
-import { spawn } from "node:child_process";
-import { writeFileSync, mkdirSync, statSync } from "node:fs";
-import { join, resolve } from "node:path";
-import { tmpdir } from "node:os";
+import { startRenderJob } from "@/lib/run-render";
 
 export async function POST(request: Request) {
   const body = await request.json();
@@ -32,153 +26,16 @@ export async function POST(request: Request) {
     return NextResponse.json({ error: "text or source is required" }, { status: 400 });
   }
 
-  const job = createJob({ ...input, text: input.text ?? "", platforms });
-
-  const jobDir = join(tmpdir(), "pipeline-jobs", job.id);
-  mkdirSync(jobDir, { recursive: true });
-
-  // Same unified output directory the CLI resolves (OUTPUT_DIR > config > ./output).
-  const config = loadConfig();
-  const outputDir = resolveOutputDir(config?.output?.dir);
-
-  const cliArgs = [
-    "render",
-    "-t", input.template,
-    "-p", platforms.join(","),
-    "--tts-provider", input.ttsProvider,
-    "--output", outputDir,
-  ];
-
-  if (input.source) {
-    cliArgs.push("--source", input.source);
-    if (input.sourceArgs?.owner) cliArgs.push("--source-owner", input.sourceArgs.owner);
-    if (input.sourceArgs?.repo) cliArgs.push("--source-repo", input.sourceArgs.repo);
-  } else {
-    const inputFile = join(jobDir, "input.json");
-    writeFileSync(inputFile, input.text!, "utf-8");
-    cliArgs.splice(1, 0, inputFile);
-  }
-  if (input.voiceId) {
-    cliArgs.push("--voice", input.voiceId);
-  }
-  if (options?.noTts) {
-    cliArgs.push("--no-tts");
-  }
-
-  const cliPath = resolve(process.cwd(), "../cli/dist/index.js");
-  const projectRoot = resolve(process.cwd(), "..");
-
-  updateJob(job.id, { status: "running" });
-
-  const child = spawn("node", [cliPath, ...cliArgs], {
-    cwd: projectRoot,
-    stdio: ["ignore", "pipe", "pipe"],
-    env: { ...process.env },
-  });
-
-  const jobIdShort = job.id.slice(0, 8);
-  const linePrefix = `[job ${jobIdShort}] `;
-  const fwdOut = makePrefixer(linePrefix, (s) => process.stdout.write(s));
-  const fwdErr = makePrefixer(linePrefix, (s) => process.stderr.write(s));
-
-  let stdout = "";
-  let stderr = "";
-  let logBuf = "";
-
-  // Capture for parsing/UI AND mirror to the container's stdout/stderr so
-  // `docker compose logs` shows every stage / TTS retry / publish event live.
-  child.stdout.on("data", (data: Buffer) => {
-    const s = data.toString();
-    stdout += s;
-    logBuf += s;
-    fwdOut.push(s);
+  const { jobId } = startRenderJob({
+    template: input.template,
+    platforms,
+    ttsProvider: input.ttsProvider,
+    source: input.source,
+    sourceArgs: input.sourceArgs,
+    text: input.text,
+    voiceId: input.voiceId,
+    noTts: options?.noTts,
   });
 
-  child.stderr.on("data", (data: Buffer) => {
-    const s = data.toString();
-    stderr += s;
-    logBuf += s;
-    fwdErr.push(s);
-  });
-
-  child.on("close", (code: number) => {
-    fwdOut.flush();
-    fwdErr.flush();
-    const log = capLog(logBuf);
-    if (code === 0) {
-      const mp4Matches = [...stdout.matchAll(/-> (.+\.mp4)/g)];
-      const filePaths = mp4Matches.map(m => m[1].trim());
-
-      const outputFiles = filePaths.map(filePath => {
-        let fileSizeBytes = 0;
-        try { fileSizeBytes = statSync(filePath).size; } catch {}
-        const isPortrait = filePath.includes("douyin") || filePath.includes("9x16");
-        return {
-          filePath,
-          fileSizeBytes,
-          width: isPortrait ? 1080 : 1920,
-          height: isPortrait ? 1920 : 1080,
-          durationSeconds: 0,
-        };
-      });
-
-      const ossUrls = [...stdout.matchAll(/^[ \t]*oss: (\S+)/gm)].map(m => m[1].trim());
-      const publishWarn = stdout.match(/^Publish warning: (.+)$/m);
-      const publishError = publishWarn ? publishWarn[1].trim() : undefined;
-
-      updateJob(job.id, {
-        status: "completed",
-        outputFiles: outputFiles.length > 0 ? outputFiles : undefined,
-        ossUrls: ossUrls.length > 0 ? ossUrls : undefined,
-        publishError,
-        log,
-      });
-    } else {
-      updateJob(job.id, {
-        status: "failed",
-        error: stderr.trim() || `Process exited with code ${code}`,
-        log,
-      });
-    }
-  });
-
-  child.on("error", (err: Error) => {
-    fwdOut.flush();
-    fwdErr.flush();
-    updateJob(job.id, {
-      status: "failed",
-      error: err.message,
-      log: capLog(logBuf),
-    });
-  });
-
-  return NextResponse.json({ jobId: job.id, status: "started" });
-}
-
-// Keep the per-job log bounded so jobs.json doesn't grow without limit.
-const LOG_CAP = 200_000;
-function capLog(buf: string): string {
-  if (buf.length <= LOG_CAP) return buf;
-  return `...(truncated ${buf.length - LOG_CAP} bytes from the top)\n` + buf.slice(-LOG_CAP);
-}
-
-// Line-buffered forwarder: writes each complete line (prefixed with the job id)
-// to the container's stdout/stderr, holding back a trailing partial line until
-// the next chunk or flush. Lets `docker compose logs` attribute lines to jobs.
-function makePrefixer(prefix: string, write: (s: string) => void) {
-  let pending = "";
-  return {
-    push(chunk: string) {
-      pending += chunk;
-      const lines = pending.split("\n");
-      pending = lines.pop() ?? "";
-      for (const line of lines) write(prefix + line + "\n");
-    },
-    flush() {
-      if (pending.length > 0) {
-        write(prefix + pending + "\n");
-        pending = "";
-      }
-    },
-  };
+  return NextResponse.json({ jobId, status: "started" });
 }

+ 14 - 0
apps/web/src/instrumentation.ts

@@ -0,0 +1,14 @@
+// Next.js instrumentation hook — `register()` runs once when the Next.js server
+// starts (Node.js runtime). Used to boot the in-process render scheduler so the
+// container can generate videos on a cron without any external scheduler.
+// Stable in Next.js 15 (no next.config flag needed); not invoked during build.
+export async function register(): Promise<void> {
+  if (process.env.NEXT_RUNTIME !== "nodejs") return; // skip edge runtime
+  if (process.env.SCHEDULER_ENABLED === "false") return; // global kill switch
+  const { startScheduler } = await import("./lib/scheduler");
+  try {
+    startScheduler();
+  } catch (e) {
+    console.error("[scheduler] failed to start:", e);
+  }
+}

+ 190 - 0
apps/web/src/lib/run-render.ts

@@ -0,0 +1,190 @@
+import { createJob, updateJob } from "@/lib/job-store";
+import { loadConfig } from "@/lib/config";
+import { resolveOutputDir } from "@pipeline/shared/node";
+import { spawn } from "node:child_process";
+import { writeFileSync, mkdirSync, statSync } from "node:fs";
+import { join, resolve } from "node:path";
+import { tmpdir } from "node:os";
+
+export interface RenderJobParams {
+  template: string;
+  platforms: string[];
+  ttsProvider: string;
+  source?: string;
+  sourceArgs?: Record<string, string>;
+  /** Raw input text (JSON / markdown / plain). Required when `source` is absent. */
+  text?: string;
+  voiceId?: string;
+  noTts?: boolean;
+  /** Skip OSS upload + Feishu notification. Default: publish when configured. */
+  noPublish?: boolean;
+}
+
+/**
+ * Spawn the compiled CLI to render a video, tracking it in the job store so it
+ * shows up in the WebUI / jobs list exactly like a WebUI/HTTP-triggered render.
+ * Shared by `POST /api/render` and the in-process scheduler.
+ *
+ * Returns immediately with the `jobId`; `done` resolves when the child process
+ * exits (success or failure) so callers that need ordering (e.g. the scheduler's
+ * single-flight guard) can await it.
+ */
+export function startRenderJob(params: RenderJobParams): {
+  jobId: string;
+  done: Promise<void>;
+} {
+  const {
+    template, platforms, ttsProvider, source, sourceArgs,
+    text, voiceId, noTts, noPublish,
+  } = params;
+
+  const job = createJob({ text: text ?? "", template, platforms, ttsProvider, voiceId });
+
+  const jobDir = join(tmpdir(), "pipeline-jobs", job.id);
+  mkdirSync(jobDir, { recursive: true });
+
+  // Same unified output directory the CLI resolves (OUTPUT_DIR > config > ./output).
+  const config = loadConfig();
+  const outputDir = resolveOutputDir(config?.output?.dir);
+
+  const cliArgs = [
+    "render",
+    "-t", template,
+    "-p", platforms.join(","),
+    "--tts-provider", ttsProvider,
+    "--output", outputDir,
+  ];
+
+  if (source) {
+    cliArgs.push("--source", source);
+    if (sourceArgs?.owner) cliArgs.push("--source-owner", sourceArgs.owner);
+    if (sourceArgs?.repo) cliArgs.push("--source-repo", sourceArgs.repo);
+  } else {
+    const inputFile = join(jobDir, "input.json");
+    writeFileSync(inputFile, text!, "utf-8");
+    cliArgs.splice(1, 0, inputFile);
+  }
+  if (voiceId) cliArgs.push("--voice", voiceId);
+  if (noTts) cliArgs.push("--no-tts");
+  if (noPublish) cliArgs.push("--no-publish");
+
+  const cliPath = resolve(process.cwd(), "../cli/dist/index.js");
+  const projectRoot = resolve(process.cwd(), "..");
+
+  updateJob(job.id, { status: "running" });
+
+  const child = spawn("node", [cliPath, ...cliArgs], {
+    cwd: projectRoot,
+    stdio: ["ignore", "pipe", "pipe"],
+    env: { ...process.env },
+  });
+
+  const jobIdShort = job.id.slice(0, 8);
+  const linePrefix = `[job ${jobIdShort}] `;
+  const fwdOut = makePrefixer(linePrefix, (s) => process.stdout.write(s));
+  const fwdErr = makePrefixer(linePrefix, (s) => process.stderr.write(s));
+
+  let stdout = "";
+  let stderr = "";
+  let logBuf = "";
+
+  const done = new Promise<void>((resolveDone) => {
+    // Capture for parsing/UI AND mirror to the container's stdout/stderr so
+    // `docker compose logs` shows every stage / TTS retry / publish event live.
+    child.stdout.on("data", (data: Buffer) => {
+      const s = data.toString();
+      stdout += s;
+      logBuf += s;
+      fwdOut.push(s);
+    });
+    child.stderr.on("data", (data: Buffer) => {
+      const s = data.toString();
+      stderr += s;
+      logBuf += s;
+      fwdErr.push(s);
+    });
+
+    child.on("close", (code: number) => {
+      fwdOut.flush();
+      fwdErr.flush();
+      const log = capLog(logBuf);
+      if (code === 0) {
+        const mp4Matches = [...stdout.matchAll(/-> (.+\.mp4)/g)];
+        const filePaths = mp4Matches.map((m) => m[1].trim());
+
+        const outputFiles = filePaths.map((filePath) => {
+          let fileSizeBytes = 0;
+          try { fileSizeBytes = statSync(filePath).size; } catch {}
+          const isPortrait = filePath.includes("douyin") || filePath.includes("9x16");
+          return {
+            filePath,
+            fileSizeBytes,
+            width: isPortrait ? 1080 : 1920,
+            height: isPortrait ? 1920 : 1080,
+            durationSeconds: 0,
+          };
+        });
+
+        const ossUrls = [...stdout.matchAll(/^[ \t]*oss: (\S+)/gm)].map((m) => m[1].trim());
+        const publishWarn = stdout.match(/^Publish warning: (.+)$/m);
+        const publishError = publishWarn ? publishWarn[1].trim() : undefined;
+
+        updateJob(job.id, {
+          status: "completed",
+          outputFiles: outputFiles.length > 0 ? outputFiles : undefined,
+          ossUrls: ossUrls.length > 0 ? ossUrls : undefined,
+          publishError,
+          log,
+        });
+      } else {
+        updateJob(job.id, {
+          status: "failed",
+          error: stderr.trim() || `Process exited with code ${code}`,
+          log,
+        });
+      }
+      resolveDone();
+    });
+
+    child.on("error", (err: Error) => {
+      fwdOut.flush();
+      fwdErr.flush();
+      updateJob(job.id, {
+        status: "failed",
+        error: err.message,
+        log: capLog(logBuf),
+      });
+      resolveDone();
+    });
+  });
+
+  return { jobId: job.id, done };
+}
+
+// Keep the per-job log bounded so jobs.json doesn't grow without limit.
+const LOG_CAP = 200_000;
+function capLog(buf: string): string {
+  if (buf.length <= LOG_CAP) return buf;
+  return `...(truncated ${buf.length - LOG_CAP} bytes from the top)\n` + buf.slice(-LOG_CAP);
+}
+
+// Line-buffered forwarder: writes each complete line (prefixed with the job id)
+// to the container's stdout/stderr, holding back a trailing partial line until
+// the next chunk or flush. Lets `docker compose logs` attribute lines to jobs.
+function makePrefixer(prefix: string, write: (s: string) => void) {
+  let pending = "";
+  return {
+    push(chunk: string) {
+      pending += chunk;
+      const lines = pending.split("\n");
+      pending = lines.pop() ?? "";
+      for (const line of lines) write(prefix + line + "\n");
+    },
+    flush() {
+      if (pending.length > 0) {
+        write(prefix + pending + "\n");
+        pending = "";
+      }
+    },
+  };
+}

+ 136 - 0
apps/web/src/lib/scheduler.ts

@@ -0,0 +1,136 @@
+import { schedule, validate } from "node-cron";
+import { loadConfig } from "@/lib/config";
+import { startRenderJob } from "@/lib/run-render";
+
+export interface ScheduleJob {
+  /** 5-field cron (min hour dom month dow). Interpreted in the container TZ. */
+  cron: string;
+  template: string;
+  platform?: string;
+  /** Data source to collect before rendering (e.g. "github-trending"). */
+  source?: string;
+  /** Upload OSS + push Feishu after render. Default true. */
+  publish?: boolean;
+  enabled?: boolean;
+}
+
+// Single-flight: renders are heavy (Remotion + Chrome + ffmpeg). Only one
+// scheduled render runs at a time; overlapping fires are skipped.
+let busy = false;
+let started = false;
+
+const timezone = (): string => process.env.TZ || "Asia/Shanghai";
+
+/**
+ * Load scheduled-render jobs. Reads `config.schedules` and lets the `SCHEDULES`
+ * env var (a JSON array of the same shape) override it entirely, so a deployment
+ * can change schedules without rebuilding the image. Invalid/disabled entries
+ * are dropped with a warning.
+ */
+export function loadSchedules(): ScheduleJob[] {
+  let raw: unknown;
+  const envSchedules = process.env.SCHEDULES?.trim();
+  if (envSchedules) {
+    try {
+      raw = JSON.parse(envSchedules);
+    } catch (e) {
+      console.error("[scheduler] SCHEDULES env is invalid JSON, falling back to config:", e);
+      raw = loadConfig()?.schedules;
+    }
+  } else {
+    raw = loadConfig()?.schedules;
+  }
+  if (!Array.isArray(raw)) return [];
+
+  const jobs: ScheduleJob[] = [];
+  for (const item of raw) {
+    const j = item as Partial<ScheduleJob>;
+    if (!j || typeof j !== "object") continue;
+    if (!j.cron || typeof j.cron !== "string") {
+      console.warn("[scheduler] skipping schedule missing 'cron':", j);
+      continue;
+    }
+    if (!j.template || typeof j.template !== "string") {
+      console.warn(`[scheduler] skipping schedule missing 'template': ${j.cron}`);
+      continue;
+    }
+    if (!validate(j.cron)) {
+      console.warn(`[scheduler] skipping schedule with invalid cron expression: ${j.cron}`);
+      continue;
+    }
+    if (j.enabled === false) continue;
+    jobs.push({
+      cron: j.cron,
+      template: j.template,
+      platform: j.platform || "bilibili",
+      source: j.source,
+      publish: j.publish !== false, // default true
+      enabled: true,
+    });
+  }
+  return jobs;
+}
+
+/** Register every schedule with node-cron. Idempotent (safe if called twice). */
+export function startScheduler(): void {
+  if (started) return;
+  started = true;
+
+  const jobs = loadSchedules();
+  if (jobs.length === 0) {
+    console.log("[scheduler] no enabled schedules — scheduler idle");
+    return;
+  }
+
+  let registered = 0;
+  for (const job of jobs) {
+    try {
+      schedule(
+        job.cron,
+        () => {
+          fire(job).catch((e) => console.error("[scheduler] fire error:", e));
+        },
+        { timezone: timezone() },
+      );
+      registered += 1;
+      console.log(
+        `[scheduler] registered: "${job.cron}" (${timezone()}) → ${job.template}/${job.platform}` +
+          `${job.source ? ` source=${job.source}` : ""}${job.publish ? "" : " (no-publish)"}`,
+      );
+    } catch (e) {
+      console.error(`[scheduler] failed to register "${job.cron}" (${job.template}):`, e);
+    }
+  }
+  console.log(`[scheduler] started: ${registered}/${jobs.length} schedule(s) active, tz=${timezone()}`);
+}
+
+/** Fire one scheduled render. Reuses the same CLI spawn + job tracking as the
+ *  WebUI/HTTP path, so the result lands in the jobs list and goes through OSS /
+ *  Feishu publishing like any other render. */
+async function fire(job: ScheduleJob): Promise<void> {
+  if (busy) {
+    console.log(
+      `[scheduler] skip "${job.cron}" ${job.template}/${job.platform}: previous run still active`,
+    );
+    return;
+  }
+  busy = true;
+  const ttsProvider = loadConfig()?.tts?.provider ?? "openai-tts";
+  console.log(`[scheduler] firing ${job.template}/${job.platform} (cron "${job.cron}")`);
+  try {
+    const { jobId, done } = startRenderJob({
+      template: job.template,
+      platforms: [job.platform ?? "bilibili"],
+      ttsProvider,
+      source: job.source,
+      noPublish: !job.publish,
+    });
+    console.log(`[scheduler] job ${jobId.slice(0, 8)} started for ${job.template}/${job.platform}`);
+    await done;
+    console.log(`[scheduler] job ${jobId.slice(0, 8)} finished for ${job.template}/${job.platform}`);
+  } catch (e) {
+    console.error(`[scheduler] render failed for ${job.template}/${job.platform}:`, e);
+  } finally {
+    busy = false;
+  }
+}

+ 19 - 0
config/default.yaml

@@ -96,3 +96,22 @@ collect:
     trendingUrl: "https://github.crawler.corp.shuidi.tech/api/trending"
     repoUrl: "https://github.crawler.corp.shuidi.tech/api/repos/:owner/:repo"
     maxRepos: 5
+
+# 定时生成(容器内进程内调度)。Next.js 服务启动时(instrumentation 钩子)注册
+# 一个进程内调度器,到点 spawn CLI 渲染——复用与 WebUI/HTTP 完全相同的链路,
+# 任务同样进 jobs 列表、走 OSS/飞书发布。改配置后需重启容器生效。
+#   cron     — 5 字段 cron(分 时 日 月 周),按 TZ(默认 Asia/Shanghai=北京时间)
+#   template — 渲染模板(news|knowledge|opinion|marketing|github-trending)
+#   platform — 平台规格(bilibili|douyin-long|douyin-short|...),默认 bilibili
+#   source   — 采集源(如 github-trending);定时场景一般用 source 自动取数
+#   publish  — 是否上传 OSS + 推送飞书,默认 true
+#   enabled  — 是否启用该条,默认 true
+# 环境变量 SCHEDULES(JSON 数组,同结构)可整体覆盖(部署期改无需重建镜像);
+# SCHEDULER_ENABLED=false 关闭整个调度器。
+schedules:
+  - cron: "50 7 * * *"          # 北京时间每天 07:50
+    template: "github-trending"
+    platform: "bilibili"
+    source: "github-trending"    # 先采集当日 trending,再解析→渲染→发布
+    publish: true
+    enabled: true

+ 21 - 1
docs/DEPLOYMENT.md

@@ -99,6 +99,8 @@ docker compose up --build -d        # 改完代码后重新构建
 | `FEISHU_WEBHOOK_URL` `FEISHU_WEBHOOK_SECRET` | 否 | 飞书自定义机器人 webhook 及签名密钥 |
 | `LOG_LEVEL` | 否 | 日志级别 `debug\|info\|warn\|error`,默认 `info` |
 | `PORT` | 否 | 宿主机映射端口,默认 13000 |
+| `SCHEDULES` | 否 | 定时任务(JSON 数组),整体覆盖 `config/default.yaml` 的 `schedules`,便于部署期改而无需重建镜像 |
+| `SCHEDULER_ENABLED` | 否 | 调度器总开关,`false` 关闭(默认开启) |
 
 > 也可在 WebUI **Settings** 页面填写并保存到 `.env`(敏感值自动脱敏显示)。
 
@@ -106,6 +108,24 @@ docker compose up --build -d        # 改完代码后重新构建
 
 TTS provider 选择、模型、对齐(whisper)、模板配色、采集源(`collect`)、OSS/飞书的**非敏感**默认值(region/bucket/path 等)在此配置。详见文件内注释。
 
+### 4.3 定时生成(容器内调度)
+
+`schedules` 段定义定时任务,Next.js 服务启动时由**进程内调度器**(`instrumentation` 钩子 + `node-cron`)注册,到点 spawn CLI 渲染——复用 WebUI/HTTP 同一链路,任务进 jobs 列表、走 OSS/飞书发布。cron 按容器时区(`TZ`,默认 `Asia/Shanghai`=北京时间)解释。
+
+```yaml
+schedules:
+  - cron: "50 7 * * *"          # 北京时间每天 07:50
+    template: "github-trending"
+    platform: "bilibili"
+    source: "github-trending"    # 先采集当日 trending,再解析→渲染→发布
+    publish: true
+    enabled: true
+```
+
+- 改配置后需重启容器(`docker compose up -d`)生效;或用 `SCHEDULES` 环境变量(JSON 数组,同结构)整体覆盖,部署期改无需重建镜像。
+- `SCHEDULER_ENABLED=false` 关闭整个调度器。
+- 单副本下进程内调度天然不会重复执行;渲染较重,已内置单飞(上一次未结束则跳过本次)。
+
 ---
 
 ## 5. 输出目录与缓存清理
@@ -266,7 +286,7 @@ curl http://localhost:13000/api/jobs/<jobId>
 
 ```
 apps/cli/            CLI 入口(Commander)
-apps/web/            Next.js WebUI + API 路由
+apps/web/            Next.js WebUI + API 路由 + 进程内定时调度器(instrumentation/node-cron)
 packages/shared/     Zod schema、类型、常量、LLM 客户端、路径/发布配置解析
 packages/core/       流水线编排、7 阶段、cleanup、publish(OSS+飞书)
 packages/tts/        TTS provider 注册表

+ 25 - 0
pnpm-lock.yaml

@@ -63,6 +63,9 @@ importers:
       next:
         specifier: ^15.3.0
         version: 15.5.18(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+      node-cron:
+        specifier: ^3.0.3
+        version: 3.0.3
       react:
         specifier: ^19.0.0
         version: 19.2.6
@@ -79,6 +82,9 @@ importers:
       '@types/node':
         specifier: ^22.0.0
         version: 22.19.19
+      '@types/node-cron':
+        specifier: ^3.0.11
+        version: 3.0.11
       '@types/react':
         specifier: ^19.0.0
         version: 19.2.15
@@ -869,6 +875,9 @@ packages:
   '@types/json-schema@7.0.15':
     resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
 
+  '@types/node-cron@3.0.11':
+    resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==}
+
   '@types/node-fetch@2.6.13':
     resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==}
 
@@ -1521,6 +1530,10 @@ packages:
       sass:
         optional: true
 
+  node-cron@3.0.3:
+    resolution: {integrity: sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==}
+    engines: {node: '>=6.0.0'}
+
   node-domexception@1.0.0:
     resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
     engines: {node: '>=10.5.0'}
@@ -1989,6 +2002,10 @@ packages:
     resolution: {integrity: sha512-PYxZDA+6QtvRvm//++aGdmKG/cI07jNwbROz0Ql+VzFV1+Z0Dy55NI4zZ7RHc9KKpBePNFwoErqIuqQv/cjiTA==}
     engines: {node: '>= 0.12.0'}
 
+  uuid@8.3.2:
+    resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
+    hasBin: true
+
   watchpack@2.5.1:
     resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==}
     engines: {node: '>=10.13.0'}
@@ -2737,6 +2754,8 @@ snapshots:
 
   '@types/json-schema@7.0.15': {}
 
+  '@types/node-cron@3.0.11': {}
+
   '@types/node-fetch@2.6.13':
     dependencies:
       '@types/node': 22.19.19
@@ -3401,6 +3420,10 @@ snapshots:
       - '@babel/core'
       - babel-plugin-macros
 
+  node-cron@3.0.3:
+    dependencies:
+      uuid: 8.3.2
+
   node-domexception@1.0.0: {}
 
   node-fetch@2.7.0:
@@ -3866,6 +3889,8 @@ snapshots:
       mz: 2.7.0
       unescape: 1.0.1
 
+  uuid@8.3.2: {}
+
   watchpack@2.5.1:
     dependencies:
       glob-to-regexp: 0.4.1