编程
本站架构设计
Oolaf 拆成 mono(后台管理/API)和 stereo(前台 SSR)。核心仍是四类内容(长文、动态、知识库、Vault),门控按 clientOptions 按人开通。Client API 后来也给 formant 用,书签 / New Tab / 手势按 Key 隔离。还是靠数据模型边界、RBAC 和职责分离撑这套 CMS。
Oolaf 拆成 mono(后台管理/API)和 stereo(前台 SSR)。核心仍是四类内容(长文、动态、知识库、Vault),门控按 clientOptions 按人开通。Client API 后来也给 formant 用,书签 / New Tab / 手势按 Key 隔离。还是靠数据模型边界、RBAC 和职责分离撑这套 CMS。
{"official":[],"popular":[]}我的划线
选中正文即可划线,划过的句子会留在这里。
我最初只想写博客。
后来多了几类完全不同的东西:排查半天才搞定的技术记录、随手发的短动态、偶尔想分享的曲库、以及只给少数人看的私密笔记。把它们全塞进一张 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。
三个硬约束:
内容类型不能混写。 长文、短动态、结构化知识、私密笔记各有数据模型,权限边界也不同。
后台和前台必须解耦。 后台用 Vue + Arco 做管理;前台用 Astro SSR 做 SEO 和首屏;通过 /api/v1/web/* 通信,可以独立部署、独立发版。
读者不是管理员。 客户端用户(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,不往那四张内容表里塞。边界还是那四条。

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 全文搜索,不影响核心链路。

所有 .vue 文件强制 Options API(defineComponent),禁止 <script setup>。props、data、computed、watch、生命周期、methods 按分类组织——看起来老派,但在多人协作和 CodeGraph 分析调用链时,结构是可预期的。新逻辑不能往文件末尾堆,这条规则写进了 AGENTS.md。
Arco Design Vue 是组件库基座,但页面里不直接堆原子组件。我封装了 OolafFilter 和 OolafTable,把查询区和分页表格的重复结构收进去。用户管理页迁移之后,卡片间距、header padding 的微调只改封装层,不用逐页覆盖 Arco 默认样式。
每新增一个操作权限,必须同步四件事:权限表、角色默认授权、角色分配权限树、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 是独立的 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 代理。 浏览器分页、tab 切换走 /content-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 内容。
首页公开流是文章 / 动态。门控能力按账号挂在左侧导航:知识库、音乐、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 色板语义。


后台 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 负责:
后台页面新增/编辑/删除私密笔记
私密图片库上传和管理
私密文件记录(蓝奏目录上传,正文用 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 侧笔记正文按需懒加载:卡片进入视口附近才请求详情,避免一次性拉整页密文解密后的正文。
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.cjs:oolaf-mono-server(cluster)+ oolaf-mono-admin + oolaf-mono-danmu。环境读服务器上的 .env。
mono(Mac mini):同一套 workflow,经 Tailscale + SSH;PM2 用 ecosystem.mac.config.cjs:aphelios-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。
CDN:www.oolaf.top 走阿里云 CDN 回源,Nginx 把 /api/ 反代到 mono API。
触发条件也分开:普通提交只跑检查;只有 .build_run 变更才发版,避免每次改文案都全量构建。
本地开发四个端口各管各的:Admin 3100、API 3101、Stereo 3102、Danmu 3103。stereo 的 .env 里 CMS_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
在这里阅读读者对这篇文章的讨论。
暂无评论
确认操作
请再次确认填写 Client Key
首次发布动态需要密钥,将保存在本机编辑图片
待上传图片 (剩余 0 张)
项目详情
确认评论邮箱
邀请制评论