ARTICLE DETAIL

资讯详情

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

SpringBoot+Vue3相亲网站全栈项目实战:数据库设计、匹配算法与部署避坑指南

SpringBoot+Vue3相亲网站全栈项目实战:数据库设计、匹配算法与部署避坑指南 最近有个相亲网站的源码项目收尾了前后端分离Java SpringBootVue3MyBatisMySQL这套组合从零搭到能跑通核心业务整个过程踩坑无数但也把很多网上讲得含糊的地方彻底搞明白了。今天不聊虚的直接把这套系统的架构设计、数据库表结构、核心接口逻辑、前端实现、以及我在开发过程中趟过的那些坑全部倒给你们。如果你正想找一个能写进简历的Java全栈项目或者是打算接相亲类外包的单子再或者纯粹想看看SpringBootVue3到底怎么联调顺畅这篇都可以直接拿来当操作手册用。先说清楚这个相亲网站系统到底做了什么。用户注册登录、完善资料、条件筛选、互相心动匹配、私信聊天、开通会员解锁高级功能这六块是主业务线。另外还包含后台管理端用来审核用户资料、管理举报、统计站内数据。整个系统用前后端分离的方式开发前端Vue3负责页面渲染和交互后端SpringBoot提供Restful接口MyBatis作为ORM框架操作MySQL数据。源码结构清楚模块边界明确我尽量按真实项目的开发流程来讲而不是教科书式地堆知识点。如果你时间紧想直接跑起来用代码拿到手先看README里的数据库初始化脚本和启动说明环境装好基本就能起服务。但如果你想从原理层面吃透这套系统建议顺着我下面的思路过一遍尤其是数据库设计和匹配逻辑那两节很多人自己写着写着就卡住了。1. 相亲网站先想清楚你的核心卖点1.1 需求拆解看起来简单做起来全是细节相亲类网站和普通社交软件最大的区别在于用户目标是明确找对象而不是单纯消磨时间。所以业务上要重点解决三件事资料可信度、匹配效率、沟通意愿。三者缺一不可。最开始我和很多人一样以为做一个注册、列表、详情页就完事了真正落地的时候才发现问题一个接一个用户资料字段几十个怎么设计才能既完整又灵活用户筛选条件多接口怎么组合查询才能不写死SQL两个人互相感兴趣之后要不要有个“匹配成功”的提示会员付费之后哪些接口需要加权限消息模块是轮询还是走WebSocket这些都是开发前就必须想清楚的问题。否则代码写一半发现表结构不对返工成本非常高。所以我会建议先画业务流程图哪怕只花半个小时把核心路径梳理一遍后面写代码的顺畅程度会好很多。1.2 技术栈为什么是SpringBootVue3MyBatisMySQL网上各种新技术层出不穷为什么这套系统还是选择这些“老熟人”说白了稳定、生态全、岗位需求大对大多数中小型项目来说完全够用。SpringBoot就不用多说了内嵌Tomcat、自动配置、生态丰富写业务接口几乎零负担。Vue3的组合式API比Vue2更灵活响应式系统重写过配合Vite构建工具启动飞快。MyBatis作为半自动ORMSQL自己掌控复杂查询的优化空间很大。MySQL则是不太需要动脑的稳定选择事务、索引、全文检索都能覆盖相亲系统的常规需求。还要说明一点为什么不用MyBatis Plus不是因为不好而是这个项目的查询复杂度并没有那么高MyBatis原生XML写动态SQL完全够用还能顺便复习一下SQL功底。如果你不喜欢写XML换成MyBatis Plus也没问题接口层封装更省事。关于“前后端分离”核心价值就一句话前端和后端可以独立开发、独立部署、独立扩容。相亲网站的Web端、H5、管理后台和后端服务都可以拆开互不影响。我在项目里把前端拆成了用户端和管理端两个Vue项目后端只负责输出JSON结构很清爽。1.3 前后端分离的工程化布局实际操作中前端和后端的目录边界必须提前约定好。我这边后端用的是Maven标准结构springboot-matchmaker/ ├── src/main/java │ ├── controller/ // 控制层 │ ├── service/ // 业务逻辑层 │ ├── mapper/ // MyBatis接口层 │ ├── entity/ // 数据库实体类 │ ├── dto/ // 前端交互对象 │ ├── config/ // 配置类比如拦截器、跨域、线程池 │ ├── utils/ // 工具类比如JWT工具、日期处理 │ └── MatchmakerApplication.java ├── src/main/resources │ ├── mapper/ // MyBatis XML文件 │ ├── application.yml │ └── sql/ // 数据库初始化脚本前端用Vite初始化Vue3项目目录如下vue3-matchmaker/ ├── src │ ├── api/ // 所有接口调用层统一走request封装 │ ├── router/ // 路由配置 │ ├── stores/ // Pinia状态管理 │ ├── views/ // 页面组件 │ ├── components/ // 公共组件 │ ├── utils/ // 请求工具、本地存储管理 │ └── App.vue不要小看这套目录约定前后端分离的项目最怕“各写各的”接口路径、参数命名、错误码规范如果不统一联调阶段就是大型灾难现场。所以项目一开始我建议就定义接口文档规范/api/user/login、/api/match/list这种REST风格返回体统一为code、message、data结构所有接口都按这个来。2. 数据库设计相亲网站的“根”2.1 核心表结构拆解相亲网站的表其实不少但核心就那几张。设计原则是“把不常变的信息拆开把经常被查询的条件字段放到主表上”。我实际用到的核心表如下表名用途关键字段user用户账号表id、username、password、statususer_profile用户资料表user_id、nickname、gender、birthday、height、education、job、city、self_intro、photosmatch_record匹配记录表id、from_user_id、to_user_id、statusvip_order会员订单表id、user_id、type、pay_amount、status、create_timemessage私信表id、from_user_id、to_user_id、content、is_read、create_timereport举报表id、report_user_id、reported_user_id、reasonuser和user_profile为什么要分开因为用户登录次数多查询频繁如果把昵称、生日、职业、身高这些大字段都放同一张表每次登录都要把大对象读出来性能不值得。分开之后关联查询的时候用JOIN只取需要的字段灵活很多。还有一个经常被忽略的点照片字段的存储。我在user_profile里直接存JSON字符串数组存图片URL列表。这样做避免另建一张图片表列表页获取头像和相册时一次查出来直接用效率高代价就是后续要做图片审核时得自己解析JSON但对相亲网站这个量级完全够用。2.2 匹配记录与会员模块怎么设计匹配是相亲系统的核心动作。用户在推荐列表里对某人点“喜欢”或者“心动”对方也点了你这时候就产生一次“匹配成功”。我用的是match_record这张表状态字段用status区分0 待处理我点了喜欢对方还没反应1 匹配成功互相喜欢2 已拒绝我点了不喜欢或者对方点了不喜欢3 取消匹配匹配成功后有一方解除这张表就是用户之间关系的“账本”。查询推荐列表的时候还要把已经匹配过和已拒绝过的用户过滤掉避免反复展示同一个人。会员模块相对独立我只用了vip_order这张订单表用户下单后更新user_profile.vip_expire_time到期判断只看这个时间字段。有人说这样会丢订单数据其实不会订单表和用户可购买时间分开报表统计走订单表权限判断走时间字段各司其职。关于私信表字段简单但要注意from_user_id和to_user_id必须建联合索引而且私信列表查询通常按最近时间倒序时间和用户字段都要进索引。这个我在后面性能优化部分会详细说。2.3 MyBatis动态SQL与分页插件实战很多新人在相亲系统里写查询条件时会遇到一个问题筛选条件不稳定。比如用户可能只填性别和城市也可能填身高、学历、年龄范围还可能有多个不固定组合。这种情况直接在Mapper里写死SQL根本不现实。我用的是MyBatis的动态SQL在XML里拼装条件select idsearchMatchList resultTypecom.example.entity.UserProfileVO SELECT u.id, p.nickname, p.gender, p.birthday, p.city, p.height, p.education, p.job, p.photos, p.vip_expire_time NOW() AS is_vip FROM user_profile p INNER JOIN user u ON p.user_id u.id WHERE u.status 1 if testgender ! null and gender ! AND p.gender #{gender} /if if testcity ! null and city ! AND p.city LIKE CONCAT(%, #{city}, %) /if if testminAge ! null AND p.birthday lt; DATE_SUB(NOW(), INTERVAL #{minAge} YEAR) /if if testmaxAge ! null AND p.birthday gt; DATE_SUB(NOW(), INTERVAL #{maxAge} YEAR) /if ORDER BY p.vip_expire_time DESC, p.update_time DESC /select这块要注意一个坑if test里判断的是传入的字段名而不是数据库字段名所以约定的查询条件对象必须和前端传参完整对齐。而且年龄范围筛选我这里直接用生日反推不要在数据库里存一个“年龄”字段因为年龄每年变维护起来太麻烦。分页方面我用的PageHelper插件使用非常简单PageHelper.startPage(pageNum, pageSize); ListUserProfileVO list userProfileMapper.searchMatchList(query); PageInfoUserProfileVO pageInfo new PageInfo(list);然后在application.yml里加上配置pagehelper: helper-dialect: mysql reasonable: true support-methods-arguments: truereasonable设为true当页码超出范围时自动修正到首页或最后一页避免前端传错参数导致接口报错。PageHelper的底层原理是拦截器拦截到紧接着的MyBatis查询自动拼接LIMIT语句所以使用的时候一定要保证startPage和查询语句之间没有其他SQL操作否则分页条件会串到别的查询上。3. 后端核心逻辑接口、权限与匹配算法3.1 JWT登录与权限控制不用Spring Security也可以关于登录方案我选了JWT而不是Session因为前后端分离后Session跨域和安全问题比较麻烦JWT天然支持无状态认证放在请求头Authorization里就能校验。登录逻辑不复杂前端传用户名密码后端用MD5加盐后比对也可以加BCrypt更安全。比对成功生成tokenpayload里放userId和expireTime。生成JWT的工具类网上一大堆我贴一个常用的核心逻辑public String generateToken(Integer userId) { Date now new Date(); Date expireDate new Date(now.getTime() 24 * 3600 * 1000); return Jwts.builder() .setSubject(String.valueOf(userId)) .setExpiration(expireDate) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); }然后写一个拦截器实现HandlerInterceptor接口public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); // 解析token如果无效直接返回401 // 有效则把userId放到request attribute里供后面业务逻辑直接使用 }我在项目中并没有走Spring Security因为这个系统的角色只有普通用户和管理员两种用拦截器控制接口权限完全够用。Spring Security的学习曲线会让新手劝退而且配置不当会产生各种403、CSRF问题。真正的业务核心是匹配和用户交互权限这块能简则简把时间花在价值更高的事情上。3.2 匹配算法的落地实现相亲网站的匹配算法听上去很玄学但实际项目里不需要搞什么AI大数据推荐把常规规则做好就比市面上不少站内信靠谱多了。我实现的匹配推荐基于两点硬性条件过滤和软性权重排序。硬性条件就是用户自己设置的偏好比如性别、年龄范围、城市、学历要求这部分直接用2.3节的动态SQL过滤掉不合适的用户。软性权重则是给符合条件的用户打一个“推荐分”会员用户优先展示普通用户免费看基础推荐会员用户加权资料完整度高的人优先展示照片数量、自我介绍字数、是否认证手机最近活跃时间近的人优先展示双方条件匹配度分数比如身高、学历、收入越接近分值越高权重排序在Java代码里实现前端推荐列表接口返回的时候已经排好序了。代码逻辑大致是这样int score 0; if (profile.getVipExpireTime() ! null profile.getVipExpireTime().after(new Date())) { score 30; } if (StringUtils.isNotBlank(profile.getSelfIntro()) profile.getSelfIntro().length() 100) { score 10; } if (profile.getPhotos() ! null !profile.getPhotos().isEmpty()) { score 20; } if (System.currentTimeMillis() - profile.getUpdateTime().getTime() 7 * 24 * 3600 * 1000L) { score 15; }排序取分高的前面。这套逻辑有几个好处实现简单容易解释效果也有保证。如果后续想优化可以把评分逻辑放在SQL里用CASE WHEN实现减少Java侧计算压力。3.3 事务边界与并发控制相亲系统里最容易出现并发问题的地方就是购买会员和匹配状态修改。购买会员时用户下单后要同时更新订单表和用户的会员到期时间这两步必须在一个事务里。我用Transactional注解要注意REQUIRED传播级别是默认的内部两个Mapper方法只要异常就会回滚。Transactional(rollbackFor Exception.class) public VipOrder buyVip(Integer userId, Integer months) { // 1. 创建订单 // 2. 更新user_profile的vip_expire_time }匹配状态修改值得单独说一下。两个人“互相喜欢”这个动作和商品秒杀不一样不是并发抢一个资源所以不需要Redis分布式锁但要注意状态更新的幂等性。我在match_record表上建了唯一索引unique(from_user_id, to_user_id)并且更新状态的时候用带条件的SQLUPDATE match_record SET status 1 WHERE from_user_id #{fromUserId} AND to_user_id #{toUserId} AND status 0这样即使重复点击后面的更新也会因为状态不再是0而失败从数据库层面避免重复匹配。你说那些高端方案什么消息队列、分布式锁这个项目确实用不上但明白什么时候不用也是一种架构经验。4. Vue3前端实现从脚手架到上线4.1 项目初始化与路由守卫前端这块我用了Vite创建Vue3项目UI组件库选了Element Plus。功能页面包括推荐列表、用户详情、个人中心、私信会话、会员中心、登录注册页、以及管理后台的页面。路由配置时核心是登录状态的判断。我用router.beforeEach来加守卫const whiteList [/login, /register]; router.beforeEach((to, from, next) { if (localStorage.getItem(token)) { if (to.path /login) { next(/home); } else { next(); } } else { if (whiteList.includes(to.path)) { next(); } else { next(/login); } } });简洁明了。注意localStorage存储token之后还要和Pinia里的用户信息同步不然刷新页面后用户状态会丢失。我的做法是应用启动时先解析token拿userId调一次getUserInfo接口再把用户数据塞进Pinia保证刷新后不白屏。4.2 组件通信与Pinia状态管理Vue3里父子组件通信很简单props下发、emit上报。但相亲系统的用户状态在多个页面都要用比如头像、昵称、VIP状态所以要集中管理。我用Pinia写法如下export const useUserStore defineStore(user, { state: () ({ userId: null, nickname: , avatar: , isVip: false }), actions: { setUserInfo(data) { this.userId data.userId; this.nickname data.nickname; this.avatar data.avatar; this.isVip data.isVip; } } });页面里直接调用useUserStore()就能拿到或者修改用户状态比Vuex更简洁也天然支持setup语法。另外组件通信里最容易踩的坑是修改对象的引用问题。Vue3的reactive在直接替换整个对象时失去了响应式要用Object.assign。比如用户资料详情页拿接口数据时const profile reactive({}); Object.assign(profile, res.data);而不是profile res.data。这个细节面试也常出现。4.3 接口层封装与错误拦截前后端分离的日子里axios封装是标配。我的做法是创建utils/request.jsconst request axios.create({ baseURL: /api, timeout: 10000 }); request.interceptors.request.use(config { config.headers[Authorization] localStorage.getItem(token); return config; }); request.interceptors.response.use( response { const res response.data; if (res.code ! 200) { ElMessage.error(res.message); if (res.code 401) { localStorage.removeItem(token); router.push(/login); } return Promise.reject(new Error(res.message)); } return res; }, error { ElMessage.error(网络请求失败请检查服务端是否启动); return Promise.reject(error); } );注意开发环境的跨域问题。前端跑在localhost:5173后端跑在localhost:8080我在Vite配置里加了代理解决避免多次配置CORS头// vite.config.js server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }后端也要开放跨域配置我写了一个简单的WebMvcConfigurer允许所有来源访问接口但是限定在开发环境。生产环境可以通过Nginx反代剥离跨域这个后面说。4.4 关键页面与交互逻辑推荐列表页是用户端最重要的页面。我实现了无限滚动加载不用传统的分页按钮div v-infinite-scrollloadMore :infinite-scroll-disabledloading UserCard v-foritem in list :keyitem.id :infoitem / /divconst loadMore () { if (finished) return; pageNum; getUserList().then(res { list.push(...res.data.list); if (res.data.isLastPage) finished true; }); };这里要注意几个问题重复触发请求的防抖、列表项唯一key、滑动时的图片懒加载。Element Plus自带的el-image内置lazy模式直接用就行。用户详情页里面我放了几个按钮心动、不喜欢、私信。相位判断逻辑是点喜欢时如果发现对方之前已经喜欢过我直接把匹配状态改为1并弹窗提示“匹配成功快去打招呼吧”同时给双方推送一条混合通知。这个交互很带感实际写代码也就是查一下match_record里的反向记录。5. 实战踩坑与性能调优5.1 MyBatis缓存导致的数据不一致这块是获取信息好长一段时间的坑。MyBatis有二级缓存虽然默认关闭但我在某些XML里显式开启过结果用户修改昵称、头像之后推荐列表还是显示旧数据。排查好久才发现是二级缓存刷新的问题。如果你在项目里开了二级缓存必须要理解缓存刷新的粒度insert/update/delete操作会清空这个namespace下的缓存但是两个不同的Mapper如果有联表查询其中一个更新后另一个查询结果不会自动刷新。我的建议是这种以用户为中心的数据交互项目直接关闭二级缓存依赖MySQL本身的行缓存和前端状态管理。想要更好的读性能自己去弄Redis缓存可控性更高。在一段实践里关闭二级缓存之后数据的强一致性保证会比以前稳得多。5.2 跨域与前端联调乱象前后端联调时最经典的问题就是CORS报错。你以为后端加了CrossOrigin就完事实际上还要处理OPTIONS预检请求以及addCorsMappings时正确配置allowedOriginPatterns。我建议前端开发用Vite代理解决跨域生产环境用Nginx把/api转到后端服务这样前后端都不需要关心跨域头的问题。Nginx配置片段如下location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }还要设置一些常规的超时参数防止上传大图时Nginx默认超时断开。这个项目里用户照片上传接口经常被超时问题困扰最后把client_max_body_size调成了10mproxy_read_timeout调到60s问题才消停。5.3 大列表性能优化实录相亲系统到后面难免会出现几个“热门城市”大量用户被重复查询的情况。从MySQL的角度我看手头这个项目最明显的瓶颈是私信列表和推荐列表。私信列表的查询条件基本都是to_user_id ? ORDER BY create_time DESC所以我在message表上建了(to_user_id, create_time)联合索引。索引不是越多越好但针对这种高频查询的复合索引是必须要加的。推荐列表方面如果用户量到了几十万LIKE %城市%这种模糊查询也可以加索引或者用全文索引但在人不多的时候完全没必要过度设计。真到了很大的量级就应该把匹配筛选条件放到Elasticsearch里做倒排索引了。目前这个项目用MySQL还算合适真上线到几十万用户的时候再谈迁移分布式方案也不迟。除了索引我在接口层面还做了响应数据的精简。比如推荐列表接口我只返回用户id、昵称、头像、年龄、城市、性格标签这十几个字段绝不在列表接口里返回用户完整自我介绍和所有照片。详情接口再返回完整信息这样列表接口的传输体积就小很多渲染速度也更快。5.4 数据安全与隐私保护相亲系统涉及极为敏感的个人信息说几句我的实践经验。用户表的密码绝对不存明文MD5加盐是最低要求实际建议用BCrypt加密。调用数据库的账号权限分离普通业务账号不能有DDL权限。传输方面全站走HTTPS这个不用我多说了不然密码和加密后的私信内容在中间层裸奔出了事谁也担不起。我在这套系统里还给用户隐私做了“模糊展示”机制非会员用户查看对方联系方式时只显示尾号或者隐藏中间号以此为抓手引导用户付费开通VIP。这个既是商业模式也是隐私保护手段一次搞定两件事。6. 源码使用建议与项目扩展思路前面都是技术细节最后这部分是我真正想对拿着这套源码去用的人说的。第一项目跑起来不等于会了。如果你拿它去面试至少要把数据库设计的思路、匹配算法的选择、缓存和事务的使用给面试官讲明白。很多候选人简历里写着“精通SpringBoot”一问“你项目里怎么处理事务传播行为”就哑火这一看就是网上拉来的项目模板。建议你把核心模块自己重新敲一遍比如匹配算法你简单改个排序逻辑然后推导它的时间复杂度这带来的理解深度远远超过CtrlC、CtrlV。第二这套系统的扩展空间很大。你可以顺手给它加上Redis缓存把热门用户的推荐列表缓存起来同时用Redis的Set结构处理每天的心动次数限制也可以引入WebSocket把私信模块从轮询升级成实时聊天还可以对接第三方实名认证让用户可信度模型更完善。这些方向自己动手改一改整个项目的含金量立刻上一个台阶。第三如果真的想用这套系统做商业运营别只盯着程序本身。相亲业务本质上是一个强运营、强审核、强信任的活。用户真实性审核、内容安全过滤、会员定价策略这些功夫都在代码之外。技术代码只是地基你需要在这个地基上盖什么楼想清楚再动手。我个人的体会是这种带完整业务闭环的系统比你在GitHub上复制一百个商品管理系统都要练人。因为它逼着你考虑用户与用户之间的关系逼着你处理复杂查询和权限控制逼着你想清楚性能和安全。这些才是Java后端工程师真正值钱的地方。以上。有问题欢迎评论交流我会挑典型问题后续继续细写。
返回列表