SpringBoot+Vue3考研互助系统:前后端分离毕业设计实战
2026/9/24 21:11:16 网站建设 项目流程

后台连着收到好几条私信,问的都是同一件事:“学长,考研互助系统用SpringBoot+Vue做毕业设计,靠不靠谱?”说真的,每次看到这类问题我都得先反问一句:你想做的是考研场景下的信息共享平台,还是只想做个带登录功能的论坛?这两个方向的代码量可能差不多,但对评委来说,看到的是完全不同的东西。去年我做“聚力”考研互助系统(也就是“研友圈”考研信息共享平台)的时候,一开始就差点把项目做成一个通用BBS,后来推倒重来才把方向掰回来,这篇就把完整的设计、实现和踩坑过程写出来,给打算做类似题目的同学一个能直接参考的路线。

先说清楚这个项目是什么:它不是一个论文资料堆砌系统,而是一个定位在考研备考场景下的互助社区,包含用户体系、研友圈动态、研友匹配推荐、资料共享与审核、个人中心等模块。技术上分为SpringBoot后端和Vue3前端,采用前后端分离开发,部署时用Docker Compose一把梭。下面按我从需求分析到答辩准备的完整顺序来讲,重点会放在“为什么这样做”和“真正动手时会踩到什么坑”上。

1. 考研互助这个点子,是怎么从“功能堆砌”里捞出来的

1.1 只要“发帖、评论、点赞”,那和通用论坛有什么区别

很多同学选题的时候容易犯一个毛病:把“考研互助系统”理解成“论坛系统换个名字”。于是一上来就拆功能:用户管理、帖子管理、评论管理、点赞管理、后台管理……功能清单列了满满一页,但仔细一想,把“考研”两个字去掉,这个系统套在任何一个垂直社区上都成立。

我当时也犯了这个错,第一版设计文档被老师打回来,原话是:“我看不出这个系统哪里考研了。”这句话对我影响很大,也直接改变了后续的设计思路。

真正的考研互助需求,是和备考场景强绑定的。考研学生的痛点不是什么发帖评论,而是这么几个:

  • 信息分散:目标院校的报录比、专业课参考书、学长学姐的经验帖,散落在QQ群、微信群、知乎、贴吧里,找起来非常费劲。
  • 研友难找:备考是长期战、信息战、心态战,一个人复习容易焦虑,想找一个考同校同专业的研友互相监督,但身边不一定有。
  • 资料真假难辨:网上下载的专业课资料,经常是旧版、残缺版甚至带病毒的文件,缺少一个“有人审核过”的渠道。

这三个痛点,分别对应了系统的三个核心模块:信息共享平台(资料库)、研友圈(经验与动态)、研友匹配推荐。每一个模块都能说出明确的用户场景,而不是为了凑功能而做功能。

1.2 三个真实使用场景决定了三个核心模块

我在需求分析阶段,给自己定了三个必须能走通的“用户故事”:

场景一:找研友。用户注册时填写目标院校、报考专业、考试科目、备考状态这些标签,系统根据标签相似度向他推荐其他研友。比如A考生目标是郑州大学计算机技术,考408、英语一;B考生的目标也是郑大计算机,同样考408。系统就应该把B推荐给A,并显示“你们都在考408,目标院校相同”的解释理由。

场景二:找资料。用户上传一份专业课真题PDF,填写标题、科目、年份、描述。资料不会立刻出现在前台,要进入审核队列,管理员确认没有病毒、没有版权争议、文件能正常打开之后,才会在资料广场展示。其他用户浏览资料详情时可以下载,系统会累计下载次数,作为资料热度的衡量。

场景三:圈子互助。用户在研友圈发布每日复习打卡帖,配上一张学习照片,其他研友可以点赞、评论、收藏。考同一所学校的研友可以在评论区交换复习进度,互相答疑。帖子按最新和热门两种方式排序,热门度的计算综合浏览数、点赞数、评论数。

这三个场景不是我想象出来的,而是我访谈了身边四个考研的同学,把他们的原话整理后得到的。事实证明,带着真实场景去做设计,后面的开发会顺畅很多,因为每个功能都有明确的业务逻辑,不会出现“为CRUD而CRUD”的空洞代码。

1.3 项目的边界:非核心功能做“够用就行”

一个容易被忽略但非常重要的问题是:毕设项目的时间精力有限,不可能像商业产品一样什么都做。我当时对功能边界做了明确的取舍:

  • 私信聊天功能做最小实现。原本计划做站内私信,后来发现实时通讯是个无底洞,改为“评论区内@回复”和“互相关注后查看对方动态”这两个轻量功能。
  • 后台管理不做复杂的权限粒度。只分管理员和普通用户两种角色,管理员维护资料审核、帖子违规处理、用户禁用。
  • 不做支付、不做课程售卖。考研资料免费共享,避免涉及交易合规问题。

