聊天消息撤回重点关注
聊天消息撤回由于属于一个低频率的操作,所以我们重点关注实时性、一致性即可
主要技术及解决方案
一、消息分发与同步
- 消息分发与同步相对复杂,由于一条消息可能会被发送给多个接收方,所以撤回功能必须要确保所有接收方都能同步收到撤回操作,这就要求撤回指令能有效分发并被客户端正确处理
- 不同的客户端可能会有不同的同步延迟或网络问题,如何确保所有客户端在撤回后尽快同步消息状态是一个挑战
解决方案:
- 消息撤回通知机制:使用消息队列Kafka来分发撤回通知
- 可靠传输协议:使用可靠的传输协议WebSocket保持客户端与服务器的长连接,即时推送撤回通知
- 冗余确认机制:每个客户端在接收到撤回消息后,必须向服务器发送确认消息
二、存储与缓存一致性
- 消息撤回通常涉及到消息状态的修改,消息在客户端和服务器之间可能都有缓存。撤回后,需要确保所有缓存、数据库和日志中的消息状态一致更新
- 在数据库层面增加了撤回状态字段,则消息队列、缓存层如Redis等也需要考虑如何及时清理和同步更新
解决方案:
- 软删除和状态字段设计:在数据库中,使用软删除来标记已撤回的消息,保持历史记录的完整性。通过添加字段
revoke
_status
来记录消息是否被撤回 - 缓存与数据库的同步:撤回消息时,通过脚本更新数据库、缓存。如需要,比如网络抖动等特殊情况导致缓存更新失败则通过事务锁、回滚等操作同步删除、更新缓存(由于撤回消息属于一个低频的操作,所以不进行此优化)
- 异步处理机制:使用异步任务处理来更新不同系统中的消息状态,通过消息队列通知缓存层和数据库同步撤回状态
三、多端状态管理
- 如果用户在多个设备上登录(如手机、平板、PC等),撤回消息时需要同时通知所有设备,并且处理设备之间不同步带来的数据冲突问题
- 各个终端的消息展示状态、撤回提示(如"该消息已撤回")都需要一致,避免出现某端显示错误或未撤回的情况
解决方案:
(脱敏)既定规则下,操作一个端时,自动同步其他端
四、历史记录的管理
- 用户有可能在聊天历史中搜索或查看过去的消息,撤回功能需要确保历史记录中的消息也能正确显示已撤回的状态
- 需要避免撤回后的消息在某些缓存中被错误保留,导致用户通过某种方式仍能查看到已撤回的内容
解决方案:
- 更新撤回后的历史记录
- 缓存清理与持久存储同步:消息撤回后,更新客户端的本地缓存与服务器端的持久化存储,通过事件驱动机制(钩子(类似于中间件),进行回调)确保撤回信息同步到所有存储层(es、日志等)。在客户端,定期刷新缓存,以防旧数据残留
- 防止缓存保留已撤回内容:客户端在收到撤回消息时,清除本地缓存的相关内容,避免在用户通过某些方式(如离线模式、数据抓包等)重新读取已撤回的消息
五、安全性与隐私
- 消息撤回的实现要确保消息内容在撤回后不会通过客户端逆向工程、缓存泄露等方式被找回
- 消息撤回需考虑如何防止恶意用户通过撤回功能误导或进行不当操作
解决方案:
- 消息加密与撤回保护:为了防止客户端逆向工程获取撤回的消息内容,消息在传输和存储过程中应使用端到端加密
- 本地存储清理:撤回操作后,客户端及时清理本地缓存中的消息记录,防止用户通过客户端内存或本地文件找回已撤回的消息,同时通过本地文件系统的加密存储,进一步防止消息泄露
- 日志记录:为了防止恶意撤回行为,通过日志记录所有撤回操作
核心代码实现
核心代码实现包括【api】聊天记录消息撤回、【ws】撤回消息通知接收、【ws】消息事件更新
聊天记录消息撤回
一、校验消息参数
if len(req.ServerId) == 0 {
err = openerror.New(cerror.LostRequiredParameters, `缺少聊天消息server_id`)
return
}
if len(req.LocalId) == 0 {
err = openerror.New(cerror.LostRequiredParameters, `缺少聊天消息local_id`)
return
}
if len(req.HkExtUserid) == 0 {
err = openerror.New(cerror.LostRequiredParameters, `缺少聊天消息hk_ext_userid`)
return
}
二、检查消息是否存在
var msgInfo dao.ZqMsg
session := dao.NewZqMsgDao().GetSlaveDB(req.HkUserid).
Where("hk_userid = ?", req.HkUserid).
And("hk_object_id = ?", req.HkExtUserid).
And("server_id = ?", req.ServerId).
And("local_id = ?", req.LocalId)
if req.MsgId > 0 {
session.And("msg_id = ?", req.MsgId)
}
_, err = session.OrderBy("id desc").Get(&msgInfo)
if err != nil {
logger.Ex(ctx, tag, fmt.Sprintf("查询聊天消息失败 req = %+v, err = %v", req, err))
return
}
if msgInfo.MsgId == 0 {
err = openerror.New(cerror.InvalidData, `聊天消息不存在`)
logger.Ex(ctx, tag, fmt.Sprintf("撤回的聊天消息不存在 req = %+v", req))
return
}
三、权限与时效性检查
if msgInfo.SenderWorkCode == `` {
err = openerror.New(cerror.InvalidData, `该消息非客服发送消息,无法撤回`)
logger.Wx(ctx, tag, fmt.Sprintf("撤回的聊天消息非客服发送消息 req = %+v", req))
return
}
//如果消息发送超过2分钟,提示消息超过2分钟无法撤回
if gtools.UnixMillis()-msgInfo.CreateTime > 120*1000 {
err = openerror.New(cerror.InvalidData, `超过2分钟无法撤回`)
logger.Wx(ctx, tag, fmt.Sprintf("撤回的聊天消息超过2分钟 msg_id:%d req = %+v", msgInfo.MsgId, req))
return
}
四、调用RPC接口撤回消息
rpcReq := &proto.RevokeMsgSendRequest{
Request: proto.Request{
HkUserId: req.HkUserid,
CorpId: req.CorpId,
},
MsgId: msgInfo.MsgId,
ServerId: req.ServerId,
LocalId: req.LocalId,
}
rpcResp := &proto.RevokeMsgSendResponse{}
err = client.RevokeMsgSend(ctx, rpcReq, rpcResp)
logger.Ix(ctx, tag, fmt.Sprintf("撤回聊天消息 req:%+v resp:%+v", rpcReq, rpcResp))
if err != nil {
logger.Ex(ctx, tag, fmt.Sprintf("RPC调用失败 req = %+v, err = %v", req, err))
return
}
if rpcResp.TaskId == `` {
logger.Ex(ctx, tag, fmt.Sprintf("撤回聊天消息失败 msg_id:%d req = %+v, resp = %+v", msgInfo.MsgId, req, rpcResp))
return
}
五、缓存记录
_, _ = cache.NewStringRepo().SetNx(ctx, rediskey.RdsImMsgKey, fmt.Sprintf(rediskey.ImMsgRevokeStaffWorkCodeKey, req.MsgId), req.MsgId, 120)
return
撤回消息通知接收
一、注册handle
// hk用户消息事件
handleMap["hk_user_msg_event"] = handle.HkUserMsgEvent
二、消息事件接收,根据事件类型调用对应的处理函数
func HkUserMsgEvent(ctx context.Context, v []byte) (ret bool, err error) {
tag := "HkUserMsgEvent"
ret = true
logger.Ix(ctx, tag+".body", string(v))
eventMsg := new(metadata.WechatZhuqueEvent)
err = json.Unmarshal(v, eventMsg)
if err != nil {
logger.Ex(ctx, tag, fmt.Sprintf("data json unmarshal error. data:%s", string(v)), "error", err)
return
}
switch eventMsg.Event {
case metadata.RetryUploadMediaEvent:
// 处理消息
err = immsgService.HandleImMsgReload(ctx, eventMsg)
if err != nil {
logger.Ex(ctx, tag, fmt.Sprintf("send msg error, err:%+v", err))
return
}
case metadata.RevokeMsgEvent:
err = hkmsgService.RevokeMsg(ctx, eventMsg)
if err != nil {
logger.Ex(ctx, tag, fmt.Sprintf("revoke msg error err:%+v", err))
return
}
default:
logger.Wx(ctx, tag, "event type error")
return
}
return true, err
}
消息事件更新
全部代码不再一一赘述,展示的代码已进行脱敏处理:
一、解析事件数据
二、判断消息是否为引用的消息,是则暂不进行撤回
三、查询消息(是否存在)
四、设置撤回锁,防止消息重复撤回
if back, err = cache.NewStringRepo().SetNx(ctx, rediskey.RdsImMsgKey, fmt.Sprintf(rediskey.ImMsgRevokeLockKey, hkUserMsgInfo.MsgId), hkUserMsgInfo.MsgId, 30); err != nil || !back {
logger.Dx(ctx, tag, fmt.Sprintf("setnx error. hk_userid:%s msg_id:%d server_id:%s back:%v err:%+v", imMsg.HkUserid, hkUserMsgInfo.MsgId, eventBody.ServerId, back, err))
return
}
if hkUserMsgInfo.RevokeStatus == 1 {
logger.Dx(ctx, tag, fmt.Sprintf("msg is revoke hk_userid:%s msg_id:%d server_id:%s err:%+v", imMsg.HkUserid, hkUserMsgInfo.MsgId, eventBody.ServerId, err))
return
}
五、查询消息撤回数量,更新撤回状态
六、判断消息撤回方、撤回消息、推送前端
//构造revoke消息
msgItem := entity.RevokeMsgItem{
Workcode: convInfo.StaffWorkcode,
WxUserid: convInfo.WxUserid,
WxCorpId: convInfo.WxCorpId,
ConvId: convInfo.Id,
ConvType: convInfo.Type,
Msgid: hkUserMsgInfo.MsgId,
ServerId: hkUserMsgInfo.ServerId,
LocalId: hkUserMsgInfo.LocalId,
RevokeTime: eventBody.SendTime * 1000,
UserType: getRevokeUserType(ctx, hkUserMsgInfo),
}
//推送Ws消息到前端
traceId, err := wsmanage.RevokeMsg(ctx, convInfo.StaffWorkcode, convInfo.WxCorpId, &msgItem)