WebSocket开发(客服对话)功能

2022-11-02 16:34:46 浏览数 (1)

前言

在前两篇中完成了客户端一对一聊天消息落地的场景,这次来实现客服对话的场景,先考虑客服对话场景的核心需求。

  1. 区分角色在连接建立时区分用户跟客服的客户端角色
    1. 客服角色客户端id固定
    2. 用户角色客户端id可变
  2. 连接指定客户端无需选择指定客户端,系统自动匹配客服客户端
  3. 一对多一个客服是对应多个用户的
  4. 双向绑定一个客户跟一个客服建立消息连接后重新进入尽量分配给此客服
  5. 消息同步一个客户重新进入连接后并且更换客服后历史消息同步

1. 区分角色

区分角色需要在建立连接时就进行区分,所以在ServerEndpoint地址增加type类型

代码语言:javascript复制
@ServerEndpoint(value = "/api/websocket/client/{type}/{clientId}",encoders = {HashMapEncoder.class, BaseModelEncoder.class})

在建立连接时使用枚举判断客户端连接是那个角色,枚举如下

代码语言:javascript复制
public enum WebSocketTypeEnum {

    /**
     * 用户
     */
    USER(0,"USER"),
    /**
     * 客服
     */
    CUSTOMERSERVICE(1,"KF");

    private int code;
    private String msg;

    WebSocketTypeEnum(int code,String msg){
        this.code = code;
        this.msg = msg;
    }
}

在写入Map结构时要进行判断是写入用户Map还是客服Map

代码语言:javascript复制
    @OnOpen
    public void onOpen(Session session,@PathParam("clientId") String clientId,@PathParam("type") Integer type){
        if (Objects.isNull(WebSocketTypeEnum.getEnum(type))){
            log.info("客户端类型异常:{}",type);
        }
        if (!webSocketClientMap.containsKey(clientId)){
            onlineUsers.addAndGet(1);
        }
        this.clientId = clientId;
        this.type = type;
        if(!webSocketClientMap.containsKey(type)){
            webSocketClientMap.put(type,new HashMap<String,WebSocketClient>());
        }
        webSocketClientMap.get(type).put(clientId,this);

        infoSession = session;
        log.info("客户端:{}建立连接,角色:{},当前用户在线人数:{}",clientId,type,onlineUsers.get());
        /**
         * 持久化
         */
        baseWebSocketService.saveUserLoginEvent(clientId,(byte) 0,new Date());
        /**
         * 消息补偿
         */
        List<ClientCompensateMsg> list = baseWebSocketService.queryClientCompensateMsg(clientId,0);
        if (!CollectionUtils.isEmpty(list)){
            list.forEach(userMessageModel->{
                log.info("消息补偿记录,客户端:{},消息内容:{}",clientId,userMessageModel);
                this.sendMessage(BaseResponseMessage.success(userMessageModel));
            });
        }
    }

Map的获取简单点的话就是定义多个Map实体,设计优雅点就是嵌套Map,如下:

代码语言:javascript复制
    public static HashMap<Integer,HashMap<String,WebSocketClient>> webSocketClientMap = new HashMap<>();

这样的设计只要判断对应TypeMap为空的时候初始化一下,后面直接获取对应TypeMap进行put即可,多个Type也不怕

旧的代码:

代码语言:javascript复制
	// 多个Map
    public static HashMap<String,WebSocketClient> webSocketClientMap = new HashMap<>();

    public static HashMap<String,WebSocketClient> webSocketKFMap = new HashMap<>();
    
    // 多个判断
    if (WebSocketTypeEnum.USER.msg.equals(type))
        webSocketClientMap.put(clientId,this);
    else
        webSocketKFMap.put(clientId,this);

新的代码:

代码语言:javascript复制
	// 一个Map
    public static HashMap<Integer,HashMap<String,WebSocketClient>> webSocketClientMap = new HashMap<>();
    
    // 一个判断兼容多个Type
    if(!webSocketClientMap.containsKey(type)){
        webSocketClientMap.put(type,new HashMap<String,WebSocketClient>());
    }
    webSocketClientMap.get(type).put(clientId,this);

