白物集完整架构复盘:从零到上线一个内容网站
一个内容网站从零到上线,涉及多少层?白物集目前的架构经历了三轮迭代,从纯静态 .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 表,字段包括 id、title、content_md、content_html、abstract、category、tags(PostgreSQL array)、status、published_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 引用)。
一路踩过的坑
回顾整个开发过程,最耗时的不是写功能代码,而是修这些小问题:
content_html没传 → 页面显示「内容加载中」- 首行
# 标题没删除 → 页面双 H1 b.id - a.id排序 → 新文章永远不出现- Zod v4
.errors不存在 → 500 错误 - PATCH 用错本地文件旧版本 → 回退到错误内容
- SSR 缓存未清 → 文章 PATCH 了但页面几个小时不变
每一个问题都有具体修复方案。下一篇文章会把它们整理成一份可直接用的「白物集维护检查清单」。从搭建到日常运维,一张表查完所有容易漏的东西。
下一篇:从零到全栈:一条完整的网站开发学习路线图——这条系列的最后一张地图,告诉你从今天开始,该学什么、按什么顺序学、白物集的每篇文章对应学习路线上的哪个节点。