为了保证成功,我将流程分为四个详细步骤:


第一步:准备 GitHub 访问令牌 (PAT)

你需要一个令牌让 Cloudflare 有权把文件写入你的私有仓库。

  1. 登录 GitHub,访问 Tokens (Fine-grained) 设置页面
  2. 点击 Generate new token
  3. Token Name: 填 CF-D1-Backup
  4. Repository access: 选择 Only select repositories,并选择 用户名/仓库名
  5. Permissions: 点击 Repository permissions,找到 Contents,选择 Read and write
  6. 点击 Generate token立即复制并保存这个 Token(它只会出现一次)。

第二步:准备 Cloudflare API 令牌

Worker 需要权限来操作 D1 数据库进行导出。

  1. 访问 Cloudflare API Tokens 页面
  2. 点击 Create Token
  3. 选择 Create Custom Token
  4. Token name: D1-Export-Token
  5. Permissions:
    • Account -> D1 -> Edit (导出操作需要 Edit 权限)。
  6. Resources:
    • Include -> 你的账号
  7. 点击继续并生成,保存这个 Token

同时,在 D1 数据库的控制面板页面,记录下你的 Account IDDatabase ID


第三步:创建并编写 Worker 代码

建议在本地使用 wrangler 工具,或者直接在 Cloudflare 网页端创建一个新的 Worker。

1. 编写 index.ts (或 index.js)

这是核心逻辑:触发导出 -> 轮询等待完成 -> 上传 GitHub。

export default {
  // 手动触发(用于平时想临时备份时)
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    if (url.pathname.includes('favicon')) return new Response(null, { status: 204 });

    // 核心改动:使用 ctx.waitUntil 把任务丢给 Cloudflare 后台
    // 这样网页会立刻显示“已触发”,你直接关掉网页它也会继续执行完!
    ctx.waitUntil(handleBackup(env));

    return new Response(
      "🚀 备份任务已成功在后台触发!\n\n你可以直接关闭本网页。\n大约 1 分钟后,请前往 GitHub 仓库查看最新备份文件。",
      { status: 200, headers: { "Content-Type": "text/plain; charset=utf-8" } }
    );
  },

  // 定时器自动触发(每天凌晨运行)
  async scheduled(event, env, ctx) {
    ctx.waitUntil(handleBackup(env));
  }
};

async function handleBackup(env) {
  const { CF_ACCOUNT_ID, CF_DB_ID, CF_API_TOKEN, GH_PAT, GH_REPO } = env;
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 16);
  const fileName = `backups/db-${timestamp}.sql`;

  console.log("--- 步骤 1: 发起导出任务 ---");
  const initRes = await fetch(
    `https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/d1/database/${CF_DB_ID}/export`,
    {
      method: 'POST',
      headers: { 'Authorization': `Bearer ${CF_API_TOKEN}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({ "output_format": "file" })
    }
  );
  const initData = await initRes.json();

  let downloadUrl = initData.result?.signed_url || initData.result?.result?.signed_url || initData.result?.url;

  if (downloadUrl) {
    console.log("秒回下载链接!");
  } else if (!initData.success && initData.errors?.some(e => e.message?.includes("long-running export"))) {
    console.log("发现已有任务在后台运行,直接进入等待...");
  } else if (!initData.success) {
    console.error("启动导出失败: " + JSON.stringify(initData.errors));
    return;
  }

  console.log("--- 步骤 2: 轮询获取下载链接 ---");
  let attempts = 0;
  while (attempts < 15 && !downloadUrl) {
    attempts++;
    await new Promise(r => setTimeout(r, 4000));

    const pollRes = await fetch(
      `https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/d1/database/${CF_DB_ID}/export`,
      {
        method: 'POST',
        headers: { 'Authorization': `Bearer ${CF_API_TOKEN}`, 'Content-Type': 'application/json' },
        body: JSON.stringify({ "output_format": "polling" })
      }
    );
    const pollData = await pollRes.json();
    const resStatus = pollData.result?.status || pollData.result?.result?.status || "processing";

    if (resStatus === "complete") {
      downloadUrl = pollData.result?.result?.signed_url || pollData.result?.signed_url || pollData.result?.result?.url || pollData.result?.url;
      if (!downloadUrl) {
        const str = JSON.stringify(pollData);
        const match = str.match(/"(https:\/\/[^"]+)"/);
        if (match) downloadUrl = JSON.parse(`"${match[1]}"`);
      }
      if (downloadUrl) break;
    }
  }

  if (!downloadUrl) {
    console.error("等待超时或无法提取链接,任务终止。");
    return;
  }

  console.log("--- 步骤 3: 下载 SQL 并转码 ---");
  const sqlRes = await fetch(downloadUrl);
  if (!sqlRes.ok) {
    console.error("下载 SQL 文件本身失败");
    return;
  }
  const sqlText = await sqlRes.text();

  const uint8array = new TextEncoder().encode(sqlText);
  let binary = "";
  for (let i = 0; i < uint8array.byteLength; i++) { binary += String.fromCharCode(uint8array[i]); }
  const base64Content = btoa(binary);

  console.log("--- 步骤 4: 推送至 GitHub ---");
  const ghRes = await fetch(`https://api.github.com/repos/${GH_REPO}/contents/${fileName}`, {
    method: 'PUT',
    headers: {
      'Authorization': `token ${GH_PAT}`,
      'User-Agent': 'Cloudflare-Worker-Backup',
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      message: `Daily Backup ${timestamp}`,
      content: base64Content
    })
  });

  if (!ghRes.ok) {
    const err = await ghRes.text();
    console.error("GitHub 报错: " + err);
    return;
  }

  console.log("✅ 全部流程完成!文件已保存到仓库的 " + fileName);
}