这样角色的区分就完成了,客服客户端ID固定的操作交给客户端传参设置,可以做个简单的校验登录角色

2. 连接指定

之前假设用户是在页面上进行指定客户端进行一对一通讯,在客服场景下用户肯定不能输入客服客户端编号进行通讯吧,那体验可想而知,所以需要判断如果是用户客户端发送的消息就匹配在线的客服将消息推送过去

这时候可以复用用户一对一时的UserMessageModel中的参数acceptId,将客服在线的Map中随机取出一个客户端id放进去进行消息发送。

2.1 发送消息

OnMessage的事件需要进行简单修改,不能直接获取WebScoketClientMap了,得反向获取。

代码如下:

代码语言:javascript复制
    @OnMessage
    public void onMessage(String message, Session session,@PathParam("clientId") String clientId){
        /**
         * 持久化
         */
        baseWebSocketService.saveClientSendMsg(clientId,message,new Date());
        /**
         * 处理消息
         */
        UserMessageModel userMessageModel = JSONObject.parseObject(message, UserMessageModel.class);
        if (userMessageModel == null){
            this.sendMessage(BaseResponseMessage.error(null,"传递参数结构异常"));
        }
        HashMap<String,WebSocketClient> hashMap = webSocketClientMap.get(WebSocketTypeEnum.getAcceptType(this.type));
        if (!CollectionUtils.isEmpty(hashMap)){
            this.toCSucceed(userMessageModel);
        }else{
            log.info("对于客户端不在线");
        }
    }

核心是toCSucceed类中获取WebSocketServer

之前: 直接根据输入的接收端id进行消息发送

代码语言:javascript复制
    private void toCSucceed(UserMessageModel userMessageModel){
        WebSocketClient webSocketClient = webSocketClientMap.get(this.type).get(userMessageModel.getAcceptId());
        BaseResponseMessage infoMsg = BaseResponseMessage.success(userMessageModel);
        /**
         * 持久化
         */
        baseWebSocketService.saveCTOCMsg(this.clientId,webSocketClient.clientId,JSONObject.toJSONString(infoMsg),new Date(),new Date());
        /**
         * 发送消息
         */
        webSocketClient.sendMessage(infoMsg);
        this.sendMessage(infoMsg);
        log.info("客户端:{} 发送到客户端:{},消息内容:{}",clientId,userMessageModel.getAcceptId(),userMessageModel.getMessage());
    }

现在: 获取对应端的连接Map,获取第一个客户端。 当然不能一直获取第一个客户端,现在demo先这样做

代码语言:javascript复制
    private void toCSucceed(UserMessageModel userMessageModel){
        HashMap<String,WebSocketClient> hashMap = webSocketClientMap.get(WebSocketTypeEnum.getAcceptType(this.type));
        WebSocketClient webSocketClient = hashMap.get(hashMap.entrySet().iterator().next().getKey());

        BaseResponseMessage infoMsg = BaseResponseMessage.success(userMessageModel);
        /**
         * 持久化
         */
        baseWebSocketService.saveCTOCMsg(this.clientId,webSocketClient.clientId,JSONObject.toJSONString(infoMsg),new Date(),new Date());
        /**
         * 发送消息
         */
        webSocketClient.sendMessage(infoMsg);
        this.sendMessage(infoMsg);
        log.info("客户端:{} 发送到客户端:{},消息内容:{}",clientId,webSocketClient.clientId,userMessageModel.getMessage());
    }

这样进行修改后客户端不需要输入接收端的编号会自动分配第一个客服客户端的连接,为了优雅一点,我将客户端对于接收端类型放到了枚举中,通过客户端类型找到对应接收端类型来获取接收端的map集

代码如下:

代码语言:javascript复制
public enum WebSocketTypeEnum {

    /**
     * 用户
     */
    USER(0,1,"USER"),
    /**
     * 客服
     */
    CUSTOMERSERVICE(1,0,"KF");

