前言

客服系统比较常见,主流的还是采用三方SDK接入,这些SDK的实现方式大都采用长连接,性能要求比较高,费用也偏高。此系列文章采用短连接的形成,快速开发一个实用性客服系统。

规划:

1.通过短连接实现客服系统,代码全部开源在github上(已完成)
2.将此客服系统通过SDK的方式供别人使用(已完成)
3.通过长连接实现IM聊天系统+客服系统,并开源(未完成)


一、聊天系统为什么使用短连接?

  1. 客服系统的及时性不是很高,客服一般要处理多个用户的聊天咨询,在一般情况下,客服和用户之间的聊天实时性不是很高,一般会有几秒的等待时间。
  2. 开发成本:短连接通过http协议实现,收发消息只需要发送http请求即可,开发简单。
  3. 性能:长连接需要客户端和服务器一直保持连接,比较消耗服务器性能,用户量一大,服务器的压力很大。

二、技术方案

在这里插入图片描述
通过短连接轮询的方式,达到收发消息。

后端技术方案:

数据库:MySQL
项目框架:Sping Boot
缓存:Redis
消息队列:Rabbit

前端技术方案

VUE

原生端

安卓:未开发
IOS:未开发
目前原生端接入方式为:跳转H5聊天页面,以内嵌的方式,短连接的方案目前不考虑原生端,后面长连接的方式会考虑原生端。

三、代码详细设计

1.数据库设计

表名:account
主要功能:后台管理账号,客服人员登录
核心字段:app_id,name

表名:user
主要功能:聊天用户表,每个需要聊天的用户都需要自动注册该表,通过该表的id来收发消息
核心字段:id,type(用户类型:1游客,2管理员,3登录用户),app_key,client_type(客户端类型:1H5,2PC,3安卓,4IOS),out_user_id(外部系统的用户id)

表名:app
主要功能:应用表,每个后台管理员账号下可以新增多个应用,每个应用都归于一个后台管理员
核心字段:app_key,app_secret,state,user_id(管理员id,对应account表)

表名:conversation
主要功能:会话表,每个聊天窗口都会新建一个会话
核心字段:from_user_id,to_user_id,last_text,from_unread_count(未读消息数),to_unread_count(未读消息数),extra(扩展字段,用户昵称、头像或其他字段在这里)

表名:conversation
主要功能:会话表,每个聊天窗口都会新建一个会话
核心字段:from_user_id,to_user_id,text,type(消息类型:1文本,2图片,3语音,4视频,5其他),file_url(文件对于的URL,发送图片/文件),file_small_url(文件小图的URL),state(消息状态),extra(扩展字段,用户昵称、头像或其他字段在这里),conversation_id,cover_img_url(封面图片的URL,如发布的视频)

2.后端程序

1.发消息

public void sendMsg(SendMsgParam param, ResponseDataBase responseDataBase) {
        String lastText = param.text;
        if (TextUtil.isEmpty(lastText)){
            lastText = "["+MsgType.getMsgType(param.type).desc+"]";
        }
        else {
            if (lastText.length()>8){
                lastText = lastText.substring(0,8);
                lastText += "...";
            }
        }

        boolean isFirstCreateConversation = false;
        if (param.conversationId<=0){
            //会话id为空,则有可能是第一次聊天
            //1.查询是否以前有聊天会话
            ConversationExample conversationExample = new ConversationExample();
            ConversationExample.Criteria criteria1 = conversationExample.createCriteria();
            criteria1.andFromUserIdEqualTo(param.fromUserId);
            criteria1.andToUserIdEqualTo(param.toUserId);

            ConversationExample.Criteria criteria2 = conversationExample.createCriteria();
            criteria2.andFromUserIdEqualTo(param.toUserId);
            criteria2.andToUserIdEqualTo(param.fromUserId);

            conversationExample.or(criteria2);
            List<Conversation> conversations = conversationMapper.selectByExample(conversationExample);
            if (!CollectionUtils.isEmpty(conversations)){
                param.conversationId = conversations.get(0).getId();
            }
            else {
                //第一次会话,建立新的会话
                Conversation conversation = new Conversation();
                conversation.setFromUserId(param.fromUserId);
                conversation.setToUserId(param.toUserId);
                conversation.setTimestamp(System.currentTimeMillis());
                conversation.setState(1);
                conversation.setToUnreadCount(1);
                conversation.setFromUnreadCount(0);
                conversation.setLastText(lastText);
                conversation.setExtra(param.userInfoExtra);
                conversationMapper.insert(conversation);
                param.conversationId = conversation.getId();
                isFirstCreateConversation = true;
            }
        }

        Message message = new Message();
        message.setType(param.type);
        message.setFromUserId(param.fromUserId);
        message.setToUserId(param.toUserId);
        message.setText(param.text);
        message.setExtra(param.extra);
        message.setConversationId(param.conversationId);
        message.setState(0);
        message.setTimestamp(System.currentTimeMillis());
        message.setDatetime(new Date());

        if (!TextUtil.isEmpty(param.fileUrl)){
            message.setFileUrl(param.fileUrl);
            message.setFileSmallUrl(param.fileSmallUrl);
        }

        int insert = messageMapper.insert(message);
        if (insert>0){
            //成功
            if(!isFirstCreateConversation){
                //更新会话
                Conversation c = getConversationByFromUserId(param.conversationFromUserId,param.conversationId);;
                c.setLastText(lastText);
                c.setTimestamp(System.currentTimeMillis());
                //c.setState(1);
                c.setExtra(param.userInfoExtra);
                if(param.conversationFromUserId<=0){
                    param.conversationFromUserId = c.getFromUserId();
                }

                //设置未读消息数量
                if (param.fromUserId == param.conversationFromUserId){
                    c.setToUnreadAddCount(1);  //未读消息数量+1
                }
                else {
                    c.setFromUnreadAddCount(1);  //未读消息数量+1
                }
                conversationMapper.updateByPrimaryKeySelective(c);
            }
            responseDataBase.data = message;
        }
        else {
            responseDataBase.code = HttpUtil.ErrorDec.RequestError.value;
        }
    }

