资讯详情 SpringBoot+SpringCloud在线教育系统微服务架构与核心链路
📅 2026/10/23 18:36:03
简介online_edu 是一套面向 Java 开发者与在线教育业务学习者的微服务项目源码覆盖前台课程、问答、文章与后台运营管理场景适合用来理解前后端分离、服务拆分和分布式登录等工程实践。项目后端采用 SpringBoot、SpringCloud、MyBatis-Plus结合 MySQL、Redis、ActiveMQ、Docker、Maven 与 HttpClient前端使用 Node.js、Vue.js并接入阿里云 OSS、视频点播业务侧还使用 ECharts 做图表展示、POI 完成用户信息批量上传与注册单点登录基于 JWT接口文档由 Swagger 生成。压缩包约 198KB文件总数与类型明细上游暂未提供可结合目录结构按模块查阅。已有 1155 人浏览学习可作为课程服务、内容管理、运营后台与微服务分库设计的参考样例帮助读者梳理依赖关系、接口划分和中间件接入方式适合具备 Java 与 Spring 基础、希望积累在线教育项目经验的中级开发者。1. 课程视频看到一半断了学员的追问没人接课程视频看到第 37 分钟突然黑屏学员在问答区连发三条追问运营只能手动翻数据库查学习记录——这是不少在线教育系统上线三个月后常见的场面。这套 online_edu 在线教育系统就是冲着这类场景拆出来的前台学员端负责课程浏览、视频学习、提问回答和文章阅读后台运营端负责课程上架、内容审核与数据看板两侧共用一套基于 SpringBoot SpringCloud 的微服务后端和 Vue 前端工程。它适合两类人一是正在做毕设或课程设计、需要一套结构完整且能讲清楚的服务拆分方案的学生二是要交付一套可运行在线教育平台原型的开发者。下面按需求、架构、模块实现、避坑、验收的顺序把这份方案摊开讲。2. 总体架构设计服务拆分边界与前后端分离的接口契约架构这一步最容易出现的偏差不是技术选型错而是拆得太碎。有人一上来按表拆服务课程一张表一个服务、章节一张表一个服务结果一次课程详情页要跨四个服务调用链路长得像一条绳子出问题连日志都串不起来。这套系统按业务域切切完每个服务能独立打包、独立部署、独立扩缩容同时保证一次页面请求的跨服务调用不超过两次。2.1 按业务域切出五个服务加一个网关业务域的划分依据是「谁的数据谁负责写入」。课程、章节、课时、学习记录属于课程域问答、文章、评论属于内容域用户、角色、菜单、鉴权属于用户域后台的统计报表、导出、审核属于运营域。四个域各自一个服务前面挂一个网关统一收口。服务职责主要依赖是否对外edu-gateway路由转发、JWT 校验、跨域、限流Redis是edu-user注册登录、角色权限、菜单MySQL、Redis否edu-course课程、章节、课时、学习记录、视频凭证MySQL、Redis、对象存储、视频点播否edu-content问答、回答、文章、评论MySQL、ActiveMQ否edu-admin数据看板、报表导出、内容审核MySQL、各服务 OpenAPI否edu-common统一响应、异常、工具类、常量无否网关是唯一对外暴露的入口其余服务只开内网端口。这样做的好处是鉴权只写一遍网关解出 JWT 里的 userId 和角色塞进请求头往下透传下游服务不再重复解析 token。很多项目在这里翻车网关剥了一次头下游又解析一次两处逻辑不一致改一个忘一个权限就成了玄学。服务之间的同步调用走 HttpClient 封装好的客户端异步解耦走 ActiveMQ。为什么不全用 OpenFeign因为课程服务调用视频点播和对象存储的外部接口本身就是 HTTP统一用一层 HttpClient 封装超时、重试、日志埋点只维护一份少一层抽象少一处坑。服务内部的分层是 controller → service → mapperMyBatis-Plus 负责单表 CRUD复杂统计手写 XML别指望条件构造器把多表聚合也包了。2.2 前后端分离先定接口契约再写页面前后端分离最怕的是接口随写随改。这套方案的约定是所有接口返回统一结构前端 axios 拦截器只认一种壳业务代码里不再写 if (res.code 0) 之外的判断。// edu-common 模块统一响应体 Data public class ResultT implements Serializable { /** 业务状态码200 成功401 未登录403 无权限500 业务异常 */ private Integer code; /** 提示信息前端可直接 toast */ private String message; /** 业务数据列表接口统一返回 PageResult */ private T data; public static T ResultT ok(T data) { ResultT r new Result(); r.setCode(200); r.setMessage(success); r.setData(data); return r; } public static T ResultT fail(Integer code, String message) { ResultT r new Result(); r.setCode(code); r.setMessage(message); return r; } }这个类看着简单但它决定了后面所有接口的形态。三个字段各自承担一件事code 给前端做分支判断message 给人看data 给页面渲染。分页接口的 data 固定包一层 PageResult里面是 total、pages、current、records 四个字段前端表格组件直接对接不用每个页面单独适配。异常处理配一个 RestControllerAdvice 全局拦截业务代码里只管抛 BusinessException不再写 try-catch 包满整个 controller。参数校验用 Validated 加注解校验失败的信息由同一个 advice 转成 Result.fail前端拿到的永远是同一种结构。跨域在网关层处理不要在每个服务里加 CrossOrigin。原因是浏览器只会跟网关打交道服务里的跨域配置纯属摆设配多了还会出现响应头重复导致浏览器拒绝请求。网关里统一配置允许的来源、方法和请求头预检请求直接放行。2.3 构建与部署Maven 多模块出包Docker 起环境工程用 Maven 多模块组织父 pom 锁版本子模块只声明依赖不写版本号。这是避免「本地能跑、服务器报 NoSuchMethodError」最省事的办法版本冲突在父 pom 里一次解决。# 父工程根目录跳过测试打包全部模块 mvn clean package -DskipTests # 只打课程服务及其依赖的 common 模块 mvn clean package -pl edu-course -am -DskipTests # 构建镜像镜像名带模块与版本方便回滚 docker build -t edu/course:1.0.0 -f edu-course/Dockerfile . # 用 compose 起中间件与服务配置文件挂载在宿主机便于改参数 docker compose -f deploy/docker-compose.yml up -dDockerfile 要分层写先 COPY pom.xml 跑一次 dependency:go-offline 拉依赖再 COPY 源码打包。这样改一行 Java 代码不会把依赖层全部失效构建时间从几分钟降到几十秒。容器内时区、字符集、JVM 堆参数通过环境变量注入别在镜像里写死测试环境和生产环境的堆大小往往差一倍。docker-compose 里至少要包含 MySQL、Redis、ActiveMQ 和服务本身。数据库初始化脚本挂到 /docker-entrypoint-initdb.d首次启动自动建库建表这样别人拿到方案后一条命令就能起来答辩现场也不用现场导入 SQL。部署顺序是 MySQL → Redis → ActiveMQ → 业务服务 → 网关compose 用 depends_on 加健康检查控制不然服务起来了数据库还没初始化完第一批请求全是连接异常。3. 课程模块实现从课程发布到视频播放的完整链路课程模块是整个系统里链路最长的一块运营在后台建课程、传视频学员在前台看列表、进详情、播视频、记进度。这条链路上任何一环设计得糙后面都要返工。所以先把表定死再谈视频凭证最后处理进度存储。3.1 数据结构课程、章节、课时与学习记录课程和章节是一对多章节和课时是一对多学习记录按「用户 课时」唯一。这张设计的关键点是不把学习进度塞进课时表否则一万个学员就有一万份课时数据课时表会被写爆。-- 课程主表 CREATE TABLE edu_course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(120) NOT NULL COMMENT 课程标题, cover_url VARCHAR(500) DEFAULT NULL COMMENT 封面图地址, teacher_id BIGINT NOT NULL COMMENT 讲师ID, category_id BIGINT NOT NULL COMMENT 分类ID, price DECIMAL(10,2) DEFAULT 0.00 COMMENT 价格0为免费, status TINYINT DEFAULT 0 COMMENT 0草稿 1上架 2下架, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_category_status (category_id, status) ) COMMENT 课程表; -- 章节表 CREATE TABLE edu_chapter ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_id BIGINT NOT NULL, title VARCHAR(120) NOT NULL, sort INT DEFAULT 0 COMMENT 章节顺序, KEY idx_course (course_id) ) COMMENT 章节表; -- 课时表视频元信息只存引用不存文件 CREATE TABLE edu_lesson ( id BIGINT PRIMARY KEY AUTO_INCREMENT, chapter_id BIGINT NOT NULL, course_id BIGINT NOT NULL COMMENT 冗余课程ID避免详情页回表, title VARCHAR(120) NOT NULL, video_id VARCHAR(64) DEFAULT NULL COMMENT 点播服务返回的视频ID, duration INT DEFAULT 0 COMMENT 时长秒, free_flag TINYINT DEFAULT 0 COMMENT 1可试看, sort INT DEFAULT 0, KEY idx_chapter (chapter_id), KEY idx_course (course_id) ) COMMENT 课时表; -- 学习记录用户课时唯一 CREATE TABLE edu_learning_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, course_id BIGINT NOT NULL, lesson_id BIGINT NOT NULL, progress INT DEFAULT 0 COMMENT 已观看秒数, finished TINYINT DEFAULT 0 COMMENT 1看完, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_user_lesson (user_id, lesson_id), KEY idx_user_course (user_id, course_id) ) COMMENT 学习记录表;三个设计取舍值得说一下。课时表冗余 course_id是为了课程详情页一次查询把整棵树拉出来不用先查章节再回查课时省一次往返。video_id 存的是点播服务的视频标识视频文件本身在对象存储里数据库只存引用这是视频类系统的基本纪律——把文件塞进数据库或塞进应用服务器磁盘迁移和扩容时会非常难受。学习记录用 user_id lesson_id 做唯一索引配合 INSERT ... ON DUPLICATE KEY UPDATE把「新增还是更新」的判断交给数据库应用层不用先查后写减少一次并发下的竞态。3.2 视频上传与播放凭证签发而不是转发流量视频这条链路上最常见的错误做法是前端把文件传到业务服务业务服务再转发到对象存储。文件一过业务服务内存和带宽立刻吃紧几个并发上传就能把服务拖垮。正确的做法是直传。业务服务只负责签发上传凭证和播放凭证文件流不经过业务服务。Service public class VideoService { Autowired private HttpClientUtil httpClientUtil; /** 签发上传凭证前端拿到后直传对象存储服务端不碰文件流 */ public UploadAuthVO createUploadAuth(String fileName, String title) { MapString, Object param new HashMap(); param.put(fileName, fileName); param.put(title, title); // 调用点播服务的创建视频接口返回视频ID与上传地址、凭证 String resp httpClientUtil.postJson(VOD_CREATE_URL, param); JSONObject json JSON.parseObject(resp); if (json.getInteger(code) ! 200) { throw new BusinessException(视频凭证签发失败 json.getString(message)); } UploadAuthVO vo new UploadAuthVO(); vo.setVideoId(json.getString(videoId)); vo.setUploadAuth(json.getString(uploadAuth)); vo.setUploadAddress(json.getString(uploadAddress)); return vo; } /** 签发播放凭证带有效期前端每次进入播放页重新获取 */ public PlayAuthVO getPlayAuth(Long lessonId, Long userId) { Lesson lesson lessonMapper.selectById(lessonId); if (lesson null || lesson.getVideoId() null) { throw new BusinessException(该课时暂无可播放视频); } // 付费课程要在这里校验购买记录试看课时放行 if (lesson.getFreeFlag() ! 1 !orderService.hasBought(userId, lesson.getCourseId())) { throw new BusinessException(尚未购买该课程); } String resp httpClientUtil.postJson(VOD_PLAY_URL, Collections.singletonMap(videoId, lesson.getVideoId())); PlayAuthVO vo new PlayAuthVO(); vo.setPlayAuth(JSON.parseObject(resp).getString(playAuth)); vo.setExpireSeconds(1800); return vo; } }这段代码有两个关键点。第一播放凭证的有效期设成 30 分钟前端播放器在到期前重新拉一次。凭证有效期设太长链接被分享出去就等于课程被白嫖设太短学员看长视频中途断流体验很差。半小时是个平衡点。第二权限校验放在签发凭证之前而不是在前端做按钮置灰。前端隐藏播放按钮只是交互层的礼貌真正的门禁必须在服务端凭证签发这一关守住了视频就漏不出去。上传凭证的返回结构里uploadAuth 和 uploadAddress 是前端 SDK 直接消费的服务端不要尝试解析或改写原样透传即可。HttpClient 封装里要设置连接超时和读取超时外部服务偶尔抖动是常态超时时间设成 3 秒和 10 秒配合一次重试基本能扛住。3.3 学习进度Redis 承接高频写定时任务落库播放器每 15 秒上报一次进度一个热门课程同时在线几百人这个写频率直接打 MySQL 是扛不住的。方案是把进度先写 Redis定时任务批量落库。Service public class ProgressService { Autowired private StringRedisTemplate redisTemplate; private static final String KEY_PREFIX learn:progress:; /** 上报进度Hash 结构field 为课时IDvalue 为已观看秒数 */ public void report(Long userId, Long courseId, Long lessonId, Integer seconds) { String key KEY_PREFIX userId : courseId; String field String.valueOf(lessonId); // 取旧值比较只前进不后退防止拖动进度条把记录改小 String old (String) redisTemplate.opsForHash().get(key, field); if (old ! null Integer.parseInt(old) seconds) { return; } redisTemplate.opsForHash().put(key, field, String.valueOf(seconds)); // 7 天过期给定时任务留足落库窗口 redisTemplate.expire(key, Duration.ofDays(7)); } }写入逻辑里那句「只前进不后退」是血泪经验。学员把进度条拖回开头重看如果照单全收学习记录就被改回 10 秒第二天进来发现要从头看投诉就来了。用 max 比较之后进度只增不减。落库用 Spring 的 Scheduled每 5 分钟扫一批 key。扫描不要用 keys 命令生产环境上百万 key 会直接把 Redis 卡住用 scan 游标分批遍历。落库语句走 ON DUPLICATE KEY UPDATE天然幂等任务重复执行也不会写脏数据。INSERT INTO edu_learning_record (user_id, course_id, lesson_id, progress, finished) VALUES (#{userId}, #{courseId}, #{lessonId}, #{progress}, #{finished}) ON DUPLICATE KEY UPDATE progress GREATEST(progress, VALUES(progress)), finished GREATEST(finished, VALUES(finished));GREATEST 这一手是第二道保险。即使 Redis 里的比较因为并发漏了一次数据库层再兜一次进度依然不会回退。课时时长在落库时一起查出来比对progress 大于等于 duration 的 90% 就标记 finished别要求 100%很多人看完最后一个画面就关掉了永远卡在 99%。4. 内容与运营问答、文章、图表与导出的落地细节内容和运营是这套系统里最容易被轻视的部分。课程模块写完了很多人觉得问答就是两张表加 CRUD文章就是一个富文本字段后台看板就是把 count 查出来丢给前端。真做起来问答的状态流转、文章的分页检索、报表导出的内存每一块都能让人加班。4.1 问答模块用状态机管住回答与采纳问题的生命周期是固定的待回答 → 已回答 → 已采纳 → 已关闭。状态不乱跳业务才不会乱。最典型的乱象是没有状态校验一个已关闭的问题还能被回答一个已采纳的问题还能再改采纳对象数据看着没错逻辑上已经烂了。public enum QuestionStatus { WAIT_ANSWER(0), ANSWERED(1), ACCEPTED(2), CLOSED(3); private final int code; QuestionStatus(int code) { this.code code; } public int getCode() { return code; } } Service public class AnswerService { /** 采纳回答只有提问者本人、且问题处于已回答状态才能操作 */ Transactional(rollbackFor Exception.class) public void accept(Long questionId, Long answerId, Long currentUserId) { Question q questionMapper.selectById(questionId); if (q null) { throw new BusinessException(问题不存在); } if (!q.getUserId().equals(currentUserId)) { throw new BusinessException(只有提问者可以采纳回答); } if (q.getStatus() ! QuestionStatus.ANSWERED.getCode()) { throw new BusinessException(当前状态不允许采纳); } // 先清掉旧的采纳标记再置新的保证一个问题只有一条被采纳 answerMapper.clearAccepted(questionId); answerMapper.markAccepted(answerId); questionMapper.updateStatus(questionId, QuestionStatus.ACCEPTED.getCode()); } }三个判断缺一不可身份、状态、唯一性。身份判断防越权状态判断防跳步唯一性靠先清后置保证。这三个点在答辩时被问到的概率很高因为它们对应的是「你的系统怎么防止脏数据」比背框架特性有说服力得多。回答列表按「是否采纳 → 点赞数 → 时间」三级排序采纳的置顶这个排序规则写在 SQL 的 ORDER BY 里不要拿到内存里排分页会错。4.2 文章模块分页检索与索引配合文章模块的查询比问答复杂因为要支持分类筛选、关键词模糊匹配、按时间或热度排序。用 MyBatis-Plus 的条件构造器写把动态条件组装清楚别在 XML 里堆 if 标签。public PageResultArticleVO page(ArticleQuery query) { PageArticle page new Page(query.getPageNum(), query.getPageSize()); LambdaQueryWrapperArticle wrapper new LambdaQueryWrapper(); // 只查已发布的草稿对前台不可见 wrapper.eq(Article::getStatus, 1); wrapper.eq(query.getCategoryId() ! null, Article::getCategoryId, query.getCategoryId()); wrapper.like(StringUtils.hasText(query.getKeyword()), Article::getTitle, query.getKeyword()); // 排序置顶优先其次按发布时间倒序 wrapper.orderByDesc(Article::getTopFlag).orderByDesc(Article::getCreateTime); PageArticle result articleMapper.selectPage(page, wrapper); return PageResult.of(result, ArticleVO.class); }条件构造器里每个 eq/like 的第一个参数是布尔开关条件为空时自动跳过这是 MyBatis-Plus 里最实用的一个设计。但要注意 like %关键词% 前置通配符走不了索引文章量上了十万级就要换成全文索引或者独立的搜索服务这一点在方案里要写清楚边界别把一个演示级的实现说成能扛百万数据。文章正文存 longtext列表页千万不要 select 正文只查 id、标题、摘要、封面、时间这几个字段。有人图省事直接 select *列表页一次拉回二十篇长文响应体几兆前端渲染卡顿还以为是网络问题。摘要字段单独存一份发布时截取正文前 200 字列表页直接用这是最省事的做法。文章详情页的阅读数用 Redis 的 incr 累加定时回写数据库跟学习进度是同一个套路。4.3 后台运营图表接口与大数据量导出后台看板用 ECharts 渲染前端要的数据形状是「类目轴数组 数值数组」。接口直接按这个形状返回别让前端做二次转换。{ code: 200, data: { xAxis: [2024-01, 2024-02, 2024-03, 2024-04], series: [ { name: 新增学员, data: [120, 180, 210, 260] }, { name: 课程销量, data: [80, 95, 130, 175] } ] } }接口这一层要做的是把 SQL 查出来的行转成列。数据库按月份 group by 出来是「月份、类型、数量」三列服务端用 Map 按月份聚合再拆成两条 series。这个转换放在服务端做前端只管画图换图表库也不用改后端。导出是后台最容易出事的环节。用 POI 的 XSSFWorkbook 导十万行订单堆内存直接爆掉现象是页面转圈半天然后 502日志里一行 OutOfMemoryError。解决办法是换 SXSSFWorkbook它把数据分批刷到临时文件内存里只留固定窗口的行。public void exportOrder(HttpServletResponse response, OrderQuery query) throws IOException { response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setHeader(Content-Disposition, attachment;filenameorders.xlsx); // 第二个参数 500 表示内存中最多保留 500 行超出写临时文件 try (SXSSFWorkbook workbook new SXSSFWorkbook(500); ServletOutputStream out response.getOutputStream()) { Sheet sheet workbook.createSheet(订单明细); Row header sheet.createRow(0); String[] titles {订单号, 学员, 课程, 金额, 下单时间}; for (int i 0; i titles.length; i) { header.createCell(i).setCellValue(titles[i]); } // 分批查库每批 1000 条避免一次把全表拉进内存 int pageNo 1, rowIndex 1; while (true) { ListOrderVO batch orderMapper.pageForExport(query, (pageNo - 1) * 1000, 1000); if (batch.isEmpty()) { break; } for (OrderVO o : batch) { Row row sheet.createRow(rowIndex); row.createCell(0).setCellValue(o.getOrderNo()); row.createCell(1).setCellValue(o.getUserName()); row.createCell(2).setCellValue(o.getCourseTitle()); row.createCell(3).setCellValue(o.getAmount().doubleValue()); row.createCell(4).setCellValue(o.getCreateTime()); } pageNo; } workbook.write(out); // 删除临时文件否则磁盘会被慢慢吃满 workbook.dispose(); } }三个参数值是关键窗口 500 行决定内存占用批大小 1000 决定数据库压力dispose() 决定磁盘会不会被临时文件撑爆。导出接口要单独设超时不能跟普通查询共用同一条线程池一个导出把线程占满前台页面就全挂了。5. 避坑与排查五个最容易翻车的点这一章列的是我在类似项目里真实见过、或者调试过的问题。共同特点是本地跑得好好的一上多实例或者一带数据量就出问题排查时靠猜最后发现原因都特别朴素。5.1 服务间调用超时叠成雪崩现象是某个下午开始课程详情页大面积转圈重启服务能好几分钟然后又挂。查日志发现课程服务在等用户服务返回用户服务在等数据库数据库连接池满了。原因是 HttpClient 没设超时用了默认的无限等待一个慢请求占住线程请求越积越多线程池耗尽整个服务不可用。解决办法是三层都要设死连接超时 3 秒读取超时 10 秒重试最多一次。重试只对幂等的 GET 请求开POST 一律不重试否则会出现一次下单扣两次款这种事。网关层再加熔断某个下游错误率超过阈值就快速失败返回兜底数据不要把请求堵在网关。5.2 播放凭证过期前端缓存了旧地址现象是学员反馈「看到一半提示无权限」刷新页面又好了。原因是前端把播放凭证缓存在了组件状态里学员暂停去接了杯水回来继续播凭证已经过期。播放器报 403用户看到的就是黑屏加一句莫名其妙的提示。解决办法有两个层面。前端在播放器里监听错误事件遇到鉴权类错误自动重新拉一次凭证再续播这个逻辑必须写不能指望用户自己刷新。后端在凭证过期时间上留余量签发时返回的 expireSeconds 比实际有效期短 60 秒让前端提前续期。另外凭证不要存 localStorage页面关闭即失效减少泄露面。5.3 Redis 和数据库的进度对不上现象是学员看完一节课第二天进来发现进度退回去了。原因通常是定时任务落库时用了覆盖写或者 Redis 的 key 到期了但任务还没扫到数据直接丢了。解决办法分两步。写入时用 GREATEST 保证只增不减这是数据库层的兜底扫描时用 scan 而不是 keys并且把过期时间设得比扫描周期长得多7 天对 5 分钟留足了容错空间。如果还是担心丢在用户退出播放页时主动触发一次同步落库把最关键的那个时间点保住。5.4 消息重复消费积分翻倍现象是学员反馈积分莫名其妙多了一倍。查下来是 ActiveMQ 的消息重投机制消费者处理完业务但还没返回 ACK 时网络抖动 broker 认为消费失败又投了一次业务被执行两遍。解决办法是消费端做幂等。给每条消息带一个业务唯一键比如「用户ID 订单号 行为类型」消费前先查这张去重表存在就直接 ACK 返回。去重表的唯一索引是最后一道防线两个实例同时消费同一条消息时数据库会拦住其中一个。消息队列只能保证「至少一次」做不到「恰好一次」幂等必须业务自己做这是设计时就要想清楚的事。5.5 网关鉴权头丢失后台接口裸奔现象是后台某些接口不登录也能访问前端明明做了路由守卫。原因是网关的路由配置里某条路径用了 stripPrefix 过滤把携带用户信息的请求头一并处理掉了下游服务拿不到 userId权限拦截器判断为空就直接放行。解决办法是权限拦截器的默认策略必须是拒绝取不到用户信息就返回 401而不是放行。同时网关的过滤器顺序要固定先解析 token 注入请求头再做路由转发顺序反了头就丢了。这类问题最隐蔽的地方在于「有时候好有时候坏」取决于请求走了哪条路由排查时要在网关打一条完整日志把入参、注入的头、转发的目标地址都打出来别靠猜。6. 验收与答辩用一条链路把系统讲透验收这件事很多人栽在「功能太多讲不完」。四十分钟里想把课程、问答、文章、后台全演示一遍结果是每个都点两下评委什么也没记住。我的建议是只讲一条主链路把这条链路讲穿。主链路选「学员从首页搜索课程 → 进入详情 → 试看视频 → 购买 → 记录进度 → 提问 → 讲师回答 → 学员采纳」。这条链路串起了网关鉴权、课程查询、视频凭证、订单校验、Redis 进度、问答状态机六个技术点覆盖了系统八成的设计含量。演示前先跑一遍自检清单检查项通过标准常见问题环境启动compose 一条命令起全栈数据库初始化脚本没挂载鉴权未登录访问后台接口返回 401拦截器默认放行视频试看课时能播付费课时提示购买凭证签发没校验订单进度刷新页面后进度不回退覆盖写导致回退问答非提问者采纳被拒绝缺身份校验导出一万行导出不超时、不 OOM用了 XSSFWorkbook图表切换时间范围数据联动接口返回结构不统一答辩提问高频集中在三处为什么这样拆服务、Redis 和数据库不一致怎么办、消息重复消费怎么处理。前两个前面都写了第三个记住一句话就够队列保证至少一次幂等必须自己做。还有一个容易被忽略的点是压测数据。不用真压出多高的 QPS但至少要有一次并发验证用几十个线程同时上报学习进度观察 Redis 写入和落库结果是否一致。有这组数据讲「高频写为什么走 Redis」就不是空话。从那以后我每次带这类项目验收前都强制自己走一遍完整主链路从注册新账号开始不用任何现成数据走完再交给别人看。因为开发过程中用的都是自己造的数据状态永远是「刚好合适」只有从零走一遍才会发现那些藏在边界里的问题。希望这套结构能帮到你把方案交出去的时候心里有底。本文还有配套的精品资源点击获取