说实话,很多团队说"上协作平台",最后无非是拉个群、共享个网盘、再开个在线文档,等人数一多、权限一乱、消息一炸,整个工作空间就变成数字垃圾场。做这个gstack项目的时候,我给自己定的目标就一句话:用一套自研的轻量级方案,把安全加固、监控治理和团队协作这三件本来该分开做的事,收拢到一个统一工作空间里。
这个项目前后迭代了大半年,技术栈选型很直接:前端React、后端NestJS、实时通信走Socket.IO,整体是一个前后端分离的Web端团队协作平台。标题里的"08_gstack"是我们内部的第8个业务模块代号,g就是group的意思,后来被叫成了gstack。整个过程里踩过的坑不少,尤其是实时通信的稳定性、安全边界的收紧、监控数据的口径统一这三块,每一步都有值得复盘的地方。这篇文章我把当时的架构决策、安全实现、监控治理方案和协作功能的核心链路都摊开来讲,想自己搭一套同类系统的团队可以少走不少弯路。
1. 项目整体架构与设计初心
1.1 为什么是React + NestJS + Socket.IO这套组合
先说选型。前端用React,这事没什么悬念,我们团队对React的生态最熟,组件复用和状态管理方案都现成,真要快速搭工作台类的应用,React比Vue在这类中后台场景里的周边库更顺手。后端选NestJS,看中的是它对TypeScript的原生支持、依赖注入的写法、以及模块化组织方式——团队协作平台这种业务模块边界清晰的项目,用NestJS写出来结构非常规整,不会因为人多就代码乱飞。至于Socket.IO,当时也对比过原生WebSocket和SockJS,最后还是选Socket.IO,因为它自带房间(Room)机制、自动重连、心跳检测和ack回调,这些功能做实时协作时几乎一个都省不掉。
我见过不少人在选型时犹豫,总想上更"高级"的东西。我的建议是:协作平台的核心瓶颈不在框架本身,而在实时连接管理和消息可靠性。框架选你团队最熟的,反而是最大的优势,因为后期调试效率高。
1.2 核心模块划分与数据流向
gstack从功能上划分成五个核心模块:身份认证(Auth)、成员与权限(RBAC)、消息中心(Message)、文档协作(Doc)、监控与审计(Monitor)。模块之间通过NestJS的Module机制解耦,数据层统一走TypeORM连PostgreSQL,实时状态和Token黑名单放Redis,文件上传走MinIO。
数据流向其实不复杂。前端所有请求先过NestJS网关层,经Guard做身份校验,再进业务模块;Socket连接独立走一条实时链路,通过Gateway统一管理连接和房间。这个架构的好处是,安全能力在网关层集中收口,监控能力在管道层埋点,业务层只需要关心业务逻辑本身,不会被横切逻辑稀释。
2. 安全加固实战拆解
2.1 认证体系:JWT + Refresh Token双令牌循环
安全加固第一步是认证。gstack采用短效Access Token + 长效Refresh Token的双令牌方案。Access Token有效期设在15分钟,Refresh Token有效期7天,存Redis并绑定设备指纹。每次刷新Token时,服务端校验Refresh Token的有效性和指纹信息,确认无误才签发新的Access Token,同时轮换Refresh Token。
这里有一个容易被忽视的细节:Refresh Token必须支持吊销。我们的做法是在Redis里维护一组refresh_token的key,键名是refresh:{userId}:{设备Id},值就是Token本身,并设置过期时间。用户改密码、踢人下线、或者管理员封禁账号时,直接删掉对应的key,这个设备就立刻失去刷新能力,不用等自然过期。实际运行中,这套机制帮我们在一次内部账号泄露演练里快速收掉了风险,比单纯依赖JWT过期时间靠谱得多。
Socket.IO连接的认证也有讲究。客户端建立连接时,在auth字段里带上Access Token,Gateway端写一个自定义中间件做校验,解析出userId后挂到socket.data上,后续所有事件都能直接取到当前用户身份。这个设计避免了在业务事件里反复传userId,也杜绝了前端伪造身份的可能。
2.2 权限模型:从RBAC到数据级隔离
权限模型我们用的是RBAC + 数据范围限制的组合方案。基础角色分三级:管理员(Admin)、项目负责人(Owner)、成员(Member)。每个角色对应一组权限点,比如project:create、doc:edit、member:invite,后端通过NestJS的@Roles()装饰器搭配RolesGuard统一校验。
但光有角色还不够,协作场景里最大的风险是水平越权。举个真实例子:某项目的文档列表接口如果只校验了"登录用户",没校验"这个用户是否属于该项目",那任何一个登了系统的人都能枚举项目ID把别的团队文档拉走。我们在所有涉及资源访问的Service层入口,都强制走一道checkPermission(userId, resourceId, action)的数据权限校验函数,规则写在资源表里,比如"文档的可见成员列表""项目的工作组成员表"。没有过这道校验,一律返回404而不是403——这个细节很重要,避免暴露资源是否存在。
2.3 输入安全与传输安全
输入校验方面,NestJS的ValidationPipe配合class-validator的DTO定义,几乎覆盖了所有对外接口。这里多说一句,很多后端项目校验只做"字段是否存在"和"类型是否对",这是远远不够的。我们对所有字符串字段都限制了长度上限,比如昵称最多32字符、个性签名最多200字符、文档标题最多100字符,防止有人往数据库里灌超大字段拖垮查询性能。同时用DTO把content这类富文本字段单独拎出来做清洗,过滤掉可执行的<script>标签和javascript:协议链接,XSS的风险基本就从入口堵死了。
传输安全这层,Nginx层配置了全站HTTPS,HTTP请求自动301跳HTTPS,并添加了Strict-Transport-Security响应头。Socket.IO长连接的上行路径是wss://,跨域配置用的是白名单方式,不在名单内的Origin直接拒绝握手。
2.4 审计日志与行为追踪
安全加固不能只做防御,还要能事后追溯。gstack的审计模块会在关键操作发生时写审计日志,比如登录成功/失败、权限变更、文档导出、成员移除、项目删除。这些日志单独存一张audit_log表,业务表的数据删除可以物理删,但审计日志只追加不删除,保留周期至少一年。
审计日志的埋点方式是在Service层用装饰器实现的,比如@AuditLog('DOC_EXPORT', 'document'),方法执行成功后自动记录操作人、操作对象、IP、UserAgent、操作时间、请求追踪ID。好处是审计逻辑和业务逻辑完全分离,后续加新的审计点,只需要在方法上贴一个装饰器,不需要改业务代码。
3. 监控治理体系建设
3.1 结构化日志:从"能看"到"能查"
监控治理的第一步是日志治理。早期版本我们的日志是console.log一坨,排障时只能去服务器上grep,效率低到崩溃。后来全面切换到pino做结构化日志,每条日志都带上timestamp、level、requestId、userId、module、message、context字段。requestId在NestJS中间件里生成,挂在请求上下文上,后续所有日志、审计、异常上报都会带这个ID。这样出了问题,前端把请求ID复制过来,后端一条命令就能把整个请求链路涉及的所有日志捞出来,定位速度肉眼可见地变快。
日志的采集和存储用的是Loki + Promtail方案,因为它的标签索引机制特别适合我们这种以服务维度查询日志的场景,相比ELK那一套,Loki的部署成本低很多。每个服务的日志按{service="nest-server", env="prod"}打标签,查询时只捞对应标签的数据,索引效率比全文检索差了点,但胜在轻量和便宜。
3.2 指标采集:Prometheus接入与关键监控项
日志解决的是"发生了什么",指标解决的是"系统处于什么状态"。我们通过NestJS的@nestjs/terminus做健康检查,配合prom-client暴露Prometheus格式的指标。每个服务实例的/metrics端点被Prometheus每15秒抓一次,重点监控以下几类指标:
| 指标类别 | 具体指标 | 告警阈值 |
|---|---|---|
| HTTP请求 | QPS、P95/P99延时、5xx错误率 | P99 > 2s 告警;5xx率 > 5% 告警 |
| Socket连接 | 活跃连接数、连接频次、断连率 | 连接数突增50%告警;断连率 > 10%/分钟 告警 |
| 消息链路 | 消息发送QPS、队列积压数 | 队列积压 > 1000 告警 |
| 业务指标 | 活跃项目数、DAU、文档编辑冲突次数 | 冲突率 > 5% 关注 |
Prometheus配合Grafana做了三个看板:运维总览(服务器CPU、内存、磁盘、网络)、应用状态(请求量、延迟、错误率、Socket连接)、业务健康(注册数、协作频率、存储用量)。运维和业务分开,不同角色的同事各看各的看板,不乱。
3.3 告警通知与治理策略:限流、熔断、降级
监控不只是看板,告警必须能触达责任人。我们在Grafana里配置AlertRule,通过Webhook打到飞书群里,告警等级分P0/P1/P2,P0是服务不可用,直接电话打到值班人;P1是可用性受损如P99超时、错误率飙高,飞书群里@对应服务负责人;P2是资源水位告警比如磁盘超过70%,通知运维小组处理。
治理策略上,限流用@nestjs/throttler,按IP和用户ID双维度限流。比如登录接口每IP每分钟最多20次,消息发送接口每用户每10秒最多10条,防止有人拿脚本刷屏。熔断和降级在NestJS里没有特别现成的库,我们自己封装了一个基于计数窗口的熔断器,当某个下游服务在30秒内错误率超过30%时熔断打开,后续请求直接走降级逻辑(比如文档历史版本列表直接返回"暂不可用"而不是让用户一直转圈)。这套治理机制上线后,最直观的变化是故障影响范围从之前的"模块瘫痪"缩小到"单接口降级",团队群里的报障消息肉眼可见地变少了。
4. 团队协作核心链路实现
4.1 工作空间模型与房间划分设计
团队协作的功能落点是一个"统一工作空间",里面包含项目分组、成员列表、消息频道和文档聚合。从实时通信的角度看,空间模型决定了Socket.IO的房间如何划分。我们把房间分成三级:全局广播房间(系统通知)、项目房间(项目内的所有成员订阅)、私聊房间(一对一会话)。每个房间的命名规则是proj:{projectId}和user:{userId}:{targetUserId},命名上要做到"一个房间只服务一个业务场景",避免消息串台。
房间的加入和退出由Gateway统一管理,客户端在进入项目页后emit一个joinProject事件,后端校验完权限后socket.join('proj:123');离开项目页时emitleaveProject。这里有一个踩过的坑:如果前端只在页面卸载时leave房间,用户在多个标签页开了同一个项目,一个标签页关闭会把另一个标签页也带出去。我们后来在前端做了一层引用计数,每个项目维护一份打开的标签数,只有计数归零才真正emit leave事件。
4.2 在线状态维护与心跳机制
在线状态是协作的最基础能力。我们用Redis + Socket.IO的connection状态维护了一套在线/离线/离开的三态模型。
Socket层有自带的心跳ping/pong,每25秒ping一次,如果60秒内没有收到pong,服务端主动断开并触发disconnect事件。服务端在用户连接和断开时,把用户的在线状态写进Redis的hash结构online_users,字段是userId,值是JSON,包含最新活跃时间、当前所在项目ID、连接设备类型。
状态变更的时候,会向用户所在的房间广播一个presence:update事件。为了减少广播频率,我们用"每5秒聚合一次状态并批量推送"的方式替代实时逐条推。具体做法是:状态变化先写Redis,然后由一个定时任务每5秒扫描一次Redis里的待推送队列,合并同房间的状态变更后统一广播。实测下来,成员列表的在线状态刷新延迟最多5秒,但消息推送量降低了大约70%,效果非常明显。
4.3 消息可靠性保障:ACK确认、去重与离线补拉
团队协作的核心是消息,消息就两个要求:不丢、不重复。
客户端发送消息时,emit的同时注册ack回调,socket.timeout(5000).emit('message:send', data, (err, result) => {...})。服务端收到消息后先落库,落库成功后才往房间广播,广播成功后再触发ack回调告诉发送方"消息已送达服务端"。如果5秒内没有ack,前端就标记这条消息为"发送中"状态,并提供手动重发按钮——不自动重发,避免用户输入框里还改着文字时后端重复落库。
服务端广播时,每条消息带一个全局唯一的messageId(UUID),客户端收到消息后用本地Set记录最近200条已处理的消息ID,重复的一律丢弃。这个机制解决的是Socket.IO在弱网环境下可能重复推送同一条消息的问题。离线消息的处理走补偿机制:用户上线时,前端以最后一次收到的消息ID为游标,向HTTP接口请求增量消息,GET /api/message/pull?after={lastMsgId},由后端从数据库里捞出来补齐。这样即使实时链路出现短暂断裂,也不会造成消息空白。
4.4 文档协作:编辑锁与操作合并
文档模块是协作平台里最重的一块,我们做的是轻量级的协同方案,没有上复杂的CRDT,而是采用"编辑锁 + 操作合并"策略。当时评估过Yjs这类CRDT框架,确实功能强大,但对后端服务端的改造量比较大,而且我们文档以Markdown为主,冲突发生的概率远低于富文本,编辑锁方案在投入产出比上明显更优。
实现思路是:文档以段落(Block)为粒度做锁,用户进入编辑状态时,前端向服务端申请锁定区块,服务端在Redis里记录docLock:{docId}:{blockIndex} = {userId},设置1分钟的过期时间防止死锁。其他用户如果尝试编辑同一区块,前端会收到lock:acquired事件并显示只读提示。编辑完成后前端把变更后的区块内容整体提交到服务端,保存操作带baseVersion,服务端比较版本号,如果版本落后则返回冲突标记,前端自动合并并提示用户"内容已更新,请确认"。
这套方案虽然比CRDT"低级",但胜在稳定和可控,上线大半年没有出现过一次文档内容互相覆盖的严重事故。如果你是中小团队自己搭协作工具,我对这个方案的评价是:先用锁解决80%的问题,剩下20%等业务量到了再上CRDT,别一上来就追求最难的技术。
4.5 Socket网关的高可用与扩缩容
Socket.IO的高可用是协作平台的硬骨头。单节点很好跑,但一旦要上多实例,粘性会话和跨节点推送就得解决。gstack的部署是多Node实例,前面挂Nginx做负载均衡,启用ip_hash保证同一个客户端的连接固定打到同一个实例上。但光有ip_hash不够,因为客户端会切换网络,源IP会变,连接可能落到别的节点,所以Redis Adapter是必须启用的。
我们用@socket.io/redis-adapter让所有Socket.IO节点共享同一个Redis消息通道,任何节点收到的事件,通过Redis广播到其他节点,从而实现在任意节点上都能向指定房间推送消息。这里有个重要的参数调优:Redis Adapter默认用的pub/sub通道是即时性的,如果Redis连接闪断,订阅关系会丢失,客户端感知不到,但emit的事件就静默丢失了。后来我们在Gateway层加了一层补偿:每个节点维护一份本地连接表Map<userId, Socket>,推送消息时先查本地,查不到再走Redis广播,同时定时从Redis同步全量用户节点映射关系,把跨节点推送的丢失率降到了接近零。
5. 排查实录与技术债
5.1 实时消息重复推送问题
一个印象很深的线上问题是:用户A连续发三条消息,用户B那边偶尔会看到其中某一条出现两次。排查过程花了一下午,最终定位在Socket.IO的volatile事件误用上。当时为了降低推送压力,我在某些消息事件上加了volatile标记,但volatile在高频发射时,如果底层连接正好处于写入中的状态,事件会被跳过,客户端触发重连补偿,重连后又拉到了全量增量消息,就会和实时通道推来的消息产生重叠。
解决方法是:实时通道不再用volatile标记任何业务消息,只对在线状态这类"可丢"的瞬时事件使用volatile,业务消息全部走可靠事件通道,配合前端的messageId去重,最终重复率降为了0。
5.2 慢查询拖垮文档列表接口
文档列表接口在数据量到10万条时出现明显变慢,最慢的时候接口耗时到了6秒。用EXPLAIN分析后发现问题出在doc表上的updated_at排序没走索引,而且tags字段用的是PostgreSQL的jsonb存储,查询时WHERE tags @> '["xxx"]'走了全表扫描。优化手段是:给project_id + updated_at建复合索引,排序查询直接从秒级降到毫秒级;tags字段改成GIN索引,标签过滤查询的耗时也降了一个数量级。
顺带说一个预判性的建议:列表接口一定不要用SELECT *,写清楚需要返回的字段列表,否则PostgreSQL需要回表获取数据,一旦数据量上来性能就是灾难。我们重构这个接口时顺手把所有列表接口的字段选择都重新梳理了一遍,整体慢查询数量减少了一大半。
5.3 常见问题速查表
| 问题现象 | 可能原因 | 排查要点 |
|---|---|---|
| Socket频繁断连 | Nginx代理超时时间设置太短 | 检查proxy_read_timeout,建议至少60s |
| 发送消息偶尔失败 | Redis pub/sub链路闪断 | 查看Redis日志,Gateway层加本地连接表补偿 |
| 成员列表在线状态不准 | 前端标签页引用计数Bug | 检查leaveProject事件是否被重复触发 |
| 文档保存提示版本冲突 | 编辑锁过期但前端未刷新 | 确认docLock过期时间是否小于用户编辑耗时 |
| Token刷新失败 | Refresh Token轮换时并发请求 | 刷新接口需要加分布式锁,防止并发刷新过期 |
| 权限变更后旧Token仍生效 | Token内嵌权限信息 | 权限校验应从数据库实时读取,不信任Token里的角色字段 |
5.4 安全加固的后续演进方向
安全是持续对抗的过程,不是上线安全加固模块就一劳永逸。gstack的下一阶段重点有两个方向:一是接入WebAuthn做硬件密钥登录,替代目前短信验证码这种弱因子;二是给对外分享链接加细粒度的权限控制,比如链接有效期、访问次数上限、指定企业邮箱白名单。监控治理方面的规划是接入链路追踪系统,给所有跨服务调用的请求补全完整的trace视图,到时候定位问题的时间还能再砍一半。
写在最后
从最初一个简单的群聊需求,演变成集安全、监控、协作于一体的内部工作空间,这个项目最让我有成就感的不是某个技术亮点,而是整套系统的"治理感"——安全不是某几个接口加个鉴权就完事,监控也不是装个采集器就叫可观测,协作更不是能发消息就代表高效。它们是一条链路上的三个环节,环环相扣。
如果你也在规划类似的团队协作平台,我最后的建议就三条:选型求稳不求新,安全收口在网关,监控从第一天就做。别等上线了再补安全,也别等出故障了再补监控,更别为了炫技把一个简单需求搞成复杂架构。先把核心链路走通,再逐步迭代,这样出来的系统才真正能扛住业务的大流量和团队的高频使用。