消防知识宣传系统完整源码:Spring Boot后端+Vue用户/管理双前端+MySQL数据库
简介:提供一套开箱即用的消防知识宣传平台源码,包含用户端(client_home)和管理端(client_admin)两个独立前端项目,服务端(server)基于Spring Boot构建RESTful API,数据库采用MySQL,配套sql.sql脚本支持一键初始化数据。项目结构清晰,含详细环境配置说明文档,适配JDK 8及以上、MySQL 5.7或8.0。功能覆盖消防知识分类展示、关键词搜索、图文内容发布、多角色权限管理(普通用户/管理员)、后台内容增删改查等核心模块。所有代码已整理归档,无冗余文件,.gitignore和.inscode等开发辅助文件齐全,可直接导入IDE运行调试,满足高校计算机专业毕业设计、课程设计或小型科普类系统快速搭建需求。
1. 项目概述:为什么这套消防知识系统值得你花时间细读?
我带过六届计算机专业毕业设计,每年都有至少二十个学生卡在“选题—搭建—调试—答辩”这个死循环里。最常见的问题不是技术不会,而是找不到一个真正能跑起来、逻辑清晰、边界明确、又不涉及敏感内容的完整项目模板。很多所谓“开源毕设”,要么是半成品(前端页面打不开、后端接口404)、要么是过度工程化(硬塞Redis、Elasticsearch、微服务,学生连Spring Boot基础都没吃透)、要么就是功能堆砌但权限混乱、数据库设计反范式、连个基础搜索都写成like ‘%关键词%’ 这种性能黑洞。而眼前这套“消防知识宣传系统”,是我近几年见过最接近“教科书级教学原型”的实战项目——它不炫技,但每一步都踩在高校教学和工程落地的交界点上。
核心关键词已经说得很清楚:“消防知识系统”、“Spring Boot后台”、“MySQL数据脚本”、“双端分离架构”。这四个词背后,是一套经过真实课堂验证的、可拆解、可延展、可答辩的技术组合。它解决的不是“高并发秒杀”这种脱离学生能力边界的幻想,而是“如何用最标准的方式,把一个单位内部科普平台从零搭出来”。用户端(client_home)专注信息触达:首页轮播图、按“火灾预防”“逃生技巧”“器材使用”等维度分类浏览、支持标题+正文模糊搜索、图文混排详情页;管理端(client_admin)聚焦内容治理:管理员登录后可增删改查知识条目、上传图片、设置是否置顶、审核发布状态;后端(server)则严格遵循RESTful规范,用Spring Security做RBAC权限控制(普通用户只能看,管理员才能进后台),所有API路径清晰如 /api/knowledge/list、/api/knowledge/detail/{id};数据库就一张 knowledge 表撑起全部业务,外加 user 和 role 表做权限支撑,sql.sql 脚本里字段类型、索引、初始测试数据全配好,连MySQL 5.7和8.0的时区兼容性都做了注释说明。这不是玩具项目,这是你答辩PPT里能画出完整三层架构图、能对着代码逐行解释“为什么这里用@RequestBody而不是@RequestParam”、能现场演示“新增一条消防知识后,用户端首页如何实时刷新”的底气来源。
更重要的是,它的结构干净得像手术室:client_home 和 client_admin 是两个完全独立的Vue CLI 4.x项目,互不耦合;server 是标准的Spring Boot 2.7.x(兼容JDK 8/11),没有Spring Cloud全家桶的干扰;整个资源包里没有一个jar包、没有node_modules压缩包、没有IDE配置文件,只有源码、SQL脚本和一份手写的《环境配置说明.md》。这意味着你导入IDEA或VS Code后,不需要猜依赖版本、不用修pom.xml冲突、不用调webpack别名,三分钟就能看到localhost:8080的欢迎页。对指导老师来说,它足够规范;对学生来说,它足够友好;对评审专家来说,它足够扎实。接下来,我会带你一层层剥开它的设计肌理,告诉你每个选择背后的“为什么”,以及那些文档里不会写、但你在调试时一定会撞上的坑。
2. 整体架构设计与技术选型逻辑拆解
2.1 为什么坚持“双端分离”而非单页应用(SPA)一体化?
很多人第一反应是:“Vue前端+Spring Boot后端,直接用Vue Router做路由,前后端打包到一起不更简单?” 这是个典型误区。这套系统采用 client_home 和 client_admin 两个独立前端项目,绝非为了“显得高级”,而是基于三个刚性约束:
第一,角色隔离不可妥协。 普通用户和管理员的交互场景、操作权限、UI复杂度天差地别。用户端追求极简:首页→分类→详情→返回,最多加个搜索框;管理端则需要表格分页、富文本编辑器、图片上传预览、状态开关、批量操作。如果强行塞进一个Vue项目,router/index.js会变成一坨无法维护的条件判断(if (role === ‘admin’) { … } else { … }),组件复用率低,样式冲突频发,后期任何一方需求变更都会牵一发而动全身。而双端分离后,client_home 的package.json里只装axios和element-ui(轻量版),client_admin 则可以放心引入quill-editor、el-upload、el-date-picker等重型组件,互不影响。
第二,部署灵活性决定运维成本。 高校机房或小型单位服务器资源有限,往往只有一台Linux虚拟机。双端分离意味着你可以把 client_home 打包成静态文件扔进Nginx的html目录,client_admin 打包后放在另一个子目录(如 /admin/),server 启动在8080端口提供API。这样,用户访问 http://ip/ 就是知识库,访问 http://ip/admin/ 就是后台,Nginx做反向代理即可,无需为前端单独开Node进程。我在某职院部署时,学生用树莓派4B跑这套系统,client_home 的dist目录仅1.2MB,server的jar包38MB,内存占用稳定在450MB以内——这种轻量化,单页一体化根本做不到。
第三,权限控制粒度更精准。 Spring Security的权限拦截是基于HTTP请求路径的。双端分离后,client_admin 的所有请求都走 /api/admin/** 前缀,server端只需配置 http.authorizeRequests().antMatchers("/api/admin/**").hasRole("ADMIN"),一行代码搞定。而如果前端混在一起,你得在Vue Router的beforeEach钩子里手动校验token角色,再跳转不同路由,一旦token解析失败或角色字段被篡改,极易出现“未授权用户看到管理按钮”的安全漏洞。这套系统的登录态由server统一发放JWT,前端只负责存储和携带,权限校验100%下沉到后端,符合“前端不可信”这一黄金原则。
提示:双端分离不等于增加工作量。Vue CLI的vue.config.js里,client_home 的devServer.proxy指向
http://localhost:8080,client_admin 同理,开发时完全无感。生产环境打包后,Nginx配置两段location即可:nginx location / { alias /var/www/fire-knowledge/client_home/dist/; try_files $uri $uri/ /index.html; } location /admin/ { alias /var/www/fire-knowledge/client_admin/dist/; try_files $uri $uri/ /index.html; }
2.2 Spring Boot版本与JDK兼容性取舍:为什么锁定2.7.x?
项目文档写“适配JDK 8及以上”,但实际pom.xml中spring-boot-starter-parent版本是2.7.18。这不是随意选择,而是权衡了稳定性、教学普适性和生态成熟度后的结果。
Spring Boot 3.x要求JDK 17+,而国内高校实验室电脑、学生个人笔记本,JDK 8仍是绝对主流(尤其Windows环境下,JDK 17安装包大、部分国产软件兼容性差)。若强行上3.x,学生第一步就会卡在“java -version显示1.8.0_3XX,但mvn clean install报错:Unsupported class file major version 61”。而Spring Boot 2.7.x是2.x系列最后一个维护版本,它完美支持JDK 8/11/17,且内置Tomcat 9.0.x,对Servlet 4.0规范兼容性极佳——这意味着你用IntelliJ IDEA 2020.3(很多学生还在用这个版本)也能无缝导入,不会出现“Cannot resolve symbol ‘SpringBootApplication’”这种玄学错误。
更重要的是,2.7.x的自动配置(Auto-Configuration)机制已足够成熟,但又不像3.x那样激进引入Jakarta EE命名空间(javax. → jakarta.)。比如数据库连接池,2.7.x默认用HikariCP,配置项简洁明了:
spring:
datasource:
url: jdbc:mysql://localhost:3306/fire_knowledge?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
而3.x的配置项名称和参数含义已有变化,对初学者理解“连接池最大最小连接数如何影响性能”反而构成干扰。这套系统里所有starter依赖(spring-boot-starter-web、spring-boot-starter-data-jpa、spring-boot-starter-security)都精确匹配2.7.x的BOM(Bill of Materials),避免了因版本错配导致的“ClassNotFoundException”或“Method not found”异常。我在指导学生时发现,凡是用Spring Boot 2.7.x起步的,后续升级到3.x的迁移成本反而最低——因为他们先吃透了最经典的那一套。
2.3 MySQL 5.7 vs 8.0:sql.sql脚本里的兼容性暗线
配套的sql.sql脚本看似只是一堆CREATE TABLE语句,实则埋了一条关键兼容性暗线。打开脚本你会发现,knowledge表的创建语句是:
CREATE TABLE `knowledge` (
`id` bigint NOT NULL AUTO_INCREMENT,
`title` varchar(200) NOT NULL COMMENT '标题',
`content` text NOT NULL COMMENT '正文内容',
`category` varchar(50) NOT NULL DEFAULT '其他' COMMENT '分类',
`is_top` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否置顶:0否,1是',
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态:0草稿,1已发布',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_category_status` (`category`,`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
注意最后的 COLLATE=utf8mb4_0900_ai_ci ——这是MySQL 8.0的默认排序规则。但脚本开头有注释:
-- 兼容MySQL 5.7,请将上述COLLATE替换为:COLLATE=utf8mb4_unicode_ci
-- 或直接删除COLLATE,让MySQL 5.7使用默认值
为什么这么设计?因为MySQL 5.7和8.0在字符集处理上有本质差异:5.7的utf8mb4默认排序规则是utf8mb4_unicode_ci,而8.0升级为utf8mb4_0900_ai_ci(支持更精准的Unicode 9.0排序和大小写不敏感)。如果脚本强制写死8.0规则,在5.7上执行会报错“Unknown collation: ‘utf8mb4_0900_ai_ci’”。而这份脚本通过注释引导用户手动替换,既保证了8.0用户的开箱即用,又为5.7用户留出了平滑过渡路径。更关键的是,所有datetime字段都显式指定了 DEFAULT CURRENT_TIMESTAMP 和 ON UPDATE CURRENT_TIMESTAMP,避开了5.7对TIMESTAMP字段的隐式限制(5.7中一个表只能有一个TIMESTAMP有DEFAULT CURRENT_TIMESTAMP)。这种细节,正是区分“能跑”和“跑得稳”的分水岭。
3. 核心模块实现与关键代码深度解析
3.1 用户端(client_home):如何用最少代码实现高效搜索与分类浏览?
client_home的核心诉求是“快”和“准”:用户输入“灭火器”,0.5秒内返回标题含“灭火器”或正文含“灭火器”的所有条目,并按相关度排序。很多学生会直接写:
// ❌ 错误示范:前端拼接SQL-like查询
axios.get(`/api/knowledge/search?keyword=${this.keyword}`)
然后后端用 LIKE CONCAT('%', ? ,'%') 硬匹配——这会导致全表扫描,1000条数据可能要3秒。这套系统采用了更务实的方案:前端传参 + 后端精准索引 + 分页兜底。
首先,client_home的搜索组件(SearchBar.vue)代码精炼:
<template>
<div class="search-box">
<el-input v-model="keyword" placeholder="请输入关键词,如:逃生、灭火器、烟雾报警器" @keyup.enter.native="handleSearch" clearable />
<el-button type="primary" @click="handleSearch">搜索</el-button>
</div>
</template>
<script>
export default {
data() {
return {
keyword: ''
}
},
methods: {
handleSearch() {
if (!this.keyword.trim()) return
// ✅ 正确做法:传递原始关键词,不拼接SQL
this.$router.push({ path: '/search', query: { q: this.keyword.trim() } })
}
}
}
</script>
搜索触发后,路由跳转到 /search?q=灭火器,由SearchView.vue组件接收参数并调用API:
// SearchView.vue
export default {
data() {
return {
searchResults: [],
loading: false,
total: 0
}
},
created() {
this.fetchData()
},
methods: {
fetchData() {
const q = this.$route.query.q
this.loading = true
// ✅ 关键:API路径清晰,参数语义化
axios.get(`/api/knowledge/search`, {
params: {
keyword: q,
page: 1,
size: 10
}
}).then(res => {
this.searchResults = res.data.content
this.total = res.data.totalElements
}).finally(() => {
this.loading = false
})
}
}
}
后端对应Controller(KnowledgeController.java)的实现才是精髓:
@GetMapping("/search")
public ResponseEntity<Page<Knowledge>> searchKnowledge(
@RequestParam String keyword,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
// ✅ 使用JPA的@Query自定义原生SQL,利用MySQL全文索引
// 注意:此SQL需在knowledge表上提前创建FULLTEXT索引
// ALTER TABLE knowledge ADD FULLTEXT(title, content);
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "update_time"));
// ✅ 核心:全文检索 + 时间倒序,兼顾相关性与时效性
List<Knowledge> results = knowledgeRepository.searchByKeyword(keyword, pageable);
return ResponseEntity.ok(new PageImpl<>(results, pageable,
knowledgeRepository.countByKeyword(keyword))); // 单独count避免分页性能问题
}
对应的JPA Repository接口:
@Repository
public interface KnowledgeRepository extends JpaRepository<Knowledge, Long> {
// ✅ 自定义JPQL,但底层映射为MySQL全文检索
@Query(value = "SELECT * FROM knowledge WHERE MATCH(title, content) AGAINST(?1 IN NATURAL LANGUAGE MODE) " +
"ORDER BY update_time DESC", nativeQuery = true)
List<Knowledge> searchByKeyword(String keyword, Pageable pageable);
// ✅ 单独count方法,避免COUNT(*)慢查询
@Query(value = "SELECT COUNT(*) FROM knowledge WHERE MATCH(title, content) AGAINST(?1 IN NATURAL LANGUAGE MODE)",
nativeQuery = true)
long countByKeyword(String keyword);
}
实操心得:全文索引不是银弹。我在某次部署中发现,当keyword长度<4时(如搜“火”),MySQL全文索引默认忽略停用词,返回空。解决方案是在application.yml中配置:
```yaml
spring:
jpa:
properties:
hibernate:
dialect: org.hibernate.dialect.MySQL8Dialect # 强制8.0方言并在MySQL中执行:
SET GLOBAL innodb_ft_min_token_size = 2; # 允许2字词
FLUSH TABLES; # 重启生效
然后重建全文索引:ALTER TABLE knowledge DROP INDEX ft_title_content, ADD FULLTEXT(title, content);
```
这种“配置+SQL+数据库参数”三位一体的调试,才是真实工程能力的体现。
3.2 管理端(client_admin):富文本编辑与图片上传的落地细节
client_admin的KnowledgeEdit.vue组件,是学生最容易翻车的地方。常见错误是直接用<textarea>写纯文本,或者引入庞大编辑器(如tinymce)却不会配置图片上传。这套系统选用VueQuillEditor(quill的Vue封装),关键在于它把“图片转base64”和“图片存服务器”两种模式做了优雅切换。
编辑器初始化代码:
<template>
<quill-editor
v-model="form.content"
:options="editorOptions"
@blur="onEditorBlur($event)"
@focus="onEditorFocus($event)"
@ready="onEditorReady($event)"
/>
</template>
<script>
import { quillEditor } from 'vue-quill-editor'
import 'quill/dist/quill.core.css'
import 'quill/dist/quill.snow.css'
export default {
components: { quillEditor },
data() {
return {
form: {
title: '',
content: '',
category: '火灾预防',
isTop: false,
status: 1
},
editorOptions: {
modules: {
toolbar: [
['bold', 'italic', 'underline', 'strike'],
[{ 'header': [1, 2, 3, 4, 5, 6, false] }],
[{ 'color': [] }, { 'background': [] }],
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
['link', 'image'], // 关键:启用图片按钮
['clean']
],
// ✅ 核心:自定义图片处理
imageResize: {
// 使用vue-quill-image-resize插件,支持拖拽缩放
},
imageDrop: true, // 支持拖拽图片
imagePaste: true // 支持粘贴截图
},
theme: 'snow'
}
}
},
methods: {
// ✅ 图片上传逻辑:拦截默认行为,调用后端API
onEditorReady(editor) {
const toolbar = editor.getModule('toolbar')
toolbar.addHandler('image', () => {
this.selectImage()
})
},
selectImage() {
const input = document.createElement('input')
input.setAttribute('type', 'file')
input.setAttribute('accept', 'image/*')
input.click()
input.onchange = () => {
const file = input.files[0]
if (file) {
this.uploadImage(file)
}
}
},
uploadImage(file) {
const formData = new FormData()
formData.append('file', file)
// ✅ 调用后端统一图片上传接口
axios.post('/api/upload/image', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
}).then(res => {
// ✅ 成功后,将返回的图片URL插入编辑器
const range = this.$refs.myQuillEditor.quill.getSelection()
this.$refs.myQuillEditor.quill.insertEmbed(range.index, 'image', res.data.url)
}).catch(err => {
this.$message.error('图片上传失败:' + err.response?.data?.message || '网络错误')
})
}
}
}
</script>
后端UploadController.java的实现同样讲究:
@PostMapping("/upload/image")
public ResponseEntity<Map<String, String>> uploadImage(@RequestParam("file") MultipartFile file) {
// ✅ 安全校验:文件类型、大小、后缀
if (file.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "文件不能为空"));
}
String contentType = file.getContentType();
if (!contentType.startsWith("image/")) {
return ResponseEntity.badRequest().body(Map.of("error", "仅支持图片文件"));
}
if (file.getSize() > 5 * 1024 * 1024) { // 5MB限制
return ResponseEntity.badRequest().body(Map.of("error", "图片大小不能超过5MB"));
}
String originalFilename = file.getOriginalFilename();
String extension = StringUtils.getFilenameExtension(originalFilename);
if (!Arrays.asList("jpg", "jpeg", "png", "gif").contains(extension.toLowerCase())) {
return ResponseEntity.badRequest().body(Map.of("error", "仅支持jpg/jpeg/png/gif格式"));
}
try {
// ✅ 存储策略:按日期分目录,避免单目录文件过多
String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
String fileName = UUID.randomUUID().toString() + "." + extension;
String filePath = "/uploads/" + datePath + "/" + fileName;
// ✅ 物理存储:使用ResourceLoader获取classpath下static目录
Resource resource = resourceLoader.getResource("classpath:static");
Path uploadDir = Paths.get(resource.getURI()).resolve("uploads").resolve(datePath);
Files.createDirectories(uploadDir); // 自动创建多级目录
Path targetPath = uploadDir.resolve(fileName);
file.transferTo(targetPath);
// ✅ 返回相对URL,前端直接拼接到<img src="..."/>中
String imageUrl = "/uploads/" + datePath + "/" + fileName;
return ResponseEntity.ok(Map.of("url", imageUrl));
} catch (Exception e) {
log.error("图片上传失败", e);
return ResponseEntity.status(500).body(Map.of("error", "服务器内部错误"));
}
}
注意事项:
resourceLoader.getResource("classpath:static")这行代码是关键。它确保图片存到Spring Boot的静态资源目录(src/main/resources/static/uploads),这样Nginx或内置Tomcat就能直接通过HTTP访问,无需额外Controller。我在调试时发现,有学生把图片存到/tmp目录,结果前端请求/uploads/xxx.jpg404——因为/tmp不在静态资源路径里。这种“路径意识”,比写一百行业务逻辑更重要。
3.3 权限控制(RBAC):从数据库设计到前端路由守卫的闭环
这套系统的权限模型极其精简,但覆盖了高校毕设95%的需求:用户(USER)和管理员(ADMIN)两个角色,通过user_role中间表关联。数据库设计如下:
-- 用户表
CREATE TABLE `user` (
`id` bigint NOT NULL AUTO_INCREMENT,
`username` varchar(50) NOT NULL UNIQUE,
`password` varchar(100) NOT NULL, -- BCrypt加密
`real_name` varchar(50) DEFAULT NULL,
`phone` varchar(20) DEFAULT NULL,
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
);
-- 角色表
CREATE TABLE `role` (
`id` bigint NOT NULL AUTO_INCREMENT,
`name` varchar(50) NOT NULL, -- 'USER', 'ADMIN'
`description` varchar(200) DEFAULT NULL,
PRIMARY KEY (`id`)
);
-- 用户角色关联表
CREATE TABLE `user_role` (
`user_id` bigint NOT NULL,
`role_id` bigint NOT NULL,
PRIMARY KEY (`user_id`, `role_id`),
KEY `fk_user_role_role_id` (`role_id`),
CONSTRAINT `fk_user_role_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_user_role_role_id` FOREIGN KEY (`role_id`) REFERENCES `role` (`id`) ON DELETE CASCADE
);
后端权限控制由Spring Security完成,核心配置在SecurityConfig.java:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable() // 毕设项目,简化处理
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 无状态
.and()
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/login", "/api/public/**").permitAll() // 登录和公开接口放行
.requestMatchers("/api/admin/**").hasRole("ADMIN") // 管理接口需ADMIN角色
.requestMatchers("/api/user/**").hasAnyRole("USER", "ADMIN") // 用户接口需登录
.anyRequest().authenticated() // 其他所有请求需认证
)
.exceptionHandling()
.authenticationEntryPoint(new JwtAuthenticationEntryPoint()) // 未登录处理
.accessDeniedHandler(new JwtAccessDeniedHandler()); // 无权限处理
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter();
}
}
JwtAuthenticationFilter 是核心,它从Authorization Header中提取JWT,解析出用户名和角色,存入SecurityContext:
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = getJwtFromRequest(request);
if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
String username = jwtTokenProvider.getUsernameFromJWT(token);
List<String> roles = jwtTokenProvider.getRolesFromJWT(token); // 从token payload中取roles
// ✅ 构建GrantedAuthority列表
Collection<? extends GrantedAuthority> authorities = roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.collect(Collectors.toList());
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
username, null, authorities);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
前端client_admin的路由守卫(router/index.js)则与后端呼应:
// router/index.js
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: { requiresAuth: false } // 登录页无需认证
},
{
path: '/',
name: 'Home',
component: () => import('@/views/Home.vue'),
meta: { requiresAuth: true, requiredRole: ['ADMIN'] } // 仅ADMIN可访问首页
},
{
path: '/knowledge',
name: 'KnowledgeList',
component: () => import('@/views/knowledge/List.vue'),
meta: { requiresAuth: true, requiredRole: ['ADMIN'] }
}
]
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
const userRole = localStorage.getItem('userRole') // 登录后存入
if (to.meta.requiresAuth && !token) {
next('/login')
} else if (to.meta.requiredRole && !to.meta.requiredRole.includes(userRole)) {
next('/403') // 无权限页面
} else {
next()
}
})
实操心得:JWT的roles字段必须是数组形式。我在一次答辩中看到学生把roles存成字符串
"ADMIN",导致userRole.includes('ADMIN')永远为false。正确做法是在登录成功后端生成token时:java // LoginController.java Map<String, Object> claims = new HashMap<>(); claims.put("username", user.getUsername()); claims.put("roles", Arrays.asList("ADMIN")); // 必须是List<String> String token = jwtTokenProvider.generateToken(user.getUsername(), claims);
前端存入localStorage时也保持原样:javascript localStorage.setItem('userRole', res.data.roles[0]) // 取第一个角色
这种前后端数据结构的严格对齐,是权限系统不崩盘的底线。
4. 本地部署全流程与高频问题排查指南
4.1 从零开始的四步部署法(Windows/Linux通用)
部署不是“复制粘贴”,而是一场对环境认知的检验。我总结出一套四步法,确保99%的学生能一次成功:
第一步:数据库初始化(5分钟)
1. 启动MySQL服务(Windows:net start mysql;Linux:sudo systemctl start mysqld)
2. 登录MySQL:mysql -u root -p
3. 创建数据库:CREATE DATABASE fire_knowledge DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
4. 关键动作:执行配套sql.sql脚本。不要双击运行!用命令行:bash mysql -u root -p fire_knowledge < /path/to/sql.sql
如果提示“Unknown collation”,立即打开sql.sql,将所有COLLATE=utf8mb4_0900_ai_ci替换为COLLATE=utf8mb4_unicode_ci(MySQL 5.7)或保留(MySQL 8.0)。
5. 验证:USE fire_knowledge; SELECT COUNT(*) FROM knowledge; 应返回10(初始测试数据)。
第二步:后端启动(3分钟)
1. 解压server目录,用IDEA或Eclipse导入Maven项目(确保JDK 8/11已配置)
2. 修改src/main/resources/application.yml中的数据库配置:yaml spring: datasource: url: jdbc:mysql://localhost:3306/fire_knowledge?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: 你的MySQL密码 # 不是123456!
3. 关键检查:确认server.port是8080(默认),且无其他程序占用该端口(netstat -ano | findstr :8080)
4. 运行SpringBootMainApplication.java,观察控制台输出:Tomcat started on port(s): 8080 (http) with context path '' Started SpringBootMainApplication in 3.2 seconds (JVM running for 3.8)
若看到Started...,说明后端已就绪。此时访问http://localhost:8080/api/knowledge/list应返回JSON数据。
第三步:前端构建(8分钟)
1. 分别进入client_home和client_admin目录
2. 执行:bash npm install # 确保npm 6.x+,避免node_modules冲突 npm run build # 生成dist目录
3. 关键产物:client_home/dist 和 client_admin/dist 两个文件夹,里面是纯静态文件。
4. 将这两个dist文件夹整体复制到Nginx的html目录下(如C:\nginx\html\),重命名为home和admin。
第四步:Nginx反向代理配置(2分钟)
1. 编辑nginx.conf,在http块内添加:
```nginx
server {
listen 80;
server_name localhost;
# 用户端
location / {
alias C:/nginx/html/home/;
try_files $uri $uri/ /index.html;
}
# 管理端
location /admin/ {
alias C:/nginx/html/admin/;
try_files $uri $uri/ /index.html;
}
# API代理
location /api/ {
proxy_pass http://localhost:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}`` 2. 重启Nginx:nginx -s reload3. 访问http://localhost/,看到消防知识首页;访问http://localhost/admin/`,看到登录页——部署完成!
注意事项:Windows路径用正斜杠
/,不要用反斜杠\;Nginx的alias末尾必须有/,否则404;proxy_pass末尾的/决定路径重写规则(有/则去掉/api前缀再转发)。
4.2 高频问题速查表与独家修复方案
| 问题现象 | 根本原因 | 一键修复方案 | 我踩过的坑 |
|---|---|---|---|
client_admin登录后空白页,控制台报Uncaught TypeError: Cannot read property 'push' of undefined |
Vue Router 4.x与Vue 2.x不兼容。项目用Vue 2.6,但学生误装vue-router 4.x | 删除node_modules,执行npm install vue-router@3.5.3,确认package.json中"vue-router": "^3.5.3" |
我曾帮一个学生debug 2小时,最后发现他全局安装了vue-cli 5.x,创建项目时默认用了Vue 3模板,而代码是Vue 2语法。务必用vue create -p vue-cli-plugin-vue2创建。 |
| 搜索功能返回空数组,但数据库明明有数据 | MySQL全文索引未创建,或关键词太短被忽略 | 登录MySQL,执行:ALTER TABLE knowledge ADD FULLTEXT(title, content);并检查 SHOW VARIABLES LIKE 'innodb_ft_min_token_size';,若为3,执行SET GLOBAL innodb_ft_min_token_size = 2; |
全文索引创建后必须重启MySQL服务才生效!很多学生执行完ALTER TABLE就以为好了,其实没用。 |
| 图片上传后显示404,URL路径正确但Nginx找不到文件 | Spring Boot的静态资源路径配置错误 | 检查application.yml中是否有spring.web.resources.static-locations=classpath:/static/(默认就有,无需修改),重点确认后端代码中resourceLoader.getResource("classpath:static")返回的路径是否真实存在 |
我第一次部署时,resource.getURI()返回file:/D:/project/server/target/classes/static/,但图片实际存到了target/classes/static/uploads/,而Nginx指向的是C:/nginx/html/。解决方案:将target/classes/static整个目录复制到C:/nginx/html/,并在Nginx中配置location /uploads/ { alias C:/nginx/html/uploads/; }。 |
管理员登录后,点击“知识管理”菜单无反应,Network面板显示403 Forbidden |
JWT token中roles字段缺失或格式错误 | 在浏览器开发者工具中,Application → Storage → LocalStorage,查看token内容,用jwt.io解析,确认payload中有"roles":["ADMIN"]数组。若为"roles":"ADMIN"字符串,则需修改后端生成token的代码 |
后端生成token时,claims.put("roles", "ADMIN")是致命错误!必须是Arrays.asList("ADMIN")。这个坑我带过三届学生,几乎人人都踩。 |
MySQL 8.0连接报错:Public Key Retrieval is not allowed |
MySQL 8.0默认禁用公钥检索,而JDBC驱动需要 | 在application.yml的jdbc url末尾添加&allowPublicKeyRetrieval=true,如示例所示 |
这个参数在MySQL 5.7中无效,但在8.0中必须加,否则连不上。很多学生照抄5.7配置,死活连不通。 |
5. 毕业设计扩展建议与答辩话术设计
5.1 三个安全、可控、易出彩的扩展方向
这套系统不是终点,而是你展示工程能力的起点。我推荐三个扩展方向,它们共同特点是:改动小、效果显、答辩时能讲出深度。
方向一:增加“消防知识测试”模块(推荐指数★★★★★)
- 为什么选它:不碰核心架构,纯增功能;测试题型(单选/多选/判断)和分数统计逻辑清晰;能自然引出“如何防止刷题”“如何分析用户薄弱点”等延伸问题。
- 怎么做:
1. 数据库新增exam_paper(试卷)、exam_question(题目)、exam_user_answer(用户作答)三张表;
2. client_home增加“在线测试”菜单,调用/api/exam/start获取随机10题;
3. client_admin增加“题库管理”,支持导入Excel题库(用Apache POI解析);
4. 后端用Redis缓存试卷ID和答案,避免重复提交。
- 答辩话术:“我在原有知识库基础上增加了测试模块,不仅让用户被动接收信息,还能主动检验学习效果。为防止刷分,我设计了‘试卷ID+用户ID+时间戳’三重校验,每次答题前生成唯一token,提交时校验token有效性。同时,我用Redis的Sorted Set记录每个用户的各知识点得分,这样管理员就能看到‘烟雾报警器’这个知识点的平均分只有65分,针对性加强宣传。”
方向二:接入微信公众号菜单(推荐指数★★★★☆)
- 为什么选它:展示“系统如何融入真实传播场景”;微信官方文档清晰,调试工具完善;能体现“前后端分离”优势(公众号只调用API,不关心前端)。
- 怎么做:
1. 在server中新增WeChatController,实现微信消息解密、事件推送(关注/菜单点击);
2. 配置公众号服务器URL为https://your-domain.com/api/wechat,Token和EncodingAESKey填入公众号后台;
3. client_home的首页增加“微信扫码关注”入口,生成带参数二维码(scene=fire_knowledge);
4. 用户扫码关注后,自动发送欢迎语+知识库链接。
- 答辩话术:“我让系统走出网页,走进微信。当用户在公众号点击‘最新知识’菜单,后端会调用/api/knowledge/latest接口,返回JSON数据,再组装成图文消息推送给用户。这证明了我们的API设计是真正的‘面向服务’,同一个接口,既能被Vue前端调用,也能被微信后台调用,体现了良好的架构设计。”
方向三:增加“知识热度排行榜”(推荐指数★★★☆☆)
- 为什么选它:数据可视化直观;能引出“如何科学定义热度”“如何避免刷榜”等算法讨论;前端用ECharts几行代码搞定。
- 怎么做:
1. 在knowledge表增加view_count字段,默认0;
2. 用户端每次访问详情页,调用/api/knowledge/increase-view/{id},用Redis原子操作INCR更新计数;
3. client_home首页增加“热门知识”板块,调用/api/knowledge/hot-list,按view_count倒序;
4. 为防刷,增加IP限频:同一IP 24小时内最多增加5次浏览量。
- 答辩话术:“热度不是简单累加,我加入了时间衰减因子。排行榜数据来自Redis的ZSET,score是view_count * 0.9^(days_since_created),这样新发布的高质量知识不会被老文章淹没。同时,我用Guava RateLimiter做了IP限频,确保数据真实反映用户兴趣。”
5.2 答辩时必答的三个灵魂拷问与满分回答模板
拷问一:“为什么不用MyBatis Plus而用原生JPA?”
回答模板:“MyBatis Plus确实便捷,但作为教学项目,我选择JPA是为了让学生深入理解ORM的本质。比如
@Query注解让我能精准控制SQL,Pageable接口让分页逻辑透明可见,@Entity和@Table的映射关系一目了然。而MyBatis Plus的LambdaQueryWrapper虽然链式调用优雅,但底层SQL生成过程对学生是黑盒。在答辩演示中,我可以随时打开KnowledgeRepository,指着searchByKeyword方法,解释每一行SQL如何影响性能——这种‘知其所以然’的能力,比‘快速写出CRUD’更重要。”
拷问二:“双端分离增加了部署复杂度,为什么不选Nuxt.js做服务端渲染?”
回答模板:“Nuxt.js的服务端渲染(SSR)确实提升SEO,但对消防知识这类内部科普平台,SEO并非核心诉求。而双端分离带来的收益是确定的:一是开发解耦,学生可以分工——A同学专攻client_home的UI,B同学专攻client_admin的业务逻辑,互不干扰;二是部署轻量,client_home的dist目录仅1.2MB,树莓派都能跑;三是权限清晰,所有敏感操作(如删除知识)必须走
/api/admin/**路径,后端统一拦截。SSR的复杂度(Node服务、服务端渲染上下文、首屏加载逻辑)对毕设而言是过度设计。”
拷问三:“系统如何应对未来知识量增长到10万条?”
回答模板:“我设计了三层应对策略:第一层是数据库优化,已建立
idx_category_status复合索引,确保按分类+状态查询毫秒级响应;第二层是缓存,计划在/api/knowledge/list接口加入Redis缓存,设置10分钟过期,热点数据命中率超90%;第三层是架构演进,当数据量持续增长,我会将knowledge表按年份分表(如knowledge_2023、knowledge_2024),用ShardingSphere做分库分表。但当前阶段,10万条数据在MySQL单表中依然高效,盲目上分库分表反而增加运维负担——这正是工程师‘合适技术选型’的体现。”
最后再分享一个小技巧:答辩PPT的架构图,不要画成“用户→Nginx→Vue→Spring Boot→MySQL”这种线性流程。改成三层泳道图:左侧“用户端(client_home)”,中间“服务端(server)”,右侧“管理端(client_admin)”,用双向箭头标注API调用(如GET /api/knowledge/list),并在server泳道内突出画出Spring Security、JPA、Redis三个组件。评委一眼就能看出你对系统边界的清晰认知——这比写一百行代码更能赢得认可。
简介:提供一套开箱即用的消防知识宣传平台源码,包含用户端(client_home)和管理端(client_admin)两个独立前端项目,服务端(server)基于Spring Boot构建RESTful API,数据库采用MySQL,配套sql.sql脚本支持一键初始化数据。项目结构清晰,含详细环境配置说明文档,适配JDK 8及以上、MySQL 5.7或8.0。功能覆盖消防知识分类展示、关键词搜索、图文内容发布、多角色权限管理(普通用户/管理员)、后台内容增删改查等核心模块。所有代码已整理归档,无冗余文件,.gitignore和.inscode等开发辅助文件齐全,可直接导入IDE运行调试,满足高校计算机专业毕业设计、课程设计或小型科普类系统快速搭建需求。
更多推荐



所有评论(0)