这个取舍在答辩时反而成了加分项,评委看到的是“有明确的产品边界”,而不是“什么都想做什么都做得浅”。

2. 技术选型这一步,我纠结过,但最后还是这套组合最稳

2.1 后端:为什么是SpringBoot而不是SSM或微服务

后端框架我做过对比。SSH(Struts2+Spring+Hibernate)和SSM(Spring+SpringMVC+MyBatis)是很多教材里的老组合,但配置繁琐,Xml文件一大堆,现在几乎没有新项目这么写了。SpringBoot 2.x基于自动配置,内嵌Tomcat,一个Jar包就能跑起来,对毕设来说开发效率高得多。

至于微服务,我直接排除了。考研互助系统这个体量,用SpringCloud拆五六个服务属于给自己挖坑,服务注册、配置中心、网关、分布式事务每一个都是额外的工作量,而且很容易被答辩评委追问:“你用微服务解决了什么单体解决不了的问题?”如果答不上来,这反而是减分项。

我最终选择了SpringBoot 2.7.18,原因其实很务实:这个版本对JDK8支持非常稳定。当时SpringBoot 3.x已经出了,但它强制要求JDK17,而大多数学校的课程环境和毕设服务器还停留在JDK8。为了不让环境问题浪费时间,我选了2.7.18这个成熟的长期维护版本。

2.2 前端:为什么选Vue3而不是JSP模板或Vue2

我曾经认真考虑过用Thymeleaf配合Bootstrap做服务端渲染,因为这样不用处理跨域,部署也简单。但后来想想,题目里都写着“前后端分离”了,再用模板引擎就失去了这个技术亮点的展示机会,答辩也不好讲。

Vue2和Vue3之间我几乎没有犹豫,直接选了Vue3。原因也简单:

  • Vue3的Composition API组织逻辑更清晰,一个考研圈页面的数据加载、点赞、分页、评论逻辑可以拆分到单独的hooks里,比Vue2的Options API更容易讲清楚。
  • 官方生态全面转向Vue3,Element Plus、Pinia、Vue Router 4都是配套Vue3的。
  • Vite构建速度比Webpack快一个量级,开发体验好,演示时改代码热更新快。

前端UI组件库选了Element Plus,它对“信息管理类页面”的覆盖太全了:表格、表单、上传、分页、对话框、消息提示全是现成的。自己手搓UI不是不行,但会分散大量精力,对毕设来说性价比太低。

2.3 最终技术栈清单

层次技术选型选择理由
后端框架SpringBoot 2.7.18自动配置、内嵌Tomcat、JDK8兼容
ORMMyBatis-Plus 3.5.x单表CRUD免写SQL,分页插件好用
权限认证JWT + HandlerInterceptor无状态认证,适合前后端分离
数据库MySQL 8.0稳定可靠,课程通用
缓存Redis 5.x存验证码、热点帖子数据、匹配推荐短期结果
对象存储MinIO图片和PDF上传,支持预签名直传
前端Vue3 + Vite + Pinia + Vue Router + Element Plus + Axios官方生态成熟,开发效率高
部署Docker Compose + Nginx一键启动所有组件,答辩演示稳定

这套组合还有一个隐藏优势:网上资料和开源项目特别多,任何一个点出了问题都能搜到解决方案,这在毕设时间有限的情况下比所谓“更先进的技术”重要得多。

3. 核心模块拆解:研友圈、匹配推荐、资料共享是怎么实现的

3.1 用户体系:标签是后面所有推荐的地基

登录注册是第一关。注册表单除了基本的用户名、密码、昵称之外,我还加了三个必填项:目标院校、报考专业、考试科目。这三项是后面研友匹配的核心标签,必须在注册时就采集到,而不是等用户进来后再慢慢补全。

密码存储用的是BCrypt加密,不是MD5。MD5加盐虽然也能用,但答辩时被问到“密码安全性怎么保障”的时候,BCrypt的可解释性明显更强,而且Spring Security的加密库可以直接引入,代码量很少。

登录后签发JWT,我用的是jjwt库,token里只放userId和expire时间,不在token里塞大段的用户信息。具体逻辑是这样的:

  • 登录接口校验用户名密码通过后,生成token返回前端。
  • 前端把token存在localStorage,每次请求通过axios请求拦截器放到Authorization头。
  • 后端写一个HandlerInterceptor,拦截除了登录注册接口之外的所有请求,校验token是否有效。
  • 校验通过后把userId解析出来,放Request attribute里,Controller里直接取用。

管理员和普通用户的权限区分,是在Intercepter里根据userId查角色实现的,写了自定义注解@RequireRole("admin")标注需要管理员权限的接口,逻辑清晰,答辩也好讲。

3.2 研友圈:帖子、评论、点赞、收藏的组合拳

研友圈是整个系统互动频率最高的模块,包含帖子发布、帖子列表、帖子详情、评论、点赞、收藏、话题标签筛选。

