十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Boot+Vue个性化学习系统:权限设计与推荐算法源码解析

Spring Boot+Vue个性化学习系统:权限设计与推荐算法源码解析 简介一套基于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.css、app.4c9a2401.css、485.bf0bf71c.css看起来像纯前端项目但打开完整源码才发现真正的主干是 Java 后端。这个所谓个性化智能学习系统本质是 Spring Boot 写权限、写推荐、写测评报告Vue 只负责消费接口。系统解决的是“千人一面”的在线学习问题学生注册后先做问卷生成基础水平画像之后每次学习行为都被记录成时间线由后端决定下一步推荐什么资源、安排什么练习。教师端负责上传视频、文档和习题按分类与标签管理。对要做毕业设计或课程作业的人来说这套代码值得拆的是三层角色权限如何控制接口、推荐引擎怎么算相似度、学习报告如何从行为数据聚合而来。下面从数据库模型开始一层层展开中间涉及的命令和代码可以直接抄到项目里改改跑通。2. 权限与业务模块Spring Security 配合 JWT 的角色/资源模型拿到源码第一件事不是看页面而是看pom.xml和application.yml。这套系统后端是 Spring Boot MyBatis-Plus MySQL 的常见组合Redis 用来缓存登录态和热点学习数据文件上传走本地磁盘路径。登录认证采用 JWT后端签发 token 后前端每次请求在Authorization头里携带Spring Security 过滤器负责校验。代码里学生、教师、管理员三种角色不是一个布尔字段硬编码而是通过 JWT 的role声明和 URL 拦截规则双重控制。2.1 六张核心表用户、资源、标签和学习行为建表语句里能把个性化学习的边界看得最清楚。用户表不只存用户名和密码还冗余了base_level和study_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_resource和resource_tag做多对多关联表但推荐引擎每次要给候选资源计算相似度时多一次 JOIN 都会拖慢响应。这版代码直接存逗号拼接的 tag id查询时用FIND_IN_SET或LIKE匹配。数据量在几千条时没问题到了百万级再考虑拆关联表这是毕业设计里一个值得写进答辩词的选型理由。2.2 JWT 登录与过滤器链接口权限的三个层次权限控制没有堆成一个巨型工具类而是分了三层过滤器层只做 token 校验逻辑层用PreAuthorize做角色判定数据层再根据当前登录用户过滤“只能看自己的数据”。登录接口的典型写法如下这里encoder是BCryptPasswordEncodertokenProvider是封装了jjwt的工具类。Service public class AuthService { private final UserMapper userMapper; private final JwtTokenProvider tokenProvider; private final PasswordEncoder encoder; public String login(String username, String password) { LambdaQueryWrapperSysUser 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, ListLong 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 ListLearnResVO recommend(Long userId, int limit) { UserProfile profile profileService.getByUserId(userId); ListEduResource 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) { SetString setA Arrays.stream(a.split(,)).collect(Collectors.toSet()); SetString setB Arrays.stream(b.split(,)).collect(Collectors.toSet()); SetString 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.6、0.25、0.15不是随便拍的。标签相似度占大头因为个性化推荐的本质是主题匹配难度拟合次之防止推荐内容超出学生当前承受能力时间衰减最小只会稍微压旧资源的分数。调参时建议先固定两个逐个改梯度下降式地找最优组合而不是三个一起动否则出问题没法定位。3.3 动态计划表按周生成学习日程并回填有了推荐结果还要把推荐资源落到具体日期。普遍做法是生成study_plan父表加plan_item子表父表记录计划的起止时间和目标子表按天拆分学习单元public StudyPlan generateWeeklyPlan(Long userId, ListLearnResVO recs) { StudyPlan plan new StudyPlan(); plan.setUserId(userId); plan.setStartDate(LocalDate.now()); plan.setEndDate(LocalDate.now().plusDays(7)); plan.setStatus(0); ListPlanItem 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.css、app.4c9a2401.css、485.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 命名来对抗数字包如果想让自己维护的版本摆脱485、398这种无意义命名直接在vue.config.js里配 optimizationconst 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.css、page-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表每天由定时任务跑一次前端查的就是汇总表而不是实时刷行为表。本文还有配套的精品资源点击获取
返回列表