尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

自研团队协作平台gstack:安全加固、监控治理与实时协作的架构实践

自研团队协作平台gstack:安全加固、监控治理与实时协作的架构实践 说实话很多团队说上协作平台最后无非是拉个群、共享个网盘、再开个在线文档等人数一多、权限一乱、消息一炸整个工作空间就变成数字垃圾场。做这个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 TokenGateway端写一个自定义中间件做校验解析出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层配置了全站HTTPSHTTP请求自动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的部署成本低很多。每个服务的日志按{servicenest-server, envprod}打标签查询时只捞对应标签的数据索引效率比全文检索差了点但胜在轻量和便宜。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/P2P0是服务不可用直接电话打到值班人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前端就标记这条消息为发送中状态并提供手动重发按钮——不自动重发避免用户输入框里还改着文字时后端重复落库。服务端广播时每条消息带一个全局唯一的messageIdUUID客户端收到消息后用本地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层加了一层补偿每个节点维护一份本地连接表MapuserId, 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视图到时候定位问题的时间还能再砍一半。写在最后从最初一个简单的群聊需求演变成集安全、监控、协作于一体的内部工作空间这个项目最让我有成就感的不是某个技术亮点而是整套系统的治理感——安全不是某几个接口加个鉴权就完事监控也不是装个采集器就叫可观测协作更不是能发消息就代表高效。它们是一条链路上的三个环节环环相扣。如果你也在规划类似的团队协作平台我最后的建议就三条选型求稳不求新安全收口在网关监控从第一天就做。别等上线了再补安全也别等出故障了再补监控更别为了炫技把一个简单需求搞成复杂架构。先把核心链路走通再逐步迭代这样出来的系统才真正能扛住业务的大流量和团队的高频使用。
返回列表