Spring Boot+Vue个性化学习系统:权限设计与推荐算法源码解析
2026/9/14 6:33:51 网站建设 项目流程

简介:一套基于Spring Boot + Vue的前后端分离java个性化智能学习系统毕业设计/课程作业项目,面向学生、教师、管理员三类角色,完整覆盖用户注册登录与角色权限管理、学习资源上传分类与标签检索、学习需求分析、动态学习内容推荐、个性化学习计划制定、在线测试自动评分与即时反馈、学习进展报告、论坛讨论、小组学习、教师课程与学生管理等核心业务,功能体系完整,适合Java Web实战练习、毕业设计参考或在此基础二次开发。资源共364个文件,压缩包大小仅11.43MB,包含100个Java后端接口与业务逻辑代码、77个Vue前端页面组件、42个JavaScript脚本与20个CSS样式文件、46个PNG及23个JPG界面素材,另有SQL数据库脚本、YAML配置文件、说明文档等,目录层次清晰,便于按模块学习。目前已有85人学习下载,是轻量但功能齐全的全栈学习样例。通过该项目可掌握Spring Boot接口开发、Vue组件化开发、权限控制、数据库设计等关键技能,并能快速跑通一个可演示的智能学习平台,适合毕业设计或课程设计冲刺阶段参考借鉴。

1. 从 chunk 文件逆推这套 Spring Boot 学习系统,别只盯着前端产物

下载包里躺着一堆 Vue 构建产物,chunk-vendors.83167ee3.cssapp.4c9a2401.css485.bf0bf71c.css看起来像纯前端项目,但打开完整源码才发现真正的主干是 Java 后端。这个所谓个性化智能学习系统,本质是 Spring Boot 写权限、写推荐、写测评报告,Vue 只负责消费接口。系统解决的是“千人一面”的在线学习问题:学生注册后先做问卷,生成基础水平画像,之后每次学习行为都被记录成时间线,由后端决定下一步推荐什么资源、安排什么练习。教师端负责上传视频、文档和习题,按分类与标签管理。对要做毕业设计或课程作业的人来说,这套代码值得拆的是三层:角色权限如何控制接口、推荐引擎怎么算相似度、学习报告如何从行为数据聚合而来。下面从数据库模型开始一层层展开,中间涉及的命令和代码可以直接抄到项目里改改跑通。

2. 权限与业务模块:Spring Security 配合 JWT 的角色/资源模型

拿到源码第一件事不是看页面,而是看pom.xmlapplication.yml。这套系统后端是 Spring Boot + MyBatis-Plus + MySQL 的常见组合,Redis 用来缓存登录态和热点学习数据,文件上传走本地磁盘路径。登录认证采用 JWT,后端签发 token 后,前端每次请求在Authorization头里携带,Spring Security 过滤器负责校验。代码里学生、教师、管理员三种角色不是一个布尔字段硬编码,而是通过 JWT 的role声明和 URL 拦截规则双重控制。

2.1 六张核心表:用户、资源、标签和学习行为

建表语句里能把个性化学习的边界看得最清楚。用户表不只存用户名和密码,还冗余了base_levelstudy_goal,前者来自注册后的初始测试,后者来自问卷。教师表和学生表没有单独拆分,而是共用一个sys_user,通过role_type区分,后续课程归属用teacher_id指向同表字段。学习资源表是把视频、文档、习题统一存放的。

CREATE TABLE `sys_user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL, `password` varchar(120) NOT NULL, `role_type` tinyint NOT NULL DEFAULT 0 COMMENT '0-学生 1-教师 2-管理员', `base_level` tinyint DEFAULT 1 COMMENT '1-基础 2-进阶 3-提高', `study_goal` varchar(255) DEFAULT NULL COMMENT '学习目标', `created_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ); CREATE TABLE `edu_resource` ( `id` bigint NOT NULL AUTO_INCREMENT, `title` varchar(120) NOT NULL, `resource_type` tinyint NOT NULL COMMENT '1-视频 2-文档 3-习题', `category_id` bigint NOT NULL, `tag_ids` varchar(255) DEFAULT NULL COMMENT '逗号分隔的标签id', `difficulty` tinyint DEFAULT 2 COMMENT '1-简单 2-中等 3-困难', `teacher_id` bigint NOT NULL, `file_url` varchar(255) DEFAULT NULL, `created_time` datetime DEFAULT NULL, PRIMARY KEY (`id`) );

注意tag_ids这个字段,它是反范式设计。严格的三范式会让edu_resourceresource_tag做多对多关联表,但推荐引擎每次要给候选资源计算相似度时,多一次 JOIN 都会拖慢响应。这版代码直接存逗号拼接的 tag id,查询时用FIND_IN_SETLIKE匹配。数据量在几千条时没问题,到了百万级再考虑拆关联表,这是毕业设计里一个值得写进答辩词的选型理由。