第四步:配置环境变量与定时器

如果你是在网页端操作:

  1. 设置环境变量:进入 Worker 的 Settings -> Variables,添加以下加密变量(Secrets):

    • CF_ACCOUNT_ID: 你的 Cloudflare 账号 ID。
    • CF_DB_ID: 你的 D1 数据库 ID。
    • CF_API_TOKEN: 第二步创建的令牌。
    • GH_PAT: 第一步创建的 GitHub 令牌。
    • GH_REPO: 填 monstercjz/xxxxxx
  2. 设置定时器 (Triggers)

    • 进入 Worker 的 Triggers 选项卡。
    • Cron Triggers 点击 Add Cron Trigger
    • 输入 0 2 * * * (代表每天凌晨 2 点执行,注意这是 UTC 时间)。

如何验证?

  1. 手动测试:在 Worker 控制台点击 “Save and Deploy” 后,在浏览器访问你的 Worker 分配的 URL(例如 https://your-worker.your-subdomain.workers.dev)。
  2. 检查 GitHub:访问 monstercjz/xxxxx 仓库,看是否出现了一个名为 backups/db-202X-XX-XX.sql 的文件。
  3. 查看日志:如果没成功,可以在 Worker 的 Logs 实时日志里查看具体的报错信息。

进阶建议

  • 防止文件过多:这个脚本每天产生一个新文件。如果你想节省空间,可以修改代码,把文件名固定为 db-latest.sql,这样每天都会覆盖旧文件(GitHub 会保留历史版本供你回溯)。

  • 大小警告:如果你的数据库导出后超过 20MB,Worker 的内存可能会溢出。那时你需要切换到流式处理或改用官方的 R2 存储。对于一般的小型项目,这个脚本非常稳健。

拓展究竟github的token选哪一种

强烈建议选择 Fine-grained tokens (细粒度令牌)

为什么选它?

  • 安全性极高:你可以限制这个令牌只能访问 monstercjz/xxxxx 这一个仓库。即使令牌不小心泄露,别人的权限也仅限于这一个仓库,无法动你账号下的其他代码。

  • 权限精准:你可以只给它“写入内容”的权限,而不给它读取你个人信息或其他设置的权限。

  • 未来主流:这是 GitHub 目前力推的安全标准,而 Tokens (classic) 正在被逐渐淘汰。


选择 Fine-grained tokens 后的具体操作

  1. 点击 Fine-grained tokens 选项进入。
  2. 点击右上角的 Generate new token
  3. Token name: 随便起个名字,比如 CF-D1-Backup
  4. Expiration: 建议选 90 天或 1 年(到期后需要重新生成填入 Cloudflare)。
  5. Repository access:
    • 选择 Only select repositories
    • 在下拉列表搜索并选择你的私有仓库 monstercjz/sitebox-db
  6. Permissions (最关键的一步):
    • 点击 Repository permissions
    • 找到 Contents,在右侧下拉菜单选择 Read and write
  7. 点击最下方的 Generate token
  8. 立即复制保存生成的那个以 github_pat_ 开头的长字符串。

如果选了 Tokens (classic) 会怎样? 如果选 Classic,为了能往仓库写文件,必须勾选 repo 整个大项。这意味着这个令牌拥有账号下所有私有和公开仓库的完整控制权。对于自动化备份这种场景,这存在巨大的安全隐患。

所以,请果断选择 Fine-grained tokens!

调试版本代码

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (url.pathname.includes('favicon')) return new Response(null, { status: 204 });

    try {
      const result = await handleBackup(env);
      return new Response("✅ " + result, { status: 200 });
    } catch (e) {
      return new Response("❌ 备份失败: " + e.message, { status: 500 });
    }
  },

  async scheduled(event, env, ctx) {
    ctx.waitUntil(handleBackup(env));
  }
};

