好的,我会基于你的简历背景,以汤斌强的身份来回答这些问题。我会尽量真实、诚恳,既展示优势也坦诚不足。
架构设计类
1. 为什么选择WebSocket而不是轮询或长轮询来实现即时通讯?WebSocket相比HTTP有什么优势?
我选择WebSocket主要基于以下考虑:
- 实时性更好:WebSocket是全双工通信,服务端可以主动推送消息,延迟低,而轮询需要客户端不断发请求,有明显延迟
- 性能更优:轮询会产生大量无效请求,浪费带宽和服务器资源。WebSocket建立连接后保持长连接,减少了HTTP握手的开销
- 服务器压力小:短轮询每次都要建立HTTP连接,高并发下服务器压力很大。WebSocket只需建立一次连接
- WebSocket相比HTTP的优势:持久连接、双向通信、更小的数据帧头部开销、更适合实时通信场景
2. 你的IM系统是单体架构还是分布式架构?如果用户量增长到10万、100万,你会如何优化架构?
目前是单体架构,因为这是学习项目,主要目的是掌握核心技术。
如果用户量增长,我会这样优化:
- 负载均衡:使用Nginx做反向代理和负载均衡,部署多台应用服务器
- Session共享:WebSocket的Session不能像HTTP Session那样共用,需要引入消息中间件如RabbitMQ或Kafka,让不同服务器的用户也能通信
- 数据库优化:读写分离、分库分表,消息表按用户ID或时间进行分表
- 缓存优化:使用Redis集群缓存用户信息、在线状态、好友列表等热点数据
- 微服务改造:将用户服务、消息服务、文件服务拆分成独立的微服务
- 引入消息队列:削峰填谷,处理高并发场景
坦白说,这些只是我的理论设想,实际项目中我还没有实践过分布式架构。
3. WebSocket连接断开后如何处理?有没有实现心跳检测和自动重连机制?
连接断开处理:
- 服务端的@OnClose方法会监听到连接关闭,我会从在线用户的Session映射表中移除该用户
- 将用户状态更新为离线
心跳检测:
- 客户端每隔30秒向服务端发送一个ping消息
- 服务端收到后回复pong
- 如果超过一定时间(比如60秒)没收到心跳,认为连接已断开
自动重连:
- 前端监听WebSocket的onclose和onerror事件
- 断开后使用指数退避算法重连(第一次等1秒,第二次等2秒,第三次等4秒…)
- 最多重连5次,超过后提示用户手动刷新
不过老实说,我在这个项目中心跳检测实现得比较简单,还有优化空间。
4. 如果部署多台服务器,用户A连接服务器1,用户B连接服务器2,他们之间如何通信?你有考虑过这个问题吗?
这是个很好的问题,我有思考过。解决方案有几种:
方案一:使用Redis的发布订阅
- 每台服务器订阅一个Redis频道
- 用户A发消息时,服务器1发布消息到Redis
- 服务器2订阅到消息后,推送给用户B
方案二:使用消息队列(RabbitMQ/Kafka)
- 原理类似,通过MQ实现服务器间的消息转发
- 更适合高并发场景,消息持久化更可靠
方案三:专门的IM服务器
- 引入像Netty这样的高性能通信框架
- 搭建独立的长连接服务器集群
但我这个项目是单机部署的,还没有真正实现分布式通信,这是我接下来想要学习和改进的方向。
5. 系统的并发量大概能支持多少?你做过压力测试吗?
坦白说,我没有做过系统的压力测试,这是项目的不足之处。
根据理论估算:
- 单台服务器理论上可以支持上万个WebSocket连接(取决于服务器配置)
- 但实际并发消息处理能力会受数据库写入、业务逻辑复杂度影响
- 我估计在我的配置下,同时在线500-1000人应该问题不大
如果要做压力测试,我会使用JMeter或者专门的WebSocket压测工具,测试:
- 最大并发连接数
- 消息吞吐量(TPS)
- 响应时间
- 资源占用情况
这是我后续需要补充的工作。
消息相关
6. 消息的可靠性如何保证?如果消息发送失败怎么处理?有重试机制吗?
目前的可靠性保证:
- 消息先存入数据库,再通过WebSocket推送,保证消息不丢失
- 如果接收方离线,消息会保存在数据库,等用户上线后可查询历史记录
发送失败处理:
- 前端会监听消息发送状态,如果发送失败会提示用户
- 用户可以手动重新发送
重试机制:
- 坦白说,目前没有实现自动重试
- 这是一个可以改进的地方,可以在前端加入自动重试逻辑,比如失败后自动重试3次
消息确认机制(ACK)也没有实现,这是我认识到的不足,后续可以加入消息ID和确认机制,类似TCP的确认应答。
7. 消息是否有已读/未读状态?如何实现的?
有实现已读/未读状态。
实现方式:
- 消息表中有一个
isRead字段(0未读,1已读) - 用户接收到消息时默认是未读状态
- 当用户打开聊天窗口查看消息后,前端发送一个”标记已读”的请求
- 后端更新对应消息的
isRead字段为1 - 同时可以统计未读消息数量,在好友列表显示小红点
不过我没有实现像微信那样的”对方已读”提示功能,这个可以通过WebSocket推送已读回执来实现。
8. 群聊消息如果群成员有1000人,你是怎么推送的?会不会造成性能问题?
目前的实现方式:
- 查询群成员列表
- 遍历每个成员,如果在线就通过WebSocket推送
- 同时将消息存入数据库
性能问题:
- 确实会有性能问题,1000人的话需要1000次推送操作
- 如果是同步推送,会阻塞很久
优化方案:
- 异步推送:使用线程池异步处理推送任务,主线程快速返回
- 批量处理:将推送任务放入消息队列,由专门的消费者处理
- 分批推送:每次推送100个,分10批进行
- 只推送在线用户:离线用户不推送,等上线后拉取
坦白说,我的项目主要是小规模测试,没有真正处理过大群的场景,这些是我的优化思路。
9. 离线消息如何处理?用户上线后如何拉取离线消息?有消息数量限制吗?
离线消息处理:
- 当接收方不在线时,消息只存入数据库,不推送
- 消息表中记录了接收者ID和是否已读状态
用户上线后拉取:
- 用户建立WebSocket连接后,会发送一个”拉取离线消息”的请求
- 后端查询数据库,找出该用户所有未读消息(
isRead=0) - 按时间顺序返回给客户端
- 为了避免一次性返回太多数据,我设置了分页,每次最多返回100条
消息数量限制:
- 目前没有设置总量限制
- 但如果离线很久,消息量可能很大,这时候可以只返回最近7天或最近1000条消息
- 更早的消息可以通过”查看更多历史记录”功能分页加载
10. 消息的存储策略是什么?所有消息都永久保存吗?有没有考虑过消息归档或定期清理?
目前的策略:
- 所有消息都保存在MySQL中,没有删除机制
- 这显然不是最优方案,随着时间增长,数据量会越来越大
改进方案:
- 冷热分离:
- 最近30天的消息存MySQL(热数据),方便快速查询
- 超过30天的消息归档到MongoDB或对象存储(冷数据)
- 定期清理:
- 超过1年的消息可以考虑删除(需要用户同意)
- 或者提供”清空聊天记录”功能
- 消息漫游限制:像QQ一样,普通用户保存7天,会员保存更久
这是生产环境必须考虑的问题,我的项目由于是学习性质,还没有实现这些策略。
11. 如何保证消息的顺序性?特别是在高并发场景下?
这是个很好的问题。我目前的实现:
- 数据库层面:消息表有一个自增的主键ID和创建时间
createTime字段 - 查询时排序:按
createTime升序查询,保证显示顺序正确 - 单聊场景:因为是一对一,顺序问题不大
但在高并发场景下确实可能有问题:
- 如果用户A快速发送多条消息,可能出现乱序
- 数据库写入时间可能不准确
改进方案:
- 消息ID生成器:使用雪花算法生成全局唯一且递增的消息ID
- 客户端序号:客户端发送消息时带上序号(1,2,3…),服务端按序号排序
- 时间戳+序号:结合时间戳和序号,确保顺序性
- 消息队列:使用Kafka等支持顺序消费的消息队列
老实说,我在项目中没有遇到明显的乱序问题,因为测试并发量不高,但这确实是需要注意的点。
12. 图片、视频等大文件消息如何处理?有大小限制吗?
图片和文件处理:
- 上传流程:
- 前端选择文件后,先调用文件上传接口
- 后端接收文件,保存到服务器的指定目录(如
/uploads/images/) - 返回文件URL给前端
- 前端将URL作为消息内容发送
- 接收方收到消息后,通过URL展示或下载文件
大小限制:
- 图片限制5MB,避免上传时间过长
- 普通文件限制20MB
- 视频限制100MB
- 这些限制在Spring Boot配置文件中设置
存储位置:
- 目前存在本地服务器
- 更好的方案是使用阿里云OSS、腾讯云COS等对象存储服务
- 优点是不占用服务器空间、支持CDN加速、自动备份
安全措施:
- 检查文件类型(只允许图片、文档、压缩包等)
- 检查文件后缀名
- 重命名文件(UUID+原后缀),避免覆盖和路径遍历攻击
视频处理比较复杂,我项目里主要实现了视频通话的信令交互,文件形式的视频消息只做了基础的上传下载。
数据库设计
13. 能详细说说你的数据库表结构设计吗?用户表、好友关系表、消息表分别有哪些字段?
好的,我详细介绍一下:
用户表(user):
- id (BIGINT, 主键,自增)
- username (VARCHAR, 用户名,唯一)
- password (VARCHAR, 密码,加密存储)
- nickname (VARCHAR, 昵称)
- avatar (VARCHAR, 头像URL)
- email (VARCHAR, 邮箱)
- phone (VARCHAR, 手机号)
- status (TINYINT, 状态:0离线 1在线)
- create_time (DATETIME, 创建时间)
- update_time (DATETIME, 更新时间)好友关系表(friend_relation):
- id (BIGINT, 主键,自增)
- user_id (BIGINT, 用户ID)
- friend_id (BIGINT, 好友ID)
- remark (VARCHAR, 备注名)
- status (TINYINT, 状态:0待确认 1已同意 2已拒绝)
- create_time (DATETIME, 创建时间)
- 唯一索引:(user_id, friend_id)消息表(message):
- id (BIGINT, 主键,自增)
- from_user_id (BIGINT, 发送者ID)
- to_user_id (BIGINT, 接收者ID,单聊时使用)
- group_id (BIGINT, 群ID,群聊时使用,可为空)
- content (TEXT, 消息内容)
- msg_type (TINYINT, 消息类型:1文本 2图片 3文件 4语音 5视频)
- is_read (TINYINT, 是否已读:0未读 1已读)
- create_time (DATETIME, 发送时间)
- 索引:(from_user_id), (to_user_id), (group_id), (create_time)群组表(group_info):
- id (BIGINT, 主键,自增)
- group_name (VARCHAR, 群名称)
- group_avatar (VARCHAR, 群头像)
- owner_id (BIGINT, 群主ID)
- create_time (DATETIME, 创建时间)群成员表(group_member):
- id (BIGINT, 主键,自增)
- group_id (BIGINT, 群ID)
- user_id (BIGINT, 用户ID)
- role (TINYINT, 角色:0普通成员 1管理员 2群主)
- join_time (DATETIME, 加入时间)
- 唯一索引:(group_id, user_id)14. 好友关系表是如何设计的?是单向关系还是双向关系?如何避免重复添加好友?
我采用的是双向关系设计:
- 当用户A添加用户B为好友时,会在表中插入两条记录:
- 一条是A→B(user_id=A, friend_id=B)
- 一条是B→A(user_id=B, friend_id=A)
- 这样查询A的好友列表时,只需查询user_id=A的记录即可
为什么选择双向:
- 查询效率高,不需要用OR条件
- 可以为每个用户设置不同的备注名
- 符合IM系统的业务逻辑
避免重复添加:
- 数据库层面:在user_id和friend_id上建立唯一索引
- 业务层面:添加好友前先查询是否已存在关系
- 如果已存在且status=1,提示”已经是好友”
- 如果已存在且status=0,提示”好友申请已发送,等待对方确认”
好友申请流程:
- A添加B时,先插入状态为0(待确认)的记录
- B收到好友申请后,可以同意或拒绝
- 同意:将两条记录的status更新为1
- 拒绝:将status更新为2,或直接删除记录
15. 消息表的数据量会越来越大,你有考虑过分表分库吗?如何设计分表策略?
确实,消息表是增长最快的表,我有考虑过分表,但项目中还没实现。
分表策略设想:
方案一:按时间分表
- 每月一张表:message_202401, message_202402…
- 优点:查询最近消息很快,旧数据可以归档
- 缺点:跨月查询需要关联多张表
方案二:按用户ID哈希分表
表序号 = user_id % 分表数量
比如:message_0, message_1, ... message_9 (10张表)
- 优点:数据分布均匀,查询某用户的消息只需访问一张表
- 缺点:扩容时需要数据迁移
方案三:按用户ID范围分表
user_id 1-100万 -> message_0
user_id 100万-200万 -> message_1
- 优点:扩容方便
- 缺点:可能数据分布不均
我倾向的方案:
- 垂直分表:将消息内容(content)单独存一张表,主表只存元数据
- 水平分表:按用户ID哈希 + 时间分区相结合
- 冷热分离:旧消息归档到历史表或NoSQL
具体实现可以用MyBatis Plus的分表插件或ShardingSphere中间件。
16. 查询历史消息时,如果数据量很大,如何保证查询效率?有建立索引吗?建在哪些字段上?
索引设计:
- from_user_id索引:查询某用户发送的所有消息
- to_user_id索引:查询某用户接收的所有消息
- (from_user_id, to_user_id, create_time)组合索引:
- 查询两人的聊天记录,并按时间排序
- 这是最常用的查询场景
- group_id索引:查询群聊消息
- create_time索引:按时间范围查询
查询优化:
- 分页查询:使用LIMIT分页,避免一次加载全部数据
SELECT * FROM message
WHERE (from_user_id=? AND to_user_id=?)
OR (from_user_id=? AND to_user_id=?)
ORDER BY create_time DESC
LIMIT 20 OFFSET 0- **避免SELECT ***:只查询需要的字段
- 覆盖索引:把常用字段都放在索引里,避免回表
- 反向查询:查最新消息时,用倒序查询+LIMIT效率更高
ES全文搜索:
- 如果要支持消息内容搜索,可以引入Elasticsearch
- 将消息同步到ES,利用其全文检索能力
缓存策略:
- 最近100条消息缓存到Redis
- 命中缓存直接返回,未命中再查数据库
17. 为什么选择MySQL存储消息?有没有考虑过使用MongoDB这类文档数据库?
选择MySQL的原因:
- 事务支持:需要保证消息存储和好友关系操作的一致性
- 关系型查询:需要关联查询用户信息、好友关系等
- 熟悉度:我对MySQL比较熟悉,开发效率高
- 学习目的:这是学习项目,主要练习关系型数据库
MongoDB的优势:
- Schema灵活:消息类型多样(文本、图片、文件),MongoDB更合适
- 横向扩展:天然支持分片,扩展性更好
- 写入性能:高并发写入场景下性能更优
- 文档存储:消息本身就是独立文档,不需要复杂的关联查询
如果重新设计:
- 用户、好友关系:仍用MySQL,保证ACID特性
- 消息存储:用MongoDB,存储海量消息
- 在线状态、未读数:用Redis,高速读写
- 历史消息归档:用HBase或对象存储
这是一个混合架构,各取所长。不过对于学习项目,MySQL足够用了。
WebSocket相关
18. WebSocket的握手过程是怎样的?底层原理了解吗?
WebSocket握手过程:
1. 客户端发起HTTP请求
GET /chat HTTP/1.1
Host: localhost:8080
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
关键点:
Upgrade: websocket:告诉服务器要升级协议Connection: Upgrade:连接升级Sec-WebSocket-Key:Base64编码的随机字符串,用于握手验证
2. 服务端响应
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
- 状态码
101:协议切换 Sec-WebSocket-Accept:根据客户端的Key计算出来的,用于验证
3. 握手成功
- 此时连接从HTTP升级为WebSocket
- 双方可以互相发送数据帧
底层原理:
- WebSocket基于TCP协议
- 首次握手借用HTTP协议,之后直接通过TCP传输数据帧
- 数据帧格式包括:FIN位、操作码、掩码、载荷长度、载荷数据
- 客户端发送的帧必须有掩码,服务端发送的帧不需要
- 支持文本帧、二进制帧、关闭帧、Ping/Pong心跳帧等
我在项目中用的是Spring Boot的@ServerEndpoint注解,底层实现细节由框架处理了,但理解这些原理有助于排查问题。
19. 如何管理WebSocket的Session?用什么数据结构存储在线用户?
我的Session管理方式:
数据结构:
// 使用ConcurrentHashMap存储,线程安全
private static ConcurrentHashMap<Long, Session> onlineUsers
= new ConcurrentHashMap<>();
// key: 用户ID
// value: WebSocket Session对象为什么用ConcurrentHashMap:
- 线程安全:多个用户同时连接/断开时不会出问题
- 高性能:读多写少的场景,性能优于synchronized
- 方便查询:通过用户ID快速找到对应的Session
Session管理操作:
// 1. 用户连接时
@OnOpen
public void onOpen(Session session, @PathParam("userId") Long userId) {
onlineUsers.put(userId, session);
// 更新数据库中的在线状态
updateUserStatus(userId, 1);
}
// 2. 用户断开时
@OnClose
public void onClose(@PathParam("userId") Long userId) {
onlineUsers.remove(userId);
updateUserStatus(userId, 0);
}
// 3. 发送消息时
public void sendMessage(Long userId, String message) {
Session session = onlineUsers.get(userId);
if (session != null && session.isOpen()) {
session.getBasicRemote().sendText(message);
}
}补充管理:
- 定期清理无效Session(心跳超时的)
- 记录每个Session的最后活跃时间
- 支持一个用户多端登录(手机、电脑),可以用
Map<Long, List<Session>>
改进方向:
- 分布式场景下,Session不能只存在内存,需要存Redis
- 使用Redis的Hash结构:key=userId, value=服务器IP+Session标识
20. 一个用户可以同时在多个设备登录吗?如果可以,消息如何同步到多个设备?
目前的实现:
- 不支持多端登录,后登录会挤掉先登录的
- 实现方式:建立连接时,如果该用户已有Session,先关闭旧Session
支持多端登录的改进方案:
1. 数据结构调整
// 改用Map嵌套
private static ConcurrentHashMap<Long, ConcurrentHashMap<String, Session>>
onlineUsers = new ConcurrentHashMap<>();
// key: 用户ID
// value: Map<设备ID, Session>
// 设备ID可以是:web、android、ios、windows等2. 连接时携带设备信息
ws://localhost:8080/chat/{userId}/{deviceType}
3. 消息同步
- 用户A发送消息给用户B
- 查询用户B的所有在线设备
- 遍历所有Session,推送消息
Map<String, Session> devices = onlineUsers.get(toUserId);
if (devices != null) {
for (Session session : devices.values()) {
if (session.isOpen()) {
session.getBasicRemote().sendText(message);
}
}
}4. 已读状态同步
- 任一设备标记已读后,通知其他设备更新状态
- 通过WebSocket推送”已读同步”消息
挑战:
- 多端同时在线,如何处理消息去重
- 如何区分是”新消息”还是”其他设备已读的同步”
- 离线消息只需推送一次,不能重复发给多个设备
这些细节我还没有完全实现,但思路是这样的。
21. WebSocket连接建立后,如何验证用户身份?JWT Token是放在哪里传递的?
JWT Token传递方式:
方案一:URL参数(我目前用的)
const token = localStorage.getItem('token');
const ws = new WebSocket(`ws://localhost:8080/chat/${userId}?token=${token}`);服务端验证:
@OnOpen
public void onOpen(Session session,
@PathParam("userId") Long userId,
@QueryParam("token") String token) {
// 验证JWT Token
if (!JwtUtil.verify(token)) {
session.close();
return;
}
// 验证token中的userId是否匹配
Long tokenUserId = JwtUtil.getUserId(token);
if (!tokenUserId.equals(userId)) {
session.close();
return;
}
// 验证通过,建立连接
onlineUsers.put(userId, session);
}方案二:通过握手请求的Header(更安全)
const ws = new WebSocket('ws://localhost:8080/chat');
// 在握手时设置header(需要特殊配置)但WebSocket API不直接支持自定义Header,需要:
- 使用
Sec-WebSocket-Protocol字段传递token - 或者在建立连接后,第一条消息发送认证信息
方案三:连接后发送认证消息(最安全)
ws.onopen = () => {
ws.send(JSON.stringify({
type: 'auth',
token: token
}));
};服务端:
@OnMessage
public void onMessage(String message, Session session) {
JSONObject json = JSON.parseObject(message);
if ("auth".equals(json.getString("type"))) {
String token = json.getString("token");
if (JwtUtil.verify(token)) {
// 认证成功,记录session
} else {
session.close();
}
}
}我的项目用的是方案一,虽然简单但把token暴露在URL中,不够安全。生产环境应该用方案三。
22. 如果WebSocket连接异常断开,服务端如何感知?有超时机制吗?
服务端感知连接断开的几种方式:
1. @OnClose注解
@OnClose
public void onClose(@PathParam("userId") Long userId, Session session) {
log.info("用户{}断开连接", userId);
onlineUsers.remove(userId);
updateUserStatus(userId, 0);
}但这个方法只有正常关闭时才会触发,如果是网络异常、客户端崩溃,可能不会触发。
2. @OnError注解
@OnError
public void onError(Session session, Throwable error) {
log.error("WebSocket错误:{}", error.getMessage());
// 清理资源
}3. 心跳机制(最可靠)
客户端定时发送心跳:
setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({type: 'ping'}));
}
}, 30000); // 每30秒一次服务端记录最后心跳时间:
// 维护心跳时间
private static ConcurrentHashMap<Long, Long> lastHeartbeat
= new ConcurrentHashMap<>();
@OnMessage
public void onMessage(String message, @PathParam("userId") Long userId) {
if ("ping".equals(message)) {
lastHeartbeat.put(userId, System.currentTimeMillis());
// 回复pong
sendMessage(userId, "pong");
}
}
// 定时任务检查超时(每分钟执行一次)
@Scheduled(fixedRate = 60000)
public void checkTimeout() {
long now = System.currentTimeMillis();
for (Map.Entry<Long, Long> entry : lastHeartbeat.entrySet()) {
if (now - entry.getValue() > 90000) { // 90秒无心跳
Long userId = entry.getKey();
Session session = onlineUsers.get(userId);
if (session != null) {
session.close();
}
onlineUsers.remove(userId);
lastHeartbeat.remove(userId);
}
}
}超时参数配置:
# application.yml
spring:
websocket:
session-timeout: 60000 # 60秒超时我在项目中实现了基础的心跳,但定时检查超时的任务还没做得很完善。
23. WebSocket的@OnMessage方法是同步处理还是异步处理?如果是同步的,会不会阻塞其他消息?
这是个很好的问题!
默认是同步处理:
- 每个Session的消息是串行处理的
- 如果@OnMessage方法执行时间长,会阻塞该Session的后续消息
- 但不会阻塞其他用户的消息(不同Session之间是独立的)
问题场景:
@OnMessage
public void onMessage(String message, Session session) {
// 如果这里有耗时操作,比如:
processMessage(message); // 处理业务逻辑,2秒
saveToDatabase(message); // 存数据库,1秒
// 总共3秒,这期间该用户的其他消息会被阻塞
}异步处理方案:
方案一:使用线程池
private ExecutorService executor = Executors.newFixedThreadPool(10);
@OnMessage
public void onMessage(String message, Session session) {
executor.submit(() -> {
try {
// 异步处理业务逻辑
processMessage(message);
saveToDatabase(message);
} catch (Exception e) {
log.error("处理消息失败", e);
}
});
}方案二:使用@Async注解
@OnMessage
public void onMessage(String message, Session session) {
messageService.handleMessageAsync(message);
}
@Service
public class MessageService {
@Async
public void handleMessageAsync(String message) {
// 异步处理
}
}方案三:消息队列
@OnMessage
public void onMessage(String message, Session session) {
// 直接放入消息队列
rabbitTemplate.convertAndSend("message.queue", message);
// 立即返回,由消费者异步处理
}我的实践:
- 简单消息(文本):同步处理,速度很快
- 复杂消息(文件、语音转文字):异步处理
- 群发消息:使用线程池异步推送
不过老实说,我项目里大部分操作还是同步的,只有群发做了异步,这也是可以优化的地方。
安全性
24. 如何防止消息被篡改或伪造?
我在项目中实现的安全措施:
1. JWT Token认证
- 每个WebSocket连接必须携带有效的JWT Token
- Token包含用户ID和过期时间,由服务端签名
- 客户端无法伪造Token,因为没有密钥
2. 用户身份验证
- 发送消息时,后端从Token中提取真实的userId
- 不信任客户端传来的userId
// 错误做法(客户端可以伪造userId)
JSONObject msg = JSON.parseObject(message);
Long fromUserId = msg.getLong("fromUserId"); // 危险!
// 正确做法
Long fromUserId = JwtUtil.getUserId(session.getToken()); // 从Token提取3. 消息签名(进阶方案,我没实现)
- 客户端发送消息时,用密钥对消息进行签名
- 服务端验证签名,确保消息未被篡改
signature = HMAC-SHA256(message + timestamp, secretKey)
4. HTTPS + WSS
- 使用WebSocket Secure (wss://)
- 基于TLS加密,防止中间人攻击和消息窃听
- 我本地开发用的是ws://,生产环境应该用wss://
5. 防重放攻击
- 消息中加入时间戳和nonce(随机数)
- 服务端检查时间戳是否在合理范围(如5分钟内)
- 缓存已处理的nonce,拒绝重复消息
坦白说,我的项目主要实现了第1、2点,第3、4、5点都是理论知识,还没有真正实践过。
25. 敏感消息有没有加密传输?用的什么加密算法?
坦白说,我的项目没有实现端到端加密,消息是明文存储在数据库中的。
这是一个明显的不足,如果要实现加密:
传输加密:
- 使用WSS(WebSocket Secure),基于TLS/SSL
- 类似HTTPS,在传输层加密,防止窃听
存储加密:
方案一:对称加密(AES)
// 存储时加密
String encryptedContent = AESUtil.encrypt(content, secretKey);
message.setContent(encryptedContent);
// 读取时解密
String decryptedContent = AESUtil.decrypt(message.getContent(), secretKey);- 优点:速度快
- 缺点:密钥管理困难,如果密钥泄露,所有消息都能被解密
方案二:端到端加密(E2EE) 类似微信、WhatsApp的加密方式:
- 每个用户有一对公钥和私钥(RSA或ECC)
- A发消息给B:
- 用B的公钥加密消息
- B收到后用自己的私钥解密
- 服务端只能看到密文,无法解密
加密:ciphertext = RSA.encrypt(message, B_publicKey)
解密:message = RSA.decrypt(ciphertext, B_privateKey)
方案三:混合加密
- 用AES加密消息(快)
- 用RSA加密AES密钥(安全)
- 这是TLS的做法
我会选择的方案:
- 普通消息:不加密,提高性能
- 敏感消息(如密码、身份证号):AES加密
- 如果要做真正的端到端加密:学习Signal协议
这块确实是我项目的薄弱环节,后续有时间会补上。
26. 如何防止暴力破解登录?除了验证码还有其他措施吗?
我实现的防护措施:
1. 图形验证码
// 使用Easy-Captcha库
@GetMapping("/captcha")
public void captcha(HttpServletResponse response) {
// 生成验证码
SpecCaptcha captcha = new SpecCaptcha(130, 48, 4);
String code = captcha.text();
// 存入Session或Redis
redisTemplate.opsForValue().set("captcha:" + sessionId, code, 5, TimeUnit.MINUTES);
// 输出图片
captcha.out(response.getOutputStream());
}2. 登录失败次数限制(我有实现)
@PostMapping("/login")
public Result login(@RequestBody LoginDTO dto) {
String key = "login:fail:" + dto.getUsername();
Integer failCount = (Integer) redisTemplate.opsForValue().get(key);
// 失败超过5次,锁定30分钟
if (failCount != null && failCount >= 5) {
return Result.error("登录失败次数过多,请30分钟后再试");
}
// 验证密码
if (!passwordCorrect) {
// 失败次数+1
redisTemplate.opsForValue().increment(key);
redisTemplate.expire(key, 30, TimeUnit.MINUTES);
return Result.error("用户名或密码错误");
}
// 登录成功,清除失败记录
redisTemplate.delete(key);
return Result.success(token);
}3. IP限流(没实现,但知道思路)
// 同一IP,1分钟内最多尝试10次
String ipKey = "login:ip:" + getClientIP();
Long count = redisTemplate.opsForValue().increment(ipKey);
if (count == 1) {
redisTemplate.expire(ipKey, 1, TimeUnit.MINUTES);
}
if (count > 10) {
return Result.error("请求过于频繁");
}4. 账号锁定机制
- 连续失败10次,锁定账号24小时
- 需要管理员或邮箱验证才能解锁
5. 异地登录提醒
- 记录用户常用登录地点
- 异地登录时发送邮件或短信验证
6. 双因素认证(2FA)
- 除了密码,还需要手机验证码或Google Authenticator
7. 滑动验证码
- 比图形验证码更难破解
- 可以用第三方服务如腾讯云验证码
我的实现程度:
- ✅ 图形验证码
- ✅ 登录失败次数限制
- ❌ IP限流
- ❌ 账号锁定
- ❌ 双因素认证
主要做了基础防护,高级功能还有待完善。
27. 文件上传有没有做安全校验?如何防止上传恶意文件?
我实现的文件上传安全措施:
1. 文件类型检查
@PostMapping("/upload")
public Result upload(@RequestParam("file") MultipartFile file) {
// 获取原始文件名
String originalFilename = file.getOriginalFilename();
// 检查后缀名
String suffix = originalFilename.substring(originalFilename.lastIndexOf("."));
List<String> allowedTypes = Arrays.asList(".jpg", ".png", ".gif", ".pdf", ".doc", ".docx", ".zip");
if (!allowedTypes.contains(suffix.toLowerCase())) {
return Result.error("不支持的文件类型");
}
}2. 文件大小限制
# application.yml
spring:
servlet:
multipart:
max-file-size: 20MB # 单个文件最大20MB
max-request-size: 50MB # 整个请求最大50MB3. 文件重命名
// 防止路径遍历攻击和文件覆盖
String newFilename = UUID.randomUUID().toString() + suffix;
String filePath = uploadDir + "/" + newFilename;
file.transferTo(new File(filePath));4. MIME类型检查(更严格)
String contentType = file.getContentType();
// 不仅检查后缀,还检查真实的MIME类型
if (!"image/jpeg".equals(contentType) &&
!"image/png".equals(contentType)) {
return Result.error("文件类型不匹配");
}我没实现但应该做的:
5. 文件内容扫描
// 使用工具扫描文件是否包含病毒或恶意代码
// 可以集成ClamAV等杀毒软件6. 图片二次处理
// 重新渲染图片,去除可能的恶意脚本
BufferedImage img = ImageIO.read(file.getInputStream());
ImageIO.write(img, "jpg", new File(savePath));7. 存储路径隔离
// 不要把上传文件放在Web目录下
// 避免直接访问执行(如PHP、JSP文件)
String uploadDir = "/data/uploads"; // 非Web目录8. 禁止执行权限
# Linux下设置上传目录权限
chmod 644 /data/uploads/* # 只读,不可执行9. CDN加速
- 上传到OSS,通过CDN访问
- 隔离应用服务器和文件服务器
我的实现程度:
- ✅ 后缀名检查
- ✅ 大小限制
- ✅ 文件重命名
- ⚠️ MIME类型检查(部分实现)
- ❌ 文件内容扫描
- ❌ 图片二次处理
基础的安全措施做了,但深度防御还不够。
28. JWT Token的过期时间设置多久?Token刷新机制是怎样的?
我的JWT配置:
过期时间设置:
public String generateToken(Long userId) {
return JWT.create()
.withClaim("userId", userId)
.withExpiresAt(new Date(System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000))
// 7天过期
.sign(Algorithm.HMAC256(SECRET_KEY));
}为什么设置7天:
- 太短(如1小时):用户频繁需要重新登录,体验差
- 太长(如30天):安全风险大,Token泄露影响时间长
- 7天是平衡点:既保证安全,又不频繁打扰用户
Token刷新机制(我没完全实现,但知道思路):
方案一:双Token机制
accessToken: 有效期2小时,用于API请求
refreshToken: 有效期7天,用于刷新accessToken
流程:
- 登录成功,返回两个Token
- 请求API时携带accessToken
- accessToken过期,用refreshToken请求新的accessToken
- refreshToken也过期,需要重新登录
@PostMapping("/refresh")
public Result refresh(@RequestHeader("Refresh-Token") String refreshToken) {
if (JwtUtil.verify(refreshToken)) {
Long userId = JwtUtil.getUserId(refreshToken);
String newAccessToken = JwtUtil.generateAccessToken(userId);
return Result.success(newAccessToken);
}
return Result.error("请重新登录");
}方案二:滑动过期
- 每次使用Token,都更新过期时间
- 只要用户活跃,Token永不过期
- 长期不活跃才过期
// 拦截器中实现
if (tokenWillExpireSoon(token)) { // 即将过期(如剩余1天)
String newToken = refreshToken(token);
response.setHeader("New-Token", newToken);
}方案三:Redis黑名单
- Token本身不过期或长过期(如30天)
- 注销时将Token加入Redis黑名单
- 每次验证Token时检查黑名单
public boolean isValid(String token) {
// 检查是否在黑名单
if (redisTemplate.hasKey("token:blacklist:" + token)) {
return false;
}
return JwtUtil.verify(token);
}我目前的实现:
- 只有一个Token,过期时间7天
- 没有refreshToken
- 没有自动刷新机制
- 过期后需要重新登录
这是可以优化的地方,双Token机制是更好的方案。
29. 如何防止CSRF攻击和XSS攻击?
CSRF(跨站请求伪造)防护:
我的防护措施:
-
使用JWT Token
- Token存在localStorage,不在Cookie中
- 恶意网站无法获取Token
- 每次请求手动在Header中携带Token
axios.defaults.headers.common['Authorization'] = 'Bearer ' + token; -
检查Referer(没实现)
String referer = request.getHeader("Referer"); if (referer == null || !referer.startsWith("https://myapp.com")) { return Result.error("非法请求"); } -
CSRF Token(没实现)
- Spring Security提供的CSRF保护
- 每次表单提交需要携带随机生成的CSRF Token
XSS(跨站脚本攻击)防护:
-
前端输入过滤
// Vue.js 默认会转义HTML <div>{{ userInput }}</div> // 自动转义,安全 // 如果必须渲染HTML,要先净化 import DOMPurify from 'dompurify'; const clean = DOMPurify.sanitize(dirtyHTML); -
后端输出转义(我有做)
// 存储消息前,转义HTML特殊字符 public String escapeHtml(String input) { return input.replace("<", "<") .replace(">", ">") .replace("\"", """) .replace("'", "'"); } -
CSP(内容安全策略)(没实现)
response.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self' https://cdn.example.com"); -
HttpOnly Cookie
- 如果用Cookie存Token,设置HttpOnly
- JavaScript无法读取,防止XSS窃取
Cookie cookie = new Cookie("token", token); cookie.setHttpOnly(true); -
输入验证
// 限制输入长度 if (message.length() > 500) { return Result.error("消息过长"); } // 过滤敏感词 message = sensitiveWordFilter.filter(message);
我的实现程度:
- ✅ JWT Token(防CSRF)
- ✅ 基础的HTML转义(防XSS)
- ❌ Referer检查
- ❌ CSRF Token
- ❌ CSP策略
- ⚠️ 输入验证(部分实现)
主要依赖JWT和Vue.js的自动转义,深度防御还不够。
功能细节
30. 语音通话和视频通话的信令交互具体是怎么实现的?用的WebRTC吗?
是的,我使用了WebRTC技术,但实现得比较基础。
WebRTC简介:
- Web Real-Time Communication
- 浏览器间点对点(P2P)音视频通信
- 不需要经过服务器转发媒体流,延迟低
信令交互流程:
1. 发起通话
// 用户A点击"视频通话"
function startCall(userId) {
// 创建RTCPeerConnection
const pc = new RTCPeerConnection(config);
// 获取本地摄像头
navigator.mediaDevices.getUserMedia({ video: true, audio: true })
.then(stream => {
localVideo.srcObject = stream;
stream.getTracks().forEach(track => pc.addTrack(track, stream));
// 创建Offer
return pc.createOffer();
})
.then(offer => {
pc.setLocalDescription(offer);
// 通过WebSocket发送Offer给对方
ws.send(JSON.stringify({
type: 'call-offer',
toUserId: userId,
offer: offer
}));
});
}2. 接收并应答
// 用户B收到Offer
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'call-offer') {
// 创建自己的RTCPeerConnection
const pc = new RTCPeerConnection(config);
// 设置远端描述
pc.setRemoteDescription(new RTCSessionDescription(data.offer));
// 获取本地摄像头
navigator.mediaDevices.getUserMedia({ video: true, audio: true })
.then(stream => {
localVideo.srcObject = stream;
stream.getTracks().forEach(track => pc.addTrack(track, stream));
// 创建Answer
return pc.createAnswer();
})
.then(answer => {
pc.setLocalDescription(answer);
// 发送Answer给对方
ws.send(JSON.stringify({
type: 'call-answer',
toUserId: data.fromUserId,
answer: answer
}));
});
}
};3. ICE候选交换
// 监听ICE候选
pc.onicecandidate = (event) => {
if (event.candidate) {
ws.send(JSON.stringify({
type: 'ice-candidate',
toUserId: remoteUserId,
candidate: event.candidate
}));
}
};
// 收到对方的ICE候选
if (data.type === 'ice-candidate') {
pc.addIceCandidate(new RTCIceCandidate(data.candidate));
}4. 显示远端视频
pc.ontrack = (event) => {
remoteVideo.srcObject = event.streams[0];
};服务端的角色:
@OnMessage
public void onMessage(String message, Session session) {
JSONObject json = JSON.parseObject(message);
String type = json.getString("type");
if ("call-offer".equals(type) ||
"call-answer".equals(type) ||
"ice-candidate".equals(type)) {
// 服务端只做信令转发
Long toUserId = json.getLong("toUserId");
Session targetSession = onlineUsers.get(toUserId);
if (targetSession != null && targetSession.isOpen()) {
targetSession.getBasicRemote().sendText(message);
}
}
}我实现的程度:
- ✅ 基础的Offer/Answer交换
- ✅ ICE候选交换
- ✅ 一对一视频通话
- ❌ 群视频通话(需要MCU/SFU服务器)
- ❌ 屏幕共享
- ❌ 通话录制
- ❌ 网络质量检测
存在的问题:
- NAT穿透可能失败(需要TURN服务器)
- 纯P2P,服务器压力小但不稳定
- 跨网段可能无法连接
改进方向:
- 部署STUN服务器(检测公网IP)
- 部署TURN服务器(中继服务器,NAT穿透失败时使用)
- 使用第三方服务如Agora、腾讯云TRTC
坦白说,WebRTC的实现比较复杂,我只做到了能跑通的程度,还有很多细节需要优化。
31. 群聊的管理功能有哪些?比如踢人、禁言、转让群主等,你实现了吗?
坦白说,我的群聊功能比较基础,只实现了核心的聊天功能。
已实现的功能:
-
✅ 创建群聊
@PostMapping("/group/create") public Result createGroup(@RequestBody GroupDTO dto) { Group group = new Group(); group.setGroupName(dto.getGroupName()); group.setOwnerId(getCurrentUserId()); groupService.save(group); // 创建者自动成为群主 GroupMember member = new GroupMember(); member.setGroupId(group.getId()); member.setUserId(getCurrentUserId()); member.setRole(2); // 2=群主 groupMemberService.save(member); return Result.success(group); } -
✅ 邀请成员
@PostMapping("/group/invite") public Result inviteMember(@RequestBody InviteDTO dto) { // 检查是否已在群中 // 添加成员记录 GroupMember member = new GroupMember(); member.setGroupId(dto.getGroupId()); member.setUserId(dto.getUserId()); member.setRole(0); // 0=普通成员 groupMemberService.save(member); return Result.success(); } -
✅ 退出群聊
@PostMapping("/group/quit") public Result quitGroup(@RequestParam Long groupId) { groupMemberService.remove( new QueryWrapper<GroupMember>() .eq("group_id", groupId) .eq("user_id", getCurrentUserId()) ); return Result.success(); } -
✅ 群消息发送
- 查询群成员
- 遍历推送
未实现的高级功能:
1. 踢人(思路)
@PostMapping("/group/kick")
public Result kickMember(@RequestBody KickDTO dto) {
// 权限检查:只有群主和管理员能踢人
GroupMember operator = getMemberInfo(dto.getGroupId(), getCurrentUserId());
if (operator.getRole() < 1) { // 0=普通成员
return Result.error("无权限");
}
// 不能踢群主
GroupMember target = getMemberInfo(dto.getGroupId(), dto.getTargetUserId());
if (target.getRole() == 2) {
return Result.error("不能踢出群主");
}
// 删除成员记录
groupMemberService.removeById(target.getId());
// 通知被踢的人和群内其他人
notifyGroupMembers(dto.getGroupId(), "xxx被移出群聊");
return Result.success();
}2. 禁言(思路)
// 群成员表增加字段
ALTER TABLE group_member ADD COLUMN is_muted TINYINT DEFAULT 0;
ALTER TABLE group_member ADD COLUMN mute_until DATETIME;
@PostMapping("/group/mute")
public Result muteMember(@RequestBody MuteDTO dto) {
// 权限检查
// 设置禁言
GroupMember member = groupMemberService.getById(dto.getMemberId());
member.setIsMuted(1);
member.setMuteUntil(new Date(System.currentTimeMillis() + dto.getDuration()));
groupMemberService.updateById(member);
return Result.success();
}
// 发消息时检查
@OnMessage
public void onMessage(String message) {
GroupMember member = getMemberInfo(groupId, userId);
if (member.getIsMuted() == 1 &&
new Date().before(member.getMuteUntil())) {
sendError(session, "您已被禁言");
return;
}
// 继续发送消息
}3. 转让群主(思路)
@PostMapping("/group/transfer")
public Result transferOwner(@RequestBody TransferDTO dto) {
// 只有群主能转让
Group group = groupService.getById(dto.getGroupId());
if (!group.getOwnerId().equals(getCurrentUserId())) {
return Result.error("只有群主可以转让");
}
// 更新群主
group.setOwnerId(dto.getNewOwnerId());
groupService.updateById(group);
// 更新成员角色
updateMemberRole(dto.getGroupId(), getCurrentUserId(), 0); // 原群主变普通成员
updateMemberRole(dto.getGroupId(), dto.getNewOwnerId(), 2); // 新群主
return Result.success();
}4. 设置管理员(思路)
@PostMapping("/group/setAdmin")
public Result setAdmin(@RequestBody AdminDTO dto) {
// 只有群主能设置
// 更新成员role为1(管理员)
updateMemberRole(dto.getGroupId(), dto.getUserId(), 1);
return Result.success();
}5. 群公告(思路)
// 群表增加字段
ALTER TABLE group_info ADD COLUMN announcement TEXT;
@PostMapping("/group/announcement")
public Result setAnnouncement(@RequestBody AnnouncementDTO dto) {
// 只有群主和管理员能设置
Group group = groupService.getById(dto.getGroupId());
group.setAnnouncement(dto.getContent());
groupService.updateById(group);
// 通知群成员
notifyGroupMembers(dto.getGroupId(), "群公告已更新");
return Result.success();
}6. 群名片/群昵称(思路)
// 群成员表增加字段
ALTER TABLE group_member ADD COLUMN group_nickname VARCHAR(50);我的实现程度总结:
- ✅ 基础功能:创建、加人、退出、发消息
- ❌ 管理功能:踢人、禁言、转让、设置管理员
- ❌ 高级功能:群公告、群名片、@提醒、消息免打扰
这些都是我后续可以补充的功能,主要是时间和精力有限,先实现了核心流程。
32. 好友申请的流程是怎样的?有通知机制吗?
我实现的好友申请流程:
1. 发送好友申请
@PostMapping("/friend/apply")
public Result applyFriend(@RequestBody FriendApplyDTO dto) {
Long fromUserId = getCurrentUserId();
Long toUserId = dto.getToUserId();
// 检查是否已经是好友
if (isFriend(fromUserId, toUserId)) {
return Result.error("已经是好友了");
}
// 检查是否已发送过申请
FriendRelation existApply = friendRelationService.getOne(
new QueryWrapper<FriendRelation>()
.eq("user_id", fromUserId)
.eq("friend_id", toUserId)
.eq("status", 0) // 待确认
);
if (existApply != null) {
return Result.error("已发送过好友申请,等待对方确认");
}
// 创建好友申请记录
FriendRelation apply = new FriendRelation();
apply.setUserId(fromUserId);
apply.setFriendId(toUserId);
apply.setStatus(0); // 0=待确认
apply.setMessage(dto.getMessage()); // 申请消息
friendRelationService.save(apply);
// 发送通知给对方(如果在线)
sendNotification(toUserId, "好友申请",
getUserName(fromUserId) + "请求添加你为好友");
return Result.success();
}2. 获取好友申请列表
@GetMapping("/friend/applyList")
public Result getApplyList() {
Long userId = getCurrentUserId();
List<FriendRelation> applyList = friendRelationService.list(
new QueryWrapper<FriendRelation>()
.eq("friend_id", userId)
.eq("status", 0)
.orderByDesc("create_time")
);
// 关联查询申请人的信息
List<FriendApplyVO> result = applyList.stream()
.map(apply -> {
User user = userService.getById(apply.getUserId());
FriendApplyVO vo = new FriendApplyVO();
vo.setApplyId(apply.getId());
vo.setUserId(user.getId());
vo.setNickname(user.getNickname());
vo.setAvatar(user.getAvatar());
vo.setMessage(apply.getMessage());
vo.setCreateTime(apply.getCreateTime());
return vo;
})
.collect(Collectors.toList());
return Result.success(result);
}3. 同意好友申请
@PostMapping("/friend/accept")
public Result acceptFriend(@RequestParam Long applyId) {
FriendRelation apply = friendRelationService.getById(applyId);
if (apply == null) {
return Result.error("申请不存在");
}
if (!apply.getFriendId().equals(getCurrentUserId())) {
return Result.error("无权操作");
}
// 更新申请状态
apply.setStatus(1); // 1=已同意
friendRelationService.updateById(apply);
// 创建反向好友关系(双向好友)
FriendRelation reverse = new FriendRelation();
reverse.setUserId(apply.getFriendId());
reverse.setFriendId(apply.getUserId());
reverse.setStatus(1);
friendRelationService.save(reverse);
// 发送通知
sendNotification(apply.getUserId(), "好友申请",
getUserName(getCurrentUserId()) + "已同意你的好友申请");
return Result.success();
}4. 拒绝好友申请
@PostMapping("/friend/reject")
public Result rejectFriend(@RequestParam Long applyId) {
FriendRelation apply = friendRelationService.getById(applyId);
if (apply == null) {
return Result.error("申请不存在");
}
if (!apply.getFriendId().equals(getCurrentUserId())) {
return Result.error("无权操作");
}
// 更新状态为已拒绝 或 直接删除记录
apply.setStatus(2); // 2=已拒绝
friendRelationService.updateById(apply);
// 可选:发送通知(一般不通知被拒绝)
return Result.success();
}通知机制实现:
方案一:WebSocket推送(已实现)
private void sendNotification(Long userId, String title, String content) {
Session session = onlineUsers.get(userId);
if (session != null && session.isOpen()) {
JSONObject notification = new JSONObject();
notification.put("type", "notification");
notification.put("title", title);
notification.put("content", content);
notification.put("time", new Date());
session.getBasicRemote().sendText(notification.toJSONString());
} else {
// 用户离线,存入数据库,等上线后拉取
saveNotificationToDB(userId, title, content);
}
}方案二:系统消息(可补充)
// 创建一个系统消息表
CREATE TABLE system_message (
id BIGINT PRIMARY KEY,
user_id BIGINT,
type VARCHAR(20), -- 'friend_apply', 'friend_accept'等
content TEXT,
is_read TINYINT DEFAULT 0,
create_time DATETIME
);
// 用户上线后查询未读系统消息
@GetMapping("/message/system")
public Result getSystemMessages() {
List<SystemMessage> messages = systemMessageService.list(
new QueryWrapper<SystemMessage>()
.eq("user_id", getCurrentUserId())
.eq("is_read", 0)
.orderByDesc("create_time")
);
return Result.success(messages);
}前端处理:
// 收到好友申请通知
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'notification') {
// 显示桌面通知
new Notification(data.title, {
body: data.content,
icon: '/logo.png'
});
// 更新小红点
updateNotificationBadge();
// 刷新好友申请列表
loadFriendApplyList();
}
};优化点:
- ✅ 申请时可以备注消息
- ✅ 实时通知(WebSocket)
- ⚠️ 离线通知(部分实现)
- ❌ 批量处理申请
- ❌ 申请过期机制(如7天未处理自动过期)
整体流程是完整的,但一些细节功能还可以完善。
33. 撤回消息功能实现了吗?如何实现的?
坦白说,撤回消息功能我没有实现,但我知道实现思路:
实现方案:
1. 数据库设计
-- 消息表增加字段
ALTER TABLE message ADD COLUMN is_recalled TINYINT DEFAULT 0;
ALTER TABLE message ADD COLUMN recall_time DATETIME;2. 后端接口
@PostMapping("/message/recall")
public Result recallMessage(@RequestParam Long messageId) {
Message message = messageService.getById(messageId);
// 权限检查:只能撤回自己的消息
if (!message.getFromUserId().equals(getCurrentUserId())) {
return Result.error("只能撤回自己的消息");
}
// 时间限制:只能撤回2分钟内的消息
long diff = System.currentTimeMillis() - message.getCreateTime().getTime();
if (diff > 2 * 60 * 1000) {
return Result.error("只能撤回2分钟内的消息");
}
// 标记为已撤回
message.setIsRecalled(1);
message.setRecallTime(new Date());
messageService.updateById(message);
// 通知对方(通过WebSocket)
if (message.getToUserId() != null) {
// 单聊
notifyRecall(message.getToUserId(), messageId);
} else {
// 群聊
notifyGroupRecall(message.getGroupId(), messageId);
}
return Result.success();
}
private void notifyRecall(Long userId, Long messageId) {
Session session = onlineUsers.get(userId);
if (session != null && session.isOpen()) {
JSONObject msg = new JSONObject();
msg.put("type", "message_recall");
msg.put("messageId", messageId);
session.getBasicRemote().sendText(msg.toJSONString());
}
}3. 前端处理
// 撤回按钮(长按消息显示)
function recallMessage(messageId) {
axios.post('/message/recall', { messageId })
.then(res => {
if (res.data.success) {
// 本地更新UI
updateMessageToRecalled(messageId);
}
});
}
// 收到撤回通知
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'message_recall') {
// 更新消息显示
updateMessageToRecalled(data.messageId);
}
};
function updateMessageToRecalled(messageId) {
const msgElement = document.getElementById('msg-' + messageId);
msgElement.innerHTML = '<span class="recalled">对方撤回了一条消息</span>';
}4. 查询历史消息时的处理
@GetMapping("/message/history")
public Result getHistory(@RequestParam Long friendId) {
List<Message> messages = messageService.list(...);
// 处理已撤回的消息
messages.forEach(msg -> {
if (msg.getIsRecalled() == 1) {
msg.setContent("对方撤回了一条消息");
msg.setMsgType(1); // 文本类型
}
});
return Result.success(messages);
}高级功能(未实现):
1. 群主/管理员撤回任意消息
if (isGroupOwnerOrAdmin(groupId, getCurrentUserId())) {
// 可以撤回他人消息
}2. 撤回后编辑重发
// 微信的功能
// 撤回时保存消息内容,用户可以编辑后重新发送3. 撤回记录
// 保留撤回记录,用于审计
CREATE TABLE message_recall_log (
id BIGINT PRIMARY KEY,
message_id BIGINT,
user_id BIGINT,
recall_time DATETIME
);技术难点:
- 如何保证多端同步(A在手机撤回,电脑端也要更新)
- 撤回后对方已读怎么处理
- 群聊中,如何保证所有人都收到撤回通知
我为什么没实现:
- 时间有限,先实现核心功能
- 技术难度不大,主要是细节多
- 这是个可以快速补充的功能
如果面试官问到,我会诚实地说没实现,但能详细讲出实现思路。
34. @某人的功能实现了吗?
没有实现,但我了解实现思路:
实现方案:
1. 消息格式设计
{
"content": "@张三 今天开会讨论一下项目",
"mentionedUsers": [123, 456], // 被@的用户ID列表
"type": "text"
}2. 数据库设计
-- 方案一:在消息表中增加字段
ALTER TABLE message ADD COLUMN mentioned_users JSON;
-- 方案二:单独的提醒表
CREATE TABLE message_mention (
id BIGINT PRIMARY KEY,
message_id BIGINT,
mentioned_user_id BIGINT,
is_read TINYINT DEFAULT 0,
create_time DATETIME
);3. 前端输入@
// 在输入框中输入@时,弹出群成员列表
function onInput(event) {
const text = event.target.value;
const lastChar = text[text.length - 1];
if (lastChar === '@') {
// 显示群成员选择器
showMemberSelector();
}
}
// 选择成员后
function selectMember(member) {
const input = document.getElementById('messageInput');
input.value += member.nickname + ' ';
// 记录被@的用户
mentionedUsers.push(member.id);
}4. 发送消息时处理
@OnMessage
public void onMessage(String message, Session session) {
JSONObject json = JSON.parseObject(message);
String content = json.getString("content");
JSONArray mentionedUsers = json.getJSONArray("mentionedUsers");
// 保存消息
Message msg = new Message();
msg.setContent(content);
msg.setMentionedUsers(mentionedUsers.toJSONString());
messageService.save(msg);
// 群发消息
sendToGroup(groupId, msg);
// 特别通知被@的人
if (mentionedUsers != null) {
for (Object userId : mentionedUsers) {
sendMentionNotification((Long)userId, msg);
}
}
}5. 特殊通知
private void sendMentionNotification(Long userId, Message msg) {
Session session = onlineUsers.get(userId);
if (session != null && session.isOpen()) {
JSONObject notification = new JSONObject();
notification.put("type", "mention");
notification.put("messageId", msg.getId());
notification.put("groupId", msg.getGroupId());
notification.put("fromUser", msg.getFromUserId());
notification.put("content", msg.getContent());
session.getBasicRemote().sendText(notification.toJSONString());
} else {
// 离线时,记录到提醒表
saveMentionRecord(userId, msg.getId());
}
}6. 前端显示
// 收到@提醒
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'mention') {
// 显示红点或特殊标记
updateGroupBadge(data.groupId, 'mention');
// 桌面通知
new Notification('有人@你', {
body: data.content
});
// 高亮显示该消息
highlightMessage(data.messageId);
}
};7. 查询所有@我的消息
@GetMapping("/message/mentions")
public Result getMyMentions() {
Long userId = getCurrentUserId();
List<Message> mentions = messageService.list(
new QueryWrapper<Message>()
.like("mentioned_users", "\"" + userId + "\"")
.orderByDesc("create_time")
);
return Result.success(mentions);
}高级功能:
1. @全体成员
if (content.contains("@全体成员")) {
// 检查权限(只有群主/管理员可以)
if (!isGroupOwnerOrAdmin(groupId, userId)) {
return Result.error("无权限@全体成员");
}
// 通知所有人
List<GroupMember> members = getGroupMembers(groupId);
for (GroupMember member : members) {
sendMentionNotification(member.getUserId(), msg);
}
}2. 消息免打扰下仍提醒@
// 即使设置了免打扰,被@时依然要提醒
if (user.isMuted(groupId) && isMentioned(userId, message)) {
sendNotification(userId, message);
}3. @消息快速定位
// 点击"@我的消息",跳转到对应位置
function jumpToMention(messageId) {
const element = document.getElementById('msg-' + messageId);
element.scrollIntoView({ behavior: 'smooth' });
element.classList.add('highlight');
}技术难点:
- 如何解析消息中的@(正则表达式)
- 如何区分文本中的@和真正的提醒
- 消息编辑后@的人变了怎么处理
我为什么没实现:
- 属于增强功能,不是核心必需
- 需要前端UI配合,工作量较大
- 优先实现了基础聊天功能
35. 消息搜索功能有吗?如何实现高效的全文搜索?
没有实现,但我了解几种实现方案:
方案一:MySQL LIKE查询(最简单,性能差)
@GetMapping("/message/search")
public Result searchMessage(@RequestParam String keyword) {
List<Message> results = messageService.list(
new QueryWrapper<Message>()
.like("content", keyword)
.eq("to_user_id", getCurrentUserId())
.or()
.eq("from_user_id", getCurrentUserId())
.orderByDesc("create_time")
.last("LIMIT 100")
);
return Result.success(results);
}- 优点:实现简单
- 缺点:
- LIKE查询不走索引,性能差
- 不支持分词,搜”编程语言”匹配不到”编程”
- 大数据量下很慢
方案二:MySQL全文索引(中等)
-- 创建全文索引(MySQL 5.7+支持中文)
ALTER TABLE message ADD FULLTEXT INDEX idx_content(content) WITH PARSER ngram;
-- 查询
SELECT * FROM message
WHERE MATCH(content) AGAINST('编程' IN NATURAL LANGUAGE MODE);@GetMapping("/message/search")
public Result searchMessage(@RequestParam String keyword) {
// 使用原生SQL
String sql = "SELECT * FROM message " +
"WHERE MATCH(content) AGAINST(? IN NATURAL LANGUAGE MODE) " +
"AND (to_user_id=? OR from_user_id=?) " +
"ORDER BY create_time DESC LIMIT 100";
List<Message> results = messageMapper.searchByFullText(keyword, userId, userId);
return Result.success(results);
}- 优点:性能比LIKE好,支持相关性排序
- 缺点:
- 对中文支持一般
- 功能有限(不如Elasticsearch)
- 更新索引有延迟
方案三:Elasticsearch(最佳,我会选这个)
1. 消息同步到ES
@Service
public class MessageService {
@Autowired
private ElasticsearchRestTemplate esTemplate;
public void saveMessage(Message message) {
// 保存到MySQL
messageMapper.insert(message);
// 同步到ES
MessageDocument doc = new MessageDocument();
doc.setId(message.getId());
doc.setContent(message.getContent());
doc.setFromUserId(message.getFromUserId());
doc.setToUserId(message.getToUserId());
doc.setCreateTime(message.getCreateTime());
esTemplate.save(doc);
}
}
// ES文档定义
@Document(indexName = "messages")
public class MessageDocument {
@Id
private Long id;
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
private String content;
@Field(type = FieldType.Long)
private Long fromUserId;
@Field(type = FieldType.Long)
private Long toUserId;
@Field(type = FieldType.Date)
private Date createTime;
}2. 搜索接口
@GetMapping("/message/search")
public Result searchMessage(@RequestParam String keyword,
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "20") Integer size) {
Long userId = getCurrentUserId();
// 构建查询条件
BoolQueryBuilder query = QueryBuilders.boolQuery()
.must(QueryBuilders.matchQuery("content", keyword))
.should(QueryBuilders.termQuery("fromUserId", userId))
.should(QueryBuilders.termQuery("toUserId", userId))
.minimumShouldMatch(1);
// 执行搜索
NativeSearchQuery searchQuery = new NativeSearchQueryBuilder()
.withQuery(query)
.withPageable(PageRequest.of(page - 1, size))
.withSort(SortBuilders.fieldSort("createTime").order(SortOrder.DESC))
.withHighlightFields(new HighlightBuilder.Field("content"))
.build();
SearchHits<MessageDocument> hits = esTemplate.search(searchQuery, MessageDocument.class);
// 处理高亮
List<MessageVO> results = hits.stream()
.map(hit -> {
MessageDocument doc = hit.getContent();
MessageVO vo = new MessageVO();
vo.setId(doc.getId());
// 获取高亮内容
List<String> highlights = hit.getHighlightField("content");
if (!highlights.isEmpty()) {
vo.setContent(highlights.get(0)); // 高亮后的内容
} else {
vo.setContent(doc.getContent());
}
vo.setFromUserId(doc.getFromUserId());
vo.setCreateTime(doc.getCreateTime());
return vo;
})
.collect(Collectors.toList());
return Result.success(results);
}3. 高级搜索功能
// 按用户筛选
.must(QueryBuilders.termQuery("fromUserId", friendId))
// 按时间范围
.must(QueryBuilders.rangeQuery("createTime")
.gte("2024-01-01")
.lte("2024-12-31"))
// 按消息类型
.must(QueryBuilders.termQuery("msgType", 1)) // 1=文本
// 多字段搜索(搜索内容或发送者昵称)
.should(QueryBuilders.matchQuery("content", keyword))
.should(QueryBuilders.matchQuery("fromUserName", keyword))优点:
- 性能高,百万级数据毫秒级响应
- 支持中文分词(IK分词器)
- 支持高亮、聚合、复杂查询
- 支持搜索建议、纠错
缺点:
- 需要额外部署ES集群