2.2 JWT 登录与过滤器链:接口权限的三个层次

权限控制没有堆成一个巨型工具类,而是分了三层:过滤器层只做 token 校验,逻辑层用@PreAuthorize做角色判定,数据层再根据当前登录用户过滤“只能看自己的数据”。登录接口的典型写法如下,这里encoderBCryptPasswordEncodertokenProvider是封装了jjwt的工具类。

@Service public class AuthService { private final UserMapper userMapper; private final JwtTokenProvider tokenProvider; private final PasswordEncoder encoder; public String login(String username, String password) { LambdaQueryWrapper<SysUser> wrapper = Wrappers.lambdaQuery(SysUser.class) .eq(SysUser::getUsername, username); SysUser user = userMapper.selectOne(wrapper); if (user == null || !encoder.matches(password, user.getPassword())) { throw new BizException("用户名或密码错误"); } // 生成 token,把角色塞进 claim,过滤器里只读 claim 不查库 return tokenProvider.createToken(user.getId(), RoleEnum.fromCode(user.getRoleType())); } }

这段逻辑配合的过滤器在 Spring Security 里是重点面试考察点。OncePerRequestFilter里解析Authorization头,拿到 userId 和角色后放进SecurityContextHolder,控制层就能直接用@AuthenticationPrincipal取当前用户。下面是最常见的过滤链配置方式:

@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf().disable() .authorizeRequests() .antMatchers("/api/auth/**", "/file/**").permitAll() .antMatchers("/api/teacher/**").hasRole("TEACHER") .antMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated() .and() .addFilterBefore(new JwtAuthenticationFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class); return http.build(); }

antMatchers顺序是有讲究的,越具体的路径要放在越前面,.anyRequest().authenticated()是兜底。如果把/api/auth/**放到最后,匿名登录请求会先被拦截,排查半天发现是规则顺序问题。这属于典型的 java 面试八股文题目:Spring Security 过滤链是怎么排序的。

2.3 七个业务模块与 REST 接口的对应关系

摘要里列出的七个模块落到代码里是七个 controller。权限、资源、学习路径、测评、社交、教师管理、系统设置各有独立前缀。理解这个映射关系后,想扩展功能时就知道往哪个目录放文件。

模块接口前缀主要实体
用户与权限/api/auth/**/api/user/**SysUser,SysRole
学习资源/api/resource/**EduResource,ResourceCategory
个性化路径/api/plan/**StudyPlan,PlanItem
在线测评反馈/api/quiz/**QuizPaper,QuizQuestion,QuizAnswer
社交学习/api/forum/**/api/group/**ForumPost,LearnGroup
教师管理/api/teacher/**Course,StudentProgress
系统设置/api/admin/**SysConfig

有些接口路径是双身份复用的,比如/api/quiz/answer学生调用是提交答案,教师调用是查看全班报告,判断逻辑写在 Service 里。这种设计对课程作业来说完全够用,而且面试时能讲“一个接口两套语义怎么用角色区分”。

3. 个性化学习路径:基于标签匹配与难度系数的推荐实现

这一章是整个系统的灵魂。学习路径不是按课程表硬排,而是给出一个可计算的排序公式:候选资源进入推荐池之后,按标签相似度、难度适配度、时间衰减三个维度加权,最后取 Top N 插入学习计划表。

3.1 学习需求分析:问卷结果如何沉淀成用户画像

初始测试的数据结构很简单,一张user_profile表记录兴趣标签位图、基础水平、每周可投入时长。问卷不用做成几十道题,十道以内就能出结果。后端用枚举或位运算存储用户偏好的多个标签。下面用一个简化版本演示画像构建过程:

public UserProfile buildProfile(Long userId, Integer baseLevel, List<Long> interestTags, Integer weeklyHours) { UserProfile profile = new UserProfile(); profile.setUserId(userId); profile.setBaseLevel(baseLevel); // 把标签存成逗号字符串,方便与 edu_resource.tag_ids 直接比较 profile.setInterestTags(String.join(",", interestTags.stream().map(String::valueOf).collect(Collectors.toList()))); profile.setWeeklyHours(weeklyHours); return profileMapper.insert(profile) > 0 ? profile : null; }

这种直接在表里冗余逗号字符串的做法,换来的是推荐阶段少了一次多对多查询。如果将来标签数量膨胀到几十个,再改成JSON字段或关联表都来得及。关键是在这里确定了“兴趣标签列表”这个核心纬度的存储格式。

3.2 动态推荐:Jaccard 相似度与加权打分

推荐引擎的核心是一个打分函数。对每一条候选资源,算出它与用户兴趣的 Jaccard 相似度。Jaccard 的公式是交集大小除以并集大小,标签完全重合时为 1,完全不重合为 0。然后乘上难度拟合系数和时间衰减系数,综合排序。

public List<LearnResVO> recommend(Long userId, int limit) { UserProfile profile = profileService.getByUserId(userId); List<EduResource> candidates = resourceService.selectByLevelAndTag( profile.getBaseLevel(), profile.getInterestTags()); return candidates.stream() .map(res -> { double tagScore = jaccard(profile.getInterestTags(), res.getTagIds()); double diffScore = difficultyFit(profile.getBaseLevel(), res.getDifficulty()); double timeScore = timeDecay(res.getCreatedTime()); // 权重可以放到配置中心,方便调参 double score = 0.6 * tagScore + 0.25 * diffScore + 0.15 * timeScore; return new LearnResVO(res, score); }) .sorted(Comparator.comparingDouble(LearnResVO::getScore).reversed()) .limit(limit) .collect(Collectors.toList()); } private double jaccard(String a, String b) { Set<String> setA = Arrays.stream(a.split(",")).collect(Collectors.toSet()); Set<String> setB = Arrays.stream(b.split(",")).collect(Collectors.toSet()); Set<String> union = new HashSet<>(setA); union.addAll(setB); return union.isEmpty() ? 0 : (double) setA.size() / union.size(); }

注意jaccard这里偷了个懒,setA.size() / union.size()在 setA 和 setB 完全相等时为2/2而不是1,因为先求交集会多写三行代码,直接用较小集合数量除并集数量,在很多实际场景里也能排序。真正严格的 Jaccard 需要写成intersection.size() / union.size(),这版源码采用的正是简化口径。

权重系数0.60.250.15不是随便拍的。标签相似度占大头,因为个性化推荐的本质是主题匹配;难度拟合次之,防止推荐内容超出学生当前承受能力;时间衰减最小,只会稍微压旧资源的分数。调参时建议先固定两个,逐个改,梯度下降式地找最优组合,而不是三个一起动,否则出问题没法定位。

3.3 动态计划表:按周生成学习日程并回填

有了推荐结果,还要把推荐资源落到具体日期。普遍做法是生成study_plan父表加plan_item子表,父表记录计划的起止时间和目标,子表按天拆分学习单元:

public StudyPlan generateWeeklyPlan(Long userId, List<LearnResVO> recs) { StudyPlan plan = new StudyPlan(); plan.setUserId(userId); plan.setStartDate(LocalDate.now()); plan.setEndDate(LocalDate.now().plusDays(7)); plan.setStatus(0); List<PlanItem> items = new ArrayList<>(); for (int i = 0; i < recs.size(); i++) { PlanItem item = new PlanItem(); item.setPlanId(plan.getId()); item.setResourceId(recs.get(i).getResourceId()); // 每天安排一个学习单元,时间冲突时顺延 item.setPlanDate(LocalDate.now().plusDays(i / 2)); item.setFinishFlag(false); items.add(item); } // 批量插入子表,避免 for 循环里逐条 insert planItemService.saveBatch(items); return plan; }

i / 2的设计是每天排两个资源,撑满七天的计划只需要 14 条推荐。这个生成方法建议放到异步任务里执行,因为学生完成测评后往往立刻想看结果,同步生成会导致接口响应变慢。这里隐藏的坑是saveBatch需要 MyBatis-Plus 的IService支持,普通 Mapper 没有批量方法,逐条 insert 在计划较大时性能很差。

4. 前端构建产物还是后端接口:用 vue-router 懒加载反查 chunk 与 API

资源清单里重复出现的chunk-vendors.83167ee3.cssapp.4c9a2401.css485.bf0bf71c.css不是乱码,而是 Webpack 打包策略的直接证据。chunk-vendors是所有第三方库的公共包,Vue、Vue Router、Axios、ElementUI 都在里面;app是入口包;数字前缀的 chunk 则是各个路由页面按需加载的产物,也是页面懒加载是这样配置的:

// 动态 import 让 Webpack 自动按路由拆包 const StudentIndex = () => import(/* webpackChunkName: "student-index" */ '@/views/student/index.vue'); const TeacherDashboard = () => import(/* webpackChunkName: "teacher-index" */ '@/views/teacher/index.vue'); const routes = [ { path: '/student', component: StudentIndex }, { path: '/teacher', component: TeacherDashboard } ];

