科技前沿 科普Web 基础教程建站系列

白物集 API 架构拆解:分层设计背后的工程取舍

📅 2026-07-17

一个内容网站的后端 API 长什么样?白物集线上跑了几个月,API 从最初的几个路由膨胀到了二十多个端点。这篇文章直接拆开 api/src/ 目录,看看一个中型 Express 项目的分层设计是怎么组织的。

三个目录,三层职责

白物集 API 的源代码结构很简单:

api/src/
├── index.js           # 入口:中间件链 + 路由挂载
├── supabase.js        # 数据库客户端
├── routes/            # 路由层(9 个路由模块)
│   ├── health.js
│   ├── articles.js
│   ├── ingest.js
│   ├── cards.js
│   ├── views.js
│   ├── likes.js
│   ├── stats.js
│   ├── tasks.js
│   ├── garage.js
│   └── agent-memory.js
└── lib/               # 服务层
    ├── validation.js  # Zod 校验规则
    ├── errors.js      # 统一错误响应
    ├── cache.js       # NodeCache 封装
    ├── logger.js      # Pino 日志
    └── openapi.js     # OpenAPI 文档生成

三层各司其职:

  • 路由层(routes/):处理 HTTP 请求/响应,不做业务逻辑
  • 服务层(lib/):校验、缓存、日志、错误处理
  • 数据层(supabase.js):封装 Supabase 客户端,是整个 API 唯一的数据源

中间件链:请求从进来到出去

入口文件 index.js 的中间件挂载顺序决定了每个请求的完整生命周期:

// index.js 的中间件链
app.use((req, res, next) => {
  // 1. 请求日志(方法、路径、状态码、耗时)
  const start = Date.now();
  res.on('finish', () => {
    logger.info({ method: req.method, path: req.originalUrl,
      status: res.statusCode, durationMs: Date.now() - start });
  });
  next();
});

// 2. CORS
app.use(cors());
// 3. JSON 解析(限制 10MB)
app.use(express.json({ limit: '10mb' }));

CORS 放在最前,确保跨域请求不因其他中间件拦截。JSON 解析限 10MB 是因为 content_md/content_html 字段可能很大——早报正文加 HTML 转换后经常上百 KB。

然后是限流。白物集对普通 API 和内容入库 API 用了不同的限流策略:

const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 分钟窗口
  max: 100,                    // 普通 API 最多 100 次
  handler: (req, res) => rateLimited(res, '...')
});

const ingestLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 20,                     // 入库 API 更严格:20 次
});

一个经验:内容入库(ingest)限流要比普通 API 更严,因为 pipeline 自动化写稿可能出错导致频繁 POST。15 分钟 20 次足够 cron 写稿用,也不至于被误触打满。

路由挂载:版本化前缀

所有路由挂在 /api/v1 下:

const v1 = express.Router();
v1.use('/articles', apiLimiter, articlesRouter);
v1.use('/content/ingest', ingestLimiter, ingestRouter);
v1.use('/knowledge-cards', apiLimiter, cardsRouter);
v1.use('/health', healthRouter);
v1.use('/views', apiLimiter, viewsRouter);
v1.use('/likes', apiLimiter, likesRouter);
v1.use('/stats', apiLimiter, statsRouter);
v1.use('/tasks', apiLimiter, tasksRouter);
v1.use('/agent-memory', apiLimiter, agentMemoryRouter);
v1.use('/garage', apiLimiter, garageRouter);
app.use('/api/v1', v1);

几个设计上的取舍:

限流在路由挂载层而非路由模块内。 这样新增路由时不用重复写限流配置,也避免遗漏。只有 health 不需要限流——健康检查本身不应该被限流干扰。

ingest 路由用独立的限流器。 因为 POST /content/ingest 是 pipeline 写稿的唯一入口,频率低但 payload 大,和前端列表请求是两种负载特征。

一个反例: 早期版本把所有限流写死在每个路由模块里,结果新增 agent-memory 路由时忘了加限流,被 hot-article-pipeline 的代理请求打到 Supabase 连接池耗尽。后来移到公共挂载层,这个坑才填上。

路由模块内部:一个统一的模式

每个路由模块遵循相同的结构。以 articles.js 为例:

import { Router } from 'express';
const router = Router();

// 认证中间件(写操作专用)
function requireIngestAuth(req, res, next) {
  const token = req.headers['x-api-key'];
  if (!token || token !== (process.env.INGEST_API_KEY || '')) {
    return unauthorized(res, 'Invalid or missing API key');
  }
  next();
}

// 公共模式:校验 → 缓存查询 → 数据库查询 → 响应
router.get('/', async (req, res) => {
  const parsed = articleQuerySchema.safeParse(req.query);
  if (!parsed.success) return zodError(res, parsed.error);

  const data = await getOrSet('articles', cacheKey, async () => {
    // ... Supabase 查询
  }, TTL.ARTICLE_LIST);

  res.json(data);
});

认证中间件不全局挂载,而是只在需要写操作的路由(PATCH、DELETE、POST)单独引用。这样列表页的 GET 请求不需要走认证检查,减少不必要的计算。

校验层:Zod 落地经验

白物集用 Zod v4 做入参校验。每个路由模块定义自己的 schema:

