为了保证成功,我将流程分为四个详细步骤:
第一步:准备 GitHub 访问令牌 (PAT)
你需要一个令牌让 Cloudflare 有权把文件写入你的私有仓库。
- 登录 GitHub,访问 Tokens (Fine-grained) 设置页面。
- 点击 Generate new token。
- Token Name: 填
CF-D1-Backup。 - Repository access: 选择 Only select repositories,并选择
用户名/仓库名。 - Permissions: 点击 Repository permissions,找到 Contents,选择 Read and write。
- 点击 Generate token,立即复制并保存这个 Token(它只会出现一次)。
第二步:准备 Cloudflare API 令牌
Worker 需要权限来操作 D1 数据库进行导出。
- 访问 Cloudflare API Tokens 页面。
- 点击 Create Token。
- 选择 Create Custom Token。
- Token name:
D1-Export-Token。 - Permissions:
Account->D1->Edit(导出操作需要 Edit 权限)。
- Resources:
Include->你的账号。
- 点击继续并生成,保存这个 Token。
同时,在 D1 数据库的控制面板页面,记录下你的 Account ID 和 Database 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);
}
第四步:配置环境变量与定时器
如果你是在网页端操作:
设置环境变量:进入 Worker 的 Settings -> Variables,添加以下加密变量(Secrets):
CF_ACCOUNT_ID: 你的 Cloudflare 账号 ID。CF_DB_ID: 你的 D1 数据库 ID。CF_API_TOKEN: 第二步创建的令牌。GH_PAT: 第一步创建的 GitHub 令牌。GH_REPO: 填monstercjz/xxxxxx。
设置定时器 (Triggers):
- 进入 Worker 的 Triggers 选项卡。
- 在 Cron Triggers 点击 Add Cron Trigger。
- 输入
0 2 * * *(代表每天凌晨 2 点执行,注意这是 UTC 时间)。
如何验证?
- 手动测试:在 Worker 控制台点击 “Save and Deploy” 后,在浏览器访问你的 Worker 分配的 URL(例如
https://your-worker.your-subdomain.workers.dev)。 - 检查 GitHub:访问
monstercjz/xxxxx仓库,看是否出现了一个名为backups/db-202X-XX-XX.sql的文件。 - 查看日志:如果没成功,可以在 Worker 的 Logs 实时日志里查看具体的报错信息。
进阶建议
防止文件过多:这个脚本每天产生一个新文件。如果你想节省空间,可以修改代码,把文件名固定为
db-latest.sql,这样每天都会覆盖旧文件(GitHub 会保留历史版本供你回溯)。大小警告:如果你的数据库导出后超过 20MB,Worker 的内存可能会溢出。那时你需要切换到流式处理或改用官方的 R2 存储。对于一般的小型项目,这个脚本非常稳健。
拓展究竟github的token选哪一种
强烈建议选择 Fine-grained tokens (细粒度令牌)。
为什么选它?
安全性极高:你可以限制这个令牌只能访问
monstercjz/xxxxx这一个仓库。即使令牌不小心泄露,别人的权限也仅限于这一个仓库,无法动你账号下的其他代码。权限精准:你可以只给它“写入内容”的权限,而不给它读取你个人信息或其他设置的权限。
未来主流:这是 GitHub 目前力推的安全标准,而 Tokens (classic) 正在被逐渐淘汰。
选择 Fine-grained tokens 后的具体操作
- 点击 Fine-grained tokens 选项进入。
- 点击右上角的 Generate new token。
- Token name: 随便起个名字,比如
CF-D1-Backup。 - Expiration: 建议选 90 天或 1 年(到期后需要重新生成填入 Cloudflare)。
- Repository access:
- 选择 Only select repositories。
- 在下拉列表搜索并选择你的私有仓库
monstercjz/sitebox-db。
- Permissions (最关键的一步):
- 点击 Repository permissions。
- 找到 Contents,在右侧下拉菜单选择 Read and write。
- 点击最下方的 Generate token。
- 立即复制保存生成的那个以
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);
}