文章目录
- 前言
- 一、聊天系统为什么使用短连接?
- 二、技术方案
- 后端技术方案:
- 前端技术方案
- 原生端
- 三、代码详细设计
- 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 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的方式供大家使用,大家赶兴趣的小伙伴可以一起加入开发。