文章目录
- 前言
- 一、聊天系统为什么使用短连接?
- 二、技术方案
-
- 后端技术方案:
- 前端技术方案
- 原生端
- 三、代码详细设计
-
- 1.数据库设计
- 2.后端程序
- 3.前端程序
- 四、效果展示
- 五、源码-GitHub
- 六、后期计划
前言
客服系统比较常见,主流的还是采用三方SDK接入,这些SDK的实现方式大都采用长连接,性能要求比较高,费用也偏高。此系列文章采用短连接的形成,快速开发一个实用性客服系统。
规划:
1.通过短连接实现客服系统,代码全部开源在github上(已完成)
2.将此客服系统通过SDK的方式供别人使用(已完成)
3.通过长连接实现IM聊天系统+客服系统,并开源(未完成)
一、聊天系统为什么使用短连接?
- 客服系统的及时性不是很高,客服一般要处理多个用户的聊天咨询,在一般情况下,客服和用户之间的聊天实时性不是很高,一般会有几秒的等待时间。
- 开发成本:短连接通过http协议实现,收发消息只需要发送http请求即可,开发简单。
- 性能:长连接需要客户端和服务器一直保持连接,比较消耗服务器性能,用户量一大,服务器的压力很大。
二、技术方案
后端技术方案:
数据库: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 conversationwhere (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();}//把当前查询者的未读消息设置为0if (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存入tokenredisUtils.set( CacheUtil.getImUserTokenRedisKey(user.getId(),param.clientType),token, Config.imUserTokenExpireDay, TimeUnit.DAYS); //7天//对方用户不为空时注册imif (!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;}//校验appKeyAppExample 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); //保存tokenif (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个值初始化,初始化成功后才可以通信。
2.后台聊天界面(发送消息界面)
目前消息类型支持文字和图片
五、源码-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的方式供大家使用,大家赶兴趣的小伙伴可以一起加入开发。