上一篇我用 Cloudflare Pages 发布了这个博客。Pages 只能放“提前做好的”静态网页,要让网站真的“干点活”(比如生成短链接、调用 AI),就要用到 Cloudflare Workers。
这两天我用 Workers 做了两个小工具,现在都放在博客的 工具页 里,可以直接试用:
- 短链接 dreamer-link:把长网址变成短网址,还能看点击次数
- AI 小助手 dreamer-ai:中英互译 + 简单问答
下面按顺序记录一下我是怎么做的,以及踩过的坑。
Workers 是什么
可以把 Worker 理解成“一段放在 Cloudflare 机房里的 JavaScript 函数”。每次有人访问它的网址,Cloudflare 就在离访客最近的机房里运行这个函数,函数返回什么,访客就看到什么。
最小的 Worker 长这样:
export default { async fetch(request, env, ctx) { return new Response("Hello, Dreamer!"); },};request:这次请求的信息(网址、方法、请求头、请求体)env:绑定(Bindings),比如数据库、AI,后面会讲ctx:可以用ctx.waitUntil()让一些任务在返回响应之后继续在后台跑完
不用买服务器,免费版每天有 10 万次请求,学习完全够用。
工具一:短链接(Workers + KV)
思路
短链接的本质就是一张“对照表”:
| 短码 | 原网址 |
|---|---|
AcsDNY | https://blog.dogelover.online/posts/... |
这张表我存在 Workers KV 里。KV 是 Cloudflare 的“键值存储”,就像一个超大的字典:用键(key)存,用键取。这里键是 6 位短码,值是一段 JSON:
{"url":"https://...","clicks":3,"created":"2026-10-08T12:00:00.000Z"}Worker 只需要处理几种请求:
POST /api/shorten:收到一个长网址,生成短码,存进 KVGET /xxxxxx:按短码查 KV,找到就跳转过去GET /api/stats/xxxxxx:查看点击次数
生成短链接
核心就是一句 env.LINKS.put()。LINKS 是我给 KV 绑定起的名字,KV 只能存字符串,所以要先 JSON.stringify:
// POST /api/shorten(节选)const check = validateUrl(body && body.url, selfUrl); // 只接受正常的 http/https 网址if (!check.ok) return jsonResponse({ error: check.error }, 400);
const code = await createUniqueCode(env); // 生成一个没被占用的 6 位短码const record = { url: check.url, clicks: 0, created: new Date().toISOString() };await env.LINKS.put(code, JSON.stringify(record));
return jsonResponse({ short: `${selfUrl.origin}/${code}`, code, url: check.url }, 201);跳转 + 点击计数
访问短链接时,查到记录就返回 302 跳转。这里有两个我觉得挺有意思的小细节:
const record = await env.LINKS.get(code, { type: "json" });if (!record || !record.url) return htmlResponse(notFoundPage(code), 404);
// 点击数 +1:放进 waitUntil,先让用户跳走,写 KV 在后台慢慢做const updated = { ...record, clicks: (record.clicks || 0) + 1 };ctx.waitUntil( env.LINKS.put(code, JSON.stringify(updated)).catch((err) => console.warn("点击计数写入失败:", err) ));
return new Response(null, { status: 302, headers: { Location: record.url, "Cache-Control": "no-store" },});- 为什么用 302 而不是 301? 301 是“永久跳转”,浏览器会把它缓存起来,下次直接跳,不再经过 Worker,点击就数不到了。302 是“临时跳转”,每次都会来问一遍。
- 为什么用
ctx.waitUntil? 写 KV 需要一点时间,没必要让访客干等。先把跳转发出去,计数在后台写完就行。.catch保证就算写入失败,跳转也不受影响。
免费额度要注意
KV 免费版每天读取 10 万次,但写入只有 1,000 次。我这里“生成一个短链接”是一次写入,“每被点击一次”也是一次写入。自己用完全够,但如果哪天某个短链接被疯狂转发,计数可能当天就写不进去了(跳转本身不受影响,因为只用到读取)。
工具二:AI 小助手(Workers AI)
绑定 AI
Workers AI 让 Worker 可以直接调用 Cloudflare 机房里的大模型,不需要自己申请什么 API Key。只要在 Worker 的设置里加一个 AI 绑定,起名叫 AI,代码里就能用 env.AI.run(模型ID, 参数) 调用模型。
我用的主模型是 @cf/zai-org/glm-4.7-flash,备用模型是 @cf/qwen/qwen3-30b-a3b-fp8:主模型出错时自动换备用的再试一次。
const CONFIG = { MODEL: "@cf/zai-org/glm-4.7-flash", FALLBACK_MODEL: "@cf/qwen/qwen3-30b-a3b-fp8",};
async function runAI(env, ctx, messages, { temperature, stream, meta }) { const models = [CONFIG.MODEL, CONFIG.FALLBACK_MODEL]; let lastError = null; for (const model of models) { try { const result = await env.AI.run(model, { messages, stream, temperature }); if (!stream) return jsonResponse({ ok: true, model, text: extractText(result) }); return sseResponse(convertStream(result, { model, ...meta }), ctx); } catch (err) { lastError = err; if (isQuotaError(err)) break; // 额度用完了,换模型也没用 } } return jsonError(429, "今天的免费额度用完了,明天再来吧。");}messages 就是和 ChatGPT 类似的对话格式:一条 system 告诉 AI “你是谁、要怎么做”,后面是 user 和 assistant 轮流说话。翻译和问答其实是同一个模型,只是 system 提示词不一样。翻译方向(中→英还是英→中)我用代码先判断好,再写进提示词,比让 AI 自己猜稳定得多。
流式输出(SSE)
如果等 AI 把整段话写完再返回,用户要盯着空白页面等好几秒。所以我开了 stream: true,让模型“边想边吐字”,Worker 再用 SSE(Server-Sent Events) 一点一点推给网页:
return new Response(readable, { headers: { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache", },});网页收到的是一行一行的 data: {...},每行带几个新字,拼起来就是打字机效果。
免费额度:Neurons
Workers AI 按 Neurons 计量,免费版每天 10,000 Neurons,北京时间每天早上 8 点(UTC 0 点)重置。我做的这种短翻译和短问答,一次只消耗一点点。
让我比较放心的一点是:免费版额度用完后只会报错,不会扣钱。我在代码里把这个错误识别出来,换成了一句中文提示“今天的免费额度用完了”,而不是甩给用户一堆英文报错。
把两个工具放进博客:CORS
一开始两个工具各有各的网页,分别在 dreamer-link.….workers.dev 和 dreamer-ai.….workers.dev。我想把它们都放进博客的 工具页,网页用博客的样式,背后还是调用这两个 Worker。
结果第一次预览,工具页只显示“暂时连不上”。原因就是 跨域(CORS)。
为什么会被拦
博客在 blog.dogelover.online(以及原来的 dreamer-home.pages.dev),Worker 在 workers.dev,对浏览器来说这是两个不同的网站。出于安全考虑,浏览器默认不让一个网站的页面去读另一个网站接口返回的内容,除非对方明确说“我允许你”。
而且像我这种发 JSON 的 POST 请求,浏览器会先偷偷发一个 OPTIONS 预检请求,问 Worker:“blog.dogelover.online 想用 POST 调你,行不行?” Worker 回答“行”,真正的请求才会发出去。
我的做法:白名单
我在两个 Worker 里都加了一份允许名单,只放行我的博客、博客的预览地址和本机调试地址:
CORS_ORIGINS: ["https://blog.dogelover.online", "https://dreamer-home.pages.dev"],
function isAllowedOrigin(origin) { if (!origin) return false; if (CONFIG.CORS_ORIGINS.includes(origin)) return true; // 博客的 Pages 预览地址,例如 https://3f2a1b4c.dreamer-home.pages.dev if (/^https:\/\/[a-z0-9-]+\.dreamer-home\.pages\.dev$/.test(origin)) return true; // 本机调试:http://localhost:4321 之类 if (/^http:\/\/(localhost|127\.0\.0\.1)(:\d{1,5})?$/.test(origin)) return true; return false;}
function corsHeaders(request) { const origin = request.headers.get("origin"); const headers = { Vary: "Origin" }; if (isAllowedOrigin(origin)) { headers["Access-Control-Allow-Origin"] = origin; headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"; headers["Access-Control-Allow-Headers"] = "Content-Type"; headers["Access-Control-Max-Age"] = "86400"; // 预检结果缓存 1 天 } return headers;}
// 在 fetch 入口最前面:预检请求直接回 204if (request.method === "OPTIONS") return preflightResponse(request);这里我没有偷懒写 Access-Control-Allow-Origin: *(允许所有网站)。如果写了 *,别人把我的工具页照抄一份放到他自己的网站上,就能白嫖我的 AI 额度。用白名单的话,别的网站在浏览器里就调不动了。
小提醒:CORS 只是浏览器的规矩。如果有人直接用 curl 之类的工具调接口,CORS 拦不住,所以 Worker 里该做的输入检查(网址格式、长度限制)还是要做。
另外,发布顺序也有讲究:先更新 Worker(加上白名单),再更新博客。反过来的话,新工具页上线的那几分钟里会一直显示“连不上”。
踩过的坑
- 在控制台粘贴代码出现乱码。 我是在 Cloudflare 控制台的在线编辑器里直接粘代码的。有一次代码里的中文注释粘进去变成了一堆乱码(mojibake),部署后网页上的中文也全乱了。后来换成从一个 UTF-8 编码的网页里复制代码,就正常了。教训:粘贴完先扫一眼中文,再点部署。
- 绑定要在 Settings 里加。 代码里写了
env.LINKS、env.AI,不代表它们就存在。必须到 Worker 的 Settings → Bindings 里手动添加 KV 命名空间和 Workers AI,并且名字要和代码里一模一样(大小写也算)。没绑定时env.LINKS是undefined,会直接报错。我在 AI 小助手里加了一个/api/health接口,能直接看到“AI 绑定:已绑定 / 未绑定”,排查起来方便很多。 - AI 不太听话。 我让它“用一句话介绍”,它有时还是会写两段,还带了个外卖和便利店的比喻 😂。提示词写得越具体,效果越好,这个还得慢慢调。
小结
这次学到的东西:
- Workers:一段跑在 Cloudflare 机房里的函数
- KV:简单的键值存储,读多写少的场景很合适,注意每天 1,000 次写入
- Workers AI:通过绑定调用大模型,
env.AI.run()一行搞定,免费每天 10,000 Neurons,超了只报错不扣钱 - SSE 流式输出:让 AI 回答有打字机效果
- CORS:跨网站调用接口要对方放行,用白名单比用
*安全
两个工具都在 工具页,欢迎来试试~