1、起因:为什么要自己造轮子
要不是运维同事要离职还留下一堆零散的交接材料,真不太想自己动手再造一个轮子。过去同事间做交接,基本都是SVN归档配文档指引,这套流程本身没毛病,就是真用起来让人抓狂。比如你想查个东西,得先回想这玩意儿存哪儿了,然后再去具体位置翻内容……说实在的,费时费力。

更何况,交接完不可能立刻把所有细节烂熟于心,等出了紧急状况才去翻档案,那场面完全就是“抓马”本马。这时候,要是能有一个在线统一归档、随查随用的知识库该多好。
说实话,第一反应是——这有什么难的,Confluence不就在那里(之前还分享过怎么搭Jira和Confluence)。但仔细琢磨了一下,问题来了:
- Confluence 太重了。Ja va一跑起来,内存先吃掉2G,服务器资源不够根本扛不住。而且功能也太复杂了,大部分功能根本用不上,纯属资源浪费。
- 部署贼麻烦。不管用MediaWiki还是Wiki.js,光是折腾数据库、Node版本、反向袋里配置就能消耗半天精力。
- 数据安全问题。大多数Wiki系统,数据库里的内容都是明文存的。运维文档里难免有敏感信息——服务器IP、密码规则、内部拓扑。数据库文件被人拖走了怎么办?这种事情不是没发生过。就更别提用Notion、语雀、印象笔记这些第三方知识库了。
- 权限控制太死板。团队需要的是“这篇文章只给张三和李四看”,而不是简单的“管理员/普通用户”二元权限。
挑来挑去,实在找不到同时满足轻量部署 + 内容加密 + 细粒度分享这三个条件的工具。所以这个周末,干脆动手自己写了一个。
2、技术选型:为什么是 Flask + 原生 JS
不少人看到这个选型会觉得奇怪:“都2026年了,谁还用Flask写Web应用?前端连个框架都不上?”
2.1、后端:Flask + SQLAlchemy + SQLite
选Flask的理由其实很简单:够用,部署成本约等于零。
整个应用就一个 app.py 入口,pip install 装完依赖就能直接跑。不需要Nginx、不需要WSGI配置、不需要数据库迁移脚本——db.create_all() 一行代码搞定一切。
SQLite确实有并发写的争议。但内部知识库的写入频率低得可怜(一天可能就改几篇文档),WAL模式足够应付。真到了并发瓶颈的时候,把连接字符串换成PostgreSQL就行,这也正是使用SQLAlchemy的理由。
2.2、前端:Vanilla JS,零构建步骤
必须承认,前端水平比较渣(所以这部分适当借助AI做了些东西)。
前端技术大概停留在10年前的水平,为了自己能看懂,不做SPA路由、不做虚拟DOM、不上Webpack。所有UI逻辑散落在几个JS文件里,页面只有一个 index.html(操作DOM就操作DOM,能用就好)。而且零构建意味着:改一行JS直接刷新浏览器就能看到效果。没有 npm run dev、没有HMR抽风、没有 node_modules 黑洞。说实在的,这体验相当好。
3、加密方案:拿了你也看不到
这是整个项目里最花心思的部分。
3.1、设计目标
简单说就一条:即使有人物理拿到了 wiki.sqlite 文件,他也读不懂任何一篇文章的内容。
不少团队觉得“反正是内网部署,加密没必要”。但现实是——服务器被攻破、备份文件泄露、离职员工带走数据,这些事发生的概率一点都不低,等出了问题根本来不及处理。
3.2、技术实现:AES-256-GCM + RSA-2048 混合加密
核心思路和Telegram、WhatsApp的端到端加密类似,做了简化:
1. 用户注册 → 服务端自动生成 RSA-2048 密钥对
2. 创建 article → 随机生成 AES-256 密钥 → AES-GCM 加密内容 → RSA 公钥加密 AES 密钥
3. 存储到数据库 → encrypted_content(密文) + encrypted_key(加密后的 AES 密钥)
4. 读取 article → RSA 私钥解密 AES 密钥 → AES 密钥解密内容 → 返回明文
代码其实不复杂,核心就80行:
def encrypt_content(plaintext: str, public_key_pem: str) -> tuple[str, str]:
aes_key = AESGCM.generate_key(bit_length=256)
aesgcm = AESGCM(aes_key)
nonce = os.urandom(12)
ciphertext = aesgcm.encrypt(nonce, plaintext.encode('utf-8'), None)
encrypted_content = base64.b64encode(nonce + ciphertext).decode('utf-8') pubkey = serialization.load_pem_public_key(public_key_pem.encode('utf-8'))
encrypted_key = pubkey.encrypt(
aes_key,
asym_padding.OAEP(
mgf=asym_padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
)
return encrypted_content, base64.b64encode(encrypted_key).decode('utf-8')
4个设计方向:
- 密钥存在服务端。这个方案并不是真正的端到端加密,密钥对存储在数据库的
User表里。肯定会有人问“那服务端被完全攻破了怎么办?”答案是:如果攻击者拿到了运行时的内存和数据库,确实很难防。但防御目标是“数据库文件被拖走”这个场景——攻击者只拿到静态文件,没有解密密钥。 - document 节点不加密,article 节点加密。目录结构没必要加密,不然连树都渲染不了。只有文章内容走加密通道就可以了。
- 分享时的重新加密。分享文档给另一个用户时,系统会用对方的RSA公钥重新加密AES密钥。对方用自己的私钥就能解密。权限回收同理,删除对应的
NodePermission记录就行,内容本身不需要动。 - 权限转让更复杂。如果把文档所有权转给另一个人,所有子节点的AES密钥都要用新所有者的公钥重新加密。递归转让时每个节点独立处理,若有100个节点的目录,就会有100次RSA加密操作。不过好在这是低频操作,性能不是问题。
3.3、一个真实的防御场景
假设服务器在联通云上,有人通过某个漏洞拿到了文件系统的访问权限,把 wiki.sqlite 下载走了。他能看到什么?
- User 表的邮箱、注册时间(能猜到是谁)
- Node 表的标题和树形结构(能看到文档目录)
- Node 表的
content字段(AES-GCM 密文,没有密钥解不开) - Node 表的
encrypted_key字段(RSA-2048 加密的 AES 密钥,没有私钥解不开)
说实话,标题泄露也是信息泄露。但和全文泄露相比,这个风险对于一般小微企业来说还是可以接受的。如果未来需要更彻底的方案,可以引入客户端加密——密钥存在浏览器 localStorage 里,服务端完全不碰明文。
4、编辑器体验:三个满意的细节
编辑器是用户接触最多的部分,这里参考了别人家(Markdown编辑器)的经验:
4.1、分屏拖拽吸附
左右分屏(Markdown源码 / HTML预览),中间有一条可拖拽的分隔条。但常规拖拽有个问题——很难精确拖到“全屏Markdown”或“全屏预览”。做法是加了一个吸附逻辑:
const SNAP_THRESHOLD = 20; // 距离边缘 20px 以内触发吸附
// 拖到最左边 → HTML 预览全屏(markdown pane 折叠)
// 拖到最右边 → Markdown 全屏(html pane 折叠)
// 中间任意位置 → 自由比例分屏
这个交互比想象中好用。写长文档时全屏Markdown,校对排版时全屏预览,平时分屏对照——三种模式切换零成本。
4.2、双向滚动同步
实现方案是比例映射:两边各自计算 scrollTop / (scrollHeight - clientHeight) 的比例,然后按这个比例设置另一侧的滚动位置。用 requestAnimationFrame 防抖,再用一个全局锁 isScrollSyncing 防止两边互相触发形成死循环。
按比例映射在文档结构差异比较大时(比如Markdown开头50行大部分是YAML front matter,在HTML里只渲染成一行),同步精度会下降。但对于普通的技术文档来说,够用了。
4.3、Ctrl+S 保存
这个功能很小,但没做的话体验会很差。如果用户写了一篇文章忘记保存就关了页面,那种感觉……懂的都懂。
用 beforeunload 事件 + isDirty 标记追踪未保存状态:
window.addEventListener('beforeunload', (e) => {
if (WikiEditor.isUnsa ved()) {
e.preventDefault();
e.returnValue = '';
}
});
5、文件导入:Word 和 Excel 直接转 Markdown
运维有很多存量文档是Word格式的——故障排查、操作手册、部署文档等等。让人一篇一篇重新用Markdown写完全不现实。
所以做了三个导入通道:
- Markdown / 纯文本 / 代码文件:直接读内容,不转换
- Word (.docx):用
python-docx解析,标题样式(Heading 1~4)自动映射到Markdown标题层级,正文转普通段落。图片提取保存到images/目录,并保留在文档中原段落的位置 - Excel (.xlsx):用
openpyxl解析,每个 Sheet 渲染为独立的Markdown表格,以## Sheet名称作为二级标题
导入逻辑的大致流程:
- 先扫描
doc.part.rels,把文档里所有图片的关系ID(rId)和实际图片数据对应起来,保存到images/目录 - 然后逐段落遍历,用
qn("w:drawing")检测当前段落是否包含图片 - 没图片的段落:直接按标题样式或正文提取文本
- 有图片的段落:按
元素的原始顺序逐个处理,在文本和图片之间保持相对位置
python-docx 的API在图片提取这块支持不太好,需要手动遍历OOXML的 和 元素才能拿到 r:embed 属性去查关系ID。
6、权限与分享:不只是简单的 RBAC
大多数Wiki系统用的是“空间级”权限——要么能看整个空间,要么什么都看不了。但实际需求往往是这样:
树形节点 + 独立的授权记录表,天然支持这种粒度:
-- 权限模型
User (id, email, rsa_public_key, rsa_private_key, ...)
Node (id, parent_id, title, content, owner_id, encrypted_key, ...)
NodePermission (node_id, grantor_id, grantee_id, is_active)
关键设计:
- 授权不是给用户分配角色,而是给节点绑定被授权人。每个
NodePermission记录代表“用户A授权用户B看节点C” - 被分享者看到只读视图:全宽HTML预览,隐藏Markdown编辑器和保存按钮
- 祖先节点可见:被分享的节点在分享对象的树中,能看到完整的父级链——虽然父节点没有直接权限,但节点路径需要完整显示才有意义
批量操作也做了:Ctrl+Click多选节点,然后批量授权或撤销。
还有个有意思的场景——权限转让。比如原来的文档负责人离职了,需要把所有权转给别人:
- 选中节点 → 右键 → 权限转让 → 选择新所有者
- 系统用旧所有者的私钥解密所有子节点的AES密钥
- 用新所有者的公钥重新加密
- 更新
owner_id
每个节点独立重新加密。处理时间大概几秒钟,对体验影响不大。
7、一些不完美的地方(坦白局)
7.1、前端没有做路由
所有页面状态靠JS变量维护,刷新页面就回到初始状态。虽然SPA本来就是这样,但没有URL状态意味着你没法复制一个链接发给同事说“你看这篇文档”。这是未来需要加的功能——至少把 ?node_id=123 映射到对应的文档打开状态。
7.2、密钥存在服务端
前面说过了。这是安全性和便利性的权衡。如果引入客户端加密,分享功能会复杂很多——需要客户端交换公钥。目前这个方案对内部团队来说够用,但如果要开放公网注册,这个设计需要重新评估。
7.3、SQLite 的并发天花板
虽然内部Wiki不太可能触发SQLite的并发瓶颈,但如果一个团队有几百人同时编辑,确实会出现写锁冲突。好在换成PostgreSQL只需要改一行配置:
# config.py
SQLALCHEMY_DATABASE_URI = 'postgresql://user:pass@localhost/wiki'
ORM层完全不用动。
7.4、没有做 i18n
目前所有的UI文本、错误提示都是硬编码的中文。如果有国际化需求,需要重构一遍。不过对国内运维团队来说这不是什么问题。
8、部署:比冲一杯咖啡还快
故意把部署成本压到了最低:
pip install -r requirements.txt
python app.py
就这两步。默认监听 0.0.0.0:5100,浏览器打开就能用。
第一个注册的用户自动成为管理员。之后任何人注册都需要管理员在后台审批——避免匿名用户往Wiki里灌垃圾内容。
生产环境挂gunicorn:
gunicorn -w 4 -b 0.0.0.0:5100 app:create_app()
邮件通知是可选的,配了SMTP就用,不配也不影响核心功能。
9、最后:适合谁用
Ops Wiki 不是要替代Confluence或Notion。它解决的是一个很具体的场景:
如果符合这个场景,拿去用吧。如果不符,它至少展示了“不用框架、不用构建工具、用原生JS + Flask做一个完整的Web应用是什么体验”。