    public int code;
    public int acceptType;
    public String msg;

    WebSocketTypeEnum(int code,int acceptType,String msg){
        this.code = code;
        this.msg = msg;
        this.acceptType = acceptType;
    }

    public static WebSocketTypeEnum getEnum(Integer code){
        for (WebSocketTypeEnum e: WebSocketTypeEnum.values()) {
            if (e.code == code){
                return e;
            }
        }
        return null;
    }

    public static Integer getAcceptType(Integer code){
        for (WebSocketTypeEnum e: WebSocketTypeEnum.values()) {
            if (e.code == code){
                return e.acceptType;
            }
        }
        return null;
    }

}

角色类型是0为用户1为客服,复制一份目前的前端demo,写死连接方式

用户连接地址:

代码语言:javascript复制
var websocket = new WebSocket("ws://127.0.0.1:5822/api/websocket/client/0/" uid);

客服连接地址:

代码语言:javascript复制
var uid = 1;
var websocket = new WebSocket("ws://127.0.0.1:5822/api/websocket/client/1/" uid);

2.2 验证

使用两个角色的客户端进行连接建立和用户客户端的消息发送

2.2.1 建立连接

从日志上看用户跟客服的角色是分开建立了

2.2.2 发送消息

使用用户端不输入接受端id进行消息发送

用户端: 这里不需要填写接收人

客服端:

日志记录:

这样就完成用户给客服单向发消息无需值得接收端了。

2.3 补丁

上面这个还有两个问题

  • 客服无法回馈消息,因为没有发送人客户端信息
  • 用户消息一直会发送给第一个客服客户端
2.3.1 客服端回馈消息

这个处理比较简单,消息的接受是通过UserMessageModel接受的,直接在UserMessageModel中加入发送端参数

代码如下:

代码语言:javascript复制
@Data
public class UserMessageModel {

    /**
     * 消息内容
     */
    private String message;

    /**
     * 发送类型:USER
     */
    private String sendType;

    /**
     * 发送端id
     */
    private String sendId;
    
    /**
     * 接收端id
     */
    private String acceptId;

    /**
     * 接收类型:USER
     */
    private String acceptType;

    /**
     * 消息类型:1:纯文本消息,2:文件消息,3:富文本消息
     */
    private Byte messageType;

}

发送端ID不能通过客户端传参,要通过服务端取建立连接的客户端ID保证合法性,在onMessage事件中转换message消息时将客户端ID填充上去

代码语言:javascript复制
    @OnMessage
    public void onMessage(String message, Session session,@PathParam("clientId") String clientId){
        /**
         * 持久化
         */
        baseWebSocketService.saveClientSendMsg(clientId,message,new Date());
        /**
         * 处理消息
         */
        UserMessageModel userMessageModel = JSONObject.parseObject(message, UserMessageModel.class);
        userMessageModel.setSendId(clientId);
        if (userMessageModel == null){
            this.sendMessage(BaseResponseMessage.error(null,"传递参数结构异常"));
        }
        HashMap<String,WebSocketClient> hashMap = webSocketClientMap.get(WebSocketTypeEnum.getAcceptType(this.type));
        if (!CollectionUtils.isEmpty(hashMap)){
            this.toCSucceed(userMessageModel);
        }else{
            baseWebSocketService.saveClientCompensateMsg(userMessageModel.getAcceptId(),message,(byte) 0);
            log.info("客户端:{} 发送消息到接受端:{} 不在线,放置到代发送列表,当前待发送列表:{}条",clientId,userMessageModel.getAcceptId());
            this.sendMessage(BaseResponseMessage.error(null,"接收端不在线"));
        }
    }

**用户客户端:**用户客户端无需输入接收人即会自动分配客服客户端

客服客户端:

在客服客服端这里也可以得到发送人的客户端ID,通过输入接收人返回消息,这样双端可以开始通讯

2.3.2 多客服端分配

如果是随机分配客服将Map的获取操作搞成随机的即可,如下示例:

代码语言:javascript复制
    private void toCSucceed(UserMessageModel userMessageModel){
        HashMap<String,WebSocketClient> hashMap = webSocketClientMap.get(WebSocketTypeEnum.getAcceptType(this.type));
        Random generator = new Random();
        Object[] values = hashMap.values().toArray();
        WebSocketClient webSocketClient = (WebSocketClient) values[generator.nextInt(values.length)];

        BaseResponseMessage infoMsg = BaseResponseMessage.success(userMessageModel);
        /**
         * 持久化
         */
        baseWebSocketService.saveCTOCMsg(this.clientId,webSocketClient.clientId,JSONObject.toJSONString(infoMsg),new Date(),new Date());
        /**
         * 发送消息
         */
        webSocketClient.sendMessage(infoMsg);
        this.sendMessage(infoMsg);
        log.info("客户端:{} 发送到客户端:{},消息内容:{}",clientId,webSocketClient.clientId,userMessageModel.getMessage());
    }

日志: 用户客户端发送的消息可以随机发送到不同客服端

但是这一个客户端的消息发了好几个客服,每个客服的消息不完整客服也懵啊,所以要解决这个问题,这里先有以下两个方案

  • 一个客户端只跟一个客服聊天
  • 消息记录跟客服端不绑定

3. 一对多

一对多好说,客服根据不同的接受消息返回不同客户端不同消息就好了,前面在Model中加入了发送人客户端信息客服端就可以回复多个信息了。

比如下图,客服可以根据sendID参数回复用户端信息

4. 双向绑定

在上面第二节的最后提出了问题:一个客户端的消息发了好几个客服,每个客服的消息不完整。在上面给出了两个解决方案

  • 一个客户端只跟一个客服聊天
  • 消息记录跟客服端不绑定

第一个一个客户端只跟一个客服聊天就跟是本节的一个思路,双向绑定,当一个客户端与一个客服端发送消息后尽量将消息发送给此客服端。

  • 一是为了熟悉之前需求便于解决
  • 二是如果有缓存消息也不用重新加载历史消息

当然如果绑定的客服不在线或者设置了最大连接数已满无法建立连接那也是要漂移连接到其他客服的。如果与其他客服建立连接就需要一个历史消息补偿的操作了。这个在下一节消息同步捋捋。

4.1 绑定客服端

设置一个Map<String,String>结构,存储用户端对应的客服端,如果没有绑定的客服端就按上面的走随机分配。

定义一个HashMap存储绑定信息:

代码语言:javascript复制
    private static HashMap<String,String> bindKfClients = new HashMap<>();

发送消息判断是否有绑定客服端,如果没有走正常随机匹配并写入,有则与绑定客户端进行通讯。

代码如下:

代码语言:javascript复制
    private void toCSucceed(UserMessageModel userMessageModel){
        HashMap<String,WebSocketClient> hashMap = webSocketClientMap.get(WebSocketTypeEnum.getAcceptType(this.type));
        WebSocketClient webSocketClient;
        if (StringUtils.isEmpty(bindKfClients.get(this.clientId))){
            Random generator = new Random();
            Object[] values = hashMap.values().toArray();
            webSocketClient = (WebSocketClient) values[generator.nextInt(values.length)];
            bindKfClients.put(clientId,webSocketClient.clientId);
        }else{
            webSocketClient = hashMap.get(bindKfClients.get(this.clientId));
        }

        BaseResponseMessage infoMsg = BaseResponseMessage.success(userMessageModel);
        /**
         * 持久化
         */
        baseWebSocketService.saveCTOCMsg(this.clientId,webSocketClient.clientId,JSONObject.toJSONString(infoMsg),new Date(),new Date());
        /**
         * 发送消息
         */
        webSocketClient.sendMessage(infoMsg);
        this.sendMessage(infoMsg);
        log.info("客户端:{} 发送到客户端:{},消息内容:{}",clientId,webSocketClient.clientId,userMessageModel.getMessage());
    }

4.2 验证