4.1 Webpack runtime 里藏着 chunk id 到文件名的映射

如果只拿到构建产物且没被混淆到不可读,可以通过 runtime 文件找到 chunk id 和文件名的对应关系。现代 Webpack 会把这一映射写在js/runtime.js里,浏览器打开未压缩的 runtime 能直接看到类似{"485":"485.bf0bf71c.css"}的结构。找不到时就在浏览器控制台执行下面的代码来枚举已加载的 chunk:

// 常见实现:Webpack 4 将已加载的 chunk 记录放在 window.webpackJsonp // 数组元素结构是 [chunkId数组, 模块对象, 回调函数] window.webpackJsonp && window.webpackJsonp.forEach((entry) => { console.log(entry[0]); // 输出该次加载对应的 chunk id 集合 });

把输出的 chunkId 列表和 Network 面板里实际加载的文件对照,就能确定某个页面对应哪个数字 chunk。这个方法在做问题定位时很有效,比如用户反馈测评页面白屏,先看 Network 里是否加载了485.*.css,若没有就说明路由懒加载的 chunk 因为路径写错而 404 了,这是 Vue 项目部署到二级目录时常见的坑。

4.2 从 chunk 反推页面的 API 依赖

每个懒加载的 chunk 内部会 import 对应的 service 模块,这些 service 里已经写好了后端接口地址。反查思路是先搜索baseURL,比如 axios 实例中配置了baseURL: '/api',则后面的所有请求路径都是相对这个前缀展开的。在一套完整源码里,最直接的方式是用 IDE 全局搜索url:@RequestMapping,把后端 controller 和前端 service 做一次脑内映射。