async function handleBackup(env) {
  const { CF_ACCOUNT_ID, CF_DB_ID, CF_API_TOKEN, GH_PAT, GH_REPO } = env;
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 16);
  const fileName = `backups/db-${timestamp}.sql`;

  console.log("--- 步骤 1: 发起导出任务 ---");

  const initRes = await fetch(
    `https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/d1/database/${CF_DB_ID}/export`,
    {
      method: 'POST',
      headers: { 'Authorization': `Bearer ${CF_API_TOKEN}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({ "output_format": "file" })
    }
  );
  const initData = await initRes.json();

  // 兼容可能的秒回情况
  let downloadUrl = initData.result?.signed_url || initData.result?.result?.signed_url || initData.result?.url;

  if (downloadUrl) {
    console.log("秒回下载链接!");
  } else if (!initData.success && initData.errors?.some(e => e.message?.includes("long-running export"))) {
    console.log("发现已有任务在后台运行,直接进入等待...");
  } else if (!initData.success) {
    throw new Error("启动导出失败: " + JSON.stringify(initData.errors));
  } else {
    console.log("新导出任务已成功触发!准备等待生成链接...");
  }

  console.log("--- 步骤 2: 轮询获取下载链接 ---");

  let attempts = 0;
  while (attempts < 15 && !downloadUrl) {
    attempts++;
    await new Promise(r => setTimeout(r, 4000));

    const pollRes = await fetch(
      `https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/d1/database/${CF_DB_ID}/export`,
      {
        method: 'POST',
        headers: { 'Authorization': `Bearer ${CF_API_TOKEN}`, 'Content-Type': 'application/json' },
        body: JSON.stringify({ "output_format": "polling" })
      }
    );
    const pollData = await pollRes.json();

    const resStatus = pollData.result?.status || pollData.result?.result?.status || "processing";
    console.log(`[轮询 ${attempts}/15] 任务状态: ${resStatus}`);

    if (resStatus === "complete") {
      // 核心修复点:捕捉 signed_url
      downloadUrl = pollData.result?.result?.signed_url ||
                    pollData.result?.signed_url ||
                    pollData.result?.result?.url ||
                    pollData.result?.url;

      // 如果依然没找到,暴力从 JSON 文本中提取安全链接
      if (!downloadUrl) {
        console.log("尝试暴力提取。完整返回体: " + JSON.stringify(pollData));
        const str = JSON.stringify(pollData);
        // 寻找 https://... 且不包含引号的字符串
        const match = str.match(/"(https:\/\/[^"]+)"/);
        if (match) {
          // JSON.parse 用于处理自动转义的字符 (如 \u0026 变成 &)
          downloadUrl = JSON.parse(`"${match[1]}"`);
        }
      }

      if (downloadUrl) {
        console.log("🎉 成功获取到下载链接!");
        break; // 打断循环,继续下一步
      }
    }
  }

  if (!downloadUrl) throw new Error("等待超时或无法提取链接,请查看日志。");

  console.log("--- 步骤 3: 下载 SQL 并转码 ---");
  const sqlRes = await fetch(downloadUrl);
  if (!sqlRes.ok) throw new Error("下载 SQL 文件本身失败");
  const sqlText = await sqlRes.text();
  console.log(`SQL 下载完成,大小: ${sqlText.length} bytes,准备上传...`);

  // 处理中文等特殊字符的 Base64 转码
  const uint8array = new TextEncoder().encode(sqlText);
  let binary = "";
  for (let i = 0; i < uint8array.byteLength; i++) { binary += String.fromCharCode(uint8array[i]); }
  const base64Content = btoa(binary);

  console.log("--- 步骤 4: 推送至 GitHub ---");
  const ghRes = await fetch(`https://api.github.com/repos/${GH_REPO}/contents/${fileName}`, {
    method: 'PUT',
    headers: {
      'Authorization': `token ${GH_PAT}`,
      'User-Agent': 'Cloudflare-Worker-Backup',
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      message: `Daily Backup ${timestamp}`,
      content: base64Content
    })
  });

  if (!ghRes.ok) {
    const err = await ghRes.text();
    throw new Error("GitHub 报错: " + err);
  }

  console.log("✅ 全部流程完成!");
  return `文件已保存到仓库的 ${fileName}`;
}

