
1. 这个相亲网站系统解决了什么问题先聊聊做它的初衷先说个背景这段时间很多做全栈的朋友在问手里有没有那种“能完整跑起来、从数据库到前端页面都有、不是阉割版”的项目方便学完SpringBoot和Vue3之后把整个前后端链路串起来。我手上这个相亲网站系统就是这样一个标准的全栈实战项目技术栈是Java SpringBoot Vue3 MyBatis MySQL前后端分离数据库用 MySQL整套源码是可以直接拿去部署运行的。相亲网站这个业务选得非常有意思它的信息管理需求覆盖了 CRUD、多条件检索、复杂表关联、用户状态流转、图片上传、消息通知、权限控制几乎把 Web 开发里日常要用的东西全过了一遍。一个完整的相亲平台至少要处理注册登录、资料完善、推荐匹配、心动互动、会员服务、后台审核这几条主线业务每条线做下来都能遇到真实的工程问题。很多人在学完框架基础之后会卡在一个地方看了一堆教程知道注解怎么用、脚手架怎么搭但一到动手做项目就不知道从哪开始表怎么建、接口怎么设计、页面和接口怎么对接全是模糊的。这个项目的价值就在于它是一个完整的闭环——从用户打开页面注册账号到完善择偶资料到系统按标签条件推荐潜在对象再到双方互相心动后开始聊天每一步都有对应的数据表和接口支撑。你跟着源码走一遍等于把 SpringBoot 的后端分层、MyBatis 的持久层映射、Vue3 的组件化开发全部实操了一遍。文章里我会按真实的开发顺序来讲核心模块拆解、数据库模型设计、匹配功能怎么实现、前后端联调有哪些坑、合理规避一些常见安全风险。适合的读者分两类一类是 Java 后端想补 Vue3 前端知识另一类是前端想搞懂后端接口是怎么设计出来的。两个人协作角色互换看一个项目收获会更大。2. 技术选型回顾为什么这套组合会成为主流搭配2.1 四位主角的分工先看这张技术分工表理清每个组件在系统里承担什么职责技术组件角色定位在相亲系统中承担的核心职责SpringBoot后端服务框架提供 RESTful 接口、业务逻辑处理、事务管理、定时任务Vue3前端框架页面渲染、表单校验、状态管理、路由控制、组件复用MyBatis持久层框架操作 MySQL 的数据读写、动态 SQL 处理复杂查询条件MySQL数据库存储用户资料、标签、匹配记录、消息记录等核心业务数据2.2 为什么坚持前后端分离前后端分离这几年已经成为 Web 项目的绝对主流原因很现实相亲平台的业务迭代速度很快产品和运营隔三差五就要调页面交互、加推荐策略如果前后端耦合在一起每次小改动都要重新走一遍完整发布流程非常浪费时间。前后端分离之后后端只需要把标准化的 JSON 接口给到前端前端根据自己的节奏发版迭代两边互不阻塞。特别是相亲业务里涉及大量异步操作——比如用户上传照片后需要触发平台审核审核结果通过 WebSocket 或者轮询通知前端这些场景在分离架构下处理起来逻辑更清晰后端只负责业务处理和数据落地前端只关注交互反馈。而且这套系统本身逻辑够复杂功能点密集用 Vue3 可以让不同模块的组件高度复用开发者上手之后能快速定位需要修改的代码位置。还有一个实际体验招聘市场上前后端分离经验几乎是标配做完这个项目再出去沟通聊到项目经验时你能说清楚接口是怎么定义、如何部署联调的这比只做过单体页面项目要有说服力得多。2.3 选 MyBatis 而不是 JPA 的理由很多朋友问我为什么用 MyBatis 而不是 Spring Data JPA。相亲平台这种业务数据查询条件极其多变用户搜索要按城市、年龄范围、学历、收入段、身高段、是否有车房、兴趣爱好等多个维度自由组合筛选。如果用 JPA面对这种动态组合查询就得写 Specification 或者拼接方法名方法和字段一多可读性会明显下降调试也很头疼。MyBatis 的强项就在这里通过 XML 里的动态 SQL 标签where、if、foreach按条件片段拼装 SQL逻辑直观写完直接能预判 SQL 长什么样。比如用户筛选匹配对象时不同用户勾选的标签组合完全不一样动态 SQL 几行就能搞定而且输出上可以直接看到 SQL 日志排查性能问题也方便。同时 MyBatis 保留了 SQL 的灵活度团队里 DBA 出身的人看着 XML 也能快速给出优化建议。对于相亲平台这种 SQL 复杂度高的业务把 SQL 保持看得见摸得着的状态远比自己猜框架生成器如何生成 SQL 靠谱。后面我会发一个实际匹配查询的 SQL 片段你看完就会明白我的意思。2.4 版本选择与踩坑提醒开发时要注意版本匹配问题网上其实很多奇怪报错都出在版本不对齐上SpringBoot 使用2.7.x版本稳定且生态成熟。如果不要求 JDK172.7 JDK8 是最省心的搭配。Vue3 配合Element Plus做后台和用户端界面组件丰富遇到问题很容易搜到解决方案。MyBatis Spring Boot Starter 使用2.3.x。数据库使用 MySQL8.0注意驱动依赖要换成com.mysql.cj.jdbc.Driver否则启动就会报时区或驱动类错误。提示SpringBoot 3.x 对 JDK 和 Jakarta 命名空间有要求本身没有太复杂但第三方插件生态还在适配期实际项目里用 2.7 往往更省力。如果你是学新东西并希望长期维护斟酌最新版本之前务必先验证要用到的中间件是否支持。3. 数据库模型设计相亲网站的核心表结构和搭建逻辑做相亲平台数据库设计可以说是整个项目的地基后端的推荐功能、匹配效率、运营报表全部依赖表结构是否合理。我在设计这套系统的时候核心思路是把用户的“基础身份信息”“择偶条件”“互动行为轨迹”分开建模互不干扰又通过外键关联。3.1 用户相关核心表项目里最重要的几张表如下user_account账号表保存手机号、登录密码加盐哈希存储、角色类型普通用户/管理员、账号状态。登录认证只碰这张表避免每次查询都拖上大字段资料。user_profile用户资料表保存真实姓名、性别、出生日期、身高、学历、职业、收入、所在城市、自我介绍、择偶要求等个人描述性信息。和账号表分开是为了让认证和资料管理职责单一以后做用户隐私的分级授权也好实现。user_tags用户标签表一张多对多关联表把用户和标签关联标签用来记录性格特点、兴趣爱好、生活习惯等另一张tag_dict保存标签字典比如“运动健身”“看书阅读”“旅行探店”等。user_photo照片表一个用户可能传多张生活照包含封面标识、审核状态、排序号。照片审核是相亲平台的硬需求必须有独立的表支撑审核流程。3.2 互动行为相关核心表用户之间的心动、匹配、聊天都有对应的表来支撑业务状态流转user_like心动表记录“谁喜欢了谁”包含用户ID、目标用户ID、是否互相喜欢、创建时间。user_match匹配表当双方互相心动时写入一条匹配记录并触发一条系统通知告诉双方你们互相心动了可以开始聊天。chat_message消息表保存用户之间的聊天记录为了保证消息的离线能力和性能表结构包含会话ID、发送者ID、接收者ID、消息类型文本/图片/系统提示、消息内容、已读状态、发送时间。设计表时有个容易忽略的细节所有表都要带create_time和update_time并且后端的实体类要统一继承一个BaseEntity。相亲平台的运营非常依赖时间统计比如每日新增用户数、每周匹配对数、消息活跃度这些字段没有的话后面写统计 SQL 会非常痛苦。3.3 最关键的那条推荐查询 SQL用户搜索潜在匹配对象时最核心的 SQL 逻辑按条件动态拼接我用 MyBatis 的 XML 来写核心片段大概是下面这个思路select idselectRecommendUsers resultTypecom.demo.match.vo.RecommendUserVO SELECT u.id, p.nickname, p.age, p.city, p.height, p.education, p.job, p.annual_income, p.brief_intro, GROUP_CONCAT(t.tag_name) AS tags FROM user_account u LEFT JOIN user_profile p ON u.id p.user_id LEFT JOIN user_tags ut ON u.id ut.user_id LEFT JOIN tag_dict t ON ut.tag_id t.id where u.status 1 AND u.id ! #{currentUserId} AND u.id NOT IN ( SELECT target_id FROM user_like WHERE user_id #{currentUserId} ) if testquery.city ! null and query.city ! AND p.city #{query.city} /if if testquery.minAge ! null AND p.age gt; #{query.minAge} /if if testquery.maxAge ! null AND p.age lt; #{query.maxAge} /if if testquery.education ! null and query.education ! AND p.education IN foreach collectionquery.educationList itemitem open( separator, close) #{item} /foreach /if /where GROUP BY u.id ORDER BY p.last_active_time DESC LIMIT #{offset}, #{pageSize} /select这段 SQL 看起来复杂其实拆开就两层先把基础的筛选条件拼起来排除自己已经心动过的用户避免重复推荐然后通过GROUP_CONCAT把标签拼成一个字段供前端展示。这个写法在实际项目里效果很不错单表关联两张字典表索引建好之后查询性能完全扛得住。3.4 索引设计中的实操经验数据量起来后相亲平台的查询很容易慢在条件查询上索引设计需要提前布局user_profile(city)城市字段几乎用户筛选的必选项。user_profile(age)年龄范围筛选范围查询注意是普通 B-Tree 索引即可。user_like(user_id, target_id)复合索引查是否已心动、避免重复推荐都能快速命中。chat_message(conversation_id, create_time)消息列表按会话查询这个复合索引能避免聊天记录查询全表扫描的问题。我做过一次压测万级用户数据条件下上述索引加上合理的分页接口响应能稳定在 200ms 以内如果索引缺失同样的数据量有些查询会直接飙到 3 秒以上。给用户的建议是每建完一张业务表先把它最常见的查询场景列出来根据场景决定索引组合而不是事后回头慢查询日志才发现问题。4. 匹配推荐与心动流程系统核心业务逻辑是怎么落地的4.1 推荐策略的两种实现层次相亲平台给人最直接的观感就是推荐是否靠谱这里我按两个层次来实现第一层是硬性条件筛选本质就是上文的那种动态 SQL第二层是软性匹配打分。硬性条件是用户自己设的门槛比如“我只接受同城、本科以上、25到30岁”这类条件用 SQL 精确过滤。软性匹配则是把用户兴趣标签、活跃度、资料完整度、登录频次转换成匹配得分给用户更贴心的推荐排序。软性匹配的实现不复杂核心思路是计算两个人标签重合度 行为活跃度加权。算分逻辑可以写在内存中也可以直接在 SQL 里计算。我采用的做法是查询候选用户时把标签带出来在 Java 服务层里做标签重合度计算。比如用户 A 的标签集合是 {运动健身, 看书阅读, 旅行探店}侯选用户 B 是 {运动健身, 美食探店, 旅行探店}重合度 共同标签数 / 两个集合的并集大小这里就是 2 / 4 0.5。实际代码里给推荐结果加分时可以这样处理public double calculateMatchScore(ListString currentUserTags, ListString targetUserTags) { if (CollectionUtils.isEmpty(currentUserTags) || CollectionUtils.isEmpty(targetUserTags)) { return 0.0; } SetString intersection new HashSet(currentUserTags); intersection.retainAll(targetUserTags); SetString union new HashSet(currentUserTags); union.addAll(targetUserTags); return Math.round(intersection.size() * 100.0 / union.size()) / 100.0; }很多人以为匹配算法很神秘实际上相亲平台早期的推荐可以不用上机器学习用这种标签重合度活跃度排序的方式就能给用户一个不错的初始体验。把这个基础做好以后后续如果加入协同过滤模型底层的用户标签数据已经完全现成了。4.2 心动与互相心动状态机的设计与触发用户点“心动”的动作看起来只是一个按钮但背后有状态逻辑用户 A 对用户 B 点击心动先查user_like表是否已经存在 A - B 的记录不存在则写入。再反向查询是否存在 B - A 的记录。如果存在说明是互相心动插入user_match记录同时给双方发送站内消息和通知。如果不存在则只更新心动列表等对方点击心动后再触发匹配流程。这个流程用事务包裹起来确保不会出现“一方收到匹配通知另一方没收到”等数据不一致的情况。SpringBoot 中使用Transactional是常规操作但我有一点经验事务里不应该包含耗时的外部 IO 操作比如推送消息、上传图片否则会拉长事务时间造成数据库连接长时间占用。我自己在处理时是先完成数据库操作然后提交事务事务成功后再去调用消息推送服务把推送失败重试的活交给队列。4.3 每日推荐名单的缓存策略匹配结果如果每次请求都实时计算数据量大后数据库压力会很大这种场景下需要缓存。每日推荐名单在用户第一次打开首页时生成然后把生成结果放到 Redis 里设置 24 小时的过期时间。这样大部分用户当天看到的推荐列表是稳定的不会因为每次刷新就变也减轻了频繁 SQL 查询的压力。Redis 缓存这块除了每日推荐外短信验证码也放在 Redis 里设置 5 分钟过期用手机号当 key限制发送频率为 60 秒一次。这个方案比用数据库保存验证码好用得多因为验证码本身就是短生命周期、高频率读写的数据放进 MySQL 还会产生一堆过期垃圾数据。这里才是缓存最典型的应用场景不是所有数据都该塞进 Redis。4.4 会员体系如何实现运营一个相亲平台会员体系是绕不开的商业化模块。普通用户每天可以查看 10 位推荐用户每日只能发送 5 次心动开通会员后查看和心动次数不限制还可以看到谁喜欢了我等增值功能。业务逻辑上我设计了一个member_order表记录会员套餐购买记录包含用户ID、套餐类型月卡/季卡/年卡、开始时间、结束时间、订单状态。每次用户执行查看推荐或心动操作时后端会先校验当前用户是否有有效期内的会员套餐这个查询很简单直接在member_order按用户和状态筛选即可。对比一些容易做偏的方案有些开发者会把会员状态直接建成用户表上一个布尔字段这种做法在续费延期时会覆盖已购套餐还无法准确记录历史订单碰到运营需要做活动就会露馅。用独立订单表是最好的以后做退款、开发票、活动赠送都能有据可查。5. 前后端联调与安全加固从接口设计到部署上线的完整链路5.1 接口统一返回结构与状态码前后端分离项目里接口规范往往决定联调效率。我在项目里定义了一个统一返回结构ApiResponsepublic class ApiResponseT { private int code; // 200成功401未登录403无权限500服务异常 private String message; private T data; private long timestamp; }所有接口统一返回这个结构前端 axios 拦截器里对code进行统一处理200正常取数据401跳转登录页并清除本地 Token403提示无权限操作其他状态码直接弹出 message。这个约定如果做好了前端处理接口错误时几乎不需要在每一处业务代码里写重复的 catch 逻辑。5.2 JWT 登录认证与路由守卫登录认证采用 JWTJSON Web Token用户输入手机号密码成功登录后后端生成一个包含用户ID、角色信息、过期时间的 Token 返回给前端前端将其存到localStorage之后每次请求在请求头里加上Authorization: Bearer {token}。后端用一个拦截器解析 Token校验身份。Vue3 端配合导航守卫在router.beforeEach里判断当前路由是否需要登录router.beforeEach((to, from, next) { const token localStorage.getItem(token); if (to.meta.requiresAuth !token) { next({ path: /login, query: { redirect: to.fullPath } }); return; } next(); });这里有一个我上手时踩过的小坑JWT 签发的密钥不能写死在代码里应该放在配置文件中并通过环境变量注入。还有 Token 过期时用户正在操作页面可能直接跳登录导致刚填写的内容全部丢失所以我额外在拦截器里做了静默续期当 Token 剩余有效期小于 2 小时后端在响应头里返回新 Token前端 axios 检测到后自动更新本地存取。5.3 跨域问题的处理前后端分离必然面对跨域问题。SpringBoot 后端配置跨域有两种常见方式新增一个WebMvcConfigurer配置类重写addCorsMappings方法。使用CrossOrigin注解在每个 Controller 上单独标注。我推荐前者统一在一个配置类里管理避免给每个接口单独加注解。注意配置时allowedOriginPatterns要使用具体的前端域名不要直接*因为携带凭证时浏览器不允许通配符跨域。实际部署上线时更好的做法是通过 Nginx 反向代理把/api前缀转发到后端服务让前后端在同一个域下资源访问彻底规避跨域。5.4 上传接口的安全处理与 XSS 编码项目里有用户头像和个人照片上传Multipart 文件上传时必须校验文件类型、大小和内容。文件校验光看扩展名是不靠谱的攻击者可以把恶意脚本改成.jpg上传正确做法是解析文件的真实头部字节判断 Magic Number或者用第三方库检测 MIME 类型。同时用户填写的个人简介、心动留言这些文本内容一律要做 HTML 特殊字符转义。前端 Vue3 默认的文本插值{{ }}自带转义能力这能挡掉大部分 XSS 风险后端也要加一道防线对传入的文本内容做过滤转义。我在项目里写了一个全局过滤器统一处理请求参数中的 HTML 标签和特殊字符比如把script、iframe等标签剔除这个方法配合前端的默认转义做到双重保障。注意Vue3 的v-html指令要非常谨慎使用后台管理界面里如果必须渲染带有排版格式的富文本内容务必要先过一道白名单校验的富文本清洗组件不能直接把接口返回的富文本内容交给v-html渲染。5.5 部署流程与资源分离项目部署时要思考和注意的点我把完整过程整理成了操作清单后端项目用 Maven 打包mvn clean package -DskipTests产出xxx.jar。前端项目执行npm run build产出dist静态目录。服务器上安装 JDK 和 Nginx用nohup java -jar xxx.jar --spring.profiles.activeprod logs/app.log 21 启动后端。Nginx 中配置dist目录作为静态资源根目录同时把/api路径转发到后端端口。图片上传后存储到服务器独立目录通过 Nginx 映射成访问 URL不要把图片直接放在前端打包目录里否则下次发版全部图片会被覆盖或删除。我实际遇到过一个典型事故把用户上传的图片存储在dist/upload目录下前端发版时直接在服务器上删掉旧dist重新部署结果所有用户头像全部消失运营那边直接炸锅。后来改成服务端独立存储目录 Nginx 静态映射问题才彻底解决。这个经验后来写进了团队的部署规范里。6. 开发中踩过的坑MyBatis 缓存、分页插件、事务与空指针问题排查6.1 MyBatis 启动时报错 Invalid bound statement (not found)这是新手遇到频率最高的问题报错信息明确告诉你找不到某个 Mapper 方法对应的 SQL 语句。原因通常是以下几种XML 文件的 namespace 和 Mapper 接口全限定名不一致。Mapper 接口方法名和 XML 中的 id 对不上。XML 文件没有放在resources/mapper目录下或者application.yml里没有配置mybatis.mapper-locations。我自己的解决方式是项目结构里强制约定 Mapper 接口和 XML 文件同名且 XML 统一放在src/main/resources/mapper/路径下。配置里加上mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.match.entity如果加了配置仍然报错那就检查 target/classes 目录下有没有编译进去 XML 文件。曾有同事遇到的是pom.xml里没有配置资源过滤导致 XML 文件没被打进包这种问题不容易注意到确认时必查。6.2 MyBatis 二级缓存为什么推荐项目里我选择不开MyBatis 的一级缓存是 SqlSession 级别的同一个会话内两次相同查询不会重复查库。二级缓存是 namespace 级别的跨 SqlSession 共享。听起来很好但实际开发中二级缓存对相亲平台这种数据一致性要求高的业务来说风险不小。二级缓存最容易踩的问题是缓存刷新时机不可控。用户资料更新后如果关联表的更新操作没有正确清空对应 namespace 的缓存用户看到的信息就是旧的。举个例子用户修改了自己的职业和学历资料表执行了 update但如果其他会话里的查询结果还在缓存中读到的还是更新前的数据。对于资料一致性这么敏感的行为我选择直接把二级缓存关闭只在有明显读多写少特征的数据上手动用 Redis 做缓存控制。这一条可以说是从实际教训换来的。6.3 分页插件 PageHelper 的使用陷阱首页推荐列表、用户搜索列表、管理员审核列表都涉及分页PageHelper 是一个很常用的分页插件。它的用法看起来很简单PageHelper.startPage(pageNum, pageSize); ListUserVO list userMapper.selectRecommendUsers(query); PageInfoUserVO pageInfo new PageInfo(list);但这里有一个极其容易出错的点PageHelper.startPage()只对下一条 SQL 语句生效。曾经有一次我在调用 startPage 之前执行了一个无关的查询方法导致分页被作用到错误的查询语句上列表数据在页面里一直显示得很蹊跷排查了半天才发现是这条语句的副作用。正确姿势是startPage 紧接着就要调用 Mapper 方法中间不要穿插任何可能触发数据库查询的代码。另外PageHelper的依赖版本需要和 SpringBoot 版本保持兼容否则会出现代理对象转换异常这个排查起来非常绕。6.4 事务注解失效this 调用和异常被吞Transactional失效是 Spring 开发里高频遇见的坑最典型的场景是从同一个类内部调用带事务注解的方法。因为 Spring 的事务是基于代理实现的this.method()调用的其实是目标对象自己的方法没走代理自然没有事务。解决方式很简单把事务方法拆分到独立的 Service Bean 里注入调用或者通过AopContext.currentProxy()获取代理对象。另外还要注意Transactional默认只回滚RuntimeException如果方法里捕获异常后直接处理掉而不抛出事务一样不会回滚。相亲平台里我遇到过的真实场景是用户互相心动时插入心动记录成功、插入匹配记录失败但因为异常被 catch 住没有往外抛事务照样提交了最后数据库里出现了一条没有匹配关系的心动记录用户看到的状态和实际数据不一致。后来我在关键写入操作里加上Transactional(rollbackFor Exception.class)并且异常必须上抛或标记回滚才把这个坑填平。6.5 空指针问题MyBatis 查询结果为 null 导致的连锁反应MyBatis 查询单个对象时如果查不到记录返回的是 null而不是空对象。业务代码里如果直接拿这个结果调用方法就会触发空指针。在匹配功能里就真实遇到过用户查看推荐列表时userMapper.selectProfileByUserId()返回的profile是 null后面的profile.getNickname()直接崩溃。修复方案是一切从接口层兜底对象一律判空集合一律判断是否为 null 和 isEmpty并且把查询失败返回 null的情况在 MyBatis 的 XML 中尽量用COALESCE给字段默认值。更稳妥的是封装一个OptionalUserProfile的查询方式接口调用方明确知道可能为空从设计层面倒逼代码注意处理。6.6 慢查询与新索引上线系统上线一段时间后运营反馈后台审核列表打开越来越慢我看了慢查询日志发现是审核列表里关联了用户资料表却没有合适的索引。解决方案倒不复杂给审核查询的两个筛选字段分别加了单列索引并把列表默认只查询最近 7 天的数据加上时间条件限制响应速度立刻回来了。这儿有个小技巧不要在索引列上使用函数。比如DATE(create_time) CURDATE()这种写法会让索引失效正确写法是create_time 2025-06-01 00:00:00 AND create_time 2025-06-02 00:00:00。这个细节在写日期相关查询时一定要留意。最后多分享一句一旦你把相亲网站这套业务从注册到匹配到审核整条链路完整跑通SpringBoot Vue3 MyBatis MySQL 这套组合在实战里的默契点你会摸得比较透。代码里我把业务逻辑做成模块化配置后续想做 AI 推荐、反欺诈识别、直播相亲功能都可以在现有表结构上平滑扩展。如果你正在找这类全栈项目练手建议拿到源码后不要只启动起来看一眼就跑而是自己动手删掉某张表再顺着报错把依赖关系和业务流重新走一遍收获会翻倍。