简介:这是一套面向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 和分页,减少手写 SQL;Redis 扛课程详情和热门问答的缓存;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_PASSWORD=root 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 Page<CourseVO> pageCourses(CourseQuery query) { Page<CourseVO> page = new Page<>(query.getPageNum(), query.getPageSize()); LambdaQueryWrapper<Course> 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}") Result<CourseVO> getCourseById(@PathVariable("id") Long id); } @Component public class CourseClientFallback implements CourseClient { @Override public Result<CourseVO> 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 Map<String, 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); Map<String, 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-course,Path=/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 policy,OPTIONS 请求返回 403。原因:网关跨域配置没放行 OPTIONS,或者allowed-headers没包含自定义头。解决:在网关跨域配置里确保allowed-methods包含 OPTIONS,allowed-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, List<CourseVO> 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; filename=course.xlsx"); workbook.write(response.getOutputStream()); workbook.dispose(); // 清理临时文件 }逻辑说明:SXSSFWorkbook(100)表示内存中只保留 100 行,超出部分写临时文件。workbook.dispose()必须调用,否则临时文件残留。响应头里的文件名如果含中文,要做 URL 编码。
参数上,滑动窗口大小根据内存和导出量调整,太小会频繁写磁盘,太大失去意义。导出接口建议加权限校验和限流,防止被恶意调用。
我自己做这类项目时,习惯先把注册中心和网关跑通,再逐个接业务服务,每接一个就用 Postman 或前端页面验证一条链路。这样出问题时范围小,不用在几十个服务里猜。另外,所有中间件连接信息都放配置中心,本地用命名空间隔离,避免误连生产。希望帮到你。
本文还有配套的精品资源,点击获取