Oolaf 拆成 mono(后台管理/API)和 stereo(前台 SSR)。核心仍是四类内容(长文、动态、知识库、Vault),门控按 clientOptions 按人开通。Client API 后来也给 formant 用,书签 / New Tab / 手势按 Key 隔离。还是靠数据模型边界、RBAC 和职责分离撑这套 CMS。

我最初只想写博客。

后来多了几类完全不同的东西:排查半天才搞定的技术记录、随手发的短动态、偶尔想分享的曲库、以及只给少数人看的私密笔记。把它们全塞进一张 posts 表,大概三个月后,检索、权限、展示形态都乱了——长文被当成动态刷过去,知识库条目和博客文章混在同一个列表里,私密内容还要额外打补丁。

Oolaf 就是在这个节点上拆出来的:mono 管生产和权限,stereo 管阅读和体验。不是「又一个 WordPress」,而是一套我能完全掌控、边界清晰的内容基础设施。后面又叠了图床、传文件、蓝奏、影视,再后来直播、书签、速记、热榜也挂进来了。边界没变:能写的进 mono,能看的进 stereo,能不能看由 clientOptions 说了算。


平台到底在解决什么

Oolaf 分两层:

  • 后台 + API:仓库 oolaf-mono。Admin 3100,API 3101,弹幕上游 3103(本机)。负责内容录入、审核、RBAC、加密存储、AI 辅助、对外 Web/Client API。

  • 公开站点:仓库 oolaf-stereo,端口 3102。Astro SSR 渲染、客户端登录、只读消费;重交互用 Vue islands。

