简介:本资源是一套完整的Java毕业设计项目——基于SSM框架(Spring+SpringMVC+MyBatis)与Vue前后端分离架构的学习交流论坛系统,面向计算机专业本科生及Java初学者,解决课程设计、毕设选题与全栈开发实践需求。压缩包为ZIP格式,大小20.79MB,包含可直接运行的源码工程、MySQL5.7数据库脚本、开题报告与毕业论文文档等核心内容,涵盖后端Maven工程结构、前端静态资源目录、SQL建表语句及完整业务模块代码。已有63人学习下载,适合需要快速掌握B/S系统开发流程、理解管理员与用户双角色权限设计、实践博客管理+论坛交互+公告轮播等典型功能的学生开发者。资源提供开箱即用的完整解决方案,含登录鉴权、文章分类、留言反馈、收藏评论、个人中心等模块源码,以及清晰的分层结构与注释,便于二次开发与技术复现。
1. 这不是又一个“SSM毕设模板”:它真能跑通前后台分离的论坛交互闭环,且 Vue + SSM 的请求链路不丢 session、不跨域报错、不卡在登录态校验——适合想交差但更想搞懂「为什么毕业设计总卡在登录跳转」的同学
你肯定见过太多标着“SSM论坛系统”的压缩包:解压后pom.xml里 Spring 版本写的是 4.3.20,web.xml还在配DispatcherServlet映射路径,前端index.jsp里混着<c:forEach>和jQuery.ajax,一启动就报No mapping found for HTTP request with URI [/login]。这不是代码问题,是架构断层——后端用 MVC 模式,前端却硬塞 Vue 实例,路由、状态、鉴权全靠 JS 拼凑,结果登录成功后跳首页,Vue 路由守卫拿不到 token,后台接口返回 401,学生只能截图发群里问:“老师,为啥我点登录没反应?”
这个项目不一样。它把 B/S 架构真正拆成两层:SpringMVC 只做 RESTful 接口(/api/user/login,/api/article/list),Vue CLI 项目独立运行(npm run serve),通过axios.defaults.baseURL = 'http://localhost:8080'统一代理,关键是在LoginController里用HttpSession存用户 ID + 角色,再配合@CrossOrigin注解和WebMvcConfigurer配置CorsRegistry,让前端跨域请求带credentials: true时,后端能正确读取JSESSIONIDCookie 并校验登录态。数据库用 MySQL 5.7,所有表字段加了NOT NULL和COMMENT,连article_collect收藏表都建了联合唯一索引(user_id, article_id),防止重复收藏。它不是教你“怎么写增删改查”,而是告诉你:毕业设计里最常翻车的不是功能缺,而是请求链路断在哪一环——是 Cookie 传不过去?是 Session ID 没同步?还是 Vue 路由守卫没等接口返回就跳了?如果你正卡在“登录成功但进不了首页”“管理员后台点不了按钮”“收藏文章后刷新就没了”,这篇笔记就是为你写的。
2. 把 SSM 后端从“能编译”变成“能扛住并发请求”:SpringMVC 接口设计、MyBatis 动态 SQL 与事务边界实操
2.1 SpringMVC 接口分层设计:为什么/api/admin/和/api/user/必须物理隔离?
项目里所有接口路径强制以/api/开头,这是为后续可能的 Nginx 反向代理或微服务拆分留的余地。更关键的是,管理员接口和用户接口做了物理路径隔离:
// AdminController.java @RestController @RequestMapping("/api/admin") public class AdminController { @Autowired private ArticleService articleService; @GetMapping("/articles") public Result<List<Article>> listArticles(@RequestParam Integer page, @RequestParam Integer limit) { PageHelper.startPage(page, limit); List<Article> articles = articleService.selectAll(); return Result.success(articles); } }// UserController.java @RestController @RequestMapping("/api/user") public class UserController { @Autowired private UserService userService; @PostMapping("/register") public Result<String> register(@RequestBody User user) { // 密码加密逻辑在此 userService.insert(user); return Result.success("注册成功"); } }提示:
@RequestMapping("/api/admin")不是装饰,是契约。它让前端 Axios 实例能按角色创建不同 base URL:// api/admin.js const adminApi = axios.create({ baseURL: '/api/admin' }); // api/user.js const userApi = axios.create({ baseURL: '/api/user' });这样,当管理员点击“文章管理”时调
adminApi.get('/articles'),用户点击“注册”时调userApi.post('/register'),路径天然隔离,避免权限混淆。如果全写成/api/articles,后期加 RBAC 权限控制时,你得在每个 Controller 方法上加@PreAuthorize("hasRole('ADMIN')"),而路径隔离后,Nginx 层就能直接拦截/api/admin/**的非管理员请求。
2.2 MyBatis 动态 SQL 实战:论坛帖子搜索、分页、多条件组合查询怎么写才不慢?
论坛交流模块的帖子列表支持按标题关键词、发布时间范围、分类 ID 三重筛选。如果用WHERE 1=1 AND title LIKE '%xxx%' AND category_id = ?硬拼,SQL 会丢失索引。项目用 MyBatis 的<if>标签动态拼接:
<!-- ArticleMapper.xml --> <select id="selectByCondition" resultType="Article"> SELECT * FROM forum_post <where> <if test="title != null and title != ''"> AND title LIKE CONCAT('%', #{title}, '%') </if> <if test="categoryId != null and categoryId != 0"> AND category_id = #{categoryId} </if> <if test="startTime != null"> AND create_time >= #{startTime} </if> <if test="endTime != null"> AND create_time <= #{endTime} </if> </where> ORDER BY create_time DESC </select>关键点有三个:
<where>标签自动处理AND开头问题:它会智能去掉第一个AND,避免WHERE AND title LIKE ...的语法错误;CONCAT('%', #{title}, '%')防止 SQL 注入:#{}是预编译参数,比${}安全;ORDER BY create_time DESC必须加索引:MySQL 5.7 中,对create_time字段建INDEX idx_create_time (create_time),否则LIMIT 10,20分页会全表扫描。
参数说明:
startTime和endTime是String类型(如"2024-01-01"),MyBatis 会自动转换为Date;categoryId为Integer,0表示“全部分类”,所以判断!= 0而非!= null。
2.3 Service 层事务边界:为什么“发布帖子+更新分类统计数”必须在一个@Transactional里?
用户发布一篇新帖子时,系统要同时做两件事:
- 插入
forum_post表一条记录; - 更新
forum_category表中对应分类的post_count字段(post_count = post_count + 1)。
如果这两步分开写,中间出错(比如插入成功但更新失败),就会导致帖子存在但分类计数不准。项目在PostService中用@Transactional包裹:
@Service public class PostService { @Autowired private ForumPostMapper postMapper; @Autowired private ForumCategoryMapper categoryMapper; @Transactional(rollbackFor = Exception.class) public void publishPost(ForumPost post) { postMapper.insert(post); // 步骤1:插入帖子 categoryMapper.updatePostCount(post.getCategoryId()); // 步骤2:更新分类计数 } }rollbackFor = Exception.class是重点:默认@Transactional只对RuntimeException回滚,而updatePostCount()可能抛SQLException(检查异常),不加这句,SQL 异常发生时事务不会回滚,造成数据不一致。
血泪经验:很多同学把
@Transactional加在 Controller 层,这是大忌。Controller 层可能调用多个 Service,事务边界过宽会导致锁表时间过长。必须精确到“一个业务原子操作”,比如“发帖”是一个原子,“收藏文章”是另一个原子,各自独立事务。
3. Vue 前端不是“套个模板”:路由守卫、Token 管理、Axios 请求拦截器的真实落地
3.1 Vue Router 路由守卫:如何让“未登录用户点个人中心自动跳登录页”不丢参数?
项目用 Vue Router 4(Composition API),路由配置里明确区分publicRoutes(无需登录)和privateRoutes(需登录):
// router/index.js const publicRoutes = [ { path: '/', component: () => import('@/views/Home.vue') }, { path: '/login', component: () => import('@/views/Login.vue') }, { path: '/register', component: () => import('@/views/Register.vue') } ] const privateRoutes = [ { path: '/user/profile', component: () => import('@/views/UserProfile.vue') }, { path: '/admin/articles', component: () => import('@/views/AdminArticles.vue') } ] const router = createRouter({ history: createWebHistory(), routes: [...publicRoutes, ...privateRoutes] })全局前置守卫判断登录态:
router.beforeEach(async (to, from, next) => { const token = localStorage.getItem('token') const isPublic = publicRoutes.some(route => route.path === to.path) if (isPublic) { next() // 公共页面直接放行 } else if (!token) { next({ path: '/login', query: { redirect: to.fullPath } }) // 记录原目标路径 } else { try { // 验证 token 是否有效(调用 /api/user/info) const res = await axios.get('/api/user/info') if (res.data.code === 200) { next() } else { localStorage.removeItem('token') next('/login') } } catch (e) { localStorage.removeItem('token') next('/login') } } })注意:
next({ path: '/login', query: { redirect: to.fullPath } })是关键。用户从/user/profile被重定向到/login后,登录成功时应自动跳回/user/profile,而不是首页。to.fullPath包含完整路径(如/user/profile?tab=posts),query参数确保 URL 参数不丢失。
3.2 Axios 请求拦截器:为什么所有请求必须带X-Requested-With: XMLHttpRequest头?
SpringMVC 默认开启X-Requested-With头校验,用于区分 AJAX 请求和普通表单提交。如果前端不带这个头,@ResponseBody返回的 JSON 会被ViewResolver当作视图名处理,返回 404。项目在utils/request.js中统一设置:
const service = axios.create({ baseURL: 'http://localhost:8080', timeout: 10000, headers: { 'X-Requested-With': 'XMLHttpRequest' // 必须!否则后端返回 404 } }) // 请求拦截器 service.interceptors.request.use( config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }, error => Promise.reject(error) )玄学点:
X-Requested-With头不是可选的“兼容性补丁”,而是 SpringMVC 的底层机制。如果你删掉这行,/api/user/login接口会返回{"code":200,"msg":"登录成功"}的 HTML 页面源码(因为ViewResolver找不到login视图),而不是纯 JSON。这是毕业设计里最隐蔽的坑之一。
3.3 用户角色动态菜单:如何让管理员看到“用户管理”,普通用户看不到?
前端菜单不是写死的,而是根据登录后返回的role字段动态渲染。登录成功后,后端返回:
{ "code": 200, "data": { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "user": { "id": 1001, "username": "admin", "role": "ADMIN" } } }Vuex store 中存user.role,侧边栏组件用v-for渲染:
<!-- components/Sidebar.vue --> <template> <el-menu :default-active="activeMenu"> <menu-item v-for="item in filteredMenus" :key="item.path" :index="item.path"> {{ item.title }} </menu-item> </el-menu> </template> <script setup> import { computed } from 'vue' import { useStore } from '@/store' import { menus } from '@/config/menus' const store = useStore() const role = computed(() => store.state.user.role) const filteredMenus = computed(() => { return menus.filter(menu => { if (menu.role === 'ALL') return true return menu.role.includes(role.value) }) }) </script>menus.js配置如下:
// config/menus.js export const menus = [ { title: '首页', path: '/', role: ['ALL'] }, { title: '个人中心', path: '/user/profile', role: ['USER', 'ADMIN'] }, { title: '文章管理', path: '/admin/articles', role: ['ADMIN'] }, { title: '用户管理', path: '/admin/users', role: ['ADMIN'] } ]避坑:不要在
v-if里写v-if="role === 'ADMIN'"。这样会导致 DOM 节点被销毁重建,Vue 组件状态丢失。用filter动态生成菜单数组,既保持响应式,又避免重渲染。
4. 数据库设计不是“建完表就完事”:MySQL 5.7 的字符集、索引、外键约束与备份脚本实操
4.1 字符集与排序规则:为什么utf8mb4是唯一选择,utf8会丢 emoji?
项目数据库建库语句明确指定:
CREATE DATABASE `learning_forum` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;utf8mb4是 MySQL 对 UTF-8 的完整实现,支持 4 字节字符(如 🐍、👨💻、❤️),而 MySQL 的utf8实际是utf8mb3,最多只支持 3 字节,遇到 emoji 会存成?或报错Incorrect string value。
参数说明:
COLLATE utf8mb4_unicode_ci表示按 Unicode 标准排序和比较,比utf8mb4_general_ci更准确(后者已废弃)。例如,'café'和'cafe'在unicode_ci下不相等,在general_ci下可能被误判为相等。
4.2 关键字段索引策略:为什么forum_post表要建联合索引(category_id, create_time)?
forum_post表有 3 个高频查询场景:
- 按分类查最新帖子(
WHERE category_id = ? ORDER BY create_time DESC LIMIT 10); - 按用户查自己发的帖子(
WHERE user_id = ? ORDER BY create_time DESC); - 全站最新帖子(
ORDER BY create_time DESC)。
单列索引INDEX idx_category_id (category_id)无法优化ORDER BY create_time,因为 MySQL 只能用索引的最左前缀。项目建联合索引:
-- 优化“按分类查最新帖子” ALTER TABLE forum_post ADD INDEX idx_category_time (category_id, create_time); -- 优化“按用户查自己发的帖子” ALTER TABLE forum_post ADD INDEX idx_user_time (user_id, create_time);验证方法:用
EXPLAIN查看执行计划。执行EXPLAIN SELECT * FROM forum_post WHERE category_id = 5 ORDER BY create_time DESC LIMIT 10;,如果type是ref,key是idx_category_time,Extra里有Using index condition; Using filesort,说明索引生效但排序仍需临时文件——这是正常现象,因为ORDER BY方向与索引方向一致(都是 DESC),MySQL 5.7 会利用索引有序性,避免额外排序。
4.3 外键约束与级联删除:为什么forum_comment表的post_id必须加外键?
forum_comment表的post_id字段关联forum_post.id,项目建外键:
ALTER TABLE forum_comment ADD CONSTRAINT fk_comment_post FOREIGN KEY (post_id) REFERENCES forum_post(id) ON DELETE CASCADE;ON DELETE CASCADE表示:当删除一篇帖子时,该帖子下的所有评论自动删除。如果不加外键,靠应用层手动删评论,容易漏删或事务失败导致数据不一致。
注意:MySQL 5.7 中,外键字段必须有索引(
post_id已建索引),且两张表引擎必须是 InnoDB(MyISAM 不支持外键)。建表时务必写ENGINE=InnoDB DEFAULT CHARSET=utf8mb4。
4.4 数据库备份脚本:一行命令导出结构+数据,且排除日志表
项目提供backup_db.sh脚本,一键备份:
#!/bin/bash # backup_db.sh DB_NAME="learning_forum" BACKUP_DIR="/home/backup" DATE=$(date +%Y%m%d_%H%M%S) mysqldump -u root -p'your_password' \ --databases $DB_NAME \ --single-transaction \ --routines \ --triggers \ --events \ --ignore-table=$DB_NAME.forum_log \ > $BACKUP_DIR/$DB_NAME_$DATE.sql--single-transaction保证备份时数据一致性(InnoDB 表);--ignore-table排除日志表(如forum_log),避免备份文件过大;--routines导出存储过程和函数(项目虽未用,但留扩展空间)。
血泪经验:别用
mysqldump -A备份所有库,生产环境可能有其他测试库,备份文件里混着敏感数据。必须明确指定--databases $DB_NAME。
5. 避坑:SSM + Vue 项目部署上线前必须过的五道坎
5.1 现象:前端npm run serve能登录,但npm run build后部署到 Tomcat,登录接口 404
原因:Vue CLI 打包后,静态资源放在dist/目录,需通过 Nginx 或 Tomcat 的webapps/ROOT目录访问。但项目后端是 SpringMVC,web.xml中DispatcherServlet映射路径为/,会拦截所有请求(包括/static/js/app.js),导致静态资源 404。
解决:在web.xml中配置静态资源不拦截:
<servlet-mapping> <servlet-name>default</servlet-name> <url-pattern>/static/*</url-pattern> </servlet-mapping> <servlet-mapping> <servlet-name>default</servlet-name> <url-pattern>/favicon.ico</url-pattern> </servlet-mapping>同时,Vue 的vue.config.js中设置publicPath: '/static/',确保打包后 JS/CSS 路径正确。
5.2 现象:管理员登录后,点“用户管理”页面空白,浏览器控制台报GET http://localhost:8080/api/admin/users 403
原因:Spring Security 未配置/api/admin/**的权限放行,或@PreAuthorize注解写错。项目实际未用 Spring Security,而是手写拦截器,但拦截器中excludeUrls没包含/api/admin/**。
解决:检查LoginInterceptor.java:
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String uri = request.getRequestURI(); // 必须放行所有 /api/** 接口,否则拦截器会拦掉 if (uri.startsWith("/api/")) { return true; } // ... 其他逻辑 }5.3 现象:MySQL 5.7 启动时报ERROR 1045 (28000): Access denied for user 'root'@'localhost'
原因:项目application.yml中数据库密码写的是123456,但本地 MySQL root 密码不是这个,或者 MySQL 5.7 默认认证插件是caching_sha2_password,而老版 JDBC 驱动不支持。
解决:
- 登录 MySQL:
mysql -u root -p; - 执行:
ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '123456';; - 刷新权限:
FLUSH PRIVILEGES;; pom.xml中 JDBC 驱动版本升级到8.0.28:
<dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.28</version> </dependency>5.4 现象:Vue 页面中v-model绑定的输入框,输入中文后失去焦点,内容消失
原因:Vue 2.x 中v-model在某些输入法下触发时机异常,项目用 Vue 2.6+,需加.lazy修饰符或改用@input。
解决:将v-model="form.title"改为:
<input v-model.lazy="form.title" /> <!-- 或 --> <input @input="form.title = $event.target.value" />5.5 现象:IDEA 启动项目报Error creating bean with name 'sqlSessionFactory',提示Cannot load driver class 'com.mysql.cj.jdbc.Driver'
原因:JDK 1.8 与 MySQL 8.0+ 驱动不兼容,但项目要求 MySQL 5.7,驱动应为mysql-connector-java:5.1.47。
解决:检查pom.xml,确保:
<dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.47</version> </dependency>并删除 Maven 仓库中mysql-connector-java-8.x的 jar 包,避免冲突。
6. 从“能跑起来”到“能讲清楚”:用 Postman 验证接口、用 Chrome DevTools 查看 Cookie、用 MySQL Workbench 分析慢查询
6.1 用 Postman 验证登录流程:三步确认 Session 传递是否可靠
毕业答辩时,老师最爱问:“你怎么证明登录态是有效的?” 光说“我用了 HttpSession”不够,得拿出证据。我习惯用 Postman 做三步验证:
- 第一步:POST
/api/user/login,Body 选x-www-form-urlencoded,填username=admin&password=123456;- 成功返回
{"code":200,"data":{"token":"xxx"}}; - 查看 Postman 的
Cookies标签页,确认JSESSIONID已自动保存(值如ABC123XYZ);
- 成功返回
- 第二步:GET
/api/user/info,Headers 加Cookie: JSESSIONID=ABC123XYZ;- 返回
{"code":200,"data":{"id":1,"username":"admin","role":"ADMIN"}},证明 Session 有效;
- 返回
- 第三步:GET
/api/user/info,Headers 删除Cookie行;- 返回
{"code":401,"msg":"未登录"},证明无 Session 时被拦截。
- 返回
技巧:Postman 的
Cookies标签页里,右键JSESSIONID→Edit,可以手动修改值,模拟 Session 失效场景,验证后端拦截逻辑是否健壮。
6.2 用 Chrome DevTools 查看 Cookie:为什么SameSite=Lax会导致跨域请求丢失 Cookie?
Vue 项目运行在http://localhost:8080,后端接口在http://localhost:8081(或同端口但不同上下文),这就是跨域。Chrome 80+ 默认给 Cookie 加SameSite=Lax,意味着跨站 POST 请求(如登录)会带上 Cookie,但跨站 GET 请求(如/api/user/info)不会带。项目解决方案是:
- 后端
WebMvcConfigurer中配置CorsConfiguration:
@Bean public WebMvcConfigurer corsConfigurer() { return new WebMvcConfigurer() { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOrigins("http://localhost:8080") .allowCredentials(true) // 关键!允许携带 Cookie .maxAge(3600); } }; }- 前端 Axios 请求必须加
withCredentials: true:
axios.defaults.withCredentials = true;然后在 Chrome DevTools → Application → Cookies 中,查看localhost下的JSESSIONID,其SameSite字段应为None(因allowCredentials=true,Spring 会自动设为None),否则请求发不出 Cookie。
6.3 用 MySQL Workbench 分析慢查询:定位“论坛首页加载慢”的真实瓶颈
假设首页加载超过 3 秒,怀疑是SELECT * FROM forum_post ORDER BY create_time DESC LIMIT 20慢。在 MySQL Workbench 中:
- 打开
Performance Dashboard→Query Analyzer; - 执行
SELECT * FROM forum_post ORDER BY create_time DESC LIMIT 20; - 查看
Execution Plan:如果type是ALL,说明全表扫描; - 执行
SHOW CREATE TABLE forum_post,确认create_time有索引; - 如果仍有问题,加
FORCE INDEX (idx_create_time):
SELECT * FROM forum_post FORCE INDEX (idx_create_time) ORDER BY create_time DESC LIMIT 20;表格:常见慢查询优化对照表
| 场景 | 原 SQL | 优化后 SQL | 索引建议 | |------|--------|------------|----------| | 按分类查最新帖子 |WHERE category_id=5 ORDER BY create_time DESC|WHERE category_id=5 ORDER BY create_time DESC|INDEX idx_category_time (category_id, create_time)| | 模糊搜索标题 |WHERE title LIKE '%java%'|WHERE title LIKE 'java%'(左匹配) |INDEX idx_title (title)| | 统计某用户发帖数 |SELECT COUNT(*) FROM forum_post WHERE user_id=1001|SELECT COUNT(*) FROM forum_post WHERE user_id=1001|INDEX idx_user_id (user_id)|
从那以后我每次交接毕业设计代码,都会先跑一遍 Postman 三步验证,再打开 Chrome DevTools 看一眼 Cookie 的SameSite值,最后用 MySQL Workbench 抓一个慢查询。不是为了炫技,是怕学生第二天早上哭着找我说:“老师,我昨天还能登录,今天突然 401 了……” —— 其实就是JSESSIONIDCookie 被浏览器清掉了,而他没意识到localStorage里的 token 和Cookie是两套体系。希望帮到你。
本文还有配套的精品资源,点击获取