TRANSMISSION LOG
从博客到数字观测站:Noctiluna Observatory 的架构与工程实践
从请求渲染、内容快照到后台同步与容器发布,结合真实 TypeScript、SQL 和 Compose 代码,完整拆解夜月观测站的工程实现。
Noctiluna Observatory(夜月观测站)并不是把几个社交链接摆在一起的个人主页。它更像一套小型而完整的数字出版系统:文章和项目是长期保存的档案,GitHub、Steam 与 Nexus Mods 是持续变化的外部信号,而搜索、分享、音乐和运行状态则把这些信息连接成一个可以被阅读、被发现、也能被长期维护的空间。
这篇文章不只列出技术名词,而是从真实问题出发,解释这座观测站为什么这样设计、数据怎样流动,以及它如何在速度、可靠性、安全与维护成本之间取得平衡。
核心原则只有一句:页面首先服务阅读,数据必须来自真实来源;任何外部服务的迟缓或故障,都不应拖垮整个网站。
01 · 系统全景:一条请求如何穿过观测站
生产环境由 Caddy、Next.js 应用与 PostgreSQL 三个核心部分组成。Caddy 是唯一面向公网的入口,负责 HTTPS 与反向代理;Next.js 同时承担页面渲染和服务端接口;PostgreSQL 保存公开内容、草稿、外部数据缓存、同步状态与设置。
访客 / 搜索引擎
│ HTTPS
▼
┌──────────────┐
│ Caddy │ TLS、HTTP/2、HTTP/3、反向代理
└──────┬───────┘
▼
┌──────────────┐ 后台采集 ┌─────────────────────┐
│ Next.js Web │ ─────────────────────▶ │ GitHub / Steam / │
│ 页面 + API │ │ Nexus Mods / 网易云 │
└──────┬───────┘ └─────────────────────┘
│ 内部网络
▼
┌──────────────┐
│ PostgreSQL │ 内容快照、缓存、历史、设置、任务状态
└──────────────┘
这是一种有意保持克制的单体架构。页面、接口和数据模型在同一个 TypeScript 项目中,减少了跨服务协议、重复类型和部署单元;真正需要隔离的边界——公网入口、应用进程和数据库——则由容器网络清晰分开。
技术栈一览
| 层级 | 选型 | 在本站中的职责 |
|---|---|---|
| Web 框架 | Next.js 16 App Router、React 19 | 服务端渲染、动态路由、Metadata、Route Handlers |
| 开发语言 | TypeScript | 页面、领域模型、接口与校验共享类型 |
| 内容渲染 | React Markdown、GFM、Highlight.js | 安全正文、表格、目录、代码高亮 |
| 数据库 | PostgreSQL 17 | 内容、版本历史、缓存、设置与同步租约 |
| 编辑器 | Milkdown / ProseMirror | 富文本与 Markdown 双向写作工作台 |
| 部署 | Docker Compose、Caddy | 不可变应用镜像、内网数据库、自动 HTTPS |
| 运行环境 | Ubuntu Server 24.04 LTS | 自托管、备份、定时同步与回滚 |
02 · 展示层:把“动态”留在服务器
网站使用 Next.js App Router。首页、文章、项目、观测台、搜索和管理后台各自拥有明确路由;页面主体默认是 Server Component,只在筛选、播放器、阅读进度、复制和分享等确实需要浏览器状态的地方使用 Client Component。
这样做带来三个直接收益:
- 首次访问能够获得完整 HTML,正文无需等待客户端 JavaScript 才出现。
- 数据库凭据和第三方 API 密钥始终留在服务器。
- 浏览器下载的代码更少,内容页也更适合搜索引擎与低性能设备。
文章详情页使用动态路径 /blog/[slug]。同一份已发布数据同时生成页面正文、标题与摘要、Canonical、Open Graph、X Card,以及 BlogPosting JSON-LD。换句话说,正文和 SEO 没有两套容易漂移的配置源。
视觉上,整站延续“深夜观测站”的主题:深灰蓝背景、低饱和淡紫强调色、衬线标题和克制的玻璃质感。内容页把正文宽度限制在舒适的阅读范围,桌面端提供粘性目录与阅读进度,移动端则把目录移到正文之前。字号放大只作用于文章,不会破坏导航或播放器布局。
03 · 内容系统:草稿与公开世界之间有一道闸门
夜月观测站最初以文件中的 MDX 保存文章,后来演进为 PostgreSQL 驱动的内容工作室。现在,公开页面渲染的是安全 Markdown,而不是可执行 MDX:支持标题、列表、表格、代码、引用和受限排版,但不执行原始 HTML、脚本或任意 CSS。
数据库中的每篇内容同时保留两种状态:
| 数据 | 用途 | 何时改变 |
|---|---|---|
draft_json | 后台继续编辑的工作副本 | 手动保存或空闲自动保存 |
published_json | 访客实际看到的公开快照 | 仅在明确点击发布时替换 |
这个模型解决了一个常被低估的问题:编辑已发布文章时,半成品不应该实时泄漏到线上。保存草稿只更新工作副本;发布操作在数据库事务中原子替换公开快照;撤回只移除公开快照,草稿仍然保留。
每次写入还带有版本号。若两个标签页同时修改同一篇文章,较旧的请求会被拒绝,而不是静默覆盖新内容。数据库触发器保留最近 50 个版本;恢复历史只会载入草稿,仍需再次确认发布。
图片也遵循同样的边界。上传文件会校验真实格式与像素规模,自动旋转、压缩为 WebP 并移除元数据,然后以内容哈希命名存入 PostgreSQL。这样图片能够随数据库一同备份,也不会依赖容器里不可持续的可写目录。
04 · 信号系统:快照优先,刷新在后
观测台汇集 GitHub 贡献与仓库、Release 下载趋势、Steam 在线状态与最近游玩、Nexus Mods 作品数据,以及应用和服务器运行摘要。最危险的实现方式,是每次打开页面都同步等待这些平台返回。
早期实测中,Steam 接口的一次超时足以把首次首页响应拖到约 10 秒。因此现在所有公开页面只读取 PostgreSQL 中的最近快照:
收到页面请求
├─ 缓存新鲜 ──────▶ 立即返回
├─ 缓存过期 ──────▶ 立即返回旧数据 ─▶ 响应后刷新
└─ 尚无缓存 ──────▶ 立即返回明确空状态 ─▶ 响应后采集
这是一种“旧数据优于慢页面”的取舍。过期快照会被清楚标注,不伪装成实时结果;第一次还没有数据时,也展示诚实的空状态,而不是编造演示数字。
刷新有两条互补路径:
- 访问补偿:页面发现缓存过期后,利用 Next.js 的响应后任务启动采集。同一进程内的重复任务会按缓存键合并,失败后进入退避。
- 独立调度:服务器上的 systemd 定时器每分钟检查数据库中的到期任务。GitHub 默认 30 分钟、Steam 5 分钟、Nexus Mods 60 分钟同步一次。
定时任务、后台手动同步和访问补偿通过数据库租约协调,避免多个入口同时采集同一来源。失败会保留最后一次有效快照,并从 1 分钟开始指数退避,最长 60 分钟。外部服务可以变慢、限流甚至暂时不可用,但网站仍能继续阅读。
05 · 搜索、阅读与分享:让内容真正可用
发布并不等于可达。夜月观测站把文章、项目、固定页面和公开 Mod 统一整理为服务端搜索索引;搜索结果使用经过清理的纯文本,不执行正文标记。文章列表支持标题与摘要搜索、标签筛选,浏览器只接收列表所需的摘要,不下载全部正文。
长文阅读由 Markdown AST 驱动:渲染前提取二级、三级标题,生成稳定且去重的中文锚点,同一棵语法树的规则也用于侧边目录。代码块在服务器高亮,复制动作留给客户端;图片懒加载,并在载入后由 ResizeObserver 修正阅读进度。
站点地图、RSS、站内搜索和详情页都只查询 published_json。因此一篇文章撤回后,不会因为某个旧文件回退或另一套索引而重新出现。
分享功能同样保持单一来源。页面的标题、摘要和封面生成分享元信息,页脚面板再读取当前页面的准确地址,提供系统分享、复制链接与本地生成的二维码。二维码内容不会发送给第三方服务;没有文章封面时,也不会错误地套用首页图片。
06 · 音乐为什么是全局能力,而不是页面组件
悬浮播放器跨页面存在,因此它被放在根布局中,只维持一个音频会话。站内导航不会重新创建播放器,暂停、切歌、音量和当前曲目可以自然延续。
播放器支持站长配置的公开 HTTPS 音频,也支持网易云公开歌单的只读试接入。音源解析设有短期缓存、请求超时和并发限制;不可播放与试听状态会如实提示。浏览器若阻止有声自动播放,界面会退回到明确的点击播放入口,而不是反复重试或劫持用户操作。
这个细节体现了全站一致的设计态度:功能可以降级,但不能偷偷破坏控制权。
07 · 安全边界:默认不信任输入和外部数据
个人网站同样需要严肃的安全模型,尤其当它拥有管理后台和第三方密钥时。
- PostgreSQL 只存在于 Compose 内部网络,不映射公网端口;公网只开放 Caddy 的 80/443。
- 管理会话使用 HttpOnly Cookie,并设置有效期与登录限速;写接口校验会话、请求来源、内容类型和大小。
- GitHub Token、Steam Web API Key 等设置使用 AES-256-GCM 加密后入库;会话密钥、加密密钥和数据库密码只通过服务器环境注入。
- Markdown 不执行原始 HTML 或 JavaScript;外部链接和图片地址只接受安全协议及受限格式。
- Nexus 等上游内容先经过字段校验,远端说明中的 HTML/BBCode 不会直接注入本站。
- 全站设置
nosniff、严格来源策略、禁止被嵌入,以及摄像头、麦克风和定位权限限制。
安全不是某个中间件的名字,而是从输入、存储、渲染、网络到部署的连续边界。
08 · 自托管发布:小而可回滚
生产环境运行在 Ubuntu Server 上。构建阶段使用锁定依赖生成 Next.js standalone 产物,运行镜像只携带必要文件;应用进程以非 root 用户启动。部署由 Docker Compose 编排:数据库健康后执行增量迁移,迁移成功后启动 Web,Web 健康后 Caddy 才接入流量。
构建并验证 → 生成不可变版本 → 备份数据库 → 应用增量迁移
→ 启动 Web → 健康检查通过 → Caddy 接入 → 保留回滚点
数据库迁移只做增量修改,已应用文件不重写。应用版本也保存在独立发布目录中,回滚时可以切回上一份镜像;涉及新增调度器的版本,则先停止对应 systemd timer,再回退应用。
这套流程没有引入 Kubernetes 或复杂的云服务。对当前单实例规模而言,Docker Compose、健康检查、数据库备份和明确回滚点已经覆盖了最重要的风险,同时保留未来拆分任务队列或多副本协调的空间。
09 · 验证策略:测试系统的边界,而不只测试函数
工程质量检查分成几层:
- TypeScript 检查类型边界,ESLint 检查代码约束,生产构建验证 Next.js 的真实编译路径。
- 单元测试覆盖内容校验、Markdown 安全、搜索清理、同步退避、播放器解析、信号转换等领域逻辑。
- 隔离 PostgreSQL 与测试应用验证草稿、发布、撤回、并发冲突、RSS、Sitemap、搜索、图片持久化和鉴权。
- 慢请求与失败注入用来确认“外部接口故障不会阻塞页面”这条关键承诺。
测试不会连接生产数据库,也不会把虚构的统计数据写进正式缓存。对观测站而言,真实性本身就是需要被验证的产品能力。
10 · 设计取舍与下一步
当前架构主动接受了一些限制:它是单站长、单应用实例;PostgreSQL 是核心依赖;信号刷新不是跨区域分布式队列;内容协作没有多人权限模型。这些不是遗漏,而是与当前规模匹配的边界。
只有当实际压力出现时,系统才需要继续演进:例如多实例部署时把进程内任务合并升级为完全基于数据库的协调,数据量增长后拆分搜索索引,或把高耗时采集移到独立 Worker。每一步都应由真实瓶颈触发,而不是为了让架构图看起来更复杂。
Noctiluna Observatory 最重要的技术成果,也许并不是用了哪一个框架,而是把一组朴素原则落实到了每一层:公开快照与草稿分离,缓存与真实状态分离,阅读路径与外部请求分离,密钥与浏览器分离,发布与回滚彼此相邻。
一座好的数字观测站,不需要假装永远实时、永远在线。它只需要在变化发生时准确记录,在故障出现时诚实降级,并让那些值得留下的内容,很多年后依然可以被安静地读到。
11 · 工程附录:代码如何真正落地
前面的章节解释了系统为什么这样设计,这一部分继续向下走到实现层。以下示例都来自夜月观测站当前使用的代码路径,并省略与主题无关的样板,以便看清每一层如何衔接。
11.1 从 URL 到文章:Server Component 请求链
文章地址采用 /blog/[slug] 动态路由。在 Next.js 16 中,params 是 Promise,因此页面和元数据函数都显式等待参数。查找不到公开内容时直接进入 404 边界。
export const dynamic = "force-dynamic";
async function find(slug: string) {
return (await getPublishedContent()).find(
item => item.kind === "post" && item.slug === slug
);
}
export async function generateMetadata({ params }: PageProps<"/blog/[slug]">) {
const item = await find((await params).slug);
return item ? contentMetadata(item) : {};
}
export default async function Page({ params }: PageProps<"/blog/[slug]">) {
const item = await find((await params).);
(!item) ();
;
}
这里有两个刻意的决定。第一,文章不在浏览器中请求 API 后再拼装,而是在服务器完成查询与首屏 HTML;第二,generateMetadata 与正文复用同一个查询函数,使标题、摘要、Canonical 和正文来自同一份公开快照。
一次访问的实际链路如下:
GET /blog/engineering-noctiluna-observatory
→ App Router 解析 slug
→ Server Component 查询 published_json
→ Zod 校验数据库内容
→ Markdown AST 转换为 React 节点
→ 同步生成目录、代码高亮与 JSON-LD
→ Caddy 将完整 HTML 返回访客
11.2 公开内容查询:数据库结果也必须校验
getPublishedContent 只选择 published_json is not null 的记录。数据库返回的 JSONB 不会被直接断言为可信类型,而是再次经过 contentDraft.parse 校验。
export const getPublishedContent = cache(async (): Promise<PublishedContent[]> => {
const rows = await getDatabase()`
select id, kind, slug, published_json, published_at
from content_entries
where published_json is not null
order by published_json->>'date' desc, slug asc
`;
return rows.map(row => ({
...contentDraft.parse(row.published_json),
id: row.id,
kind: row.kind,
slug: row.slug,
publishedAt: row.published_at.toISOString(),
}));
});
这里的 cache 是 React 单次服务端渲染范围内的请求合并,不是长期缓存。文章页面在生成 Metadata、正文和前后篇导航时可能多次需要公开内容,React 会在同一次渲染中复用结果;下一次请求仍读取数据库,因此后台发布后无需重新构建网站。
11.3 发布操作:一条带版本条件的原子更新
保存和发布都不是“先读取版本、再无条件写入”。更新语句把内容 ID、类型、路径和当前版本全部放进 where 条件,并在同一条 SQL 中递增版本:
const rows = await sql<Row[]>`
update content_entries set
draft_json = case
when ${input.action} = 'withdraw' then draft_json
else ${sql.json(input.draft)}
end,
published_json = case
when ${input.action} = 'publish' then ${sql.json(input.draft)}
when ${input.action} = 'withdraw' then null
else published_json
end,
published_at = case
when ${input.action} = 'publish' then now()
when ${input.action} = 'withdraw' then null
else published_at
end,
version = version + 1,
published_version = case
when ${input.action} = 'publish' then version + 1
when ${input.action} = 'withdraw' then null
else published_version
end
where id = ${input.id}
and version = ${input.version}
and kind = ${input.kind}
and slug = ${input.slug}
returning *
`;
if (!rows[0]) throw new ContentConflict("内容已在其他窗口更新");
如果两个编辑窗口都以版本 7 提交,只有第一个请求能匹配 version = 7;它写入后版本变成 8,第二个请求得到零行并返回 HTTP 409。这个乐观并发模型不需要长时间锁住文章,却能避免静默覆盖。
发布时,draft_json 和 published_json 在同一条语句里更新,因此不存在正文已经发布、元信息仍是旧值的中间状态。撤回则只清空公开字段,不删除草稿。
11.4 API 边界:认证、来源、体积与结构四层校验
管理接口在进入数据库之前依次验证管理员会话、请求来源、媒体类型、正文体积和 Zod 数据结构:
if (
!await verifyAdminSession(request.cookies.get(ADMIN_COOKIE)?.value) ||
!hasTrustedOrigin(request)
) return reply({ error: "登录失效或请求来源不符" }, 403);
if (!request.headers.get("content-type")?.includes("application/json"))
return reply({ error: "请发送 JSON 内容" }, 415);
const raw = await boundedBody(request, 450_000);
const parsed = contentMutation.safeParse(JSON.parse(raw.toString("utf8")));
if (!parsed.success)
return reply({ error: parsed.error.issues[0]?.message }, 400);
仅检查前端表单是不够的,因为请求可以绕过页面直接发送。服务端约束标题、路径、日期、标签数量、正文长度和 URL 协议;请求体读取也有硬上限,避免攻击者省略 Content-Length 后持续占用内存。
11.5 Markdown 渲染:可表达,但不可执行
公开文章使用 react-markdown。GFM 提供表格等常用语法,目录插件为标题生成稳定锚点,Highlight.js 在服务器完成代码高亮;skipHtml 明确阻止原始 HTML 执行。
<Markdown
remarkPlugins={[
remarkGfm,
remarkDirective,
remarkTextStyle,
remarkReadingHeadings,
]}
rehypePlugins={[[rehypeHighlight, { detect: false }]]}
skipHtml
components={{
pre: ({ node, children }) => (
<CodeBlock text={nodeText(node)}>{children}</CodeBlock>
),
a: ({ href, children }) => (
<a href={href} rel="noopener noreferrer">{children}</a>
),
img: ({ src, alt }) => typeof src === "string" && src
? <img src={src} alt={alt ?? ""} loading="lazy" decoding="async" />
: null,
}}
>
{body}
</Markdown>
局部字体和字号也不是 HTML style。编辑器把它们保存为受限文本指令,例如:
:text-style[这段文字使用等宽字体]{font=mono size=18}
渲染插件只接受三种字体和 14–24 px 的固定档位,再映射到预定义 CSS 类。任意字体名、任意数值、style 属性和脚本都无法进入输出。
11.6 标题锚点:Markdown AST 是唯一依据
目录不是通过浏览器加载后扫描 DOM 临时生成的。服务端先遍历 Markdown AST,对标题文本执行 Unicode 规范化、字符清理与碰撞去重:
const base = "section-" + text
.normalize("NFKC")
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, "-")
.replace(/^-|-$/g, "")
.slice(0, 100);
let id = base || "section-heading";
let index = 2;
while (used.has(id)) id = `${base}-${index++}`;
因此“中文标题”可以生成 section-中文标题,第二个同名标题会得到 section-中文标题-2。代码块中的 ## 不会被误认为目录项,目录链接与正文 ID 也使用同一套规则。
11.7 外部信号:先返回快照,再调度刷新
页面读取 GitHub、Steam 或 Nexus Mods 时,核心返回值不是简单的“有或没有”,而是区分新鲜、过期、首次缺失和刷新中的状态:
export function snapshotResult(cached, empty, refreshing) {
if (cached?.fresh) return cached.payload;
return {
...(cached?.payload ?? empty),
stale: Boolean(cached),
refreshing,
message: refreshing
? cached
? "正在显示最近缓存,数据在后台更新"
: "首次数据正在后台采集,请稍候"
: cached
? "暂时无法更新,继续显示最近缓存"
: "数据暂时无法获取,请稍后重试",
};
}
进程内调度器在入队之前就登记任务,避免两个同时到达的页面请求重复刷新同一个缓存键。运行中的任务不会被淘汰,失败后至少冷却 60 秒,登记表最多保留 64 项,防止异常键无限占用内存。
const existing = this.jobs.get(key);
if (existing?.running) return true;
if (existing && existing.retryAt > this.clock()) return false;
const state = { running: true, retryAt: 0 };
this.jobs.set(key, state);
enqueue(async () => {
try {
await work();
state.retryAt = this.clock() + 2_000;
} catch {
state.retryAt = this.clock() + 60_000;
} finally {
state.running = false;
}
});
独立调度器的失败退避是纯函数,便于直接测试:
export const syncIntervals = {
github: 1800,
steam: 300,
nexusmods: 3600,
} as const;
export function retrySeconds(failures: number) {
return Math.min(
3600,
60 * 2 ** Math.min(Math.max(0, failures - 1), 6)
);
}
连续失败时等待时间为 1、2、4、8、16、32、60 分钟。成功时间与尝试时间分开保存,所以一次失败不会抹去“上次完整成功”的事实。
11.8 管理会话与密钥:密码验证不等于数据加密
管理员密码使用 scrypt 派生并通过 timingSafeEqual 比较。登录成功后签发有效期 8 小时、带固定签发者和角色声明的 HS256 会话:
return new SignJWT({ role: "owner" })
.setProtectedHeader({ alg: "HS256" })
.setSubject("admin")
.setIssuer("noctiluna-observatory")
.setIssuedAt()
.setExpirationTime("8h")
.sign(sessionKey());
第三方 API Token 需要在后台保存,不能只做哈希,因为采集时还要取回原值。它们使用独立的 32 字节密钥和 AES-256-GCM 加密,每次生成新的 12 字节 IV,并保存认证标签:
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", getEncryptionKey(), iv);
const ciphertext = Buffer.concat([
cipher.update(value, "utf8"),
cipher.final(),
]);
return {
ciphertext: ciphertext.toString("base64"),
iv: iv.toString("base64"),
tag: cipher.getAuthTag().toString("base64"),
};
会话密钥和设置加密密钥彼此独立,并且都不提交到仓库。数据库泄露不会直接暴露明文 Token;应用配置泄露也不会包含数据库备份本身。
11.9 容器边界:只有反向代理面对公网
Compose 网络只让 Caddy 暴露端口。PostgreSQL 没有 ports 配置,Web 也只通过内部服务名访问数据库:
services:
postgres:
image: postgres:17-alpine
volumes:
- postgres_data:/var/lib/postgresql/data
networks: [internal]
web:
image: noctiluna-web:${DEPLOY_VERSION}
depends_on:
migrate:
condition: service_completed_successfully
networks: [internal]
caddy:
image: caddy:2.10-alpine
ports:
- "80:80"
- "443:443"
- "443:443/udp"
networks: [internal]
应用使用 Next.js standalone 输出构建镜像,并以非 root 用户运行。Windows 本地生成的 standalone 包在服务器镜像中补入 Linux/musl 版本的 Sharp 原生依赖,保证图片处理不会因构建平台不同而失效。
11.10 一个功能如何贯穿全栈:以“发布文章”为例
“发布”按钮背后横跨了多个层级:
| 阶段 | 代码职责 | 失败时的结果 |
|---|---|---|
| 编辑器 | 把富文本序列化为 Markdown 草稿 | 保留当前输入,不发送不完整状态 |
| Route Handler | 校验会话、Origin、体积和 Zod Schema | 返回 4xx,不访问数据库 |
| 内容仓库 | 使用版本条件原子更新两个 JSONB 快照 | 冲突返回 409,不覆盖新版本 |
| PostgreSQL 触发器 | 写入历史版本并只保留最近 50 条 | 与正文写入处于同一事务 |
| 文章页面 | 下次请求读取 published_json | 无需重建或重启应用 |
| 搜索 / RSS / Sitemap | 查询同一公开快照 | 与文章可见性保持一致 |
| SEO | 从公开标题、摘要、封面生成 Metadata | 不维护第二套易漂移配置 |
这也是“代码应用”的真正含义:不是孤立展示一个组件,而是让一次业务动作从浏览器到数据库,再回到搜索与分享入口,全程共享同一组状态与边界。
11.11 项目目录与职责
app/
blog/[slug]/page.tsx 动态文章路由与 Metadata
api/admin/content/route.ts 内容写接口
api/internal/sync/route.ts 独立调度入口
components/
content/ 编辑、渲染、目录与阅读控件
observatory/ 信号卡片、图表与运行状态
lib/
content-store.ts 内容查询和原子发布
content-schema.ts Zod 领域约束
background-refresh.ts 访问后的任务合并
sync-policy.ts 周期、退避与结果分类
settings-crypto.ts 第三方密钥加解密
migrations/ 只增不改的数据库迁移
scripts/ 迁移、同步与隔离集成测试
deploy/ 生产镜像、Caddy 与 systemd 单元
目录按业务边界而不是文件类型堆叠:路由负责 HTTP,组件负责呈现,lib 负责可测试的领域逻辑,迁移负责数据库演进,部署目录负责运行环境。要定位一个问题时,可以沿着“入口 → 领域逻辑 → 数据 → 运维”的方向快速缩小范围。
11.12 怎样安全地增加一种新信号
如果未来要增加新的公开平台,完整实现至少包含六步:
- 在转换层定义白名单字段,把上游响应转换为本站稳定模型。
- 为分页、数值边界、空结果和恶意链接编写单元测试。
- 设计 PostgreSQL 缓存键与新鲜期,不让页面直接依赖上游结构。
- 实现带总超时的刷新函数,并接入数据库租约和失败退避。
- 页面只读取快照,同时呈现 fresh、stale、cold 和 unavailable 状态。
- 在隔离环境注入慢请求与错误,验证导航仍能及时完成。
只有这六步都成立,一个第三方接口才真正成为“观测信号”,而不是埋在页面请求里的不稳定依赖。
12 · 从原则回到代码
夜月观测站的实现没有依赖某个神奇抽象。它把复杂度拆成了可以验证的小边界:Zod 管输入,条件更新管并发,JSONB 快照管发布,AST 管内容安全,数据库租约管任务竞争,健康检查和备份管上线风险。
这些代码最终服务的仍然是同一件事:访客打开页面时,先看到可以信任的内容;站长修改系统时,始终拥有保存、发布、撤回和回滚的余地。