docs: add cloudflare shortlinks implementation plan

This commit is contained in:
Hermes Agent
2026-06-20 10:01:53 +10:00
parent 32913619f4
commit bac169fe89
@@ -0,0 +1,994 @@
# 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 | private`
- `link_type = redirect | custom`
- `alias`
- `target_url`redirect 类型必填
- `content_markdown`custom 类型必填
- `owner_user_id`private 必填,public 可为空或记录提交者
- `visibility/status`active/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
```text
/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`
```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`:
```bash
npm create cloudflare@latest -- cloudflare-shortlinks --framework=react --typescript --git=false
```
Expected: creates React + Vite + Worker app.
**Step 2: Add routing library and validation**
```bash
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**
```bash
npm run dev
```
Expected: local Worker dev server starts and React page loads.
**Step 4: Commit**
```bash
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` 关键配置:
```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**
```bash
npx wrangler d1 migrations apply heygo-shortlinks --local
```
Expected: migration applied locally.
**Step 4: Commit**
```bash
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**
```ts
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**
```bash
npm test -- tests/aliases.test.ts
```
Expected: FAIL because functions do not exist.
**Step 3: Implement**
```ts
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**
```bash
npm test -- tests/aliases.test.ts
```
Expected: PASS.
**Step 5: Commit**
```bash
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:
```ts
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**
```bash
npm test -- tests/templates.test.ts
```
Expected: PASS.
**Step 4: Commit**
```bash
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:
```ts
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**
```bash
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**
```bash
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/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**
```bash
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:**
```bash
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:**
```bash
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:**
```text
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`:
```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:**
```bash
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:**
```text
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:**
```bash
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:**
```json
{"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:**
```bash
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:**
```bash
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:**
```bash
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:**
```bash
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:**
```bash
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:**
```bash
npx wrangler d1 migrations apply heygo-shortlinks --remote
npm run build
npx wrangler deploy
```
**Smoke tests:**
```bash
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 计数会形成写热点。
- 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
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.