简介这是一套面向本科/高职毕业设计的在线协同编辑系统毕设源码基于微服务架构拆分后端业务前端采用Vue适合计算机相关专业学生用于课程设计、毕业设计或微服务实践学习。压缩包共253个文件约2.9MB涵盖82个Java后端服务源码、32个Vue前端页面、22个TypeScript与17个JavaScript交互逻辑并搭配Dockerfile、YAML/JSON部署配置及SQL初始化脚本便于按模块理解工程结构与本地启动。源码均经过本地编译验证按文档配置好环境即可运行项目难度适中内容经过助教老师审定。目前已有195人学习下载可作为快速上手微服务与前后端分离开发的完整参考。1. 微服务架构的在线协同编辑系统毕设源码解决了什么问题你在文档中间选中一句话准备删掉同事恰好在你选中的位置后方插入了一段新文字——最终结果是用你的删除覆盖他的插入还是让他的插入保留这不是网络延迟问题而是协同编辑系统的并发冲突问题。这套基于微服务架构的在线协同编辑系统源码把用户、文档、协作、通知拆成独立服务最核心的难点落在 WebSocket 长连接和 OT 操作转换算法上。它适合三类人正在选微服务方向毕设题目的同学、想从单体项目转向微服务开发的从业者、对协同编辑底层机制好奇但不想从零啃论文的读者。源码把最难的部分收在服务端业务代码不绕答辩时能拿出来的点却不少。2. 架构与服务拆分从用户登录到文档协同的消息链路2.1 服务拆分五个业务服务与一个网关的职责边界这套系统的服务划分是典型的微服务课程设计思路按业务域拆不按技术层拆。网关单独成一个服务负责路由和统一鉴权业务服务之间不直接开放 HTTP 接口给对方调所有跨服务请求都走网关。具体拆分如下服务端口职责关键依赖gateway-service8080路由转发、登录鉴权、WebSocket 握手入口Spring Cloud Gateway、JWTuser-service8101用户注册、登录、用户信息维护MySQL、Redisdoc-service8102文档 CRUD、文档快照存取、版本号管理MySQL、MinIOcollab-service8103WebSocket 长连接管理、OT 转换、消息广播Redis、MySQLnotify-service8104协作者加入提醒、文档分享通知Redis 队列注意 doc-service 和 collab-service 都操作文档数据但侧重点不同doc-service 管「文档长什么样」collab-service 管「这次编辑怎么合入」。这样拆分的好处是协作服务可以独立水平扩展——如果线上有 500 个用户同时编辑只需把 collab-service 扩容到多实例doc-service 不必跟着动。代价是两份服务都访问 MySQL 同一条 doc 记录所以在保存快照时必须有版本号机制兜底这个放到第 4 章细说。2.2 协同核心OT 操作转换算法与 WebSocket 消息协议选 OT 而不是 CRDT是有意的取舍。CRDT如 Yjs更适合同步和离线优先场景但引入现成库之后答辩时没有太多可展开的原理。OT 需要一个中心服务器做串行化和转换正好和 collab-service 的定位匹配而且算法部分可以自己实现并讲清楚。常见做法是只支持文本的 insert 和 delete 两种操作不做富文本这样 transform 逻辑能控制在一个类里。// OperationTransform.java public class OperationTransform { // 服务端已按 revision 排序opA 先到opB 后到 public static Op transform(Op opA, Op opB) { if (opA.type OpType.INSERT opB.type OpType.INSERT) { // 两个插入撞在同一位置位置小的先落后到的让位 if (opA.pos opB.pos) { return opA; } // 位置相等时用 clientId 保证全序避免随机结果 if (opA.clientId.compareTo(opB.clientId) 0) { return opA; } return new Op(opA.type, opA.pos opB.len(), opA.text, opA.clientId); } if (opA.type OpType.INSERT opB.type OpType.DELETE) { // B 删在 A 插入位置之前A 的位置需要左移 if (opB.pos opA.pos) { return new Op(opA.type, opA.pos - opB.len(), opA.text, opA.clientId); } return opA; } if (opA.type OpType.DELETE opB.type OpType.INSERT) { // B 插入在 A 删除区间之前A 的删除位置右移 if (opB.pos opA.pos) { return new Op(opA.type, opA.pos opB.len(), opA.text, opA.clientId); } return opA; } // delete 与 delete 的区间重叠判断较长实际项目里放到单独方法 return opA; } }这段代码是 OT 里最核心的位置修正逻辑。insert/insert 冲突时让后到者向后挪 len 个位置insert/delete 冲突时按删除点相对插入点的前后关系做位移核心思想就是「把并发操作变换到同一时刻的文档视图上」。clientId是每个浏览器会话生成的唯一标识用来解决位置完全相同时的次序问题注意这不是登录用户 ID一个用户可以同时开多个标签页。服务端和客户端之间走 JSON 消息格式定得越简单越不容易出错。实际项目里我会再加一个opId做幂等防止重试造成重复插入课堂演示做到下面这个粒度就够了{ type: op, docId: d_1001, clientId: c_8f2a3b, revision: 12, opType: insert, pos: 45, text: A }字段含义说明type消息类型op / cursor / sync / ping 四类revision客户端基于的文档版本服务端用来判断能否直接 applyopType操作类型只有 insert 和 delete 两种pos操作位置基于当前 revision 的文档下标从 0 开始text插入文本或删除长度delete 时 text 填删除的字符数2.3 数据一致性与存储设计Redis 管在线状态MySQL 管文档快照用户在线状态不适合放 MySQL因为协作者列表是高频读写的。Redis 里用一个 set 存某个文档的在线成员key 设计成doc:online:{docId}成员是 userId。光标位置用更短的 keydoc:cursor:{docId}:{userId}存一个整数下标过期时间 60 秒配合心跳续期。这样用户关掉页面最多 60 秒后自动从在线列表消失不需要显式发离线消息。文档正文存 MySQL表结构里必须有一列revision记录版本号。每次收到合法操作revision 就加一保存快照时把最新文档内容整体写回。为什么不存操作日志再重放因为重放几百条 op 既慢又难排查快照加版本号的方式恢复成本最低最多丢最后一次未落盘的修改。一次按键的完整链路是浏览器 WebSocket 发送 op 到网关网关转发给 collab-servicecollab-service 做 OT 转换后把结果广播给协作者同时异步调用 doc-service 的保存接口doc-service 对 revision 做乐观锁更新 MySQL。这条链路第 3 章跑通之后你会对微服务之间「同步调用做实时广播、异步调用做持久化」的分工有更直观的感觉。3. 本地复现整套源码环境版本对齐与双人协同编辑跑通3.1 环境版本对齐先把版本矩阵钉死再谈启动微服务毕设 90% 的启动失败不是代码问题而是 Spring Cloud Alibaba、Spring Boot、Nacos 三者的版本不匹配。常见做法是直接照抄项目里pom.xml的依赖版本不要自己升版本。我平时拆这类项目时第一步永远是先画一张版本对照表组件版本备注JDK17配 Spring Boot 3.x 必须 17Spring Boot3.2.x由 Spring Cloud Alibaba BOM 决定Spring Cloud Alibaba2022.0.0.0适配 Spring Boot 3.2不要用 2.x 老版Nacos Server2.2.32.x 配置格式与 1.x 不同MySQL8.0字符集必须 utf8mb4Redis7.x小版本随意Redis 6 以上均可Node.js18前端 Vue3 Vite 构建16 以下会报错3.2 数据库与配置中心初始化SQL 导入和 Nacos 配置先起基础设施。MySQL 和 Redis 用 docker 拉起是最省时间的注意给 MySQL 加--character-set-serverutf8mb4否则中文存入后取出来是乱码。Nacos 用官方 bin 脚本单机模式启动即可。# 启动 MySQL指定字符集 docker run -d --name collab-mysql \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD123456 \ mysql:8.0 \ --character-set-serverutf8mb4 --collation-serverutf8mb4_unicode_ci # 启动 Redis docker run -d --name collab-redis -p 6379:6379 redis:7-alpine # 启动 Nacos 单机模式控制台地址 http://127.0.0.1:8848/nacos cd nacos/bin sh startup.sh -m standalone数据库脚本在项目包的sql/目录下核心是下面这两张表。doc_user 是文档与用户的关联表控制谁能编辑这份文档CREATE DATABASE IF NOT EXISTS collab_doc DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE collab_doc; CREATE TABLE doc ( id BIGINT PRIMARY KEY AUTO_INCREMENT, doc_name VARCHAR(128) NOT NULL, content LONGTEXT NOT NULL, revision INT NOT NULL DEFAULT 0, owner_id BIGINT NOT NULL, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_owner (owner_id) ) ENGINEInnoDB; CREATE TABLE doc_user ( doc_id BIGINT NOT NULL, user_id BIGINT NOT NULL, role TINYINT NOT NULL DEFAULT 1 COMMENT 1 可编辑2 只读, PRIMARY KEY (doc_id, user_id) ) ENGINEInnoDB;revision是并发控制的锚点第 4 章的保存冲突问题要靠它来解决。每个服务里都要有bootstrap.yml指向 Nacos数据源配置统一放在 Nacos 的配置中心不在本地写死这是微服务配置和单体项目最直观的区别# collab-service/src/main/resources/bootstrap.yml spring: application: name: collab-doc-service cloud: nacos: config: server-addr: 127.0.0.1:8848 namespace: collab-dev file-extension: yaml discovery: server-addr: 127.0.0.1:8848namespace用来做环境隔离dev、test、prod 各一个。如果你在 Nacos 控制台新建了命名空间记得把生成的命名空间 ID 填进来填名字是无效的。数据库连接、Redis 地址这些敏感配置都放到 Nacos 里维护本地配置文件只留注册中心和配置中心地址。3.3 后端服务启动顺序先注册中心再启动业务服务启动顺序有讲究但没网上说的那么玄。唯一硬性要求是 Nacos 先启动业务服务后启动服务之间不需要严格按顺序。因为 Spring Cloud 的负载均衡会在调用时动态发现服务哪怕 doc-service 还没起来collab-service 启动也不会失败只是首次调用会报连接异常。稳妥起见推荐按 user → doc → collab → notify → gateway 的顺序起# 以 doc-service 为例项目根目录执行 mvn clean package -DskipTests java -jar doc-service/target/doc-service-1.0.0.jar # 验证 Nacos 服务列表是否已经有 4 个服务注册进来 curl -X GET http://127.0.0.1:8848/nacos/v1/ns/catalog/services?namespaceIdcollab-devpageNo1pageSize10 # 或者直接在 Nacos 控制台服务管理-服务列表里看Nacos 控制台里能看到服务名、实例 IP 和端口就说明注册成功了。常见的一个翻车点服务启动成功但控制台里看不到先检查 namespaceId 是否匹配再看spring.application.name是否写错。如果用了 Maven 多模块mvn clean package一定要在根目录执行子模块单独打包会因为依赖不到 sibling 模块而失败。3.4 前端 Vue 工程启动与联调前端是标准的 Vue3 Vite 工程环境变量文件.env.development控制接口地址。网关地址是 8080前端所有请求都走网关不要直连 8103 端口否则 Cookie 和鉴权头会乱掉// vite.config.js 项目根目录下创建 .env.development VITE_API_BASEhttp://localhost:8080/api VITE_WS_BASEws://localhost:8080/wscd frontend npm install npm run dev这里的VITE_WS_BASE是 WebSocket 的入口网关需要对/ws/**做转发。Vite 默认监听 5173 端口登录时前端把账号密码 POST 到网关的/api/user/login拿到的 JWT 存进 localStorage之后每次建立 WebSocket 连接时把 token 放到 query 参数里传过去。3.5 功能验收两个浏览器窗口验证并发编辑不丢字跑通之后先别急着改代码按下面这套流程验收。注册两个账号在 A 窗口创建文档把文档 ID 发给 B 窗口B 打开同一文档后A 在开头输入 20 个字符B 在末尾同时输入 20 个字符最后检查正文——两端的内容合并结果应该等于 A 的内容加 B 的内容且顺序符合操作先后。再把光标停在中间做交叉操作A 选中中间一段准备删除B 在删除区间末尾插入一行释放删除后B 插入的内容必须完整保留。想验证并发压力下的 revision 变化可以用 Node 脚本模拟两个 WebSocket 客户端同时发操作// stress-test.js const wsA new WebSocket(ws://localhost:8080/ws/doc?docIdd_1001) const wsB new WebSocket(ws://localhost:8080/ws/doc?docIdd_1001) let revision 0 // 两个客户端交替发送 50 次 insert位置随机 function sendOp(ws, text) { const pos Math.floor(Math.random() * 20) ws.send(JSON.stringify({ type: op, docId: d_1001, clientId: test-a, revision, opType: insert, pos, text })) revision }脚本跑完后重点看服务端日志有没有revision mismatch的告警如果出现说明 OT 串行化逻辑需要修具体排查方法见第 4 章的第三条避坑记录。4. 协同编辑避坑排查五个部署与并发场景的真实翻车点4.1 Nacos 配置不生效服务起了但连接的是本地默认配置现象服务能启动但日志里数据源地址是127.0.0.1:3306而不是 Nacos 里配置的地址甚至直接报Failed to configure a DataSource。原因Spring Boot 3.x 默认不再加载bootstrap.yml需要显式引入spring-cloud-starter-bootstrap依赖否则项目读取不到 Nacos 配置中心的数据源、Redis 等配置。解决在pom.xml中加入以下依赖再重新启动。注意这个依赖要加在每一个需要从 Nacos 拉取配置的服务模块里漏一个就会翻一个。dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-starter-bootstrap/artifactId /dependency也可以在application.yml里用spring.config.import: nacos:collab-dev.yaml替代但改动面较大还是加依赖最省事。从那以后我每次拆 Spring Cloud 项目都会先看一眼依赖树里有没有 starter-bootstrap这已经是肌肉记忆了。4.2 WebSocket 握手被网关拦截404 还是 403现象前端控制台里 WebSocket 连接一直pending几秒后报Error during WebSocket handshake后端日志看不到任何连接日志。原因Spring Cloud Gateway 默认对未知路径返回 404WebSocket 升级请求Upgrade: websocket走到网关就被吞掉了根本没到达 collab-service。另外网关开启了 JWT 鉴权过滤器没有放行/ws/**路径。解决在网关配置里加一条路由匹配/ws/**转发到 collab-service并在鉴权过滤器白名单里加入该路径。下面是网关路由的关键配置spring: cloud: gateway: routes: - id: collab-websocket uri: lb://collab-doc-service predicates: - Path/ws/** filters: - StripPrefix1这里StripPrefix1会把/ws前缀剥掉collab-service 里的 WebSocket endpoint 就不用再写一层/ws路径。4.3 并发编辑丢字服务端处理顺序错了现象压力测试时偶尔丢字符两个协作者各自看到的内容不一致刷新后才恢复。原因collab-service 收到操作的顺序和客户端发出顺序不完全一致服务端没有按revision做串行化就直接执行了 OT 转换。OT 的前提是操作基于同一文档版本依次到达如果 clientId 为 A 的 revision 12 还没落库B 的 revision 12 就进来了转换结果必然错。解决collab-service 内部维护一个按 docId 路由的单线程执行器同一篇文档的操作全部进同一个队列消费时校验revision是否等于当前文档版本不相等就丢弃并要求客户端重新拉取快照。注意 revision 从 0 开始第一个操作是 revision 0合法操作的 revision 必须严格递增缺失即视为丢消息。4.4 保存文档死锁WebSocket 线程里做了同步写库现象两人同时编辑并触发保存时偶发Lock wait timeout exceeded异常文档服务接口耗时飙升。原因操作广播是实时的全部跑在 WebSocket 的 Netty 线程里如果直接在广播路径上同步调 doc-service 的保存接口两个线程同时UPDATE doc SET content?, revision? WHERE id?InnoDB 行锁竞争激烈且 Netty 线程被阻塞后整个服务的 WebSocket 心跳都断了。解决保存动作改成异步。collab-service 收到操作后先广播把快照持久化消息丢进 Redis 队列doc-service 监听队列做落库。更新语句必须带 revision 条件做乐观锁UPDATE doc SET content ?, revision revision 1 WHERE id ? AND revision ?;受影响行数为 0 时说明版本已被其他协作者抢先更新文档服务需要重新拉取最新快照再做合并而不是盲目覆盖。4.5 断线重连后本地状态过期光标乱跳、内容回滚现象浏览器刷新页面后文档内容短暂回滚到几分钟前的版本光标位置错乱。原因WebSocket 断开重连时前端没有重新拉取最新快照而是拿本地旧内容继续渲染服务端重新广播协作者的光标时旧的revision和新快照对不上OT 转换直接失配。解决前端断线重连后必须发sync消息服务端收到后返回当前文档完整快照和最新 revision。前端以快照为基准重新渲染再把离线期间缓存的未发送操作逐一转换后补发。这个机制是协同编辑不掉字的底裤省略掉后续所有体验问题都会从这里冒头。重连逻辑可以简单封装为// 重连后先同步再补发离线操作 ws.onopen () { ws.send(JSON.stringify({ type: sync, docId })) } // 收到快照后重置编辑器再补发 pendingOps ws.onmessage (e) { const msg JSON.parse(e.data) if (msg.type snapshot) { editor.reset(msg.content, msg.revision) pendingOps.forEach(op ws.send(op)) pendingOps [] } }5. 进阶改造把光标位置广播做成在线协作的仪式感协同编辑做到不丢字只是及格用户感知最明显的其实是远端光标——看到别人的光标在动才有「一起写」的感觉。这个功能加在 collab-service 上非常顺手不需要动 OT 核心。消息协议只需要再扩展一种cursor类型载荷里带上 userId 和光标在文档中的下标服务端收到后不落库直接转发给同一文档的其他协作者理论上毫秒级就能到另一端。前端渲染远端光标的核心逻辑是光标跟随。用一个透明的span元素作为远端光标挂载点样式做成和其他用户头像颜色呼应的竖条元素跟随文本流走天然具备插入和删除时的位置适应能力。每次收到 cursor 消息就重新定位一次// 远端光标渲染 function renderRemoteCursor(userId, pos, color) { let marker cursorLayer[userId] if (!marker) { marker document.createElement(span) marker.className remote-cursor marker.style.borderLeft 2px solid ${color} cursorLayer[userId] marker } // 通过 Range 对象精确定位到 pos 下标 const range document.createRange() range.setStart(editorTextNode, pos) range.collapse(true) range.insertNode(marker) }createRange的方式比 CSS 绝对定位可靠得多它让光标成为文本流的一部分协作者在你前面加了字远端光标会自动往后挪不需要额外换算坐标。注意服务端要做一层过滤同一文档内 userId 相同的 cursor 消息直接丢弃避免自己的光标被广播回来后绕一圈。再配合 60 秒心跳机制客户端每 20 秒发一次ping服务端记录最后活动时间Redis 里doc:cursor:{docId}:{userId}设置 60 秒过期。协作者列表靠这个 key 的扫描生成用户关页面后最多 1 分钟自动消失不需要处理浏览器关闭事件。这套光标广播加心跳的方案思路和代码量都适合写进毕设的「系统创新点」章节。可能有人会问为什么不用现成的 y-websocket 或者 ShareDB这些库确实几分钟就能接好但对你理解这套系统没有帮助。自己实现一次 OT、自己广播一次光标你才能真正说出「revision 不一致会发生什么」这种有深度的话。从那以后我每次拉下来一套微服务毕设源码第一步一定不是看业务代码而是先把服务拆分图、消息格式、版本对齐表钉在屏幕上再逐个服务启动。这套在线协同编辑系统的坑基本都集中在这三张表上填平它们之后剩下的代码顺着链路读就能读懂。希望帮到你。本文还有配套的精品资源点击获取