ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

SpringBoot+Vue前后端分离交友系统:从设计到部署全解析

SpringBoot+Vue前后端分离交友系统:从设计到部署全解析 前后端分离的实战项目网上一抓一大把但我见过太多人下载了源码却根本跑不起来更别提部署上线了。今天分享的这个“志同道合交友网站系统”是我花了不少业余时间打磨出来的一套完整作品技术栈就是最主流的 SpringBoot Vue MyBatis MySQL没有复杂到劝退的框架也没有从零教环境的注水内容重点放在“业务怎么设计、代码怎么写、项目怎么部署”这三件事上。系统内核是兴趣标签驱动的交友匹配通过用户给自己打的兴趣标签计算彼此之间的契合度把真正“志同道合”的人互相推荐。刚开始学前后端分离的同学可以把这套系统当作教科书级的练手项目已经准备找工作的朋友也能从里面提炼出简历上拿得出手的技术亮点。接下来我从需求拆解开始一路讲到线上部署踩过的坑。1. 项目整体设计与核心思路做这类偏应用型的系统最怕一开始就埋头写代码。前后端分离不是说前端放一个 Vue 工程、后端放一个 SpringBoot 工程就算完事关键在于职责边界、数据交互方式、接口约定和部署形态这些在设计阶段就得定清楚。1.1 交友系统的功能定位与用户价值虽然叫“交友网站”但这个系统并不是做成陌陌或者探探那种“附近的人”模式核心差异化在“志同道合”四个字上。传统的社交软件推荐逻辑偏向地理位置和热度而这里我们要解决的是兴趣连接的问题。具体落到功能上我划分了6个核心模块用户模块注册、登录、个人信息管理、头像上传、密码修改。兴趣标签模块预设标签库 用户自选标签用户最多选12个标签作为匹配基础。推荐匹配模块根据标签重叠度给用户推荐“可能感兴趣的人”支持按契合度排序。互动模块关注/取消关注、查看访客、点亮喜欢、好友申请与处理。私信模块支持好友之间的一对一实时聊天用轮询实现没有引入 WebSocket别急后面我会解释为什么。个人动态模块发布文字心情、查看好友动态、给动态点赞。对于学习用途来说这套功能量级正好。太少显得单薄太多像聊天室、直播那种又超出个人项目的能力范围而且核心技术点重复度高。1.2 为什么坚持前后端分离架构从开发模式和部署形态两个维度看前后端分离是这套系统的必选项不是可选项。项目对比维度传统单体JSP项目前后端分离项目职责分工前端页面和后端逻辑耦合在同一个工程里前端只管界面交互后端只提供JSON接口开发效率前端改样式需要重启应用前后端可并行开发使用Mock数据联调部署方式打成一个 WAR 包丢进 Tomcat前端静态资源由 Nginx 托管后端独立部署扩展能力难以承接移动端、小程序等新端同一套后端API可同时服务Web、App、小程序SpringBoot 天然适合做后端 API 服务内嵌 Tomcat 让部署非常干净Vue 则负责把页面组件化配合 Vue Router 做前端路由、Pinia 做状态管理单页应用的体验和无刷新交互都很成熟。前后端之间通过 JSON 交换数据接口就是唯一的契约这也是我现在做任何项目都默认采用的方式。1.3 技术选型背后的取舍逻辑你是不是好奇用人气更高的 MyBatis-Plus 不是更方便吗为什么选 MyBatis坦白说这个选择我是故意的。MyBatis-Plus 确实把单表 CRUD 简化到了极致但这也成了很多人“只会用不会写”的根源。这个交友系统里有大量多表关联和动态 SQL 场景比如标签匹配、好友列表带用户信息、动态联表查询作者信息用原生 MyBatis 能把 XML 映射、动态 SQL、resultMap 关联映射这些核心能力吃透。面试时这些恰好是高频考点。MySQL 8.0 作为唯一的存储层承担用户数据、标签关系、互动记录、消息记录和动态数据。搞笑的是很多人在环境准备阶段就栽了跟头MySQL 安装配置反而不是应用代码的难点。后面部署章节我会专门讲。2. 数据库设计交友系统的地基工程数据库设计是整套系统里我最看重的部分。交友系统业务看起来简单但只要涉及到标签匹配、好友关系、消息记录这几个场景表结构设计稍有不慎后边查询会写得痛不欲生。2.1 核心表结构与字段设计整个系统一共6张核心表外加1张标签字典表。数据库名我建议用soulmate_db字符集选 utf8mb4因为用户昵称和心情动态里可能出现 emojiutf8mb4 才能完整存储。用户表userCREATE TABLE user ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 用户ID, username varchar(50) NOT NULL COMMENT 登录账号, password varchar(100) NOT NULL COMMENT BCrypt加密后的密码, nickname varchar(50) DEFAULT NULL COMMENT 昵称, avatar varchar(255) DEFAULT NULL COMMENT 头像URL, gender tinyint(4) DEFAULT 0 COMMENT 性别 0未知 1男 2女, birthday date DEFAULT NULL COMMENT 出生日期, city varchar(50) DEFAULT NULL COMMENT 所在城市, signature varchar(200) DEFAULT NULL COMMENT 个性签名, status tinyint(4) DEFAULT 1 COMMENT 账号状态 0禁用 1正常, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8mb4 COMMENT用户表;USER_ID 一定要是雪花ID或者自增主键这里我用自增是为了演示简单。但注意 username 必须加唯一索引。标签字典表tag和用户标签关联表user_tagCREATE TABLE tag ( id int(11) NOT NULL AUTO_INCREMENT, name varchar(30) NOT NULL COMMENT 标签名称, category varchar(20) DEFAULT NULL COMMENT 标签分类运动/音乐/阅读/旅行等, 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, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_id (user_id), KEY idx_tag_id (tag_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户兴趣标签关联表;user_tag 就是经典的“用户—标签”多对多映射表匹配推荐全靠这张表能查出来。好友关系表user_friend、喜欢表user_like、私信表message和动态表post的设计也需要注意。好友关系表我用的是双行冗余设计避免查“我关注了谁”和“谁关注了我”时都要做一次反向查询。CREATE TABLE user_friend ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL COMMENT 发起方用户ID, friend_id bigint(20) NOT NULL COMMENT 被关注用户ID, status tinyint(4) DEFAULT 0 COMMENT 状态 0待处理 1已通过 2已拒绝, create_time datetime DEFAULT CURRENT_TIMESTAMP, handle_time datetime DEFAULT NULL COMMENT 处理时间, PRIMARY KEY (id), KEY idx_user_friend (user_id, friend_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT好友关注关系表;私信表message设计上需要存 sender_id、receiver_id、content、is_read、create_time重点在查询未读消息数和会话列表的 SQL。动态表post需要冗余一个 author_id 和 content、image_url、like_count方便列表页直接展示不用每次联查 count。2.2 兴趣匹配背后的数学逻辑“志同道合”在系统里不是一个口号它本质上是一个简化版的推荐算法。我采用的是基于标签集合的 Jaccard 相似度。假设用户 A 的标签集合是 TA用户 B 的标签集合是 TB那他们的契合度就是similarity(A, B) |TA ∩ TB| / |TA ∪ TB|也就是交集元素个数除以并集元素个数。两个人都有“跑步”和“摄影”标签其中一个人还有“阅读”那重叠度就是 2/3 ≈ 0.67匹配度非常高了。考虑到前端推荐列表要按契合度排序我不能在内存里把所有用户两两算一遍。实际实现上我先把当前用户的标签ID集合查出来然后用 SQL 统计出至少有一个共同标签的候选用户再在 Service 层精确计算 Jaccard 值最后按值排序取前20个。数据量在十万级以内这种方案完全够用比引入 Redis 做实时推荐少一大截复杂度。2.3 数据库设计里我踩过的坑三个回看时很重要的经验第一字符串类型的枚举字段控制好长度。gender 用 tinyintstatus 用 tinyint不要真的在数据库里存“男”“女”这种字符串。Java 枚举转 JSON 输出时处理一下即可数据库层保持干净的数值语义。第二所有联表查询字段都加了索引。最典型的是 user_tag 表的 user_id、tag_id 必须分别建索引否则匹配推荐接口在数据量上来之后会慢得非常明显。早年我犯过只建联合索引、不建单列索引的错结果某个查询用不上索引排查半天。第三时间字段统一用 datetime不要混合 timestamp。订单、消息、日志类表对时间精度要求其实没那么高但不同 MySQL 版本对 timestamp 的时区处理不一致很容易在前后端时间显示上出现8小时的偏移。3. 后端核心实现SpringBoot MyBatis 的完整落地后端工程我按标准的四层结构来拆分Controller 层负责参数接收与响应封装Service 层处理业务逻辑Mapper 接口 XML 负责数据访问Entity 实体与数据库表对应。包名用com.soulmate.*类命名没有奇技淫巧清晰第一。3.1 工程结构一览soulmate-backend/ ├── src/main/java/com/soulmate/ │ ├── SoulmateApplication.java // SpringBoot 启动类 │ ├── config/ │ │ ├── CorsConfig.java // 跨域配置 │ │ ├── WebMvcConfig.java // 拦截器注册 │ │ └── JwtInterceptor.java // JWT 登录拦截器 │ ├── controller/ │ │ ├── UserController.java │ │ ├── MatchController.java │ │ ├── MessageController.java │ │ └── PostController.java │ ├── service/ │ │ ├── UserService.java │ │ ├── MatchService.java │ │ └── ... │ ├── mapper/ │ │ ├── UserMapper.java │ │ ├── UserTagMapper.java │ │ └── ... │ ├── entity/ │ │ ├── User.java │ │ ├── UserTag.java │ │ └── ... │ ├── common/ │ │ ├── Result.java // 统一响应体 │ │ └── BusinessException.java │ └── util/ │ └── JwtUtil.java └── src/main/resources/ ├── application.yml └── mapper/ ├── UserMapper.xml ├── UserTagMapper.xml └── ...这个结构没有把 controller/service 拆出无数个包适合中小型项目也让刚接触的人不容易迷路。3.2 用户注册登录与 JWT 权限控制用户密码一律用 BCrypt 加密存储这是 Spring Security 内置的加密算法比 MD5 安全得多。MD5 加盐虽然有改善但 BCrypt 在暴力破解防护上明显更专业。我把 spring-security-crypto 单独引进来只用了它的 BCryptPasswordEncoder没有引入整套 Spring Security避免过滤器链对接口造成额外干扰。注册逻辑的核心代码Service public class UserServiceImpl implements UserService { Autowired private UserMapper userMapper; private final BCryptPasswordEncoder encoder new BCryptPasswordEncoder(); Override public Result register(UserRegisterDTO dto) { // 1. 检查用户名是否存在 User exist userMapper.selectByUsername(dto.getUsername()); if (exist ! null) { return Result.error(用户名已存在); } // 2. 构造用户对象密码加密 User user new User(); user.setUsername(dto.getUsername()); user.setPassword(encoder.encode(dto.getPassword())); user.setNickname(dto.getNickname()); // 3. 保存并返回 userMapper.insert(user); return Result.success(); } }登录成功之后后端生成 JWT token 返回给前端。JWT 的好处是服务端无需保存会话状态token 自带过期时间非常适合前后端分离的部署结构。我的 JwtUtil 里把过期时间设置为 24 小时密钥放在 application.yml 中通过 Value 注入自然不会在代码里写死。拦截器里只拦截/api/**下需要登录的接口对/api/user/login、/api/user/register放行。token 校验失败时直接返回 401前端收到 401 就跳到登录页。3.3 匹配推荐模块的核心实现这是全系统灵魂所在。我的实现分三步查候选用户、算相似度、排序取前 N。第一步查出至少有一个共同标签的候选用户!-- UserTagMapper.xml -- select idselectCandidateUserIds resultTypejava.lang.Long SELECT DISTINCT ut1.user_id FROM user_tag ut1 JOIN user_tag ut2 ON ut1.tag_id ut2.tag_id WHERE ut2.user_id #{currentUserId} AND ut1.user_id ! #{currentUserId} /select第二步在 Service 里计算每个候选用户的 Jaccard 相似度public ListMatchUserVO matchUsers(Long currentUserId, int limit) { // 1. 当前用户的标签集合 SetLong myTagIds userTagMapper.selectTagIdsByUserId(currentUserId); if (myTagIds.isEmpty()) { return Collections.emptyList(); } // 2. 候选用户ID ListLong candidateIds userTagMapper.selectCandidateUserIds(currentUserId); if (candidateIds.isEmpty()) { return Collections.emptyList(); } // 3. 批量查候选用户的标签集合 ListMatchUserVO resultList new ArrayList(); for (Long candidateId : candidateIds) { SetLong candidateTags userTagMapper.selectTagIdsByUserId(candidateId); // 交集和并集计算 Jaccard 相似度 SetLong union new HashSet(myTagIds); union.addAll(candidateTags); SetLong intersection new HashSet(myTagIds); intersection.retainAll(candidateTags); double score (double) intersection.size() / union.size(); MatchUserVO vo new MatchUserVO(); vo.setUserId(candidateId); vo.setMatchScore(score); resultList.add(vo); } // 4. 按契合度降序排序取前N个 resultList.sort((a, b) - Double.compare(b.getMatchScore(), a.getMatchScore())); return resultList.stream().limit(limit).collect(Collectors.toList()); }这段代码信息量不小。候选人先通过 SQL 粗筛缩小范围然后逐个精确计算排序取前 20整体性能在个人项目场景下毫无压力。3.4 接口设计与统一返回格式约定所有接口统一返回 JSON{ code: 200, message: success, data: { } }Result 类是一个泛型对象code 为 200 表示成功401 表示未登录500 表示业务失败。这样约定以后前端 Axios 拦截器只需要判断 code 字段不需要对 HTTP 状态码做各种特判。这里补充一个大多数人忽略的点跨域配置。SpringBoot 后端如果直接让 Vue 的开发服务器代理转发其实不需要额外处理 CORS我开发阶段用 Vite 的 proxy 来转发/api请求生产环境用 Nginx 反向代理都不会产生跨域问题。但如果前后端分离部署在不同的域名下就必须在 CorsConfig 里配置allowedOrigins。4. 前端实战Vue 3 Vite Pinia 的组件化实现前端工程我用 Vue 3 Vite 构建在开发体验上比 Vue CLI 明显轻快。Vite 的依赖预构建和热更新速度用过的都知道这里不再安利直接讲工程结构和关键实现。4.1 前端工程结构与路由设计页面划分为登录/注册页、首页推荐卡片流、用户详情页、标签选择页、私信会话列表页、聊天窗、个人中心、动态发布与浏览页。// router/index.js const routes [ { path: /, component: HomePage, meta: { requiresAuth: true } }, { path: /login, component: LoginPage }, { path: /register, component: RegisterPage }, { path: /tags, component: TagSelectPage, meta: { requiresAuth: true } }, { path: /messages, component: MessageListPage, meta: { requiresAuth: true } }, { path: /chat/:friendId, component: ChatPage, meta: { requiresAuth: true } }, { path: /profile, component: ProfilePage, meta: { requiresAuth: true } }, { path: /posts, component: PostPage, meta: { requiresAuth: true } } ];路由守卫的作用是拦截未登录用户。我在beforeEach里检查 localStorage 是否存在 token不存在就跳/login存在就放行。这个逻辑花了五分钟写完但避免了每个页面都做重复的登录判断。4.2 首页推荐卡片流与标签选择器首页是整站的门面。推荐卡片流我用了纯 CSS 的 flex 布局 卡片组件每一张卡片展示用户头像、昵称、城市、共同标签和契合度百分比。契合度低于 30% 的卡片在视觉上置灰展示用户一看就知道匹配度高的在顶部。标签选择器是这个系统的关键交互组件。标签库按分类分组展示用户点击切换选中状态上限12个。如果超过12个给一个轻提示。选择结束后保存到后端后端返回推荐列表时就能直接使用。这里有个设计细节标签选择页在首次登录后强制跳转一次用户没选完标签不允许进入推荐页。目的是保证系统冷启动阶段所有用户都有标签数据不至于出现匹配接口查不到候选人的情况。4.3 Axios 封装与接口联调Axios 封装是前端工程的重头戏。我把它拆成三层请求实例配置、请求拦截器、响应拦截器。// api/request.js import axios from axios; import { ElMessage } from element-plus; const request axios.create({ baseURL: /api, timeout: 10000 }); // 请求拦截器自动携带 token request.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer ${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); window.location.href /login; } return Promise.reject(new Error(res.message)); } return res.data; }, error { ElMessage.error(网络异常请稍后重试); return Promise.reject(error); } );baseURL 设成/api之后开发阶段 Vite 的 proxy 可以把/api开头的请求转发到http://localhost:8080生产阶段 Nginx 也可以把/api反向代理到后端服务。这套设计让我在本地和服务器上运行项目时前端代码一个字都不用改。5. 部署上线从本地到云服务器的完整链路整套系统最关键也最容易出问题的是部署环节。我真见过有人代码写得很漂亮结果卡在 Nginx 配置上白白浪费了很多时间。部署方案我用的是最通用、成本最低的方式后端 SpringBoot jar 包部署在服务器上前端 Vue 打包成静态文件交给 Nginx 托管Nginx 反向代理/api接口给后端。5.1 本地构建与打包后端打包前需要在 application.yml 里把数据库连接改成服务器的内网或公网地址server: port: 8080 servlet: context-path: /api spring: datasource: url: jdbc:mysql://your-server-ip:3306/soulmate_db?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/Shanghai username: root password: your-password driver-class-name: com.mysql.cj.jdbc.Driver注意 MySQL 8.0 必须带serverTimezone参数否则数据库连接会报时区错误。这行配置在本地和服务器上我都踩过坑。执行打包mvn clean package -DskipTests打包完成后target 目录下会生成soulmate-0.0.1-SNAPSHOT.jar。这个 jar 内置了 Tomcat不用再装额外容器直接java -jar就能启动。前端打包npm install npm run build构建完成后会在 dist 目录生成静态文件下一步把整个 dist 目录上传到服务器即可。5.2 服务器环境准备服务器我用的 CentOS 7 系统如果是从零开始需要依次装好 JDK、MySQL、Nginx。JDK 安装最省事的方式yum install -y java-1.8.0-openjdk如果不想用系统自带的 JDK也可以下载 JDK 8 或 11 的 tar 包解压后配置环境变量。注意 SpringBoot 2.5 以下版本对高版本 JDK 支持不太好SpringBoot 2.7 可以配 JDK 8 或 11这个组合非常稳。MySQL 8.0 安装一定要看官方仓库的方式别用系统自带的 mariadb 替代否则容易出现驱动不兼容# 下载官方 MySQL rpm 仓库 wget https://repo.mysql.com//mysql80-community-release-el7-3.noarch.rpm rpm -ivh mysql80-community-release-el7-3.noarch.rpm yum install -y mysql-server systemctl start mysqld初次安装后MySQL 会生成一个临时 root 密码在日志里找grep temporary password /var/log/mysqld.log用临时密码登录后必须立刻修改密码然后创建数据库导入我提供的 init.sql 即可。这里提醒一句MySQL 8.0 默认的密码校验策略比较严格如果密码太简单比如只含数字会提示不符合策略。可以在 MySQL 里修改 validate_password 策略但不建议在生产环境这样做。Nginx 安装yum install -y nginx启动后访问服务器 IP 看到默认页面说明 Nginx 正常工作。5.3 Nginx 反向代理配置全解析下面是我线上正在用的 nginx 配置注释都写好了可以直接照着改。server { listen 80; server_name your-domain.com; # 改成自己的域名或服务器IP # 前端静态资源 root /usr/share/nginx/dist; index index.html; # 解决 Vue Router history 模式刷新404的问题 location / { try_files $uri $uri/ /index.html; } # 后端接口反向代理 location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # 上传的头像和图片静态资源 location /upload/ { alias /usr/share/nginx/upload/; } # 静态资源缓存提升加载速度 location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { expires 7d; add_header Cache-Control public, no-transform; } }nginx 配置里最容易踩坑的是try_files $uri $uri/ /index.html这一行。如果前端用了 Vue Router 的 history 模式刷新非根路径页面比如/profile时会向 Nginx 请求这个路径找不到真实文件就会出现 404。加上这行请求会回退到 index.html由前端路由接管问题就解决了。5.4 部署过程常见问题排查速查表这个表我整理自真实排障经验几乎覆盖了常见的部署事故。问题现象排查方向解决方案前端页面能打开接口 404看 Nginx location 匹配规则确认接口路径以 /api 开头且 proxy_pass 转发地址正确后端启动报端口被占用检查8080端口netstat -tlnp | grep 8080找到PIDkill 后重启数据库连接拒绝检查 MySQL 状态和端口systemctl status mysqld确认3306端口监听检查防火墙前端页面白屏看浏览器 Console 报错确认 dist 文件是否正确上传Nginx root 路径是否指向 dist 目录上传头像失败看 Nginx 错误日志检查 upload 目录的读写权限路径是否挂载正确页面刷新后 404前端路由模式与 Nginx 配置冲突确认 try_files 配置存在且写法正确接口返回 401 后无限跳转登录页检查 token 过期和 JWT 密钥确认前后端密钥一致过期时间合理Axios 拦截器不要重复跳转部署完整流程走通之后整套系统在云服务器上跑起来那一刻你会发现之前在本地改代码、调样式、捋接口的每一分钟都没白费。6. 我对这套系统的复盘与扩展建议回顾整个开发过程我最想强调的一点是任何看起来“高大上”的系统落到底层就是数据的增删改查加合理的业务逻辑。交友网站的亮点在于“志同道合”这个匹配场景而技术上真正考验人的是能不能把标签体系、关系链和消息模块的边界理顺。这个项目后续扩展空间非常大。比如把私信的轮询机制升级成 WebSocket 或 SSE聊天体验会有一个质的飞跃再比如引入 Redis 做推荐结果的缓存不用每次请求都重算 Jaccard 相似度还可以在部署上换成 Docker Compose一套命令同时拉起 MySQL、后端、Nginx排障和维护成本进一步降低。我个人踩过几次坑之后最大的体会是部署环境永远比代码本身更容易出意外。MySQL 8.0 的时区、Nginx 的 try_files、防火墙端口、jar 包启动参数任何一个环节出问题都能卡一整个晚上。所以如果这个项目对你的价值是“跑通部署”我强烈建议你在本地把 Docker 环境、传统环境两种部署方式各走一遍体验过完整的链路之后你再遇到线上环境问题心里就有底了。最后再分享一个小技巧写完接口之后在浏览器里用 Postman 或 Apifox 把每个接口的请求和响应格式记录下来再跟前端联调。接口文档就是你的设计依据也是项目做完之后最有说服力的交付物。这套方法论比代码本身值钱得多。
返回列表