Files
heygo/.hermes/plans/2026-06-20_093535-cloudflare-workers-shortlinks.md
T

31 KiB
Raw Blame History

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.pyLink(alias, original_url, text, link_type, click_count, tags, task_states)
    • links/views.pyredirect_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/yearApple 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=redirect302/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 | private
  • link_type = redirect | custom
  • alias
  • target_urlredirect 类型必填
  • content_markdowncustom 类型必填
  • owner_user_idprivate 必填,public 可为空或记录提交者
  • visibility/statusactive/archived/deleted

支持三类创建:

  1. 常规短链:alias -> https://example.com
  2. 自定义短链:alias -> markdown/custom page
  3. 带参数短链: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”。

流程:

  1. 用户选择一个或多个 private links。
  2. 对每个链接填写/确认 proposed_alias、说明、可选标签。
  3. 系统创建 promotion_submissions(status='pending')
  4. 管理员在 heygo.cc/admin/review 审核:
    • approve:创建 public link,或如果 alias 冲突则要求管理员改 alias。
    • reject:写入 rejection reason。
    • needs_changes:让用户修改后重提。
  5. 审核通过后,heygo.cc/<alias> 全球公开可访问。
  6. 原 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. 分阶段实施计划

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"

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/crypto incompatible 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.cc and visible to my.heygo.cc if 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.

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 users 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.json listing 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-shortlinks
  • my.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 decisionGoogle/GitHub 先上;Apple provider 预留但 feature flag off。
  • Action:实施前登录 Apple Developer Portal 实测 Service ID/Sign in with Apple capability 是否要求 paid team。

5.2 KV eventual consistency

  • 风险:公开短链修改后,全球某些地区可能短时间读到旧目标。
  • MitigationD1 为准;KV 只缓存 public redirects;后台提示“公开短链修改最多 60 秒全球生效”。如不能接受,MVP 先不启用 KV。

5.3 D1 写热点

  • 风险:热门公开短链每次点击更新 D1 计数会形成写热点。
  • MVPctx.waitUntilclick_daily,失败不影响跳转。
  • 后续:用 Queues batching 或 Analytics Engine 存点击流。

5.4 私人短链不能公开分享

  • 这是当前域名设计的自然结果:my.heygo.cc/<alias> 依赖登录用户。
  • 如果未来想分享个人公开页,需要新增 u/<username>/<alias>my.heygo.cc/@user/<alias>,不是本 MVP 范围。
  • 本计划只迁移短链功能。
  • 不迁移 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

  1. Phase 1: skeleton + D1 + alias/template/public redirect.
  2. Phase 2: auth + private redirect + CRUD UI.
  3. Phase 3: promotion queue + admin review.
  4. Phase 4: Django export/import.
  5. Phase 5: deploy to Cloudflare and smoke test.

Do not start production deploy until local wrangler dev, Vitest, and Playwright smoke tests pass.