另一个技巧是在控制台里直接过滤请求:

// 钩住 fetch 和 XMLHttpRequest,截获所有向后端发出的请求 const originalFetch = window.fetch; window.fetch = function(...args) { console.log('[api]', new Date().toLocaleTimeString(), args[0]); return originalFetch.apply(this, args); };

打开/student页面,控制台会依次打印出/api/plan/today/api/quiz/undone/api/forum/hot等请求,这就是该系统的主要数据流。发现了某个页面缺少数据源,就去后端找对应 controller,再看这个 controller 的 Service 实现,排查链路就建立起来了。

4.3 自定义 chunk 命名来对抗数字包

如果想让自己维护的版本摆脱485398这种无意义命名,直接在vue.config.js里配 optimization:

const path = require('path'); module.exports = { productionSourceMap: false, configureWebpack: { optimization: { splitChunks: { chunks: 'all', cacheGroups: { views: { test: /[\\/]src[\\/]views[\\/]/, name(module) { const match = module.resource.match(/views[\\/](.+?)[\\/]/); return match ? 'page-' + match[1].toLowerCase() : 'page-other'; } } } } } } };

这样构建出来的产物会变成page-student.xxxxx.csspage-teacher.xxxxx.css,后期维护时一眼就知道哪个 chunk 属于哪个页面。需要强调的是,缓存策略对文件名极其敏感,改了 chunk 名会让重新发布的版本整体失效,部署时要做 hash 指纹的对比和缓存清理,否则用户端拿到的还是旧 chunk。

5. 学习报告接口不只是分页:一周学习行为的 SQL 聚合与热力图

测评报告和学习报告是两回事,前者是答题得分,后者是行为分析。系统定期生成的学习进展报告,本质是对study_record表的按时间分组聚合。这里通常用一条 SQL 解决,避免在 Java 内存里做大量计算:

SELECT DATE(study_time) AS study_date, COUNT(*) AS study_count, SUM(TIMESTAMPDIFF(MINUTE, start_time, end_time)) AS total_minutes, SUM(CASE WHEN is_pass = 1 THEN 1 ELSE 0 END) AS pass_count FROM study_record WHERE student_id = #{studentId} AND study_time >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) GROUP BY DATE(study_time) ORDER BY study_date;

5.1 聚合结果落到 VO 后再做有效格式化

把 SQL 查出的结果放进一个WeeklyReportVO,里面包含日期、学习次数、时长、通过数四个字段。如果某天没有学习记录,SQL 不会返回这一行,Java 端需要补零,做法是循环七天日期逐个查询 Map,缺失日期补 0。这个补零逻辑是报告中容易漏的地方,直接拿 SQL 结果渲染图表会让周报缺位置,导致 ECharts 的横轴少几天。

5.2 用 ECharts 热力图把报告可视化

ECharts 的 calendar 热力图适合展示一周学习趋势,每个格子表示一天的学习时长,颜色越深说明投入越大。前端拿到WeeklyReportVO列表后转换成[日期, 学习分钟数]的二维数组,配置里用visualMap控制颜色渐变。这里注意,后端返回的时间字段要统一成yyyy-MM-dd,否则 JS 端new Date()在不同浏览器下解析结果有差异,时区问题会让日期偏移一天。

报告接口同时也是一个常见的 java 面试题切入点:一张大表要出周报,除了GROUP BY还能怎么做?该系统的体量不需要引入 ES,但面试官会顺着追问索引怎么建,此时可以说student_id + study_time的联合索引是必加的,且study_time表达式必须写在整个查询的WHERE后面才有可能命中索引。若把这版源码改造成复杂报表,可以考虑把聚合结果预计算进一张learner_report_daily表,每天由定时任务跑一次,前端查的就是汇总表,而不是实时刷行为表。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询