资讯动态

FDE驱动的企业学习平台开发:Spring Boot+Vue+MySQL实践

发布时间:2026/10/13 3:38:36 来源:尧图企业网站定制
写这类“企业学习助手”项目容易陷入两个极端要么把需求简单理解成“做个课程列表加个上传功能”最后交付一个只有空壳的 CRUD要么一上来就微服务、中间件、容器编排全上工期翻倍业务却还在等第一个可用版本。码士集团学习助手这个项目我的选择是用 FDEFeature-Driven Engineering功能驱动工程化实施的方法来推进先锚定业务痛点再拆解功能最后才谈技术和代码。这篇文章会把完整路径讲清楚包括需求怎么拆、数据库怎么建、后端怎么写、前端怎么联调、怎么验证效果。这个项目本身不算大但它是企业内训场景里非常有代表性的一类系统。你只要认真跟完这套流程至少能收获三件事第一知道怎么把一句“我们要一个学习平台”转化成能落地的功能清单第二掌握 Spring Boot Vue MySQL 这类主流技术栈的实际集成方式包括学习进度、测验成绩这类状态型数据的处理第三避开会话安全、数据幂等、事务边界这些只有在真实项目中才会暴露的坑。1. 企业学习平台的业务痛点与解决思路先复盘一下码士集团当初遇到的业务现状。很多成规模的企业都有类似的困惑培训资料并不少文档、PPT、视频课程散落在各业务部门的共享盘和网盘里但新员工入职之后仍然不知道从哪开始学培训部门每月花大量时间整理学习清单、发通知、盯进度精力主要耗在“人肉催办”上测验考完成绩只停留在 Excel 里无法形成趋势分析也无法反过来优化课程内容。这些问题表面上是“缺少一个学习系统”本质上其实是三个问题内容没有统一承载、学习路径没有显性化、学习效果没有数据反馈。引入 FDE 之前团队内部其实有过不同意见。有同事认为应该先做权限和用户中心把组织架构打通有同事认为要先把直播功能做进去因为培训部门提到了“在线直播”还有同事觉得短视频和学习积分更重要。如果按这些方向并行推进项目大概率会失控。FDE 的思路正好相反先不要被技术想象牵着走而是围绕“用户真正能感知的功能”来划分范围和优先级。我们先把学习助手拆成几条完整的功能链路学员看到自己的学习任务清单点开课程资料完成学习系统记录学习进度。学员完成课程后参加在线测验提交后能看到得分和答案解析。管理员可以维护课程和测验内容并能查看每个学员的学习进度与考试成绩。这三条链路已经覆盖了企业学习助手最核心的价值。直播、积分、社区问答都先放到二期等核心链路跑通、业务验证有效之后再加。这个“先打通一条可工作的完整链路再扩展功能边界”的节奏正是 FDE 最关键的判断。2. FDE 核心概念与适用场景FDE 并不是一个新造的概念。它和敏捷开发中的 FDDFeature-Driven Development有思想渊源核心都是围绕“Feature功能特性”组织开发活动。但在码士集团这个项目里我更强调它的工程化落地面功能是拆解业务的单元也是排优先级、估工期、写代码、做验收的共同语言。FDE 的三个基本原则值得记一下功能即交付单元。每个功能必须能被用户直接感知并且可以单独验收。例如“学员提交测验后看到得分和解析”是一个功能单元而“封装一个自动阅卷工具类”不是。这种粒度能够逼着团队从业务结果反推设计。痛点驱动取舍。功能不是越多越好而是要看它是否真的缓解了某个业务痛点。不能直接回答“这个功能解决了谁的什么问题”的需求默认砍掉。可运行可验证。每个功能完成后必须有明确的验证方式可以是接口测试、页面操作或数据检查。没有验证方案的功能不允许宣称完成。用表格对比 FDE 与其他常见方法会更清楚方法需求输入形态拆分粒度适合场景主要风险瀑布流完整文档阶段/模块需求明确的传统项目变更成本高反馈周期长DDD 领域驱动设计领域模型聚合/限界上下文复杂业务逻辑、中大型系统建模门槛高小项目容易过度设计FDE 功能驱动工程用户故事/痛点清单可感知功能链路中小型业务系统、快速交付缺乏约束时容易演变成贫血 CRUDFDE 不是银弹。如果你的系统逻辑极其复杂、领域规则深不见底比如金融清结算、供应链计划调度那 DDD 仍然是更稳妥的底座。但对企业内部的学习平台、内容管理、工作流审批、运营后台这类系统FDE 的效率优势非常明显它让团队始终把注意力放在“用户拿这个功能做什么”上而不是“我要用什么架构来显得先进”上。3. 码士集团学习助手需求分析与功能拆解现在把“业务痛点”翻译成“需求文档”。学习助手的用户角色大致分三类角色主要诉求核心使用场景学员知道学什么、学了没、考得怎么样查看任务、学习资料、参加测验、看成绩讲师/课程管理员发布内容、组织测验、了解学员情况维护课程、维护题库、查看统计系统管理员保障系统可用、管理用户和权限用户管理、角色管理、日志查看在 FDE 的框架里需求拆解要细化到“功能点 验收标准”的级别。下面是我为第一版规划的核心功能清单按优先级标记P0第一版必须交付用户登录认证与角色鉴权支持学员、讲师管理员两类角色登录后访问权限隔离。课程资料管理讲师管理员可以新增课程、编辑课程基本信息、上传课件链接。学员课程列表与详情学员可以看到发布且对自己可见的课程。学习进度记录学员观看/阅读资料后标记为已完成系统持久化学习进度。在线测验支持单选题、多选题自动阅卷并返回得分和答案。学员成绩查询学员可查看历史测验成绩。数据统计看板基础版管理员可查看课程维度的学员完成率和平均分。P1第二版迭代学习计划指定讲师为指定学员批量分配学习任务。考试期限控制设置开始时间和截止时间超时不可提交。消息通知作业到期提醒邮件或站内信。导出报表把学习进度和成绩导出成 Excel。这里有一个容易被新手忽略的要求每个功能点必须写清“完成的标准是什么”。例如“学员提交测验后看到得分和解析”的验收标准可以是学员提交全量作答后系统返回本次得分每题显示正确答案与学员所选答案同一套试卷不可重复提交除非讲师管理员重置。如果验收标准不写清楚前后端联调阶段就很容易产生纠纷。4. 系统架构与技术选型这是个典型的中小型 Web 系统不需要微服务。微服务适合解决组织复杂度、独立扩缩容和故障隔离问题但它同时会引入服务发现、配置中心、分布式事务、链路追踪等额外的运维成本。对码士集团学习助手来说单应用模块化 独立数据库已经足够支撑几千名员工的中等并发量。技术栈选择如下后端Java 17 或更高版本 Spring Boot。为什么选 Spring Boot生态成熟开发效率高后续做企业集成容易而且国内招聘和团队维护成本低。持久层MyBatis-Plus 或 Spring Data JPA 均可。本文演示使用 MyBatis-Plus因为它对单表 CRUD 的封装非常省事复杂查询用 XML 也方便。数据库MySQL 8.x。企业学习系统是典型读多写少场景MySQL 足以应对。缓存Redis可选。如果预测并发提高可以缓存课程列表和热门数据但第一版如果预算紧张不加也能跑。前端Vue 3 Vite Element Plus。Vue 在国内普及度高组件库成熟适合快速搭后台管理界面。认证方式JWT。无状态、前后端分离友好短期会话场景够用。模块结构上后端按业务能力分包而不是按技术分层。你没必要在功能不复杂时硬套“controller-service-dao-entity”之外更复杂的架构。合理的分包方案com.mashi.learn ├── controller # 对外 HTTP 接口 ├── service # 业务逻辑 ├── mapper # 数据访问 ├── entity # 数据库实体 ├── dto # 接口入参/出参 ├── config # 配置认证、跨域、异常处理 ├── security # JWT 过滤器与权限工具 └── common # 通用返回体、异常、工具类这种结构的好处是业务边界清晰后续要拆微服务时controller/service/mapper 可以直接平移成独立模块同时第一版的维护成本很低。5. 数据库设计与核心建表 SQL数据库设计是这类项目真正见功底的地方。学习助手的数据模型至少要覆盖用户、课程、学习进度、测验、答题记录这五类核心数据。几个设计要点先说清楚用户表不要和课程表直接关联通过“创建人”字段关联避免把用户角色绑定死。学习进度表使用user_id course_id做唯一约束保证一个用户对同一门课程只有一条进度记录更新而不是插入。测验答题记录需要保存学员的原始答案因为自动阅卷之后讲师可能需要复核不能只看总分。所有业务表都保留create_time和update_time这是排查线上问题的基础。下面是核心建表 SQL实际项目中可按公司规范调整注释和字段名-- 用户表 CREATE TABLE learn_user ( id bigint NOT NULL AUTO_INCREMENT COMMENT 用户ID, username varchar(64) NOT NULL COMMENT 登录名, password varchar(128) NOT NULL COMMENT BCrypt加密后的密码, real_name varchar(64) DEFAULT NULL COMMENT 姓名, role varchar(16) DEFAULT STUDENT COMMENT STUDENT/ADMIN/TEACHER, dept_name varchar(128) DEFAULT NULL COMMENT 所属部门, status tinyint DEFAULT 1 COMMENT 1启用 0禁用, create_time datetime DEFAULT NULL, update_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; -- 课程表 CREATE TABLE course_info ( id bigint NOT NULL AUTO_INCREMENT COMMENT 课程ID, course_name varchar(255) NOT NULL COMMENT 课程名称, description text COMMENT 课程简介, cover_url varchar(512) DEFAULT NULL COMMENT 封面图地址, doc_url varchar(512) DEFAULT NULL COMMENT 课件文档/视频资源地址, creator_id bigint DEFAULT NULL COMMENT 创建人ID, status tinyint DEFAULT 1 COMMENT 1上架 0下架, create_time datetime DEFAULT NULL, update_time datetime DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT课程资料表; -- 学习进度表 CREATE TABLE learning_progress ( id bigint NOT NULL AUTO_INCREMENT COMMENT 主键, user_id bigint NOT NULL COMMENT 学员ID, course_id bigint NOT NULL COMMENT 课程ID, status tinyint NOT NULL DEFAULT 0 COMMENT 0未完成 1已完成, finished_time datetime DEFAULT NULL COMMENT 完成时间, create_time datetime DEFAULT NULL, update_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_user_course (user_id, course_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT学习进度表; -- 测验试卷表 CREATE TABLE exam_paper ( id bigint NOT NULL AUTO_INCREMENT COMMENT 试卷ID, paper_name varchar(255) NOT NULL COMMENT 试卷名称, course_id bigint NOT NULL COMMENT 关联课程ID, duration_minutes int DEFAULT 30 COMMENT 考试时长分钟, pass_score int DEFAULT 60 COMMENT 及格分数, total_score int DEFAULT 100 COMMENT 总分, create_time datetime DEFAULT NULL, update_time datetime DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT试卷表; -- 测验题目表 CREATE TABLE exam_question ( id bigint NOT NULL AUTO_INCREMENT COMMENT 题目ID, paper_id bigint NOT NULL COMMENT 所属试卷ID, question_type varchar(8) DEFAULT SINGLE COMMENT SINGLE单选 MULTI多选, content text COMMENT 题干, options_json text COMMENT 选项JSON数组形式, answer varchar(64) COMMENT 正确选项如A或A,C, score int DEFAULT 5 COMMENT 本题分值, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT测验题目表; -- 学员答题记录表 CREATE TABLE exam_record ( id bigint NOT NULL AUTO_INCREMENT COMMENT 记录ID, paper_id bigint NOT NULL COMMENT 试卷ID, user_id bigint NOT NULL COMMENT 学员ID, user_answers_json text COMMENT 学员答案JSON对象, score int DEFAULT NULL COMMENT 得分, is_pass tinyint DEFAULT 0 COMMENT 是否及格, submit_time datetime DEFAULT NULL COMMENT 提交时间, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT学员答题记录表;这张表设计下来可以发现一个关键点学习进度和测验记录是用户行为中最容易产生脏数据的地方。如果没有唯一约束学员反复点击“标记完成”日志里会不断出现重复记录如果没有答题记录表管理员无法回答“为什么这个学员得 80 分而不是 90 分”。数据库设计并不复杂但它决定了很多业务问题是从源头解决还是留到代码里到处打补丁。6. 后端核心代码实现Spring Boot后端实现过程中最有价值的不是 CRUD 代码而是“进度记录”和“测验提交”这两个业务节点。它们牵涉到幂等、事务和权限校验是学习助手项目里最容易出错的地方。6.1 工程配置先把application.yml的主要配置写出来。需要注意生产环境不要明文写数据库密码建议使用环境变量或配置中心。# src/main/resources/application.yml server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/mashi_learn?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: ${DB_USER:root} password: ${DB_PASSWORD:root} driver-class-name: com.mysql.cj.jdbc.Driver redis: host: ${REDIS_HOST:localhost} port: 6379 mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml configuration: map-underscore-to-camel-case: true global-config: db-config: id-type: auto6.2 学习进度记录接口进度提交的幂等处理是第一个重点。前端可能因为用户重复点击、网络超时重试而发出多次相同请求。如果每次都插入新记录就会出现大量重复数据。方案是先按userId courseId查询存在则 update不存在则 insert。更进一步配合数据库的唯一索引兜底捕获冲突异常后转为更新逻辑。下面用 MyBatis-Plus 实现核心 Service// 文件路径src/main/java/com/mashi/learn/service/LearningProgressService.java Service public class LearningProgressService { Resource private LearningProgressMapper progressMapper; /** * 记录学习进度。 * 幂等规则同一个学员对同一门课程只会有一条进度记录重复提交时更新状态。 * * param userId 当前登录学员ID * param courseId 课程ID * param status 最新状态0 未完成1 已完成 */ Transactional(rollbackFor Exception.class) public void recordProgress(Long userId, Long courseId, Integer status) { LearningProgress progress progressMapper.selectOne( new LambdaQueryWrapperLearningProgress() .eq(LearningProgress::getUserId, userId) .eq(LearningProgress::getCourseId, courseId) ); if (progress null) { progress new LearningProgress(); progress.setUserId(userId); progress.setCourseId(courseId); progress.setStatus(status); progress.setCreateTime(LocalDateTime.now()); progress.setUpdateTime(LocalDateTime.now()); if (status 1) { progress.setFinishedTime(LocalDateTime.now()); } progressMapper.insert(progress); } else { progress.setStatus(status); progress.setUpdateTime(LocalDateTime.now()); if (status 1 progress.getFinishedTime() null) { progress.setFinishedTime(LocalDateTime.now()); } progressMapper.updateById(progress); } } }如何判断进度接口写对了不只要看第一次提交返回成功还要看同一学员对同一课程重复提交 3 次后数据库里仍然只有一条记录。你可以顺手在事务里加一条查询语句验证也可以在测试环境里用下面的 SQL 检查SELECT user_id, course_id, COUNT(*) FROM learning_progress GROUP BY user_id, course_id HAVING COUNT(*) 1;如果 HAVING 子句返回了记录说明幂等没做干净。这是非常实用的线上自查手段。6.3 测验提交与自动阅卷测验提交涉及跨表操作写学员答题记录、计算得分、更新测验状态。整个过程必须是一个事务。如果先写答题记录再算分算分失败就会留下一条没有成绩的脏记录如果先算分但答题记录写入失败学员提交了答案却看不到得分体验更糟。// 文件路径src/main/java/com/mashi/learn/service/ExamService.java Service public class ExamService { Resource private ExamRecordMapper examRecordMapper; Resource private ExamQuestionMapper examQuestionMapper; /** * 提交测验答案自动阅卷并在同一事务中保存答题记录和得分。 * * param userId 学员ID * param paperId 试卷ID * param userAnswers 学员答案key为题目IDvalue为选项例如 {1:A,2:C,D} * return 得分与是否正确 */ Transactional(rollbackFor Exception.class) public ExamSubmitResult submitExam(Long userId, Long paperId, MapLong, String userAnswers) { // 校验是否已经提交过避免重复交卷 ExamRecord existRecord examRecordMapper.selectOne( new LambdaQueryWrapperExamRecord() .eq(ExamRecord::getPaperId, paperId) .eq(ExamRecord::getUserId, userId) ); if (existRecord ! null) { throw new BusinessException(该试卷已经提交请勿重复交卷); } // 查询试卷下所有题目 ListExamQuestion questions examQuestionMapper.selectList( new LambdaQueryWrapperExamQuestion().eq(ExamQuestion::getPaperId, paperId) ); int totalScore 0; for (ExamQuestion question : questions) { String userAnswer userAnswers.get(question.getId()); if (userAnswer ! null normalizeAnswer(question.getAnswer()) .equals(normalizeAnswer(userAnswer))) { totalScore question.getScore(); } } ExamRecord record new ExamRecord(); record.setPaperId(paperId); record.setUserId(userId); record.setUserAnswersJson(JSON.toJSONString(userAnswers)); record.setScore(totalScore); ExamPaper paper examPaperMapper.selectById(paperId); record.setIsPass(totalScore paper.getPassScore() ? 1 : 0); record.setSubmitTime(LocalDateTime.now()); examRecordMapper.insert(record); ExamSubmitResult result new ExamSubmitResult(); result.setScore(totalScore); result.setPass(record.getIsPass() 1); return result; } /** * 归一化答案去除选项中的空格并把大小写统一例如 a,c - A,C */ private String normalizeAnswer(String answer) { if (answer null) { return ; } String[] parts answer.trim().toUpperCase().split(,); Arrays.sort(parts); return String.join(,, parts); } }这段代码里有三个必须掌握的工程细节重复提交校验。没有这个校验学员可以反复刷新页面提交成绩记录会出现多行统计看板的数据直接失真。答案归一化。多选题的正确选项如果存成A,C学员填的可能是C,A字符串比较会误判错误。先切分、排序、再拼接是比较稳妥的处理方式。事务边界。阅卷过程任何一步抛异常整个答题记录都不会写入。这在生产环境里意味着“要么交卷成功并出分要么交卷失败且不留脏数据”。6.4 Controller 与权限控制接口层的权限控制不要自己造轮子用 Spring Security JWT 可以快速实现基于角色的访问控制。下面示例简化了完整过滤链核心是校验 Token将当前用户信息放入上下文管理员接口检查角色权限。// 文件路径src/main/java/com/mashi/learn/controller/LearningController.java RestController RequestMapping(/api/learn) public class LearningController { Resource private LearningProgressService progressService; Resource private ExamService examService; /** * 提交学习进度 */ PostMapping(/progress) public ResultVoid submitProgress(RequestBody ProgressSubmitDTO dto, RequestAttribute(currentUserId) Long userId) { progressService.recordProgress(userId, dto.getCourseId(), dto.getStatus()); return Result.success(); } /** * 提交测验答案 */ PostMapping(/exam/submit) public ResultExamSubmitResult submitExam(RequestBody ExamSubmitDTO dto, RequestAttribute(currentUserId) Long userId) { ExamSubmitResult result examService.submitExam(userId, dto.getPaperId(), dto.getUserAnswers()); return Result.success(result); } }这里的RequestAttribute(currentUserId)需要你提前在 JWT 过滤器里把解析出的用户 ID 放进 request attribute。这是一个很低成本但效果很好的设计Controller 层不需要关心 Token 解析细节只专注于业务参数。7. 前端联调与页面交互实现前端这里不展开完整工程只讲两个最核心的联调场景课程列表渲染和测验表单提交。演练使用 Vue 3 组合式 API。7.1 课程列表与学习进度状态课程列表要注意的是展示状态要与后端数据保持一致。学员看列表时每一门课都要能看出“未学习/学习中/已完成”这个状态来自learning_progress表。前端拿到课程列表后再请求一次进度列表合并渲染。// 文件路径src/views/CourseList.vue template el-card v-forcourse in courseList :keycourse.id classcourse-card h3{{ course.courseName }}/h3 p{{ course.description }}/p el-tag :typegetStatusTag(course.id) {{ getProgressStatus(course.id) }} /el-tag el-button typeprimary clickopenCourse(course.id) {{ progressMap[course.id] 1 ? 重新学习 : 开始学习 }} /el-button /el-card /template script setup import { ref, onMounted } from vue import { getCourseList, getMyProgress } from /api/learn const courseList ref([]) const progressMap ref({}) const loadProgress async () { const { data } await getMyProgress() progressMap.value {} data.forEach(item { progressMap.value[item.courseId] item.status }) } const getProgressStatus (courseId) { const status progressMap.value[courseId] if (status 1) return 已完成 if (status undefined) return 未开始 return 学习中 } onMounted(async () { const res await getCourseList() courseList.value res.data await loadProgress() }) /script这里容易出现的联调问题后端返回的字段是courseId前端组件里用的是id后端返回状态是数字1/0前端模板里比较的是字符串1。建议前后端在项目启动前就约定一套统一的 API 字段规范最好直接基于后端 DTO 生成 TypeScript 类型从源头避免字段不一致。7.2 测验提交与防重复点击测验页面的重点不只是把答案提交给后端还包括提交状态的“置灰”处理。很多线上事故都是用户等不及响应多次点击提交按钮导致后端的幂等逻辑被高频触发。前端也要主动防御。// 文件路径src/views/ExamPage.vue const submitExam async () { if (submitting.value) return // 防止重复提交 submitting.value true try { const payload { paperId: paper.value.id, userAnswers: answerMap.value // {题目ID: 选项字符串} } const { data } await submitExamApi(payload) ElMessage.success(提交成功得分${data.score}) // 跳转到成绩页 router.push({ path: /exam/result, query: { id: paper.value.id } }) } finally { submitting.value false } }按钮置灰、submitting 锁、后端事务一致性这三层一起才称得上“一个完整的防重复交卷方案”。只做前端或者只做后端都不够。8. 运行、部署与效果验证前端工程和后端工程准备好后本地开发环境的运行很简单。下面以本地部署为例演示如何快速把系统跑起来并验证关键接口。8.1 初始化数据库与启动后端先执行第 5 节的建表 SQL然后启动后端# 创建数据库如果还没建 mysql -uroot -p -e CREATE DATABASE mashi_learn DEFAULT CHARACTER SET utf8mb4; # 导入建表 SQL mysql -uroot -p mashi_learn sql/learn.sql # 启动 Spring Boot 应用 mvn spring-boot:run后端启动成功后控制台会看到类似Tomcat started on port(s): 8080的输出。此时可以先用 Swagger 或 Postman 验证登录接口拿到 Token 后再访问受保护接口。8.2 验证进度接口的幂等性用一个测试账号模拟学习进度提交。假设用户 ID 为 1课程 ID 为 101# 第一次提交 curl -X POST http://localhost:8080/api/learn/progress \ -H Content-Type: application/json \ -H Authorization: Bearer 你的Token \ -d {courseId:101,status:1} # 再次提交相同内容 curl -X POST http://localhost:8080/api/learn/progress \ -H Content-Type: application/json \ -H Authorization: Bearer 你的Token \ -d {courseId:101,status:1}预期结果两次请求都返回成功但数据库learning_progress表中该学员与课程只有一条记录且status为 1。如果出现两条记录优先检查唯一索引是否生效再检查 Service 层查询条件是否写错。8.3 验证测验自动阅卷使用包含两题单选的试卷测试curl -X POST http://localhost:8080/api/learn/exam/submit \ -H Content-Type: application/json \ -H Authorization: Bearer 你的Token \ -d {paperId:1,userAnswers:{1:A,2:B}}如果题 1 正确答案是 A题 2 正确答案是 B且每题 5 分预期响应是{ code: 200, message: success, data: { score: 10, pass: true } }再提交一次相同内容预期应该返回业务异常“该试卷已经提交请勿重复交卷”。这个验证能同时确认事务写入和重复提交校验是否生效。9. 常见问题与排查思路这套项目开发过程中我整理过一份连接高频问题的排查表遇到问题时先按表格顺序自查可以省下大量联调时间问题现象可能原因排查方式解决方案后端接口返回 401Token 过期或用户角色无权访问查看 Spring Security 过滤链日志解析 JWT 中 role 字段重新登录检查PreAuthorize注解角色值前端请求跨域报错后端未配置 CORS打开浏览器 Network 查看响应头在 WebMvcConfigurer 中配置跨域规则或使用网关统一处理中文乱码数据库、连接 URL、页面编码不一致检查 MySQL 库表字符集和 JDBC URL 的 characterEncoding统一使用 utf8mb4连接 URL 显式指定编码进度重复插入唯一索引缺失或查询条件写错SQL 直接查learning_progress表执行 HAVING 语句补唯一约束检查 LambdaQueryWrapper 是否带上 userId 和 courseId测验提交后分数为 0答案归一化逻辑有误或题目 ID 类型不一致打印用户提交的userAnswers的 key 类型与题目 ID 类型统一用 Long 类型传输调试 normalizeAnswer 方法页面列表很慢缺少索引或每次加载都全量查询打开慢查询日志查看 explain 执行计划为 user_id/course_id 等查询字段建立联合索引多人同时交卷出现重复记录事务隔离级别不够或校验与插入存在时间窗口模拟并发请求检查日志中的 SQL 顺序在 exam_record 表增加唯一索引paper_iduser_id捕获冲突异常后返回“重复交卷”第 4 条和第 5 条是最常见的问题容易在自测时被忽略。强烈建议把“重复数据自查 SQL”和“答案归一化单测”纳入你的项目测试清单成本低但收益明显。10. 最佳实践与工程建议到这里项目已经能从开发环境跑通了。但要把这套系统真正交付到生产环境使用还有六个层面的工作值得重视。第一安全意识别停留在口头上。学习助手虽然是企业内部系统用户的账号密码、学习数据、考试成绩都属于敏感信息。密码必须用 BCrypt 加密存储绝不能出现自定义 MD5 加密后明文入库的情况。接口层要显式校验当前登录用户的 ID防止“水平越权”——比如学员 A 通过篡改请求参数查看或提交学员 B 的成绩。在 Controller 里不要信任前端传的userId而应该从登录态中获取。第二事务边界要简单清晰。学习进度接口和测验提交接口都用了事务这是正确的。但要注意不要在事务里做远程调用、发送邮件、调用外部 AI 服务等耗时操作否则数据库连接会被长时间占用并发一高立刻拖垮系统。如果需要发送通知邮件先写业务数据提交事务再异步发送。第三日志是排查问题的唯一线索。在接口入口打印请求参数摘要在业务异常处打印堆栈在关键数据变更后打印操作记录。学习助手这类系统虽然不复杂但一旦出问题“用户说看不到成绩数据库里有没有记录”靠的就是日志和数据库查询不能靠猜。第四参数校验和返回结构要统一。所有接口入参使用 DTO 并进行 Bean Validation 校验返回统一使用ResultT包装全局异常处理器统一转换错误格式。这样前后端联调时不需要针对同一个字段反复确认返回结构。第五配置和密钥使用环境变量管理。数据库密码、JWT 密钥、Redis 连接信息都要从环境变量或配置中心读取不能提交到 Git。JWT 的签名密钥生产环境必须改掉默认值否则等于把系统权限的大门钥匙贴在了门上。第六版本和上线节奏要留回滚后路。数据库变更脚本单独存放每次发版要有顺序先执行 SQL 变更再发布后端最后发布前端。如果升级失败利用 Git 标签回滚代码同时准备好对应的 SQL 回滚脚本。这一条不一定第一天就用上但应该从一开始就成为团队习惯。11. 写在最后从功能到落地的经验沉淀码士集团学习助手这类项目单看技术难度在中等偏下但它很能检验工程师的“系统思维”你能否从一句模糊的“搞个学习平台”里识别出关键链路是否知道学习进度要幂等、交卷要加锁、密码要加密、事务要短小这些能力比背住某个框架的 API 重要得多。如果今天只记住一句话我希望是先让一条核心业务链路完整跑通再扩展功能边界。FDE 的价值不是让你少写代码而是帮你把精力集中到真正需要解决的问题上避免在需求发散和技术炫技中浪费工期。下一步建议是在本文示例基础上继续做三个小升级给学习进度增加“最近学习时间”和“学习时长累计”字段让数据看板更有说服力加入讲师批量指派学习计划把测验成绩按课程聚合形成管理报表。这三个功能都不难但会明显提升学习助手的业务价值。动手改完再遇到类似的“业务系统型”项目你会发现自己的方法已经不一样了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