// validation.js — 集中的模式定义
export const articleQuerySchema = z.object({
  category: z.enum(ALL_CATEGORIES).optional(),
  status: z.enum(['published', 'draft', 'archived']).optional().default('published'),
  search: z.string().max(200).optional(),
  tag: z.string().max(100).optional(),
  limit: z.coerce.number().int().min(1).max(200).optional().default(20),
  offset: z.coerce.number().int().min(0).optional().default(0),
  sort: z.enum(['published_at', 'created_at', 'title', 'id']).optional().default('published_at'),
});

几个关键设计:

  1. z.coerce.number() 处理 query string。 Express 的 req.query 所有字段都是字符串,直接 z.number() 会校验失败。z.coerce.number() 自动转类型。

  2. 所有枚举值集中在 ALL_CATEGORIES 前端 constants.ts 和后端 validation.js 维护同一份分类列表,两边不同步会导致「API 返回的数据在前端不显示」。

  3. articleCreateSchema 的 content_html 设为 optional().default('') 这样即使 pipeline 忘了传 content_html,API 也不会报 500——虽然页面会显示「内容加载中」,但至少数据写进去了,之后可以用 PATCH 补救。

  4. 给 status 设置默认值 'published',避免 pipeline 忘记传 status 时文章变成 draft。

缓存层:最简单的 NodeCache

白物集没有上 Redis。对于单机 Express 服务,NodeCache 够了:

const cache = new NodeCache({
  stdTTL: 300,       // 默认 5 分钟
  checkperiod: 60,   // 每 60 秒清理过期键
  useClones: false,  // 返回引用而非深拷贝
});

export async function getOrSet(keyPrefix, params, fetchFn, ttl) {
  const key = cacheKey(keyPrefix, params);
  const cached = cache.get(key);
  if (cached !== undefined) return cached;

  const data = await fetchFn();
  cache.set(key, data, ttl);
  return data;
}

不同数据用不同的 TTL:

数据类型 TTL 原因
文章列表 5 分钟 首页/频道页更新频率低
文章详情 2 分钟 新文章发布后需要较快可见
分类统计 10 分钟 几乎不变
知识卡片 5 分钟 采集频率低

写入操作时主动失效缓存:

export function invalidateArticles() {
  invalidate('articles');
  invalidate('categories');
}

注意分类缓存也要失效——新增一篇文章后,分类下的文章计数变了。

错误响应:统一的格式

所有错误响应走同一个格式:

{
  "error": {
    "code": "NOT_FOUND",
    "message": "Article #42 not found"
  }
}

错误码枚举在 errors.js 中集中管理:

const ERROR_CODES = {
  VALIDATION_ERROR: 'VALIDATION_ERROR',
  NOT_FOUND: 'NOT_FOUND',
  UNAUTHORIZED: 'UNAUTHORIZED',
  RATE_LIMITED: 'RATE_LIMITED',
  INTERNAL_ERROR: 'INTERNAL_ERROR',
  BAD_REQUEST: 'BAD_REQUEST',
};

全局错误处理器兜底所有未捕获异常:

app.use((err, req, res, next) => {
  const log = logger.child({ method: req.method, path: req.originalUrl });
  internalError(res, err, log);
});

这个兜底极度重要。没有它,任何路由中未 catch 的 throw error 都会导致 Express 挂起请求(客户端收到 304 或超时)。白物集踩过一次:Zod v4 升级后 zodError() 函数用 zodErr.errors.map() 取校验失败信息,但 Zod v4 只有 .issues 没有 .errors,导致校验失败时抛出 TypeError: Cannot read properties of undefined (reading 'map')——如果没有全局错误处理器,客户端只会收到一个空响应,排查几乎不可能。

代理透传:garage 模块的特殊设计

garage.js 是一个特殊的路由——它透传到另一个 Supabase 实例(车研库):

// 白名单表
const ALLOWED_TABLES = [
  'articles', 'models', 'brands', 'diagnostic_codes',
  'parts', 'specifications', 'maintenance_schedules',
  'repair_guides', 'knowledge_base',
];

router.get('/rest/v1/*', async (req, res) => {
  const tableName = req.params[0].split('?')[0].split('/')[0];
  if (!ALLOWED_TABLES.includes(tableName)) {
    return unauthorized(res, `Table '${tableName}' is not accessible`);
  }
  // ... fetch from remote Supabase
});

// 阻止所有非 GET 请求
router.all('/rest/v1/*', (req, res) => {
  return unauthorized(res, 'Only GET requests are allowed');
});

这层代理有两个作用:限制可访问的表(白名单而不是黑名单),和限制操作为只读(非 GET 直接拒绝)。这样即使前端被 XSS 拿到 API key,也无法通过这个代理删改车研库的数据。

总结

白物集 API 的分层设计可以用一句话概括:路由管 HTTP,lib 管逻辑,supabase 管数据。 每个模块只做一件事,中间件的挂载顺序决定了请求的完整流程。

这种设计的价值不在理论完美,而在实际迭代中——当你要加一个新的路由时(比如 agent-memory),照着已有的路由模块复制粘贴,改改 schema 和 Supabase 查询,十分钟就能上线。这才是「架构」对独立开发者的真正意义。


下一篇预告:前端怎么调后端接口?白物集 Astro 页面如何通过 SSR API 客户端获取数据、实现列表分页和文章详情。我们直接从 src/lib/api.js 开始拆。

← uv——比 pip 快 10 倍的 Python 包管理器 → Apple 频道 n8n:400 个集成节点秒杀脚本胶水,可视化工作流引擎实战 →
🍎 Apple 深度分析
本文基于 Apple 公开资料及行业分析撰写。观点仅供参考与学习交流。