帖子发布时支持上传图片,最多9张,用的是前端直传MinIO的方案,后面专门讲。帖子内容字段分两种:标题和正文字段是纯文本,发布会做两端校验(前端长度限制+后端参数校验),不引入富文本编辑器,减少XSS面。话题标签用逗号分隔存在帖子的tags字段里,为什么这么做,数据库章节详细说。

评论做了两层设计:一级评论直接挂在帖子下面,二级回复挂在评论下面,通过parent_id和reply_user_id两个字段组合。这样实现的效果是:如果A发了一条评论,B回复A,C再回复B,这条回复会出现在B的评论下,并且@了B。从用户体验来说,回复会看到清晰的通知引导,但从数据表设计来说,只需要一张comment表和两个外键字段就够了。

点赞和收藏都是用单独的一张关系表存储。点赞表加了唯一索引(user_id, post_id)来防止重复点赞;收藏表同理。帖子列表页显示点赞数和收藏数,这两个数字不是每次都count出来的,而是冗余在post表里,后面细讲。

热门排序的算法我用了最直观的加权公式:热度 = 浏览量 * 0.3 + 点赞数 * 2 + 评论数 * 5 + 收藏数 * 8,按时间衰减后再和最新帖子混合排序。算法不复杂,但答辩时能讲出一套逻辑,比单纯按创建时间倒序要有说服力。

3.3 研友匹配:一种简单可解释的标签相似度算法

研友匹配是“考研互助”这个题目最独特的模块,也是答辩时最容易被深挖的地方。我没有用协同过滤或者深度学习这类看起来很炫但实际上很难解释清楚的模型,而是选了基于标签的Jaccard相似度算法,简单、高效、可在答辩时手推公式。

用户标签从五个维度收集:目标院校、报考专业、考试科目(允许选多个)、备考状态(刚开始/一轮复习/二轮冲刺)、是否二战。匹配时,把两个用户的标签集合分别记为集合A和集合B,相似度计算公式为:

J(A, B) = |A ∩ B| / |A ∪ B|

举个例子:用户甲标签是{郑州大学, 计算机技术, 408, 英语一},用户乙是{郑州大学, 软件工程, 408, 英语一}。交集是{郑州大学, 408, 英语一}共3个,并集是{郑州大学, 计算机技术, 软件工程, 408, 英语一}共5个,相似度就是3/5 = 60%。

计算结果以百分比展示,同时把共同的标签列出来作为“推荐理由”。比如显示“你们都在考408,目标院校都是郑州大学”,用户很容易理解为什么系统把这个人推荐给自己,这种可解释性的推荐比黑盒模型在答辩中更有优势。

匹配的查询策略是:先用SQL筛出目标院校或报考专业相同(至少一个相同)的候选用户,缩小范围,再在内存中遍历计算相似度,按分数排序取Top10。用户量在几千级别时,这种方案性能完全没问题。如果以后用户量大了,可以直接在数据库里加一个标签索引字段,或者用Redis的Set结构做交集运算,但这属于优化项,不做也不会影响系统当前功能。

3.4 信息共享:资料库不是网盘,要有审核队列

资料库模块是“信息共享平台”的直接体现,但我没有把它做成一个简单的文件列表,而是设计了完整的审核流程,这是整个项目最接近真实业务的地方。

用户上传资料时,填写标题、科目分类(政治/英语/数学/408/专业课)、适用院校、年份、描述,然后上传PDF或图片文件。上传的文件会先经过后端校验:文件类型是否在白名单、文件名是否包含非法字符、文件大小上限是否超过50MB,防止有人上传可执行文件或恶意文件。同时计算文件的MD5值,用来做重复检测——如果库里已经有同样内容的文件,直接提示“该资料已被上传过,下载次数为XX”,避免重复资料堆积。

审核流程是这样的:提交的资料默认是PENDING状态,只有管理员在后台点击通过、变成APPROVED之后,才会出现在前台的资料广场。被拒绝的资料会记录拒绝原因,用户可在个人中心查看并修改后重新提交。这个设计一开始看起来增加了很多工作量,但它是把“信息共享平台”和“普通网盘”区分开的关键点,直接体现了平台的治理能力,答辩时是一个非常值得讲的产品决策。

资料详情页展示文件信息、上传者、下载次数,下载按钮会校验用户登录状态,下载时后端通过流式写出的方式返回文件,并设置Content-Disposition: attachmentX-Content-Type-Options: nosniff响应头。这样既保证下载体验,又防了一手XSS,这点在后面避坑章节还会详细说。

4. 数据库设计:这几张核心表,是我改了三版才定下来的

4.1 表结构总览

数据库设计我前后改了三版。第一版按需求文档的功能列表建表,导致表极度碎片化;第二版开始合并冗余字段;第三版才确定了最终结构。核心表如下:

