31 KiB
Heygo Cloudflare Workers Shortlinks Implementation Plan
For Hermes: Use subagent-driven-development skill to implement this plan task-by-task.
Goal: 把现有 Linux/Django Links 里的短链能力迁移到 Cloudflare 全栈架构:heygo.cc 提供全球公共短链和管理后台,my.heygo.cc 提供登录用户自己的私人短链。
Architecture: 新建 Cloudflare Workers + Workers Assets 全栈应用,前端用 React + Vite + shadcn/ui,后端 Worker 用 TypeScript 路由 API 和重定向逻辑。D1 作为关系型 source of truth,KV 作为公开短链的可选读缓存;个人短链根据登录态和当前用户在 D1 中解析。
Tech Stack: TypeScript, Cloudflare Workers, Workers Assets, D1, optional KV, React, Vite, shadcn/ui, Hono 或同等轻量 Worker router, Zod, Drizzle ORM 或原生 D1 SQL, Better Auth/Auth.js with D1/Cloudflare adapter, Vitest, Playwright, Wrangler.
0. 已确认背景与关键结论
0.1 现有 Linux/Django 短链能力
从 /home/ai-bot/code/links 读到的现状:
- 项目是 Django 5.1 + DRF + SQLite/R2,短链核心在:
links/models.py:Link(alias, original_url, text, link_type, click_count, tags, task_states)links/views.py:redirect_to_original()处理/<alias>/与/<alias>/<param>/links/forms.py:允许{param,default=value}模板 URL,并支持中文静态文本 URL 编码校验tests/test_links_api.py:覆盖创建、重定向、未知 alias、模板参数默认值和 override
- 当前短链类型:
- 常规短链:
LINK -> original_url - 自定义短链:
CUSTOM -> markdown/text page - 带参数短链:
https://example.com/{query,default=hello},访问/alias/value/用value替换参数,否则使用默认值
- 常规短链:
- 当前未找到 Cloudflare 版本,计划新建 Cloudflare app,不直接改 Django 运行逻辑。
0.2 Cloudflare 产品选择
结论:优先用 Cloudflare Workers + Workers Assets,不要从 Cloudflare Pages 起步。
理由:
- Cloudflare 官方 React + Vite 文档现在推荐
npm create cloudflare@latest -- --framework=react,生成 React SPA + Worker API + Workers Assets。 - Workers Assets 的静态资源请求免费且不限量;只有 Worker 脚本请求按 Workers 计费。
- 同一个 Worker 可以同时绑定
heygo.cc/*和my.heygo.cc/*,根据Host分流重定向、API、SPA。 - Pages Functions 也能做,但对这个需求没有明显优势;Workers 自定义路由和 D1/KV 绑定更直接。
0.3 数据库选择
结论:D1 做 source of truth;KV 只做公开短链读缓存,不做最终一致性依赖。
理由:
- 需要用户、OAuth account、session、私人短链、公共短链、推荐审核、标签、点击统计,这些是关系型数据,D1 比 KV 合适。
- D1 是 SQLite 语义,官方适合轻量 relational data;每个 D1 DB 目前有 10GB 上限,读多写少短链足够。
- KV 读快但 eventual consistency,写入/修改/删除后其他地区可能 60 秒或更久才可见;不能作为审核、权限、唯一性、刚创建短链是否存在的最终判断。
- 公开短链可用 KV 缓存
public:<alias>,miss 后回 D1;后台保存时同步更新 KV。私人短链先不进 KV,避免登录态 + owner 维度复杂化。
0.4 登录供应商选择
- 首批支持:Google + GitHub。
- Apple 登录:先预留 provider,但默认不启用。
- 核实结果:Apple 官方页面区分免费 Apple developer account 与 Apple Developer Program $99/year;Apple Sign In Web 通常需要 Service ID、Team ID、Key ID、private key 等开发者门户能力。第三方实现文档也明确需要 active Apple Developer account。结论:大概率需要 $99/year 付费 Apple Developer Program,除非后续官方后台实际验证有免费路径;MVP 不把 Apple 作为阻塞项。
1. 产品需求定稿
1.1 域名行为
heygo.cc
GET https://heygo.cc/<alias>- 只查公共短链。
- 不要求登录。
- 找到
link_type=redirect:302/307 跳转到目标 URL。 - 找到
link_type=custom:渲染公开 markdown/custom page。 - 找不到:返回公开 404 页面。
GET https://heygo.cc/app/*- 管理后台 React SPA。
- 未登录:显示登录页。
- 已登录:显示个人短链管理、推荐审核状态、公共短链浏览。
GET/POST https://heygo.cc/api/*- API 入口。
- 公共查询 API 可匿名;创建/管理/推荐/审核必须鉴权。
GET https://heygo.cc/admin/*- 管理员页面/API。
- 只有
users.role = 'admin'可访问。
my.heygo.cc
GET https://my.heygo.cc/<alias>- 查当前登录用户自己的私人短链。
- 未登录:返回 404 页面,正文下面显示 “Login to use your private links” 链接;不要直接跳登录,保持用户要求的 404-first 行为。
- 已登录但 alias 不存在:返回私人 404,提供 “Create this private link” 按钮。
- 已登录且找到:按 link 类型跳转或渲染 custom page。
GET https://my.heygo.cc/- 未登录:404 + login link。
- 已登录:302 到
https://heygo.cc/app/private或直接展示私人管理 SPA。
1.2 短链类型
统一抽象为 links 表的一条记录:
scope = public | privatelink_type = redirect | customaliastarget_url:redirect 类型必填content_markdown:custom 类型必填owner_user_id:private 必填,public 可为空或记录提交者visibility/status:active/archived/deleted
支持三类创建:
- 常规短链:
alias -> https://example.com - 自定义短链:
alias -> markdown/custom page - 带参数短链:
alias -> https://google.com/search?q={query,default=hello}
1.3 参数短链规则
保持兼容 Django 旧语法,同时补强规则:
- 模板参数格式:
{name}或{name,default=value}。 - 参数名只允许
[A-Za-z_][A-Za-z0-9_]*。 - 访问方式:
/<alias>/<value>:填入第一个模板参数。/<alias>?query=value&lang=en:按参数名填入。- query 参数优先于 path 参数。
- 缺失参数时使用默认值;无默认值则 400/友好错误页,不跳转。
- 替换时必须
encodeURIComponent(value),但不能把整个 URL 双重编码。 - 额外 query 是否透传:MVP 不透传未知 query,避免意外泄露;以后可加
forward_unknown_query开关。
1.4 私人短链唯一性
重要设计:my.heygo.cc/<alias> 的解析依赖当前登录用户。
- 同一个用户内 alias 唯一。
- 不同用户可以有相同 alias。
- 私人短链本质是个人快捷方式,不适合公开分享;分享出去对未登录或其他用户返回 404。
1.5 推荐到公共库审核流
用户在个人短链列表里可单选/多选 “Recommend to public”。
流程:
- 用户选择一个或多个 private links。
- 对每个链接填写/确认
proposed_alias、说明、可选标签。 - 系统创建
promotion_submissions(status='pending')。 - 管理员在
heygo.cc/admin/review审核:- approve:创建 public link,或如果 alias 冲突则要求管理员改 alias。
- reject:写入 rejection reason。
- needs_changes:让用户修改后重提。
- 审核通过后,
heygo.cc/<alias>全球公开可访问。 - 原 private link 不自动删除,继续归属用户。
2. 推荐目录与文件结构
在现有 repo 下新增一个独立 Cloudflare app,避免直接影响 Django:
/home/ai-bot/code/links/
├── cloudflare-shortlinks/
│ ├── package.json
│ ├── wrangler.jsonc
│ ├── vite.config.ts
│ ├── tsconfig.json
│ ├── components.json # shadcn/ui
│ ├── migrations/
│ │ └── 0001_init.sql
│ ├── src/
│ │ ├── main.tsx
│ │ ├── App.tsx
│ │ ├── routes/
│ │ │ ├── LoginPage.tsx
│ │ │ ├── PrivateLinksPage.tsx
│ │ │ ├── PublicLinksPage.tsx
│ │ │ ├── AdminReviewPage.tsx
│ │ │ └── NotFoundPage.tsx
│ │ ├── components/
│ │ │ ├── LinkForm.tsx
│ │ │ ├── LinkTable.tsx
│ │ │ ├── ProviderButtons.tsx
│ │ │ └── ui/ # shadcn generated
│ │ └── lib/
│ │ ├── api.ts
│ │ └── validators.ts
│ ├── worker/
│ │ ├── index.ts
│ │ ├── env.ts
│ │ ├── auth.ts
│ │ ├── db.ts
│ │ ├── routes/
│ │ │ ├── api.links.ts
│ │ │ ├── api.promotions.ts
│ │ │ ├── api.admin.ts
│ │ │ ├── auth.ts
│ │ │ └── redirect.ts
│ │ └── lib/
│ │ ├── aliases.ts
│ │ ├── templates.ts
│ │ ├── responses.ts
│ │ └── security.ts
│ ├── scripts/
│ │ ├── import-links-to-d1.ts
│ │ └── seed-admin.ts
│ └── tests/
│ ├── templates.test.ts
│ ├── aliases.test.ts
│ ├── redirect.test.ts
│ └── api.links.test.ts
└── links/
└── management/commands/
└── export_shortlinks_jsonl.py # 从 Django 导出短链,后续任务新增
3. D1 schema 初稿
Create: cloudflare-shortlinks/migrations/0001_init.sql
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE,
name TEXT,
image_url TEXT,
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin')),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE oauth_accounts (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL CHECK (provider IN ('google', 'github', 'apple')),
provider_account_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
UNIQUE(provider, provider_account_id)
);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
session_token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE links (
id TEXT PRIMARY KEY,
scope TEXT NOT NULL CHECK (scope IN ('public', 'private')),
owner_user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
alias TEXT NOT NULL,
link_type TEXT NOT NULL CHECK (link_type IN ('redirect', 'custom')),
target_url TEXT,
content_markdown TEXT,
description TEXT,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived', 'deleted')),
click_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
CHECK (
(scope = 'public' AND owner_user_id IS NULL)
OR (scope = 'private' AND owner_user_id IS NOT NULL)
),
CHECK (
(link_type = 'redirect' AND target_url IS NOT NULL)
OR (link_type = 'custom' AND content_markdown IS NOT NULL)
)
);
CREATE UNIQUE INDEX links_public_alias_unique
ON links(alias)
WHERE scope = 'public' AND status != 'deleted';
CREATE UNIQUE INDEX links_private_owner_alias_unique
ON links(owner_user_id, alias)
WHERE scope = 'private' AND status != 'deleted';
CREATE INDEX links_owner_idx ON links(owner_user_id, updated_at DESC);
CREATE INDEX links_scope_updated_idx ON links(scope, updated_at DESC);
CREATE TABLE tags (
id TEXT PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE link_tags (
link_id TEXT NOT NULL REFERENCES links(id) ON DELETE CASCADE,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (link_id, tag_id)
);
CREATE TABLE promotion_submissions (
id TEXT PRIMARY KEY,
private_link_id TEXT NOT NULL REFERENCES links(id) ON DELETE CASCADE,
submitted_by_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
proposed_alias TEXT NOT NULL,
note TEXT,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected', 'needs_changes')),
reviewed_by_user_id TEXT REFERENCES users(id),
rejection_reason TEXT,
public_link_id TEXT REFERENCES links(id),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
reviewed_at TEXT
);
CREATE INDEX promotion_status_idx ON promotion_submissions(status, created_at DESC);
CREATE TABLE click_daily (
link_id TEXT NOT NULL REFERENCES links(id) ON DELETE CASCADE,
day TEXT NOT NULL,
count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (link_id, day)
);
4. 分阶段实施计划
Phase 1 — Cloudflare skeleton + core shortlink logic
Task 1: 创建 Cloudflare React + Workers app 骨架
Objective: 新建 cloudflare-shortlinks/,能本地启动一个 React SPA + Worker API。
Files:
- Create:
cloudflare-shortlinks/package.json - Create:
cloudflare-shortlinks/wrangler.jsonc - Create:
cloudflare-shortlinks/vite.config.ts - Create:
cloudflare-shortlinks/src/main.tsx - Create:
cloudflare-shortlinks/src/App.tsx - Create:
cloudflare-shortlinks/worker/index.ts
Step 1: Scaffold
Run inside /home/ai-bot/code/links:
npm create cloudflare@latest -- cloudflare-shortlinks --framework=react --typescript --git=false
Expected: creates React + Vite + Worker app.
Step 2: Add routing library and validation
cd /home/ai-bot/code/links/cloudflare-shortlinks
npm install hono zod nanoid
npm install -D vitest @cloudflare/vitest-pool-workers typescript
Expected: dependencies installed.
Step 3: Verify dev server
npm run dev
Expected: local Worker dev server starts and React page loads.
Step 4: Commit
git add cloudflare-shortlinks
git commit -m "feat: scaffold cloudflare shortlinks app"
Task 2: 配置 D1 与初始 schema
Objective: 添加 D1 binding 和第一版 schema migration。
Files:
- Modify:
cloudflare-shortlinks/wrangler.jsonc - Create:
cloudflare-shortlinks/migrations/0001_init.sql
Step 1: Update wrangler config
wrangler.jsonc 关键配置:
{
"name": "heygo-shortlinks",
"main": "worker/index.ts",
"compatibility_date": "2026-06-20",
"assets": {
"directory": "./dist",
"not_found_handling": "single-page-application"
},
"observability": { "enabled": true },
"d1_databases": [
{
"binding": "DB",
"database_name": "heygo-shortlinks",
"database_id": "TODO_FROM_WRANGLER",
"migrations_dir": "migrations"
}
],
"kv_namespaces": [
{
"binding": "PUBLIC_LINK_CACHE",
"id": "TODO_FROM_WRANGLER"
}
]
}
Step 2: Create local migration SQL
Use schema from section 3.
Step 3: Run local migration
npx wrangler d1 migrations apply heygo-shortlinks --local
Expected: migration applied locally.
Step 4: Commit
git add cloudflare-shortlinks/wrangler.jsonc cloudflare-shortlinks/migrations/0001_init.sql
git commit -m "feat: add D1 schema for shortlinks"
Task 3: 实现 alias 规范化与校验
Objective: 统一 alias 行为,避免 Django 旧逻辑里创建允许 _/- 但重定向时剥离特殊字符的问题。
Files:
- Create:
cloudflare-shortlinks/worker/lib/aliases.ts - Create:
cloudflare-shortlinks/tests/aliases.test.ts
Step 1: Write failing tests
import { describe, expect, it } from 'vitest'
import { normalizeAlias, validateAlias } from '../worker/lib/aliases'
describe('aliases', () => {
it('lowercases aliases', () => {
expect(normalizeAlias('Claude')).toBe('claude')
})
it('keeps hyphen and underscore instead of stripping them', () => {
expect(normalizeAlias('my_link-1')).toBe('my_link-1')
})
it('rejects path separators and empty aliases', () => {
expect(validateAlias('')).toEqual({ ok: false, error: 'Alias is required' })
expect(validateAlias('a/b')).toEqual({ ok: false, error: 'Alias may contain only letters, numbers, underscore, and hyphen' })
})
})
Step 2: Run test to verify failure
npm test -- tests/aliases.test.ts
Expected: FAIL because functions do not exist.
Step 3: Implement
export type ValidationResult = { ok: true; value: string } | { ok: false; error: string }
const ALIAS_RE = /^[a-z0-9][a-z0-9_-]{0,99}$/
export function normalizeAlias(input: string): string {
return input.trim().toLowerCase()
}
export function validateAlias(input: string): ValidationResult {
const alias = normalizeAlias(input)
if (!alias) return { ok: false, error: 'Alias is required' }
if (!ALIAS_RE.test(alias)) {
return { ok: false, error: 'Alias may contain only letters, numbers, underscore, and hyphen' }
}
return { ok: true, value: alias }
}
Step 4: Run test
npm test -- tests/aliases.test.ts
Expected: PASS.
Step 5: Commit
git add cloudflare-shortlinks/worker/lib/aliases.ts cloudflare-shortlinks/tests/aliases.test.ts
git commit -m "feat: validate shortlink aliases"
Task 4: 实现模板 URL 参数替换
Objective: 兼容 {param,default=value} 并支持 path/query 参数。
Files:
- Create:
cloudflare-shortlinks/worker/lib/templates.ts - Create:
cloudflare-shortlinks/tests/templates.test.ts
Step 1: Write failing tests
Cover:
- default value
- path param fills first variable
- query param overrides path param
- missing required param throws typed error
- Chinese/static unicode around template param does not break URL
Step 2: Minimal implementation
Core API:
export type TemplateInput = {
targetUrl: string
pathParam?: string
query: URLSearchParams
}
export function resolveTemplateUrl(input: TemplateInput): string {
// parse placeholders with /\{([^{}]+)\}/g
// split first comma only
// replace each placeholder with encodeURIComponent(value)
// return final URL string
}
Step 3: Verify
npm test -- tests/templates.test.ts
Expected: PASS.
Step 4: Commit
git add cloudflare-shortlinks/worker/lib/templates.ts cloudflare-shortlinks/tests/templates.test.ts
git commit -m "feat: resolve parameterized shortlink URLs"
Task 5: 实现公共短链重定向路由
Objective: heygo.cc/<alias> 可从 D1 读取 public link 并跳转。
Files:
- Modify:
cloudflare-shortlinks/worker/index.ts - Create:
cloudflare-shortlinks/worker/routes/redirect.ts - Create:
cloudflare-shortlinks/worker/lib/responses.ts - Create:
cloudflare-shortlinks/tests/redirect.test.ts
Step 1: Write tests
Cases:
- public redirect found -> 302 Location
- public custom -> 200 HTML
- unknown alias on
heygo.cc-> 404 - parameterized alias -> resolved URL
Step 2: Implement route
Pseudo-flow:
if (host === 'heygo.cc' && isAliasPath(pathname)) {
const link = await getPublicLink(env, alias)
if (!link) return publicNotFound(alias)
return renderOrRedirect(link, request, pathParam)
}
Step 3: Add click count asynchronously
Use ctx.waitUntil(recordClick(env.DB, link.id)); do not block redirect on analytics failure.
Step 4: Verify
npm test -- tests/redirect.test.ts
npm run dev
curl -I 'http://localhost:8787/claude' -H 'Host: heygo.cc'
Expected: seeded alias returns 302.
Step 5: Commit
git add cloudflare-shortlinks/worker/index.ts cloudflare-shortlinks/worker/routes/redirect.ts cloudflare-shortlinks/tests/redirect.test.ts
git commit -m "feat: resolve public shortlink redirects"
Phase 2 — Auth + private shortlinks
Task 6: Auth provider spike and decision checkpoint
Objective: 在 Worker runtime 中验证 Better Auth/Auth.js 的 D1 + Google + GitHub 是否能本地跑通。
Files:
- Create:
cloudflare-shortlinks/docs/auth-spike.md - Create/Modify after decision:
cloudflare-shortlinks/worker/auth.ts
Decision criteria:
- Must run on Cloudflare Workers runtime, not require Node
fs/net/cryptoincompatible APIs. - Must support Google and GitHub OAuth.
- Must store users/accounts/sessions in D1 or easily adapt to existing tables.
- Must support secure HttpOnly cookie sessions under
heygo.ccand visible tomy.heygo.ccif cookie domain is.heygo.cc.
Preferred path: Better Auth Cloudflare integration if spike passes.
Fallback path: Auth.js D1 adapter or manual OAuth with a small edge-compatible OAuth helper.
Apple checkpoint: Add Apple provider only after confirming paid Apple Developer Program is available; otherwise keep disabled.
Commit
git add cloudflare-shortlinks/docs/auth-spike.md cloudflare-shortlinks/worker/auth.ts
git commit -m "docs: record auth provider decision for workers"
Task 7: Implement session guard
Objective: Worker can identify current user for API and my.heygo.cc routes.
Files:
- Modify:
cloudflare-shortlinks/worker/auth.ts - Modify:
cloudflare-shortlinks/worker/index.ts - Create:
cloudflare-shortlinks/tests/auth.test.ts
Tests:
- no cookie ->
getCurrentUser()returns null - valid session -> returns user
- expired session -> null
- admin role ->
requireAdmin()passes
Verification:
npm test -- tests/auth.test.ts
Expected: PASS.
Task 8: Implement private redirect behavior
Objective: my.heygo.cc/<alias> behaves exactly as requested.
Files:
- Modify:
cloudflare-shortlinks/worker/routes/redirect.ts - Modify:
cloudflare-shortlinks/worker/lib/responses.ts - Create/Modify:
cloudflare-shortlinks/tests/redirect.test.ts
Tests:
- unauthenticated
my.heygo.cc/foo-> 404 with login link - authenticated user A
foo-> resolves user A private link - authenticated user B
foo-> resolves user B private link or 404 if absent - private alias never falls back to public link on
my.heygo.cc
Verification:
npm test -- tests/redirect.test.ts
Expected: PASS.
Task 9: Implement link CRUD API
Objective: 登录用户可创建/编辑/删除自己的 private links;管理员可创建 public links。
Files:
- Create:
cloudflare-shortlinks/worker/routes/api.links.ts - Create:
cloudflare-shortlinks/src/lib/api.ts - Create:
cloudflare-shortlinks/tests/api.links.test.ts
API endpoints:
GET /api/links/private
POST /api/links/private
PATCH /api/links/private/:id
DELETE /api/links/private/:id
GET /api/links/public
POST /api/admin/public-links # admin only
PATCH /api/admin/public-links/:id # admin only
DELETE /api/admin/public-links/:id # admin only / soft delete
Validation:
Use Zod schemas in worker/routes/api.links.ts or shared src/lib/validators.ts:
const linkInputSchema = z.object({
alias: z.string().min(1).max(100),
linkType: z.enum(['redirect', 'custom']),
targetUrl: z.string().url().optional(),
contentMarkdown: z.string().optional(),
description: z.string().optional(),
})
Tests:
- create private link lowercases alias
- duplicate private alias for same user rejected
- same alias for different users allowed
- non-admin cannot create public link
- admin can create public link
Task 10: Build private/public management UI
Objective: React UI can manage private links and browse public links.
Files:
- Modify:
cloudflare-shortlinks/src/App.tsx - Create:
cloudflare-shortlinks/src/routes/LoginPage.tsx - Create:
cloudflare-shortlinks/src/routes/PrivateLinksPage.tsx - Create:
cloudflare-shortlinks/src/routes/PublicLinksPage.tsx - Create:
cloudflare-shortlinks/src/components/LinkForm.tsx - Create:
cloudflare-shortlinks/src/components/LinkTable.tsx - Create:
cloudflare-shortlinks/src/components/ProviderButtons.tsx
UI requirements:
- shadcn Button/Input/Dialog/Table/DropdownMenu/Checkbox/Toast
- private links table supports multi-select
- create form supports:
- alias
- regular URL
- custom markdown
- parameterized URL hint and examples
- public links page is read-only for normal users
- admin sees extra actions
Verification:
npm run build
npm run dev
npx playwright test
Expected: UI flows pass.
Phase 3 — Promotion workflow
Task 11: Promotion submission API
Objective: 用户可以把 private links 推荐到 public queue。
Files:
- Create:
cloudflare-shortlinks/worker/routes/api.promotions.ts - Create:
cloudflare-shortlinks/tests/api.promotions.test.ts
Endpoints:
POST /api/promotions
GET /api/promotions/mine
GET /api/admin/promotions?status=pending
POST /api/admin/promotions/:id/approve
POST /api/admin/promotions/:id/reject
POST /api/admin/promotions/:id/needs-changes
Tests:
- user can submit own private link
- user cannot submit another user’s private link
- duplicate pending submission rejected
- admin approval creates public link
- approval alias conflict returns 409 and does not approve
- rejection stores reason
Task 12: Promotion UI
Objective: 用户多选推荐;管理员审核。
Files:
- Modify:
cloudflare-shortlinks/src/routes/PrivateLinksPage.tsx - Create:
cloudflare-shortlinks/src/routes/AdminReviewPage.tsx - Create:
cloudflare-shortlinks/src/components/PromotionDialog.tsx
Verification:
npx playwright test tests/e2e/promotions.spec.ts
Expected: user submits, admin approves, heygo.cc/<alias> resolves.
Phase 4 — Django data export/import
Task 13: Add Django export command
Objective: 从现有 Linux/Django app 导出短链为 JSONL,不迁移 pages/posts/images。
Files:
- Create:
links/management/commands/export_shortlinks_jsonl.py - Create:
tests/test_export_shortlinks_jsonl.py
Output format:
{"alias":"claude","link_type":"redirect","target_url":"https://claude.ai","content_markdown":null,"description":"","tags":["ai"],"created_at":"...","updated_at":"..."}
Rules:
- 所有 Linux/Django 短链先导入为某个 admin 用户的 private links。
- 不自动导入 public,避免把私人历史数据暴露给全球。
- alias 冲突时导入脚本生成 report,不静默覆盖。
Verification:
python manage.py export_shortlinks_jsonl --output /tmp/shortlinks.jsonl
python -m json.tool /tmp/shortlinks.jsonl
Expected: JSONL valid; row count equals Link.objects.count().
Task 14: Add D1 import script
Objective: 把 JSONL 导入 Cloudflare D1 private links。
Files:
- Create:
cloudflare-shortlinks/scripts/import-links-to-d1.ts
Command:
npm run import:links -- --file ./exports/shortlinks.jsonl --owner-email admin@example.com --local
Behavior:
- lookup/create owner user
- validate alias and URL templates
- insert as
scope='private' - write
import-report.jsonlisting imported/skipped/conflicts
Verification:
npm run import:links -- --file ./exports/shortlinks.jsonl --owner-email <admin-email> --local
npx wrangler d1 execute heygo-shortlinks --local --command "SELECT COUNT(*) FROM links WHERE scope='private';"
Expected: count equals imported count from report.
Phase 5 — Cloudflare deployment
Task 15: Create Cloudflare resources
Objective: 创建 D1/KV/secrets/custom domains。
Commands:
cd /home/ai-bot/code/links/cloudflare-shortlinks
npx wrangler d1 create heygo-shortlinks
npx wrangler kv namespace create PUBLIC_LINK_CACHE
npx wrangler secret put GOOGLE_CLIENT_ID
npx wrangler secret put GOOGLE_CLIENT_SECRET
npx wrangler secret put GITHUB_CLIENT_ID
npx wrangler secret put GITHUB_CLIENT_SECRET
npx wrangler secret put SESSION_SECRET
Then update wrangler.jsonc with generated IDs.
Custom domains/routes:
heygo.cc/*->heygo-shortlinksmy.heygo.cc/*->heygo-shortlinks
Do not enable Apple secrets unless paid Apple Developer Program credentials exist:
npx wrangler secret put APPLE_CLIENT_ID
npx wrangler secret put APPLE_TEAM_ID
npx wrangler secret put APPLE_KEY_ID
npx wrangler secret put APPLE_PRIVATE_KEY
Task 16: Production migration and smoke test
Objective: Apply schema, import private links, deploy, verify domain behavior.
Commands:
npx wrangler d1 migrations apply heygo-shortlinks --remote
npm run build
npx wrangler deploy
Smoke tests:
curl -I https://heygo.cc/claude
curl -i https://heygo.cc/definitely-not-existing
curl -i https://my.heygo.cc/claude
Expected:
- public existing alias -> 302
- public missing alias -> 404
- private unauthenticated alias -> 404 body includes login link
5. Risks / tradeoffs / open questions
5.1 Apple 登录是否支持
- 风险:Apple web sign-in 需要 $99/year Apple Developer Program。
- Plan decision:Google/GitHub 先上;Apple provider 预留但 feature flag off。
- Action:实施前登录 Apple Developer Portal 实测 Service ID/Sign in with Apple capability 是否要求 paid team。
5.2 KV eventual consistency
- 风险:公开短链修改后,全球某些地区可能短时间读到旧目标。
- Mitigation:D1 为准;KV 只缓存 public redirects;后台提示“公开短链修改最多 60 秒全球生效”。如不能接受,MVP 先不启用 KV。
5.3 D1 写热点
- 风险:热门公开短链每次点击更新 D1 计数会形成写热点。
- MVP:
ctx.waitUntil写click_daily,失败不影响跳转。 - 后续:用 Queues batching 或 Analytics Engine 存点击流。
5.4 私人短链不能公开分享
- 这是当前域名设计的自然结果:
my.heygo.cc/<alias>依赖登录用户。 - 如果未来想分享个人公开页,需要新增
u/<username>/<alias>或my.heygo.cc/@user/<alias>,不是本 MVP 范围。
5.5 与现有 Django Links 的范围边界
- 本计划只迁移短链功能。
- 不迁移 pages/posts/images/files/invest/price monitor 等 Links app 其它模块。
- 迁移导入默认为 private,避免误公开历史数据。
6. Validation checklist
Before calling the Cloudflare version ready:
heygo.cc/<public-alias>anonymous redirect works.heygo.cc/<public-custom>anonymous custom page works.heygo.cc/<missing>returns public 404.my.heygo.cc/<alias>unauthenticated returns 404 + login link.- Google login works.
- GitHub login works.
- Private aliases are isolated per user.
- Parameterized URL default and override match Django tests.
- User can create private regular/custom/parameterized links.
- User can multi-select recommend private links.
- Admin can approve/reject promotions.
- Approved promotion appears on
heygo.cc/<alias>without login. - Django export row count matches imported private row count.
- Production deploy has secrets, D1 migration, domains, and smoke tests passing.
7. Suggested execution order
- Phase 1: skeleton + D1 + alias/template/public redirect.
- Phase 2: auth + private redirect + CRUD UI.
- Phase 3: promotion queue + admin review.
- Phase 4: Django export/import.
- Phase 5: deploy to Cloudflare and smoke test.
Do not start production deploy until local wrangler dev, Vitest, and Playwright smoke tests pass.