我最初只想写博客。
后来多了几类完全不同的东西:排查半天才搞定的技术记录、随手发的短动态、偶尔想分享的曲库、以及只给少数人看的私密笔记。把它们全塞进一张 posts 表,大概三个月后,检索、权限、展示形态都乱了——长文被当成动态刷过去,知识库条目和博客文章混在同一个列表里,私密内容还要额外打补丁。
Oolaf 就是在这个节点上拆出来的:mono 管生产和权限,stereo 管阅读和体验。不是「又一个 WordPress」,而是一套我能完全掌控、边界清晰的内容基础设施。后面又叠了图床、传文件、蓝奏、影视、直播、书签、速记、热榜和 humanize。边界没变:能写的进 mono,能看的进 stereo,能不能看由 clientOptions 说了算。
这次前台重构没有改掉 mono / stereo 的分工,但把“所有东西都塞在一个首页壳里”的做法扔了。/ 是博客,/posts 是动态,音乐、影视、图床、速记等都有自己的路由。/legacy?tab=* 只负责把旧书签 302 到新地址,不再渲染三栏页面。
平台到底在解决什么
Oolaf 分两层:
后台 + API:仓库
oolaf-mono。Admin3100,API3101,弹幕上游3103(本机)。负责内容录入、审核、RBAC、加密存储、AI 辅助、对外 Web/Client API。公开站点:仓库
oolaf-stereo,端口3102。Astro SSR 负责首屏和 SEO,页面统一套SiteLayout;ClientRouter 处理站内软跳转,影视、直播、速记、热榜等重交互继续用 Vue islands。Stereo 不拥有内容数据库,也不承担后台录入。
三个硬约束:
内容类型不能混写。 长文、短动态、结构化知识、私密笔记各有数据模型,权限边界也不同。
后台和前台必须解耦。 后台用 Vue + Arco 做管理;前台用 Astro SSR 做 SEO 和首屏;通过
/api/v1/web/*通信,可以独立部署、独立发版。读者不是管理员。 客户端用户(
ClientUser)和后台用户是两套账号体系。知识库、音乐、Vault、图床、传文件、蓝奏、影视、直播、书签、速记、humanize 和发布动态通过clientOptions按人开通;热榜谁都能刷,一键发现另开hotlist-discover。公开内容助手允许匿名使用,robot-agent只负责取消次数限制。不是登录就能看全部。
四块内容,四条边界
我在设计阶段就写死了「是什么 / 不是什么」,避免模块膨胀:
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,不往那四张内容表里塞。边界还是那四条。
总体架构

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 面后来又多了几条不同性质的链路:文章划线需要登录后同步;humanize 是登录加 option 的异步任务;直播只从 mono 取得 session,媒体流改为浏览器直连;公开内容助手则允许匿名使用。可选 Bearer Token 失效时,公开接口按匿名继续,不能因为浏览器里留了一枚过期 token,就把本来能用的机器人也打成 401。
Web API 单独拆出来的另一个原因是缓存和限流:公开读接口走 Redis 缓存,配合 IP 限流,和后台写操作混在一起会把策略搞复杂。异步上传任务(图床 / Catbox / 蓝奏)的状态也放 Redis(oolaf:upload-job:{id}),否则 PM2 cluster 多实例会「创建成功、轮询不到」。
数据层:MySQL 是真实数据源(Prisma ORM),Redis 做会话、Web 缓存、限流和异步任务状态,S3 兼容对象存储负责媒体。Meilisearch 除了知识库搜索,还有独立的 robot_public_corpus;机器人索引失败时回退 MySQL LIKE。回答用量另写 robot_usage_logs,可以在 Admin 日志中心查看和清理。
mono 后台:代码怎么组织

Vue 组件规范
所有 .vue 文件强制 Options API(defineComponent),禁止 <script setup>。props、data、computed、watch、生命周期、methods 按分类组织——看起来老派,但在多人协作和 CodeGraph 分析调用链时,结构是可预期的。新逻辑不能往文件末尾堆,这条规则写进了 AGENTS.md。
Arco Design Vue 是组件库基座,但页面里不直接堆原子组件。我封装了 OolafFilter 和 OolafTable,把查询区和分页表格的重复结构收进去。用户管理页迁移之后,卡片间距、header padding 的微调只改封装层,不用逐页覆盖 Arco 默认样式。
后台的领域面也比最初多了不少:简历设计器、文章划线管理、定时任务、AI 模型池、机器人语料和用量日志都各自成模块。它们没有继续往 article 或 dashboard 里硬塞,路由、service、repository 和权限仍按自己的边界组织。
权限不是事后补
每新增一个操作权限,必须同步四件事:权限表、角色默认授权、角色分配权限树、role_permissions 初始化 SQL。漏任何一步,就会出现「权限树里看不到、超级管理员也没有」的隐性 bug——我在知识库和 Vault 上线时都踩过。
知识库编辑器
KnowledgeMarkdownEditor.vue 是我自研的业务组件,形态很简单:
左侧
a-textarea编辑 Markdown右侧实时预览(
markdown-it,html: false)粘贴/拖拽图片 → IndexedDB 本地缓存 → Markdown 插入
kb-local://{localId}Ctrl + S保存 → 只上传正文仍引用的图片 → 替换为 CDN URL → 写 MySQL
没选「粘贴即上传」,因为误粘贴、编辑后删掉的截图会变成 S3 孤儿文件。保存时上传更可控,媒体库也更干净。
编辑区和预览区还做了滚动比例同步——看起来是小细节,但 Markdown 长文编辑时,不同步的话眼睛要在左右两栏之间来回找位置,录一条知识的时间会明显变长。
stereo 前台:SSR 和客户端局部更新的混合

stereo 是独立的 Astro SSR 项目,不拥有内容数据库。所有数据来自 mono 的 Web API。
三种数据访问模式
SSR 直拉。 首页、文章详情、知识库详情在 Astro 渲染阶段直接 fetch mono,HTML 首屏带内容,SEO 和 LCP 有保障。后来补了
/robots.txt、/sitemap.xml和页面级 SeoHead;工具类 tab、搜索、私密动态 noindex。生产优先走内网CMS_INTERNAL_API_BASE_URL,少绕 CDN。同源 JSON 代理。 浏览器分页走
/feed.json、/posts/feed.json、/knowledge-feed.json、/vault-notes.json等 Stereo endpoint,由服务端转发到 mono。列表类读请求仍藏着 CMS 地址。浏览器直连 mono。 登录、刷新 token、图床上传、Catbox 传文件等写路径走
PUBLIC_CMS_API_BASE_URL+/api/v1/web/*。生产靠站点/api/反代到127.0.0.1:3101。access token 同时写 Cookie,让 SSR 能读 gated 内容。
主产品面:从 tab 壳拆成独立路由
Stereo 现在是“SSR 页面 + 独立路由 + 少量持久客户端状态”,不再是首页 tab 的 DOM 替换器。/ 是博客,/posts 是动态,工具和娱乐页也都有自己的地址;/legacy?tab=* 只做兼容跳转。


SiteLayout 常驻主题、确认框、邀请框、Client Key 弹层、音乐 audio 和右下角机器人。ClientRouter 软跳转时,这些需要跨页面保留的东西不会被一起销毁。页面自己的数据和 island 仍按路由装载,边界比旧 tab 壳清楚很多。
单主栏与移动端导航
桌面端用顶栏和下拉菜单分组;移动端小于 768px 后改成右侧抽屉。主内容区不再固定成左中右三栏,文章详情直接吃满站点主栏。主题默认黑色,也可以切白天,偏好存在 aphelios-site-theme。

公开内容助手:检索和回答分开

右下角机器人不是把整站内容直接塞给模型。Stereo 先把问题、当前路径、对话历史和可选的 focusArticleId 发给 mono;mono 从公开文章和公开动态里检索候选,再把上下文交给固定模型,最后通过 SSE 一段段返回。
文章正文同步时会拆成带锚点的语料块,引用可以回到 #robot-p-n。追问可先做 query rewrite,再重新检索。普通访客按 device cookie 和 IP 双重计数;账号有 robot-agent 时取消次数限制。敏感文章、私密动态和其它门控内容不会进入公开语料。
两个功能的实现路径

知识库:录入 → 索引 → 只读展示
后台 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}.webpvault-image://{id}/vault-file://{id}引用解析和删除保护
stereo 只调只读接口:
GET /web/vault/notesGET /web/vault/notes/:idGET /web/vault/images/:id/content(经同源代理)GET /web/vault/files/:id/open(经同源代理打开文件)
不把 S3/CDN 永久地址返回给浏览器。又拍云 S3 兼容接口返回 base64 content-md5 导致 AWS SDK checksum mismatch 的问题,我专门把客户端响应校验改成 WHEN_REQUIRED 才解决——这种云厂商细节不踩一次不会写进代码。
stereo 侧笔记正文按需懒加载:卡片进入视口附近才请求详情,避免一次性拉整页密文解密后的正文。
设计系统:后台和前台各走各的
mono 和 stereo 现在不再强行共用一套视觉皮肤。mono 后台继续使用 Arco Design Vue,并由 _design-tokens.scss 把主题色映射到 Starbucks 色板。这部分适合管理系统,不需要因为前台改版一起推倒。
Stereo 则换成自己的 site token:默认 void 黑底,白色主 CTA,同时提供一套白天主题。边框、输入框、frost 面板和文字层级都从 --site-* 变量取值,阴影尽量少。两边共享的是模块边界、接口和工程约束,不再是假装 Vue 后台和 Astro 阅读站必须长一个样。
部署与运维
现在 mono 和 stereo 都只发布到 Mac mini。GitHub Actions 负责构建 release 包,阿里云不再作为应用部署目标,只是公网 SSH ProxyJump。Actions 先连阿里云跳板,再进入星空组网里的 192.168.188.3,上传 release、切换 current 软链并重启 PM2。
mono 在 Mac 上跑单实例 aphelios-mono-server、Admin 和弹幕服务;stereo 跑 aphelios-stereo-blog。SSR 和 API 都在同一台机器上,因此内部读取优先打 127.0.0.1:3101。外部访问仍由 www.oolaf.top 经 CDN 和 Nginx 回源。
普通提交只跑检查,只有 .build_run 变更才自动发布。本地开发仍是 Admin 3100、API 3101、Stereo 3102、Danmu 3103,各自管各自的进程。

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

确认评论邮箱
邀请制评论