表名说明关键字段
user用户表id, username, password, nickname, avatar, school, major, subjects, status
post帖子表id, user_id, title, content, tags, images, view_count, like_count, comment_count, favorite_count, status
comment评论表id, post_id, user_id, parent_id, reply_user_id, content
like_record点赞记录表id, user_id, post_id, create_time
favorite收藏表id, user_id, post_id, create_time
material资料表id, user_id, title, category, description, file_url, file_md5, download_count, status, reject_reason
follow关注表id, user_id, follow_user_id, create_time
admin管理员表id, username, password

这里有几个字段需要特别解释一下。

4.2 为什么帖子表要冗余点赞数、评论数、收藏数字段

最直观的做法是:显示帖子列表时,用count语句去like_record、comment、favorite三张表分别统计数量,再拼接结果。但这样有两个问题。第一,列表页每显示20条帖子,就要额外执行60次count查询,数据量上去后性能会非常难看。第二,统计代码散落在多个Service里,写起来也繁琐。

所以我选择了“空间换时间”的经典方案:在post表里直接冗余like_countcomment_countfavorite_count三个字段。用户点赞时,除了在like_record插入一条记录,同时执行update post set like_count = like_count + 1 where id = ?。两个操作处于同一个事务中,不存在数据不一致的问题。

同理,删除评论或取消点赞时,对应的计数字段同步减一。这套方案的代码改动集中在Service层,写一次后所有列表查询都能直接取字段,不用联表聚合。如果有人追问“并发下数字会不会不准”,答案是:flush刷盘由MySQL行锁保护,Update语句本身是原子递增,正确性是有保证的。

4.3 评论表用parent_id,而不是把所有回复都平铺

评论模块的坑主要在“删除”上。如果用户删除了一条一级评论,但这条评论下还有二级回复,直接物理删除会导致二级回复变成无头孤魂。我的方案是逻辑删除:给comment表加一个deleted字段,删除时只更新这个字段为1,查询时默认过滤已删除数据。

同时,在前端页面,被删除的评论显示为一行灰色文字:“该评论已删除”。这样做的另一个好处是:帖子下的评论数不会因为删除而减少,保持计数一致性。虽然数据量大了以后会有冗余废数据,但对于毕设项目来说,逻辑删除带来的数据安全问题远比那点存储空间重要。

parent_id字段的语义是:为0表示一级评论(直接挂在帖子下),不为0则表示这条评论是对parent_id这条评论的回复,reply_user_id记录被回复人的ID,用于前端展示“@张三”。评论的嵌套层级我控制在两级,不无限递归——用户A发评论,用户B回复A这条评论,这个B的回复直接归到A的一级评论下,不再生成三级、四级结构。

4.4 帖子标签存JSON还是关联表

这个话题在知乎上吵得不可开交,我的选择是:帖子标签用字符串存JSON数组,用户标签用多列冗余。听起来有点“不讲武德”,但实际场景下各有原因。

帖子标签的特点是可动态扩展、同一篇帖子数量不确定,最多可能有五六个。如果严格按照范式建post_tag关联表,查询时有JOIN post_tagJOIN tag两层,代码麻烦,而且对列表页的人来说,标签只是展示用的,不需要按标签做复杂的多维筛选。所以我在post表里用一个tags VARCHAR(500)字段存["计算机", "408", "打卡"]这种JSON数组,插入时由后端序列化,查询出来用Jackson反序列化成List,逻辑非常干净。按标签筛选的接口,用JSON_CONTAINS(tags, ?)就能实现,MySQL原生支持。

用户表则不同。用户的标签是研友匹配的核心数据,需要频繁按字段查询,比如“找出目标院校为郑州大学的用户”。如果也在一个JSON串里存,查询时没法走索引,匹配推荐的性能会受影响。所以我在user表里直接设计了school、major、subjects、status几个独立字段,subjects虽然还是JSON数组,但只在匹配时做普通查询条件,不做索引依赖。

5. 后端实现里的硬骨头:认证、分页、XSS、跨域

5.1 JWT登录态:Interceptor和Filter的注册顺序别搞错

关于登录认证,最核心的一段代码是拦截器注册。SpringBoot中实现登录态校验通常有两种方式:一是写一个HandlerInterceptor,二是写一个Filter。我一开始用的是Filter,因为看到网上很多教程都写Filter,但后来发现项目里有需要根据角色放行不同接口的需求,Filter里拿不到HandlerMethod(也就是Controller方法对象),做起权限控制很别扭。

我最终换成了HandlerInterceptor,在preHandle方法里校验token,然后通过handler判断接口上是否有@RequireRole注解。核心流程是这样的:

