智能课程学习系统的开发难点,通常不是某个接口写不出来,而是课程数据、接口返回结构和 Vue3 页面之间能否形成一条完整链路。一个基于 Java + SpringBoot3 + Vue.js3 + MySQL 的课程学习系统,核心就是三件事:把课程和分类存进 MySQL,用 SpringBoot3 把课程查询、选课、推荐接口暴露出来,再让 Vue3 页面把这些接口完整消费掉。下面按一条从数据库到前端页面的主线来做最小闭环,包含表结构、后端 Service、Mapper XML、前端 API 封装、联调验证和常见报错,最终得到一个可以直接继续扩展的课程学习系统骨架。
很多课程学习类项目会把精力放在页面样式上,等后端联调时才意识到数据结构没设计好。出现这种问题,是因为没有先想清楚“这个系统最少要完成哪些业务动作”。与其急着写代码,不如先把一个最小业务闭环定下来,后面的实现节奏会顺很多。
1. 智能课程学习系统的业务边界和数据库设计
1.1 “智能”在这个项目里落地成什么功能
课程学习系统的常规功能包括课程展示、课程筛选、课程详情、用户选课和学习记录。这里的“智能”如果一开始就引入推荐算法、行为埋点、标签画像,开发周期会不可控。对一个最小闭环来说,更务实的做法是把“智能”做成可解释的规则推荐:按用户已经选择的课程分类,从相同分类里找出还未选择的课程,再按课程报名人数倒序返回。这样既能让系统具备“根据历史选择找相似课程”的能力,也方便后续替换成真正的算法模型。
建议先接受一个判断:智能推荐不是这个项目的全部,它只是课程业务上的一个功能出口。真正重要的,是课程、分类、选课记录三类数据能够稳定地关联起来。数据结构稳定之后,无论换成协同过滤还是向量召回,都只是替换查询逻辑,不需要推翻页面和接口。
1.2 三张核心表:课程、分类、选课记录
课程学习系统的最小数据模型可以拆成三张表。课程表保存课程的基础信息,分类表保存课程所属方向,选课记录表保存学生与课程的关系。选课记录表是理解这个项目数据流的关键,它让系统知道某个学生学过哪些分类,从而为推荐提供依据。
CREATE DATABASE IF NOT EXISTS edu_smart_course DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci; USE edu_smart_course; CREATE TABLE course_type ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '分类ID', type_name VARCHAR(50) NOT NULL COMMENT '分类名称', sort INT DEFAULT 0 COMMENT '排序值', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间' ) ENGINE = InnoDB COMMENT ='课程分类表'; CREATE TABLE course ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '课程ID', title VARCHAR(120) NOT NULL COMMENT '课程标题', cover VARCHAR(255) DEFAULT '' COMMENT '封面图', intro TEXT COMMENT '课程简介', type_id BIGINT NOT NULL COMMENT '分类ID,关联 course_type.id', teacher VARCHAR(50) DEFAULT '' COMMENT '授课老师', price DECIMAL(10, 2) DEFAULT 0.00 COMMENT '价格,单位元', student_count INT DEFAULT 0 COMMENT '报名人数', status TINYINT DEFAULT 1 COMMENT '状态 1上架 0下架', publish_time DATETIME COMMENT '上架时间', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_type_id (type_id), KEY idx_status (status), KEY idx_publish_time (publish_time) ) ENGINE = InnoDB COMMENT ='课程表'; CREATE TABLE course_selection ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL COMMENT '学生ID,关联 student.id', course_id BIGINT NOT NULL COMMENT '课程ID,关联 course.id', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_student_course (student_id, course_id) ) ENGINE = InnoDB COMMENT ='学生选课记录表';这里有一个常见取舍:课程表与分类表之间没有建物理外键,选课表与课程表之间也没有建物理外键。很多学习项目喜欢加上物理外键,认为这样更安全,但当课程删除、分类调整、批量导入数据时,物理外键反而会成为变更瓶颈。实际项目中更常见的做法是用普通索引保证查询效率,由 Service 层保证数据关系,遇到违规操作再通过异常处理兜底。选课记录表的唯一索引uk_student_course是必须的,它能从数据库层面防止同一个学生重复选择同一门课程。
1.3 前后端分工和接口边界
系统的后端职责只负责数据库读写和业务规则,前端职责只负责展示课程、传递查询条件、触发选课动作。前端不能依赖数据库字段的直觉去拼装业务数据,后端需要把分类名称、选课状态这类已经加工好的数据直接返回。
项目最少需要四个接口,可以把后续扩展方向也框定在这条主线上。
| 接口 | 请求方式 | 作用 |
|---|---|---|
/api/course/type/list | GET | 返回全部课程分类 |
/api/course/page | GET | 分页查询课程,支持关键词和分类筛选 |
/api/course/recommend | GET | 根据学生已选分类返回推荐课程 |
/api/course/select | POST | 学生选择课程,重复选择会被拦截 |
没有登录模块时,推荐接口和选课接口可以通过studentId传入演示数据。项目接入登录后,再改成从 Token 或 Session 中取出当前用户,这是后端接口设计中最常见的扩展点。接下来按这个边界准备工程。
2. 环境准备与工程骨架:先对齐版本再写功能
SpringBoot3 和早期 SpringBoot2 的底层差异较大,如果后端工程初始化版本不对,代码写好后很可能在启动阶段报错。前端 Vue3 对 Node.js 版本也有要求,因此在克隆模板或创建工程前,应先用表格把环境版本固定下来。
2.1 本机环境版本清单
| 软件 | 建议版本 | 说明 |
|---|---|---|
| JDK | 17+ | SpringBoot3 依赖 Jakarta EE,JDK8 无法编译 |
| Spring Boot | 3.2.x | 示例配置使用 3.2.5 |
| MyBatis-Plus | mybatis-plus-spring-boot3-starter 3.5.5+ | 注意不能使用面向 SpringBoot2 的旧 starter |
| MySQL | 8.0+ | 字符集使用 utf8mb4 |
| Node.js | 18+ | Vite 5 要求高版本 Node |
| Vue | 3.4.x | 使用<script setup>组合式 API |
| Element Plus | 2.7.x | Vue3 的 UI 组件库 |
安装 MySQL 后需要先把它启动起来,再检查3306端口。启动 MySQL 后,用命令行或可视化客户端执行上一节的建表 SQL。如果连接时出现Access denied,检查用户名和密码是否与后续application.yml配置一致。
2.2 创建后端 SpringBoot3 工程
后端工程可以使用 Spring Initializr 创建,也可以手写 Maven 工程。建议包名结构为com.example.educourse,模块内部再按entity、mapper、service、controller、vo、config分包。严格的按业务分包在大型项目中会导致包数量爆炸,但当前这个规模下,按技术分层阅读顺序更直观。
后端pom.xml依赖是启动能否成功的关键,需要检查 MyBatis-Plus 依赖名是否带spring-boot3。
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.5</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>3.5.5</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> </dependencies>SpringBoot3 由jakarta.*包名体系驱动,如果误用了早期版本的mybatis-plus-boot-starter,运行时会因为无法找到javax.sql.DataSource相关适配或 AOP 类而启动失败。
2.3 创建前端 Vue3 工程
前端工程使用 Vite 初始化,创建之后还需要额外安装 Vue Router、Axios 和 Element Plus。Element Plus 对表单和列表场景支持完整,可以节省大量样式代码。
npm create vite@latest edu-course-front -- --template vue cd edu-course-front npm install npm install vue-router@4 axios element-plus安装完成后核对前端目录,确保src/views和src/api目录存在。Vite 工程默认没有 vue-router 的目录结构,需要手动添加路由文件和路由出口。
2.4 准备后端配置 application.yml
在src/main/resources/application.yml中配置数据源、MyBatis-Plus 的 XML 路径和日志。MySQL 连接串里的serverTimezone不能省略,否则会导致时间字段偏移。allowPublicKeyRetrieval=true是 MySQL8 连接时常见参数,用于支持使用 caching_sha2_password 认证的账号。
server: port: 8080 spring: application: name: edu-course-server datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/edu_smart_course?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: root hikari: maximum-pool-size: 20 minimum-idle: 5 mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImplmapper-locations指定 XML 文件位置后,业务 SQL 可以集中在 XML 中维护,避免把大量动态 SQL 拼进 Java 代码。开发阶段打开StdOutImpl日志,能够看到每次请求执行的 SQL 和参数,排错效率会高很多,生产环境再切换成异步日志或关闭。
3. 后端课程模块:实体映射、查询 SQL 与业务规则
后端代码的清晰度,取决于实体、VO、Mapper XML 和 Service 各层边界是否明确。课程表结构并不是天然适合直接返回给前端的,比如前端显示需要分类名称,但 course 表里只有type_id。因此,后端要区分实体和 VO 两个概念。
3.1 实体类如何映射数据库表
Course 实体使用 MyBatis-Plus 的注解完成表和字段映射。因为表字段是下划线风格,Java 属性是驼峰风格,开启map-underscore-to-camel-case后大部分字段可以自动映射,但主键策略、逻辑删除等仍建议通过注解显式声明。
package com.example.educourse.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.math.BigDecimal; import java.time.LocalDateTime; @Data @TableName("course") public class Course { @TableId(type = IdType.AUTO) private Long id; private String title; private String cover; private String intro; private Long typeId; private String teacher; private BigDecimal price; private Integer studentCount; private Integer status; private LocalDateTime publishTime; private LocalDateTime createTime; private LocalDateTime updateTime; }如果你的数据库字段数量较多,可以按这张基础结构扩展,比如增加课时数、难度等级、课程时长等字段。但要注意,前端卡片每个课程对象只使用哪些字段,就把哪些字段放入 VO,不需要把数据库所有字段强行暴露给页面。
3.2 列表 VO:比实体多一个分类名称
CourseVO 继承 Course,再追加一个typeName属性,这样列表接口一次就能给前端返回课程标题、老师和分类名称。继承方式在字段数量可控时清晰,在字段差异大时,推荐单独声明 CourseVO,而不是让 VO 与实体强耦合。
package com.example.educourse.vo; import com.example.educourse.entity.Course; import lombok.Data; import lombok.EqualsAndHashCode; @Data @EqualsAndHashCode(callSuper = true) public class CourseVO extends Course { private String typeName; }查询逻辑放在 CourseMapper 中,通过 XML SQL 对course和course_type做 LEFT JOIN。前台只查status = 1的上架课程,下架课程不能不小心出现在入口页。
package com.example.educourse.mapper; import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.toolkit.Constants; import com.example.educourse.entity.Course; import com.example.educourse.vo.CourseVO; import org.apache.ibatis.annotations.Param; import java.util.List; public interface CourseMapper extends BaseMapper<Course> { IPage<CourseVO> selectCoursePage(IPage<CourseVO> page, @Param("keyword") String keyword, @Param("typeId") Long typeId); }XML 文件放在src/main/resources/mapper/CourseMapper.xml。resultType使用全限定名CourseVO,避免依赖包的别名配置。分页查询对象IPage由 MyBatis 框架自动处理,开发者只需要关心 SQL 和查询条件。
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.example.educourse.mapper.CourseMapper"> <select id="selectCoursePage" resultType="com.example.educourse.vo.CourseVO"> SELECT c.id, c.title, c.cover, c.intro, c.type_id, t.type_name, c.teacher, c.price, c.student_count, c.status, c.publish_time, c.create_time, c.update_time FROM course c LEFT JOIN course_type t ON c.type_id = t.id WHERE c.status = 1 <if test="keyword != null and keyword != ''"> AND (c.title LIKE CONCAT('%', #{keyword}, '%') OR c.teacher LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="typeId != null"> AND c.type_id = #{typeId} </if> ORDER BY c.publish_time DESC, c.id DESC </select> </mapper>这个 XML 的动态 SQL 是最常见的手写方式。<if>判断可以避免传入空关键词时生成多余的AND。注意不要使用直接字符串拼接的方式生成 SQL,这里参数通过#{}预编译绑定,可以避免普通 SQL 注入风险。
3.3 配置分页插件,否则分页不会生效
MyBatis-Plus 的分页查询不是天然生效的,需要先注册MybatisPlusInterceptor并加入分页插件。如果漏掉这个配置,IPage的参数只是被当作普通参数传给 SQL,返回对象里 total 永远是 0。
package com.example.educourse.config; import com.baomidou.mybatisplus.annotation.DbType; import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.MYSQL); paginationInterceptor.setMaxLimit(200L); interceptor.addInnerInterceptor(paginationInterceptor); return interceptor; } }setMaxLimit(200L)表示单页最大允许 200 条,避免有人把 pageSize 设置为极大值后拖垮数据库。分页插件会为selectCoursePage自动生成 COUNT 查询。开发者不需要在 XML 中再写一条 COUNT SQL。
3.4 Service 层:查询、选课和推荐规则
Service 层承担核心业务。分页查询逻辑很简单,直接调用 Mapper 方法。选课逻辑则需要利用唯一索引拦截重复选择,并用异常处理器把错误信息转成统一返回。推荐逻辑是本项目的“智能”落点。
选课记录复用 MyBatis-Plus 的 BaseMapper 即可,为了演示,不需要在 CourseMapper 中新增其它方法。下面是 CourseService 的核心代码。
package com.example.educourse.service; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.example.educourse.entity.CourseSelection; import com.example.educourse.mapper.CourseMapper; import com.example.educourse.mapper.CourseSelectionMapper; import com.example.educourse.mapper.CourseTypeMapper; import com.example.educourse.vo.CourseVO; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import java.util.Collections; import java.util.List; import java.util.Set; import java.util.stream.Collectors; @Service public class CourseService { private final CourseMapper courseMapper; private final CourseSelectionMapper selectionMapper; public CourseService(CourseMapper courseMapper, CourseSelectionMapper selectionMapper) { this.courseMapper = courseMapper; this.selectionMapper = selectionMapper; } public IPage<CourseVO> pageCourses(long pageNum, long pageSize, String keyword, Long typeId) { Page<CourseVO