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

白物集完整架构复盘:从零到上线一个内容网站

📅 2026-07-22

一个内容网站从零到上线,涉及多少层?白物集目前的架构经历了三轮迭代,从纯静态 .md 文件到完整的四层分离架构。这篇文章把每一层拆开来看,不藏私。

架构全景

白物集当前架构分为四层:

管线层 → API 层(Express + Supabase) → 前端层(Astro SSR) → 多通道分发(网站/小程序/知乎)

管线层负责内容生产(AI 写稿、热点采集),API 层负责内容存储和读取,前端层负责 SSR 渲染,多通道分发把同一份内容推到不同平台。

管线层:内容怎么进去的

管线层是自动化写稿系统。以建站系列教程为例,cron 在 09:30 触发,读取排期表找到下一篇 scheduled 文章,调用模型写稿后 POST 到 API。

入口路由在 api/src/routes/ingest.js,核心是这篇代码:

router.post('/', requireIngestAuth, async (req, res) => {
  const parsed = articleCreateSchema.safeParse(req.body);
  if (!parsed.success) return zodError(res, parsed.error);

  const { title, content_md, content_html, abstract, category, tags, source, author, status } = parsed.data;

  const { data, error } = await supabase
    .from('articles')
    .insert({ title, content_md, content_html, abstract, category, tags, source, author, status,
      published_at: status === 'published' ? new Date().toISOString() : null,
      pub_website: status === 'published',
    })
    .select('id, title, category, status, published_at')
    .single();

  if (error) throw error;
  invalidateArticles();
  res.status(201).json({ article: data });
});

几个关键的踩坑点:

第一,content_html 必须一起传。Astro 页面模板里有一个 fallback:article.content_html || '<p>内容加载中...</p>'。如果只传了 content_md 没传 content_html,页面会直接显示「内容加载中」。修复方法是写稿时用 Python markdown 库先转一遍 HTML,同时去掉 markdown 首行的 # 标题(避免页面双 H1)。

第二,Zod v4 有一个坑。它的 ZodError 只有 .issues 属性,没有 .errors。如果 errors.js 里写了 zodErr.errors.map(),API 会抛 TypeError: Cannot read properties of undefined (reading 'map')。ETL 上的修复是改成 (zodErr.issues || []).map()

API 层:19 个路由的分层设计

Express API 用 Router() 拆了 9 个模块,挂载在 /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('/garage', apiLimiter, garageRouter);
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);
app.use('/api/v1', v1);

每类接口都配了独立的 rate limiter。写入接口(ingest)限流更严格(15 分钟 20 次),读取接口宽松一些(15 分钟 100 次)。Middleware 还做了一件事——每次请求都打日志,记录 method、path、status code 和耗时:

app.use((req, res, next) => {
  const start = Date.now();
  res.on('finish', () => {
    logger.info({
      method: req.method, path: req.originalUrl || req.url,
      status: res.statusCode, durationMs: Date.now() - start,
    }, 'request');
  });
  next();
});

每一层都有缓存。API 层用 node-cache,前端层用进程内 Map。API 的文章列表缓存 TTL 是 300 秒,详情缓存是 120 秒。前端层 SSR 列表缓存 60 秒(比 API 短,保证写入后快速可见)。写入新文章时调 invalidateArticles() 清空两层的缓存。

前端层:Astro SSR 的页面结构

前端基于 Astro SSR + @astrojs/node。共有四类页面:

首页src/pages/index.astro)展示 AI、Apple、科技三个频道的文章各 3 篇,再加早报 2 篇。按 published_at 降序排列。

频道页/ai/apple/tech)展示每个分类的全部文章,支持搜索和标签筛选。

文章详情页src/pages/articles/[id].astro)最复杂——包了面包屑导航、H1 标题、正文渲染、上下篇导航、交叉推荐、分享按钮和关联知识卡片。

交叉推荐机制用了标签匹配打分:每共享一个标签 +2 分,跨类别 +1 分,近期文章 +0.5 分,取 Top 3:

const crossArticles = published
  .filter(a => a.id !== id)
  .map(a => {
    let score = 0;
    const shared = (a.tags || []).filter(t => articleTags.has(t)).length;
    score += shared * 2;
    if (isAiArticle && isAppleCategory(a.category)) score += 1;
    if (isAppleArticle && AI_CATEGORIES.includes(a.category)) score += 1;
    score += 0.5;
    return { a, score };
  })
  .sort((x, y) => y.score - x.score)
  .slice(0, 3)
  .map(item => item.a);

