毕业设计做到一半,最让人头疼的不是写代码,而是“不知道下一步该干什么”。尤其是这种“基于Spring Boot的学习课程辅助系统”选题,听起来很标准,查资料也到处都是,但真正动手时才发现——要么是代码片段东拼西凑跑不起来,要么是数据库表设计得一塌糊涂,做到后面自己都分不清哪个模块对应哪张表。这篇文章我想把自己的完整设计思路和实现过程整理出来,包括需求分析、技术选型、数据库设计、核心模块代码怎么写、联调时踩了哪些坑,以及一些在常规文档里不会写但实际很有用的经验。如果你也在做类似的Spring Boot毕设或课程设计,或者说想用一个项目把Spring Boot、MyBatis、Vue这套主流技术栈串起来,这篇文章应该能帮你省下不少时间。
1. 为什么选Spring Boot做学习辅助系统:需求拆解与技术选型
1.1 这类系统到底在解决什么问题
“学习课程辅助系统”这个名称听起来有点抽象,我刚开始理解得也比较浅,以为就是做个课程列表加个视频播放页面。后来跟指导老师聊了两次,才明白这类系统的核心价值其实落在“辅助”两个字上——它不是在线教育平台那样要把整个教学过程搬上线,而是给“教”和“学”两端提供支撑工具:老师能发布课程、布置作业、查看学生完成情况;学生能查看课程、提交作业、记录学习进度、看到自己的统计结果。
所以在做需求分析时,我没有直接照搬网上那些动辄十几张表的后台管理系统设计,而是先梳理了三类核心角色和各自关心的问题:
- 管理员:要管理教师账号、课程分类、系统基础配置,关心的是“系统能不能稳定运行、数据是否统一”。
- 教师:要维护课程内容、发布作业、查看学生提交情况,关心的是“发布是否方便、批改结果能否留存”。
- 学生:要浏览课程、报名学习、提交作业、查看自己的学习进度,关心的是“操作是否顺畅、数据是否准确”。
基于这些,我把系统功能收敛成下面几条主线:用户认证与角色权限、课程管理、作业提交与批改、学习进度记录与统计、后台管理。这些功能往下拆就是具体的接口和页面,整个项目的范围就清楚了,后面写代码时不会东一榔头西一棒槌。
1.2 技术栈选型:为什么是Spring Boot + MyBatis + Vue
选技术栈时,我其实纠结过一段时间。网上类似项目的方案五花八门,有说用Spring Boot + JPA的,有说用SSM传统三层架构的,还有直接用若依这类脚手架二次开发的。我的结论是:如果你跟我一样是拿这个项目做毕设或课程设计,而不是给真实企业做系统,那**Spring Boot + MyBatis + MySQL + Vue(或Thymeleaf)**这套组合是性价比最高的选择。
原因有三个。第一,Spring Boot是目前Java后端的主流框架,自动装配、起步依赖、内嵌容器这些特性让项目搭建成本直线下降,而且面试和答辩时被问到Spring Boot自动装配原理、约定优于配置这些概念,你都有真实的代码可以佐证。第二,MyBatis相比JPA更直观,SQL自己控制,排查问题时能看到完整的SQL语句,对于学生项目来说,SQL的可控性比ORM的便捷性更实用。第三,这个组合的资料极其丰富,遇到问题基本都能搜到解决方案,不会因为某个冷门技术卡住整个进度。
不过这里有一个很多人容易忽略的点:**前后端分离到底要不要做。**如果你前端基础一般,我建议不要强行拆成Spring Boot后端 + Vue独立前端,因为联调时的跨域、Token传递、接口联调会产生大量额外工作。我自己最终做的是前后端分离,因为我前面前端比较熟,但如果你时间紧张,用Spring Boot + Thymeleaf直接把页面渲染好,集成在一个项目里,毕设答辩完全够用,而且工作量会少很多。
1.3 整体架构设计:单体应用 + 分层思想
架构上我并没有搞微服务那套,一个单体Spring Boot应用就够了。但单体不意味着代码可以乱写,我还是按照标准的Controller-Service-Mapper三层来组织,同时增加了DTO和VO的转换层。这样做的好处是:各层职责清晰,答辩时能讲清楚每一层的定位;后续加功能时,不需要改动已有代码的结构。
项目结构设计如下:
course-helper ├── src/main/java/com/example/coursehelper │ ├── common // 统一返回结果、异常处理、工具类 │ ├── config // 拦截器、跨域、静态资源配置 │ ├── controller // 接口层 │ ├── service // 业务逻辑层 │ ├── mapper // 数据访问层 │ ├── entity // 数据库实体 │ ├── dto // 数据传输对象 │ └── vo // 视图对象 ├── src/main/resources │ ├── mapper // MyBatis XML文件 │ ├── static // 静态资源 │ └── application.yml └── pom.xml这里我想特别说明一下DTO和VO的使用。很多学生项目图省事,直接用Entity返回给前端,结果就是前端能拿到密码字段、一些不必要的关联信息也会被序列化出去。我在实现时,所有接口返回的都是VO,接收参数都用DTO,Entity只在Service和Mapper之间传递。这个习惯看起来多写几个类,但到了联调和答辩时,你能很清楚地说明“哪些字段暴露给了前端,哪些没有”,这是加分项。
2. 系统核心模块拆解与数据库设计
2.1 功能模块划分:先把边界画清楚
系统功能我拆成了六个模块,每个模块在代码里有独立的包和Controller,互不混淆:
| 模块 | 核心功能 | 涉及角色 |
|---|---|---|
| 用户认证模块 | 登录、注册、Token签发与校验 | 全部 |
| 用户管理模块 | 用户信息维护、角色分配、状态管理 | 管理员 |
| 课程管理模块 | 课程CRUD、分类管理、上下架 | 管理员、教师 |
| 学习记录模块 | 课程学习进度记录、进度查询 | 学生 |
| 作业管理模块 | 作业发布、提交、批改、成绩查看 | 教师、学生 |
| 数据统计模块 | 学习时长统计、作业完成率、选课人数 | 教师、管理员 |
模块划分完之后,我做了接口文档,用表格记下每个接口的路径、请求方式、参数、返回值。虽然开始觉得麻烦,但后来写前端页面时就不用反复翻代码了,而且答辩时的“系统设计”部分也有了现成素材。
2.2 数据库设计:六张核心表怎么建
数据库设计是整个项目的地基,表结构不合理,后面写SQL时会非常痛苦。我最终设计了六张核心表,外加两张关联表,下面把其中最重要的列出来,字段解释也一并写上。
用户表(user):
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键,自增 |
| username | varchar(50) | 登录用户名,唯一 |
| password | varchar(100) | BCrypt加密后的密码 |
| real_name | varchar(50) | 真实姓名 |
| role | tinyint | 角色:1管理员 2教师 3学生 |
| varchar(100) | 邮箱,可空 | |
| status | tinyint | 是否启用:1启用 0禁用 |
| create_time | datetime | 创建时间 |
课程表(course):
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| course_name | varchar(100) | 课程名称 |
| teacher_id | bigint | 授课教师ID |
| category_id | bigint | 所属分类 |
| cover_url | varchar(255) | 封面图地址 |
| description | text | 课程简介 |
| total_hours | int | 总学时 |
| status | tinyint | 状态:1上架 0下架 |
| create_time | datetime | 创建时间 |
学习记录表(study_record):
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| user_id | bigint | 学生ID |
| course_id | bigint | 课程ID |
| chapter_id | bigint | 章节ID |
| study_duration | int | 本次学习时长(秒) |
| study_date | date | 学习日期 |
| create_time | datetime | 记录创建时间 |
作业表(homework)和作业提交表(homework_submit)也类似,homework记录作业基本信息,homework_submit记录每个学生的提交内容和教师批改结果。
设计表的时候有一个很重要的教训:**一定要考虑角色权限在数据库层面的落点。**比如课程表里直接存teacher_id,这样查询“某教师教的所有课程”就只是一条SQL,而不需要写复杂的关联逻辑;用category_id关联分类表则方便做课程分类筛选。这种“把业务关系直接落到外键字段”的设计思路,能让后续的开发高效很多。
2.3 生产环境不可忽略的一点:时间字段和软删除
很多项目在做数据库设计时会把这两件事忽略掉,但到了真正部署运行的时候才发现问题。第一个是时间字段类型,Java里的LocalDateTime对应MySQL的datetime,但为了避免时区问题,我在连接串上强制指定了serverTimezone=Asia/Shanghai,同时在实体类上统一用LocalDateTime类型,不让Date和Timestamp混用——混用的后果是前端拿到的数据格式非常混乱,有的带毫秒、有的不带。第二个是软删除,我在每张业务表上都加了deleted字段,默认0,删除操作统一用update语句把deleted置为1,这样数据不会物理消失,对学习记录、作业这类审计需求很强的业务来说特别重要。
3. 核心功能实现:认证、拦截器与课程管理链路
3.1 基于JWT的登录认证:从签发到拦截的完整链路
用户认证我选的是JWT方案,没有用Session。原因很简单:前后端分离架构下,Session要处理跨域Cookie问题,而JWT无状态,后端不用存会话信息,扩展性也更好。实现过程并不复杂,核心就三步。
第一步,引入依赖。在pom.xml里加上jwt相关库和Spring Security的crypto模块(这里只用它的BCryptPasswordEncoder做密码加密,避免引入完整Spring Security导致拦截逻辑冲突):
<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-crypto</artifactId> </dependency>第二步,写JwtUtil工具类。这里要注意的是,密钥不要硬编码在代码里,我放在application.yml中,用@Value注入,这样部署时可以随时修改而不需要重新编译。签发Token时,把用户ID、用户名、角色放进claim,过期时间设置为72小时,方便开发调试。
@Component public class JwtUtil { @Value("${jwt.secret}") private String secret; @Value("${jwt.expire-hours:72}") private Long expireHours; public String generateToken(Long userId, String username, Integer role) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + expireHours * 3600 * 1000); return Jwts.builder() .setSubject(username) .claim("userId", userId) .claim("role", role) .setIssuedAt(now) .setExpiration(expiryDate) .signWith(SignatureAlgorithm.HS256, secret.getBytes(StandardCharsets.UTF_8)) .compact(); } }第三步,写拦截器解析Token。这里有一个非常容易踩坑的地方:拦截器里拿到的request是HttpServletRequest,但要往Controller传用户信息,一般是通过request.setAttribute或者ThreadLocal。我最终用的是ThreadLocal + 一个UserContext工具类,在拦截器里解析出userId和role后存入ThreadLocal,Controller里直接UserContext.getUserId()就能拿到当前登录用户,用完再在拦截器的afterCompletion里remove掉,避免线程池复用导致数据串线。
拦截器的注册也要注意放行路径。登录、注册、首页课程列表这些不需要Token的接口,必须放在排除列表里;其他接口全部拦截。我一开始配置时就漏掉了一个静态资源路径,结果前端一直报401,排查了很久才发现。
3.2 课程管理模块的开发:从接口定义到Mapper映射
课程管理模块是最典型的CRUD业务,但它能扩展出不少技巧。我以课程列表的分页查询为例,说明整个链路怎么写。
Controller层,接收分页参数和筛选条件,返回统一分页对象:
@RestController @RequestMapping("/api/course") public class CourseController { @GetMapping("/list") public Result<PageResult<CourseVO>> page(@RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer size, @RequestParam(required = false) String courseName, @RequestParam(required = false) Long categoryId) { CourseQueryDTO dto = new CourseQueryDTO(); dto.setPage(page); dto.setSize(size); dto.setCourseName(courseName); dto.setCategoryId(categoryId); return Result.success(courseService.pageQuery(dto)); } }Service层,把查询条件转成Map传给Mapper,核心是构建动态SQL:
public PageResult<CourseVO> pageQuery(CourseQueryDTO dto) { Map<String, Object> params = new HashMap<>(); params.put("page", (dto.getPage() - 1) * dto.getSize()); params.put("size", dto.getSize()); params.put("courseName", dto.getCourseName()); params.put("categoryId", dto.getCategoryId()); List<CourseVO> list = courseMapper.selectPage(params); Long total = courseMapper.countPage(params); return PageResult.of(list, total, dto.getPage(), dto.getSize()); }Mapper XML里的动态SQL,用where和if标签拼接条件:
<select id="selectPage" resultType="com.example.coursehelper.vo.CourseVO"> SELECT c.id, c.course_name, c.teacher_id, t.real_name AS teacher_name, c.category_id, c.cover_url, c.description, c.total_hours FROM course c LEFT JOIN user t ON c.teacher_id = t.id <where> c.deleted = 0 <if test="courseName != null and courseName != ''"> AND c.course_name LIKE CONCAT('%', #{courseName}, '%') </if> <if test="categoryId != null"> AND c.category_id = #{categoryId} </if> </where> ORDER BY c.create_time DESC LIMIT #{page}, #{size} </select>动态SQL这块是MyBatis的灵魂,尤其多条件筛选场景,用<where>加<if>比拼接字符串安全得多,还能自动处理多余AND的问题。我个人强烈建议把SQL写在XML里,而不是写在注解里,因为后期SQL一旦复杂起来,注解方式会非常难维护,而XML有着清晰的层级和注释空间。
3.3 为什么Mapper要返回VO而不是Entity
这个问题是我写代码时反复纠结过的。最终答案是:Mapper返回VO并没有问题,尤其是在这种关联查询场景。比如课程列表需要显示教师姓名,而course表里只有teacher_id,Entity里根本没有teacherName这个字段,如果Mapper返回Course对象,你还要在Service里做二次查询来补全教师名,白白多出N+1条SQL。直接在SQL里LEFT JOIN查出teacherName,返回CourseVO,一次查询全部搞定,性能更好,代码也更简洁。
当然,实体和VO分离也有代价,就是需要写转换逻辑。我用MapStruct来做对象转换,比手写BeanUtils.copyProperties性能更好,而且在编译期就能发现字段不匹配的问题。这个工具值得花半小时学一下。
3.4 文件上传与作业提交:本地存储还是对象存储
作业提交功能必然涉及文件上传。我最初想的是把文件存到服务器本地磁盘,实现简单,但对于部署在云服务器上的项目来说,文件会随着容器重建而丢失。后来我改成了本地存储 + 数据库记录路径的方式,架构上给未来替换对象存储留了接口。
具体实现时,我定义了StorageService接口,默认的LocalStorageServiceImpl把文件写到配置好的upload.dir目录,文件名用UUID重命名,避免中文名和重名问题。提交作业的接口逻辑是:
@PostMapping("/submit") public Result<Void> submitHomework(@RequestParam("homeworkId") Long homeworkId, @RequestParam("file") MultipartFile file) { Long studentId = UserContext.getUserId(); // 检查作业是否已提交过 HomeworkSubmit submit = homeworkSubmitMapper.selectByHomeworkIdAndStudentId(homeworkId, studentId); if (submit != null) { return Result.error(400, "该作业已提交,请勿重复提交"); } // 保存文件,记录文件路径 String fileUrl = storageService.store(file); // 插入提交记录 homeworkSubmitService.create(homeworkId, studentId, fileUrl); return Result.success(); }这里有几个隐藏问题值得注意:第一是文件类型校验,不能只依赖前端,后端必须判断扩展名和Content-Type,我限制为常见的文档类型(doc、docx、pdf、zip等)。第二是文件大小限制,我在application.yml中配置了spring.servlet.multipart.max-file-size和max-request-size,分别设为10MB和20MB。第三是提交去重,一个作业每个学生只能提交一次,如果允许重新提交,则需要修改已有记录的fileUrl而不是新增。
3.5 统一返回结果与全局异常处理:项目气质的分水岭
很多学生项目的接口返回值是裸的JSON,要么是Map,要么直接把Entity序列化出去,错误处理更是随心所欲,有的返回null,有的抛异常直接弹500页面。从专业角度来看,这其实是项目质量的关键分水岭。我自己封装了一个Result ,结构是code、message、data三个字段,成功时code为200,失败时code为业务错误码。全局异常处理用@RestControllerAdvice,捕获参数校验异常、业务异常和未知异常,统一格式返回,避免把堆栈信息暴露给前端。
写全局异常处理时有一个容易忽略的点:@RestControllerAdvice默认只能处理进入Controller之前的异常,对于在过滤器中抛出的异常,需要额外处理。我后来把JWT解析失败等认证异常在拦截器里直接catch住,手动写回JSON,不走全局异常处理器,这样就避免了拦截器异常被容器包装成HTML错误页的问题。
4. 学习进度跟踪与数据统计:从埋点到可视化的完整设计
4.1 学习进度数据怎么设计:一张记录表解决计算问题
学习辅助系统区别于普通课程管理系统的最核心模块,就是学习进度跟踪。这个功能如果设计不好,很容易做成摆设——数据记录了,但学生看不到价值,教师也用不上。
我的思路是:设计一张study_record表,每次学生学习一个章节时,前端每隔一段时间向后端上报学习记录,内容包含学生ID、课程ID、章节ID、本次学习时长。后端收到记录后,写入数据库。计算学习进度时,只需要根据course表里配置的总章节数,统计学生已学过的章节数,两者相除就得到百分比。
这里有一个细节:学习时长的准确性。如果前端只是退出页面时上报一次,用户直接关浏览器就会丢失数据。所以我间隔30秒发送一次心跳,后端做幂等处理,同一节课同一分钟内只算一次有效时长。这也是为什么study_record表里要有study_date字段——用于统计某天的学习时长,避免同一用户同一课程的数据重复累加。
上报接口的代码很简单:
@PostMapping("/record") public Result<Void> recordStudy(@RequestBody StudyRecordDTO dto) { Long studentId = UserContext.getUserId(); studyRecordService.record(studentId, dto); return Result.success(); }真正的逻辑在Service层:先检查该学生当天是否已经记录过该章节的学习数据,如果有就累加时长,没有就插入新记录。
4.2 进度计算逻辑:从课时维度而不是章节维度
在学习进度计算上,我开始犯了一个错误,直接用“已学章节数/总章节数”来计算百分比,结果发现有些视频章节时长40分钟,有些只有10分钟,进度百分比根本不能准确反映学习量。后来改成了课时维度:在course表增加total_hours字段记录总学时,在前端学习页面记录每一节课的真实视频时长,学习进度 = 已学课时数 / 总课时数。
这个改动虽然只是在计算层面调整,但它在真实使用场景中的意义很大——教师可以一眼看出学生是否只是“挂机刷进度”,对于真实的在线学习来说,学习时长的积累比章节打卡更具参考价值。
4.3 数据统计接口:给教师端看板用的SQL写法
数据统计模块是答辩时的亮点所在。我实现了三个核心统计接口:教师端课程总览、学生端个人学习报告、管理员端系统概览。
教师端总览需要的数据包括:任教课程数量、全部课程累计报名人数、作业平均提交率。这里最关键的是作业提交率的SQL写法。作业提交率 = 已提交作业数 / 应提交作业数,应提交作业数 = 作业数量 × 选课人数。我通过子查询和聚合函数实现:
SELECT h.course_id, COUNT(DISTINCT h.id) AS total_homework, COUNT(DISTINCT s.id) AS submitted_count, COUNT(DISTINCT h.id) * (SELECT COUNT(*) FROM course_selection cs WHERE cs.course_id = h.course_id) AS expected_count FROM homework h LEFT JOIN homework_submit s ON s.homework_id = h.id WHERE h.deleted = 0 GROUP BY h.course_id刚开始我直接JOIN然后COUNT,数据一直不对,后来排查发现是课程与作业一对多、作业与提交一对多,两张一对多表JOIN会导致数据翻倍。这个坑让我意识到:做统计SQL时,宁可多写子查询,也不要盲目JOIN,尤其当关联关系层级较多时,子查询的可读性和正确性都比JOIN更可靠。
学生端个人学习报告则是查询当前学生所有选课记录,关联课程表计算出每门课的进度百分比,并按最近学习时间排序,方便学生看到“最近在学什么”。
4.4 前端可视化:图表不必炫技,清楚即可
前端我用了Vue + Element Plus,图表用了ECharts。统计页面展示了三个图:选课人数最多的Top5课程用横向柱状图,最近的每日学习时长用折线图,课程完成率分布用饼图。ECharts的数据结构非常简单,后端只需返回统计结果,前端用chart.setOption填充即可。这个部分不建议花太多时间调样式,清爽直观是最重要的。
5. 联调与部署阶段的坑:这些问题花了整整三天
5.1 Maven依赖冲突:版本仲裁不是万能的
项目开发到一半时,我突然发现启动不了,报错信息是No qualifying bean of type 'XXXX' available。排查了很久,发现是因为Spring Security的crypto模块传递依赖了旧版本的commons-logging,和项目里其他依赖产生冲突,导致部分Bean的自动装配被影响。
解决方法是使用Maven依赖树分析冲突:
mvn dependency:tree -Dverbose发现冲突后,在pom.xml里用<exclusion>排除掉传递依赖,或者用<dependencyManagement>统一指定版本。这个经验说明一个问题:不要盲目引入完整Spring Security,尤其在只需要密码加密的场景,单独引入spring-security-crypto更轻量,能极大避免依赖冲突带来的莫名其妙报错。
5.2 拦截器放行路径配置失误:前后端联调的第一道坎
前后端联调时,前端访问/api/course/list,一直返回401未认证。我一开始以为是Token没带,前端检查了请求头也没问题,最后才发现是拦截器配置里放行路径写错了。
我的WebMvcConfig是这样的:
registry.addInterceptor(jwtInterceptor) .addPathPatterns("/api/**") .excludePathPatterns("/api/auth/login", "/api/auth/register", "/api/course/list");然后发现前端请求的路径实际上是/api/course/list,这个路径包含在放行中,但前端登录后访问/api/course/detail/1时,拦截器拿到的Token却是undefined。问题不在路径,而在前端没有在请求拦截器中统一携带Token。这是前后端分离联调的典型问题,解决方案是前端用Axios的请求拦截器统一从localStorage取Token并写入Authorization头。
5.3 Spring Boot默认使用CGLIB代理,事务注解失效的坑
这是一个很经典但很容易被忽略的问题。Spring Boot 2.x以上版本中,@EnableTransactionManagement默认使用CGLIB代理,而CGLIB代理对public方法生效,对同类内部方法调用不生效。我在写作业提交和成绩批改时,把两个写操作放在同一个Service类的两个方法里,其中一个方法调另一个方法,结果事务没生效,数据只写了一部分,导致出现脏数据。
解决方法是:要么把事务方法放到不同的Service类中,通过注入调用;要么在同一个类中,自注入代理对象来调用。但最稳妥的方案是避免自调用,把涉及多个写操作的核心业务独立成一个事务方法。这也是面向切面编程的一个经典应用场景,答辩时如果你能讲清楚这个原理,会显得很有深度。
5.4 统一时间格式:前后端时区不一致的惨痛教训
有一次前端反馈,学生提交作业成功的时间显示成了“2026-07-20 02:37:00”,比实际时间早了8个小时。排查发现后端返回的时间是UTC格式,而前端浏览器默认按本地时区解析,导致显示偏差。解决方式是在application.yml里配置Jackson的时区和格式:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai同时在后端实体类的LocalDateTime字段上统一用@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss"),这样任何接口返回的时间格式都完全由后端控制,不会再被环境差异影响。
5.5 部署时Spring Boot版本太高带来的连带问题
我最初为了尝鲜用了Spring Boot 3.2版本,结果发现很多第三方库还没有适配Jakarta EE 9+的包路径变化。比如最开始引入的旧版分页插件直接报ClassNotFoundException,因为我用的是javax.命名空间,而Spring Boot 3已经全部迁移到jakarta.。
如果你做毕设,我建议直接用Spring Boot 2.7.x,这是整个2.x系列的稳定收官版本,兼容性好、资料多、各种第三方组件适配成熟。除非你有明确需求,否则不必追新版本,毕竟项目的核心目标是跑得稳、讲得清、可答辩。
5.6 YAML配置随机端口与内嵌容器问题
有段时间为了调试方便,我尝试在application.yml里配置随机端口:
server: port: ${PORT:0}这意味着每次启动端口都不同,本地调试时前端强转的接口地址就全部失效。所以开发阶段不要用随机端口,直接把端口固定为8080,测试环境再用server.port=8081等命令行参数覆盖即可。
6. 一点个人经验总结
整个项目从零到一,我最大的感受是:这类“基于Spring Boot的XX系统”最考验的不是单一的奇技淫巧,而是把常规功能稳稳当当串联起来的能力。登录认证、权限拦截、课程CRUD、作业文件上传、进度计算、统计报表——每一个模块单独看都不难,但组合在一起,就是一套完整的技术栈练兵场。
对正在做这个选题的同学,我的建议是:先花三天把需求边界和数据库设计想清楚,再开始写代码,这比你边写边改高效得多。前端如果时间紧张,就用Thymeleaf,把Spring Boot集成好即可,不必强行上Vue。后端代码一定要分层明确、统一返回、全局异常处理这三件事做扎实,它们是答辩时体现专业度的关键。
至于我自己,写完这个项目之后再去看Spring Boot的自动配置源码,理解程度和前一阵子完全不一样——很多原来觉得抽象的概念,在真实业务场景里一下就变得具体。这大概就是做项目的意义吧,代码只是载体,真正收获的是把知识落地的能力。