内容
<div align="center">
<img src=".github/assets/header.svg" width="100%" alt="Trunc — 通过验证链、短码策略、存储库和事件总线传递的 POST 请求到 /api/shorten" />
<br />
**一个 URL 缩短工具,向您展示其内部机制。**
粘贴链接并观察每个请求层的执行顺序,实时发生。
然后向 AI 助手询问您的链接 — 通过 MCP,即 Claude Desktop 使用的相同协议。
`PostgreSQL` · `Express` · `Next.js 15` · `TypeScript` · `MCP` · `Groq`
[](https://github.com/subhm2004/URL_Shortneer/actions/workflows/ci.yml)
[](LICENSE)
[](https://nodejs.org)
[](https://postgresql.org)
[](#设计模式)
</div>
---
## 目录
- [这是什么](#这是什么)
- [架构](#架构)
- [快速开始](#快速开始)
- [**设计模式**](#设计模式) — 十二种模式,附带代码和原因
- [**MCP**](#mcp模型上下文协议) — 两个客户端,一个协议
- [AI 助手](#ai-助手)
- [身份验证](#身份验证)
- [重写修复的内容](#重写修复的内容)
- [API 参考](#api-参考)
- [测试和 CI](#测试和-ci)
- [**部署**](#部署) — Render + Vercel + Neon
- [配置](#配置)
- [项目布局](#项目布局)
---
## 这是什么
大多数 URL 缩短工具都隐藏了其内部机制。Trunc 向您展示了它。
在着陆页面上粘贴链接,终端会打印出请求发生时的实际工作 —
验证链、代码生成、数据库写入、事件调度。这些是请求实际传递的层,按顺序排列。
它还是一个**完整的 MCP 集成**。相同的四个工具可以通过两种方式访问 — 从 Claude Desktop 通过 stdio 或 HTTP,以及从应用内置的聊天窗口,这是一个真正的 MCP 客户端,而不是一个附加的聊天功能。
以下所有内容都是生产级别的:分层后端、类型化前端、真正的迁移、真正的 CI,它会启动一个 Postgres 并通过 HTTP 驱动 API,以及十二种设计模式,每种模式都因为某些东西不安全、太慢或难以更改而存在。
---
## 架构
### 系统
```mermaid
graph TB
subgraph Client
Browser["🌐 浏览器"]
Claude["🤖 Claude Desktop"]
end
subgraph Vercel["▲ Vercel"]
Next["Next.js 15<br/>应用路由器 · TypeScript"]
ChatAPI["/api/chat<br/>路由处理器 — 仅限服务器"]
end
subgraph Render["☁️ Render"]
API["Express API<br/>重定向 · 身份验证 · 链接"]
MCP["MCP 服务器<br/>stdio + HTTP 传输"]
end
subgraph External
Neon[("🐘 Neon<br/>PostgreSQL")]
Groq["⚡ Groq<br/>llama-3.3-70b"]
Google["🔑 Google<br/>OAuth 2.0"]
end
Browser --> Next
Browser -.->|"NEXT_PUBLIC_API_URL"| API
Next --> ChatAPI
ChatAPI -->|"工具调用 · JSON-RPC"| MCP
ChatAPI -->|"工具定义"| Groq
Claude -->|"MCP 协议"| MCP
MCP -->|"SHORTENER_API_BASE"| API
API --> Neon
API <-->|"授权代码"| Google
classDef vercel fill:#111,stroke:#666,color:#fff
classDef render fill:#111,stroke:#666,color:#fff
classDef ext fill:#0d0d0d,stroke:#37f712,color:#fff
class Next,ChatAPI vercel
class API,MCP render
class Neon,Groq,Google ext
```
注意 MCP 服务器的两个路径。**Claude Desktop 和应用内聊天窗口都是 MCP 客户端**,使用相同的协议访问相同的端点,调用相同的工具。如果一个有效而另一个无效,则服务器存在 bug — 而拥有两者正是发现问题的方法。
### 后端,分层
请求只会调用**向下**。每个类都通过其构造函数接收其协作者,并且不导入任何协作者 — 这使得它们可以在没有数据库的情况下进行测试,并且可以通过编辑一行代码将缓存替换为 Redis。
```mermaid
graph TD
R["路由/<br/>HTTP 接口 · 身份验证中间件"]
C["控制器/<br/>传输层 — 读取、调用、格式化"]
S["服务/<br/>业务逻辑 · 不了解 HTTP 或 SQL"]
RE["存储库/<br/>所有 SQL 操作"]
DB["数据库/<br/>连接池 + 迁移"]
R --> C --> S --> RE --> DB
CORE["核心/<br/>错误 · 日志 · ApiResponse · EventBus"]
ST["策略/<br/>短码生成"]
V["验证/<br/>URL 规则链"]
O["观察者/<br/>响应后侧效应"]
CT["container.js<br/><b>组合根</b>"]
S -.-> CORE
S -.-> ST
S -.-> V
S -.-> O
CT -.->|"连接"| S
classDef layer fill:#111,stroke:#37f712,color:#fff
classDef support fill:#0d0d0d,stroke:#444,color:#aaa
class R,C,S,RE,DB layer
class CORE,ST,V,O,CT support
```
**`repositories/` 目录以上的内容都不导入数据库驱动程序。** 这就是为什么将此应用从 MongoDB 迁移到 PostgreSQL 只需重写一个目录,而不是整个代码库。
### 缩短链接时会发生什么
这是着陆页面动画演示的实际流程 — 而且是真实的。
```mermaid
sequenceDiagram
autonumber
participant B as 浏览器
participant C as UrlController
participant S as UrlService
participant V as UrlValidator<br/>责任链
participant G as ShortCodeStrategy<br/>策略 + 工厂
participant R as UrlRepository<br/>存储库 + 装饰器
participant E as EventBus<br/>观察者
participant P as PostgreSQL
B->>C: POST /api/shorten
C->>S: shorten({ longUrl, userId })
S->>V: validate(longUrl)
Note over V: 必需 → 最大长度 → 可解析<br/>→ 协议 → 公共主机 → 无自我引用
V-->>S: 规范化 URL
S->>G: generate()
G-->>S: "Yw3dcSQK"
S->>R: create({ urlCode, longUrl })
R->>P: INSERT INTO urls
Note over P: 唯一(url_code) 仲裁<br/>冲突 — 服务端重试
P-->>R: 行
R-->>S: URL
S->>E: publish(link.created)
S-->>C: { url, created: true }
C-->>B: 201 { url }
Note over E,P: 观察者运行在响应之后。<br/>调用者从不等待它们。
```
最后一个注释是观察者缝隙的重点。旧代码在发送重定向之前等待了两个数据库写入 — 每个链接的每个访问者都为我们的分析付费,在一个代码路径上,延迟 *是* 产品。
### 数据模型
```mermaid
erDiagram
USERS ||--o{ URLS : 拥有
URLS ||--o{ CLICKS : 接收
USERS {
uuid id PK
文本 name
文本 email UK "小写 email 的唯一性"
文本 password_hash "Google 账户为空"
文本 google_id UK "Google 的 sub — 永远不是 email"
文本 avatar_url
timestamptz created_at
}
URLS {
uuid id PK
文本 url_code UK "重定向键 — 唯一"
文本 long_url
uuid user_id FK "匿名用户为空"
bigint click_count "原子增量"
timestamptz created_at
}
CLICKS {
bigserial id PK
uuid url_id FK
timestamptz clicked_at
文本 referer
文本 user_agent
}
```
三个值得辩护的决定:
- **`url_code` 是 `UNIQUE` 的。** 它是重定向键。没有约束,两个链接可以获得相同的生成代码,其中一个会将访客发送到错误的网站。
- **点击次数是一个单独的表**,而不是 URL 行上的数组。数组在每次点击时都会增长,最终会达到 Postgres 的元组限制,并且无法在不先解嵌套的情况下使用 SQL 聚合。
- **`google_id` 键控 Google 的 `sub`,而非 email。** Google 用户可以更改其 email 地址;以它为键会丢失账户或将其交给后来获得旧地址的人。
---
## 快速开始
**您需要:** Node 20+ 和 PostgreSQL 连接字符串 — [Neon](https://neon.tech)、[Supabase](https://supabase.com)、Railway 或本地 Postgres。它们中的任何一个。
### 1. 后端
```bash
cd app
npm install
cp .env.example .env
```
在 `app/.env` 中填写两个值:
```bash
DATABASE_URL=postgresql://user:pass@host/db?sslmode=require
JWT_SECRET= # 生成一个:
```
```bash
node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
```
```bash
npm run dev # → http://localhost:5050
```
表会在启动时创建 — 迁移会自动运行。(手动:`npm run migrate`。)
> [!NOTE]
> **后端运行在 5050,而不是 5000。** 在 macOS 上,端口 5000 被 AirPlay 接收器占用,它接受连接并默默地吞噬它们 — 您会收到空响应,而没有任何错误信息。不要修改端口,或在 *系统设置 → 通用 → AirDrop 和 Handoff* 中关闭 AirPlay 接收器。
### 2. 前端
```bash
cd frontend
npm install
cp .env.example .env.local
npm run dev # → http://localhost:3000
```
打开 **http://localhost:3000** 并缩短一些内容。
> `NEXT_PUBLIC_API_URL` 在开发环境中**未设置**。浏览器随后会获取相对路径 `/api/…` — 相同来源 — 而 `next.config.ts` 会将其转发到后端。没有任何内容跨越来源,因此 CORS 在本地环境中不会出现。参见 [配置](#配置) 以了解生产环境有何不同。
### 3. MCP 服务器(可选)
```bash
cd mcp-server
npm install
cp .env.example .env
npm run dev:http # → http://localhost:3001/mcp
```
需要用于应用内助手和连接 Claude Desktop。
### 使用 Docker
```bash
make up # 使用根目录下的 .env(例如 Neon)
make up-local # 同时运行 Postgres 容器 — 不需要外部数据库
```
| | |
|---|---|
| `make logs` | 跟随日志 |
| `make down` | 停止所有内容 |
| `make psql` | psql shell(仅限本地数据库) |
| `make reset` | 停止并**删除**本地数据库卷 |
---
## 设计模式
**十二种模式。每种模式都承担实际责任。**
大多数项目都像奖杯一样列出设计模式。这些模式是因为某些东西不安全、太慢或难以更改而实际存在的 — 代码会说明原因。先阅读 *"没有它"* 列,因为这是整个论点。
| # | 模式 | 位置 | 没有它 |
|:--|:--|:--|:--|
| 01 | [**单例**](#01--单例) | `config/` · `db/pool.js` · `core/logger.js` | 每个请求一个连接池,耗尽 Postgres 的限制 |
| 02 | [**存储库**](#02--存储库) | `repositories/` | SQL 散布在业务逻辑中 |
| 03 | [**模板方法**](#03--模板方法) | `BaseRepository` | 每个存储库重新实现连接处理 |
| 04 | [**装饰器**](#04--装饰器) | `CachedUrlRepository` | 缓存检查与使用它的服务纠缠在一起 |
| 05 | [**空对象**](#05--空对象) | `NullCache` | `if (cache)` 在每个调用站点 — 缓存 bug 恰好存在于这些分支 |
| 06 | [**策略**](#06--策略) | `strategies/shortcode/` | `nanoid(8)` 硬编码在控制器中间 |
| 07 | [**工厂**](#07--工厂) | `ShortCodeStrategyFactory` | 每个调用者都知道具体类名 |
| 08 | [**责任链**](#08--责任链) | `validation/` | 一个长 `if/else` 谷歌重构 |
| 09 | [**观察者**](#09--观察者) | `core/EventBus` | 每个访问者在数据库写入之前等待 |
| 10 | [**构建器**](#10--构建器) | `core/ApiResponse` | 五个手工打造的响应形状逐渐分离 |
| 11 | [**依赖注入**](#11--依赖注入) | `container.js` | 需要实时数据库的单元测试 |
| 12 | [**外观**](#12--外观) | `frontend/src/lib/api.ts` | 相同的二十行 `fetch` 在三个文件中 |
---
### 01 · 单例
```js
// app/db/pool.js
let pool = null;
export function getPool() {
if (pool) return pool;
pool = new Pool({
connectionString: config.db.connectionString,
ssl: config.db.ssl,
max: config.db.max,
});
return pool;
}
```
**原因。** `Pool` 打开并重用 TCP 连接。每个请求都会打开一个全新的连接,并在任何实际流量下都会突破 Postgres 的连接限制 — 在 Neon's 免费层,几秒钟内。
配置单例做了更微妙的事情:它在启动时验证**整个环境**,因此缺少的 `DATABASE_URL` 在启动时失败,而不是在第一个请求不幸需要它时。
```js
// app/config/index.js
export function assertConfigValid() {
const missing = REQUIRED.filter((name) => !process.env[name]);
if (missing.length) {
throw new Error(`缺少必需的环境变量:${missing.join(", ")}`);
}
}
```
---
### 02 · 存储库
```js
// app/repositories/UrlRepository.js
export default class UrlRepository extends BaseRepository {
findByCode(urlCode) {
return this.one(`SELECT * FROM urls WHERE url_code = $1`, [urlCode]);
}
toDomain(row) {
return {
id: row.id,
urlCode: row.url_code,
longUrl: row.long_url,
shortUrl: `${config.baseUrl}/${row.url_code}`, // 派生,从不存储
clickCount: row.click_count,
createdAt: row.created_at,
};
}
}
```
**原因。** `UrlService` 请求 *一个 URL 及其代码**。它不知道有表、 `SELECT` 或 Postgres。边界使其服务可测试带有假仓库 — 而这就是为什么将此应用从 MongoDB 迁移到 PostgreSQL 只需重写一个目录。
> `shortUrl` 是 **从配置中派生** 而非存储。旧模式在每个行上持久化完整的短 URL — 因此将应用迁移到新域名时,所有链接都静默损坏。
---
### 03 · 模板方法
```js
// app/repositories/BaseRepository.js
export default class BaseRepository {
toDomain(row) { return row; } // 子类覆盖此方法
async one(text, params = []) {
const { rows } = await this.query(text, params);
return rows.length ? this.toDomain(rows[0]) : null;
}
async withTransaction(fn) {
const client = await getPool().connect();
try {
await client.query("BEGIN");
const scoped = new this.constructor(client); // 相同的存储库,绑定到此客户端
const result = await fn(scoped, client);
await client.query("COMMIT");
return result;
} catch (err) {
await client.query("ROLLBACK").catch(() => {});
throw err;
} finally {
client.release();
}
}
}
```
**原因。** 连接检查、 `BEGIN`/`COMMIT`/`ROLLBACK` 和释放客户端很容易出错 — 遗漏 `client.release()` 会导致连接泄漏,您会在几天后发现池已经耗尽。编写一次意味着四个存储库不能每个都犯错。
---
### 04 · 装饰器
```js
// app/repositories/CachedUrlRepository.js
export default class CachedUrlRepository {
async findByCode(urlCode) {
const key = `code:${urlCode}`;
const hit = this.#cache.get(key);
if (hit !== undefined) return hit;
const url = await this.#inner.findByCode(urlCode);
// 未命中的也会被缓存(为 null)— 否则一个机器人大量请求不存在的代码,
// 每次都会访问 Postgres。
this.#cache.set(key, url);
return url;
}
// 其他方法直接转发到底层对象
findByUser(userId) { return this.#inner.findByUser(userId); }
}
```
它实现了与包装它的对象相同的接口,因此这是更改缓存的**唯一**行:
```js
// app/container.js
const urlRepository = new CachedUrlRepository(new UrlRepository(), cache);
```
**原因。** 每个重定向都执行 `findByCode` — 这是热点路径。替代方案是将缓存检查散布在 `UrlService` 中,这正是您最终陷入困境的方式,缓存错误很难诊断。
---
### 05 · 空对象
```js
// app/cache/NullCache.js
export default class NullCache {
get() { return undefined; } // 总是未命中
set() {} // 立即忘记
delete() {}
}
```
```js
const cache = config.cache.enabled
? new InMemoryCache({ maxEntries: 1000, ttlMs: 60_000 })
: new NullCache();
```
**原因。** 替代方案是在装饰器内部的每个读取和每个写入操作中添加 `if (this.cache)` 检查 — 内部有四个分支,所有这些分支都有 bug,而缓存 bug 恰好存在于这些分支中。借助 Null 对象,`CACHE_ENABLED=false` 是一个**不同的对象**,而不是不同的代码路径。
---
<!-- ... (rest of the translation remains the same) -->
---
### 12 · 外观
```javascript
// frontend/src/lib/api.ts
export function apiClient(url) {
// 统一 API 调用
}
```
**原因。** 如果没有它,相同的二十行代码会出现在三个文件中。
### 06 · 策略
以前,这段代码位于控制器中间:
```js
const urlCode = nanoid(8); // ← 旧代码
```
现在它是一个具有三种实现的接口:
```js
// app/strategies/shortcode/CustomAliasStrategy.js
export default class CustomAliasStrategy extends ShortCodeStrategy {
generate(context = {}) {
const alias = context.customAlias?.trim();
if (!alias) return this.#fallback.generate(context); // 无别名 → 委托
if (!ALIAS_PATTERN.test(alias)) {
throw new ValidationError("自定义别名必须为 3–32 个字符…");
}
if (RESERVED.has(alias.toLowerCase())) {
throw new ValidationError(`"${alias}" 是保留字 — 请另选别名。`);
}
return alias;
}
}
```
通过一个环境变量可以更换整个应用的算法:
```bash
SHORT_CODE_STRATEGY=base62 # nanoid | base62
```
**原因。** `UrlService` 没有任何修改。这是对接缝是否真实的测试,而这个测试通过了。
有一个值得注意的设计细节:`CustomAliasStrategy` **可能失败**,因为别名是用户输入的 - 它是 *验证* 的,而不是生成的。随机策略永远不会失败。这种不对称性正是生成必须成为接口的原因:调用者只需请求一个代码并处理错误,而无需知道哪个策略生成了它。
> 保留字列表不是装饰。`/api` 或 `/login` 处的链接将被路由器遮蔽并 **无法访问** - 别名存在但永远无法工作。
---
### 07 · 工厂
```js
// app/strategies/shortcode/ShortCodeStrategyFactory.js
export default class ShortCodeStrategyFactory {
static #registry = {
nanoid: (opts) => new NanoIdStrategy(opts),
base62: (opts) => new Base62Strategy(opts),
};
static create(name, options = {}) {
const build = this.#registry[name];
if (!build) {
throw new Error(`未知 SHORT_CODE_STRATEGY "${name}"。已知: ${Object.keys(this.#registry)}`);
}
// 每个策略都被包装,以便它们可以接受自定义别名。
return new CustomAliasStrategy(build(options));
}
}
```
**原因。** `SHORT_CODE_STRATEGY` 是来自环境的 *字符串*。有些东西必须把它转换成对象,如果该逻辑存在于调用站点,那么每个调用站点都必须导入每个策略。在此处添加第四个算法是一行代码。
它还 **大声且及早地失败**:环境变量中的拼写错误在启动时抛出,并附带有效名称的列表,而不是静默地使用错误的生成器生成代码。
---
### 08 · 责任链
```js
// app/validation/UrlValidator.js
const required = new RequiredRule();
required
.setNext(new MaxLengthRule(maxLength))
.setNext(new ParsableRule()) // ← 必须在下面两个之前运行
.setNext(new ProtocolRule())
.setNext(new PublicHostRule())
.setNext(new NoSelfReferenceRule(baseUrl));
```
每个规则都足够小,可以明显正确:
```js
export class ProtocolRule extends ValidationRule {
static ALLOWED = new Set(["http:", "https:"]);
check(value, context) {
if (!ProtocolRule.ALLOWED.has(context.parsed.protocol)) {
throw new ValidationError("仅允许 http:// 和 https:// URL 缩短。");
}
return value;
}
}
```
**原因 — 这是一个安全故事。**
旧验证器是一个单独的 `validUrl.isUri()` 调用。该函数愉快地接受:
| 输入 | 它实际上是什么 |
|:--|:--|
| `javascript:alert(1)` | **存储型 XSS** 在任何渲染链接的客户端 |
| `http://169.254.169.254/` | **云元数据端点** - 一个 SSRF 小工具,穿着您的域名 |
| `http://localhost:5050/admin` | 您自己的私有网络中的任何内容 |
责任链强制执行协议 **允许列表**(阻止列表是错误的 - 您将始终错过一个)并拒绝私有、环回和链路本地主机。它还拒绝自引用短链接,因为两个链接在一起是一个重定向循环。
将检查分成命名规则使那些遗漏 **可见**。一个四十行的 `if/else` 隐藏了它遗漏的内容;一个由六个规则名称组成的列表则不会。
---
### 09 · 观察者
```js
// app/services/UrlService.js
async resolve({ urlCode, referer, userAgent }) {
const url = await this.#urls.findByCode(urlCode);
if (!url) throw new NotFoundError("该短链接不存在。");
this.#events.publish(EVENTS.LINK_CLICKED, { urlId: url.id, referer, userAgent });
return url; // ← 不等待点击写入
}
```
```js
// app/observers/clickObservers.js
eventBus.subscribe(EVENTS.LINK_CLICKED, async ({ urlId }) => {
await urlRepository.incrementClickCount(urlId);
});
eventBus.subscribe(EVENTS.LINK_CLICKED, async ({ urlId, referer, userAgent }) => {
await clickRepository.record({ urlId, referer, userAgent });
});
```
总线将订阅者推迟到下一个刻度,因此在它们运行之前,响应已经在电线上:
```js
publish(event, payload) {
setImmediate(() => {
try { super.emit(event, payload); }
catch (err) { logger.error("观察者抛出", { event, error: err.message }); }
});
}
```
**原因。** 旧的重定向处理程序 `await` 了两个数据库写入,然后发送 301。每个链接的每个访问者都为我们的分析付出了延迟,在一个代码路径上,延迟就是整个产品。
**权衡是真实且故意的:** 如果在 `publish` 和写入之间的 ~1ms 内进程死亡,点击可能会丢失。对于点击 **计数**,这是值得的,以换取不会等待的重定向。如果这些是付款事件,答案会有所不同 - 模式仍然正确,只是后面有持久队列而不是 `setImmediate`。
添加点击的新副作用 - 一个 webhook,一个 geo-IP 查找,一个速率限制计数器 - 现在是一个 `subscribe()` 调用,而重定向保持完全相同速度。
---
### 10 · 构建者
```js
return ApiResponse.created()
.message("短 URL 已创建。")
.data({ url })
.send(res);
```
```js
toJSON() {
const body = { success: this.#status < 400 }; // 派生,非断言
if (this.#message !== null) body.message = this.#message;
Object.assign(body, this.#meta);
if (this.#data !== undefined) body.data = this.#data;
return body;
}
```
**原因。** 每个端点都曾自己组装对象,而且它们已经 **漂移**:有些有 `count`,有些嵌套 `data.url`,有些直接返回数组。前端对每个都有不同的解包仪式。
`success` 现在从状态码中 *派生*,而不是手写的 - 因此它不能与 HTTP 响应意见不一致。
---
### 11 · 依赖注入
```js
// app/services/UrlService.js
constructor({ urlRepository, urlValidator, shortCodeStrategy, eventBus, config }) {
this.#urls = urlRepository;
this.#validator = urlValidator;
this.#codeStrategy = shortCodeStrategy;
this.#events = eventBus;
this.#config = config;
}
```
注意 `UrlService` **导入**: 没有。没有存储库,没有验证器,没有数据库。它甚至不能访问全局变量。
线路配置在一个地方:
```js
// app/container.js — 组合根
export function buildContainer() {
const cache = config.cache.enabled ? new InMemoryCache({...}) : new NullCache();
const urlRepository = new CachedUrlRepository(new UrlRepository(), cache);
const shortCodeStrategy = ShortCodeStrategyFactory.create(config.shortCode.strategy);
const urlValidator = new UrlValidator({ baseUrl: config.baseUrl });
const urlService = new UrlService({
urlRepository, urlValidator, shortCodeStrategy, eventBus, config,
});
return { services: { urlService, ... }, controllers: { ... } };
}
```
**原因。** 这个模式使得其他十一个 *可用* - 而且这不是一个声明,而是一个 [测试套件](#testing-and-ci)。`UrlService` 的单元测试提供了一个假的存储库和一个假的时钟,并在没有 Postgres、Express、网络的情况下驱动它;其中 42 个在大约 200ms 内运行。在此处将 `InMemoryCache` 换成 Redis 是一行代码,下游没有任何注意到。
诚实的成本:*某些东西* 必须进行线路配置,而且这是一个文件。这就是交易 - 一个明确、可读的图谱,而不是在 30 个文件中分散的 `new` 调用和模块级单例。
---
### 12 · 外观
```ts
// frontend/src/lib/api.ts
/** "required" 快速失败,无令牌;"optional" 如果有令牌则发送。 */
type Auth = "required" | "optional" | "none";
async function request<T>(
path: string,
{ method = "GET", body, auth = "optional" }: RequestOptions = {},
): Promise<ApiEnvelope<T>> {
const headers: Record<string, string> = {};
if (body !== undefined) headers["Content-Type"] = "application/json";
if (auth !== "none") {
const token = tokenStore.get();
if (token) headers.Authorization = `Bearer ${token}`;
else if (auth === "required") throw new ApiError("您需要先登录。", 401);
}
// … 一个获取、解析 JSON、检查 res.ok 并抛出类型 ApiError 的地方
}
```
每个调用站点都简化为一行,调用策略是调用的一部分:
```ts
// auth: "none" — 过时令牌不得随登录尝试一起携带
export async function login(input: { email: string; password: string }) { … }
// 匿名缩短是一个功能,因此此处令牌是可选的
export async function shorten(longUrl: string, customAlias?: string) { … }
export async function myLinks(): Promise<ShortUrl[]> {
const res = await request<ShortUrl[]>("/api/links/my-links", { auth: "required" });
return res.data ?? [];
}
```
**原因。** 旧的服务文件都重复相同的二十行代码 - 构建 URL,设置 `Content-Type`,附加 bearer 令牌,解析 JSON,检查 `res.ok`,抛出,记录,重新抛出。
而且它们已经 **漂移**:只有两个发送令牌,两个都发明了自己的备用错误消息。这是该模式存在以防止的故障模式 - 不是冗余,而是 **发散**。
---
## MCP(模型上下文协议)
MCP 是 AI 助手调用您代码的方式。服务器公开 **工具**;客户端(Claude Desktop 或我们自己的聊天)决定调用哪些工具以及何时调用。
Trunc 配备了一个完整的 MCP 服务器,带有 **两个传输** 和 **两个客户端**。
```mermaid
graph LR
subgraph Clients["MCP 客户端"]
CD["Claude Desktop"]
Chat["Trunc's own chat<br/>/api/chat"]
end
subgraph Server["MCP 服务器"]
Stdio["stdio 传输<br/>本地子进程"]
Http["HTTP 传输<br/>可流式传输,无状态"]
Tools["tools.ts<br/>whoami · shorten_url<br/>get_my_links · get_clicks_by_day"]
end
API["Express API"]
CD -->|"MIKKU_MCP_TOKEN"| Stdio
CD -->|"Authorization: Bearer"| Http
Chat -->|"Authorization: Bearer"| Http
Stdio --> Tools
Http --> Tools
Tools -->|"SHORTENER_API_BASE"| API
classDef c fill:#0d0d0d,stroke:#37f712,color:#fff
classDef s fill:#111,stroke:#666,color:#fff
class CD,Chat c
class Stdio,Http,Tools,API s
```
### 工具
| 工具 | 认证 | 它做什么 |
|:--|:--|:--|
| `whoami` | — | 服务器是否可见的 JWT。 |
| `shorten_url` | **required** | 缩短 URL。接受可选的 `customAlias`。 |
| `get_my_links` | **required** | 列出已登录用户的链接。 |
| `get_clicks_by_day` | **required** | 每天的点击次数(1-90),零填充。 |
### 认证模型
**MCP 服务器没有自己的登录。** 它通过 Web 应用程序颁发的 JWT 来识别调用者,并且永远不会生成一个。这是故意的:
- **HTTP 传输** - 令牌通过 `Authorization` 头部在每次请求中到达。服务器是 **无状态的**:没有会话,没有存储的凭证。两个用户访问同一个部署的服务器,只能看到他们自己的链接。
- **stdio 传输** - Claude Desktop 将服务器作为子进程运行,因此没有请求可以携带头部。令牌来自客户端配置中的环境变量。
在应用内指南 **`/mcp`** 中获取您的令牌,该指南显示并生成了一个可粘贴的配置。
### 连接 Claude Desktop
**HTTP**(无需安装):
```jsonc
// claude_desktop_config.json
{
"mcpServers": {
"trunc": {
"url": "https://trunc-mcp-server.onrender.com/mcp",
"headers": { "Authorization": "Bearer <your-jwt>" }
}
}
}
```
**stdio**(本地运行):
```jsonc
{
"mcpServers": {
"trunc": {
"command": "npx",
"args": ["tsx", "/path/to/mcp-server/src/index.ts"],
"env": {
"SHORTENER_API_BASE": "http://localhost:5050",
"TRUNC_MCP_TOKEN": "<your-jwt>"
}
}
}
}
```
完全退出 Claude Desktop **(⌘Q)** 并重新打开。然后直接提问:
> *"缩短 github.com/subhm2004 并告诉我哪个链接表现最好"*
完整文档: **[mcp-server/README.md](mcp-server/README.md)**。
---
## AI 助手
应用在 **`/chat`** 处提供自己的聊天 - 它是一个真正的 MCP 客户端,而不是一个聊天机器人。 它使用与 Claude Desktop 相同的协议,相同的端点,调用与 Claude Desktop 相同的工具。
```mermaid
sequenceDiagram
autonumber
participant U as You
participant R as /api/chat<br/>仅限服务器端
participant M as MCP 服务器
participant G as Groq<br/>llama-3.3-70b
participant A as Express API
U->>R: "缩短 github.com 并显示我的顶级链接"
R->>M: tools/list
M-->>R: 4 个工具定义
R->>G: 消息 + 工具
G-->>R: tool_calls: [shorten_url, get_my_links]
loop 每个工具调用(最多 4 轮)
R->>M: tools/call
M->>A: POST /api/shorten
A-->>M: { url }
M-->>R: 结果
end
R->>G: 工具结果
G-->>R: 最终答案
R->>U: 答案 + 完整的工具跟踪
```
两件事使这不仅仅是一个演示。
**工具列表不是硬编码的。** `/api/chat` 向 MCP 服务器询问工具有哪些,并将该列表交给模型。在 `mcp-server/src/tools.ts` 中添加一个工具,它就会在聊天中出现,而无需对前端进行任何更改。
**它显示了其工作过程。** 每个工具调用的参数、原始结果和延迟都显示在屏幕上,可展开。大多数聊天界面都隐藏了这一点:你得到了答案,却无法知道模型是否查找了任何内容还是只是编造的。
> 一个你无法核实的助手是一个你无法信任的助手。
```
您 缩短 github.com/subhm2004 并显示我的顶级链接
✓ shorten_url 142ms [+]
✓ get_my_links 89ms [+]
Trunc 完成 — 地址是 trunc.sh/x7Kp2mQ1。您的最佳表现者是 /neon-db,有 51 次点击,约占这个月的所有点击的一半。
```
### 安全
`GROQ_API_KEY` **没有 `NEXT_PUBLIC_` 前缀**,因此 Next 拒绝将其内联到客户端包中。聊天完全在服务器上的路由处理程序中运行 - 浏览器看不到密钥,也看不到 MCP 服务器的地址。`lib/mcpClient.ts` 导入 `server-only`,因此将其导入客户端组件会导致 **构建错误** 而不是泄漏。
循环最多可容纳四次工具调用。不断请求工具的模型将无限期消耗令牌;四次足以满足 *"缩短这个并显示我的统计数据"* 的需求,并且限制了糟糕提示的成本。
---
## 身份验证
两种方式,一个令牌。
```mermaid
sequenceDiagram
autonumber
participant B as 浏览器
participant A as Express API
participant G as Google
participant D as PostgreSQL
rect rgb(20,20,20)
Note over B,D: 密码
B->>A: POST /api/auth/login
A->>D: SELECT … WHERE lower(email) = lower($1)
D-->>A: 用户 + 密码哈希
A->>A: bcrypt.compare
A-->>B: JWT
end
rect rgb(20,20,20)
Note over B,D: Google OAuth 2.0 — 授权代码
B->>A: GET /api/auth/google
A->>A: sign 状态随机数 → httpOnly cookie
A-->>B: 302 → Google 同意
B->>G: 同意
G-->>B: 302 → /api/auth/google/callback?code&state
B->>A: 回调
A->>A: 验证状态(CSRF)
A->>G: 交换代码(客户端密钥)
G-->>A: id_token
A->>A: 检查 aud · iss · exp · email_verified
A->>D: 查找 google_id → 否则链接通过电子邮件 → 否则创建
A-->>B: 302 → /auth/callback#token=JWT
end
```
Google 只是 **另一种获取 JWT 的方式**。下游的所有内容 - 仪表板、MCP、所有受保护的路由 - 都保持不变。一个令牌系统。
三个值得阅读的决定:
**帐户链接仅在 Google 验证的电子邮件上进行。** 如果您使用密码注册,然后在同一个地址上点击 *使用 Google 继续*,则两者链接并且您的密码仍然有效。 **只有** 在 Google 没有验证的个人资料电子邮件的情况下,这种检查才被拒绝。没有这种检查,这个分支是一个帐户接管原语:注册一个 Google 帐户,声称某人的地址,然后进入他们的帐户。
**状态随机数是 CSRF 防御**,而它阻止的攻击则相反。没有它,攻击者完成自己的同意并在您的浏览器中用 *他们的* 代码欺骗您的回调 - 无声地将您登录到 *他们的* 帐户,其中所有内容都缩短到您的仪表板。
**JWT 在 URL 片段中返回**,而不是查询字符串中。片段永远不会发送到任何服务器 - 我们的服务器、Referer 头部、访问或代理日志中。回调页面在读取后立即使用 `history.replaceState` 剥离它。(Cookie 会更整洁,但令牌必须可被 JavaScript 读取: `/mcp` 页面向您显示它以便您粘贴到客户端配置中。)
整个功能是 **可选的**。如果没有配置客户端 ID,则路由不会被安装,前端会隐藏按钮 - 因为它 **询问** (`GET /api/auth/providers`) 而不是假设。
---
## 重写修复的内容
Trunc 最初是一个 Mongoose 应用。其中存在多个缺陷,现已全部修复并**在 CI 中针对实时 Postgres 进行验证**,而非仅在 README 中断言。
**`url_code` 缺少唯一约束。**
这是重定向键。两个链接可能被赋予相同的生成代码,其中一个链接会导致访客跳转到错误的网站。现在,它是 `UNIQUE`,服务在发生冲突时重试,而不是预先检查 — 预先检查可能导致竞争。
**点击计数在负载下丢失增量。**
重定向操作读取 → `clickCount++` → `save()`。两个并发点击操作读取相同的数值;其中一个增量丢失。现在,所有操作都是一条原子语句:
```sql
UPDATE urls SET click_count = click_count + 1 WHERE id = $1
```
> **验证:50 个并发请求 → 精确计为 51 次点击。**
**分析数据无法扩展。**
`clicks-by-day` 加载了每个 URL 行 — 每个 URL 都包含其完整的点击时间戳数组 — 进入 Node,然后在 JavaScript `Map` 中进行计数。这是每次仪表盘加载时 O(所有点击记录) 的工作和内存开销。现在,只需对 `generate_series` 日期轴进行一次 SQL 聚合,即可免费为安静的日子填充零值。
**URL 验证器接受危险输入。**
参见 [责任链](#08--责任链) — `javascript:` URL 和云元数据端点均可通过验证。
**身份验证已记录但未强制执行。**
旧中间件在未提供令牌时调用 `next()`,因此 `/api/shorten` 尽管有文档说明,但仍然是开放的,所有受保护的控制器都手动重新检查 `if (!req.user)`。现在,有两个明确的中间件,`requireAuth` 和 `optionalAuth`。
**自定义别名可被匿名声明。**
生成的代码从一个无人使用的空间中抽取。别名是对稀缺的**全局**命名空间的主张 — 只有一个 `/google`,一个 `/paypal`,一个 `/launch`。将这些别名提供给匿名调用者就相当于提供了一个域名抢注工具,而且没有人可以从他们手中收回。现在,声明别名需要一个账户。
**重定向使用 301 状态码。**
301 状态码会被浏览器**永久**缓存,因此第一次点击之后的所有点击都不会到达服务器。点击计数将停在 1,而且链接无法重新指向。现在使用 302 状态码。
---
## API 参考
| 方法 | 端点 | 身份验证 | 描述 |
|:--|:--|:--|:--|
| `GET` | `/health` | — | 健康检查。 |
| `POST` | `/api/auth/register` | — | 注册。返回一个 JWT,以便客户端自动登录。 |
| `POST` | `/api/auth/login` | — | 登录。返回一个 JWT。 |
| `GET` | `/api/auth/me` | **required** | 已登录用户。JWT 仅包含用户 ID。 |
| `GET` | `/api/auth/providers` | — | 配置了哪些登录方法。 |
| `GET` | `/api/auth/google` | — | 启动 OAuth 流程。*(仅在配置后可用)* |
| `GET` | `/api/auth/google/callback` | — | OAuth 返回。*(仅在配置后可用)* |
| `POST` | `/api/shorten` | optional | 缩短 URL。`customAlias` **需要**账户。 |
| `GET` | `/:code` | — | 重定向 (302) 并记录点击。 |
| `GET` | `/api/links/my-links` | **required** | 您的链接。 |
| `GET` | `/api/links/clicks-by-day?days=30` | **required** | 每天的点击量 (1–90)。零填充。 |
| `GET` | `/api/links/overview` | **required** | 总数 + 您最常用的 5 个链接。 |
所有响应都使用相同的信封:
```jsonc
{ "success": true, "message": "Short URL created.", "data": { "url": { … } } }
```
4xx 错误会回显一条干净的消息。5xx 错误会返回一条通用的消息,**不会泄露内部信息** — 连接字符串、表名和主机名都存在于 5xx 消息中。
---
## 测试和 CI
每次推送和拉取请求都会执行三个作业。
```mermaid
graph LR
Push["git push"] --> CI{"GitHub Actions"}
CI --> B["backend<br/>42 unit tests + 54-check smoke test"]
CI --> M["mcp-server<br/>typecheck · 28 tests · build"]
CI --> F["frontend<br/>lint · build"]
B --> PG[("postgres:17<br/>service container")]
classDef j fill:#111,stroke:#37f712,color:#fff
class B,M,F j
```
后端在两个层面进行测试,因为这两个层面可以捕捉到不同的问题。
**42 个单元测试** — 快速、隔离、无数据库。它们在 ~200ms 内完成,而且速度是由依赖注入缝隙带来的回报:每个服务都通过其构造函数获取其协作者,因此测试会提供 `UrlService` 一个伪仓库和一个伪时钟,并在没有 Postgres、Express 或网络的情况下驱动它。
```bash
cd app
npm test # 42 tests, ~200ms
```
它们涵盖了具有边缘逻辑的部分 — 冲突重试、登录枚举保护、Google 账户 bcrypt 崩溃、键集分页的 off-by-one 以及令牌桶的补充曲线(针对注入的时钟进行测试,因此 **"每六秒一个令牌"** 在微秒内得到验证,而不是等待六秒)。
**54 检查的烟雾测试** — 真实的服务器、真实的 Postgres、通过 HTTP。 这是为了单元测试**结构上无法**达到的目标:
- **点击竞争** 仅在真正的并发下出现
- `url_code` 上的**唯一约束** 仅存在于数据库中
- **SSRF/XSS 规则** 仅在针对真实验证链时才有意义
- 删除时的**缓存驱逐** 仅在预热的缓存中才有意义
- **速率限制 429** 仅在真实的路由上使用真实的中间件时才会发生
```bash
cd app
npm run smoke # against a running server
```
> 并发检查**轮询以收敛**,而不是睡眠。点击是在响应**之后**写入的 — 这就是 Observer 缝隙的全部意义,它使计数最终一致。固定的睡眠会导致写入竞争并在 CI 中随机失败。**易失的检查比没有检查更糟糕**:人们学会了重新运行它。而这正是**保护丢失增量修复的检查**。
---
## 项目布局
```
app/ 后端 — Express + PostgreSQL
├─ config/ 环境变量解析 + 启动时验证 → 单例
├─ core/
│ ├─ errors.js 应用错误层次结构
│ ├─ logger.js 级别过滤,隐藏敏感信息 → 单例
│ ├─ ApiResponse.js 统一响应信封 → 构建器
│ └─ EventBus.js 发布/订阅 → 观察者
├─ db/
│ ├─ pool.js 连接池 → 单例
│ ├─ migrate.js 迁移运行器(使用 advisory-lock)
│ └─ migrations/ 纯 SQL,按顺序应用
├─ cache/
│ ├─ InMemoryCache.js TTL + LRU
│ └─ NullCache.js → 空对象
├─ repositories/
│ ├─ BaseRepository.js 查询 + 事务管道 → 模板方法
│ ├─ UserRepository.js → 仓库
│ ├─ UrlRepository.js → 仓库
│ ├─ ClickRepository.js → 仓库
│ └─ CachedUrlRepository.js → 装饰器
├─ strategies/shortcode/ NanoId · Base62 · CustomAlias → 策略 + 工厂
├─ validation/ URL 规则链 → 责任链
├─ services/ 身份验证 · Google · URL · 分析
├─ observers/ 响应后侧效应
├─ controllers/ 仅传输
├─ routes/ HTTP 接口
├─ scripts/smoke.mjs 37 端到端检查
├─ server.js 应用工厂
└─ container.js 组合根 → 依赖注入
frontend/ Next.js 15 · TypeScript · Tailwind v4
└─ src/
├─ app/
│ ├─ page.tsx 着陆页 — 实时流水线
│ ├─ chat/ MCP 客户端助手
│ ├─ api/chat/ Groq + 工具循环 — 仅限服务端
│ ├─ dashboard/ 链接,点击量,分析数据
│ ├─ mcp/ 令牌 + 客户端配置
│ └─ auth/callback/ OAuth 返回
├─ components/
│ ├─ Pipeline.tsx 终端 — 层叠打印如流水线
│ ├─ ScrambleText.tsx 短码解密到位
│ └─ Shortener.tsx 表单 → 流水线 → 代码 → 链接
├─ context/ 身份验证提供者
└─ lib/
├─ api.ts 统一的 API 入口点 → 外观
├─ mcpClient.ts JSON-RPC 至 MCP 服务器(仅限服务端)
└─ tokenStore.ts 唯一知道 JWT 存储位置的模块
mcp-server/ TypeScript — stdio + HTTP 传输
└─ src/tools.ts 四个工具
```
---
## 许可证
MIT — 参见 [LICENSE](LICENSE)。
<div align="center">
<br />
<sub>由 <a href="https://github.com/subhm2004">Shubham Malik</a> 构建</sub>
</div>
MCP 配置
以下是此 MCP Server 的配置信息,可直接复制到 Cursor 或其他 MCP 客户端中使用。
mcp.json
连接信息
猜你喜欢
暂无推荐服务器