部署执行代码

export default {
  // 手动触发(用于平时想临时备份时)
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    if (url.pathname.includes('favicon')) return new Response(null, { status: 204 });

    // 核心改动:使用 ctx.waitUntil 把任务丢给 Cloudflare 后台
    // 这样网页会立刻显示“已触发”,你直接关掉网页它也会继续执行完!
    ctx.waitUntil(handleBackup(env));

    return new Response(
      "🚀 备份任务已成功在后台触发!\n\n你可以直接关闭本网页。\n大约 1 分钟后,请前往 GitHub 仓库查看最新备份文件。",
      { status: 200, headers: { "Content-Type": "text/plain; charset=utf-8" } }
    );
  },

  // 定时器自动触发(每天凌晨运行)
  async scheduled(event, env, ctx) {
    ctx.waitUntil(handleBackup(env));
  }
};

async function handleBackup(env) {
  const { CF_ACCOUNT_ID, CF_DB_ID, CF_API_TOKEN, GH_PAT, GH_REPO } = env;
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 16);
  const fileName = `backups/db-${timestamp}.sql`;

  console.log("--- 步骤 1: 发起导出任务 ---");
  const initRes = await fetch(
    `https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/d1/database/${CF_DB_ID}/export`,
    {
      method: 'POST',
      headers: { 'Authorization': `Bearer ${CF_API_TOKEN}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({ "output_format": "file" })
    }
  );
  const initData = await initRes.json();

  let downloadUrl = initData.result?.signed_url || initData.result?.result?.signed_url || initData.result?.url;

  if (downloadUrl) {
    console.log("秒回下载链接!");
  } else if (!initData.success && initData.errors?.some(e => e.message?.includes("long-running export"))) {
    console.log("发现已有任务在后台运行,直接进入等待...");
  } else if (!initData.success) {
    console.error("启动导出失败: " + JSON.stringify(initData.errors));
    return;
  }

  console.log("--- 步骤 2: 轮询获取下载链接 ---");
  let attempts = 0;
  while (attempts < 15 && !downloadUrl) {
    attempts++;
    await new Promise(r => setTimeout(r, 4000));

    const pollRes = await fetch(
      `https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/d1/database/${CF_DB_ID}/export`,
      {
        method: 'POST',
        headers: { 'Authorization': `Bearer ${CF_API_TOKEN}`, 'Content-Type': 'application/json' },
        body: JSON.stringify({ "output_format": "polling" })
      }
    );
    const pollData = await pollRes.json();
    const resStatus = pollData.result?.status || pollData.result?.result?.status || "processing";

    if (resStatus === "complete") {
      downloadUrl = pollData.result?.result?.signed_url || pollData.result?.signed_url || pollData.result?.result?.url || pollData.result?.url;
      if (!downloadUrl) {
        const str = JSON.stringify(pollData);
        const match = str.match(/"(https:\/\/[^"]+)"/);
        if (match) downloadUrl = JSON.parse(`"${match[1]}"`);
      }
      if (downloadUrl) break;
    }
  }

  if (!downloadUrl) {
    console.error("等待超时或无法提取链接,任务终止。");
    return;
  }

  console.log("--- 步骤 3: 下载 SQL 并转码 ---");
  const sqlRes = await fetch(downloadUrl);
  if (!sqlRes.ok) {
    console.error("下载 SQL 文件本身失败");
    return;
  }
  const sqlText = await sqlRes.text();

  const uint8array = new TextEncoder().encode(sqlText);
  let binary = "";
  for (let i = 0; i < uint8array.byteLength; i++) { binary += String.fromCharCode(uint8array[i]); }
  const base64Content = btoa(binary);

  console.log("--- 步骤 4: 推送至 GitHub ---");
  const ghRes = await fetch(`https://api.github.com/repos/${GH_REPO}/contents/${fileName}`, {
    method: 'PUT',
    headers: {
      'Authorization': `token ${GH_PAT}`,
      'User-Agent': 'Cloudflare-Worker-Backup',
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      message: `Daily Backup ${timestamp}`,
      content: base64Content
    })
  });

  if (!ghRes.ok) {
    const err = await ghRes.text();
    console.error("GitHub 报错: " + err);
    return;
  }

  console.log("✅ 全部流程完成!文件已保存到仓库的 " + fileName);
}