三个硬约束:

  1. 内容类型不能混写。 长文、短动态、结构化知识、私密笔记各有数据模型,权限边界也不同。

  2. 后台和前台必须解耦。 后台用 Vue + Arco 做管理;前台用 Astro SSR 做 SEO 和首屏;通过 /api/v1/web/* 通信,可以独立部署、独立发版。

  3. 读者不是管理员。 客户端用户(ClientUser)和后台用户是两套账号体系。知识库、音乐、Vault、图床、传文件、蓝奏、影视、直播、书签、速记这些能力通过 clientOptions 按人开通;热榜谁都能刷,一键发现另开 hotlist-discover。不是登录就能看全部。


四块内容,四条边界

我在设计阶段就写死了「是什么 / 不是什么」,避免模块膨胀:

  • Article:长文、可外链、可置顶;可归入专题(article-collections),可挂蓝奏附件。不是知识库条目。

  • Post:短动态、多媒体、偏社交流;可见性分公开 / 受限。不是文章。

  • Knowledge:已验证的技术方案,结构化字段 + Markdown。不是博客,客户端不能编辑。

  • Vault:AES 加密私密笔记,mono 写入、stereo 只读;可挂私密图和文件引用。不进搜索索引,不给 CDN 直链。

知识库第一版明确不做富文本、协同编辑、版本历史——目标是快速记录已验证方案,不是再做一套 Notion。这个决定直接影响了编辑器选型:我选了 textarea + 实时预览,而不是复用文章模块的 Tiptap 富文本。Markdown 保持纯文本,迁移和搜索都简单;Tiptap 会引入 HTML、扩展节点和安全成本,对「碎碎念式记录」是过度设计。

Vault 则走了相反的路:stereo 第一版曾带写入能力,后来全部收回 mono。不是功能做不出来,而是权限、加密、S3 私有 key 的边界在后台更清晰——stereo 只做展示层,和知识库「客户端只读」同一套思路。

专题和附件是 Article / Post 上的扩展,不是第五种内容类型。专题管「多篇文章怎么成组展示」;附件管「正文外再挂可下载文件」。速记、直播、书签、热榜也是挂在导航上的门控工具,各走各的表和 clientOptions,不往那四张内容表里塞。边界还是那四条。


总体架构

图 1:mono 与 stereo 的分工,以及三套 API 面的关系。

mono 服务端在 apps/server/src/app.ts 里按模块注册路由。每个业务模块仍按同一套分层组织:

modules/<name>/
  interfaces/http/   router、controller、validator
  application/       service、DTO
  infrastructure/    Prisma repository
  domain/            entity、repository 接口

请求链路是 router → controller → service → repository。跨模块的横切能力——JWT 鉴权、permissionMiddleware("resource:action") 细粒度 RBAC、统一 AppError 错误码、ctx.ok() / ctx.fail() 响应封装——都在中间件层处理,业务模块只关心自己的领域逻辑。

API 拆成三个面,不是 REST 洁癖,而是暴露面和权限模型确实不同

  • Admin/api/v1/*,JWT + RBAC。后台 CRUD、审核、Vault 写入。

  • Web/api/v1/web/*,公开读 + 可选 Bearer Token。stereo 前台、客户端登录用户;门控接口无权限统一 404。

  • Client/api/v1/client/*X-Oolaf-Client-Key。移动端脚本、机器上传动态、CI 蓝奏上传;后来 Chrome 扩展 formant 也走这里。

formant 用这把 Client Key 同步书签、New Tab 布局、热点英语、鼠标手势,数据按 api_key_id 切开,不是全站一个池。Stereo 书签页只读 Admin 绑的那把 key,没配就不回落全表。它不是第五种内容,只是 Client 面多了个调用方。

Web API 单独拆出来的另一个原因是缓存和限流:公开读接口走 Redis 缓存,配合 IP 限流,和后台写操作混在一起会把策略搞复杂。异步上传任务(图床 / Catbox / 蓝奏)的状态也放 Redis(oolaf:upload-job:{id}),否则 PM2 cluster 多实例会「创建成功、轮询不到」。

数据层:MySQL 是真实数据源(Prisma ORM),Redis 做会话、Web 缓存和上传任务状态,S3 兼容对象存储(又拍云 / 七牛 / R2 等)存媒体。Meilisearch 是知识库搜索的可选派生索引——没启用时回落 MySQL 全文搜索,不影响核心链路。


mono 后台:代码怎么组织

图 2:每个业务模块共享同一套四层结构。

Vue 组件规范

所有 .vue 文件强制 Options API(defineComponent),禁止 <script setup>。props、data、computed、watch、生命周期、methods 按分类组织——看起来老派,但在多人协作和 CodeGraph 分析调用链时,结构是可预期的。新逻辑不能往文件末尾堆,这条规则写进了 AGENTS.md

Arco Design Vue 是组件库基座,但页面里不直接堆原子组件。我封装了 OolafFilterOolafTable,把查询区和分页表格的重复结构收进去。用户管理页迁移之后,卡片间距、header padding 的微调只改封装层,不用逐页覆盖 Arco 默认样式。

权限不是事后补

每新增一个操作权限,必须同步四件事:权限表、角色默认授权、角色分配权限树、role_permissions 初始化 SQL。漏任何一步,就会出现「权限树里看不到、超级管理员也没有」的隐性 bug——我在知识库和 Vault 上线时都踩过。

知识库编辑器

KnowledgeMarkdownEditor.vue 是我自研的业务组件,形态很简单:

  • 左侧 a-textarea 编辑 Markdown

  • 右侧实时预览(markdown-ithtml: false

  • 粘贴/拖拽图片 → IndexedDB 本地缓存 → Markdown 插入 kb-local://{localId}

  • Ctrl + S 保存 → 只上传正文仍引用的图片 → 替换为 CDN URL → 写 MySQL

没选「粘贴即上传」,因为误粘贴、编辑后删掉的截图会变成 S3 孤儿文件。保存时上传更可控,媒体库也更干净。

编辑区和预览区还做了滚动比例同步——看起来是小细节,但 Markdown 长文编辑时,不同步的话眼睛要在左右两栏之间来回找位置,录一条知识的时间会明显变长。


stereo 前台:SSR 和客户端局部更新的混合

图 3:首屏 SSR 和后续客户端请求走不同路径,但浏览器始终只和 stereo 同源交互。

stereo 是独立的 Astro SSR 项目,不拥有内容数据库。所有数据来自 mono 的 Web API。

三种数据访问模式

  1. SSR 直拉。 首页、文章详情、知识库详情在 Astro 渲染阶段直接 fetch mono,HTML 首屏带内容,SEO 和 LCP 有保障。后来补了 /robots.txt/sitemap.xml 和页面级 SeoHead;工具类 tab、搜索、私密动态 noindex。生产优先走内网 CMS_INTERNAL_API_BASE_URL,少绕 CDN。

  2. 同源 JSON 代理。 浏览器分页、tab 切换走 /content-feed.json/knowledge-feed.json/vault-notes.json 等 stereo 自己的 endpoint,由服务端转发到 mono。列表类读请求仍藏着 CMS 地址。

  3. 浏览器直连 mono。 登录、刷新 token、图床上传、Catbox 传文件等写路径走 PUBLIC_CMS_API_BASE_URL + /api/v1/web/*。生产靠站点 /api/ 反代到 127.0.0.1:3101。access token 同时写 Cookie,让 SSR 能读 gated 内容。

首页 tab:从整页 reload 到局部替换

首页公开流是文章 / 动态。门控能力按账号挂在左侧导航:知识库、音乐、Vault、图床、传文件(Catbox)、蓝奏、影视;直播挂在影视下面,书签和速记在「文件与笔记」,热榜在意见反馈上方。另有关于我、意见反馈、英雄联盟、小红书等入口。最初切 tab 就整页 reload,体验像 2010 年的多页站点。

后来改成客户端局部替换主区和右栏:

  • sessionStorage 缓存各 tab 第一页数据(知识库和 profile 设 TTL)

  • 每个 tab 独立 scrollTop 记忆

  • 右栏 DOM 可恢复,避免从无右栏 tab 切回内容流时 discovery rail 丢失

  • 影视等重面板用 Vue islands,非当前 tab 不预拉资源

这套方案最大的坑是 Astro scoped CSS 和动态插入 DOM 不匹配。客户端 tab 切换插入的列表、评论区、音乐面板,scoped 样式根本挂不上。最后把动态创建元素的基础样式全部迁到 global.scss,才稳定。

Infinite scroll 还有一个隐蔽 bug:tab 切走后旧 feed 的 IntersectionObserver 还在跑,会继续请求上一类内容。加载前加了「当前 feed 根节点是否仍挂载」检查才止住。

三栏布局与移动端

布局参考 X/Twitter:左导航、中间内容流、右 discovery rail。<1024px 隐藏右栏,<768px 左导航变底部 tab bar,音乐播放器改 fixed mini 条并避让 safe area。断点 token 落在 _breakpoints.scss(480 / 768 / 1024 / 1440),和 mono 后台共用同一套 Starbucks 色板语义。

首页三栏布局与门控导航截图

两个功能的实现路径

图 4:知识库和 Vault 的数据流与职责切分。

知识库:录入 → 索引 → 只读展示

后台 Markdown 编辑
  → IndexedDB 本地图片 (kb-local://)
  → Ctrl+S 保存
  → 上传正文引用的图片到 S3
  → 写 MySQL knowledge_notes
  → [可选] Meilisearch 索引
  → stereo /web/knowledge/* 只读

MySQL 是 source of truth,Meilisearch 是派生副本。客户端搜索需要登录 + knowledge client option;后台只做管理筛选,不做客户端式全局搜索入口——这两个场景的交互模型不一样,硬合并会两边都不好用。

后续 P0 计划是 AI 字段抽取和发布前质量检查:检查标题是否空泛、结论是否缺失、正文是否还有 kb-local:// 残留。知识库长期价值靠结构和质量,不是靠功能堆叠。

Vault:mono 写,stereo 读

Vault 的职责边界很硬:

mono 负责:

  • 后台页面新增/编辑/删除私密笔记

  • 私密图片库上传和管理

  • 私密文件记录(蓝奏目录上传,正文用 vault-file://{id} 引用)

  • AES-256-GCM 加密正文(vault-crypto.ts

  • S3 私有 key:private-notes/{ownerClientUserId}/{uuid}.webp

  • vault-image://{id} / vault-file://{id} 引用解析和删除保护

stereo 只调只读接口:

  • GET /web/vault/notes

  • GET /web/vault/notes/:id

  • GET /web/vault/images/:id/content(经同源代理)

  • GET /web/vault/files/:id/open(经同源代理打开文件)

不把 S3/CDN 永久地址返回给浏览器。又拍云 S3 兼容接口返回 base64 content-md5 导致 AWS SDK checksum mismatch 的问题,我专门把客户端响应校验改成 WHEN_REQUIRED 才解决——这种云厂商细节不踩一次不会写进代码。

stereo 侧笔记正文按需懒加载:卡片进入视口附近才请求详情,避免一次性拉整页密文解密后的正文。


设计系统:Starbucks 色板不是装饰

DESIGN.md 记录的是 Starbucks 官网的设计语言提取:四档绿色(#006241 / #00754A / #1E3932 / #2b5148)、暖中性画布(#f2f0eb / #edebe9)、全圆角 pill 按钮、克制的阴影层级。

选这套风格,是因为我要的是温暖、扁平、可读的零售感,不是后台默认的蓝灰 SaaS 脸。mono 通过 _design-tokens.scss + Vite less modifyVars 映射 Arco 主题色;stereo 通过 src/styles/design/ 下的 SCSS token 文件落地同一套语义。两边视觉语言一致,但技术栈各走各的——Vue 组件库和 Astro 页面不需要共享组件,只需要共享 token 定义。


部署与运维

现在是双目标发布,不是单机一份配置糊弄过去:

  • mono(阿里云):GitHub Actions 构建 release 包,SSH 上传后切软链;PM2 用 ecosystem.config.cjsoolaf-mono-server(cluster)+ oolaf-mono-admin + oolaf-mono-danmu。环境读服务器上的 .env

  • mono(Mac mini):同一套 workflow,经 Tailscale + SSH;PM2 用 ecosystem.mac.config.cjsaphelios-mono-server(单进程 fork)+ aphelios-mono-admin + aphelios-mono-danmu-server。环境读 .env.mac

  • stereo:独立仓库发布;生产 PM2 名 aphelios-stereo-blog,端口 3102。当前 Mac 侧经 Tailscale 发布,SSR 优先打本机 127.0.0.1:3101

  • CDNwww.oolaf.top 走阿里云 CDN 回源,Nginx 把 /api/ 反代到 mono API。

触发条件也分开:普通提交只跑检查;只有 .build_run 变更才发版,避免每次改文案都全量构建。

本地开发四个端口各管各的:Admin 3100、API 3101、Stereo 3102、Danmu 3103。stereo 的 .envCMS_API_BASE_URL 指向 mono;浏览器写路径另配 PUBLIC_CMS_API_BASE_URL。Windows 上跑 prisma generate 偶尔会被 DLL 文件锁卡住,停掉占用进程再重跑就行——这种环境问题不写在架构图里,但确实浪费过时间。


仓库: aphelios - mono / aphelios - stereo
本地端口: Admin 3100 · API 3101 · Stereo 3102 · Danmu 3103

评论

在这里阅读读者对这篇文章的讨论。

暂无评论