好的,我会基于你的简历背景,以汤斌强的身份来回答这些问题。我会尽量真实、诚恳,既展示优势也坦诚不足。


架构设计类

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. 图片、视频等大文件消息如何处理?有大小限制吗?

图片和文件处理:

  • 上传流程
    1. 前端选择文件后,先调用文件上传接口
    2. 后端接收文件,保存到服务器的指定目录(如/uploads/images/
    3. 返回文件URL给前端
    4. 前端将URL作为消息内容发送
    5. 接收方收到消息后,通过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为好友时,会在表中插入两条记录:
    • 一条是AB(user_id=A, friend_id=B)
    • 一条是BA(user_id=B, friend_id=A)
  • 这样查询A的好友列表时,只需查询user_id=A的记录即可

为什么选择双向:

  • 查询效率高,不需要用OR条件
  • 可以为每个用户设置不同的备注名
  • 符合IM系统的业务逻辑

避免重复添加:

  1. 数据库层面:在user_id和friend_id上建立唯一索引
  2. 业务层面:添加好友前先查询是否已存在关系
  3. 如果已存在且status=1,提示”已经是好友”
  4. 如果已存在且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. 查询历史消息时,如果数据量很大,如何保证查询效率?有建立索引吗?建在哪些字段上?

索引设计:

  1. from_user_id索引:查询某用户发送的所有消息
  2. to_user_id索引:查询某用户接收的所有消息
  3. (from_user_id, to_user_id, create_time)组合索引
    • 查询两人的聊天记录,并按时间排序
    • 这是最常用的查询场景
  4. group_id索引:查询群聊消息
  5. 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的原因:

  1. 事务支持:需要保证消息存储和好友关系操作的一致性
  2. 关系型查询:需要关联查询用户信息、好友关系等
  3. 熟悉度:我对MySQL比较熟悉,开发效率高
  4. 学习目的:这是学习项目,主要练习关系型数据库

MongoDB的优势:

  1. Schema灵活:消息类型多样(文本、图片、文件),MongoDB更合适
  2. 横向扩展:天然支持分片,扩展性更好
  3. 写入性能:高并发写入场景下性能更优
  4. 文档存储:消息本身就是独立文档,不需要复杂的关联查询

如果重新设计:

  • 用户、好友关系:仍用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的加密方式:

  1. 每个用户有一对公钥和私钥(RSA或ECC)
  2. A发消息给B:
    • 用B的公钥加密消息
    • B收到后用自己的私钥解密
  3. 服务端只能看到密文,无法解密
加密: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   # 整个请求最大50MB

3. 文件重命名

// 防止路径遍历攻击和文件覆盖
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

流程:

  1. 登录成功,返回两个Token
  2. 请求API时携带accessToken
  3. accessToken过期,用refreshToken请求新的accessToken
  4. 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(跨站请求伪造)防护

我的防护措施:

  1. 使用JWT Token

    • Token存在localStorage,不在Cookie中
    • 恶意网站无法获取Token
    • 每次请求手动在Header中携带Token
    axios.defaults.headers.common['Authorization'] = 'Bearer ' + token;
  2. 检查Referer(没实现)

    String referer = request.getHeader("Referer");
    if (referer == null || !referer.startsWith("https://myapp.com")) {
        return Result.error("非法请求");
    }
  3. CSRF Token(没实现)

    • Spring Security提供的CSRF保护
    • 每次表单提交需要携带随机生成的CSRF Token

XSS(跨站脚本攻击)防护

  1. 前端输入过滤

    // Vue.js 默认会转义HTML
    <div>{{ userInput }}</div>  // 自动转义,安全
     
    // 如果必须渲染HTML,要先净化
    import DOMPurify from 'dompurify';
    const clean = DOMPurify.sanitize(dirtyHTML);
  2. 后端输出转义(我有做)

    // 存储消息前,转义HTML特殊字符
    public String escapeHtml(String input) {
        return input.replace("<", "&lt;")
                    .replace(">", "&gt;")
                    .replace("\"", "&quot;")
                    .replace("'", "&#x27;");
    }
  3. CSP(内容安全策略)(没实现)

    response.setHeader("Content-Security-Policy", 
        "default-src 'self'; script-src 'self' https://cdn.example.com");
  4. HttpOnly Cookie

    • 如果用Cookie存Token,设置HttpOnly
    • JavaScript无法读取,防止XSS窃取
    Cookie cookie = new Cookie("token", token);
    cookie.setHttpOnly(true);
  5. 输入验证

    // 限制输入长度
    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. 群聊的管理功能有哪些?比如踢人、禁言、转让群主等,你实现了吗?

坦白说,我的群聊功能比较基础,只实现了核心的聊天功能。

已实现的功能

  1. 创建群聊

    @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);
    }
  2. 邀请成员

    @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();
    }
  3. 退出群聊

    @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();
    }
  4. 群消息发送

    • 查询群成员
    • 遍历推送

未实现的高级功能

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集群