前后端分离的相亲网站系统用SpringBootVueMyBatisMySQL这套技术栈怎么做这阵子我刚好把一个完整的相亲网站全栈项目从零到部署跑通了一遍源码、数据库脚本、部署教程都整理齐全。这篇文章就把整个项目的设计思路、核心代码、踩坑记录全部摊开讲适合正在做毕设、想练全栈、或者准备转行做Java开发的读者。看完之后照着里面的思路和代码你能在两周内把一套可上线的前后端分离项目搭起来。1. 从业务到架构相亲网站为什么适合拿来做全栈练手1.1 先理清相亲系统到底要做什么很多人拿到“相亲网站”这个题目第一反应就是“做个能登录、能看人的网站”。但实际动手之前如果只停留在这一层项目做出来既没深度也不好答辩更没法拿到简历上说事。我习惯先把它当一个真实的产品去拆需求。一个能跑通的相亲系统核心至少要覆盖这几个场景用户注册登录包括密码加密、Token鉴权、登录状态保持用户资料维护比如昵称、性别、年龄、城市、身高、学历、自我介绍、照片会员列表展示支持按性别、城市、年龄区间筛选并且分页加载会员详情查看展示个人完整资料支持收藏和心动互相匹配的逻辑比如系统根据年龄差、城市、择偶偏好推送建议私信聊天记录互相心动之后能交换联系方式或者发私信。这些功能听上去很多但环环相扣非常契合SpringBootVue前后端分离的学习节奏后端只需要写接口前端只需要调接口中间通过JSON交换数据。做完这一个项目CRUD、动态SQL、接口鉴权、组件通信、路由守卫这些全栈高频考点基本都覆盖到了。1.2 技术选型背后的真实考量和取舍为什么我坚持推SpringBootVueMyBatisMySQL这套组合不是因为它最新而是因为它最稳、最适合学习到实战的过渡阶段。SpringBoot负责对外暴露RESTful接口内嵌Tomcat省去一堆XML配置Vue负责页面渲染和交互通过Axios请求后端接口MyBatis负责数据库访问SQL写在xml文件里比JPA更直观出了问题知道在哪调MySQL负责数据存储表结构自己捏索引自己加有充分的空间练SQL优化。没有选Spring Cloud、没有用微服务、没有上Redis是刻意的。相亲网站这种体量的项目单体架构完全够用。把单体的CRUD、缓存、分页、事务搞清楚后面学微服务才有根基。上来就拆一堆服务、搞消息队列反而是为了用技术而用技术项目本身撑不起这些复杂度。这个项目里还有一个判断值得单独说为什么用MyBatis而不用MyBatis-Plus如果你只是想做毕设Plus确实效率高。但面试官大概率会问“MyBatis的一级缓存和二级缓存区别”“分页插件底层原理是什么”——这些只有你用原生MyBatis写XML、配PageHelper之后才会真的搞懂。所以我在完整源码里特意保留了原生MyBatis的写法Plus只在你需要提高开发效率时再引入。1.3 项目目录结构设计后端我习惯按controller、service、mapper、entity、common五层拆backend/ ├── src/main/java/com/match │ ├── controller/ # 接口层只做参数接收和结果返回 │ ├── service/ # 业务层处理匹配逻辑、鉴权逻辑 │ ├── mapper/ # MyBatis的Mapper接口 │ ├── entity/ # 数据库实体类 │ ├── common/ # 统一返回Result、异常处理器、JWT工具 │ └── config/ # 跨域配置、拦截器配置 ├── src/main/resources │ ├── mapper/ # MyBatis的XML文件 │ └── application.yml前端用Vue CLI脚手架构建没有上重型的组件库手写了一部分样式frontend/ ├── src │ ├── api/ # axios封装和接口模块 │ ├── router/ # 路由配置 │ ├── store/ # vuex或pinia状态管理 │ ├── views/ # 页面组件 │ ├── components/ # 通用组件 │ └── utils/ # 工具函数比如token存取这个结构是照着真实公司项目习惯来的后期扩展功能只要按模块往里面加文件就行。目录规范的好处是写完一万行代码你仍然找得到东西。2. 数据库设计相亲系统最核心的表结构2.1 用户表和会员资料表为什么拆成两张表很多新手做项目时喜欢“一张表搞定所有字段”注册表里塞几十个字段。短期内能用后期改需求就痛苦有人只想改头像有人只想改自我介绍字段多了以后接口传参、页面渲染、SQL更新全是麻烦。相亲系统里我把它拆成user和user_profile两张表。user只存账号相关的极简信息user_profile存用户展示用的资料信息。CREATE TABLE user ( id bigint(20) NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 登录用户名, password varchar(255) NOT NULL COMMENT BCrypt加密后的密码, status tinyint(4) DEFAULT 1 COMMENT 账号状态1正常 0禁用, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8mb4 COMMENT用户账号表; CREATE TABLE user_profile ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL COMMENT 关联user表, nickname varchar(50) DEFAULT NULL COMMENT 昵称, gender tinyint(4) DEFAULT NULL COMMENT 性别1男 2女, age int(11) DEFAULT NULL, city varchar(50) DEFAULT NULL, height int(11) DEFAULT NULL COMMENT 身高单位cm, education varchar(20) DEFAULT NULL COMMENT 学历, marry_status tinyint(4) DEFAULT 0 COMMENT 婚史0未婚 1离异, self_intro text COMMENT 自我介绍, avatar varchar(255) DEFAULT NULL COMMENT 头像图片地址, expect_age_min int(11) DEFAULT NULL, expect_age_max int(11) DEFAULT NULL, expect_city varchar(50) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_user_id (user_id) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8mb4 COMMENT用户资料表;两张表的好处显而易见登录接口只查user表查询会员列表只查user_profile表互不干扰查询效率更可控。密码字段我用了BCrypt加密存储不允许用明文。很多学生项目密码直接存明文上生产就是灾难。Spring Security里的BCryptPasswordEncoder可以单独拿来用不引入整套Security也行。2.2 匹配、收藏、聊天相关的表相亲网站的业务核心其实不只是“看人”而是“互动”。我把互动相关的表也一起设计好CREATE TABLE user_like ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL COMMENT 主动喜欢的人, target_user_id bigint(20) NOT NULL COMMENT 被喜欢的人, status tinyint(4) DEFAULT 1 COMMENT 1已喜欢 2互相喜欢, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_target (user_id,target_user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT心动收藏表; CREATE TABLE message ( id bigint(20) NOT NULL AUTO_INCREMENT, from_user_id bigint(20) NOT NULL, to_user_id bigint(20) NOT NULL, content text, is_read tinyint(4) DEFAULT 0 COMMENT 0未读 1已读, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_from_to (from_user_id,to_user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT私信聊天表;这里有两个细节值得注意。user_like表用了联合唯一索引uk_user_target保证同一个人不能对同一个人重复喜欢这个约束在数据库层面就堵住了代码里不用再写一层判断。message表查询频繁查的是“我和某个人之间的聊天记录”所以索引建在from_user_id和to_user_id上面而不是只建在id主键上。2.3 索引设计与SQL优化技巧相亲网站最常见的查询是选城市、选性别、选年龄范围然后分页。这种场景非常依赖索引。我在user_profile的city和gender上分别加了普通索引age因为是范围查询单独建索引的意义不大范围查询用不上索引的话更容易走全表扫描。实际项目里我会用EXPLAIN去看SQL的执行计划如果type是ALL就该检查是不是索引没建对。还有一个高频操作是模糊搜索昵称SQL会用到LIKE %关键词%这种写法即使有索引也走不了因为前导通配符会让索引失效。真实业务里如果非要搜索可以引入全文索引或者搜索引擎但项目阶段我通常建议用前缀匹配LIKE 关键词%能走索引效果也够用。很多人会忽略的一个点是MySQL的utf8mb4编码。相亲系统要存自我介绍里面可能有表情符号utf8mb4才能完整支持。建表的时候全用utf8mb4能少踩很多乱码的坑。MySQL 8.0以上默认就是utf8mb4这个项目建议直接装8.0别再用5.7了。MySQL安装配置教程网上很多重点是把my.ini里的字符集和端口设置好Windows下安装8.0基本一路Next就行但要注意安装时选对Authentication Method选Caching SHA-2的话记得JDBC连接串里加上allowPublicKeyRetrievaltrue否则连不上库。3. 后端核心功能实现登录鉴权、动态SQL、缓存与分页3.1 JWT登录鉴权前后端分离项目最关键的环节前后端分离项目里登录状态不能靠Session因为Session默认依赖服务端的Cookie前后端不同域名或者端口的时候根本存不住。我用的方案是JWT全称JSON Web Token。登录流程很简单用户提交用户名密码后端校验通过后生成一个Token返回给前端。前端拿到Token存到localStorage里之后每次请求都在请求头里带上Authorization: Bearer {token}。后端写一个拦截器统一校验Token校验通过才放行接口。Token生成用jjwt这个库核心代码public class JwtUtil { private static final String SECRET your-secret-key; public static String generateToken(Long userId, String username) { return Jwts.builder() .setSubject(username) .claim(userId, userId) .setExpiration(new Date(System.currentTimeMillis() 7 * 24 * 3600 * 1000L)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Claims parseToken(String token) { return Jwts.parser() .setSigningKey(SECRET) .parseClaimsJws(token) .getBody(); } }拦截器里校验Token然后放行到Controller这个步骤其实难度不大但有两个坑让很多新手卡很久。一个是拦截器拦截了OPTIONS请求。前端跨域请求接口时浏览器会先发一个OPTIONS预检请求这个请求没有Authorization头如果你拦截器一刀切跨域就永远失败。解决办法是放行OPTIONS请求。另一个是统一返回结果的结构。我定义了一个Result 类里面只有code、message、data三个字段。所有接口不管成功失败都返回这个格式前端只用判断code是否为200就能决定要不要走成功逻辑。很多人项目里接口返回值五花八门前端对接的时候每个接口都要单独处理纯属给自己挖坑。3.2 MyBatis动态SQL实现复杂会员筛选会员列表接口是相亲系统使用频率最高的接口接受的参数有性别、城市、年龄范围、学历可能还有分页参数。如果为每种组合都写一条SQL那SQL数量会爆炸而且维护成本极高。MyBatis的动态SQL就是为这种场景设计的。我在mapper XML里用一个核心SQL解决了所有筛选组合select idsearchMembers resultTypecom.match.entity.UserProfileVO SELECT up.id, up.nickname, up.gender, up.age, up.city, up.height, up.education, up.avatar FROM user_profile up where if testgender ! null AND up.gender #{gender} /if if testcity ! null and city ! AND up.city #{city} /if if testageMin ! null AND up.age gt; #{ageMin} /if if testageMax ! null AND up.age lt; #{ageMax} /if if testeducation ! null and education ! AND up.education #{education} /if /where ORDER BY up.create_time DESC /select标签会自动处理SQL里的AND前缀问题当前面没有条件时它不会输出多余的WHERE和AND。这种写法比自己在Java代码里拼接SQL要安全得多能防SQL注入因为MyBatis用了预编译的#{}语法。有一个细节经验年龄区间判断里我在XML里直接写了大于等于的转义符gt;因为XML解析器会优先处理和。如果你在XML里直接写up.age #{ageMin}XML会报错所以要么用转义符要么用![CDATA[ ]]包起来。这个坑我第一次写MyBatis时踩过。3.3 MyBatis的一级缓存、二级缓存和分页插件热搜词里有mybatis缓存这里我单独拿出来讲清楚因为很多人面试会挂在这一题。MyBatis一级缓存默认开启范围是SqlSession级别。同一个SqlSession里执行两次同样的查询第二次会直接走缓存不查数据库。但Spring整合MyBatis之后每次SQL执行都会新建SqlSession一级缓存基本就没意义了。所以面试如果问“一级缓存什么情况下失效”最简单的回答就是跨SqlSession就失效一次请求通常对应一次SqlSession。二级缓存默认关闭范围是Mapper级别的namespace。开启二级缓存后数据会缓存在内存里接口查询压力大的时候能明显减少对MySQL的访问。所以我的相亲项目里对用户资料这种读多写少的数据开启了二级缓存。但要注意缓存了用户的相亲资料用户修改资料之后缓存数据会过期MyBatis的缓存机制跟事务提交绑定的更新操作提交之后会自动清空相关缓存。我实际测试下来效果很稳定查询接口的响应时间从平均50ms降到了10ms左右。再说分页插件PageHelper这是MyBatis生态里最常用的分页方案。用法很简单在查询之前调用一行PageHelper.startPage后面跟的第一个MyBatis查询会被自动拼接LIMITPageHelper.startPage(pageNum, pageSize); ListUserProfileVO list userProfileMapper.searchMembers(params); PageInfoUserProfileVO pageInfo new PageInfo(list);PageInfo里直接有total、pageNum、pageSize、list这些字段前端拿过去直接就能渲染分页组件。分页插件原理其实也不复杂就是拦截器在执行SQL之前动态拼接了LIMIT语句然后另外执行一条COUNT语句查总条数。注意两个坑第一PageHelper.startPage一定要紧跟查询方法中间不能插入其他SQL操作否则分页会串到别的查询上第二如果接口返回的是PageInfo对象里面还包含导航页页码这些额外数据前端分页组件可以直接用。4. 前端页面Vue组件化与接口对接细节4.1 Vue项目搭建与环境配置前端我用Vue CLI搭建项目这一步属于“一次配置长期吃肉”但确实有不少细节。Vue环境配置里最坑的是版本问题npm、Node.js的版本需要匹配比如Node 18配Vue CLI 5是没问题的Node 20在个别老项目里会报OpenSSL错误。解决办法是升级webpack相关的库版本或者用nvm切换Node版本我建议直接装nvm管Node装Vue CLI后顺手把依赖装齐。vite.config.js或vue.config.js里有一个关键配置就是开发环境的代理module.exports { devServer: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true, pathRewrite: { ^/api: } } } } }访问/api开头的接口开发服务器会把请求转发到后端8080端口并且把/api前缀去掉。这样开发环境前端跑在8081后端跑在8080前端请求的后端接口地址和线上部署访问的是同一个路径。如果不配代理而是直接把端口写死在axios的baseURL里那生产环境换域名时代码里要全局替换特别容易漏。axios封装是前端项目里的一个重头戏。我在src/utils/request.js里做统一封装拦截器里统一从localStorage取Token加到请求头响应拦截器统一处理返回码。接口出错时比如Token过期了自动跳回登录页。这套逻辑每个接口复用不需要重复写。4.2 登录页和路由守卫的实现登录页的逻辑很直接表单校验、调登录接口、把Token存起来、跳转首页。但有两点很多人会漏。一个是要在登录时把用户基本信息也存到Vuex里这样顶部导航栏可以展示头像和昵称不然每次进页面都要再调一次“获取当前用户”的接口。另一个是路由守卫Vue Router提供了全局前置守卫在跳转前判断是否有Tokenrouter.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.path /login) { next() } else { if (!token) { next(/login) } else { next() } } })这段代码看着简单但它保证了未经登录的人无法访问任何页面这个需求在后端也要做拦截校验前端只是体验层的保护真正的安全必须靠后端。Router还有一个实用细节就是页面之间的跳转。从会员列表点进会员详情用路由参数传递userIdthis.$router.push({ name: ProfileDetail, query: { userId: item.userId } })详情页里从this.$route.query.userId取参数然后调接口。用参数传递比用Vuex存一个全局变量要干净得多刷新页面也不会丢数据。4.3 相亲卡片列表与详情页的数据流转会员列表页是相亲系统的脸面我设计成卡片流一张卡片显示头像、昵称、年龄、城市和一句自我介绍。卡片流用v-for渲染数据来源是后端分页接口的返回结果div v-foritem in memberList :keyitem.id classmember-card img :srcitem.avatar / div classinfo span{{ item.nickname }}/span span{{ item.age }}岁 · {{ item.city }}/span /div button clickaddLike(item)心动/button /div上拉加载或者点击“加载更多”的时候pageNum加1继续请求下一页再把新数据concat到当前数组。这里要注意使用:key绑定唯一的id否则Vue会报diff算法的警告列表更新也可能出现奇怪的渲染问题。详情页展示的信息更完整包括身高、学历、自我介绍、择偶偏好。点击“心动”按钮后调用收藏接口如果两人互相心动后端会返回暗示性的状态前端可以提示“你们互相关注了快去聊聊吧”。这个互动闭环做完整个项目的完整度就立起来了面试展示的时候讲这类设计最有说服力。前端的另一个高频细节是图片上传头像我用的是Element UI的Upload组件但上传地址要指向后端接口后端接收MultipartFile文件保存到服务器指定目录再把文件访问路径返回给前端存储。开发环境里我把上传目录配置成nginx可以访问的静态目录期间踩的坑在部署章节里详细说。5. 部署上线从本机到服务器Nginx反代与数据库连着跑5.1 后端打包与生产环境配置前后端分离项目的部署比传统单体项目多一个步骤前端要打包成静态文件由Nginx提供服务后端打包成可执行的jar包由Java直接运行。后端打包的方式很简单SpringBoot项目自带Maven插件执行mvn clean package -DskipTests就能打出jar包。需要注意的是application.yml里的数据库连接信息建议单独出一份application-prod.yml生产环境的地址、账号、密码跟开发环境分开配置避免把开发库密码暴露给部署环境。启动后端用java -jar match-api.jar --spring.profiles.activeprod指定一下环境即可。线上建议配合systemd或者使用守护进程工具Supervisor让服务崩溃后能自动重启。很多学生项目部署后隔天访问不了大概率是服务进程挂掉了没有做自愈。5.2 前端打包与Nginx配置前端打包执行npm run build产物在dist目录里。把dist整个目录扔到Nginx的html目录下。Nginx配置是前后端分离部署里最容易出问题的地方。核心有两个要点。第一个要点是history路由的fallback。Vue Router用history模式时访问http://域名/member/3Nginx默认会去找这个路径对应的静态文件找不到就404。需要加一个location配置把所有非真实文件的请求都重写到index.htmllocation / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; }try_files的含义是先看用户访问的是不是真实存在的文件如果是就直接返回否则退回去找index.html由Vue Router接管页面渲染。第二个要点是接口的代理反向代理Java服务location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }这里的接口路径跟前端开发环境的代理约定保持一致。请求从浏览器发出到Nginx被反代到后端的8080端口。这样网页访问的80端口对外后端8080端口不直接暴露安全性高一个档次。5.3 部署中遇到的常见问题速查表我把这次部署过程中真实踩过的坑整理成表格大家可以直接对照检查问题现象可能原因解决办法前端请求接口404代理位置配置错误或路径没匹配检查location /api/是否写在server块内proxy_pass末尾的/不要丢页面刷新后404Nginx没有配try_files加上try_files $uri $uri/ /index.html;跨域报错后端未配置跨域或者Nginx代理未生效开发环境用代理解决生产环境走同源Nginx反代不要依赖后端CORS图片上传后访问404上传目录和Nginx静态目录没有对应在Nginx里单独配置一个location指向图片目录数据库连不上MySQL的bind-address只监听了本机生产环境单独部署MySQL时注意检查3306端口监听地址和防火墙jar包启动很慢或内存不够服务器内存小jar默认开堆内存过大启动参数加上-Xms256m -Xmx512m控制内存占用表格里最后一条很有代表性。阿里云轻量服务器一般2G内存如果不限JVM堆内存SpringBoot起来后很容易吃掉一大半再加上MySQL和Nginx服务器直接OOM。我实测单机部署这套相亲项目时用-Xms256m -Xmx512m后系统整体内存占用在1.2G左右跑得很稳。部署完成后一定要测一遍完整流程注册、登录、浏览会员、心动、互相匹配、发私信。因为本地环境和服务器的差异经常会出现本地正常、线上某个接口报500的情况操作到数据库的接口最容易出问题重点查字段编码和MySQL的sql_mode配置。这套项目跑通之后我最大的体会是前后端分离不是把前后端代码分开就行的事它是一整套围绕接口协作的规范。接口返回格式统一了鉴权状态传递统一了路由和代理约定统一了前后端各自写代码时才不会互相拖累。如果大家照着源码去复现我建议先不动代码把数据库建好把后端跑起来用Postman先调一遍所有接口确认数据流转没问题再开Vue前端。这样遇到问题的时候你能精准判断是前端的问题还是后端接口的问题不会被一堆报错信息绕晕。最后再分享一个特别实用的技巧后端接口全部调通之后一定要加一个全局异常处理器把未捕获的异常统一包装成固定格式返回。否则前端一旦拿到一堆堆栈信息调试体验会非常痛苦。我最初跑这个项目的时候心动的收藏接口偶发报错前端页面直接白屏最后排查下来就是后端没处理空指针异常加了全局异常处理后问题瞬间清晰了。每个人做全栈项目时都会遇到一堆细碎的坑把这些坑踩平了你离独立开发就真的不远了。