代码语言:javascript复制
如果是之前用户端发送消息会随机发送到多个客服端,现在加入绑定信息应该是发送多个消息只会让第一次接受的客服端接受

日志验证:

现在用户端发送给客服端的信息就会发送到同一客服端了

再开个新用户端进行验证

这个新用户端的消息也一直发送到1661497033459这个客服端了,这样用户端的消息就绑定到同一客服端了

5. 消息同步

消息同步的操作就当做历史消息的补偿,之前在消息落地里会把每一个客户端发送的数据放到Mysql中存储起来,在同步这一块就可以使用了。

5.1 代码调整

步骤:用户端发送消息->接收消息->判断没有绑定客服端->查询同步数据->添加同步数据->发送客服端消息

代码如下:

代码语言:javascript复制
    @OnMessage
    public void onMessage(String message, Session session,@PathParam("clientId") String clientId){
        /**
         * 持久化
         */
        baseWebSocketService.saveClientSendMsg(clientId,message,new Date());
        /**
         * 处理消息
         */
        UserMessageModel userMessageModel = JSONObject.parseObject(message, UserMessageModel.class);
        userMessageModel.setSendId(clientId);
        if (userMessageModel == null){
            this.sendMessage(BaseResponseMessage.error(null,"传递参数结构异常"));
        }
        HashMap<String,WebSocketClient> hashMap = webSocketClientMap.get(WebSocketTypeEnum.getAcceptType(this.type));
        if (!CollectionUtils.isEmpty(hashMap)){
            if (StringUtils.isEmpty(bindKfClients.get(this.clientId))){
                List<UserMessageModel> list = new ArrayList();
                list.addAll(baseWebSocketService.queryClientSendMsg(clientId));
                list.forEach(model-> {
                    this.toCSucceed(model);
                });
            }else{
                this.toCSucceed(userMessageModel);
            }
        }else{
            baseWebSocketService.saveClientCompensateMsg(userMessageModel.getAcceptId(),message,(byte) 0);
            log.info("客户端:{} 发送消息到接受端:{} 不在线,放置到代发送列表,当前待发送列表:{}条",clientId,userMessageModel.getAcceptId());
            this.sendMessage(BaseResponseMessage.error(null,"接收端不在线"));
        }
    }

之前代码:

代码语言:javascript复制
        if (!CollectionUtils.isEmpty(hashMap)){
			this.toCSucceed(userMessageModel);
        }else{
            baseWebSocketService.saveClientCompensateMsg(userMessageModel.getAcceptId(),message,(byte) 0);
            log.info("客户端:{} 发送消息到接受端:{} 不在线,放置到代发送列表,当前待发送列表:{}条",clientId,userMessageModel.getAcceptId());
            this.sendMessage(BaseResponseMessage.error(null,"接收端不在线"));
        }

现在代码:

代码语言:javascript复制
        if (!CollectionUtils.isEmpty(hashMap)){
            if (StringUtils.isEmpty(bindKfClients.get(this.clientId))){
                List<UserMessageModel> list = new ArrayList();
                list.addAll(baseWebSocketService.queryClientSendMsg(clientId));
                list.forEach(model-> {
                    this.toCSucceed(model);
                });
            }else{
                this.toCSucceed(userMessageModel);
            }
        }else{
            baseWebSocketService.saveClientCompensateMsg(userMessageModel.getAcceptId(),message,(byte) 0);
            log.info("客户端:{} 发送消息到接受端:{} 不在线,放置到代发送列表,当前待发送列表:{}条",clientId,userMessageModel.getAcceptId());
            this.sendMessage(BaseResponseMessage.error(null,"接收端不在线"));
        }

5.2 验证

先找一下目前客户端落地的发送记录

在上图客户端1661497490016有8条发送的落地数据,在前端客户端ID固定为1661497490016,发生消息内容为:消息补偿看看

5.2.1 页面验证

用户端:

客服端:

5.2.2 日志验证

从日志上可以看到,客户端发一条消息的同时会将之前的消息也补偿同步上。如果已有建立双向连接的客服则不会补偿信息,避免信息重复

0 人点赞