public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String uri = request.getRequestURI(); if (uri.startsWith("/api/auth/")) { return true; // 登录注册接口放行 } String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { throw new BizException(401, "未登录"); } Long userId = JwtUtil.parseToken(token.replace("Bearer ", "")); if (userId == null) { throw new BizException(401, "登录已过期"); } request.setAttribute("userId", userId); return true; } }

拦截器配置类的写法也有讲究。这里最容易踩的坑是:不用@WebFilter注解,因为你没法控制执行顺序,而且Spring容器中的Bean注入会出问题。正确做法是在一个@Configuration类里注册拦截器:

@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new AuthInterceptor()) .addPathPatterns("/api/**") .excludePathPatterns("/api/auth/**"); } }

还有一个常见的坑:配置了拦截器之后,放行登录接口没问题,但是前端静态资源、Swagger文档这些路径如果也被拦截了,会导致整个项目打不开。实际开发时,我一开始把+ .excludePathPatterns("/doc.html", "/webjars/**")漏了,结果前端同事连不上接口文档,排查了半天。这个问题不大,但很影响效率,记录一下。

token过期的问题,我用了统一返回体方案:后端的全局异常处理器捕获到token解析异常时,返回code=401的JSON。前端的axios响应拦截器检测到401后,清空localStorage并跳转登录页。这样前后端各管一段,逻辑清晰。

5.2 MyBatis-Plus分页插件:不配置PaginationInnerInterceptor,分页就是“假分页”

MyBatis-Plus的分页插件是我被坑得最深的地方,没有之一。刚上手时,照着文档引入依赖后,直接在Service里写:

Page<Post> page = new Page<>(current, size); postMapper.selectPage(page, new LambdaQueryWrapper<Post>() .eq(Post::getStatus, 1) .orderByDesc(Post::getCreateTime));

结果发现page.getTotal()永远返回0,而且SQL里根本没有LIMIT语句。排查了一圈,发现问题出在MyBatis-Plus 3.5.x版本里,分页插件默认没有启用,需要手动添加一个配置类:

@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }

这个问题本质上是对框架设计理解不深导致的。MyBatis-Plus为了兼容不同数据库的方言,把所有拦截器都放在一个MybatisPlusInterceptor里,分页只是其中一种,必须显式注册。我在这个坑上花了起码两个小时,一边看控制台SQL一边怀疑人生。

分页的第二个坑是自定义联表查询时的count语句不准。比如帖子列表要联查用户表拿昵称和头像,自定义了XML里的SQL,直接selectPage会生成一个错误的count语句,总数对不上。解决方案是在测试中把count优化打开:在PaginationInnerInterceptor里有一个底层配置,MyBatis-Plus会自动尝试优化count语句,去掉冗余的left join。注意点就是:自定义SQL时,优先把关联表写在JOIN里而不是子查询里,这样优化器才能正确识别。

5.3 全局过滤器处理XSS:一次上传PDF时“中招”的完整排查链路

别笑,这个坑是从热搜词“springboot项目全局过滤器处理上传pdf文件时xss攻击”里看到的,但我自己真的踩过类似的问题。具体现象是这样的:某个用户通过资料上传页面,在“资料描述”字段里填写了<script>alert('xss')</script>,前端长度校验没有拦住(因为它以为是普通文本),后端也没有做任何特殊处理,这个脚本就存进了数据库。

后来管理员在后台打开审核列表,浏览器直接弹出了alert框。我当时第一反应是前端渲染出了问题,想着“el-input的v-model会自动转义,怎么会执行呢”,但排查后发现问题根本不在这。完整排查链路是这样的:

第一步,先用浏览器打开资料详情接口的返回JSON,发现接口直接返回了原始的<script>alert('xss')</script>字符串,确认数据库里存的就是原始内容。

第二步,用Navicat查数据库,看到content字段确实是完整的script标签,说明问题出在数据入库之前。前端展示时用的是v-html指令或者某个把富文本当HTML渲染的组件,才导致脚本执行。

第三步,定位到插入逻辑。上传接口接收DTO后直接save(bean),没有任何过滤。于是问题就变成了:怎么在不改Controller代码的情况下,统一过滤请求参数中的危险字符。

最终方案是写一个全局XssFilter,继承OncePerRequestFilter,重写getParametergetInputStream,把请求体中的HTML标签进行转义处理。核心逻辑是这样的:

public class XssFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) { HttpServletRequest req = (HttpServletRequest) request; XssWrapper xssWrapper = new XssWrapper(req); chain.doFilter(xssWrapper, response); } } public class XssWrapper extends HttpServletRequestWrapper { @Override public String getParameter(String name) { String value = super.getParameter(name); return cleanXss(value); } @Override public String getHeader(String name) { String value = super.getHeader(name); return cleanXss(value); } private String cleanXss(String value) { if (value == null || value.isEmpty()) { return value; } return value.replaceAll("<", "&lt;").replaceAll(">", "&gt;"); } }

这里有一个非常关键的分歧:如果对全站所有字段做<>的替换,那些合法想展示<h1>标签的富文本字段也会被破坏。所以我的处理方式是分两类对待:普通文本字段直接替换,富文本字段(比如研友圈的帖子内容)在入站时不做全局转义,而是在前端用白名单过滤后展示。后端只对非富文本的“纯文本”字段做统一清洗,这样既防止了脚本注入,又不破坏富文本的展示效果。

