若不是我们的运维同事要离职并且交接的东西非常零散,我都真不想自己造这个“轮子”。过往同事们的交接都是用 SVN归档 + 文档指引来完成的。这样做并没有什么大问题,只是用起来不太好用而已。譬如要查个东西,你要先知道他存在哪里,然后再看内容在哪里...坦白说有点费时费力。

况且有的时候交接完了也不会马上全部“烂熟于心”,等有紧急情况才挖出来看的,这个时候真的抓马。这个时候要是有个能够在线统一归档、查看的知识库就好了。
说实话,第一个反应是「这有什么难的,Confluence 不是现成的吗(之前我记得已经分享过了如何搭建 Jira 和 Confluence)」。但仔细一想,问题来了:
选来选去实在没找到同时满足 轻量部署 + 内容加密 + 细粒度分享 这三个条件的。所以这个周末,自己写了一个。
很多人看到这个选型会觉得奇怪,“都 2026 年了,谁还用 Flask 写 Web 应用?前端连个框架都不上?”
选 Flask 的理由其实很简单:够用,且部署成本为零。
整个应用就一个 app.py 入口,pip install 装完依赖直接跑。不需要 Nginx、不需要 WSGI 配置、不需要数据库迁移脚本——db.create_all() 一行搞定。
SQLite 确实有并发写的争议。但内部知识库的写入频率极低(一天可能就改几篇文档),WAL 模式足够应付。真到了并发瓶颈的时候,把连接字符串换成 PostgreSQL 就行,这就是我用 SQLAlchemy 的理由。
首先我必须承认,我前端是渣渣(因此这里稍微用 AI 帮我做了点东西)。
本人前端技术大概停留在 10 年前吧,为了让自己也能够看懂,不做 SPA 路由、不做虚拟 DOM、不上 Webpack。所有 UI 逻辑散在几个 JS 文件里,页面只有一个 index.html(操作 DOM 就操作 DOM 咯,能用就好)。而且零构建意味着:改一行 JS 直接刷新浏览器就能看到效果。没有 npm run dev、没有 HMR 抽风、没有 node_modules 黑洞。说实在的,这挺好。
这是整个项目里我最花心思的部分。
简单说就一条:即使有人物理拿到了 wiki.sqlite 文件,他也读不懂任何一篇文章的内容。
很多团队觉得「反正是内网部署,加密没必要」。但现实是——服务器被攻破、备份文件泄露、离职员工带数据走,这些事发生的时候你根本来不及处理。
核心思路和 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 表里。那么有小伙伴就会问「那服务端被完全攻破了怎么办?」。答案是:如果攻击者拿到了运行时的内存和数据库,确实没法防。但我们的防御目标是「数据库文件被拖走」这个场景——攻击者只有静态文件,没有解密密钥。NodePermission 记录就行,这样的话内容本身就不需要动了。假设你的服务器在联通云上,有人通过某个漏洞拿到了文件系统的访问权限,把 wiki.sqlite 下载走了。他能看到什么?
content 字段 (AES-GCM 密文,没有密钥解不开)encrypted_key 字段 (RSA-2048 加密的 AES 密钥,没有私钥解不开)说实话,标题泄露也是信息泄露。但比起全文泄露,这个风险对一般小微企业来说还是可以接受的。如果未来真的需要更彻底的方案,可以引入客户端加密——密钥存在浏览器 localStorage 里,服务端完全不碰明文。
编辑器是用户接触最多的部分,这里我吸收了别人家(Markdown 编辑器)的经验:
左右分屏(Markdown 源码 / HTML 预览),中间有一条可拖拽的分隔条。但常规拖拽有个问题——你很难精确拖到「全屏 Markdown」或「全屏预览」。我的做法是加了一个吸附逻辑:
const SNAP_THRESHOLD = 20; // 距离边缘 20px 以内触发吸附
// 拖到最左边 → HTML 预览全屏(markdown pane 折叠)
// 拖到最右边 → Markdown 全屏(html pane 折叠)
// 中间任意位置 → 自由比例分屏
这个交互比想象中好用。写长文档时全屏 Markdown,校对排版时全屏预览,平时分屏对照——三种模式切换零成本。
实现方案是比例映射:两边各自计算 scrollTop / (scrollHeight - clientHeight) 的比例,然后按这个比例设置另一侧的滚动位置。用 requestAnimationFrame 防抖,再用一个全局锁 isScrollSyncing 防止两边互相触发形成死循环。
说实话,按比例映射在文档结构差异比较大的时候(比如 Markdown 开头 50 行大部分是 YAML front matter,但在 HTML 里就渲染成一行),同步精度会下降。但对于普通的技术文档来说,够用了。
这个功能其实很小,但没做的话体验会很差。如果用户写了一篇文章忘记保存就关了页面,那感觉……懂的都懂。
用 beforeunload 事件 + isDirty 标记追踪未保存状态:
window.addEventListener('beforeunload', (e) => {
if (WikiEditor.isUnsaved()) {
e.preventDefault();
e.returnValue = '';
}
});
运维有很多存量文档是 Word 格式的——故障排查、操作手册、部署文档等等。让人一篇一篇重新用 Markdown 写不现实。
所以做了三个导入通道:
python-docx 解析,标题样式(Heading 1~4)自动映射到 Markdown 标题层级,正文转普通段落。图片提取保存到 images/ 目录,并保留在文档中原段落的位置openpyxl 解析,每个 Sheet 渲染为独立的 Markdown 表格,以 ## Sheet名称 作为二级标题导入逻辑的大致流程:
doc.part.rels,把文档里所有图片的关系 ID(rId)和实际图片数据对应起来,保存到 images/ 目录qn("w:drawing") 检测当前段落是否包含图片<w:r> 元素的原始顺序逐个处理,在文本和图片之间保持相对位置python-docx 的 API 在图片提取这块支持不太好,需要手动遍历 OOXML 的 <w:drawing> 和 <a:blip> 元素才能拿到 r:embed 属性去查关系 ID。
大多数 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」批量操作也做了:Ctrl+Click 多选节点,然后批量授权或撤销。
还有个有意思的场景——权限转让。比如原来的文档负责人离职了,需要把所有权转给别人:
owner_id每个节点独立重新加密。处理时间大概几秒钟,对体验影响不大。
所有页面状态靠 JS 变量维护,刷新页面就回到初始状态。虽然 SPA 本来就是这样,但没有 URL 状态意味着你没法复制一个链接发给同事说「你看这篇文档」。这是未来需要加的功能——至少把 ?node_id=123 映射到对应的文档打开状态。
前面说过了。这是安全性和便利性的权衡。如果引入客户端加密,分享功能会复杂很多——需要客户端交换公钥。目前这个方案对内部团队来说够用,但如果要开放公网注册,这个设计需要重新评估。
虽然内部 Wiki 不太可能触发 SQLite 的并发瓶颈,但如果一个团队有几百人同时编辑,确实会出现写锁冲突。好在换成 PostgreSQL 只需要改一行配置:
# config.py
SQLALCHEMY_DATABASE_URI = 'postgresql://user:pass@localhost/wiki'
ORM 层完全不用动。
目前所有的 UI 文本、错误提示都是硬编码的中文。如果有国际化需求,需要重构一遍。不过对国内运维团队来说这不是什么问题。
我故意把部署成本压到了最低:
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 就用,不配也不影响核心功能。
Ops Wiki 不是要替代 Confluence 或 Notion。它解决的是一个很具体的场景:
如果你符合这个场景,拿去用吧。不符合的话,它至少展示了「不用框架、不用构建工具、用原生 JS + Flask 做一个完整的 Web 应用是什么体验」。
项目地址:gitee.com/yzh0623/ops…