SpringBoot+Thymeleaf+AI推荐:校园课程推荐系统设计与实现
又是一年毕业设计季如果你正在做或者准备做计算机毕业设计最近大概率刷到过一类标题特别“炸”的题目比如“基于SpringBootThymeleafAI推荐的校园课程推荐系统(源码LWPPT讲解)”。这类题目看起来功能齐全、技术栈主流但每年答辩现场总有人被同一个问题问倒你的“推荐”到底是怎么实现的数据从哪里来为什么给这个学生推荐这门课而不推荐另一门课很多同学的思路是把“AI大模型”当成主角第一反应是去调大模型接口或者从网上复制一份协同过滤源码结果做出来的系统页面很花哨逻辑却是断层。真正动手后才会发现难点不在于接口有多高级而在于推荐逻辑怎么和课程模块联动没有用户行为数据时怎么办推荐结果怎么展示、怎么向评委解释清楚。这篇文章要讲的就是一套基于 SpringBoot Thymeleaf AI 推荐算法的校园课程推荐系统从毕业设计的真实痛点出发把核心原理、数据库建模、SpringBoot 工程实现、Thymeleaf 页面渲染、运行验证和答辩高频问题一次性讲透。我的核心判断是这套系统的亮点不在于页面多漂亮而在于“推荐一门课给学生”这个动作是否可解释、可验证。抓住这一点你的项目就超过了大部分停留在增删改查层面的毕业设计。1. 这篇文章真正要解决的问题先说说这类题目为什么每年都很“卷”。校园课程系统本质上是一个典型的 CRUD 业务系统无非是用户管理、课程管理、选课退课、成绩查询。如果只把这些功能做完代码量确实够了但在答辩时很难展现出技术深度——评委老师看过的课程系统可能比你吃过的盐还多。于是很多同学会在题目里加一个“智能推荐”“AI课程推荐”的标签希望通过算法来拉开差距。但现实是大部分人在“推荐系统”这个点上只是挂了一个名字推荐列表是前端写死的或者随机返回几门课程或者直接调一个第三方大模型接口让模型“编”几门课。这三种做法都经不起追问。评委只要问一句“你用的什么推荐策略相似度怎么计算的为什么这么计算”如果答不上来前面的功能做得再好也会被扣分。所以要解决的核心问题不是“怎么把项目跑起来”而是“怎么把推荐系统讲明白”。基于内容推荐、协同过滤、冷启动处理、相似度计算这些概念必须落到代码里落到真实的课程标签和选课记录上。本文后面的内容全部围绕这个目标展开。如果你正准备做课程推荐相关课题或者你手里已经有一套课程系统代码但推荐模块很虚这篇文章都值得认真看一遍。我不会只讲理论也不会只贴一堆完整代码而是把“理论 → 设计 → 代码 → 验证”这条链路打通。2. 推荐系统的核心原理先讲清楚再写代码2.1 不是所有“推荐”都叫推荐系统推荐系统的本质是从一堆候选物品里根据用户的兴趣特征和行为历史预测用户可能喜欢的物品并按照预测分数排序返回前 N 个结果。在校园课程场景里候选物品就是课程用户特征就是学生的专业、年级、历史选课和成绩。系统要回答的问题非常具体这个学生已经学了《Java程序设计》和《数据结构》他接下来最适合学《数据库原理》还是《计算机网络》如果没有推荐系统传统做法是让学生自己去课程列表里翻或者按类型筛选。这种方式没有个性化对用户是不友好的。2.2 两个主流方向协同过滤和基于内容业界最经典的推荐算法有两条路线路线一基于用户的协同过滤User-Based Collaborative Filtering思路是“找相似的人”。先找到和你选课记录最相似的 K 个用户然后把那些用户选过、而你没选过的课程推荐给你。核心是计算用户与用户之间的相似度。路线二基于物品的协同过滤Item-Based Collaborative Filtering思路是“找相似的课”。如果很多选《Java程序设计》的用户也选了《数据结构》那么这两门课之间的相似度就很高。当用户选过前者系统就会推荐后者。路线三基于内容的推荐Content-Based Recommendation思路是“找你喜欢的类型”。给每门课程打上标签比如“计算机、编程、AI”然后统计用户历史选课中哪些标签出现得多形成用户偏好画像再计算候选课程标签与用户偏好画像的相似度。对于校园课程推荐系统我建议把“基于内容的推荐”作为核心策略。原因有两个数据容易解释。课程标签是静态的用户偏好是从选课记录中统计出来的每个推荐结果都能说出理由答辩时特别好讲。冷启动相对可控。新课程没有选课记录也能靠标签参与推荐新用户虽然没有历史但可以用热门课程作为兜底。当然如果你时间充足也可以同时实现一个“基于用户的协同过滤”作为对比展示你对多种推荐算法的理解。但从第一版落地的角度先把基于内容推荐做扎实性价比最高。2.3 相似度计算余弦相似度怎么理解推荐算法落地最关键的一步是相似度计算。以基于内容推荐为例我们需要把“用户偏好”和“课程特征”变成向量然后计算两个向量的夹角余弦值。余弦相似度的公式是cos(A, B) (A · B) / (|A| × |B|)通俗理解如果两个向量方向越一致夹角越小余弦值越接近 1说明相似度越高如果方向完全不相关余弦值接近 0说明没什么关联。它不像欧式距离那样看重数值大小而是看重方向非常适合描述“兴趣标签是否匹配”。在课程推荐场景里我们可以简化向量空间用户偏好向量每个标签对应的选择次数课程特征向量每门课的标签集合命中标签记为 1否则为 0。然后代入公式计算就能得到每门候选课程对当前用户的推荐分数。这个分数是可以排序的也是可以解释的。2.4 冷启动问题推荐系统最怕冷启动也就是没有足够数据时怎么推荐。校园课程系统里有三类冷启动新用户没有任何选课记录无法统计偏好新课程没有任何人选过无法通过选课关系计算协同过滤系统刚上线数据库里几乎没有用户行为数据。我的建议是分级处理新用户直接返回热门课程按已选人数排序新课程如果标签完整就让它参与基于内容的推荐如果数据库整体数据太少可以预先在数据库中准备一批模拟数据便于演示效果。这不是作弊而是项目演示和答辩的必要准备。3. 系统功能规划与数据库设计3.1 功能模块划分第一版系统建议控制范围把主链路做通即可用户模块注册、登录、个人信息维护课程模块课程列表、课程详情、关键词搜索选课模块选课、退课、我的课表推荐模块根据用户历史选课生成推荐列表、展示推荐理由管理后台课程管理、用户管理、基础数据统计。推荐模块是核心其他模块是为它服务的。如果时间不够先把“我的推荐”页面做好再补课程管理后台。3.2 数据库表设计数据库设计直接决定推荐算法能不能顺利实现。我建议至少设计三张表用户表、课程表、用户选课记录表。用户表t_userCREATE TABLE t_user ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 用户ID, username VARCHAR(50) NOT NULL COMMENT 登录账号, password VARCHAR(100) NOT NULL COMMENT 登录密码建议BCrypt加密存储, real_name VARCHAR(50) DEFAULT NULL COMMENT 真实姓名, stu_no VARCHAR(20) DEFAULT NULL COMMENT 学号, major VARCHAR(50) DEFAULT NULL COMMENT 专业, grade VARCHAR(10) DEFAULT NULL COMMENT 年级, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;课程表t_courseCREATE TABLE t_course ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 课程ID, course_name VARCHAR(100) NOT NULL COMMENT 课程名称, course_type VARCHAR(50) NOT NULL COMMENT 课程类型公共课/专业课/选修课, credit DECIMAL(3,1) NOT NULL COMMENT 学分, teacher VARCHAR(50) DEFAULT NULL COMMENT 授课教师, major VARCHAR(50) DEFAULT NULL COMMENT 建议修读专业, tags VARCHAR(255) DEFAULT NULL COMMENT 课程标签逗号分隔如 计算机,数学,AI, description TEXT COMMENT 课程简介, select_count INT DEFAULT 0 COMMENT 已选人数用于热度推荐, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, PRIMARY KEY (id), KEY idx_course_type (course_type) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT课程表;用户选课记录表t_user_courseCREATE TABLE t_user_course ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT 用户ID, course_id BIGINT NOT NULL COMMENT 课程ID, score DECIMAL(4,1) DEFAULT NULL COMMENT 成绩未出分时为NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 选课时间, PRIMARY KEY (id), KEY idx_user_id (user_id), KEY idx_course_id (course_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户选课记录表;这里有两个设计点需要说明。第一tags字段用逗号分隔字符串存储。这在正规系统中可能不够规范但毕业设计场景里这种设计能大幅简化推荐逻辑的复杂度。你不需要再建一张课程标签中间表直接拆字符串即可。第二select_count字段是一个冗余字段用于热度推荐。虽然可以在查询时通过t_user_course统计但每次实时统计会增加查询压力。为了方便演示和答辩在选课成功后对这个字段做1更新是很务实的做法。除了这三张表严谨的推荐系统通常还会记录用户的浏览行为、搜索记录、收藏记录。第一版可以不建但你要知道这是后续优化方向。4. 环境准备与前置条件开始写代码之前先把环境理清楚。本文的工程示例基于以下技术栈版本以你实际项目为准JDK1.8 或 11如果使用 Spring Boot 3.x需要 JDK 17Spring Boot2.7.x稳定常用教程多踩坑少ThymeleafSpring Boot 内置 starter 管理版本MyBatismybatis-spring-boot-starter 2.3.xMySQL5.7 或 8.0Maven3.6 以上IDEIntelliJ IDEA 即可。这里特别提醒一点不要盲目追求 Spring Boot 最新大版本。很多同学一创建项目就选了最新版结果发现 Thymeleaf 的配置方式、依赖坐标跟网上教程对不上浪费大量时间。初学阶段建议选择一个稳定、文档丰富的大版本先把项目跑通再考虑升级。如果你使用 Spring Boot 3.x需要注意两个变化MyBatis Starter 要选择mybatis-spring-boot-starter的 3.x 版本javax包改成了jakarta包代码中引入注解时要注意。本文代码按 MyBatis 注解方式编写省去 XML 配置适合第一版快速落地。5. 核心代码实现与关键逻辑精讲5.1 依赖与基础配置创建 Spring Boot 项目后pom.xml中至少需要以下依赖dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency /dependencies如果你用的是 Spring Boot 3.x请到 Maven 仓库查看最新mybatis-spring-boot-starter版本不要照抄 2.3.2。再看application.yml配置# 文件路径src/main/resources/application.yml server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/course_recommend?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password thymeleaf: cache: false encoding: UTF-8 mode: HTML mybatis: configuration: map-underscore-to-camel-case: truemap-underscore-to-camel-case建议开启这样数据库里的course_name可以自动映射为 Java 实体里的courseName少写很多麻烦。5.2 实体类与 Mapper 层课程实体类// 文件路径src/main/java/com/example/courserecommend/entity/Course.java public class Course { private Long id; private String courseName; private String courseType; private Double credit; private String teacher; private String major; private String tags; private String description; private Integer selectCount; // getter / setter 省略建议用 Lombok 的 Data 简化 }用户课程 Mapper负责查询用户选课历史和候选课程// 文件路径src/main/java/com/example/courserecommend/mapper/UserCourseMapper.java Mapper public interface UserCourseMapper { // 查询用户已经选过的课程 Select(SELECT c.* FROM t_course c INNER JOIN t_user_course uc ON c.id uc.course_id WHERE uc.user_id #{userId}) ListCourse selectCoursesByUserId(Param(userId) Long userId); // 查询用户没有选过的课程候选推荐池 Select(SELECT * FROM t_course WHERE id NOT IN (SELECT course_id FROM t_user_course WHERE user_id #{userId})) ListCourse selectCoursesNotSelectedByUser(Param(userId) Long userId); // 查询热门课程用于冷启动 Select(SELECT * FROM t_course ORDER BY select_count DESC LIMIT #{limit}) ListCourse selectHotCourses(Param(limit) int limit); }这段 SQL 里最关键的是第二条。它先把当前用户已经选过的课程 ID 查出来再用NOT IN排除剩下的就是候选推荐池。这种写法性能不一定最好但逻辑直白适合作为毕设第一版。5.3 推荐服务核心逻辑这是整个系统最有技术含量的部分。推荐服务要做四件事查用户历史选课统计用户标签偏好遍历候选课程计算相似度排序返回 Top N。推荐结果 VO// 文件路径src/main/java/com/example/courserecommend/vo/CourseRecommendVO.java public class CourseRecommendVO { private Course course; private Double score; private String reason; // getter / setter 省略 }核心推荐逻辑// 文件路径src/main/java/com/example/courserecommend/service/impl/RecommendServiceImpl.java Service public class RecommendServiceImpl implements RecommendService { private final CourseMapper courseMapper; private final UserCourseMapper userCourseMapper; public RecommendServiceImpl(CourseMapper courseMapper, UserCourseMapper userCourseMapper) { this.courseMapper courseMapper; this.userCourseMapper userCourseMapper; } Override public ListCourseRecommendVO recommendByUser(Long userId, int topN) { // 1. 查询用户已经选过的课程 ListCourse historyCourses userCourseMapper.selectCoursesByUserId(userId); // 2. 没有选课记录时采用热度推荐兜底避免冷启动空白 if (historyCourses null || historyCourses.isEmpty()) { return convert(courseMapper.selectHotCourses(topN), 新用户暂无选课记录先看看热门课程); } // 3. 统计用户的标签偏好标签 - 选择次数 MapString, Integer tagPreference new HashMap(); for (Course course : historyCourses) { for (String tag : splitTags(course.getTags())) { tagPreference.put(tag, tagPreference.getOrDefault(tag, 0) 1); } } // 4. 候选课程用户没有选过的课程 ListCourse candidateCourses userCourseMapper.selectCoursesNotSelectedByUser(userId); // 5. 计算每个候选课程与用户偏好的相似度 ListCourseRecommendVO result new ArrayList(); for (Course course : candidateCourses) { double score cosineScore(course, tagPreference); CourseRecommendVO vo new CourseRecommendVO(); vo.setCourse(course); vo.setScore(score); vo.setReason(buildReason(course, tagPreference)); result.add(vo); } // 6. 按相似度降序输出前 topN 条 result.sort((a, b) - Double.compare(b.getScore(), a.getScore())); if (result.size() topN) { return result.subList(0, topN); } return result; } /** * 余弦相似度计算。 * 简化思路以用户偏好标签集合作为向量空间 * 用户向量 标签选择次数课程向量 标签是否出现0/1。 */ private double cosineScore(Course course, MapString, Integer userPreference) { ListString courseTags splitTags(course.getTags()); if (courseTags.isEmpty() || userPreference.isEmpty()) { return 0D; } double dot 0D; double normUser 0D; for (Map.EntryString, Integer entry : userPreference.entrySet()) { normUser entry.getValue() * entry.getValue(); if (courseTags.contains(entry.getKey())) { dot entry.getValue(); } } // 课程向量中每个标签记为 1模长就是 sqrt(标签数) double normCourse courseTags.size(); if (normUser 0D || normCourse 0D) { return 0D; } return dot / (Math.sqrt(normUser) * Math.sqrt(normCourse)); } /** * 生成推荐理由让推荐结果可解释。 */ private String buildReason(Course course, MapString, Integer tagPreference) { ListString courseTags splitTags(course.getTags()); ListString matched courseTags.stream() .filter(tagPreference::containsKey) .collect(Collectors.toList()); if (matched.isEmpty()) { return 根据课程热度为你推荐; } return 匹配你的兴趣标签 String.join(、, matched); } private ListString splitTags(String tags) { if (tags null || tags.trim().isEmpty()) { return Collections.emptyList(); } return Arrays.stream(tags.split(,)) .map(String::trim) .filter(s - !s.isEmpty()) .collect(Collectors.toList()); } private ListCourseRecommendVO convert(ListCourse courses, String reason) { ListCourseRecommendVO list new ArrayList(); for (Course course : courses) { CourseRecommendVO vo new CourseRecommendVO(); vo.setCourse(course); vo.setScore(0D); vo.setReason(reason); list.add(vo); } return list; } }有几个细节值得说明。第一cosineScore里的向量空间做了简化。真正的余弦相似度应该把“用户偏好标签集合”和“课程标签集合”的并集作为统一的向量空间。但在工程实现中因为候选课程的标签如果不在用户偏好集合中它的向量分量就是 0对点积没有贡献对排序结果影响不大所以可以先按用户偏好集合近似计算。如果答辩时被问到你可以坦诚说明这是工程近似并说出完整理论公式反而显得到位。第二buildReason非常关键。它让每个推荐结果都有了可解释的理由“匹配你的兴趣标签计算机、编程”。这比单纯显示一个相似度分数高明得多评委会觉得你的系统有业务思考。第三冷启动兜底逻辑直接放在推荐服务里。用户没有选课记录时返回热门课程系统不会出现空白页面。这是很实际的产品意识。5.4 Controller 层与 Thymeleaf 模板Controller 层返回 Thymeleaf 视图// 文件路径src/main/java/com/example/courserecommend/controller/RecommendController.java Controller RequestMapping(/recommend) public class RecommendController { private final RecommendService recommendService; public RecommendController(RecommendService recommendService) { this.recommendService recommendService; } GetMapping(/my) public String myRecommend(RequestParam(defaultValue 1) Long userId, RequestParam(defaultValue 6) int topN, Model model) { ListCourseRecommendVO recommendList recommendService.recommendByUser(userId, topN); model.addAttribute(recommendList, recommendList); return recommend; } }注意这里的userId在真实系统中应该从登录 Session 中获取而不是通过请求参数传进来。第一版为了演示方便可以先用固定用户但答辩时最好说明“正式环境会从 Session 获取当前登录用户 ID”。Thymeleaf 模板!-- 文件路径src/main/resources/templates/recommend.html -- !DOCTYPE html html xmlns:thhttp://www.thymeleaf.org head meta charsetUTF-8 titleAI 课程推荐/title /head body h1为你推荐的课程/h1 !-- 冷启动提示 -- div th:if${#lists.isEmpty(recommendList)} p暂时没有推荐结果请先去课程中心选几门课吧。/p /div !-- 推荐列表 -- div classcourse-grid th:unless${#lists.isEmpty(recommendList)} div classcourse-card th:eachitem : ${recommendList} th:object${item.course} h3 th:text${item.course.courseName}课程名称/h3 p课程类型span th:text${item.course.courseType}类型/span/p p学分span th:text${item.course.credit}学分/span/p p授课教师span th:text${item.course.teacher}教师/span/p p推荐理由span th:text${item.reason}推荐理由/span/p p推荐指数span th:text${#numbers.formatDecimal(item.score, 1, 2)}0.00/span/p button th:onclickselectCourse(\ ${item.course.id} \)选这门课/button /div /div script function selectCourse(courseId) { if (confirm(确认选择这门课程)) { window.location.href /course/select/ courseId; } } /script /body /htmlThymeleaf 和 JSP 的最大区别在于Thymeleaf 是 HTML 文件可以直接用浏览器打开预览不需要容器渲染也能看到页面骨架。这对前后端联调和毕业设计展示都很友好。5.5 如果想把“AI大模型”真正做进去现在回到标题里的“AI大模型”。很多同学纠结我到底是接一个 ChatGPT 接口还是自己写推荐算法我的建议是先把推荐算法做好再考虑大模型扩展。实际上如果你非要让系统和大模型沾边可以在推荐模块之外增加一个“课程问答助手”功能让大模型根据课程描述生成学习计划、课程简介或选课建议。这种功能适合作为 PPT 里的展示亮点但不适合作为核心推荐链路的依赖。原因很简单大模型接口不稳定、有调用成本一旦超时整个推荐页面就崩了。如果真要扩展比较稳妥的做法是先用本地假数据模拟大模型返回结果把流程跑通再接入真实接口。同时一定要做好超时和异常降级不能影响主流程。6. 运行结果与效果验证6.1 初始化演示数据推荐系统没有数据就谈不上推荐效果。建议在数据库里准备一组有区分度的演示数据让答辩现场一打开页面就能看到效果。例如用户 A 选过《Java程序设计》《数据结构》标签偏向“计算机、编程”用户 B 选过《大学英语》《中国近代史》标签偏向“英语、人文”课程库里同时存在计算机类、数学类、外语类课程。这样两个用户打开“我的推荐”页面时推荐结果会有明显差异演示效果会非常直观。6.2 启动项目并验证项目启动命令mvn spring-boot:run启动成功后浏览器访问http://localhost:8080/recommend/my?userId1topN6预期结果用户有选课记录时页面返回按相似度降序排列的课程每门课都显示“匹配你的兴趣标签XXX”的推荐理由推荐指数越高的课程排在越靠前新用户没有选课记录时推荐列表返回热门课程。如果页面空白先看控制台日志有没有报错再看数据库连接是否正常。如果 MySQL 8.0 的驱动包不正确启动阶段就会直接报错。6.3 验证相似度排序是否合理可以人为构造一个最简单的验证场景。假设用户选过两门课偏好标签为“计算机2、编程1”。候选课程《数据库原理》标签是“计算机,数据库”计算出来的分数应该明显高于《大学物理》这种和计算机无关的课程。这种可解释的验证过程在毕业设计论文中是非常好的实验素材。你可以把计算过程、分数对比、排序结果截图放进论文作为系统效果展示。7. 常见问题与排查方法问题现象可能原因排查方式解决方案页面访问出现 Whitelabel Error Page请求路径写错或 Controller 未生效查看控制台日志确认项目启动完成检查Controller与GetMapping路径Thymeleaf 页面 CSS/JS 加载不出来静态资源路径映射不对浏览器 F12 查看资源请求状态确认是否 404确认页面中引用路径以/static开头例如th:href{/css/style.css}推荐列表为空用户没有选课记录或候选课程 SQL 查询条件有问题先确认t_user_course是否有数据再检查 Mapper SQL确保推荐服务中加入了热门课程兜底逻辑Spring Boot 版本太高导致配置失效最新大版本调整了部分自动配置逻辑查看项目所用 Spring Boot 版本与依赖版本是否匹配初学建议使用 Spring Boot 2.7.x 稳定版后续再升级中文乱码数据库字符集或页面编码不一致检查 MySQL 表字符集、JDBC URL 中characterEncoding设置统一使用utf8mb4JDBC URL 加上characterEncodingutf8MyBatis 查询结果字段为 null数据库字段下划线和 Java 驼峰没有自动映射开启map-underscore-to-camel-case: true或写 resultMap在application.yml中配置 MyBatis 驼峰映射端口被占用