1. 从开源 WorkBuddy 到企业版 WorkDSH 的改造思路
1.1 为什么我要做这件事
开源版 WorkBuddy 我用了一年多,个人用确实舒服,任务看板、日程、笔记、轻量协作都够用。但一放到公司环境里,问题立刻暴露出来:数据在别人的云上、模型调用走公网、同事之间没法直接 @、权限体系几乎为零。公司 IT 部门的态度也很明确——要么用内部部署的版本,要么别用。
我当时的处境是:团队十几个人,已经习惯了 WorkBuddy 的交互逻辑,重新换一套工具成本太高。于是我就动了改造的念头,目标很具体:保留 WorkBuddy 的前端交互和核心数据结构,把后端换成公司内部模型服务,加上组织架构和 @ 同事的能力,最终产出一个内部代号叫 WorkDSH 的企业版。
这里要先说清楚一个概念区分。WorkBuddy 是开源社区版本,定位是个人和小团队;WorkDSH 是我基于它二次开发的企业分支,DSH 是内部命名,不代表任何官方产品。两者共享大量前端代码,但后端、鉴权、模型接入层完全重写。理解这一点很重要,因为后面所有的改造决策都围绕"哪些能复用、哪些必须重写"展开。
1.2 改造前必须想清楚的三个问题
动手之前我花了大概一周时间做调研,最后锁定三个核心问题,这三个问题决定了整个项目的技术选型。
第一个问题是模型接入方式。开源版默认走的是公有云 API,企业版必须切到公司内部模型服务。这里有个坑:内部模型服务的接口协议和公有云不完全一致,有的走 OpenAI 兼容格式,有的走自研协议。我当时的做法是在中间加一层适配器,把内部模型的请求响应格式统一转换成 WorkBuddy 前端能识别的结构。这样前端代码几乎不用改,只改适配器就行。
第二个问题是身份与组织架构。开源版基本是单用户或者简单多用户,没有组织树、没有部门、没有角色。企业版必须对接公司的账号体系,我选择对接内部 LDAP,把用户、部门、角色同步到 WorkDSH 自己的用户表里。为什么不直接每次请求都查 LDAP?因为 LDAP 查询有延迟,高频操作扛不住,本地缓存加定时同步是更稳的方案。
第三个问题是@ 同事的实现机制。这个看起来简单,实际上涉及消息通知、权限校验、实时推送三块。@ 的本质是"在一条内容里标记某个用户,并触发对该用户的通知"。通知走内部消息队列,权限校验确保你只能 @ 你有权限看到的人,实时推送用 WebSocket。这三块任何一块没做好,@ 功能都会变成"点了没反应"或者"@ 了不该 @ 的人"。
1.3 整体架构是怎么搭的
最终架构分四层,我从下往上说。
最底层是基础设施层,包括公司内部模型服务、LDAP 目录服务、消息队列、数据库。这一层我不动,只做对接。
往上是适配层,这是我写得最多的部分。模型适配器负责协议转换,鉴权适配器负责把 LDAP 用户映射成 WorkDSH 内部用户,通知适配器负责把 @ 事件投递到消息队列。适配层的好处是隔离变化——哪天公司换了模型服务,我只改适配器,上层不动。
再往上是业务层,也就是 WorkDSH 的核心逻辑:任务、日程、笔记、协作、@ 通知。这一层大量复用了 WorkBuddy 的开源代码,但把涉及用户身份和数据权限的部分全部重写。
最上面是前端层,基本沿用 WorkBuddy 的界面,只增加了组织架构选择器、@ 提及面板、企业版登录页这几个组件。
这个分层不是拍脑袋定的,是我踩过坑之后调整出来的。一开始我把模型适配逻辑直接写进业务层,结果内部模型接口一改,业务代码跟着改了一大片。后来抽成独立适配层,改动范围立刻收敛到一个文件。
提示:二次开发开源项目时,第一件事不是改代码,而是画清楚"哪些层要动、哪些层不动"。边界清晰了,后面才不会越改越乱。
2. 核心细节解析与实操要点
2.1 模型接入:从公有云到公司内部模型
模型接入是整个改造里最敏感的部分,因为涉及数据不出内网这条硬要求。我的做法是在 WorkDSH 后端加一个ModelProvider抽象接口,所有模型调用都走这个接口,具体实现分两种:PublicCloudProvider和InternalProvider。企业版默认用后者。
InternalProvider的核心工作是三件事:请求格式转换、鉴权头注入、响应解析。公司内部模型服务用的是自研协议,请求体结构和 OpenAI 格式差别不小,我写了一个转换函数,把 WorkDSH 内部的统一请求结构转成内部协议格式。鉴权头这块,内部服务用的是公司统一的 token 体系,我在适配器里统一注入,业务代码完全不用关心。
这里有个参数必须注意:超时时间。公有云 API 通常 30 秒超时够用,但内部模型服务因为要排队、要调度 GPU,首次响应可能到 60 秒以上。我一开始沿用 30 秒,结果大量请求超时失败。后来把超时调到 120 秒,并且加了重试机制,问题才解决。重试策略我用的是指数退避,第一次等 2 秒,第二次 4 秒,第三次 8 秒,最多重试三次。
还有一个细节是流式响应。WorkBuddy 前端支持打字机效果,这依赖流式响应。内部模型服务有的支持流式,有的只支持一次性返回。我的处理是:如果内部服务支持流式,直接透传;如果不支持,就在适配层做伪流式——先拿到完整结果,再按字符分片推给前端。伪流式体验差一点,但至少不会让前端报错。
2.2 组织架构与权限体系
企业版和开源版最大的区别就是"人"的维度。开源版里用户就是一个 ID,企业版里用户属于部门、有角色、有上下级。我把这块拆成三张表:users、departments、user_roles。
users表存用户基本信息,关键字段是ldap_id,用来和 LDAP 做映射。departments表存部门树,用parent_id做自关联。user_roles表存用户和角色的多对多关系,角色决定权限。
同步逻辑我写了一个定时任务,每 30 分钟从 LDAP 拉一次全量数据,做增量更新。为什么是 30 分钟?因为太频繁会给 LDAP 压力,太慢又会导致新员工入职后半天用不了系统。30 分钟是实测下来比较平衡的值。同步时要注意软删除——LDAP 里删掉的用户,WorkDSH 里不能直接物理删除,否则历史数据里的 @ 记录会变成孤儿。我的做法是标记is_active = false,用户不再出现在选择器里,但历史记录还能正常显示。
权限校验我放在业务层入口,每个涉及数据的操作都先过一遍checkPermission(userId, resourceId, action)。这个函数内部会查用户的角色、资源的归属部门、操作类型,三者匹配才放行。这里最容易出错的是跨部门协作场景——A 部门的人能不能看到 B 部门的任务?我的规则是:同部门默认可见,跨部门需要显式授权。这个规则不是技术问题,是管理问题,我专门和团队负责人确认过才定下来。
2.3 @ 同事的完整实现链路
@ 功能看起来是个小功能,实际上链路很长,我把它拆成四步。
第一步是前端提及面板。用户在输入框敲@时,前端要弹出一个用户选择列表。这个列表不能是全部用户,得是"当前用户有权限看到的人"。我一开始图省事直接拉全量用户,结果一个几百人的公司,列表卡得要死。后来改成按部门懒加载,先显示当前部门,展开才加载子部门,体验立刻顺畅。
第二步是内容标记。用户选中某人后,输入框里插入一个特殊标记,比如@[张三:user_id]。这个标记在存储时要保留,渲染时再转成可点击的链接。为什么不用纯文本@张三?因为重名。公司里两个张三很常见,纯文本没法区分。
第三步是通知投递。内容保存后,后端解析出所有 @ 标记,对每个被 @ 的用户生成一条通知,投递到消息队列。通知内容包含谁 @ 了你、在哪个任务里、内容摘要。这里要注意自己 @ 自己不发通知,否则用户会被自己的操作打扰。
第四步是实时推送。通知生成后,通过 WebSocket 推给在线用户。用户不在线怎么办?存到离线通知表,下次登录时拉取。WebSocket 这块我用的是公司内部已有的推送服务,没有自己造轮子,因为推送服务的稳定性要求很高,自研成本不划算。
注意:@ 的权限校验必须在后端做,前端的选择列表只是体验优化。我见过有人只在前端过滤,结果用户手动构造请求就能 @ 到不该 @ 的人,这是典型的安全漏洞。
2.4 数据迁移与缓存策略
从开源版迁到企业版,数据迁移是绕不开的。我的迁移分两步:先迁结构,再迁数据。结构迁移用脚本自动生成建表语句,数据迁移用分批导入,每批 1000 条,避免一次性导入把数据库压垮。
缓存这块我踩过一个大坑。WorkBuddy 开源版默认用本地内存缓存,单机没问题,但企业版是多实例部署,本地缓存会导致数据不一致——A 实例改了数据,B 实例还读旧缓存。后来我换成 Redis 做集中缓存,所有实例共享。缓存 key 的设计也有讲究,我用workdsh:user:{id}、workdsh:dept:{id}这种前缀,方便批量清理。
缓存过期时间我设的是 10 分钟,但用户信息变更时会主动失效。主动失效比单纯依赖过期更及时,尤其是新员工入职、员工调岗这种场景,等 10 分钟太久了。
3. 实操过程与核心环节实现
3.1 环境准备与依赖安装
先说环境。我用的是一台 8 核 16G 的 Linux 服务器做开发环境,生产环境是公司内部的容器平台。数据库用 PostgreSQL 14,缓存用 Redis 6,消息队列用公司统一的 Kafka。
依赖安装这块,WorkBuddy 开源版本身依赖 Node.js 和一堆 npm 包。我的做法是先git clone开源仓库,然后npm install装依赖。这里有个坑:开源版的package.json里有些依赖版本比较老,直接装可能报错。我的处理是先把 Node.js 版本对齐到开源版要求的版本,再装依赖。版本不对齐的话,会出现各种莫名其妙的编译错误。
装完依赖后,先跑一遍开源版的测试,确认基础功能正常。这一步很重要,因为如果开源版本身就跑不起来,后面改造就是空中楼阁。我当时的做法是跑npm run dev,打开浏览器确认任务、日程、笔记都能正常用,再开始改造。
3.2 模型适配器的代码实现
模型适配器我写成一个独立的模块,核心是一个类InternalModelProvider,实现ModelProvider接口。接口定义很简单,就一个chat方法,输入是消息列表,输出是模型回复。
class InternalModelProvider { constructor(config) { this.endpoint = config.endpoint; this.token = config.token; this.timeout = config.timeout || 120000; } async chat(messages, options = {}) { const payload = this.transformRequest(messages, options); const response = await this.requestWithRetry(payload); return this.transformResponse(response); } transformRequest(messages, options) { // 把 WorkDSH 内部消息格式转成内部模型协议格式 return { model: options.model || 'internal-default', input: messages.map(m => ({ role: m.role, content: m.content })), stream: options.stream || false }; } async requestWithRetry(payload, maxRetry = 3) { let lastError; for (let i = 0; i < maxRetry; i++) { try { const res = await fetch(this.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.token}` }, body: JSON.stringify(payload), timeout: this.timeout }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return await res.json(); } catch (err) { lastError = err; await this.sleep(Math.pow(2, i) * 1000); } } throw lastError; } sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } }这段代码的关键点是requestWithRetry里的指数退避。第一次失败等 1 秒,第二次 2 秒,第三次 4 秒。为什么用指数退避而不是固定间隔?因为内部模型服务过载时,固定间隔重试会加剧拥堵,指数退避给服务恢复的时间。
3.3 组织架构同步的定时任务
同步任务我用的是 Node.js 的node-cron,每 30 分钟跑一次。核心逻辑是:从 LDAP 拉全量用户,和本地users表比对,新增的插入,变更的更新,LDAP 里没有的标记为 inactive。
const cron = require('node-cron'); cron.schedule('*/30 * * * *', async () => { console.log('开始同步组织架构'); try { const ldapUsers = await fetchLdapUsers(); const localUsers = await db.query('SELECT ldap_id, is_active FROM users'); const localMap = new Map(localUsers.map(u => [u.ldap_id, u])); for (const lu of ldapUsers) { if (!localMap.has(lu.ldap_id)) { await db.query( 'INSERT INTO users (ldap_id, name, dept_id, is_active) VALUES ($1, $2, $3, true)', [lu.ldap_id, lu.name, lu.deptId] ); } else { await db.query( 'UPDATE users SET name = $1, dept_id = $2, is_active = true WHERE ldap_id = $3', [lu.name, lu.deptId, lu.ldap_id] ); } } // 标记 LDAP 里已不存在的用户 const ldapIds = new Set(ldapUsers.map(u => u.ldap_id)); for (const [ldapId] of localMap) { if (!ldapIds.has(ldapId)) { await db.query('UPDATE users SET is_active = false WHERE ldap_id = $1', [ldapId]); } } console.log('组织架构同步完成'); } catch (err) { console.error('同步失败', err); } });这里有个性能细节:不要每处理一个用户就查一次数据库,那样几百个用户就是几百次查询。我的做法是一次性把本地用户拉出来放内存里比对,数据库操作降到最少。实测下来,500 人的公司同步一次大概 3 秒,完全可接受。
3.4 @ 功能的前后端联调
@ 功能的联调是最花时间的,因为涉及前端、后端、消息队列、WebSocket 四个环节。我的调试顺序是:先单独测前端提及面板,再测后端解析,再测通知投递,最后测实时推送。
前端提及面板的调试要点是键盘导航。用户敲@后,应该能用上下键选择,回车确认,Esc 取消。这几个交互不做,用户体验会很差。我一开始只做了鼠标点击,测试时被同事吐槽"用键盘敲字的人根本不想摸鼠标",后来补上了键盘导航。
后端解析的调试要点是边界情况。比如内容里@后面跟的不是用户名而是邮箱,比如@出现在代码块里不应该被解析,比如同一个人被 @ 多次只发一条通知。这些边界我都是写单元测试覆盖的,不然上线后各种奇怪 bug。
通知投递的调试要点是幂等性。同一条内容如果被重复保存,不能重复发通知。我的做法是给每条通知生成一个唯一 key,投递前先查 key 是否存在,存在就跳过。
实时推送的调试要点是断线重连。用户网络抖动导致 WebSocket 断开后,重连时要能补拉断线期间的通知。这块我用的是推送服务自带的重连机制,加上一个"最后接收时间"参数,重连时带上这个参数,服务端补发之后的通知。
4. 常见问题与排查技巧实录
4.1 模型调用相关的问题
问题一:模型响应特别慢,前端一直转圈。
这个问题的排查思路是分层定位。先看是网络问题还是模型问题——在服务器上直接 curl 内部模型服务,如果也慢,那就是模型服务本身的问题;如果 curl 快但应用慢,那就是应用层的问题。我遇到过一次是适配器里的超时设置太短,请求被提前掐断,改成 120 秒就好了。
问题二:模型返回的内容格式不对,前端渲染乱码。
这是协议转换没做全。内部模型返回的字段名和公有云不一样,比如公有云用content,内部可能用text。我的做法是写一个映射表,把所有可能的字段名都列出来,转换时逐个匹配。这个映射表后来成了适配器里最常改的文件,因为内部模型服务升级时字段名偶尔会变。
问题三:并发请求时模型服务返回 429。
这是限流。内部模型服务通常有 QPS 限制,超过就返回 429。我的处理是在适配器里加一个令牌桶限流器,把并发请求控制在服务能承受的范围内。令牌桶的容量我设的是 10,补充速率是每秒 5 个,实测下来既能跑满服务能力,又不会触发限流。
4.2 组织架构同步相关的问题
问题一:同步后用户部门不对。
这个通常是 LDAP 里的部门字段和 WorkDSH 的部门表没对上。排查方法是打印 LDAP 返回的原始数据,看部门字段的实际值,再和本地部门表比对。我遇到过一次是 LDAP 里部门用的是英文名,本地表用的是中文名,映射失败。后来加了一层部门名映射表才解决。
问题二:同步任务跑着跑着卡死。
这是数据库连接池耗尽。同步任务里如果用了长事务,或者有未关闭的连接,跑几次就会把连接池占满。我的做法是确保每个数据库操作都正确释放连接,并且给同步任务单独配一个小的连接池,不和业务请求抢资源。
问题三:新员工入职后半天用不了系统。
这是同步频率的问题。30 分钟同步一次,最坏情况新员工要等 30 分钟。我的优化是加了一个手动触发接口,HR 录入新员工后可以点一下"立即同步",不用等定时任务。这个接口只对管理员开放,避免被滥用。
4.3 @ 功能相关的问题
问题一:@ 了同事但对方没收到通知。
排查顺序是:先看通知有没有进消息队列,再看消费者有没有正常消费,最后看 WebSocket 有没有推出去。我遇到过一次是消息队列的消费者挂了,通知堆在队列里没人处理。后来加了消费者健康检查,挂了自动重启。
问题二:@ 列表里出现了已离职的人。
这是同步任务的软删除没生效。检查is_active字段是否正确更新,以及前端查询时有没有加WHERE is_active = true条件。我一开始前端查询忘了加这个条件,导致离职员工还显示在列表里。
问题三:@ 同一个人多次,对方收到多条通知。
这是去重没做。我的做法是在生成通知前,先对 @ 列表去重,同一个人只保留一条。去重用的是 Set 数据结构,简单可靠。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 模型响应慢 | 超时设置过短 | curl 直连模型服务对比 | 超时调到 120 秒 |
| 模型返回乱码 | 字段名不匹配 | 打印原始响应 | 补全字段映射表 |
| 并发返回 429 | 触发限流 | 查看模型服务日志 | 加令牌桶限流 |
| 部门同步错误 | 部门名映射失败 | 打印 LDAP 原始数据 | 加部门名映射表 |
| 同步任务卡死 | 连接池耗尽 | 查看数据库连接数 | 独立连接池 |
| @ 通知丢失 | 消费者挂掉 | 查看队列堆积 | 消费者健康检查 |
| @ 列表含离职员工 | 软删除未生效 | 检查 is_active 字段 | 查询加过滤条件 |
| @ 重复通知 | 未去重 | 检查通知生成逻辑 | Set 去重 |
提示:排查问题时,永远先定位是哪一层出的问题,再深入那一层。跨层猜测是最浪费时间的。
5. 改造过程中的经验与取舍
5.1 哪些代码该复用,哪些该重写
这是二次开发最核心的判断。我的原则是:与业务逻辑无关的复用,与身份权限相关的重写。
WorkBuddy 的前端组件、任务数据结构、日程逻辑,这些和"企业"没关系,直接复用。用户体系、权限校验、模型接入,这些是企业版的核心差异,必须重写。我见过有人图省事,在开源代码上打补丁,结果补丁越打越多,最后代码没法维护。正确的做法是划清边界,边界内复用,边界外重写。
具体到文件级别,我复用了大概 60% 的前端代码,重写了 100% 的后端鉴权和模型接入,新增了组织架构和 @ 通知两个模块。这个比例不是一开始就定好的,是边做边调整出来的。
5.2 性能优化的几个关键点
企业版和开源版的性能要求不一样。开源版几个人用,慢一点无所谓;企业版几百人用,任何慢操作都会被放大。
第一个优化点是数据库索引。users表的ldap_id、dept_id都加了索引,@通知表的user_id和created_at加了联合索引。加索引前,查一个用户的通知要扫全表,几百毫秒;加索引后,几毫秒。
第二个优化点是缓存。用户信息、部门树、权限列表这些读多写少的数据,全部走 Redis 缓存。缓存命中率实测在 95% 以上,数据库压力降了一个数量级。
第三个优化点是批量操作。同步任务、通知投递这些批量场景,全部改成批量 SQL,避免循环单条操作。批量插入比逐条插入快 10 倍以上。
5.3 上线后的观察与迭代
上线第一周我盯得很紧,每天看日志、看监控。发现的问题主要有三类:模型调用偶发超时、@ 通知偶发延迟、组织架构同步偶发失败。这三类问题都不是必现的,但量大了就会暴露。
模型超时我加了更细的重试策略,区分网络超时和服务超时,网络超时重试,服务超时直接返回错误。@ 通知延迟我加了队列监控,堆积超过阈值就告警。组织架构同步失败我加了失败重试,失败后 5 分钟再试一次,连续失败三次才告警。
这些迭代都不是一次做完的,是上线后根据实际数据慢慢调的。我的体会是:企业版没有"做完"的时候,只有"稳定运行"的状态,持续观察、持续优化才是常态。
最后分享一个我踩过的坑:改造初期我太关注功能实现,忽略了日志和监控。结果上线后出问题,根本不知道是哪一步出的错。后来补了全链路日志,每个关键节点都打日志,排查效率立刻上来了。如果你也在做类似的改造,建议一开始就把日志和监控搭好,别等出问题再补。