ARTICLE DETAIL

资讯详情

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

SpringBoot+Vue3考研互助系统:前后端分离毕业设计实战

SpringBoot+Vue3考研互助系统:前后端分离毕业设计实战 后台连着收到好几条私信问的都是同一件事“学长考研互助系统用SpringBootVue做毕业设计靠不靠谱”说真的每次看到这类问题我都得先反问一句你想做的是考研场景下的信息共享平台还是只想做个带登录功能的论坛这两个方向的代码量可能差不多但对评委来说看到的是完全不同的东西。去年我做“聚力”考研互助系统也就是“研友圈”考研信息共享平台的时候一开始就差点把项目做成一个通用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或微服务后端框架我做过对比。SSHStruts2SpringHibernate和SSMSpringSpringMVCMyBatis是很多教材里的老组合但配置繁琐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更容易讲清楚。官方生态全面转向Vue3Element 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回复AC再回复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: attachment和X-Content-Type-Options: nosniff响应头。这样既保证下载体验又防了一手XSS这点在后面避坑章节还会详细说。4. 数据库设计这几张核心表是我改了三版才定下来的4.1 表结构总览数据库设计我前后改了三版。第一版按需求文档的功能列表建表导致表极度碎片化第二版开始合并冗余字段第三版才确定了最终结构。核心表如下表名说明关键字段user用户表id, username, password, nickname, avatar, school, major, subjects, statuspost帖子表id, user_id, title, content, tags, images, view_count, like_count, comment_count, favorite_count, statuscomment评论表id, post_id, user_id, parent_id, reply_user_id, contentlike_record点赞记录表id, user_id, post_id, create_timefavorite收藏表id, user_id, post_id, create_timematerial资料表id, user_id, title, category, description, file_url, file_md5, download_count, status, reject_reasonfollow关注表id, user_id, follow_user_id, create_timeadmin管理员表id, username, password这里有几个字段需要特别解释一下。4.2 为什么帖子表要冗余点赞数、评论数、收藏数字段最直观的做法是显示帖子列表时用count语句去like_record、comment、favorite三张表分别统计数量再拼接结果。但这样有两个问题。第一列表页每显示20条帖子就要额外执行60次count查询数据量上去后性能会非常难看。第二统计代码散落在多个Service里写起来也繁琐。所以我选择了“空间换时间”的经典方案在post表里直接冗余like_count、comment_count、favorite_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_tag、JOIN 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解析异常时返回code401的JSON。前端的axios响应拦截器检测到401后清空localStorage并跳转登录页。这样前后端各管一段逻辑清晰。5.2 MyBatis-Plus分页插件不配置PaginationInnerInterceptor分页就是“假分页”MyBatis-Plus的分页插件是我被坑得最深的地方没有之一。刚上手时照着文档引入依赖后直接在Service里写PagePost page new Page(current, size); postMapper.selectPage(page, new LambdaQueryWrapperPost() .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攻击”里看到的但我自己真的踩过类似的问题。具体现象是这样的某个用户通过资料上传页面在“资料描述”字段里填写了scriptalert(xss)/script前端长度校验没有拦住因为它以为是普通文本后端也没有做任何特殊处理这个脚本就存进了数据库。后来管理员在后台打开审核列表浏览器直接弹出了alert框。我当时第一反应是前端渲染出了问题想着“el-input的v-model会自动转义怎么会执行呢”但排查后发现问题根本不在这。完整排查链路是这样的第一步先用浏览器打开资料详情接口的返回JSON发现接口直接返回了原始的scriptalert(xss)/script字符串确认数据库里存的就是原始内容。第二步用Navicat查数据库看到content字段确实是完整的script标签说明问题出在数据入库之前。前端展示时用的是v-html指令或者某个把富文本当HTML渲染的组件才导致脚本执行。第三步定位到插入逻辑。上传接口接收DTO后直接save(bean)没有任何过滤。于是问题就变成了怎么在不改Controller代码的情况下统一过滤请求参数中的危险字符。最终方案是写一个全局XssFilter继承OncePerRequestFilter重写getParameter和getInputStream把请求体中的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.pdfscriptalert(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.requiresAuth和meta.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中先createPinia再use(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。但这个方案会占用应用服务器带宽文件一多后端接口响应会变慢。我采用的是更高效的“预签名直传”方案前端点击上传按钮后先向后端发起一个请求POST /api/file/presigned?fileNamexxx.jpgfileTypeimage/jpeg。后端收到请求后生成一个MinIO预签名URL带过期时间比如5分钟返回给前端。前端拿到预签名URL后用PUT方法直接把文件二进制上传到MinIO。上传完成后前端再把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-modelselected multiple filterable allow-create placeholder选择标签 el-option v-fortag in options :keytag.value :labeltag.label :valuetag.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-modelpostForm.tags :optionstagOptions /使用defineModel后父组件的v-model和子组件的selected是双向绑定的省去了手写props、emit、watch的样板代码。这个玩法在Vue2时代需要写一堆代码现在一行搞定展示给评委看也能证明你用了最新的Vue语法。6.6 文件上传再加一个细节PDF的在线预览资料详情页我做了PDF在线预览功能用的是前端pdf.js库。上传资料时后端返回的fileUrl如果是MinIO上的PDF地址前端直接把URL传给pdf.js的加载器就能实现在线预览。这样用户下载前可以先看文件内容是否符合需求体验比纯下载好很多。需要注意是跨域读取PDFMinIO本身支持CORS但你在Bucket权限里要允许GET的匿名访问如果文件是通过预签名URL上传的通常Bucket是私有的预览时需要临时的签名URL。如果预览时PDF加载不出来先用浏览器直接访问fileUrl看能不能打开排查路径会清晰很多。7. 部署上线与答辩准备项目能跑还要能讲7.1 Docker Compose一键部署项目开发完成后我把它部署在一台4核8G的云服务器上用Docker Compose统一管理所有服务。整套环境包含5个容器mysql存储业务数据redis缓存验证码和热点数据minio对象存储backendSpringBoot程序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.activeprod]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:8081Nginx配置中需要重点处理两点一是location /api把请求反向代理到后端容器二是解决Vue路由history模式刷新404的问题用try_files实现前端路由fallbackserver { 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纸上一旦被问到数据库设计的关联关系直接拿笔在纸上画出来比用嘴解释一百句都管用。
返回列表