上传PDF时的XSS攻击另一个入口是文件名。有人构造了一个文件名:test.pdf<script>alert('xss')</script>.pdf,如果系统直接把文件名拼进下载的URL里返回给前端,同样存在注入风险。解决办法很直接:文件名严格限制只允许字母、数字、下划线、点,其他字符一律过滤;下载链接使用系统生成的随机文件标识而不是原始文件名,前端展示时用编码后的文件名。

5.4 前后端分离的跨域问题

前后端分离开发时,前端在8080端口,后端在8081端口,浏览器会拦截跨域Ajax请求。我用的是CORS方案,没有用Nginx转发来糊弄。因为Nginx转发在本地开发测试时总要额外配置,CORS在后端加一段配置就能解决,开发更直观。

最需要注意的点是:allowedOrigins不能配置成*当接入前端跨域请求时。因为当前端请求携带自定义Header(比如Authorization)时,浏览器会先发一个OPTIONS预检请求,而后端的CORS配置如果没有显式允许这个自定义Header,预检就会失败。我在开发时就遇到过:接口返回值一切正常,但浏览器Network里总是显示红色错误,后端日志里连打印都没有,排查了半天才发现是预检请求被拦截了。

正确配置是这样的:

@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }

allowCredentials(true)allowedOriginPatterns("*")的组合代表允许携带凭证,且允许任意来源。如果你的项目在线上对Origin有严格要求,可以改成具体域名列表。但毕设阶段用这个配置最简单实用。

6. 前端Vue3实现:从路由守卫到文件上传的完整细节

6.1 工程结构与状态管理

前端工程我用了Vite创建,目录结构是标准的Vue3项目:

src/ ├── api/ // 接口请求封装 ├── assets/ // 静态资源 ├── components/ // 公共组件 ├── router/ // 路由配置 ├── stores/ // Pinia状态管理 ├── views/ // 页面组件 │ ├── home/ │ ├── circle/ // 研友圈 │ ├── match/ // 研友匹配 │ ├── material/ // 资料库 │ └── profile/ // 个人中心 ├── App.vue └── main.js

状态管理用了Pinia。用户登录后,把token和用户信息存到Pinia的user store里,同时持久化到localStorage。刷新页面时,在main.js里调用一个初始化方法,从localStorage恢复用户状态,这样刷新后不会丢失登录态。

Pinia比Vuex好的地方在于没有mutations的概念,直接在store里定义state和actions,代码量减少一半,新人看代码也容易懂。研友圈页面的loading状态、用户是否点赞过某条帖子这些UI状态,我都放在页面组件的ref里,不放全局store,只有跨页面共享的数据(用户信息、token、管理员状态)才放全局。

6.2 路由守卫与权限控制

前端路由分了普通用户区和管理员区。普通用户区的路径包括首页、研友圈、匹配推荐、资料库、个人中心;管理员区的路径包括资料审核、用户管理、帖子管理,统一挂在一个/admin路由下。

权限控制的实现:

  • 路由配置里给每个页面设置meta.requiresAuthmeta.role字段。
  • 路由守卫beforeEach里先判断是否需要登录,需要登录且没有token就跳转登录页。
  • 有token再判断meta.role是否为admin,是的话检查用户角色是否匹配,不匹配则跳转到403无权限页。

代码大致长这样:

router.beforeEach((to, from, next) => { const userStore = useUserStore() if (to.meta.requiresAuth && !userStore.token) { next({ path: '/login', query: { redirect: to.fullPath } }) return } if (to.meta.role === 'admin' && userStore.userInfo?.role !== 1) { next({ path: '/403' }) return } next() })

这个中间还有一个细节:路由守卫里不要动Pinia的store初始化顺序。一定要保证Pinia实例在挂载路由之前创建好,否则useUserStore()会报“no active Pinia”的错误。这个坑在项目初期遇到过一次,后来在main.js中先createPiniause(router)就解决了。

6.3 axios封装:请求拦截器与响应拦截器

不管项目规模多大,我都会封装一个统一的request工具。它的作用有三个:统一注入token、统一处理错误码、统一管理加载状态。

const service = axios.create({ baseURL: '/api', timeout: 10000 }) service.interceptors.request.use(config => { const userStore = useUserStore() if (userStore.token) { config.headers.Authorization = `Bearer ${userStore.token}` } return config }) service.interceptors.response.use( response => { const res = response.data if (res.code === 401) { ElMessage.error('登录已过期,请重新登录') userStore.logout() router.push('/login') return Promise.reject(new Error('unauthorized')) } if (res.code !== 200) { ElMessage.error(res.msg || '请求失败') return Promise.reject(new Error(res.msg)) } return res.data }, error => { ElMessage.error('网络异常,请稍后重试') return Promise.reject(error) } )

