
简介这是一套面向Java开发者与在线教育项目学习者的微服务实战资源聚焦课程、问答、文章三大前台业务及后台运营平台适合具备SpringBoot基础、希望进阶SpringCloud微服务与前后端分离开发的中高级学习者参考。项目后端采用SpringBoot SpringCloud MyBatis-Plus HttpClient MySQL Docker Maven前端基于Node.js Vue.js并整合Redis、ActiveMQ、阿里云OSS与视频点播业务层用ECharts做图表展示、POI完成用户信息批量上传注册分布式单点登录使用JWT微服务分库设计配合Swagger生成接口文档。压缩包为zip格式整体约198KB文件总数与类型明细上游暂未提供。目前已有1154人学习下载读者可借此梳理微服务拆分思路、分库设计、单点登录与云服务接入方案理解前后端分离下的接口协作与中间件使用场景适合作为课程设计或企业级项目练手的结构参考。1. online_edu 微服务在线教育系统从课程到问答一套能跑通的前后端分离骨架如果你正在找一个「基于 SpringBoot Vue 的前后端分离项目实战」来练手或者团队要快速搭一套在线教育业务底座online_edu 这个方向值得认真看。它把前台用户系统拆成课程、问答、文章三块后台运营平台独立部署整体走 SpringCloud 微服务架构中间件覆盖 Redis、ActiveMQ、阿里云 OSS 和视频点播图表用 ECharts文档导出用 POI。这套组合不是玩具它对应的是真实教育平台的业务切面课程要展示、要播放、要统计问答要发帖、要回复、要审核文章要发布、要检索、要分页。适合已经会写单体 SpringBoot 项目、想往微服务拆分和前后端分离工程化迈一步的开发者也适合需要一套可扩展教育系统骨架的技术负责人。下面按「先立住架构、再动手复现、最后避坑」的顺序讲透。2. 微服务拆分与前后端分离online_edu 的架构决策怎么定2.1 为什么课程、问答、文章要拆成独立服务在线教育系统的业务边界天然清晰课程服务负责课程 CRUD、章节、视频元数据问答服务负责提问、回答、采纳、点赞文章服务负责资讯、教程、公告的发布与浏览。这三块的数据一致性要求低查询模式差异大——课程偏详情聚合问答偏列表和热度排序文章偏全文检索和分页。如果塞进一个单体后期任何一块要扩容都得整体复制数据库连接池和缓存 key 也会互相挤占。常见做法是按业务能力拆而不是按技术层拆。我一般会先画一张微服务架构图把网关、认证、业务服务、中间件分层标出来。online_edu 的拆分粒度建议控制在 4 到 6 个服务网关服务、认证服务、课程服务、问答服务、文章服务后台运营平台可以复用同一套服务但走独立前端。拆太细会让分布式事务和链路追踪成本陡增拆太粗又失去微服务的意义。选型上SpringCloud 提供注册发现、配置中心、网关和负载均衡MyBatis-Plus 负责单表 CRUD 和分页减少手写 SQLRedis 扛课程详情和热门问答的缓存ActiveMQ 处理视频转码完成、文章审核通过这类异步通知。这套组合的成熟度高社区资料多遇到问题容易搜到答案。2.2 前后端分离的接口约定与跨域处理前端用 Node.js Vue.js后端只提供 JSON 接口两边通过 HTTP 契约协作。第一步是定接口规范统一响应体{ code, message, data }分页参数统一用pageNum和pageSize时间字段统一 ISO8601 字符串。这样前端封装 axios 拦截器时不用为每个接口写特殊逻辑。跨域在开发阶段用网关统一配置不要在每个 Controller 上加CrossOrigin。生产环境走同域反向代理前端静态资源由 Nginx 托管/api前缀转发到网关。下面是一个网关跨域配置的常见写法# gateway 服务的 application.yml 片段 spring: cloud: gateway: globalcors: cors-configurations: [/**]: allowed-origins: http://localhost:8080 # 开发环境前端地址 allowed-methods: * allowed-headers: * allow-credentials: true max-age: 3600逻辑说明allowed-origins在生产环境要换成真实域名不要用*配合allow-credentials浏览器会直接拒绝。max-age减少预检请求频率。参数上如果前端带 cookie 或 Authorization 头allow-credentials必须为 true且 origins 不能是通配符。前端 axios 封装建议统一加请求拦截器注入 token响应拦截器处理 401 跳登录、500 弹提示。这样课程列表、问答详情、文章分页三个模块可以共用同一套请求逻辑减少重复代码。2.3 本地跑通的最小步骤从拉代码到第一个接口返回假设你已经拿到 online_edu 的代码包本地要跑通「课程列表」这条链路按下面顺序操作。第一步启动 MySQL 和 Redis导入初始化 SQL确认课程表有测试数据。第二步启动注册中心Eureka 或 Nacos再启动网关和课程服务。第三步启动前端npm install npm run serve。第四步浏览器访问前端课程页看 Network 里/api/course/list是否返回 200。# 本地启动顺序示例假设使用 docker 跑中间件 docker run -d --name edu-mysql -p 3306:3306 -e MYSQL_ROOT_PASSWORDroot mysql:8.0 docker run -d --name edu-redis -p 6379:6379 redis:6.2 docker run -d --name edu-activemq -p 61616:61616 -p 8161:8161 webcenter/activemq # 后端按模块启动先注册中心再网关再业务服务 mvn -pl edu-registry spring-boot:run mvn -pl edu-gateway spring-boot:run mvn -pl edu-course spring-boot:run逻辑说明中间件用 Docker 跑能避免本地版本冲突MySQL 8.0 注意时区和驱动类名。启动顺序不能乱注册中心没起来时业务服务会反复重连。参数上ActiveMQ 的 8161 是控制台端口61616 是消息端口业务服务里配置的 broker URL 要对应。如果课程列表返回 500先看课程服务日志有没有数据库连接异常再看网关有没有正确路由。常见问题是网关路由的uri写成了http://localhost:8081但服务注册名是edu-course应该用lb://edu-course走负载均衡。3. SpringBoot SpringCloud MyBatis-Plus 的落地配置3.1 服务注册发现与配置中心的参数怎么设SpringCloud 里注册发现和配置中心是微服务的底座。用 Nacos 时每个业务服务的bootstrap.yml要配 Nacos 地址、命名空间和分组。命名空间用来隔离开发、测试、生产环境分组用来隔离同一环境下的不同项目。很多新手把命名空间和分组混用导致本地服务注册到测试环境调试时找不到实例。# edu-course 的 bootstrap.yml spring: application: name: edu-course cloud: nacos: discovery: server-addr: 127.0.0.1:8848 namespace: dev group: ONLINE_EDU config: server-addr: 127.0.0.1:8848 namespace: dev group: ONLINE_EDU file-extension: yaml逻辑说明spring.application.name是服务注册名网关路由和 Feign 调用都依赖它。namespace和group必须与 Nacos 控制台里创建的一致否则服务注册上去但拉不到配置。file-extension决定去 Nacos 拉edu-course-dev.yaml还是.properties。参数上server-addr如果 Nacos 开了鉴权还要加 username 和 password。生产环境建议把 Nacos 配成集群单机版只适合本地开发。服务启动后去 Nacos 控制台看服务列表如果实例数为 0检查网络和命名空间。3.2 MyBatis-Plus 分页与课程查询的代码模板课程列表通常要按分类、难度、价格区间筛选再分页返回。MyBatis-Plus 的分页插件能省掉手写 limit 的麻烦但要注意分页插件要注册成 Bean否则Page对象不生效。// 课程服务分页查询示例 Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } } Service public class CourseServiceImpl implements CourseService { Autowired private CourseMapper courseMapper; public PageCourseVO pageCourses(CourseQuery query) { PageCourseVO page new Page(query.getPageNum(), query.getPageSize()); LambdaQueryWrapperCourse wrapper new LambdaQueryWrapper(); wrapper.eq(query.getCategoryId() ! null, Course::getCategoryId, query.getCategoryId()) .like(StringUtils.hasText(query.getKeyword()), Course::getTitle, query.getKeyword()) .orderByDesc(Course::getCreateTime); return courseMapper.selectPage(page, wrapper); } }逻辑说明PaginationInnerInterceptor必须指定数据库类型否则分页 SQL 方言可能不对。LambdaQueryWrapper的条件用condition参数控制是否拼接避免为 null 时生成category_id null这种错误 SQL。orderByDesc放在最后保证排序稳定。参数上pageNum从 1 开始pageSize建议限制最大值比如 100防止前端传超大值拖垮数据库。如果课程表数据量大like查询要配合索引或改用全文检索否则翻到后面会越来越慢。3.3 用 Feign 打通课程与问答服务的调用问答服务里展示「相关课程」时需要调课程服务的接口。用 OpenFeign 声明式调用比手写 HttpClient 更清晰但要注意超时和降级。下面是一个 Feign 客户端示例FeignClient(name edu-course, fallback CourseClientFallback.class) public interface CourseClient { GetMapping(/course/{id}) ResultCourseVO getCourseById(PathVariable(id) Long id); } Component public class CourseClientFallback implements CourseClient { Override public ResultCourseVO getCourseById(Long id) { return Result.fail(课程服务暂时不可用); } }逻辑说明name对应课程服务的注册名Feign 会从注册中心找实例。fallback在课程服务超时或报错时返回兜底数据避免问答页面整体挂掉。PathVariable里的值要和路径变量名一致。参数上Feign 默认超时较短建议在配置里调大 connectTimeout 和 readTimeout。如果课程服务返回的是分页对象Feign 接口的返回类型要能反序列化泛型嵌套时注意 Jackson 的类型信息。4. Redis、ActiveMQ 与阿里云 OSS 的集成避坑4.1 课程详情缓存key 设计与过期策略课程详情是读多写少的典型场景用 Redis 缓存能显著降低数据库压力。key 设计建议用edu:course:detail:{courseId}冒号分层便于管理和批量删除。过期时间不要统一设成固定值加随机偏移防止缓存雪崩。public CourseVO getCourseDetail(Long courseId) { String key edu:course:detail: courseId; String cached redisTemplate.opsForValue().get(key); if (cached ! null) { return JSON.parseObject(cached, CourseVO.class); } CourseVO course courseMapper.selectDetailById(courseId); if (course ! null) { // 基础 30 分钟加 0 到 300 秒随机偏移 long expire 1800 new Random().nextInt(300); redisTemplate.opsForValue().set(key, JSON.toJSONString(course), expire, TimeUnit.SECONDS); } return course; }逻辑说明先查缓存再查库查库后回写缓存。随机偏移避免同一时间大量 key 同时失效。如果课程更新要主动删除缓存而不是更新缓存避免并发写导致脏数据。参数上expire根据课程更新频率调整运营频繁改价的课程可以设短一点。缓存对象建议存 JSON 字符串而不是 Java 序列化对象方便跨语言和排查。4.2 ActiveMQ 异步通知视频转码完成后的消息消费视频点播场景里用户上传视频后要转码转码完成再更新课程章节的播放地址。这个链路适合用 ActiveMQ 解耦上传服务发消息课程服务消费消息更新状态。注意消息要幂等因为 MQ 可能重复投递。JmsListener(destination edu.video.transcode.complete) public void onTranscodeComplete(String message) { VideoTranscodeMsg msg JSON.parseObject(message, VideoTranscodeMsg.class); // 幂等先查状态已处理直接返回 Chapter chapter chapterMapper.selectById(msg.getChapterId()); if (chapter ! null READY.equals(chapter.getVideoStatus())) { return; } chapterMapper.updateVideoUrl(msg.getChapterId(), msg.getVideoUrl()); }逻辑说明JmsListener监听指定队列消息体用 JSON 字符串便于排查。幂等判断放在最前面避免重复更新。更新操作要加乐观锁或状态条件防止并发覆盖。参数上ActiveMQ 的队列名要统一管理不要硬编码在多个地方。如果消息量大考虑用虚拟主题或分区队列。消费失败要配死信队列否则消息丢失后很难追。4.3 阿里云 OSS 上传前端直传与后端签名的选择课程封面和文章配图需要上传到 OSS。常见两种方案前端直传后端只发签名和后端中转。前端直传能减轻后端带宽压力但签名接口要控制有效期和上传路径。后端中转实现简单但大文件会占用服务线程。public MapString, String buildOssPolicy(String dir) { long expireEndTime System.currentTimeMillis() 300 * 1000; // 5 分钟有效 String expiration Instant.ofEpochMilli(expireEndTime).toString(); PolicyConditions conditions new PolicyConditions(); conditions.addConditionItem(PolicyConditions.COND_CONTENT_LENGTH_RANGE, 0, 10485760); conditions.addConditionItem(MatchMode.StartWith, PolicyConditions.COND_KEY, dir); String postPolicy ossClient.generatePostPolicy(expiration, conditions); String signature ossClient.calculatePostSignature(postPolicy); MapString, String result new HashMap(); result.put(policy, Base64.encode(postPolicy)); result.put(signature, signature); result.put(dir, dir); return result; }逻辑说明签名有效期设短一点防止被滥用。COND_CONTENT_LENGTH_RANGE限制文件大小MatchMode.StartWith限制上传路径前缀。前端拿到 policy 和 signature 后直接 POST 到 OSS。参数上dir按业务分目录比如course/cover/和article/image/。OSS 的 endpoint 和 bucket 要区分内网和外网后端签名用外网地址服务端上传可以用内网地址省流量。5. 常见问题与排查online_edu 部署和联调中的血泪经验5.1 服务注册不上或网关 404现象业务服务启动日志显示注册成功但网关转发请求返回 404。原因通常是网关路由配置的uri用了http://而不是lb://或者路径断言写错。解决检查网关application.yml里spring.cloud.gateway.routes的uri和predicates确保uri: lb://edu-coursePath/course/**与服务实际路径匹配。如果用了 Nacos确认网关和服务在同一个命名空间和分组。5.2 分页查询返回总数不对现象MyBatis-Plus 分页返回的total是 0 或明显偏小。原因可能是分页插件没注册或者查询用了自定义 SQL 但没走分页拦截。解决确认MybatisPlusInterceptor已注册且包含PaginationInnerInterceptor。如果是自定义 XML SQL要手动写 count 查询或使用IPage参数。另外检查pageNum是否从 0 开始传MyBatis-Plus 默认从 1 开始。5.3 Redis 缓存与数据库不一致现象课程更新后前端仍看到旧数据。原因通常是更新数据库后没有删除缓存或者删除缓存失败但没重试。解决采用「先更新数据库再删除缓存」策略删除失败时记录日志并补偿。如果并发极高可以用延迟双删更新后删一次延迟几百毫秒再删一次。注意不要用「先删缓存再更新数据库」并发下容易读到旧数据回写。5.4 ActiveMQ 消息重复消费导致状态错乱现象视频转码完成消息被消费两次章节状态被重复更新。原因MQ 的 at-least-once 语义网络抖动或消费者重启会导致重投。解决消费端做幂等用唯一业务 ID 查状态已处理直接返回。更新时加条件比如update chapter set video_url ? where id ? and video_status ! READY。同时配置死信队列处理多次失败的消息。5.5 前端跨域预检失败现象浏览器控制台报CORS policyOPTIONS 请求返回 403。原因网关跨域配置没放行 OPTIONS或者allowed-headers没包含自定义头。解决在网关跨域配置里确保allowed-methods包含 OPTIONSallowed-headers用*或列出Authorization、Content-Type。如果用了 Spring Security还要在安全配置里放行 OPTIONS 请求。6. 用 ECharts 和 POI 做运营数据导出与图表展示的进阶技巧后台运营平台需要看课程销量趋势、问答活跃度、文章阅读量这些数据用 ECharts 展示最直观。前端拿到后端聚合接口后把数据映射成series和xAxis。一个常见坑是时间轴数据没排序导致折线图乱跳。后端返回前按日期升序排好前端直接渲染。// ECharts 课程销量趋势配置示例 const option { tooltip: { trigger: axis }, xAxis: { type: category, data: trendData.map(item item.date) // 后端已按日期升序 }, yAxis: { type: value }, series: [{ name: 销量, type: line, smooth: true, data: trendData.map(item item.count) }] };逻辑说明trigger: axis让 tooltip 跟随整条时间轴适合趋势图。smooth: true让折线平滑但数据点少时可能失真按需开启。数据映射前确认后端返回的日期格式统一否则 x 轴会重复或缺失。导出 Excel 用 POI注意大数据量时分批写入避免内存溢出。用SXSSFWorkbook代替XSSFWorkbook设置滑动窗口大小只保留最近若干行在内存。public void exportCourseExcel(HttpServletResponse response, ListCourseVO list) throws IOException { SXSSFWorkbook workbook new SXSSFWorkbook(100); // 内存保留 100 行 Sheet sheet workbook.createSheet(课程数据); Row header sheet.createRow(0); header.createCell(0).setCellValue(课程名称); header.createCell(1).setCellValue(销量); for (int i 0; i list.size(); i) { Row row sheet.createRow(i 1); row.createCell(0).setCellValue(list.get(i).getTitle()); row.createCell(1).setCellValue(list.get(i).getSales()); } response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setHeader(Content-Disposition, attachment; filenamecourse.xlsx); workbook.write(response.getOutputStream()); workbook.dispose(); // 清理临时文件 }逻辑说明SXSSFWorkbook(100)表示内存中只保留 100 行超出部分写临时文件。workbook.dispose()必须调用否则临时文件残留。响应头里的文件名如果含中文要做 URL 编码。参数上滑动窗口大小根据内存和导出量调整太小会频繁写磁盘太大失去意义。导出接口建议加权限校验和限流防止被恶意调用。我自己做这类项目时习惯先把注册中心和网关跑通再逐个接业务服务每接一个就用 Postman 或前端页面验证一条链路。这样出问题时范围小不用在几十个服务里猜。另外所有中间件连接信息都放配置中心本地用命名空间隔离避免误连生产。希望帮到你。本文还有配套的精品资源点击获取