diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8be3ea1..a5f8e3e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -16,6 +16,7 @@ concurrency: env: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + VITE_TURNSTILE_SITE_KEY: ${{ secrets.TURNSTILE_SITE_KEY }} jobs: changes: @@ -74,6 +75,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Check production security configuration + run: test -n "$VITE_TURNSTILE_SITE_KEY" + - name: Generate Worker types run: npm run cf-typegen -w @podforge/worker @@ -114,6 +118,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Check production security configuration + run: test -n "$VITE_TURNSTILE_SITE_KEY" + - name: Build web app run: npm run build -w @podforge/web @@ -139,6 +146,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Check production security configuration + run: test -n "$VITE_TURNSTILE_SITE_KEY" + - name: Type-check run: npm run typecheck diff --git a/README.md b/README.md index 4c78593..876d616 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,8 @@ npx wrangler secret put MINIMAX_API_KEY npx wrangler secret put MINIMAX_GROUP_ID ``` +Turnstile site key 配置为 GitHub Actions secret `TURNSTILE_SITE_KEY`,服务端 secret 配置为 Worker Secret `TURNSTILE_SECRET`。WAF 限速和 R2 原始资料生命周期的生产配置见 [生产环境安全配置](docs/production-security.md)。 + 也可以手动执行同一套生产部署步骤: ```bash @@ -74,4 +76,4 @@ npm test npm run build ``` -生产环境建议增加 Cloudflare Turnstile/WAF 限速规则,并为 R2 原始资料配置生命周期删除策略。正式发布前需使用实际 MiniMax 账号确认两个系统音色可用。 +生产发布前需按 [生产环境安全配置](docs/production-security.md) 完成 Cloudflare 账号侧配置,并使用实际 MiniMax 账号确认两个系统音色可用。 diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..3f134b5 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,2 @@ +# Cloudflare's always-pass public test key. Replace it with the production widget site key in deployment. +VITE_TURNSTILE_SITE_KEY=1x00000000000000000000AA diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index cb84297..ea3cf30 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,2 +1,2 @@ import { useCallback,useEffect,useState } from "react";import { createJob,getJob,retryJob,type Job } from "./api";import { JobForm } from "./components/JobForm";import { JobPanel } from "./components/JobPanel"; -export default function App(){const [job,setJob]=useState(null),[id,setId]=useState(()=>new URLSearchParams(location.search).get("job"));const refresh=useCallback(async()=>{if(id)setJob(await getJob(id))},[id]);useEffect(()=>{if(!id)return;void refresh();const timer=setInterval(()=>{if(job?.status!=="completed"&&job?.status!=="failed")void refresh()},2500);return()=>clearInterval(timer)},[id,job?.status,refresh]);async function create(data:FormData){const result=await createJob(data);history.replaceState(null,"",`/?job=${result.id}`);setId(result.id)}async function retry(){if(id){await retryJob(id);setJob(job?{...job,status:"queued",progress:5,stage:"等待重试"}:null)}}function reset(){history.replaceState(null,"","/");setId(null);setJob(null)}return <>
PF PodForge
资料里的好故事,值得被听见

AI PODCAST STUDIO

把你的资料,锻造成播客。

上传 PDF、DOCX、TXT 或 Markdown,几分钟内获得一场自然、有重点的中文双人对谈。

} +export default function App(){const [job,setJob]=useState(null),[id,setId]=useState(()=>new URLSearchParams(location.search).get("job"));const refresh=useCallback(async()=>{if(id)setJob(await getJob(id))},[id]);useEffect(()=>{if(!id)return;void refresh();const timer=setInterval(()=>{if(job?.status!=="completed"&&job?.status!=="failed")void refresh()},2500);return()=>clearInterval(timer)},[id,job?.status,refresh]);async function create(data:FormData){const result=await createJob(data);history.replaceState(null,"",`/?job=${result.id}`);setId(result.id)}async function retry(token:string){if(id){await retryJob(id,token);setJob(job?{...job,status:"queued",progress:5,stage:"等待重试"}:null)}}function reset(){history.replaceState(null,"","/");setId(null);setJob(null)}return <>
PF PodForge
资料里的好故事,值得被听见

AI PODCAST STUDIO

把你的资料,锻造成播客。

上传 PDF、DOCX、TXT 或 Markdown,几分钟内获得一场自然、有重点的中文双人对谈。

PodForge · Powered securely on Cloudflare
} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 6d5a335..2e04068 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -4,5 +4,5 @@ const base = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace( async function request(path:string, init?:RequestInit):Promise{const response=await fetch(`${base}${path}`,init);const data=await response.json() as T & {error?:string};if(!response.ok)throw new Error(data.error??"服务暂时不可用");return data} export const createJob=(form:FormData)=>request<{id:string}>("/api/jobs",{method:"POST",body:form}); export const getJob=(id:string)=>request(`/api/jobs/${id}`); -export const retryJob=(id:string)=>request<{id:string}>(`/api/jobs/${id}/retry`,{method:"POST"}); +export const retryJob=(id:string,token:string)=>{const form=new FormData();form.set("cf-turnstile-response",token);return request<{id:string}>(`/api/jobs/${id}/retry`,{method:"POST",body:form})}; export const audioUrl=(path:string)=>`${base}${path}`; diff --git a/apps/web/src/components/JobForm.tsx b/apps/web/src/components/JobForm.tsx index af0ce63..5587ff0 100644 --- a/apps/web/src/components/JobForm.tsx +++ b/apps/web/src/components/JobForm.tsx @@ -1,6 +1,7 @@ -import { useRef, useState, type DragEvent } from "react"; +import { useCallback, useRef, useState, type DragEvent } from "react"; +import { Turnstile } from "./Turnstile"; type Props={onCreate:(data:FormData)=>Promise}; -export function JobForm({onCreate}:Props){const [files,setFiles]=useState([]),[error,setError]=useState(""),[busy,setBusy]=useState(false),[dragging,setDragging]=useState(false),input=useRef(null);function handleDrag(event:DragEvent){event.preventDefault();event.stopPropagation();if(event.type==="dragenter"||event.type==="dragover"){event.dataTransfer.dropEffect="copy";setDragging(true)}else{setDragging(false)}}function handleDrop(event:DragEvent){event.preventDefault();event.stopPropagation();setDragging(false);setFiles([...event.dataTransfer.files])}async function submit(event:React.FormEvent){event.preventDefault();setError("");setBusy(true);try{const data=new FormData(event.currentTarget);files.forEach(file=>data.append("files",file));await onCreate(data)}catch(cause){setError(cause instanceof Error?cause.message:"创建任务失败")}finally{setBusy(false)}}return
+export function JobForm({onCreate}:Props){const [files,setFiles]=useState([]),[error,setError]=useState(""),[busy,setBusy]=useState(false),[dragging,setDragging]=useState(false),[token,setToken]=useState(""),[challenge,setChallenge]=useState(0),input=useRef(null),handleVerify=useCallback((value:string)=>setToken(value),[]);function handleDrag(event:DragEvent){event.preventDefault();event.stopPropagation();if(event.type==="dragenter"||event.type==="dragover"){event.dataTransfer.dropEffect="copy";setDragging(true)}else{setDragging(false)}}function handleDrop(event:DragEvent){event.preventDefault();event.stopPropagation();setDragging(false);setFiles([...event.dataTransfer.files])}async function submit(event:React.FormEvent){event.preventDefault();setError("");setBusy(true);try{const data=new FormData(event.currentTarget);data.set("cf-turnstile-response",token);files.forEach(file=>data.append("files",file));await onCreate(data);setToken("");setChallenge(value=>value+1)}catch(cause){setError(cause instanceof Error?cause.message:"创建任务失败");setToken("");setChallenge(value=>value+1)}finally{setBusy(false)}}return
    {files.map(file=>
  • {file.name}{(file.size/1048576).toFixed(1)} MB
  • )}
@@ -8,6 +9,6 @@ export function JobForm({onCreate}:Props){const [files,setFiles]=useState播客标题
-

{error}

+

{error}

} function Step({number,title,copy}:{number:string;title:string;copy:string}){return
{number}

{title}

{copy}

} diff --git a/apps/web/src/components/JobPanel.tsx b/apps/web/src/components/JobPanel.tsx index fed9bc5..e7a3a76 100644 --- a/apps/web/src/components/JobPanel.tsx +++ b/apps/web/src/components/JobPanel.tsx @@ -1,2 +1,2 @@ -import type { Job } from "../api";import { audioUrl } from "../api"; -export function JobPanel({job,onRetry,onReset}:{job:Job|null;onRetry:()=>void;onReset:()=>void}){if(!job)return

你的节目将在这里诞生

上传资料并设置节目参数,生成进度与成品会实时显示。

;if(job.status==="failed")return

生成未完成

这次锻造遇到了问题

{job.error}

;if(job.status==="completed"&&job.audioUrl)return

新鲜出炉

{job.title}

;return

正在制作

{job.title}

{job.progress}%{job.stage}

可以保存此页面地址,刷新后任务仍会继续。

} +import { useCallback, useState } from "react";import type { Job } from "../api";import { audioUrl } from "../api";import { Turnstile } from "./Turnstile"; +export function JobPanel({job,onRetry,onReset}:{job:Job|null;onRetry:(token:string)=>void;onReset:()=>void}){const[token,setToken]=useState("");const handleVerify=useCallback((value:string)=>setToken(value),[]);if(!job)return

你的节目将在这里诞生

上传资料并设置节目参数,生成进度与成品会实时显示。

;if(job.status==="failed")return

生成未完成

这次锻造遇到了问题

{job.error}

;if(job.status==="completed"&&job.audioUrl)return

新鲜出炉

{job.title}

;return

正在制作

{job.title}

{job.progress}%{job.stage}

可以保存此页面地址,刷新后任务仍会继续。

} diff --git a/apps/web/src/components/Turnstile.tsx b/apps/web/src/components/Turnstile.tsx new file mode 100644 index 0000000..25c51ac --- /dev/null +++ b/apps/web/src/components/Turnstile.tsx @@ -0,0 +1,51 @@ +import { useEffect, useRef } from "react"; + +declare global { + interface Window { + turnstile?: { + render: (container: HTMLElement, options: Record) => string; + remove: (widgetId: string) => void; + }; + } +} + +const scriptId = "cloudflare-turnstile-script"; + +export function Turnstile({ onVerify }: { onVerify: (token: string) => void }) { + const container = useRef(null); + const widgetId = useRef(undefined); + const sitekey = import.meta.env.VITE_TURNSTILE_SITE_KEY as string | undefined; + + useEffect(() => { + if (!sitekey) return; + const render = () => { + if (!container.current || !window.turnstile || widgetId.current) return; + widgetId.current = window.turnstile.render(container.current, { + sitekey, + action: "turnstile-spin-v2", + callback: onVerify, + "expired-callback": () => onVerify(""), + "error-callback": () => onVerify("") + }); + }; + let script = document.getElementById(scriptId) as HTMLScriptElement | null; + if (!script) { + script = document.createElement("script"); + script.id = scriptId; + script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"; + script.async = true; + script.defer = true; + document.head.appendChild(script); + } + script.addEventListener("load", render); + render(); + return () => { + script?.removeEventListener("load", render); + if (widgetId.current && window.turnstile) window.turnstile.remove(widgetId.current); + widgetId.current = undefined; + }; + }, [onVerify, sitekey]); + + if (!sitekey) return

Turnstile 尚未配置,暂时无法提交任务。

; + return
; +} diff --git a/apps/worker/.dev.vars.example b/apps/worker/.dev.vars.example new file mode 100644 index 0000000..b01ac5c --- /dev/null +++ b/apps/worker/.dev.vars.example @@ -0,0 +1,2 @@ +# Cloudflare's always-pass test secret. Never use it in production. +TURNSTILE_SECRET=1x0000000000000000000000000000000AA diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index eaa6cb1..ef17cc6 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -1,6 +1,7 @@ import { errorResponse, json, assertSameOrigin, withCors } from "./http"; import { consume } from "./jobs"; import type { Env, JobMessage, JobRow } from "./types"; +import { verifyTurnstile } from "./turnstile"; const allowed = new Set([ "application/pdf", @@ -14,6 +15,7 @@ const cleanName = (name: string) => name.replace(/[^a-zA-Z0-9._-]/g, "_").slice( async function createJob(request: Request, env: Env): Promise { if (!assertSameOrigin(request, env.WEB_ORIGIN)) return errorResponse("Invalid request origin", 403); const form = await request.formData(); + if (!await verifyTurnstile(request, env, form.get("cf-turnstile-response"))) return errorResponse("人机验证失败,请重试", 403); const files = form.getAll("files").filter((value): value is File => value instanceof File); const title = String(form.get("title") ?? "").trim().slice(0, 120); const language = String(form.get("language") ?? "zh-CN"); @@ -26,7 +28,7 @@ async function createJob(request: Request, env: Env): Promise { const id = crypto.randomUUID(); const keys: string[] = []; for (const file of files) { - const key = `jobs/${id}/input/${crypto.randomUUID()}-${cleanName(file.name).toLowerCase()}`; + const key = `inputs/${id}/${crypto.randomUUID()}-${cleanName(file.name).toLowerCase()}`; await env.FILES.put(key, file.stream(), { httpMetadata: { contentType: file.type } }); keys.push(key); } @@ -46,6 +48,8 @@ async function getJob(id: string, env: Env): Promise { async function retryJob(id: string, request: Request, env: Env): Promise { if (!assertSameOrigin(request, env.WEB_ORIGIN)) return errorResponse("Invalid request origin", 403); + const form = await request.formData(); + if (!await verifyTurnstile(request, env, form.get("cf-turnstile-response"))) return errorResponse("人机验证失败,请重试", 403); const row = await env.DB.prepare("SELECT status FROM jobs WHERE id=?").bind(id).first<{ status: string }>(); if (!row) return errorResponse("任务不存在", 404); if (row.status !== "failed") return errorResponse("只有失败的任务可以重试", 409); diff --git a/apps/worker/src/jobs.ts b/apps/worker/src/jobs.ts index 4beaf77..1aa383c 100644 --- a/apps/worker/src/jobs.ts +++ b/apps/worker/src/jobs.ts @@ -41,7 +41,7 @@ export async function processJob(env: Env, jobId: string): Promise { parts.push(await synthesizeLine(env, line.text, line.speaker)); await update(env, jobId, "processing", 45 + Math.floor(((index + 1) / script.lines.length) * 45), `正在合成语音 ${index + 1}/${script.lines.length}`); } - const audioKey = `jobs/${jobId}/podcast.mp3`; + const audioKey = `outputs/${jobId}/podcast.mp3`; const total = parts.reduce((sum, part) => sum + part.byteLength, 0); const audio = new Uint8Array(total); let offset = 0; diff --git a/apps/worker/src/turnstile.ts b/apps/worker/src/turnstile.ts new file mode 100644 index 0000000..e694bf9 --- /dev/null +++ b/apps/worker/src/turnstile.ts @@ -0,0 +1,29 @@ +import type { Env } from "./types"; + +interface TurnstileResult { + success: boolean; + action?: string; + hostname?: string; + "error-codes"?: string[]; +} + +export async function verifyTurnstile(request: Request, env: Env, token: FormDataEntryValue | null): Promise { + if (!env.TURNSTILE_SECRET || typeof token !== "string" || !token || token.length > 2048) return false; + const body = new URLSearchParams({ secret: env.TURNSTILE_SECRET, response: token, idempotency_key: crypto.randomUUID() }); + const remoteIp = request.headers.get("CF-Connecting-IP"); + if (remoteIp) body.set("remoteip", remoteIp); + try { + const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body, + signal: AbortSignal.timeout(5000) + }); + if (!response.ok) return false; + const result = await response.json(); + const hostnames = env.TURNSTILE_HOSTNAMES.split(",").map(value => value.trim()).filter(Boolean); + return result.success === true && result.action === "turnstile-spin-v2" && !!result.hostname && hostnames.includes(result.hostname); + } catch { + return false; + } +} diff --git a/apps/worker/src/types.ts b/apps/worker/src/types.ts index 385ea4d..ba4c866 100644 --- a/apps/worker/src/types.ts +++ b/apps/worker/src/types.ts @@ -9,6 +9,8 @@ export interface Env { MINIMAX_TTS_MODEL: string; MAX_UPLOAD_BYTES: string; WEB_ORIGIN: string; + TURNSTILE_SECRET: string; + TURNSTILE_HOSTNAMES: string; } export interface JobMessage { jobId: string } diff --git a/apps/worker/test/jobs.test.ts b/apps/worker/test/jobs.test.ts index ff8f978..4e9758e 100644 --- a/apps/worker/test/jobs.test.ts +++ b/apps/worker/test/jobs.test.ts @@ -42,13 +42,13 @@ describe("source text extraction", () => { const contents = await zip.generateAsync({ type: "arraybuffer" }); const extractRawText = vi.spyOn(mammoth, "extractRawText"); - await expect(sourceText(envWithFiles({ "jobs/1/input/source.docx": contents }), ["jobs/1/input/source.docx"])) + await expect(sourceText(envWithFiles({ "inputs/1/source.docx": contents }), ["inputs/1/source.docx"])) .resolves.toBe("来自 Word 的正文"); expect(extractRawText).toHaveBeenCalledWith(expect.objectContaining({ arrayBuffer: contents })); }); it("keeps text files in mixed uploads", async () => { - await expect(sourceText(envWithFiles({ "jobs/1/input/notes.txt": "普通文本" }), ["jobs/1/input/notes.txt"])) + await expect(sourceText(envWithFiles({ "inputs/1/notes.txt": "普通文本" }), ["inputs/1/notes.txt"])) .resolves.toBe("普通文本"); }); }); diff --git a/apps/worker/test/turnstile.test.ts b/apps/worker/test/turnstile.test.ts new file mode 100644 index 0000000..3d9ab43 --- /dev/null +++ b/apps/worker/test/turnstile.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { verifyTurnstile } from "../src/turnstile"; +import type { Env } from "../src/types"; + +const env = { + TURNSTILE_SECRET: "secret", + TURNSTILE_HOSTNAMES: "podforge.example,localhost" +} as Env; + +afterEach(() => vi.unstubAllGlobals()); + +describe("Turnstile validation", () => { + it("accepts a valid token for the expected action and hostname", async () => { + const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + success: true, action: "turnstile-spin-v2", hostname: "podforge.example" + }), { headers: { "content-type": "application/json" } })); + vi.stubGlobal("fetch", fetch); + + await expect(verifyTurnstile(new Request("https://podforge.example/api/jobs", { + headers: { "CF-Connecting-IP": "203.0.113.10" } + }), env, "token")).resolves.toBe(true); + const body = fetch.mock.calls[0][1].body as URLSearchParams; + expect(body.get("remoteip")).toBe("203.0.113.10"); + expect(body.get("secret")).toBe("secret"); + }); + + it.each([ + { success: false, action: "turnstile-spin-v2", hostname: "podforge.example" }, + { success: true, action: "another-action", hostname: "podforge.example" }, + { success: true, action: "turnstile-spin-v2", hostname: "attacker.example" } + ])("rejects invalid verification data", async result => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(result)))); + await expect(verifyTurnstile(new Request("https://podforge.example/api/jobs"), env, "token")).resolves.toBe(false); + }); + + it("fails closed when configuration or Siteverify is unavailable", async () => { + await expect(verifyTurnstile(new Request("https://podforge.example/api/jobs"), { ...env, TURNSTILE_SECRET: "" }, "token")).resolves.toBe(false); + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network error"))); + await expect(verifyTurnstile(new Request("https://podforge.example/api/jobs"), env, "token")).resolves.toBe(false); + }); +}); diff --git a/apps/worker/wrangler.jsonc b/apps/worker/wrangler.jsonc index 6929b60..305ba22 100644 --- a/apps/worker/wrangler.jsonc +++ b/apps/worker/wrangler.jsonc @@ -23,7 +23,8 @@ "MINIMAX_TEXT_MODEL": "MiniMax-M3", "MINIMAX_TTS_MODEL": "speech-2.8-hd", "MAX_UPLOAD_BYTES": "10485760", - "WEB_ORIGIN": "http://localhost:5173" + "WEB_ORIGIN": "http://localhost:5173", + "TURNSTILE_HOSTNAMES": "podforge.1991421.cn,localhost,127.0.0.1" }, "observability": { "enabled": true, "head_sampling_rate": 1 } } diff --git a/docs/production-security.md b/docs/production-security.md new file mode 100644 index 0000000..0c9130c --- /dev/null +++ b/docs/production-security.md @@ -0,0 +1,44 @@ +# 生产环境安全配置 + +## Turnstile + +创建 Managed widget,并把 hostname 限制在生产域名和本地开发域名: + +```bash +npx wrangler turnstile widget create "podforge-production" \ + --domain podforge.1991421.cn --domain localhost --domain 127.0.0.1 \ + --mode managed --json +``` + +将返回的 site key 保存为 GitHub Actions secret `TURNSTILE_SITE_KEY`。Secret key 只写入 Worker Secret: + +```bash +cd apps/worker +npx wrangler secret put TURNSTILE_SECRET +``` + +不要把 secret key 放在 `wrangler.jsonc`、前端环境变量或 GitHub Actions 日志中。部署后在 Turnstile Analytics 确认 action 为 `turnstile-spin-v2`,并分别验证创建任务和失败任务重试。Worker 会同时校验 Siteverify 的 `success`、`action` 和 `hostname`;验证服务不可用时请求失败关闭。 + +本地开发可将两个 `.example` 文件复制为对应的 `.env` 和 `.dev.vars`,使用 Cloudflare 官方测试凭据。生产环境不得使用测试凭据。 + +## WAF 限速 + +在 `podforge.1991421.cn` 所属 zone 的 **Security → WAF → Rate limiting rules** 创建规则: + +- 表达式:`http.request.method eq "POST" and (http.request.uri.path eq "/api/jobs" or (starts_with(http.request.uri.path, "/api/jobs/") and ends_with(http.request.uri.path, "/retry")))` +- 计数特征:IP +- 建议初始阈值:每个 IP 每 10 秒 3 次 +- 动作:Block,缓解时间 60 秒 + +Cloudflare 套餐支持的周期、规则数量和动作不同;若当前套餐只允许一条规则,优先保护以上两个会触发 AI 计算的写接口。上线一周后根据 Security Events 中的误拦截与攻击流量调整阈值。Turnstile 是应用层最终校验,WAF 是边缘层成本保护,两者都需保留。 + +## R2 原始资料生命周期 + +新上传原始资料使用 `inputs/` 前缀,生成音频使用 `outputs/` 前缀,因此生命周期规则不会提前删除成品。为生产 bucket 添加 7 天删除策略: + +```bash +npx wrangler r2 bucket lifecycle add podforge-files delete-job-inputs-after-7-days inputs/ --expire-days 7 +npx wrangler r2 bucket lifecycle list podforge-files +``` + +此命令需要带 `Workers R2 Storage Write` 权限的 Cloudflare token。生命周期删除通常会在过期时间后 24 小时内完成。旧版本写入 `jobs//input/` 的对象不匹配新规则,应在确认没有仍需重试的旧任务后单独清理;不要对整个 `jobs/` 前缀设置规则,否则会同时删除旧版生成音频。