封装完成后,业务代码只需要写一行circleApi.getList({ page: 1 })来取数据,不用在每个页面重复处理错误码,代码整洁度大幅提升。这里有一个容易忽略的点:baseURL用/api而不是完整的前后端地址,这样本地开发能通过Vite的代理转发到后端,线上部署则通过Nginx的/api反向代理到后端容器,前后端完全解耦。Vite开发环境代理配置在vite.config.js里配一个server.proxy就能搞定。

6.4 文件上传:前端直传MinIO,不占用应用服务器带宽

研友圈发帖要传图片,资料上传要传PDF,这两个功能最直接的实现方式是:前端把文件POST给后端,后端再上传到MinIO。但这个方案会占用应用服务器带宽,文件一多,后端接口响应会变慢。我采用的是更高效的“预签名直传”方案:

  1. 前端点击上传按钮后,先向后端发起一个请求:POST /api/file/presigned?fileName=xxx.jpg&fileType=image/jpeg
  2. 后端收到请求后,生成一个MinIO预签名URL(带过期时间,比如5分钟),返回给前端。
  3. 前端拿到预签名URL后,用PUT方法直接把文件二进制上传到MinIO。
  4. 上传完成后,前端再把MinIO返回的文件Key(或URL)作为参数,带着这个地址去创建帖子或资料记录。

这个方案的优点是:上传过程不经过后端应用逻辑,大文件的上传速度完全取决于用户到MinIO的链路,后端服务不会因此阻塞。代码上的一个坑是:MinIO的预签名URL默认有有效期,如果用户选完文件后磨蹭半天才点击上传,URL可能已经过期。解决办法是让前端在上传动作发生前才请求预签名URL,而不是在选择文件时请求。

Element Plus的el-upload组件搭配这个方案非常顺手。我在组件上定制了http-request方法,让它走自己的预签名直传逻辑,而不是默认的formData提交。图片上传完可以直接在研友圈帖子里用el-image展示,图片列表字段在post表里存的是一个JSON数组字符串,前端解析后渲染。

6.5 自定义v-model组件:封装标签选择器

研友圈发布帖子时选话题、个人中心填写考试科目时选标签,这两个场景都用到了“标签多选”组件。如果每处都复制一遍Element Plus的el-select multiple代码,维护成本会很高,而且业务上标签来源(后端接口、本地常量)可能不一致。

所以我把标签选择器封装成了一个自定义组件TagSelect,支持v-model双向绑定。Vue3.4后提供了defineModel宏,写起来非常简洁:

<template> <el-select v-model="selected" multiple filterable allow-create placeholder="选择标签"> <el-option v-for="tag in options" :key="tag.value" :label="tag.label" :value="tag.value" /> </el-select> </template> <script setup> import { ref, watch } from 'vue' const props = defineProps({ options: { type: Array, default: () => [] } }) const selected = defineModel({ type: Array, default: () => [] }) </script>

父组件里这样用:

<TagSelect v-model="postForm.tags" :options="tagOptions" />

使用defineModel后,父组件的v-model和子组件的selected是双向绑定的,省去了手写propsemitwatch的样板代码。这个玩法在Vue2时代需要写一堆代码,现在一行搞定,展示给评委看也能证明你用了最新的Vue语法。

6.6 文件上传再加一个细节:PDF的在线预览

资料详情页我做了PDF在线预览功能,用的是前端pdf.js库。上传资料时,后端返回的fileUrl如果是MinIO上的PDF地址,前端直接把URL传给pdf.js的加载器,就能实现在线预览。这样用户下载前可以先看文件内容是否符合需求,体验比纯下载好很多。

需要注意是跨域读取PDF:MinIO本身支持CORS,但你在Bucket权限里要允许GET的匿名访问(如果文件是通过预签名URL上传的,通常Bucket是私有的,预览时需要临时的签名URL)。如果预览时PDF加载不出来,先用浏览器直接访问fileUrl看能不能打开,排查路径会清晰很多。

7. 部署上线与答辩准备:项目能跑,还要能讲

7.1 Docker Compose一键部署

项目开发完成后,我把它部署在一台4核8G的云服务器上,用Docker Compose统一管理所有服务。整套环境包含5个容器:

  • mysql:存储业务数据
  • redis:缓存验证码和热点数据
  • minio:对象存储
  • backend:SpringBoot程序
  • nginx:托管前端dist文件并做反向代理

后端Dockerfile很简单:

FROM openjdk:8-jre WORKDIR /app COPY target/knowledge-platform-1.0.0.jar app.jar EXPOSE 8081 ENTRYPOINT ["java", "-jar", "app.jar", "--spring.profiles.active=prod"]

docker-compose.yml里最关键的一点是服务依赖和重启策略:

version: '3' services: mysql: image: mysql:8.0 restart: always environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: kaoyan backend: build: . restart: always depends_on: - mysql ports: - "8081:8081"

