news 2026/11/3 10:12:38

Spring Boot社交后端实战:从好友申请到WebSocket实时聊天

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot社交后端实战:从好友申请到WebSocket实时聊天

在夜间节目场景里,“交个朋友”是一个非常典型的陌生人社交话题。本文不讨论节目内容本身,而是从开发者的视角,把“黑夜排档·交个朋友”这个选题落地成一个可以运行的社交后端项目:用户注册、兴趣标签匹配、好友申请、实时聊天、离线消息,一条链路完整走通。无论你是刚学完 Spring Boot 的新手,还是准备做校园交友、兴趣社区、夜间陪伴类产品的小团队,本文给出的工程思路和代码都能直接参考。

1. 为什么做“交个朋友”:业务背景与核心概念

1.1 陌生人社交产品要解决什么

“交个朋友”看起来是一句简单的话,但落到互联网产品上,它背后是一整套陌生人社交链路:

  • 让用户先“被看见”:注册、头像、昵称、兴趣标签、城市位置。
  • 让用户“找到人”:按兴趣、地域、活跃度做匹配和推荐。
  • 让用户“建立关系”:好友申请、同意/拒绝、好友列表。
  • 让用户“持续互动”:一对一的实时聊天、离线消息补偿。

这和通讯录类应用不同。通讯录解决“已有关系”的通讯问题,陌生人社交重点解决“如何从陌生人变成朋友”的推荐和破冰问题。所以在后端设计上,匹配逻辑、关系状态机、实时消息三块是核心。

1.2 “黑夜排档”场景的技术启发

夜间时段用户活跃度高、倾诉意愿强,因此产品对这种场景的诉求往往是:

  • 高峰并发波动明显,需要缓存和削峰。
  • 用户更在意私密性,聊天内容必须鉴权。
  • 需要防骚扰、防恶意注册、防刷消息。

所以本文的实战案例虽然规模不大,但会把 Redis、WebSocket、数据库事务、接口幂等等元素都带上,让项目具备向生产环境演进的骨架。

1.3 核心概念区分:好友、关注、会话

很多社交项目会混淆三个概念:

概念是否双向确认典型场景
好友是,需要对方同意私密聊天、朋友圈
关注否,单向即可微博、视频平台
会话是,自动创建客服、群聊、聊天室

本文案例选择“好友”模型,因为“交个朋友”语义上更强调双向确认。好友关系表会记录双向关系,而不是只存一条单向关注。

2. 环境准备与版本说明

2.1 运行环境

本文示例在以下环境测试,但不是唯一组合,你的项目请按实际情况调整。

组件版本建议作用
JDK17运行 Spring Boot
Spring Boot3.2.x后端框架
Maven3.8+依赖管理
MySQL8.0+主数据库
Redis6.2+缓存、Token、在线状态
WebSocketSpring Boot 内置实时消息通道
前端调试浏览器 / wscat / Postman验证接口

如果电脑已经安装旧版 JDK,建议先确认版本:

java -version mvn -v

2.2 为什么这样选型

  • Spring Boot 3.x 简化了配置和依赖管理,适合快速迭代。
  • Redis 在这里承担三个职责:用户 Token 缓存、好友申请防重、在线状态记录。
  • WebSocket 用于一对一聊天。相比 HTTP 轮询,它实时性更好、省流量。
  • MySQL 存核心业务数据。聊天记录先写库再推消息,保证不丢消息。

3. 需求分析与数据库设计

3.1 功能拆分

我们把项目拆成 4 个核心模块:

  1. 用户模块:注册、登录、获取我的信息。
  2. 匹配模块:根据兴趣标签和城市推荐用户。
  3. 好友模块:发送好友申请、通过/拒绝、好友列表。
  4. 消息模块:WebSocket 实时聊天、离线消息拉取。

3.2 数据库表设计

先建立四张表:user、friend_request、friend_relation、chat_message。

创建数据库:

CREATE DATABASE IF NOT EXISTS friend_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE friend_db;

用户表:

CREATE TABLE `user` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `phone` VARCHAR(20) NOT NULL COMMENT '手机号', `nickname` VARCHAR(64) NOT NULL COMMENT '昵称', `avatar` VARCHAR(255) DEFAULT '' COMMENT '头像地址', `city` VARCHAR(32) DEFAULT '' COMMENT '城市', `gender` TINYINT DEFAULT 0 COMMENT '性别 0未知 1男 2女', `bio` VARCHAR(255) DEFAULT '' COMMENT '个人简介', `tags` VARCHAR(255) DEFAULT '' COMMENT '兴趣标签,逗号分隔', `status` TINYINT DEFAULT 1 COMMENT '账号状态 1正常 0禁用', `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY `uk_phone` (`phone`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

好友申请表:

CREATE TABLE `friend_request` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `from_user_id` BIGINT NOT NULL COMMENT '发起方', `to_user_id` BIGINT NOT NULL COMMENT '接收方', `message` VARCHAR(255) DEFAULT '' COMMENT '申请留言', `status` TINYINT DEFAULT 0 COMMENT '0待处理 1同意 2拒绝 3已撤销', `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY `idx_to_status` (`to_user_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='好友申请表';

好友关系表:

CREATE TABLE `friend_relation` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `user_id` BIGINT NOT NULL COMMENT '用户A', `friend_id` BIGINT NOT NULL COMMENT '用户B', `source_request_id` BIGINT DEFAULT NULL COMMENT '来源申请单', `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, KEY `idx_user_friend` (`user_id`, `friend_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='好友关系表';

聊天消息表:

CREATE TABLE `chat_message` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `sender_id` BIGINT NOT NULL, `receiver_id` BIGINT NOT NULL, `content` TEXT NOT NULL, `msg_type` TINYINT DEFAULT 1 COMMENT '1文本 2图片 3语音', `status` TINYINT DEFAULT 0 COMMENT '0未读 1已读', `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, KEY `idx_sender_receiver` (`sender_id`, `receiver_id`, `created_at`), KEY `idx_receiver_status` (`receiver_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='聊天消息表';

3.3 关系状态机

好友申请的状态机建议固定为:

待处理(0) -> 同意(1) -> 建立好友关系 待处理(0) -> 拒绝(2) -> 流程结束 待处理(0) -> 已撤销(3) -> 发起方撤回

业务上要禁止重复发送“我申请过你,你还没处理”的请求。这个判断可以通过 Redis 或数据库唯一约束来实现。

4. 项目搭建:创建 Spring Boot 工程

4.1 推荐项目结构

friend-app ├── pom.xml └── src/main ├── java/com/example/friend │ ├── FriendApplication.java │ ├── config/ │ │ ├── RedisConfig.java │ │ └── WebSocketConfig.java │ ├── controller/ │ │ ├── AuthController.java │ │ ├── FriendController.java │ │ └── MatchController.java │ ├── service/ │ │ ├── AuthService.java │ │ ├── FriendService.java │ │ ├── MatchService.java │ │ └── ChatService.java │ ├── handler/ │ │ └── FriendChatHandler.java │ ├── mapper/ │ │ ├── UserMapper.java │ │ ├── FriendMapper.java │ │ └── ChatMessageMapper.java │ ├── entity/ │ │ ├── User.java │ │ ├── FriendRequest.java │ │ ├── FriendRelation.java │ │ └── ChatMessage.java │ └── common/ │ ├── Result.java │ └── JwtUtil.java └── resources ├── application.yml └── mapper/*.xml

4.2 添加依赖:pom.xml

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.5</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>3.0.3</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> </dependencies>

这里使用 MyBatis 是为了让 SQL 保持可见和可控,便于你理解每条查询。如果平时用 JPA 或 MyBatis-Plus,也可以替换,核心逻辑一致。

4.3 基础配置:application.yml

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/friend_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver data: redis: host: localhost port: 6379 database: 0 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.friend.entity configuration: map-underscore-to-camel-case: true

一个容易被忽略的点是map-underscore-to-camel-case: true,它可以让数据库字段created_at自动映射到 Java 属性createdAt,少写很多 ResultMap。

5. 核心功能实现:注册登录与 Token 鉴权

5.1 登录思路

真实项目一般接短信服务,本文提供两种方式:直接验证码(开发环境可固定为 123456)和 Redis 缓存验证码。Token 使用 JWT 生成,登录后放入 Redis 以便实现登出失效。

先写公共返回类。

package com.example.friend.common; public class Result<T> { private int code; private String message; private T data; public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.code = 0; r.message = "success"; r.data = data; return r; } public static <T> Result<T> fail(String message) { Result<T> r = new Result<>(); r.code = 1; r.message = message; return r; } public int getCode() { return code; } public void setCode(int code) { this.code = code; } public String getMessage() { return message; } public void setMessage(String message) { this.message = message; } public T getData() { return data; } public void setData(T data) { this.data = data; } }

5.2 AuthService 核心代码

package com.example.friend.service; import com.example.friend.common.Result; import com.example.friend.entity.User; import com.example.friend.mapper.UserMapper; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.security.Keys; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import javax.crypto.SecretKey; import java.nio.charset.StandardCharsets; import java.util.Date; import java.util.concurrent.TimeUnit; @Service public class AuthService { private final UserMapper userMapper; private final StringRedisTemplate redisTemplate; private static final SecretKey SECRET_KEY = Keys.hmacShaKeyFor("friend-app-demo-secret-key-2026".getBytes(StandardCharsets.UTF_8)); public AuthService(UserMapper userMapper, StringRedisTemplate redisTemplate) { this.userMapper = userMapper; this.redisTemplate = redisTemplate; } /** * 发送验证码。开发环境直接返回 123456,真实环境应接入短信服务。 */ public Result<String> sendCode(String phone) { String code = "123456"; redisTemplate.opsForValue().set("sms:code:" + phone, code, 5, TimeUnit.MINUTES); return Result.ok("验证码已发送,开发环境为 123456"); } /** * 手机号 + 验证码登录。用户不存在则自动注册。 */ public Result<String> login(String phone, String code) { String cached = redisTemplate.opsForValue().get("sms:code:" + phone); if (cached == null || !cached.equals(code)) { return Result.fail("验证码错误或已过期"); } User user = userMapper.findByPhone(phone); if (user == null) { user = new User(); user.setPhone(phone); user.setNickname("用户" + phone.substring(phone.length() - 4)); userMapper.insert(user); } long userId = user.getId(); String token = Jwts.builder() .setSubject(String.valueOf(userId)) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + 7L * 24 * 3600 * 1000)) .signWith(SECRET_KEY, SignatureAlgorithm.HS256) .compact(); redisTemplate.opsForValue().set("token:user:" + userId, token, 7, TimeUnit.DAYS); redisTemplate.delete("sms:code:" + phone); return Result.ok(token); } }

5.3 登录接口的边界问题

开发中最常见的错误是验证码无限发、接口被刷。生产环境至少要做三件事:

  • 同一手机号 60 秒内只能发送一次。
  • 单 IP 每天限制发送次数。
  • 验证码在 Redis 里必须设置过期时间。

上面代码演示了“过期时间 + 一次性校验”,发送频率可以用简单的redisTemplate.hasKey控制。由于篇幅关系不展开。

6. 核心功能实现:交友匹配与好友申请

6.1 推荐匹配规则

匹配是“交个朋友”类产品最有意思的地方。推荐规则不宜太复杂,先把基础版跑通:

  1. 优先匹配同性/异性时看用户设置。
  2. 优先推荐同城用户。
  3. 优先推荐兴趣标签重合度高的用户。

本文用 SQL 实现一个简化版本:排除自己和已经是好友的用户,排除有未处理申请的用户,再按标签相似度倒序。

6.2 MatchMapper 查询

<!-- 文件路径:src/main/resources/mapper/MatchMapper.xml --> <select id="recommendUsers" resultType="com.example.friend.entity.User"> SELECT u.*, (LENGTH(u.tags) - LENGTH(REPLACE(u.tags, #{tags}, ''))) AS match_score FROM user u WHERE u.status = 1 AND u.id != #{currentUserId} AND u.id NOT IN ( SELECT friend_id FROM friend_relation WHERE user_id = #{currentUserId} ) AND u.id NOT IN ( SELECT to_user_id FROM friend_request WHERE from_user_id = #{currentUserId} AND status = 0 ) <if test="city != null and city != ''"> AND u.city = #{city} </if> ORDER BY match_score DESC, u.created_at DESC LIMIT #{limit} </select>

这里用LENGTH函数做简单的标签重合度排序,本质上不够严谨,因为标签是逗号分隔字符串。生产环境更推荐把用户标签拆成独立的user_tag表,或者引入向量检索。本文重点是演示整体流程。

6.3 发送好友申请

发送申请时必须做幂等校验。用一个 Redis key 记录 60 秒内的重复申请:

package com.example.friend.service; import com.example.friend.common.Result; import com.example.friend.entity.FriendRequest; import com.example.friend.mapper.FriendRequestMapper; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import java.time.Duration; @Service public class FriendService { private final FriendRequestMapper friendRequestMapper; private final StringRedisTemplate redisTemplate; public FriendService(FriendRequestMapper friendRequestMapper, StringRedisTemplate redisTemplate) { this.friendRequestMapper = friendRequestMapper; this.redisTemplate = redisTemplate; } /** * 发送好友申请 */ public Result<String> apply(Long fromUserId, Long toUserId, String message) { if (fromUserId.equals(toUserId)) { return Result.fail("不能添加自己为好友"); } String key = "friend:apply:" + fromUserId + ":" + toUserId; // 已存在好友关系 if (friendRequestMapper.existsFriendRelation(fromUserId, toUserId) > 0) { return Result.fail("你们已经是好友了"); } // 已存在待处理申请 if (friendRequestMapper.existsPendingRequest(fromUserId, toUserId) > 0) { return Result.fail("好友申请已发送,请等待对方处理"); } // 60 秒内重复申请拦截 Boolean first = redisTemplate.opsForValue().setIfAbsent(key, "1", Duration.ofSeconds(60)); if (first != null && !first) { return Result.fail("操作太频繁,请稍后再试"); } FriendRequest request = new FriendRequest(); request.setFromUserId(fromUserId); request.setToUserId(toUserId); request.setMessage(message); request.setStatus(0); friendRequestMapper.insert(request); return Result.ok("申请已发送"); } }

setIfAbsent是 Redis 分布式锁的基础用法。如果两个请求同时进来,只有一个能插入成功,这就是幂等控制。

6.4 处理好友申请

同意申请时,需要在一个事务里完成两件事:

  1. 把 friend_request 的 status 改成 1。
  2. 在 friend_relation 插入两条关系记录:我->他,他->我。
package com.example.friend.service; import com.example.friend.common.Result; import com.example.friend.entity.FriendRelation; import com.example.friend.mapper.FriendMapper; import com.example.friend.mapper.FriendRequestMapper; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class FriendAcceptService { private final FriendRequestMapper friendRequestMapper; private final FriendMapper friendMapper; public FriendAcceptService(FriendRequestMapper friendRequestMapper, FriendMapper friendMapper) { this.friendRequestMapper = friendRequestMapper; this.friendMapper = friendMapper; } @Transactional(rollbackFor = Exception.class) public Result<String> accept(Long toUserId, Long requestId) { FriendRequest request = friendRequestMapper.findById(requestId); if (request == null || !request.getToUserId().equals(toUserId)) { return Result.fail("申请不存在或无权操作"); } if (request.getStatus() != 0) { return Result.fail("申请已处理"); } // 防止并发重复更新,使用乐观更新 int updated = friendRequestMapper.markAccepted(requestId); if (updated == 0) { return Result.fail("申请已被处理"); } // 建立双向关系 insertFriendRelation(request.getFromUserId(), request.getToUserId(), requestId); insertFriendRelation(request.getToUserId(), request.getFromUserId(), requestId); return Result.ok("已添加好友"); } private void insertFriendRelation(Long userId, Long friendId, Long requestId) { FriendRelation relation = new FriendRelation(); relation.setUserId(userId); relation.setFriendId(friendId); relation.setSourceRequestId(requestId); friendMapper.insertRelation(relation); } }

注意markAccepted是带条件更新:

<update id="markAccepted"> UPDATE friend_request SET status = 1 WHERE id = #{id} AND status = 0 </update>

这样即使并发点击,数据库也会保证只能成功更新一次。这是避免“重复好友关系”的关键。

7. 核心功能实现:WebSocket 实时聊天与离线消息

7.1 WebSocket 配置

Spring Boot 中先注册一个 WebSocket 处理器。这里采用最简单的方式:一个/chat/{token}端点,通过路径参数携带用户身份。生产环境建议在握手阶段用 Header 或 Query 参数做 JWT 校验。

package com.example.friend.config; import com.example.friend.handler.FriendChatHandler; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.config.annotation.WebSocketConfigurer; import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; @Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { private final FriendChatHandler friendChatHandler; public WebSocketConfig(FriendChatHandler friendChatHandler) { this.friendChatHandler = friendChatHandler; } @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(friendChatHandler, "/chat/{token}") .setAllowedOrigins("*"); } }

setAllowedOrigins("*")仅用于开发测试。上线后必须限制为你的前端域名,避免其他人直接连接你的 WebSocket 服务。

7.2 在线会话管理器

每个 WebSocket 连接都对应一个用户 ID。我们需要维护一个全局 Map:userId -> WebSocketSession。

package com.example.friend.handler; import com.example.friend.entity.ChatMessage; import com.example.friend.mapper.ChatMessageMapper; import com.example.friend.service.AuthService; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.stereotype.Component; import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.handler.TextWebSocketHandler; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @Component public class FriendChatHandler extends TextWebSocketHandler { private static final Map<Long, WebSocketSession> ONLINE_SESSIONS = new ConcurrentHashMap<>(); private final ObjectMapper objectMapper = new ObjectMapper(); private final ChatMessageMapper chatMessageMapper; public FriendChatHandler(ChatMessageMapper chatMessageMapper) { this.chatMessageMapper = chatMessageMapper; } @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { // 简化解析:从路径参数获取 token String path = session.getUri().getPath(); String token = path.substring(path.lastIndexOf('/') + 1); Long userId = AuthService.parseUserIdFromToken(token); if (userId == null) { session.close(CloseStatus.POLICY_VIOLATION); return; } ONLINE_SESSIONS.put(userId, session); System.out.println("用户上线:" + userId); } @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { JsonNode json = objectMapper.readTree(message.getPayload()); Long senderId = json.get("senderId").asLong(); Long receiverId = json.get("receiverId").asLong(); String content = json.get("content").asText(); // 好友关系校验 if (chatMessageMapper.isFriend(senderId, receiverId) == 0) { session.sendMessage(new TextMessage("{\"code\":1,\"message\":\"你们不是好友,无法聊天\"}")); return; } // 写库,保证离线消息不丢 ChatMessage chatMessage = new ChatMessage(); chatMessage.setSenderId(senderId); chatMessage.setReceiverId(receiverId); chatMessage.setContent(content); chatMessage.setMsgType(1); chatMessage.setStatus(0); chatMessageMapper.insert(chatMessage); // 接收方在线则实时推送,同时插入历史消息 WebSocketSession targetSession = ONLINE_SESSIONS.get(receiverId); if (targetSession != null && targetSession.isOpen()) { targetSession.sendMessage(new TextMessage(message.getPayload())); } } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { ONLINE_SESSIONS.entrySet().removeIf(entry -> entry.getValue().equals(session)); System.out.println("用户下线"); } }

7.3 为什么要先写库再推消息

实时聊天最怕消息丢失。如果先推送再写库,推送给对方成功但数据库写入失败,对方刷新历史记录时消息就会消失。先写库再推送的缺点是会有毫秒级延迟,但能保证消息最终一致。

如果追求更高的实时性和吞吐量,生产环境可以引入消息队列:

发送方 -> WebSocket Handler -> MQ -> 消费者写库 + 推送在线用户

本文示例先写库再推送给在线用户,简单可靠,适合中小型项目。

7.4 离线消息处理

接收方不在线时,消息已经在第 7.2 节写入了数据库。下次用户登录后,调用查询接口拉取未读消息即可。

public List<ChatMessage> pullOfflineMessages(Long userId) { return chatMessageMapper.listUnread(userId); }

更新已读状态:

UPDATE chat_message SET status = 1 WHERE receiver_id = #{userId} AND status = 0

这里要注意:不要一次把所有未读消息全部标记已读,否则用户可能来不及查看。更稳妥的做法是分页拉取,客户端确认“已读”后再调用已读接口,但复杂度会提高。

8. 运行验证与接口演示

8.1 启动项目

环境准备就绪后,直接启动主类:

package com.example.friend; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class FriendApplication { public static void main(String[] args) { SpringApplication.run(FriendApplication.class, args); } }
mvn spring-boot:run

看到如下日志说明启动成功:

Tomcat started on port(s): 8080 (http)

8.2 登录接口测试

使用 curl 模拟两个用户。

curl -X POST http://localhost:8080/auth/sendCode \ -H "Content-Type: application/json" \ -d '{"phone":"13800000001"}'

收到验证码返回后,调用登录接口:

curl -X POST http://localhost:8080/auth/login \ -H "Content-Type: application/json" \ -d '{"phone":"13800000001","code":"123456"}'

返回结果类似:

{ "code": 0, "message": "success", "data": "eyJhbGciOiJIUzI1NiJ9..." }

用同样的方式注册 13800000002 用户。登录后把两个 token 分别记为TOKEN_A和TOKEN_B。

8.3 发好友申请与通过

curl -X POST http://localhost:8080/friend/apply \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TOKEN_A" \ -d '{"toUserId":2,"message":"你好,我是黑夜排档来的"}'

返回申请 ID 后继续调用:

curl -X POST http://localhost:8080/friend/accept \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TOKEN_B" \ -d '{"requestId":1}'

8.4 WebSocket 消息测试

启动一个 Node 环境的命令行工具 wscat,或直接在网页控制台测试。这里以 wscat 为例:

npx wscat -c "ws://localhost:8080/chat/$TOKEN_B"

用户 A 发消息给用户 B:

{ "senderId": 1, "receiverId": 2, "content": "晚上好,交个朋友吗?" }

如果用户 B 在线,会实时收到相同结构的消息。之后查询 B 的历史消息接口,也能看到这条记录,说明消息未丢失。

9. 常见问题与排查思路

问题现象常见原因解决思路
项目启动报端口被占用8080 端口被其他进程使用更换server.port或停掉占用进程
数据库连接失败MySQL 没启动、密码错误检查 application.yml 中的 url、用户名、密码
Redis 连接超时Redis 服务未启动使用redis-cli ping验证
WebSocket 连接立即断开Token 解析失败确认连接地址中的 token 是否完整
发送聊天消息无响应双方不是好友关系先执行好友申请和通过流程
好友申请重复入库并发点击使用 RedissetIfAbsent和数据库条件更新
历史消息查不到消息未写库检查 chat_message 表中是否有记录

排查顺序建议:

  1. 先看控制台日志有没有异常堆栈。
  2. 确认 MySQL 和 Redis 是否正常运行。
  3. 用 curl 调普通接口,确认 HTTP 链路没问题。
  4. 再连接 WebSocket,确认 Token 鉴权是否通过。
  5. 最后检查业务状态:是否真的是好友。

10. 最佳实践与工程建议

10.1 安全与越权校验

陌生人社交产品的越权风险很高。所谓越权,就是 A 用户操作 B 用户的数据。本文示例已经在好友申请处理和聊天里做了基础判断。上线前必须全面检查:

  • 查询好友列表时,只能查自己的。
  • 处理好友申请时,必须校验 toUserId 等于当前登录用户。
  • 删除好友、拉黑等操作也要校验关系归属。

建议在 Controller 层统一从 Token 中解析 userId,不要把 userId 完全信任前端的传参。

10.2 接口幂等与防刷

“交个朋友”场景最怕恶意刷申请、刷消息。工程上推荐:

  • 发送好友申请增加冷却时间,例如 60 秒内不能重复申请同一人。
  • 聊天消息也要限制发送频率,例如每秒最多 5 条。
  • 注册登录接口增加图形验证码或行为验证。

防刷的本质是“让攻击成本高于攻击收益”。判断频率上限时,要结合业务高峰期数据,不能拍脑袋。

10.3 数据合规与隐私

社交产品涉及用户手机号、聊天内容、地理位置,合规是底线。

  • 手机号、聊天记录必须加密存储,至少不能明文落库。
  • 数据库访问遵循最小权限,线上账号不要使用 root。
  • 用户注销时,要提供删除个人数据和聊天记录的途径。
  • 日志中不要打印手机号、Token 等敏感信息。

在开发阶段就可以预留数据导出的接口,后续配合合规审计会轻松很多。

10.4 消息可靠性的进一步优化

前面的代码是“先写库再推送”,适合业务初期。当单机支撑不住时,可以按以下顺序演进:

  1. WebSocket 部署多实例,用 Redis Pub/Sub 做跨节点消息转发。
  2. 引入消息队列,异步写库,削峰填谷。
  3. 聊天记录和在线状态强依赖 Redis,做好持久化和高可用。
  4. 历史消息分页查询,优先查 Redis 热数据,再查 MySQL 冷数据。

在做多实例的时候,ONLINE_SESSIONS这个本地 Map 会失效,要用 Redis 的 Hash 或 Set 记录用户在线节点,然后定向把消息路由到对应实例。这一步是社交后端从单机走向分布式的必经之路。

10.5 日志与监控

建议从第一天就打印结构化日志:

[chat][send] senderId=1 receiverId=2 msgId=1001 cost=5ms [chat][push] receiverId=2 online=true pushCost=2ms

监控层面至少关注三个指标:

  • 好友申请接口的成功率和耗时。
  • WebSocket 在线连接数。
  • 消息推送的平均延迟和失败数量。

当在线人数上涨时,这些指标能帮你快速定位瓶颈是数据库慢查询、Redis 连接不足,还是网络带宽。

11. 总结与学习路线

从零搭建一个“交个朋友”社交后端,核心收获可以归纳为四点:

第一,业务状态机要先于代码设计清楚。好友申请的待处理、同意、拒绝、撤销,没有状态机会很容易写出“一个用户被添加两次”的脏数据。

第二,幂等控制在社交场景里格外重要。重复申请靠 Redis 去重,并发同意靠数据库条件更新,两层防线都要有。

第三,实时聊天不能只看 WebSocket。跨节点在线状态、离线消息存储、接口频率限制,这些才是生产环境真正考验人的地方。

第四,安全边界要主动设防。Token 鉴权、好友关系校验、越权拦截、敏感信息脱敏,每一步都不能省。

下一步你可以继续深入的方向包括:

  • 用 Redis Streams 或 RocketMQ 重构消息推送链路,理解异步解耦。
  • 引入 Netty 自研长连接网关,对比 Spring WebSocket 的性能差异。
  • 把关注/拉黑功能加入关系模型,完善用户之间的约束逻辑。
  • 学习向量检索或协同过滤算法,替换掉本文的简单标签匹配。

如果你最近也在写社交类项目,建议先把这个单机版完整跑通,再逐步升级。代码跑通后再谈架构优化,比一开始就堆中间件务实得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 4:38:41

Python坦克大战源码实战:从解压运行到改造排错

简介&#xff1a;Python坦克大战源代码压缩包&#xff0c;是针对Python初学者和游戏开发爱好者的入门级实战项目&#xff0c;通过一个经典坦克对战游戏串联多个编程知识点。资源共74个文件&#xff0c;约12.17MB&#xff0c;包含6个Python源文件&#xff08;如Game5.py主入口以…

作者头像 李华
网站建设 2026/10/14 9:15:38

SpringBoot+Vue外卖系统课设实战:从环境搭建到二次开发避坑指南

简介&#xff1a;这份资源是面向高校计算机相关专业学生的Java课程设计与期末大作业参考项目&#xff0c;采用SpringBoot后端搭配Vue前端实现完整的外卖管理系统&#xff0c;适合需要提交课程设计、毕业设计或自学前后端分离开发的学习者使用。压缩包共192个文件&#xff0c;约…

作者头像 李华
网站建设 2026/10/14 12:48:35

Spring Boot 2.7 + Vue 2 房屋出租系统实战

简介&#xff1a;本资源是一套面向计算机类专业本科生的毕业设计级房屋出租管理系统完整实现&#xff0c;聚焦Spring Boot与Vue.js前后端分离开发实践&#xff0c;解决传统租赁业务数字化管理难题。系统涵盖房屋发布与检索、租客与合同全周期管理、租金在线核算、多角色权限控制…

作者头像 李华
网站建设 2026/10/15 16:46:48

Java基础学习笔记 04、数组与Arrays工具类

文章目录 前言 一、一维数组 基本认识 内存空间 二、二维数组 基本认识 三、工具类Arrays 前言 本文是付费专栏 《Java后端开发从入门到进阶》 的配套文章,所属阶段与专题为「阶段一:Java基础与编程内功 / 1.1 Java语言基础」。 专栏介绍:适合 Java 初学者及希望系统查漏补…

作者头像 李华
网站建设 2026/10/15 18:28:53

Agent-Reach实战:破解AI智能体触达能力的落地瓶颈

我做了三年多AI应用落地&#xff0c;踩过的坑比看过的文档都多。今天想聊一个我最近完整跑下来的项目——Agent-Reach。这个名字听起来很抽象&#xff0c;但本质上它解决的是一个问题&#xff1a;怎么让你的AI智能体真正触达用户、系统、数据&#xff0c;把“能聊天”变成“能干…

作者头像 李华
网站建设 2026/10/17 10:35:20

从0自建工业能源监测系统:低成本搞定能耗监控与电费优化

“能耗超标被罚款”这几个字&#xff0c;我估计在座搞生产的兄弟一听就头疼。工业生产里&#xff0c;电费单上的数字从来不是简单“用了多少度电”那么单纯。峰谷电价、需量电费、力调电费&#xff0c;每一项背后都可能埋着罚款的雷。尤其这两年各地对单位能耗和碳排的考核越来…

作者头像 李华