mirror of
https://github.com/wahyd4/heygo.git
synced 2026-08-08 21:05:48 +10:00
docs: add cloudflare shortlinks implementation plan
This commit is contained in:
@@ -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 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:**
|
||||
|
||||
```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 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
|
||||
|
||||
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.
|
||||
Reference in New Issue
Block a user