用 Cloudflare Workers 和 KV 存储做一个属于自己的短链接服务:
https://s.你的域名/kv-docs一键跳转到长网址,带管理令牌的接口负责创建和查询,自动统计点击次数,部署在 Cloudflare 全球网络上,不需要任何服务器。
先看清成品
成品是一个约 80 行的 Worker。创建短链接用一条 curl 命令,访问短链接 302 跳转到目标地址。本文代码已在本地用 Wrangler 4.151.3 的
wrangler dev(本地模拟 KV)实际运行,端到端测试脚本 7 项全部通过:无令牌拒绝、拒绝地址、创建自定义短链、重复短链返回 409、正确跳转、点击数加 1、不存在返回 404。部署到 Cloudflare 的步骤需要登录账号,没有在本文环境中执行。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
| 短链接跳转 | GET /slug → 302 |
| 管理接口 | POST /api/links 创建,GET /api/links/slug 查看 |
| 自定义或随机短码 | 随机码去掉了易混淆的字符 |
| 点击统计 | 近似计数 |
| 测试脚本 | 对本地开发服务器跑端到端测试 |
需要准备哪些环境?
| 项目 | 要求 | 说明 |
|---|---|---|
| Cloudflare 账号 | 免费计划即可 | 部署时需要登录 |
| Node.js | 22 或更高 | 当前版本的 Wrangler 要求 Node 22 |
| Wrangler | 4.x | 本文使用 4.151.3,作为项目开发依赖安装 |
| 域名(可选) | 已接入 Cloudflare | 不绑定时使用 *.workers.dev 地址 |
免费计划的额度(以官方页面为准):Workers 每天 10 万次请求;KV 每天 10 万次读取、1000 次写入,存储 1 GB。每次点击会写一次 KV 更新计数,所以免费计划下每天大约支持 1000 次点击计数,超出后写入失败,但跳转仍会正常进行。
第一步:项目结构是什么样的?
short-links/
├── src/
│ └── index.js
├── wrangler.jsonc
├── package.json
├── .dev.vars # 本地开发用的令牌,不提交
└── test.sh
1. 让 AI 写初稿(可选)
写一个 Cloudflare Worker 短链接服务(ES Module 格式,绑定 KV 命名空间 LINKS):
- GET /:slug 从 KV 取目标地址 302 跳转,用 ctx.waitUntil 异步把点击数加 1
- POST /api/links 创建,Bearer 令牌校验 env.ADMIN_TOKEN,只允许 http/https 地址
- slug 可自定义(3-32 位字母数字-_),否则随机 6 位;已存在返回 409
- GET /api/links/:slug 返回记录(需令牌)
第二步:代码怎么写?
1. package.json
{
"name": "short-links",
"private": true,
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"test": "bash test.sh"
},
"devDependencies": {
"wrangler": "^4.151.3"
}
}
2. wrangler.jsonc
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "short-links",
"main": "src/index.js",
"compatibility_date": "2026-10-01",
"kv_namespaces": [
// id 用 npx wrangler kv namespace create LINKS 的输出替换
{ "binding": "LINKS", "id": "替换成你的KV命名空间ID" }
]
}
3. src/index.js
// 短链接服务:GET /:slug 跳转;POST /api/links 创建(需要管理令牌);GET /api/links/:slug 查看
const SLUG = /^[a-zA-Z0-9_-]{3,32}$/;
const RESERVED = new Set(["api", "admin", "favicon.ico", "robots.txt"]);
const json = (data, status = 200) =>
new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json; charset=utf-8" } });
function randomSlug(len = 6) {
const chars = "abcdefghijkmnpqrstuvwxyz23456789"; // 去掉容易看混的 l、o、0、1
const bytes = crypto.getRandomValues(new Uint8Array(len));
return Array.from(bytes, (b) => chars[b % chars.length]).join("");
}
function authorized(request, env) {
const auth = request.headers.get("authorization") ?? "";
return env.ADMIN_TOKEN && auth === `Bearer ${env.ADMIN_TOKEN}`;
}
async function createLink(request, env) {
if (!authorized(request, env)) return json({ error: "unauthorized" }, 401);
let body;
try {
body = await request.json();
} catch {
return json({ error: "请求体必须是 JSON" }, 400);
}
let target;
try {
target = new URL(body.url);
if (!["http:", "https:"].includes(target.protocol)) throw new Error();
} catch {
return json({ error: "url 必须是 http 或 https 地址" }, 400);
}
const slug = body.slug ?? randomSlug();
if (!SLUG.test(slug) || RESERVED.has(slug)) return json({ error: "slug 只能是 3-32 位字母、数字、- 或 _" }, 400);
if (await env.LINKS.get(slug)) return json({ error: "slug 已存在" }, 409);
const record = { url: target.toString(), createdAt: new Date().toISOString(), clicks: 0 };
await env.LINKS.put(slug, JSON.stringify(record));
return json({ slug, short: new URL(`/${slug}`, request.url).toString(), ...record }, 201);
}
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const parts = url.pathname.split("/").filter(Boolean);
if (parts[0] === "api" && parts[1] === "links") {
if (request.method === "POST" && parts.length === 2) return createLink(request, env);
if (request.method === "GET" && parts.length === 3) {
if (!authorized(request, env)) return json({ error: "unauthorized" }, 401);
const rec = await env.LINKS.get(parts[2], "json");
return rec ? json({ slug: parts[2], ...rec }) : json({ error: "not found" }, 404);
}
return json({ error: "method not allowed" }, 405);
}
if (parts.length === 1 && request.method === "GET") {
const rec = await env.LINKS.get(parts[0], "json");
if (!rec) return new Response("短链接不存在", { status: 404 });
// 点击数是近似值:KV 不支持原子自增,并发点击可能少计
ctx.waitUntil(env.LINKS.put(parts[0], JSON.stringify({ ...rec, clicks: rec.clicks + 1 })));
return Response.redirect(rec.url, 302);
}
return new Response("短链接服务运行中", { status: 200 });
},
};
| 关键点 | 原因 |
|---|---|
| 校验协议 | 防止有人创建 等危险链接 |
保留路径 api 等 |
避免短码和接口冲突 |
ctx.waitUntil |
先返回跳转,计数在后台完成,用户不用等 |
| 计数是近似值 | KV 没有原子自增,且是最终一致性存储 |
令牌放在 env |
本地用 .dev.vars,线上用 wrangler secret |
第三步:怎样在本地运行和测试?
1. 安装并启动
npm install
echo "ADMIN_TOKEN=$(openssl rand -hex 12)" > .dev.vars
npx wrangler dev
本地开发时 KV 由 Wrangler 在本机模拟,不会碰到线上数据。启动后输出:
[wrangler:info] Ready on http://localhost:8787
2. 手动试一次
T=$(cut -d= -f2 .dev.vars)
curl -s -X POST localhost:8787/api/links -H "authorization: Bearer $T" \
-d '{"url":"https://developers.cloudflare.com/kv/","slug":"kv-docs"}'
curl -sI localhost:8787/kv-docs | grep -i location
3. 端到端测试 test.sh
#!/usr/bin/env bash
# 对本地 wrangler dev 做端到端测试:先在另一个终端运行 npm run dev
set -euo pipefail
B=${BASE:-http://localhost:8787}
T=$(grep ADMIN_TOKEN .dev.vars | cut -d= -f2)
pass() { echo "通过:$1"; }
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST $B/api/links -d '{"url":"https://example.com"}')
[ "$code" = 401 ] && pass "无令牌创建返回 401"
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST $B/api/links -H "authorization: Bearer $T" -d '{"url":"alert(1)"}')
[ "$code" = 400 ] && pass "拒绝非 http 地址"
r=$(curl -s -X POST $B/api/links -H "authorization: Bearer $T" -d '{"url":"https://developers.cloudflare.com/kv/","slug":"kv-docs"}')
echo "$r" | grep -q '"slug":"kv-docs"' && pass "创建自定义短链"
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST $B/api/links -H "authorization: Bearer $T" -d '{"url":"https://example.com","slug":"kv-docs"}')
[ "$code" = 409 ] && pass "重复 slug 返回 409"
loc=$(curl -s -o /dev/null -w '%{redirect_url}' $B/kv-docs)
[ "$loc" = "https://developers.cloudflare.com/kv/" ] && pass "跳转到目标地址"
sleep 1
curl -s $B/api/links/kv-docs -H "authorization: Bearer $T" | grep -q '"clicks":1' && pass "点击数加 1"
code=$(curl -s -o /dev/null -w '%{http_code}' $B/nope)
[ "$code" = 404 ] && pass "不存在的短链返回 404"
另开一个终端运行 npm test,本文实际输出:
通过:无令牌创建返回 401
通过:拒绝非 http 地址
通过:创建自定义短链
通过:重复 slug 返回 409
通过:跳转到目标地址
通过:点击数加 1
通过:不存在的短链返回 404
第四步:怎样部署上线?
1. 登录并创建 KV
npx wrangler login
npx wrangler kv namespace create LINKS
把输出的 id 填进 wrangler.jsonc。
2. 设置线上令牌
npx wrangler secret put ADMIN_TOKEN
按提示输入一个新的长随机字符串,不要复用本地的。
3. 部署
npx wrangler deploy
部署成功后会输出 https://short-links.你的子域.workers.dev。
4. 绑定自己的域名(可选)
在后台进入该 Worker 的 设置 → 域和路由,添加自定义域,例如 s.example.com。
5. 检查结果
| 检查 | 期望 |
|---|---|
| 用线上地址创建短链 | 返回 201 和 short 地址 |
| 浏览器打开短链 | 跳转到目标页面 |
| 后台 Worker 日志 | 能看到请求记录 |
出问题时该怎么调?
1. 部署报 KV namespace not found
wrangler.jsonc 里的 id 还是占位文字,或者复制错了。用 npx wrangler kv namespace list 查看真实 ID。
2. 线上一直返回 401
线上没有设置 ADMIN_TOKEN,或者用了本地的令牌。重新执行 wrangler secret put ADMIN_TOKEN。
3. 刚创建的短链在别的地区打不开
KV 是最终一致性存储,新写入的数据传播到全球需要一点时间,通常稍等即可。
4. wrangler 报 Node 版本错误
当前 Wrangler 4 要求 Node 22 或更高,用 node -v 检查并升级。本文验证时就遇到过系统自带 Node 20 的情况。
常见问题怎么处理?
| 现象 | 原因 | 解决方法 |
|---|---|---|
| 点击数不准 | KV 无原子操作、写入额度 | 需要精确统计时改用 Durable Objects 或 D1 |
| 每天点击计数停止增长 | 免费计划写入额度用完 | 升级计划,或改为抽样计数 |
| 短链被滥用跳到恶意网站 | 令牌泄露 | 立即更换令牌,删除可疑记录 |
.dev.vars 被提交 |
没加忽略 | 加入 .gitignore,并更换令牌 |
| 中文短码不支持 | 正则只允许 ASCII | 按需修改 SLUG 正则 |
| 想批量导入 | 一条条创建太慢 | 用 wrangler kv bulk put 导入 JSON |
项目资源
建议先只在本地跑:wrangler dev 启动后执行 npm test,7 项全部通过再部署。
上线后先给自己常用的几个长网址做短链,用一周确认稳定,再分享给别人使用。