全部文章页src/pages/articles/[...page].astro)支持分页、搜索、标签筛选和三种排序(最新/最热/推荐)。前端完全用客户端 JavaScript 实现动态搜索,搜索结果通过 /api/search 走服务端全文检索。

数据层:Supabase

数据库用 Supabase(PostgreSQL 即服务)。定义了一个 articles 表,字段包括 idtitlecontent_mdcontent_htmlabstractcategorytags(PostgreSQL array)、statuspublished_at 等。文章列表 API 只读 id, title, slug, abstract, category, tags, status, published_at 等摘要字段,详情 API 才读全字段。

API 通过 @supabase/supabase-js 客户端直连。初始化很简单:

import { createClient } from '@supabase/supabase-js';
const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_ANON_KEY);

SSR 缓存策略:双层级缓存

这是从纯 .md 迁移到 API 后端时踩过最深的坑。

最初网站直接读 .md 文件构建静态页面。迁移到 API 后端后,SSR 每请求都调用 API,响应时间从 <10ms 涨到 50-80ms。解决方案是前端加进程内缓存,TTL 错开——前端先过期,从后端回源:

SSR list TTL  = 60s  <  API list TTL  = 300s
SSR detail TTL = 120s  =  API detail TTL = 120s

这样写入新文章后,前端缓存 60 秒内过期,回源 API 拿最新数据,API 缓存 300 秒才过期。前后端 TTL 差异保证用户最晚 60 秒看到新文章。

const cache = new Map();
const TTL = { list: 60_000, detail: 120_000, categories: 300_000 };

function cached(key, ttl, fetcher) {
  const now = Date.now();
  const entry = cache.get(key);
  if (entry && (now - entry.time) < ttl) return entry.data;
  return fetcher().then(data => { cache.set(key, { data, time: now }); return data; });
}

排序陷阱:为什么新文章不显示在首页

迁移后遇到一个典型问题:新通过 API 写入的文章 ID=40,之前迁移的旧 .md 文章 ID=100~144。首页按 b.id - a.id 降序取前 3 篇,ID 更大的旧文章始终占据首页,新文章永远排不上。

修复:全文搜 b.id - a.id,全部改为 new Date(b.published_at) - new Date(a.published_at)。改完重新构建 SSR 才生效。

安全敏感信息保护

API 的 .env 里配置了 Supabase URL、Anon Key、INGEST_API_KEY。前端 SSR api.js 里的 BASE 写的是 http://127.0.0.1:3001(仅内部网络可达),不对外暴露。前端域名 baiwuji.top,API 域名 ai.golfr20.cn,两者分开。

多通道分发

内容入库后,网站(Astro SSR)直接读 API 渲染。微信小程序通过 wx.request 也调同样的 API,但在首页做了标签过滤——只显示带教程标签的内容,过滤掉新闻类文章以降低审核风险。知乎专栏则从 API 拉取内容后改写成适合知乎的格式(加引言和结语,去掉内部 API 引用)。

一路踩过的坑

回顾整个开发过程,最耗时的不是写功能代码,而是修这些小问题:

  1. content_html 没传 → 页面显示「内容加载中」
  2. 首行 # 标题 没删除 → 页面双 H1
  3. b.id - a.id 排序 → 新文章永远不出现
  4. Zod v4 .errors 不存在 → 500 错误
  5. PATCH 用错本地文件旧版本 → 回退到错误内容
  6. SSR 缓存未清 → 文章 PATCH 了但页面几个小时不变

每一个问题都有具体修复方案。下一篇文章会把它们整理成一份可直接用的「白物集维护检查清单」。从搭建到日常运维,一张表查完所有容易漏的东西。

下一篇:从零到全栈:一条完整的网站开发学习路线图——这条系列的最后一张地图,告诉你从今天开始,该学什么、按什么顺序学、白物集的每篇文章对应学习路线上的哪个节点。

← 37K Star PostHog:自建用户行为分析首选 → Apple 频道 gzh-design-skill:公众号排版,永不掉格式 →
🍎 Apple 深度分析
本文基于 Apple 公开资料及行业分析撰写。观点仅供参考与学习交流。