
我接手过一个特别典型的咨询一个刚毕业的Java后端开发面试被问到“有没有完整的全栈项目经验”简历上写了个后台管理系统结果被面试官追问“用户模块、推荐逻辑、会话机制怎么设计的”时答不上来。说实话不少人的项目经验都卡在“CRUD能跑通”这个层面一聊到业务深度就露馅。后来我跟他聊到基于Java和Vue技术栈来做一套完整的交友系统他拿回去研究了一周自己动手重写了一遍再去面试明显底气不一样了。这套系统不是那种网上烂大街的增删改查demo它涵盖了真实产品需要考虑的用户管理、匹配推荐、即时通讯、后台运营这几个核心链路。无论你是拿来写毕设还是想彻底搞明白一个真实项目怎么从零落地或者干脆想二次开发套一层自己的业务逻辑都有参考价值。关于这套系统的技术选型和实现思路我在这篇里尽量把关键细节摊开讲清楚。1. 立项前先想清楚这套交友系统到底要解决什么1.1 别急着写代码先画清业务边界很多人拿到“交友系统”第一时间就想着写注册登录但实际上一个能用于学习和二次开发的项目业务边界要比这个清晰得多。典型的交友系统核心链路就三条用户从注册到建立个人资料包含基本资料、兴趣标签、照片墙。用户之间通过某种推荐或搜索机制找到彼此这是关系链的起点。感兴趣的双向用户建立会话完成沟通这是留存的关键。我在设计这套系统时把范围严格控制在上述三条链路内没有拖泥带水加什么动态广场、直播礼物、会员积分这些容易膨胀的功能。原因很简单范围越大你越难做完越难做得精。对学习或毕设来说把一个最小闭环打磨到可用价值远大于画一堆没跑通的菜单。1.2 推荐机制和“心动信号”的关系交友类应用里有个非常核心的交互用户A对用户B感兴趣通常表现为滑动卡片时“喜欢”。如果用户B恰好也对用户A感兴趣就构成“互相心动”双方才能建立聊天会话。这个交互方式在交互层叫“左右滑动卡片”在数据层本质是两张数据库表之间的交集查询我发出的兴趣记录、我收到的兴趣记录两个集合取交集。这套逻辑是这套系统区别于普通CRUD项目最典型的一个点涉及业务规则而非单纯数据存取。1.3 功能结构拆解我用思维导图的方式把系统功能结构列出来你心里先有个整体框架用户端注册/登录短信验证码模拟 JWT鉴权个人信息维护头像上传、昵称、性别、生日、城市兴趣标签管理多选、最多5个滑动发现页推荐用户卡片心动列表我发出的、收到的互相心动后的聊天WebSocket 离线消息个人主页展示管理后台用户管理列表、禁用/启用内容审核头像涉黄/违规检测的记录数据看板注册趋势、活跃度这里的管理后台我用的是同一套Vue前端代码里的独立路由模块配合后端独立的权限角色来实现。它的意义在于让你理解企业开发里“一个前端应用如何对接两套后端接口体系”。2. 技术选型的逻辑为什么是Java Vue以及各模块的搭配理由2.1 后端框架组合与理由这套系统后端以Spring Boot 2.7为基础配合了以下几个关键组件模块选型理由权限认证Spring Security JWT无状态鉴权适合前后端分离也方便做接口拦截ORMMyBatis-Plus单表CRUD极快复杂查询用XML自定义SQL数据库MySQL 8.0稳定、社区资料丰富云端部署方便缓存Redis存验证码、会话在线状态、用户推荐候选池实时通讯WebSocket Spring Messaging轻量级即时通讯不引入重量级IM中间件文件存储本地磁盘 Nginx映射学习阶段无需依赖第三方OSS降低成本选这套组合的核心逻辑是尽量让每个组件的角色单一化比如Redis就干三件事验证码、在线状态、候选用户集合不拿它当主数据库用。这对学习来说很重要出了问题时你能迅速定位到是哪个环节。2.2 前端Vue技术栈前端用Vue 3 Vite Pinia Vue Router Element Plus这套组合在当下Vue生态里属于主流搭配。Vue 3的Composition API非常适合模块化组织业务逻辑比如用户管理这个页面里列表查询、禁用操作、筛选条件可以分别封装成独立的composable函数。配合Pinia管理全局登录态和用户信息比Vuex更轻量也没有多余的概念负担。网络请求层统一用Axios实例封装拦截器负责两件事请求头自动加JWT、响应401时跳回登录页。这部分是规范前端请求链路的必备手段省掉它你的前端代码会有一堆重复的token拼接逻辑。2.3 为什么不用微服务架构有同学会问“为什么不用Spring Cloud Alibaba做一套微服务呢显得技术含量更高。”我的观点很直接单体优先。这个项目的规模用单体完全hold住业务没有独立的服务边界强行拆微服务只是在制造跨服务调用的复杂度。真实企业场景里很多内部系统至今也是单体架构先把单体撮合系统的全部业务吃透再谈分布式演进才有意义。3. 核心数据模型设计三张关键表如何撑起整个交友链路3.1 用户信息表 concentrate 核心字段users表我没有用默认的简单字段堆砌核心字段设计如下CREATE TABLE user ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键ID, phone varchar(11) NOT NULL COMMENT 手机号登录账号, password varchar(100) NOT NULL COMMENT BCrypt加密后的密码, nickname varchar(50) DEFAULT NULL COMMENT 昵称, gender tinyint(1) DEFAULT 0 COMMENT 性别 0未知 1男 2女, birthday date DEFAULT NULL COMMENT 生日, city varchar(50) DEFAULT NULL COMMENT 所在城市, avatar varchar(255) DEFAULT NULL COMMENT 头像URL, self_intro varchar(500) DEFAULT NULL COMMENT 自我介绍, status tinyint(1) DEFAULT 1 COMMENT 账号状态 1正常 0禁用, last_login_time datetime DEFAULT NULL COMMENT 最后登录时间, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_phone (phone) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;有个细节值得注意头像字段存的是URL而非BASE64字符串。这个设计是很多初学者容易犯的错误——把图片直接塞进数据库导致表体积膨胀、查询变慢。正确做法是文件上传后返回存储路径数据库只保留路径字符串。3.2 兴趣标签表与用户标签关联表标签体系采用标准的一对多关联。正常情况下需要三张表标签表、用户标签关联表、以及可选的标签分类表。这里为了减少冗余设计时把标签分类挂在了标签表内部。CREATE TABLE tag ( id int(11) NOT NULL AUTO_INCREMENT, name varchar(20) NOT NULL COMMENT 标签名, category varchar(20) DEFAULT NULL COMMENT 所属分类运动/音乐/美食/旅行等, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_name (name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT兴趣标签表; CREATE TABLE user_tag ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL COMMENT 用户ID, tag_id int(11) NOT NULL COMMENT 标签ID, PRIMARY KEY (id), UNIQUE KEY uk_user_tag (user_id,tag_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户标签关联表;这里设置唯一联合索引uk_user_tag是最容易忽略的细节。没有这个约束用户在修改标签时重复提交会出现同一用户挂两个相同标签的脏数据后续算标签匹配度时会出大问题。3.3 心动操作表记录关系和方向的载体心动表是这套系统最关键的表它的结构决定了推荐、匹配、列表页面的查询效率。CREATE TABLE like_record ( id bigint(20) NOT NULL AUTO_INCREMENT, from_user_id bigint(20) NOT NULL COMMENT 发出心动方, to_user_id bigint(20) NOT NULL COMMENT 接收心动方, is_match tinyint(1) DEFAULT 0 COMMENT 是否达成匹配 0否 1是, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_from_user (from_user_id), KEY idx_to_user (to_user_id), UNIQUE KEY uk_from_to (from_user_id,to_user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT心动记录表;我特意加了一个is_match字段。它的目的不是存计算结果而是在达成互相心动时直接标记这样聊天列表页只需要一条is_match 1的查询就能拉出所有匹配用户不需要每次都做交集计算。而唯一索引uk_from_to从数据层面保证了A不能重复向B发出心动。这是业务规则在数据库设计中的落地。3.4 聊天消息表单聊会话的兜底方案CREATE TABLE chat_message ( id bigint(20) NOT NULL AUTO_INCREMENT, match_id bigint(20) NOT NULL COMMENT 匹配关系ID, from_user_id bigint(20) NOT NULL COMMENT 发送人, to_user_id bigint(20) NOT NULL COMMENT 接收人, content varchar(2000) DEFAULT NULL COMMENT 消息内容, msg_type tinyint(1) DEFAULT 0 COMMENT 消息类型 0文本 1图片, is_read tinyint(1) DEFAULT 0 COMMENT 是否已读 0未读 1已读, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_match_id_time (match_id,create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT聊天消息表;单聊消息表用匹配ID作为主要查询维度好处是打开聊天窗口时一条带分页的查询就能拿到历史消息不需要关心两个用户之间复杂的会话关系。4. 滑动推荐模块从SQL查询到Redis缓存候选池4.1 最粗糙的版本一条SQL过滤第一版推荐页最简单直接的SQL过滤方式如下SELECT * FROM user WHERE gender ! #{currentUserGender} AND id ! #{currentUserId} AND status 1 ORDER BY create_time DESC LIMIT #{offset}, #{pageSize}这种方案的代价是每次都全表扫描并且推荐结果完全不可控。用户两次滑动看到同一批人的概率极大而且完全没有个性化因素实际体验比较糟糕。4.2 升级版标签相似度打分排序为了在SQL层面提升推荐的合理性引入了一个基于标签重合度的简单推荐评分。核心思路每个用户有一组标签系统取出当前用户的标签ID集合统计候选用户与当前用户有多少个共同标签按共同标签数降序排列。用MyBatis-Plus的QueryWrapper实现略有难度这里我是用自定义SQL实现的select idselectRecommendUsers resultTypecom.example.pojo.UserVO SELECT u.*, COUNT(ut.tag_id) AS match_score FROM user u LEFT JOIN user_tag ut ON u.id ut.user_id WHERE u.id ! #{currentUserId} AND u.status 1 AND u.gender ! #{currentUserGender} AND u.id NOT IN ( SELECT to_user_id FROM like_record WHERE from_user_id #{currentUserId} ) AND u.id NOT IN ( SELECT from_user_id FROM like_record WHERE to_user_id #{currentUserId} ) GROUP BY u.id ORDER BY match_score DESC, u.create_time DESC LIMIT #{limit} /select这个SQL里有两层子查询核心是排除已经发生过交集的用户。不管是对方向右滑过我、还是我向右滑过对方都不应该再出现在推荐流里。这也是产品逻辑里对“候选池”最基本的要求。4.3 性能优化Redis缓存每日候选池SQL方案在小数据量下没问题但到了一定的用户规模全表GROUP BY带来的开销就会变大。我做了一个妥协方案每日定时把离线计算好的推荐列表放入Redis缓存带过期时间。具体做法是每天凌晨用XXL-Job或Spring自带的Scheduled跑一次全量用户推荐预计算把每个用户ID对应的推荐结果列表序列化后存到Redis键格式为recommend:user:{userId}过期时间设为24小时。用户进入推荐页时直接从Redis读取读不到则兜底走SQL实时计算。这个优化点并不是说一定要做但它体现了“将低频高耗计算改为预计算”的通用思路且实现成本不大。这个模块在聊天室场景上很容易扩展推荐池除性别排除外还可以叠加城市、年龄区间、活跃时间等筛选本质上都是对Redis中的候选集合做过滤。5. 互相心动的判定与聊天会话的建立策略5.1 判定时机用户B点击喜欢的那一刻这个场景是并发重灾区要特别留意。当用户A已经喜欢用户B时用户B再对A点击“喜欢”系统需要同时完成两件事插入一条B到A的心动记录同时把之前A到B的那条记录is_match更新为1。注意这两步不能分开执行必须放在一个事务里。我给出的事务逻辑伪代码Transactional(rollbackFor Exception.class) public MatchResult likeUser(Long fromUserId, Long toUserId) { // 1. 检查是否已对该用户发送过喜欢 LikeRecord existing likeRecordMapper.selectOne( new LambdaQueryWrapperLikeRecord() .eq(LikeRecord::getFromUserId, fromUserId) .eq(LikeRecord::getToUserId, toUserId) ); if (existing ! null) { throw new BusinessException(不能重复喜欢); } // 2. 插入新的喜欢记录 LikeRecord newRecord new LikeRecord(); newRecord.setFromUserId(fromUserId); newRecord.setToUserId(toUserId); likeRecordMapper.insert(newRecord); // 3. 检查对方是否喜欢过我互相心动的关键判断 LikeRecord reverseRecord likeRecordMapper.selectOne( new LambdaQueryWrapperLikeRecord() .eq(LikeRecord::getFromUserId, toUserId) .eq(LikeRecord::getToUserId, fromUserId) ); // 4. 如果对方已经喜欢过我更新两条记录的匹配状态 if (reverseRecord ! null) { newRecord.setIsMatch(1); likeRecordMapper.updateById(newRecord); reverseRecord.setIsMatch(1); likeRecordMapper.updateById(reverseRecord); // 5. 创建匹配关系记录用于后续聊天会话 matchMapper.insert(new Match(fromUserId, toUserId)); return MatchResult.MATCHED; } return MatchResult.NOT_MATCH; }这套流程里有几个值得注意的并发问题如果A和B同时点击喜欢对方两个请求都在“检查是否已喜欢”这一步放行然后各插入一条记录再分别查到对方记录并更新匹配状态最终结果是正确的因为两条不同方向记录的插入互相不冲突。唯一索引uk_from_to保证A无法真正重复喜欢B即便并发请求来了第二次插入会抛DuplicateKeyException这一步属于数据库底层的兜底。5.2 匹配关系表后续所有会话的桥建立匹配关系后聊天功能就有了依附对象。每次打开聊天窗口前端传递matchId后端校验该匹配关系中确实包含当前用户然后才能发送和读取消息。这个校验很重要否则一个用户可以直接构造请求给任意匹配ID发消息形成越权漏洞。我做了一个简单的拦截判断public boolean isParticipant(Long userId, Long matchId) { Match match matchMapper.selectById(matchId); return match ! null (match.getUserAId().equals(userId) || match.getUserBId().equals(userId)); }5.3 会话列表页避免N1查询匹配成功后聊天列表页需要展示所有匹配用户的最新一条消息和未读数。如果循环遍历所有匹配ID逐条查最新消息就会产生N1问题。我用了子查询配合MySQL的GROUP BY特性先求出每个匹配ID的最大消息ID再关联查详情SELECT m.id AS match_id, u.id AS user_id, u.nickname, u.avatar, tmp.last_msg, tmp.last_time FROM match m LEFT JOIN ( SELECT match_id, content AS last_msg, max(create_time) AS last_time FROM chat_message GROUP BY match_id ORDER BY last_time DESC ) tmp ON m.id tmp.match_id LEFT JOIN user u ON u.id CASE WHEN m.user_a_id #{currentUserId} THEN m.user_b_id ELSE m.user_a_id END WHERE m.user_a_id #{currentUserId} OR m.user_b_id #{currentUserId} ORDER BY tmp.last_time DESC这个查询写完可以直接在MySQL客户端里验证执行计划确认match_id走索引再搬到代码里。6. 前后端接口约定与开发节奏管理6.1 统一返回体和异常处理前端Vu e和后端Java对接时最容易出现的问题是接口返回格式不统一。系统从第一版起就严格要求所有接口返回统一的Result结构{ code: 200, message: success, data: {} }对应的Java泛型类Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ResultT error(Integer code, String message) { ResultT result new Result(); result.setCode(code); result.setMessage(message); return result; } }全局异常处理器捕获业务异常和系统异常统一转换成Result结构返回。前端Axios响应拦截器里判断code字段非200走错误提示401跳登录页。这套约定全局统一前后端联调时非常省心。6.2 Swagger文档与前后端并行开发用Spring Boot集成SpringDoc启动后访问/swagger-ui.html即可看到所有接口定义。后端先定义好接口入参出参前端按文档mock数据结构开发页面就能把联调期的等待降到最低。具体做到以下这一步就算达到要求每个接口标注Schema描述字段含义。请求参数用DTO接收不散落裸参数。分组定义用户端接口/api/user/**管理端接口/api/admin/**。6.3 前端Vue路由与权限控制Vue端路由分两个维度无需登录即可访问登录页、注册页。需要登录的页面发现页、心动列表、聊天会话、个人中心、管理后台。我用Vue Router的beforeEach全局前置守卫做登录判断router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next(/login) } else if (to.path.startsWith(/admin) !isAdmin()) { next(/403) } else { next() } })管理端页面在路由表里加了meta: { role: ADMIN }配合后端接口接口权限双保险。前端控制只是体验层面的拦截真正的安全边界必须由后端的Spring SecurityPreAuthorize(hasRole(ADMIN))来把关。6.4 联调阶段的Mock策略前后端联调时我常常遇到这样的问题后端某个列表接口还没写完前端页面却等着数据渲染。解决办法是前端在代码里预留Mock层。在src/api/目录下每个接口文件都导出一个带环境的请求函数// src/api/user.js const isMock import.meta.env.VITE_USE_MOCK true export function getUserList(params) { if (isMock) { return Promise.resolve({ code: 200, data: mockUserList }) } return http.get(/api/admin/user/list, { params }) }环境变量切换Mock和真实后端极大降低了对后端的同步依赖。真实项目中对这套模式的应用极广养成习惯后开发效率提升明显。7. 从源码到部署数据库脚本位置、环境配置及试运行过程7.1 目录结构说明拿到源码压缩包后先看目录结构。一个规范的Java Vue项目应当有清晰的顶层目录match-system/ ├── backend/ # Spring Boot后端工程 │ ├── src/main/java │ ├── src/main/resources │ └── pom.xml ├── frontend/ # Vue前端工程 │ ├── src/ │ ├── package.json │ └── vite.config.js ├── sql/ # 数据库初始化脚本 │ └── init.sql └── docs/ # 项目开发文档 ├── 需求说明.md ├── 接口文档.md └── 部署文档.md7.2 初始化数据库用Navicat或命令行执行sql/init.sql它会自动建库、建表并插入示例标签数据和测试用户。mysql -u root -p sql/init.sql执行完成后检查三张核心表是否有数据。示例账号通常在文档里给出例如测试用户A手机号13800000001和测试用户B13800000002可以用来直接体验互相心动的完整流程。7.3 后端配置调整后端配置文件在backend/src/main/resources/application.yml需要按本地环境修改以下部分spring: datasource: url: jdbc:mysql://localhost:3306/match_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 你的数据库密码 redis: host: localhost port: 6379 password: 你的Redis密码没有密码就留空然后依次启动MySQL、Redis服务。后端启动命令cd backend mvn spring-boot:run控制台看到Started MatchApplication即表示后端启动成功。如果报数据库连接失败就回到上一步检查application.yml里的账号密码。7.4 前端安装依赖与启动cd frontend npm install npm run devnpm install慢是正常现象可以配置镜像源解决。启动完成后访问http://localhost:5173用测试账号登录即可体验完整流程。前端启动后如果请求报跨域问题检查vite.config.js里的代理配置server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }MyBatis-Plus默认驼峰映射开启数据库字段下划线自动转驼峰这一配置在application.yml里的配置如下mybatis-plus: configuration: map-underscore-to-camel-case: true7.5 最容易卡住的三个环境问题这里把我踩过的三处高频问题列出来做项目前先防住Lombok版本与JDK版本不兼容导致Data注解不生效。建议JDK 8配Lombok 1.18.20JDK 11及以上配最新版Lombok并在IDEA里安装Lombok插件且开启Annotation Processing。MySQL 8.0的驱动类名是com.mysql.cj.jdbc.Driver不是旧的com.mysql.jdbc.Driver写错了会报ClassNotFound。Redis密码留空时spring.redis.password这一项直接注释掉不要写成空字符串个别版本会把它当成有效密码导致连接拒绝。8. 这份项目的文档使用说明书学习路线与二次开发扩展方向8.1 从哪部分代码开始读拿到项目后不要从头到尾逐行读代码那样效率太低。我的建议是按业务闭环阅读先从前端“发现页”对应的Vue组件入手找到它调用的API函数。顺着这个API函数找到后端Controller中对应的接口。顺着Controller进入Service层此时你看到的就是完整的推荐逻辑。然后再看Mapper/XML层搞懂SQL是如何组织的。这样走完一条链路你就理解了“一次滑动推荐”背后完整的请求链路远比一次读几百个文件有效。8.2 基于这份源码可以扩展的方向如果你学有余力以下方向都很适合做二次开发扩展方向涉及技术点难度增加基于地理位置的距离筛选Redis GEO、GPS定位、MySQL空间函数中聊天离线推送Web Socket重连、消息队列、推送服务中高推荐算法升级协同过滤、标签权重调整、用户行为埋点高管理后台数据看板ECharts图表、数据聚合查询低图片内容安全校验第三方内容审核API、阿里云/腾讯云OSS中匹配关系的匿名打分评价表、满意度统计低其中“管理后台数据看板”是最推荐的第一个扩展点因为它的代码结构与其他模块相似涉及的热点技术点不多但能让你快速建立起“全栈式”增删改查以外的数据可视化经验体系。8.3 将项目用作毕设时的准备策略毕业设计答辩时老师不会只问“这个功能怎么做”一定会追问“为什么这样设计”。根据我的经验以下三个问题的答法准备好答辩基本稳“为什么用JWT而不用Session”答前后端分离架构下服务端不保存会话状态JWT自包含用户信息适合横向扩展但JWT有注销难的缺点所以系统里设置了token黑名单机制存在Redis中。“为什么用WebSocket而不是轮询”答聊天场景要求低延迟高实时性WebSocket是长连接双向通信协议比HTTP短轮询节省大量无效请求开销。“两个人同时喜欢对方时是怎么保证不会出现脏数据的”答数据库侧通过唯一索引防止重复喜欢通过事务保证两张表的状态一致再结合对反向记录的查询实现匹配判定。这些回答不求语出惊人但要求逻辑自洽能体现你对系统的真实掌控度。8.4 代码之外的收获文档习惯最后说一点我认为更重要的事。这套系统从设计初期就同步维护文档这不是形式主义。当你后面翻阅自己三个月前写的代码时如果没有文档很可能想不起来“like_record.is_match这个字段是哪次迭代加的、为什么加”。我在每次写完一个模块后会花十分钟更新接口文档和数据库说明这个习惯帮我在后续维护中节约了大量时间。项目交付时附带的数据库脚本、接口文档、部署指南从来不是锦上添花的附加内容。对企业团队或毕设评分来说“能不能让别人快速上手这个项目”本身就是评估项目质量的重要维度。