
如果你准备做一个“智能课程学习系统”的项目无论是用于毕业设计、课程设计还是作为 Java 全栈能力的练手作品我的第一个建议是不要急着写代码先把业务闭环想清楚。很多类似项目最后“看起来都做完了”但数据库一打开就露馅课程表是课程表用户表是用户表中间没有任何一条数据能回答“这个用户到底学了多少、学到了哪里”。这样的系统跑起来之后除了能在页面上增删改查既谈不上学习管理也做不了个性化推荐最后只剩一个空壳。本文采用 SpringBoot3 Vue3 MySQL 的组合围绕一套课程学习系统最常见的核心链路来展开用户登录学习、章节进度上报、课程进度统计、基于标签的课程推荐、后台课程维护。文章会给出可直接复制的数据库脚本、后端接口、前端联调代码也会把新手最容易踩的几个坑单独拿出来讲清楚。阅读完本文你至少能获得三样东西一套结构清晰的数据库表设计一套能处理学习进度的 Spring Boot 3 后端接口一段真正能跑通联调的 Vue3 前端代码。1. 这类项目最常见的失败点把 CRUD 当成全部几乎所有初学者做的管理类项目到最后都会变成 “CRUD 四件套”新增、删除、修改、查询。课程学习系统如果只做到这一步那它和“商品管理系统”没有本质区别也无法在答辩或面试中体现出你的建模能力。真正的学习类系统和普通管理系统的差异在于你需要持续记录用户的行为状态并根据状态产生新的业务判断。以“学习进度”为例它不是一个简单字段而是一连串问题用户打开了一个章节学到了第几秒这个章节有没有学完怎么定义“学完”一个课程有多个章节课程整体进度怎么聚合计算用户上次学到一半下次打开课程是否应该继续这些问题看似基础但如果一开始不设计好后面全都要返工。更常见的问题是权限缺失。很多项目把管理员功能和普通用户功能放在一起任何人知道接口地址就能删除课程、改学习记录。这在单机演示时没问题但一旦写到简历上面试官大概率会追问你怎么保证一个用户不能修改别人的学习记录所以这个系统的设计重点不在前端特效也不在框架选择而在于把课程内容与学习记录之间的关系表设计正确把进度上报接口做成可靠且幂等把推荐功能建立在真实学习行为之上而不是单纯写死。只有先建立这个判断下面所有代码才有意义。2. 技术选型与系统边界为什么是 SpringBoot3 Vue3 MySQL现在的 Java 全栈项目很多会直接上微服务或者把 Redis、MQ、ES 全部塞进来。但针对课程学习系统这种业务盲目引入分布式组件只会让维护成本远超收益。合理的做法是先用单体架构把业务跑通再根据真实瓶颈引入中间件。这套系统的技术选型定位如下技术组件作用选择理由Spring Boot 3后端基础框架快速构建 REST API内置依赖管理和自动装配Vue 3 Vite前端开发框架组合式 API 更适合复杂页面交互开发体验好MySQL 8.x关系型数据库课程、用户、学习记录之间存在明确关系适合用 MySQL 存储MyBatis-PlusORM 框架减少单表 CRUD 代码专注业务逻辑Maven项目管理工具Java 项目最常见的依赖管理方式在 Spring Boot 3 中需要注意一个关键点它基于 Jakarta EE 9因此原来 Spring Boot 2 中使用的javax.servlet、javax.validation等包名在 Spring Boot 3 中已经变为jakarta.servlet、jakarta.validation。如果你搜索资料时看到大量老代码第一件要做的事就是检查包名否则即使照抄也一样编译失败。系统功能边界建议控制在以下模块模块功能说明用户模块登录、注册、个人信息维护后续可扩展 Token 鉴权课程模块课程列表、课程详情、课程章节展示学习模块学习进度上报、课程总进度计算、继续学习定位收藏模块用户收藏感兴趣的课程标签与推荐模块维护课程标签根据用户学习历史推荐同标签课程管理后台课程上下架、章节管理、标签维护接下来分三步实现先设计 MySQL 表结构再写 Spring Boot 3 后端最后写 Vue3 前端页面。3. MySQL 数据库设计用学习记录连接用户与课程数据库是整个系统最值得认真投入的部分。表结构如果设计错后续代码怎么写都别扭。3.1 核心表结构总览本系统至少需要以下核心表表名说明sys_user系统用户表course课程表course_chapter课程章节表learning_record学习进度记录表course_favorite课程收藏表tag标签表course_tag课程标签关联表其中最关键的是learning_record表。它负责记录“哪个用户在哪个课程的哪个章节学到了第几秒”。3.2 建表 SQL 示例下面的 SQL 可以直接在 MySQL 8.x 中执行。先创建数据库然后创建各张表。CREATE DATABASE IF NOT EXISTS smart_course DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE smart_course; CREATE TABLE IF NOT EXISTS sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 用户ID, username VARCHAR(50) NOT NULL COMMENT 登录账号, password VARCHAR(100) NOT NULL COMMENT 密码生产环境必须加密存储, nickname VARCHAR(50) DEFAULT COMMENT 昵称, avatar VARCHAR(255) DEFAULT COMMENT 头像地址, status TINYINT DEFAULT 1 COMMENT 状态1正常 0禁用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, UNIQUE KEY uk_username (username) ) ENGINE InnoDB COMMENT 系统用户表; CREATE TABLE IF NOT EXISTS course ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 课程ID, title VARCHAR(100) NOT NULL COMMENT 课程标题, subtitle VARCHAR(200) DEFAULT COMMENT 副标题, cover_url VARCHAR(255) DEFAULT COMMENT 封面图, price DECIMAL(10, 2) DEFAULT 0.00 COMMENT 课程价格, original_price DECIMAL(10, 2) DEFAULT 0.00 COMMENT 原价用于展示划线价, difficulty TINYINT DEFAULT 1 COMMENT 难度1入门 2进阶 3高级, status TINYINT DEFAULT 0 COMMENT 状态0未上架 1已上架, view_count INT DEFAULT 0 COMMENT 浏览量, deleted TINYINT DEFAULT 0 COMMENT 逻辑删除0未删除 1已删除, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间 ) ENGINE InnoDB COMMENT 课程表; CREATE TABLE IF NOT EXISTS course_chapter ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 章节ID, course_id BIGINT NOT NULL COMMENT 所属课程ID, chapter_no INT NOT NULL COMMENT 章节序号从1开始, title VARCHAR(150) NOT NULL COMMENT 章节标题, video_url VARCHAR(255) DEFAULT COMMENT 视频地址, duration_seconds INT DEFAULT 0 COMMENT 章节视频时长单位秒, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, UNIQUE KEY uk_course_no (course_id, chapter_no) ) ENGINE InnoDB COMMENT 课程章节表; CREATE TABLE IF NOT EXISTS learning_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 记录ID, user_id BIGINT NOT NULL COMMENT 用户ID, course_id BIGINT NOT NULL COMMENT 课程ID, chapter_id BIGINT NOT NULL COMMENT 章节ID, progress_seconds INT DEFAULT 0 COMMENT 已学习到的秒数, duration_seconds INT DEFAULT 0 COMMENT 章节总时长冗余存储方便统计, finished TINYINT DEFAULT 0 COMMENT 本章节是否学完0未完成 1完成, last_learn_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 最后学习时间, UNIQUE KEY uk_user_chapter (user_id, chapter_id), KEY idx_user_course (user_id, course_id) ) ENGINE InnoDB COMMENT 学习进度记录表; CREATE TABLE IF NOT EXISTS course_favorite ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 收藏ID, user_id BIGINT NOT NULL COMMENT 用户ID, course_id BIGINT NOT NULL COMMENT 课程ID, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 收藏时间, UNIQUE KEY uk_user_course (user_id, course_id) ) ENGINE InnoDB COMMENT 课程收藏表; CREATE TABLE IF NOT EXISTS tag ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 标签ID, tag_name VARCHAR(30) NOT NULL COMMENT 标签名, UNIQUE KEY uk_tag_name (tag_name) ) ENGINE InnoDB COMMENT 课程标签表; CREATE TABLE IF NOT EXISTS course_tag ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 关联ID, course_id BIGINT NOT NULL COMMENT 课程ID, tag_id BIGINT NOT NULL COMMENT 标签ID, UNIQUE KEY uk_course_tag (course_id, tag_id), KEY idx_tag_id (tag_id) ) ENGINE InnoDB COMMENT 课程标签关联表;3.3 表结构设计的关键点这里真正容易踩坑的地方是learning_record表的唯一索引。一个用户反复学习同一个章节不应该在表中产生多条历史记录。使用UNIQUE KEY uk_user_chapter (user_id, chapter_id)之后每次上报进度时业务层只需要处理“插入”或“更新”两种可能数据不会无限膨胀。course_chapter表中course_id chapter_no也设置了唯一索引目的是避免同一个课程出现两个第 3 章。还有一个很实用的设计是course.deleted字段。在管理后台“删除课程”时不建议使用物理删除因为历史学习记录可能还引用该课程。采用逻辑删除字段之后数据仍然保留但业务查询会自动排除MyBatis-Plus 通过TableLogic注解即可支持。4. SpringBoot3 后端工程搭建4.1 创建工程与引入依赖后端工程可以通过 IDEA 的 Spring Initializr 创建也可以直接手工创建 Maven 工程。核心依赖如下示例版本请以你实际初始化出的版本为准parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.5/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.7/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency /dependencies这里需要特别提醒MyBatis-Plus 在 Spring Boot 3 下不能再使用老的mybatis-plus-boot-starter必须使用mybatis-plus-spring-boot3-starter否则会出现自动配置不生效的问题。4.2 配置文件src/main/resources/application.yml内容如下server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/smart_course?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: your_password mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: banner: false数据库密码请替换成你本机的 MySQL 密码。allowPublicKeyRetrievaltrue是 MySQL 8 连接时经常需要配置的参数否则可能报Public Key Retrieval is not allowed错误。4.3 统一返回结构与异常处理后端接口如果格式不统一前端在 axios 拦截器里就非常难处理。建议从第一步就定义统一响应结构。package com.example.smartcourse.common; import lombok.Data; Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(0); result.setMessage(success); result.setData(data); return result; } public static T ResultT error(String message) { ResultT result new Result(); result.setCode(500); result.setMessage(message); return result; } }再定义一个业务异常类用于处理“参数不合法、数据不存在”等业务错误。package com.example.smartcourse.common; public class BizException extends RuntimeException { public BizException(String message) { super(message); } }最后写一个全局异常处理器把异常转换为统一结构避免把堆栈直接抛给前端。package com.example.smartcourse.common; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; Slf4j RestControllerAdvice public class GlobalExceptionHandler { ExceptionHandler(BizException.class) public ResultVoid handleBizException(BizException e) { return Result.error(e.getMessage()); } ExceptionHandler(Exception.class) public ResultVoid handleException(Exception e) { log.error(系统异常, e); return Result.error(系统繁忙请稍后重试); } }统一返回结果之后前端只需要判断code是否为 0不用每个接口单独解析不同的字段结构。5. 核心业务实现学习进度上报与课程进度统计学习进度是整套系统的核心。下面以“用户学习某个章节后上报当前播放秒数”为例写完整实现。5.1 实体类先创建Course实体对应课程表。package com.example.smartcourse.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableLogic; 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 subtitle; private String coverUrl; private BigDecimal price; private BigDecimal originalPrice; private Integer difficulty; private Integer status; private Integer viewCount; TableLogic private Integer deleted; private LocalDateTime createTime; private LocalDateTime updateTime; }再创建LearningRecord实体。package com.example.smartcourse.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.time.LocalDateTime; Data TableName(learning_record) public class LearningRecord { TableId(type IdType.AUTO) private Long id; private Long userId; private Long courseId; private Long chapterId; private Integer progressSeconds; private Integer durationSeconds; private Boolean finished; private LocalDateTime lastLearnTime; }5.2 MapperMapper 层继承 MyBatis-Plus 的BaseMapper基础单表方法会自动生成。package com.example.smartcourse.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.example.smartcourse.entity.LearningRecord; import org.apache.ibatis.annotations.Mapper; Mapper public interface LearningRecordMapper extends BaseMapperLearningRecord { }课程 Mapper 类似但后面做推荐查询时会额外增加一个自定义方法。package com.example.smartcourse.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.example.smartcourse.entity.Course; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; import java.util.List; Mapper public interface CourseMapper extends BaseMapperCourse { Select( SELECT c.* FROM course c WHERE c.status 1 AND c.deleted 0 AND c.id NOT IN ( SELECT course_id FROM learning_record WHERE user_id #{userId} ) AND c.id IN ( SELECT DISTINCT ct.course_id FROM course_tag ct WHERE ct.tag_id IN ( SELECT DISTINCT ct2.tag_id FROM course_tag ct2 JOIN learning_record lr ON lr.course_id ct2.course_id WHERE lr.user_id #{userId} ) ) ORDER BY ( SELECT COUNT(1) FROM course_tag ct3 WHERE ct3.course_id c.id AND ct3.tag_id IN ( SELECT DISTINCT ct2.tag_id FROM course_tag ct2 JOIN learning_record lr ON lr.course_id ct2.course_id WHERE lr.user_id #{userId} ) ) DESC, c.view_count DESC LIMIT #{limit} ) ListCourse recommendByUser(Param(userId) Long userId, Param(limit) int limit); }5.3 Service 层实现幂等进度上报提交进度请求时前端会传用户 ID、课程 ID、章节 ID、当前播放秒数、章节总时长。Service 层要做的事很简单有记录就更新没有记录就插入。但这里要加一个判断如果新的进度比旧进度小说明可能是一次迟到的旧请求不应该把它覆盖成更小的值。package com.example.smartcourse.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.example.smartcourse.common.BizException; import com.example.smartcourse.dto.ProgressSubmitRequest; import com.example.smartcourse.entity.CourseChapter; import com.example.smartcourse.entity.LearningRecord; import com.example.smartcourse.mapper.CourseChapterMapper; import com.example.smartcourse.mapper.LearningRecordMapper; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.math.RoundingMode; Service RequiredArgsConstructor public class LearningRecordService { private static final double FINISH_RATIO 0.9; private final LearningRecordMapper learningRecordMapper; private final CourseChapterMapper courseChapterMapper; Transactional(rollbackFor Exception.class) public LearningRecord submitProgress(ProgressSubmitRequest request) { if (request.getProgressSeconds() request.getDurationSeconds()) { throw new BizException(学习进度不能超过章节总时长); } LearningRecord record learningRecordMapper.selectOne( new LambdaQueryWrapperLearningRecord() .eq(LearningRecord::getUserId, request.getUserId()) .eq(LearningRecord::getChapterId, request.getChapterId()) ); boolean finished request.getProgressSeconds() request.getDurationSeconds() * FINISH_RATIO; if (record null) { record new LearningRecord(); record.setUserId(request.getUserId()); record.setCourseId(request.getCourseId()); record.setChapterId(request.getChapterId()); record.setProgressSeconds(request.getProgressSeconds()); record.setDurationSeconds(request.getDurationSeconds()); record.setFinished(finished); learningRecordMapper.insert(record); } else { if (request.getProgressSeconds() record.getProgressSeconds()) { record.setProgressSeconds(request.getProgressSeconds()); record.setDurationSeconds(request.getDurationSeconds()); if (!Boolean.TRUE.equals(record.getFinished()) finished) { record.setFinished(true); } learningRecordMapper.updateById(record); } } return record; } public double getCourseProgress(Long userId, Long courseId) { Long totalChapters courseChapterMapper.selectCount( new LambdaQueryWrapperCourseChapter() .eq(CourseChapter::getCourseId, courseId) ); if (totalChapters null || totalChapters 0) { return 0D; } Long finishedChapters learningRecordMapper.selectCount( new LambdaQueryWrapperLearningRecord() .eq(LearningRecord::getUserId, userId) .eq(LearningRecord::getCourseId, courseId) .eq(LearningRecord::getFinished, true) ); return BigDecimal.valueOf(finishedChapters) .multiply(BigDecimal.valueOf(100)) .divide(BigDecimal.valueOf(totalChapters), 1, RoundingMode.HALF_UP) .doubleValue(); } }这里定义FINISH_RATIO 0.9意思是用户的播放进度达到章节总时长的 90%就认为该章节学完。这个阈值可以根据业务需要调整也可以改成“必须看到最后一秒才算学完”。LearningRecord.finished使用Boolean类型当已经完成时不会因为后续一次较小进度的上报而变回未完成这是防止状态回退的关键。5.4 DTO 与 Controller创建ProgressSubmitRequest作为参数接收对象并做参数校验。package com.example.smartcourse.dto; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; import lombok.Data; Data public class ProgressSubmitRequest { NotNull(message 用户ID不能为空) private Long userId; NotNull(message 课程ID不能为空) private Long courseId; NotNull(message 章节ID不能为空) private Long chapterId; NotNull(message 播放进度不能为空) Min(value 0, message 播放进度不能小于0) private Integer progressSeconds; NotNull(message 章节总时长不能为空) Min(value 1, message 章节总时长必须大于0) private Integer durationSeconds; }Controller 提供两个接口一个是上报进度一个是查询课程总进度。package com.example.smartcourse.controller; import com.example.smartcourse.common.Result; import com.example.smartcourse.dto.ProgressSubmitRequest; import com.example.smartcourse.entity.LearningRecord; import com.example.smartcourse.service.LearningRecordService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; RestController RequestMapping(/api/learning) RequiredArgsConstructor public class LearningRecordController { private final LearningRecordService learningRecordService; PostMapping(/progress) public ResultLearningRecord submitProgress(RequestBody Valid ProgressSubmitRequest request) { return Result.success(learningRecordService.submitProgress(request)); } GetMapping(/course-progress) public ResultDouble getCourseProgress(RequestParam Long userId, RequestParam Long courseId) { return Result.success(learningRecordService.getCourseProgress(userId, courseId)); } }启动类需要增加MapperScan注解否则 Mapper 不会自动扫描。package com.example.smartcourse; import org.mybatis.spring.annotation.M