Nginx配置中需要重点处理两点:一是location /api把请求反向代理到后端容器,二是解决Vue路由history模式刷新404的问题,用try_files实现前端路由fallback:

server { listen 80; root /usr/share/nginx/html; index index.html; location /api/ { proxy_pass http://backend:8081/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } }

部署完测试,发现一个很隐蔽的坑:MySQL 8默认认证插件是caching_sha2_password,而某些旧版本的JDBC驱动不支持,导致后端连不上数据库。解决办法是在创建用户时指定mysql_native_password,或者升级mysql-connector-java依赖。我直接升级了依赖版本,这比改数据库认证方式更合理。

7.2 演示数据怎么造得又真又好看

毕设演示最怕出现“测试帖子1”“测试帖子2”这种一看就是敷衍的数据。系统部署完,我花了几个小时准备了一套完整的演示数据,用Java的CommandLineRunner在应用启动时检查数据表,如果为空就自动插入。

帖子内容全部参考了真实的考研复习场景,比如:

  • “408四人组队打卡Day1|今天刷完二叉树遍历,明天开始图”
  • “郑州大学计算机考研经验帖:从数学二到408,我的复习时间线”
  • “求问:英语一阅读真题应该什么时候开始刷?现在二刷还是有点慌”

研友匹配的演示数据也精心设置过:我造了20个用户,分布在计算机、金融、教育学等专业,目标院校覆盖郑州大学、华中科技大学、暨南大学等,让不同组合的相似度有区分度。演示时输入一个以郑州大学计算机为目标的账号,能看到推荐列表第一名的相似度是80%,第二名60%,逻辑一目了然。

数据真实感还有一个技巧:头像用UI Avatars这类占位图服务,不同的用户昵称能自动生成不同颜色的字母头像,比全部用默认灰色人像效果好很多。帖子配图则用MinIO里预置的几张学习场景图,让研友圈列表看起来像模像样。

7.3 答辩中最容易被追问的几个点

答辩前我把评委可能问的问题过了一遍,按照“为什么选型、为什么这样设计、遇到什么问题、怎么优化”四个维度准备。高频问题大概有这么几个:

问题1:为什么用JWT而不用Session?

答案要点:前后端分离架构下,后端不维护会话状态,JWT天然无状态,适合水平扩展。同时JWT包含过期时间,便于控制登录态有效期。需要承认的缺点是JWT无法主动失效,所以我在token里只放了一个短期过期时间(2小时),配合前端401拦截实现登录过期体验。

问题2:数据库为什么要冗余计数字段,不做实时count?

答案要点:空间换时间。列表页频繁读取的计数字段,用增量更新代替count聚合,能减少90%以上的联表查询开销。同时保证数据在同一个事务中更新,一致性不会受到影响。

问题3:如果用户量变大,系统瓶颈在哪里,怎么优化?

答案要点:先从数据库层面说——热点帖子可以加Redis缓存,列表接口可以做多级缓存;再从文件存储说——MinIO替换成云OSS/CDN加速;最后说架构层面——后端可以水平扩容,用Nginx负载均衡。重点是展现你有“从单体到分布式”的演进意识,而不是真的要把项目改成微服务。

问题4:项目最大的难点是什么,怎么解决的?

这个问题一定要提前准备一个真实的技术细节来回答。我当时的回答是XSS攻击的排查和防护,完整陈述了从现象到定位再到修复的全过程。这种带着真实排查细节的回答,比夸夸其谈“实现了什么功能”更能让评委认可。

7.4 时间不够时的MVP路线

如果你现在才开始动手,时间非常紧张,我的建议是优先跑通主链路,再谈完善。MVP(最小可行产品)可以砍成四个步骤:

  • 第一周:SpringBoot + Vue3脚手架搭好,实现用户注册登录,能调通JWT。
  • 第二周:实现研友圈帖子发布和列表,MyBatis-Plus分页跑通。
  • 第三周:实现评论、点赞、收藏,把数据库冗余字段的更新逻辑写好。
  • 第四周:实现资料上传和审核闭环,然后造演示数据、部署上线、写论文。

研友匹配算法可以排到后面,但如果时间允许,一定要加。因为“考研互助”这个题目最核心的差异化功能就是匹配,没有匹配,项目就退化成了通用论坛。哪怕算法只做到了相似度推荐,也足以在答辩时撑起一个亮点。

做这种平台型毕设,我最大的体会是:需求场景决定技术设计,技术设计决定代码质量。把“考研互助”四个字落地成“找研友、找资料、圈子打卡”三个场景之后,后面所有的表结构、接口设计、页面流程都变得顺理成章。最后再分享一个小技巧:答辩前把核心的表关系图画在一张A4纸上,一旦被问到数据库设计的关联关系,直接拿笔在纸上画出来,比用嘴解释一百句都管用。

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

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

立即咨询