2.查询会话列表

<select id="queryConversationList" resultMap="BaseResultMap" parameterType="com.ideaout.im.http.param.ListParam" >
    SELECT * from conversation
    where (from_user_id=#{param.userId,jdbcType=INTEGER} or to_user_id=#{param.userId,jdbcType=INTEGER}) and state!='2'
    order by  timestamp desc
    <if test="param.pageIndex != null">
      limit ${param.pageIndex*param.pageSize},${param.pageSize};
    </if>
  </select>

3.查询消息

public List<Message> queryMsgList(QueryMsgListParam param) {
        if (param.conversationId>0){
            Conversation conversation = getConversationByFromUserId(param.conversationFromUserId,param.conversationId);
            if(param.conversationFromUserId<=0){
                param.conversationFromUserId = conversation.getFromUserId();
            }
            //把当前查询者的未读消息设置为0
            if (param.userId == param.conversationFromUserId){
                conversation.setFromUnreadCount(0);
            }
            else {
                conversation.setToUnreadCount(0);
            }
            conversationMapper.updateByPrimaryKeySelective(conversation);
        }
        return messageMapper.queryMsgList(param);
    }

4.初始化SDK

public void initSdk(InitSdkParam param, ResponseDataBase responseDataBase) {
        //聊天im初始化,游客/管理员/普通用户 都调用此方法进行初始化
        if (!initVerification(param,responseDataBase)) {
            return;
        }

        User user = getImUser(param.appKey,param.outUserId,param.clientType,param.deviceUniqueId,param.imUserType);

        InitSdkResult initSdkResult = new InitSdkResult();
        //注册成功
        if (user!=null){
            initSdkResult.imUserId = user.getId();

            String token = TokenUtils.token(new TokenAttr(UserRoleType.IMUser.value,user.getId(), param.clientType, user.getType(),param.deviceUniqueId));
            initSdkResult.token = token;

            //redis存入token
            redisUtils.set( CacheUtil.getImUserTokenRedisKey(user.getId(),param.clientType),token, Config.imUserTokenExpireDay, TimeUnit.DAYS);  //7天

            //对方用户不为空时注册im
            if (!TextUtil.isEmpty(param.otherOutUserId)){
                User otherUser = getImUser(param.appKey,param.otherOutUserId,0,"",0);
                if (otherUser!=null){
                    initSdkResult.otherImUserId = otherUser.getId();
                }
            }
        }
        responseDataBase.data = initSdkResult;

    }

    private boolean initVerification(InitSdkParam param,ResponseDataBase responseDataBase){
        if (TextUtil.isEmpty(param.appKey)){
            responseDataBase.code = HttpUtil.ErrorDec.RequestError.value;
            responseDataBase.errorDec = "初始化异常:appKey为空";
            return false;
        }
        /*else if (TextUtil.isEmpty(param.outUserId)){
            responseDataBase.code = HttpUtil.ErrorDec.RequestError.value;
            responseDataBase.errorDec = "外部应用用户id为空";
            return;
        }*/
        else if (param.clientType<=0){
            responseDataBase.code = HttpUtil.ErrorDec.RequestError.value;
            responseDataBase.errorDec = "初始化异常:客户端类型为空";
            return false;
        }

        //校验appKey
        AppExample appExample = new AppExample();
        AppExample.Criteria criteria = appExample.createCriteria();
        criteria.andAppKeyEqualTo(param.appKey);
        criteria.andStateEqualTo(1);
        List<App> apps = appMapper.selectByExample(appExample);
        if (CollectionUtils.isEmpty(apps)){
            responseDataBase.code = HttpUtil.ErrorDec.RequestError.value;
            responseDataBase.errorDec = "初始化异常:appKey无效";
            return false;
        }

        return true;
    }

    private User getImUser(String appKey,String outUserId,int clientType,String deviceUniqueId,int imUserType){
        UserExample userExample = new UserExample();
        UserExample.Criteria userCriteria = userExample.createCriteria();
        userCriteria.andAppKeyEqualTo(appKey);
        userCriteria.andOutUserIdEqualTo(outUserId);
        List<User> users = userMapper.selectByExample(userExample);
        int updateUserResult = -1;
        User user = null;
        if (!CollectionUtils.isEmpty(users)){
            user = users.get(0);
            user.setLastTimestamp(System.currentTimeMillis());

            //在被注册的情况下,这些信息初始化的时候没有,需要补充上
            if (TextUtil.isEmpty(user.getDeviceUniqueId())){
                user.setDeviceUniqueId(deviceUniqueId);
            }
            if (user.getClientType()==null || user.getClientType()==0){
                user.setClientType(clientType);
            }
            if (user.getType()==null || user.getType()==0){
                user.setType(imUserType);
            }

            updateUserResult = userMapper.updateByPrimaryKeySelective(user);
        }
        else {
            user = new User();
            user.setAppKey(appKey);
            user.setOutUserId(outUserId);
            user.setClientType(clientType);
            user.setDeviceUniqueId(deviceUniqueId);
            user.setState(UserState.Normal.value); //状态为默认
            user.setChannel(0);
            user.setType(ImUserType.getImUserType(imUserType).value); //im类型
            user.setDatetime(new Date());
            user.setTimestamp(System.currentTimeMillis());
            user.setLastTimestamp(user.getTimestamp());
            updateUserResult = userMapper.insert(user);
        }
        return user;
    }

3.前端程序

前端通过轮询的方式更新会话列表和消息列表,详细代码见源码
1.初始化代码:

/*
* 初始化sdk,返回token
* imUserType:1游客,2管理员,3已登录用户
* */
this.init = function (appKey,outUserId,imUserType,otherOutUserId,callback) {
    var param = {};
    param.appKey = appKey;
    param.outUserId = outUserId +"";
    param.clientType = DevicesUtil.getClientType();
    param.imUserType = imUserType;
    param.deviceUniqueId = DevicesUtil.getDeviceUniqueId();
    param.otherOutUserId = otherOutUserId +"";

    HttpUtil.sendPost(
        param,
        "CODE0011",
        function (data) {
            UserUtil.saveIMUserToken(data.token);  //保存token
            if (callback) {
                callback(data.imUserId,data.otherImUserId);
            }
        },
        function (data) {
            console.log("error:" + JSON.stringify(data));
        },true
    );
};

2.定时器轮询消息

setInterval(function () {
    console.log("会话轮询时间到:"+getNowFormatDate());
    app_content.loopQueryConversationList(true);
},ComConfig.CONVERSATION_LOOPER_TIME);

四、效果展示

1.后台应用列表
用户登录账号后,可以新建多个应用,新建应用会自动生成appKey和appSecret,在聊天建立之前需要通过这2个值初始化,初始化成功后才可以通信。
在这里插入图片描述

1.后台会话列表
在这里插入图片描述

2.后台聊天界面(发送消息界面)
目前消息类型支持文字和图片
在这里插入图片描述

3.前端用户会话列表
在这里插入图片描述

在这里插入图片描述

五、源码-GitHub

本系列代码全部开源放在github上,欢迎大家使用和指出问题。

同时本系统支持以三方SDK的方式供别人使用,SDK接入方式:

第一种.代码部署在我这边服务器,只需要跳转H5对应的链接即可,5分钟即可完成
第二种.代码自己部署,把前端+后端代码部署在自己服务器,更改相应的配置,1小时左右可以完成。

GitHub地址
前端:https://github.com/1812507678/LightIMWeb
后端:https://github.com/1812507678/LightIMServer

demo体验

PC客服端:http://94.191.22.221/LightIMWeb/page/admin-login.html
用户名:test
密码:123456

用户端会话列表:
http://94.191.22.221/LightIMWeb/page/conversation.html?appKey=YmnTRIiI&userId=1

用户端打开聊天:
http://94.191.22.221/LightIMWeb/page/message.html?appKey=YmnTRIiI&fromUserId=1&toUserId=2

遇到问题可以加我微信交流(添加时备注IM):mwhjjy591


六、后期计划

1.优化此聊天客服系统,以SDK的方式提供给大家使用
2.通过长连接实现im聊天系统+客服系统,以保证消息的实时性,在开发完成后将会把代码开源,或以SDK的方式供大家使用,大家赶兴趣的小伙伴可以一起加入开发。

Logo

旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。

更多推荐