
接到这个基于SpringBootVue3MyBatis的交友网站开发需求时我心里其实有点打鼓。这类垂直社交项目和以前做过的管理后台、电商系统都不一样——技术栈本身没什么新鲜的真正的难点在于怎么把志同道合这个偏主观的社交概念翻译成一套可落地的数据模型和匹配逻辑。整个项目从需求梳理到上线部署做了将近一个半月中间踩了不少坑也总结了一些比文档里更有价值的心得。这篇文章就把整个实现过程完整拆一遍从业务建模、后端模块组织、MySQL表设计、Vue3前端搭建到联调排错和最后上线的Nginx配置全部摊开来讲给准备做同类型垂直社交产品的朋友一个可参考的范本。1. 志同道合怎么拆成需求预研阶段的三个关键判断1.1 预研阶段踩到的第一个坑把交友系统当成用户管理系统做刚开始梳理需求时我差点就把这个项目做成了注册登录 用户列表 私信的三件套。如果真这么干系统能跑通但产品上毫无灵魂——用户刷了一圈发现全是陌生人列表根本不会有停留的动力。后来我重新想了志同道合这个词。它其实包含了三个可计算的维度基础条件层性别倾向、年龄段、所在城市。这层相当于粗筛保证推荐结果在基本面上是对的。兴趣标签层运动、读书、摄影、编程、旅行等几十个预置标签注册时让用户至少选3个、最多选6个。这层是匹配算法的核心权重。互动行为层收藏动态、点赞、是否互相点击喜欢。这层前期不做加权但表结构必须预留字段后期算法迭代要用。这个拆解直接决定了整个系统的功能清单。最终我给项目定下来的核心模块是个人主页、动态广场、智能匹配、双向喜欢、私信聊天、后台内容审核。注意我没有把匹配做成简单的条件筛选而是做成了一套有分值的推荐逻辑这部分后面细讲。1.2 技术栈选型的真实考量标题里已经写明技术栈是SpringBoot Vue3 MyBatis MySQL但实际选型时我确实对比过其他方案。为什么不上Spring Cloud微服务早期项目的复杂度根本撑不起服务化带来的基建成本注册中心、配置中心、网关、链路追踪这一套搭下来项目还没跑起来先被基建拖死。单体应用只要模块边界划得清楚后期拆分并不困难。为什么用MyBatis而不是JPA交友系统的查询场景大多是动态条件和多表关联统计MyBatis对SQL的控制力更直观性能优化时可以直接改SQL而不需要摸Hibernate的底层缓存脾气。为什么Vue3而不是Vue2Vue3的组合式API在组织复杂交互逻辑时优势太明显了配合Pinia做状态管理比Vuex写起来清爽得多。实际上这套组合的核心优势在于每个环节都是团队最熟悉的技术学习成本低出了问题网上资料也多。对于一个追求快速上线的垂直社交产品来说稳定熟悉比追逐新奇更重要。1.3 页面清单与功能收口拆完需求后我把页面锁定在七个登录注册页、推荐匹配页、动态广场页、个人主页、私信列表页、聊天详情页、个人信息编辑页。加上后台的内容审核页面总共八个主页面。这里我想强调一个经验社交产品的功能一定不能贪多。刚开始我列了十几张页面包括附近的人、群组、活动发布等。后来砍掉一半只保留核心闭环——用户进来看到匹配推荐了解对方建立联系聊天。这个闭环跑通了产品才有迭代的基础。2. SpringBoot后端设计从包结构到匹配算法的落地2.1 包结构怎么组织才不会被后续需求冲散后端的包结构如果按技术层划分controller、service、mapper各建一个大包项目一膨胀马上乱套。我采用的是按业务域划分 内部技术分层的方式com.example.friendsystem ├── common // 统一返回结果、异常处理、常量 ├── config // JWT拦截器、跨域配置、上传配置 ├── module │ ├── user // 注册登录、个人资料 │ │ ├── controller │ │ ├── service │ │ ├── mapper │ │ └── model │ ├── square // 动态广场发布、评论、点赞 │ ├── match // 匹配推荐、心动记录 │ ├── message // 私信聊天 │ └── admin // 内容审核、用户管理 └── utils // 工具类实际开发下来这个结构最舒服的地方在于每个业务域之间的依赖关系非常清晰。square模块可以引用user模块获取用户信息但反过来user模块完全不知道square的存在这种单向依赖让代码不容易腐化。2.2 JWT登录态的实现细节登录注册这块我直接用了比较标准的方案手机号密码注册密码用BCrypt加密存储登录成功签发JWT令牌前端后续请求在Authorization头里带上令牌后端通过拦截器统一校验。BCrypt至少比MD5靠谱两个量级MD5加盐都理论上存在彩虹表碰撞风险BCrypt自带随机盐且计算代价可调对暴力破解的抵抗能力强很多。JWT工具类核心代码public class TokenUtil { Value(${jwt.secret}) private String secret; Value(${jwt.expire}) private Long expire; public String createToken(Long userId) { return Jwts.builder() .setSubject(String.valueOf(userId)) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() expire)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } public Long parseToken(String token) { try { String subject Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody() .getSubject(); return Long.valueOf(subject); } catch (Exception e) { throw new BizException(登录状态已失效请重新登录); } } }注意JWT的secret必须放在配置文件里不要写死在代码中这个后面部署章节会再提。拦截器的核心逻辑是放行预检请求和登录注册接口其他接口一律解析token并把当前用户ID塞进requestpublic class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 跨域预检请求直接放行 if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String authHeader request.getHeader(Authorization); if (authHeader null || !authHeader.startsWith(Bearer )) { throw new BizException(401, 请先登录); } Long userId tokenUtil.parseToken(authHeader.substring(7)); request.setAttribute(currentUserId, userId); return true; } }这个拦截器最容易被忽略的地方就是OPTIONS预检请求放行很多前后端分离项目联调时跨域报错十有八九是这里漏了。后面联调章节我会专门把这个问题拿出来讲。2.3 匹配算法SQL粗筛 Java精排匹配推荐是整个系统的核心。我一开始想在SQL里把所有维度的分都算完后来发现完全行不通——资料完整度、活跃度这些因子要跨好几张表统计全塞进一条SQL里不仅写起来痛苦查询性能也很差。最终采用的方案是SQL做粗筛Java做精排。SQL层的筛选条件性别符合、年龄段上下浮动5岁、同城或城市为空则不限制、排除已互相关注或已建立关系的用户然后统计标签重合数量先按重合数倒序返回候选列表。核心SQLSELECT u.id, u.nickname, u.avatar, u.bio, COUNT(ut.tag_id) AS common_tags FROM user u INNER JOIN user_tag ut ON ut.user_id u.id WHERE u.id ! #{currentUserId} AND u.status 1 AND u.gender IN (0, #{targetGender}) AND (u.city #{city} OR u.city ) AND u.id NOT IN ( SELECT target_user_id FROM match_record WHERE user_id #{currentUserId} AND is_click_like 1 ) AND ut.tag_id IN ( SELECT tag_id FROM user_tag WHERE user_id #{currentUserId} ) GROUP BY u.id ORDER BY common_tags DESC, u.create_time DESC LIMIT #{limit}Java层拿到候选列表后再做精排加权// 对SQL粗筛结果做二次加权 ListUserMatchVO candidates userMapper.selectMatchUsers(condition); candidates.forEach(vo - { int score vo.getCommonTags() * 10; // 标签重合是核心权重 if (userService.isRecentActive(vo.getId())) { score 5; // 近7天有动态说明活跃 } if (userService.isProfileComplete(vo.getId())) { score 3; // 资料完整提升可信度 } vo.setScore(score); }); candidates.sort((a, b) - b.getScore() - a.getScore());为什么不在SQL里把所有表JOIN起来算分数因为资料完整度要查用户详情表字段活跃度要查动态表每个候选用户都关联查询的话数据库压力会非常大。粗筛先把数据量降到一个很小的范围比如30人Java再做几个单独查询是完全可接受的。3. MySQL建模交友系统比普通业务系统多出来的那些表3.1 八张核心表的职责划分交友系统的表结构设计核心思路是把用户和用户的关系行为分开建模。用户表负责存这个人是谁行为表负责存这个人和别人的连接。整个项目最终落定了八张核心表表名职责核心字段user账号与基础信息id, phone, password, nickname, gender, city, birthday, avatar, bio, status, create_timeuser_detail用户扩展资料id, user_id, occupation, education, height, horoscope, about_metag兴趣标签字典id, name, icon, sortuser_tag用户与标签关联id, user_id, tag_idsquare_post动态广场帖子id, user_id, content, view_count, like_count, comment_count, status, create_timematch_record匹配和心动记录id, user_id, target_user_id, source, is_click_like, create_timechat_message私信消息id, from_user_id, to_user_id, content, msg_type, is_read, create_timereport_record举报记录id, reporter_id, reported_id, report_type, reason, handle_status, create_timeuser表字段设计说明字段类型说明idbigint主键自增phonevarchar(20)登录手机号建唯一索引passwordvarchar(100)BCrypt加密后的密码串gendertinyint0未知1男2女cityvarchar(50)城市注意允许为空birthdaydate生日用于计算年龄avatarvarchar(255)头像URL路径biovarchar(500)个性签名statustinyint1正常2禁用这里要着重说一个设计心得match_record这张表的source字段特别有用。它记录这次匹配是来自每日推荐、同城筛选还是主动搜索以后算法团队做归因分析时这个字段就是最直接的原始数据。很多初学者设计交友系统时会漏掉这张表用临时查询去做推荐结果就是产品上线后完全不知道用户是怎么发现彼此的。3.2 建表与索引上的一些取舍建表时有几个细节是踩了坑才记住的。第一charset必须用utf8mb4而不是utf8。原因很简单用户资料里分分钟出现生僻字或特殊符号utf8mb4才能完美支持四字节字符。如果建表时图省事用了utf8后期遇到特殊字符会直接插入失败到时候做数据迁移更痛苦。第二gender字段上的索引不要建。这个字段的区分度太低了就三个值建了索引MySQL的优化器大概率也不会用。真正有效的是phone的唯一索引以及user_tag表上的(user_id, tag_id)联合唯一索引防止同一个用户重复打同一个标签。第三chat_message表必须对(from_user_id, to_user_id, is_read)建联合索引。私信列表最常见的查询是我和某个人的聊天记录这个联合索引能把扫描行数降到很低。实际测试中不加索引的聊天记录查询在数据量到十万条时已经明显变慢加了索引后稳定在几十毫秒。3.3 查询性能与N1问题动态广场列表页刚开始实现时我写了一个最直观的查询方式先查帖子列表然后循环每个帖子查作者信息。结果数据量一上来接口响应直接飙到3秒多。这就是典型的N1查询问题。解决办法很简单一次性查出所有作者ID再用IN查询批量组装。select idselectUserWithDetail resultMapUserDetailMap SELECT u.*, d.occupation, d.education, d.about_me FROM user u LEFT JOIN user_detail d ON d.user_id u.id WHERE u.id IN foreach collectionids itemid open( separator, close) #{id} /foreach /select原来的循环N次查询变成了1次配合MyBatis的结果映射代码复杂度也没增加多少。这个优化做完广场列表接口从3秒多降到了200毫秒以内体感上完全不是一个产品。4. Vue3前端工程从页面需求倒推状态与组件4.1 页面清单与组件拆分的对照前端使用Vite Vue3 Pinia Vue Router Element Plus这套组合。我习惯先列页面清单再反推组件结构因为页面是用户能感知的最小单位组件是为了服务页面而存在的。整个前端的组件拆分逻辑推荐匹配页核心组件是UserCard用户卡片展示头像、昵称、年龄、城市、标签列表和个性签名配合喜欢/跳过两个操作按钮。动态广场页PostCard动态卡片组件展示内容、图片、点赞数和评论数。私信页ConversationList会话列表和ChatWindow聊天窗口两个配合使用。重点说UserCard这个组件。它承载了匹配页的全部价值所以设计时把信息层级排得很清楚第一眼看到的是头像和昵称第二眼是年龄城市第三眼才是标签和签名。这个视觉优先级直接决定了用户会不会点喜欢。4.2 用户会话管理和路由守卫Pinia在这套系统里的核心作用就是保持登录态。用户登录成功后后端返回token和用户基本信息Pinia的store负责持久化到localStorage路由守卫负责在每次跳转前判断是否要登录。// stores/user.js export const useUserStore defineStore(user, { state: () ({ token: localStorage.getItem(token) || , userInfo: JSON.parse(localStorage.getItem(userInfo) || null) }), actions: { async login(loginForm) { const { data } await http.post(/api/auth/login, loginForm) this.token data.token this.userInfo data.userInfo localStorage.setItem(token, data.token) localStorage.setItem(userInfo, JSON.stringify(data.userInfo)) }, logout() { this.token this.userInfo null localStorage.removeItem(token) localStorage.removeItem(userInfo) } } })// router/index.js router.beforeEach((to, from, next) { const userStore useUserStore() if (to.meta.requiresAuth !userStore.token) { next(/login) } else { next() } })axios拦截器也需要统一处理token否则每个接口都要手动加请求头http.interceptors.request.use(config { const userStore useUserStore() if (userStore.token) { config.headers.Authorization Bearer ${userStore.token} } return config })4.3 消息模块的轮询方案私信消息的实时性在早期版本里我采用的是轮询而不是直接上WebSocket。原因很务实项目上线第一版的目的不是追求技术炫技而是快速跑通产品验证需求。轮询对服务端的压力在千级用户规模下完全可控。实现上我做了个优化固定长轮询加定时兜底。聊天页面激活时每3秒拉一次最新消息。setInterval切换聊天对象时立即清空旧定时器再重新拉取。实现代码function startPolling(conversationId) { if (timer.value) clearInterval(timer.value) fetchMessages(conversationId) timer.value setInterval(() { fetchMessages(conversationId) }, 3000) }这个轮询方案在开发环境完全够用真正遇到实时性瓶颈再升级WebSocket也不迟。做项目的原则永远是够用就好不要前置复杂化。4.4 移动端适配的细节交友系统的用户绝大多数会用手机访问移动端H5适配必不可少。我的方案是viewport rem结合根字号以375px设计稿为基准动态计算所有尺寸用rem单位表达内置postcss-px-to-viewport转换。实际适配下来最常出问题的不是布局而是输入框和按钮在iOS上的交互聊天页输入框容易弹键盘遮挡匹配页按钮容易被误触。这种细节只能真机测试时慢慢调模拟器完全发现不了。5. 前后端联调六个典型坑的完整排查过程5.1 跨域OPTIONS预检请求被拦截现象前端在浏览器里调用接口报错提示CORS错误但用Postman和后端调试工具测试却一切正常。排查过程打开浏览器开发者工具发现每个POST请求前面都有一个OPTIONS预检请求后端返回了401。这个现象说明问题出在后端拦截器把OPTIONS请求拦住了。根因后端JWT拦截器对所有非登录接口做了token校验但OPTIONS预检请求本身不会携带Authorization头所以被拦截器拒绝。解决方案在拦截器里对所有OPTIONS请求直接放行同时后端配置CORS过滤器允许前端来源。Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOrigin(http://localhost:5173); config.addAllowedMethod(*); config.addAllowedHeader(*); config.setAllowCredentials(true); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }5.2 图片上传时被拦截器挡在门外现象上传图片接口一直返回401但普通POST请求都正常。排查过程调出浏览器Network面板发现上传请求的Content-Type从application/json变成了multipart/form-data但拦截器读Authorization头是没有问题的。真正的问题出在别处——我发现前端axios拦截器在上传场景没有生效。根因axios上传文件时使用FormData对象如果请求头没有显式设置Content-Typeaxios会默认让它自动带出来multipart/form-data。但我的上传组件里使用了自定义请求实例这个实例没有复用全局axios拦截器。解决方案上传请求统一复用全局axios实例同时保证Authorization头一定会被带上。上传接口也要在方法参数里加上RequestParam(file)。5.3 数据库时间比当前时间少了8小时现象前端列表页显示的动态发布时间比数据库实际时间少了8小时整。排查过程第一反应是时区问题。检查服务器系统时间和数据库时间发现数据库时间和当前时间一致但Java程序查出来的时间通过接口返回后前端显示少8小时。根因MySQL连接串里没指定serverTimezoneJVM和MySQL的时区判断不一致JDBC在转换时间时按默认时区转换。8小时差是小时区偏移的典型特征。解决方案连接串显式指定时区。spring: datasource: url: jdbc:mysql://localhost:3306/friend_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai jackson: time-zone: GMT8 date-format: yyyy-MM-dd HH:mm:ss5.4 Java时间序列化成数组的怪问题现象接口返回的LocalDateTime字段在JSON里变成了一串数字数组前端没法直接显示。根因Java 8的LocalDateTime默认序列化行为不友好。Jackson核心包不自动处理jsr310时间类型需要额外引入模块。解决方案dependency groupIdcom.fasterxml.jackson.datatype/groupId artifactIdjackson-datatype-jsr310/artifactId /dependency同时配合上面yaml里的时间格式化配置最终接口返回的时间就是标准字符串了。5.5 匹配接口从2秒到200毫秒的优化现象匹配推荐页接口响应时间一直都在2秒以上。排查过程打印MyBatis日志发现每查一个用户资料都会触发一次单独的详情查询。候选列表有30人就是30次额外查询。根因N1查询问题前面第三章已经详细讲过。解决方案候选列表只查基础字段详情资料用IN批量查询一次性组装。5.6 登录按钮连点导致重复请求现象用户在弱网环境点击登录按钮请求还没返回又点了第二次后端收到了两条登录请求。根因前端没有处理按钮的loading状态。解决方案登录请求发出后立即把按钮设为loading请求结束后再恢复。这个方法简单但有效还顺便防住了用户短时间内误触其他提交按钮。问题现象根因解决跨域预检失败前端报CORS错OPTIONS被拦截拦截器放行预检上传401图片传不上FormData丢失全局拦截复用axios实例时间差8小时显示错误时区未指定serverTimezoneAsia/ShanghaiLocalDateTime数组JSON格式异常缺jsr310模块引入jackson-datatype-jsr310N1查询接口响应慢循环查用户批量IN查询重复提交弱网连点没loading状态按钮loading处理6. 部署上线Nginx反代、资源路径与安全加固6.1 前后端分离部署的核心配置部署方案是前端打包成静态文件交给Nginx托管后端SpringBoot以jar包形式运行在内网端口Nginx做反向代理。这样有一个明显好处外部流量永远只经过Nginx的80端口后端的8080端口不对公网开放攻击面一下子就小了。Nginx配置核心部分server { listen 80; server_name _; client_max_body_size 10m; # 后端API反向代理 location /api/ { proxy_pass http://127.0.0.1:8080; 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 /data/friend/upload/; expires 30d; } # 前端页面路由 location / { root /data/friend/dist; try_files $uri $uri/ /index.html; } }这里最关键的是try_files这行配置Vue Router如果使用history模式刷新任意子路由时Nginx必须能够回退到index.html否则一刷新页面就是404。这个坑几乎每个前后端分离项目都会遇到。client_max_body_size也要显式设置否则上传图片超过默认的1MB会直接被Nginx拒绝返回413错误。6.2 环境分离与配置管理开发环境和生产环境的配置一定要分开。我这边使用SpringBoot的多环境profile机制application.yml放公共配置application-dev.yml和application-prod.yml分别放开发和生产配置。关键的生产环境配置项包括JWT的secret使用单独的环境变量注入不写死在配置文件里MySQL连接串中的密码使用环境变量引用上传目录和静态资源目录使用绝对路径。这样部署时只需要在服务器上设置对应的环境变量上下文配置就直接对齐了。代码仓库里不会出现任何生产环境的敏感信息。6.3 一些基础安全实践安全这一块没有做太多炫技的东西但基础的几个点都落实了密码存储BCrypt加密这个在前面已说过。手机号脱敏接口返回用户信息时用VO对象控制输出字段手机号中间四位打码密码字段从不返回。上传白名单图片上传只允许jpg、png、webp三种格式超出直接拒绝避免有人上传可执行脚本。内容维护后台预留了内容审核入口举报记录表可以关联用户状态多次被举报且属实则封禁账号。接口防刷注册接口和发送消息接口做了简单限流同一IP每分钟最多10次请求挡住最基础的机器人骚扰。这些措施足够撑起一个早期垂直社交产品的安全底线。等用户量上来以后再引入验证码、人机校验、内容过滤这些会更合适的方案没必要在起步阶段就把系统搞得很复杂。写到这里整个项目的技术复盘就差不多了。这套系统做完之后最大的一点体会是垂直社交产品能不能立住匹配逻辑和用户体验的说服力比技术选型本身重要得多。SpringBoot、Vue3、MyBatis、MySQL这套组合每个环节都是成熟稳定的技术真正的工程挑战全部集中在如何把业务意图翻译成代码这一个问题上。如果你正准备做类似项目建议先把匹配的权重模型想透再回来写代码顺序反了后面就是无止境地改表结构。