云端部署

Cloudflare Workers + KV:开发短链接服务

用 Cloudflare Workers 和 KV 存储做一个属于自己的短链接服务:https://s.你的域名/kv-docs 一键跳转到长网址,带管理令牌的接口负责创建和查询,自动统计点击次数,部署在 Cloudflare 全球网络上,不需要任何服务器。

BINBHO · 图文教程

用 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

项目资源

用途 地址
Workers 文档 https://developers.cloudflare.com/workers/
KV 文档 https://developers.cloudflare.com/kv/
KV 定价与额度 https://developers.cloudflare.com/kv/platform/pricing/
Workers 限制 https://developers.cloudflare.com/workers/platform/limits/
Wrangler 配置 https://developers.cloudflare.com/workers/wrangler/configuration/
Secrets https://developers.cloudflare.com/workers/configuration/secrets/
自定义域 https://developers.cloudflare.com/workers/configuration/routing/custom-domains/

建议先只在本地跑:wrangler dev 启动后执行 npm test,7 项全部通过再部署。

上线后先给自己常用的几个长网址做短链,用一周确认稳定,再分享给别人使用。