简介:一份基于 Vue、Spring Boot 与 MySQL 的学校热点新闻推送系统毕业设计资源,面向需要完成课程设计或毕业设计的计算机专业学生,也适合想学习前后端分离项目结构、RBAC 权限模型及 Vue 与 Spring Boot 整合的中级开发者。系统以热点新闻管理为核心,覆盖热点新闻、留言、评论、收藏业务模块,同时内置用户、部门、角色、菜单、日志、数据字典、文件管理、图表展示等基础功能;权限方面采用基于角色的访问控制,支持自定义角色并分配权限,按钮级细粒度控制可满足学校管理员、学生等不同角色的差异化使用需求。压缩包约 7.09MB,携带便捷,适合下载后对照学习或作为毕业设计、课程设计及权限控制相关二次开发的参考蓝本。已有 354 人学习下载,可用于毕设选题、项目实训阶段深入研读与功能扩展。
1. 学校热点新闻推送系统的技术定位与选题边界
学校热点新闻系统和普通资讯站最大的区别,不在新闻的增删改查,而在「谁能发、谁能删、谁能审」。用 Java + Vue + SpringBoot + MySQL 这套技术栈做热点新闻推送,比单纯写 CRUD 有意义的地方也在这里:管理端负责 RBAC 权限控制,学生端负责热点新闻的评论、收藏、留言互动。系统自带用户、部门、角色、菜单、日志、数据字典、文件、图表等基础模块,权限可精确到按钮级别,也支持自定义角色分配权限。它的边界很清晰,适合三类人:正在选毕业设计题目的 Java 方向学生,要给院系或社团搭校园信息发布平台的人,以及想在简历里突出全栈能力和权限设计、准备 java 与 springboot 面试题的求职者。
2. SpringBoot+MyBatis 的 RBAC 权限模型与按钮级校验实现
2.1 五张权限表的建模与关系
这套系统的权限核心不是把角色字段塞进用户表,而是用标准 RBAC 的五张表:用户表 sys_user、角色表 sys_role、菜单表 sys_menu,以及两张关联表 sys_user_role、sys_role_menu。菜单表里同时存目录、页面和按钮,用 menu_type 字段区分:1 是目录,2 是菜单页,3 是按钮。按钮的权限标识放在 perms 字段,例如 news:add 表示新增热点新闻,news:delete 表示删除热点新闻。
建表时几个容易踩的细节:菜单表里 parent_id 要默认 0 而不是 NULL,否则前端组装树形组件时要多写一层判空;perms 字段要加唯一索引,因为后端做按钮校验时是直接拿字符串匹配的,重复标识会让权限判断失去意义。标准建表语句如下:
CREATE TABLE sys_menu ( id BIGINT PRIMARY KEY AUTO_INCREMENT, parent_id BIGINT DEFAULT 0 NOT NULL, menu_name VARCHAR(64) NOT NULL, menu_type TINYINT NOT NULL COMMENT '1 目录, 2 菜单, 3 按钮', perms VARCHAR(100) DEFAULT NULL COMMENT '权限标识, 如 news:add', path VARCHAR(200) DEFAULT NULL, component VARCHAR(200) DEFAULT NULL, sort_no INT DEFAULT 0, UNIQUE KEY uk_perms (perms) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;parent_id 用 0 做根节点,目录和菜单都挂在根下;perms 只对按钮有实际意义,目录和菜单可以留空。这个表设计同时服务两端:管理端用 menu_type 为 1 和 2 的记录渲染左侧菜单树,后端用 menu_type 为 3 的记录做接口权限校验,同一张表喂两个场景,避免建两张结构几乎一样的表。
2.2 登录后权限集合的组装与缓存策略
用户权限集合如果每次请求都现算,会连着 join 四张表,接口一多压力就上来了。常见做法是登录成功后一次性查出当前用户全部权限标识,放到内存或 Redis 里。查询 SQL 是标准的 RBAC 五表关联:
SELECT DISTINCT m.perms FROM sys_user u JOIN sys_user_role ur ON u.id = ur.user_id JOIN sys_role_menu rm ON ur.role_id = rm.role_id JOIN sys_menu m ON rm.menu_id = m.id WHERE u.id = #{userId} AND m.perms IS NOT NULL这条 SQL 返回的结果会存到一个线程安全的用户上下文里,后续拦截器只从上下文取值,不再回查数据库。单体部署时可以直接用 ConcurrentHashMap 做缓存,key 用 login:perms:userId;如果以后拆成多实例,再换成 Redis。我一般会顺手把用户基本信息也存进上下文,这样日志模块记录操作人时不用再按 userId 查一次用户表。刷新权限的时机也很明确:管理员修改角色菜单后,把该角色下所有用户的缓存 key 删掉,下次请求自动重建。
2.3 基于注解的按钮级权限校验
后端实现按钮级权限,最清晰的方案是自定义注解加拦截器。定义一个@RequiresPermission注解,参数就是权限标识字符串:
@Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface RequiresPermission { String value(); }Controller 上使用:
@PostMapping("/news") @RequiresPermission("news:add") public Result<Void> addNews(@RequestBody News news) { newsService.add(news); return Result.success(); }拦截器里做校验:
public class PermissionInterceptor extends HandlerInterceptorAdapter { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; // 静态资源和预检请求直接放行 } HandlerMethod handlerMethod = (HandlerMethod) handler; RequiresPermission annotation = handlerMethod.getMethodAnnotation(RequiresPermission.class); if (annotation == null) { return true; // 未标注注解的接口按公开接口处理 } List<String> perms = UserContext.getCurrentPerms(); if (!perms.contains(annotation.value())) { throw new PermissionDeniedException("无访问权限: " + annotation.value()); } return true; } }这里有个容易被忽略的细节:判断 handler 是否为 HandlerMethod 这一步不能省,否则 Swagger 接口、静态资源请求进来时 handler 不是 HandlerMethod 类型,强转会直接抛 ClassCastException。另外注意注解的默认策略是「没有注解就放行」,开发阶段方便调试,但上线前要逐个接口检查敏感操作是否都补了权限标注,漏一个就是越权风险点。
2.4 前端菜单树与权限标识的映射策略
权限标识的设计直接决定前后端联动的成本。拿热点新闻管理页举例,在 sys_menu 中会插入一条 menu_type 为 2 的菜单记录,path 为 /news,component 为 news/index,下面再挂若干 menu_type 为 3 的按钮记录。按钮标识建议统一用「模块:动作」命名:
| 权限标识 | 所在模块 | 动作说明 |
|---|---|---|
| news:list | 热点新闻 | 查询列表与详情 |
| news:add | 热点新闻 | 发布热点新闻 |
| news:edit | 热点新闻 | 修改已发布内容 |
| news:delete | 热点新闻 | 下架或删除新闻 |
| news:comment | 热点评论 | 发表评论 |
| news:favorite | 热点收藏 | 收藏与取消收藏 |
这套命名在后端按前缀判断归属模块,在前端按冒号切分后绑定按钮显隐,两侧只要共用同一份权限字典,就不会出现「按钮能看见但接口 403」或「接口能调通但按钮不显示」的问题。常见误用是让前端根据角色名判断按钮显隐,角色一旦自定义就不起作用,而这套系统支持自定义角色分配权限,所以一切判断都必须以 perms 字符串为准。
3. Vue+Element UI 动态路由与热点列表页数据流设计
3.1 动态路由的组装与菜单过滤
前端不能把菜单写死,否则管理员改了角色权限,学生端刷新后依然能看到被回收的菜单。常见做法是登录后从后端拉取当前用户的菜单树,转成 Vue Router 需要的路由对象,再通过 addRoute 动态注册。路由守卫里做前置判断:
router.beforeEach(async (to, from, next) => { const token = getToken() if (!token) { next('/login') return } if (store.state.user.permissions.length === 0) { const user = await store.dispatch('user/getInfo') const routes = buildRoutes(user.menus) routes.forEach(route => router.addRoute(route)) next({ ...to, replace: true }) return } next() })buildRoutes 的核心工作是把后端菜单记录里的 component 字符串映射到真实组件对象。直接用 import 的 glob 形式一次性注册,而不是手写 require 路径:
const modules = import.meta.glob('../views/**/*.vue') function buildRoutes(menus) { return menus.filter(m => m.menuType === 2).map(m => ({ path: m.path, name: m.path.replaceAll('/', ''), component: modules[`../views/${m.component}.vue`] })) }menuType 过滤很关键:目录和按钮不需要生成路由,只把类型为 2 的菜单页注册进去。用 import.meta.glob 的好处是 Vite 构建时能静态分析出所有组件,打包后不会因为动态字符串导致路由对应组件找不到、页面白屏。这里也顺便处理了 vue 路由参数的问题,动态路由里的查询参数通过route.query读取,列表页搜索关键字就是走这条链路。
3.2 axios 封装与热点列表的分页参数
热点列表页涉及三个核心请求参数:pageNum、pageSize、keyword。axios 实例统一设置 baseURL 和 token 请求头,响应拦截器对 401 做跳登录处理:
const service = axios.create({ baseURL: '/api', timeout: 10000 }) service.interceptors.request.use(config => { config.headers['Authorization'] = getToken() return config }) service.interceptors.response.use( response => response.data.data, error => { if (error.response && error.response.status === 401) { store.commit('user/logout') router.push('/login') } return Promise.reject(error) } )列表接口函数把分页参数显式透传:
export function getHotNewsList(params) { return service.get('/news/list', { params: { pageNum: params.pageNum, pageSize: params.pageSize, keyword: params.keyword || '' } }) }接口返回的数据结构是{ rows: [], total: 0 },el-pagination 组件直接绑定 total 即可。一个容易被忽略的点:响应拦截器把 response.data.data 直接剥出来了,分页组件拿到的就是业务数据而不是 axios 的 response 对象,组件里 data 赋值少一层嵌套。代价是类型提示会丢,但做毕设和内部系统,这个取舍值得,调试时少踩 undefined 的坑。
3.3 v-permission 指令控制按钮显隐
前端按钮级权限不能只靠 v-if 手写判断,正确的姿势是自定义指令。全局注册一个 permission 指令,在元素插入 DOM 时检查当前用户权限集合:
Vue.directive('permission', { inserted(el, binding) { const required = binding.value const permissions = store.state.user.permissions if (required && !permissions.includes(required)) { el.parentNode && el.parentNode.removeChild(el) } } })页面上直接使用:
<el-button v-permission="'news:add'" type="primary" @click="openAddDialog" >发布热点</el-button>这个方案有两个注意点:第一,Vue 3 里指令生命周期从 inserted 改成了 mounted,写法要跟着框架版本走;第二,按钮如果本身还有 v-if 条件,指令插入和元素插入的时序要保证权限集合已经加载完成,所以路由守卫里必须先 await 完 getInfo 再放行进页面,否则 permissions 还是空数组,所有按钮都会被整个删掉。与后端权限字典保持一致的对照关系如下:
| 权限标识 | 后端用法 | 前端用法 |
|---|---|---|
| news:list | 查询列表接口放行校验 | 列表页不控显隐 |
| news:add | Controller 注解校验 | 发布按钮 v-permission |
| news:edit | 修改接口校验 | 编辑按钮 v-permission |
| news:delete | 删除接口校验 | 删除按钮 v-permission |
| news:comment | 评论接口校验 | 评论提交按钮 v-permission |
从前端路由守卫到按钮指令,这一层本质是在复刻后端权限模型。真正容易出问题的不是单点,而是两侧不同步:后端改了 perms 标识,前端指令没跟着改,按钮显示了但接口 403;前端改了后端没改,接口能调但按钮不显示。两端始终用同一份权限字典,是这套系统稳定运行的前提。
4. 热点评论、留言与收藏模块的表设计和事务边界
4.1 评论模块的索引设计与防重复提交
热点新闻下的评论表不能只设计成 id、news_id、content 三列的极简结构,实际场景里要区分「根评论」和「子回复」,还要支持后台审核状态。适合直接落地的结构如下:
CREATE TABLE news_comment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, news_id BIGINT NOT NULL, user_id BIGINT NOT NULL, parent_id BIGINT DEFAULT 0 COMMENT '0 表示根评论', content VARCHAR(500) NOT NULL, status TINYINT DEFAULT 1 COMMENT '1 显示, 0 隐藏', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_news_time (news_id, create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE news_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, content TEXT NOT NULL, status TINYINT DEFAULT 0 COMMENT '0 未读, 1 已读', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;评论列表页是典型的「先查列表再补详情」场景:先按新闻 ID 分页查根评论,再按根评论 ID 集合一次性 IN 查询子评论,在内存里按 parentId 分组,避免逐条查子评论造成 N+1。idx_news_time 这个联合索引让「查某条新闻的最新评论」能走索引回表,而不是全表扫描。分页深了之后,limit 10000, 20 这种深分页会越来越慢,后续优化方向是改成 create_time 游标翻页。
评论和留言的 status 字段是给审核流程用的,管理员在后台通过后状态置 1,学生端才可见。这个状态流转要记到日志模块,方便回溯是哪位管理员、在什么时间点的审核,对应到 sys_log 里就是一条 module 为 news_comment、action 为 audit 的操作记录。
4.2 收藏模块的幂等设计与事务边界
收藏是典型的幂等敏感操作,用户快速双击「收藏」按钮会产生两个并发请求,如果只在前端做节流,后端依然可能插入两条重复记录。最底层的兜底是表结构上直接加唯一索引:
CREATE TABLE news_favorite ( id BIGINT PRIMARY KEY AUTO_INCREMENT, news_id BIGINT NOT NULL, user_id BIGINT NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_news_user (news_id, user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;后端方法用事务控制收藏关系与收藏计数的同步:
@Transactional(rollbackFor = Exception.class) public void toggleFavorite(Long newsId, Long userId) { Favorite favorite = favoriteMapper.selectByNewsAndUser(newsId, userId); if (favorite != null) { favoriteMapper.deleteById(favorite.getId()); newsMapper.decreaseFavoriteCount(newsId); } else { favoriteMapper.insert(newsId, userId); newsMapper.increaseFavoriteCount(newsId); } }这里的事务边界非常明确:收藏关系记录和新闻表里的 favorite_count 必须同时成功或同时回滚。否则会出现用户已收藏但计数没加,或者计数加了但收藏关系没落库的数据不一致。唯一索引在并发下会抛 DuplicateKeyException,捕获后返回「已收藏,请勿重复操作」,比引入分布式锁简单得多。对学生端个人中心来说,收藏列表就是按创建时间倒序查 news_favorite 再关联新闻表,数据量不大,单表查询足够。
4.3 留言模块的内容过滤与状态流转
热点留言模块的处理链路是:学生提交留言、内容过滤、入库、管理员审核、展示。内容过滤不能只靠前端 hidden 校验,后端必须过滤一次。常见做法是启动时加载敏感词文件到内存,用多模式匹配算法过滤,命中后替换为星号。项目里不引入额外重量级依赖的话,可以用 hutool 的 SensitiveUtil,内置敏感词库也支持自定义词库。
@Component public class SensitiveWordFilter { public String filter(String text) { if (text == null || text.length() == 0) { return text; } // 命中敏感词后统一替换为 *,避免前端展示时出现跳过判断的越权内容 return SensitiveUtil.getSensitiveWordReplace(text, '*'); } }这段逻辑的作用是在留言和评论入库前统一做一道拦截,替换后再走正常的业务校验。注意 SensitiveUtil 匹配的是自定义词库文件里的词条,部署新词库后需要重启或提供热加载刷新接口,否则词库更新不生效。
4.4 业务状态字段与数据字典的对应关系
系统带的数据字典模块,主要就是管理这类状态字段的可视化映射。页面展示时如果直接 if-else 写死状态文字,后面加一种状态就要改前端代码,而数据字典把「代码值」和「展示文案」解耦了:
| 字段 | 取值 | 含义 | 数据字典类型 |
|---|---|---|---|
| news_comment.status | 0 | 隐藏 | comment_status |
| news_comment.status | 1 | 显示 | comment_status |
| news_message.status | 0 | 未读 | message_status |
| news_message.status | 1 | 已读 | message_status |
后端返回给前端时,可以通过字典类型加代码值查询文案,前端只渲染从 dict 接口拿到的 label,不再硬编码状态文字。新增状态只需要在字典表里加一条记录,管理端的数据字典管理页面就能实时生效。
5. 用 sys_log 和聚合查询验证热点推送效果
5.1 从日志表统计推送点击率
日志模块记录了每个用户的关键操作,包括热点新闻的查看、评论、收藏。要验证「推送到底有没有效果」,直接对 sys_log 做聚合查询:
SELECT DATE_FORMAT(create_time, '%Y-%m-%d') AS day, COUNT(DISTINCT user_id) AS uv, COUNT(*) AS pv FROM sys_log WHERE module = 'news' AND action = 'view' GROUP BY DATE_FORMAT(create_time, '%Y-%m-%d') ORDER BY day DESC;COUNT(DISTINCT user_id) 和 COUNT(*) 的差值能看出单日访问是否被个别用户刷起来:uv 低但 pv 高,说明来了几个深度阅读的用户;uv 高但 pv 低,说明内容覆盖到了人但没人点进去,需要优化标题和封面。
5.2 图表展示模块的日期聚合口径
后台首页的图表模块通常渲染近 7 天的新增新闻量和收藏量,两条 SQL 分别按天聚合,在服务端合成 List 返回给 ECharts。折线图只需要 x 轴日期数组和 y 轴数量数组,后端返回[{ day: '2025-03-01', count: 12 }]这种结构,前端用 map 拆开即可。按新闻维度排行时用一条分组查询:
SELECT news_id, COUNT(*) AS favorite_count FROM news_favorite GROUP BY news_id ORDER BY favorite_count DESC LIMIT 10;5.3 用 EXPLAIN 验证热点列表的索引命中
热点列表如果出现慢查询,先用 EXPLAIN 看执行计划确认索引是否生效:
EXPLAIN SELECT * FROM news_comment WHERE news_id = 10086 ORDER BY create_time DESC LIMIT 20;type 是 ref 说明命中了 idx_news_time 联合索引;type 是 ALL 说明索引没建上或查询条件没走到索引。容易被忽视的一点是,ORDER BY create_time 要和 WHERE 的 news_id 组成联合索引,否则 MySQL 回表后还要做 filesort,数据量过了十万之后排序会变成明显瓶颈。验证完索引还不够,最好把这条慢查询的完整语句记录到日志表里,因为这个动作本身就是 sys_log 模块的典型用法:日志不只是给管理员看的操作记录,更是排查性能问题和统计业务指标的数据源。
本文还有配套的精品资源,点击获取