最近公司业务需求, 打算开发一款在线客服的功能提供给app使用, 本人一开始打算是使用第三方平台, 比如腾讯云的客服系统来打造的, 不过后来查找资料, 了解了下websocket之后, 发现实现起来并不困难. 在此之前, 我研究了微信网页版的聊天方式, 发现微信网页版聊天并不是基于websocket的, 还是使用了http请求发送post请求, 进行页面长轮询方式进行信息的交互的.
由于公司项目使用了Django框架进行开发的, django框架有一个channels库是支持websocket服务的, 所以在进行开发之前需要进行安装channels
打开终端, 输入:
pip install -U channels
在django配置文件中安装channels应用.
使用命令django-admin startapp chat创建一个子应用chat, 并在项目的应用的上级目录新建一个routing.py文件(与wsgi.py同级), 作为websocket服务的路由规则.
在routing.py中输入以下代码:
其实routing.py文件和django的总urls.py文件的原理是一样的, django服务在运行的时候, 如果接收到的是websocket请求, 就会跳到routing.py文件中websocket应用, 接收到http请求时会跳到urls中找http应用.
当然, 再此之前需要在配置文件中配置websocket所支持的asgi协议, 配置如下:
本次使用了redis数据库作为websocket的管道, 单点通信的信息以及组间的通信数据都缓存在了redis数据库中, 配置redis管道如下:
配置完这些之后运行manage.py, 如截图所示说明成功了
之后正式开始编写业务代码了, 首先需要在刚刚创建的应用chat中创建一个consumers.py文件, 作为channels的消费者, websocket的消息通信都在这个文件中执行.
复制channels官方文档中(https://channels.readthedocs.io/en/latest/tutorial/part_3.html)的组间消息通信代码到这个文件中, 然后根据这个基础代码进行业务代码的调整.
其里面使用了python的async库来支持异步操作, 其实现了connect, disconnect,receive,chat_message等方法, connect用来连接用户组, 也可以理解为开始连接进行websocket通信; disconnect正好时断开连接, 客户端通过手动的断开websocket通信会经过此方法; chat_message就是用来发送消息的, receive方法就是进行消息的接收.
在chat应用中创建未读消息模型类, 用来记录聊天记录, 在models.py文件中添加如下代码:
from django.db import models
from users.models import User
# Create your models here.
class ChatRecords(models.Model):
'''在线客服聊天记录模型类'''
sender = models.ForeignKey(User, on_delete=models.CASCADE, related_name='send_records', verbose_name='发送者', null=False)
receiver = models.ForeignKey(User, on_delete=models.CASCADE, related_name='receive_records', verbose_name='接收者', null=False)
message = models.TextField(verbose_name='聊天信息')
create_time = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
class Meta:
db_table = "tb_chat_records"
verbose_name = '客服聊天记录'
verbose_name_plural = verbose_name
def __str__(self):
return self.id
接下来执行数据库迁移命令
python manage.py makemigrations
数据库中生成表命令
python manage.py migrate
在consumers.py文件中添加业务代码, 添加结果如下:
# chat/consumers.py
import re
from django.conf import settings
from fdfs_client.client import Fdfs_client
from channels.exceptions import *
from calendar import timegm
from django_redis import get_redis_connection
from base64 import b64decode
from rest_framework_jwt.authentication import jwt_decode_handler
from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncWebsocketConsumer
import json
class ChatConsumer(AsyncWebsocketConsumer):
async def connect(self):
# 获取url中的参数
query_string = self.scope['query_string'].decode()
params = query_string.split('&')
item = {}
for param in params:
item[param.split('=')[0]] = param.split('=')[1]
token = item.get('token')
self.user_group = 'chat_'
if token:
try:
payload = jwt_decode_handler(token)
except:
raise DenyConnection("签证错误")
user_id = payload['user_id']
user = await self.get_user(id=user_id)
last_login = payload.get('last_login')
if last_login != timegm(user.last_login.utctimetuple()):
raise DenyConnection("签证已过期")
else:
user = self.scope['user']
if not user:
raise DenyConnection("用户不存在")
receiver_name = item.get('receiver')
if not receiver_name:
raise DenyConnection("接收者名称错误")
receiver = await self.get_user(username=receiver_name)
if not receiver:
raise DenyConnection("接收者不存在")
self.receiver = receiver
self.user = user
# 远程组
self.receiver_group = 'chat_%s_%s' % (self.receiver.username, self.user.username)
# 用户组
self.user_group = 'chat_%s_%s' % (self.user.username, self.receiver.username)
# Join room group
await self.channel_layer.group_add(
self.user_group,
self.channel_name
)
await self.accept()
async def disconnect(self, close_code):
# Leave room group
await self.channel_layer.group_discard(
self.user_group,
self.channel_name
)
# Receive message from WebSocket
async def receive(self, text_data):
text_data_json = json.loads(text_data)
message = text_data_json['message']
if message:
# data:image/png;base64,i
ret = re.findall('data:image/.*;base64,(.*)', message)
if ret:
user_pic_str = ret[0]
image_src = await self.save_image_to_fdfs(user_pic_str)
# 构造message
message = '<img style="width: 80px; height: 60px" src="'+ image_src +'" data-preview-src="">'
# Send message to room group
chat_record = await self.save_model(self.user, self.receiver, message)
if self.receiver.username == 'admin':
'''为管理员添加消息提示'''
await self.save_unread_records(chat_record, self.user)
await self.channel_layer.group_send(
# websocket发送消息
self.receiver_group,
{
'type': 'chat_message',
'message': message
}
)
# Receive message from room group
async def chat_message(self, event):
message = event['message']
# Send message to WebSocket
await self.send(text_data=json.dumps({
'message': message
}))
@database_sync_to_async
def save_model(self, sender, receiver, message):
# 保存消息记录到数据库中
from .models import ChatRecords
return ChatRecords.objects.create(sender=sender, receiver=receiver, message=message)
@database_sync_to_async
def get_user(self, id=None, username=None):
# 异步获取用户
from users.models import User
user = None
if id:
try:
user = User.objects.get(id=id)
except:
return None
if username:
try:
user = User.objects.get(username=username)
except:
return None
return user
async def save_unread_records(self, chat_record, sender):
# 保存未读消息
redis_conn = get_redis_connection('chatRecord')
p = redis_conn.pipeline()
p.rpush(sender.id, chat_record.id)
p.set('new_records', 1) # 在redis中添加未读标记
p.execute()
async def save_image_to_fdfs(self, pic_str):
# 把图片存储到fastdfs文件系统中
client = Fdfs_client(settings.FDFS_CLIENT_CONF)
ret = client.upload_appender_by_buffer(b64decode(pic_str))
if ret.get("Status") != "Upload successed.":
raise Exception("upload file failed")
file_name = ret.get("Remote file_id")
return settings.FDFS_URL + file_name
接下来还需要在chat应用中创建一个routing.py文件, 将子路由会分发到这个文件中, 输入以下代码:
通过上面的步骤, django channels搭建websocket的任务已经完成, 这个时候需要为后台管理页面提供一个聊天界面, 在项目的模板目录中创建一个chat文件夹, 在里面创建一个index.html文件
复制以下代码进去
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>koalas 客服</title>
<style type="text/css">
body{
background: url("http://47.75.156.19:8020/static/chat/images/bg.jpg") no-repeat ;
background-size: cover;
}
* {
margin: 0;
padding: 0;
}
li,
img,
label,
input {
vertical-align: middle;
}
img {
display: block;
max-height: 100%;
margin: 0;
padding: 0;
}
a {
text-decoration: none;
color: black;
}
ul, li {
list-style: none;
}
-webkit-scrollbar-track {
background-color: transparent;
}
.defaultPage {
/ / 缺省页 width: 200 px;
height: 300px;
margin-top: 120px;
}
.defaultPage > .img-box {
width: 120px;
height: 120px;
margin: 20px auto;
}
.defaultPage > .img-box > img {
width: 100%;
height: 100%;
}
.defaultPage > .noMsg {
margin: 20px 0;
text-align: center;
font-size: 14px;
color: rgba(153, 153, 153, 1);
}
.defaultPage > .helpButton {
margin: 20px 40px;
text-align: center;
font-size: 14px;
line-height: 42px;
color: #fff;
background-color: #000000;
}
.kefu {
display: flex;
margin: auto;
border: 1px solid #333;
position: absolute;
top: 50px;
left: 250px;
right: 250px;
bottom: 50px;
}
.left {
width: 18%;
overflow-x:hidden; overflow-y:auto;
border-right: 1px solid #999;
background-color: #333;
}
.left .list {
height: 100%;
}
.left .list .list-item {
display: flex;flex-wrap: nowrap;
padding: 15px;overflow: hidden;
border-bottom: 1px solid #999;
color: #fff;
}
.cur{
background-color: #333;
opacity:0.6;
}
.left .list .list-item .img-box {
position: relative;
width: 36px; height: 36px;
margin-right: 10px;
border-radius: 4px;
}
.left .list .list-item .img-box img {
width: 100%;height: 100%;
display: inline-block;
vertical-align: middle;
}
.left .list .list-item .bandage{
display:inline-block ;
position: absolute;right: -8px;top:-8px;
width:16px;height: 16px;border-radius: 12px;
text-align: center;
font-size:12px;line-height: 16px;
background-color: red;
}
.left .list .list-item .bandage span{
color: #fff;
}
.left .list .list-item .info{
flex: 1;padding-left: 10px;
}
.left .list .list-item .name {
font-size: 14px;line-height: 22px;
}
.left .list .list-item .text{
display: flex;flex-wrap: nowrap;
font-size:12px;
text-align: left;
}
.left .list .list-item .text span{
flex:1;
}
.right {
flex: 1;
height: 100%;
display: flex;
flex-direction: column;
background-color: #c3c3c3;
}
.right .name {
position: relative;
width: 100%;
height: 50px;
line-height: 50px;
text-align: center;
border-bottom: 1px solid #999;
}
.name .moreMsg{
position: absolute; left:46%;top: 61px;
height: 20px;
padding: 0 6px;
border-radius: 6px;
background-color: #FFF;
color: red;
font-size:12px;line-height: 20px;
z-index:98;
}
.right .content {
flex:1;
overflow-x:hidden; overflow-y:auto;
display: flex;flex-direction: column;
width: 100%;
padding: 10px;
box-sizing: border-box;
z-index: 88;
}
.right .content .message{
margin-top: 24px;
flex: 1;
}
.right .send_msg {
position: relative;
height: 160px;
overflow: hidden;
padding: 10px;
border-top: 1px solid #999;
z-index: 2;
}
.right .send_msg .input_text {
display: block;
width: 100%;
height: 60%;
box-sizing: border-box;
padding: 10px;
background-color: #c3c3c3;
}
.right .send_msg .btn_send {
position: absolute;
bottom: 10px;
right: 20px;
width: 70px;
height: 22px;
margin-top: 100px;
border-radius: 4px;
font-size: 14px;
line-height: 22px;
text-align: center;
border: 1px solid #333;
}
.content .time{
display: flex;
justify-content: center;
color: #fff;
line-height: 40px;
z-index: 4;
}
.content .reply {
display: flex;
width: 80%;
margin-bottom: 15px;
}
.content .reply .img-box, .content .user .img-box {
display: inline-block;
width: 40px;
height: 40px;
}
.content .reply .img-box img, .content .user .img-box img {
width: 100%;
height: 100%;
}
.content .reply .text-cont {
flex: 1;
padding: 4px 0;
margin-left: 15px;
}
.content .reply .text-cont .text, .content .user .text-cont .text {
display: inline-block;
padding: 4px 6px;
font-size: 14px;
line-height: 28px;
border-radius: 4px;
border: 1px solid #999;
background-color: #fff;
}
.content .user .text-cont img{
float: right;
}
.content .show {
visibility: hidden;
}
.content .user {
float: right;
display: flex;
width: 80%;
margin-bottom: 15px;
}
.content .user .text-cont {
flex: 1;
text-align: right;
padding: 4px;
margin-right: 15px;
}
.content .user .text-cont .text {
background-color: #b2e281;
}
.content .user .img-box {
width: 40px;
height: 40px;
}
.content .user .img-box img {
width: 100%;
height: 100%;
}
.bigimg{width:600px;position: fixed;left: 0;top: 0; right: 0;bottom: 0;margin:auto;display: none;z-index:9999;}
.mask{position: fixed;left: 0;top: 0; right: 0;bottom: 0;background-color: #000;opacity:0.5;filter: Alpha(opacity=50);z-index: 98;transition:all 1s;display: none}
.bigbox{width:840px;background: #fff;border:1px solid #ededed;margin:0 auto;border-radius: 10px;overflow: hidden;padding:10px;}
.bigbox>.text-cont{width:400px;height:250px;float:left;border-radius:5px;overflow: hidden;margin: 0 10px 10px 10px;}
.bigbox>.imgbox>img{width:100%;}
.text-cont:hover{cursor:zoom-in}
.mask:hover{cursor:zoom-out}
.mask>img{position: fixed;right:10px;top: 10px;width: 60px;}
.mask>img:hover{cursor:pointer}
</style>
</head>
<body>
<img class="bigimg">
<div class="mask">
<img src="http://47.75.156.19:8020/static/chat/images/close.png" alt="">
</div>
<div class="kefu">
<div class="left">
<ul class="list">
<li class="list-item" :class="{cur: iscur === idx}" @click="handleSelect(item);iscur=idx" v-for="(item,idx) in senderLis" :key="idx">
<div class="img-box">
<img :src="[[ item.user_pic ]]">
<div class="bandage" v-if="item.count !== 0">
<span >[[item.count]]</span>
</div>
</div>
<div class="info">
<span class="name">[[ item.username ]]</span>
<div class="text" v-html="item.last_send_message">
</div>
</div>
</li>
</ul>
</div>
<div class="right">
<div class="name" v-if="!resultShow">
<span>[[ name ]]</span>
<div class="moreMsg" @click="getMoreMsg" v-if="next">
<span>更多消息</span>
</div>
</div>
<div class="defaultPage" v-if="resultShow">
<div class="img-box">
<img :src="defaultPage">
</div>
<div class="noMsg">
<span>未选择聊天!</span>
</div>
</div>
<div class="content" v-show="!resultShow">
<div v-for="(item,idx) in chatList" :key="idx" class="message">
<div class="time">
<span>[[item.create_time]]</span>
</div>
<div class="reply" v-if="item.sender_name !== 'admin'">
<div class="img-box">
<img :src="clientImg"/>
</div>
<div class="text-cont" v-html="item.message">
</div>
</div>
<div class="user" v-else>
<div class="text-cont" v-html="item.message">
</div>
<div class="img-box">
<img :src="userImg"/>
</div>
</div>
</div>
</div>
<div class="send_msg" v-show="!resultShow">
<textarea id="dropBox" class="input_text" autofocus v-model.trim="value" @keyup.enter="sendMsg"></textarea>
<div class="btn_send" @click="sendMsg">发送</div>
</div>
</div>
</div>
</body>
<script src="https://cdn.bootcss.com/vue/2.6.10/vue.min.js"></script>
<script src="https://cdn.bootcss.com/axios/0.19.0-beta.1/axios.min.js"></script>
<script src="https://cdn.bootcss.com/jquery/3.4.0/jquery.min.js"></script>
<script src="http://47.75.156.19:8020/static/chat/js/zoom.js"></script>
<script type="text/javascript">
var obj;
var global_host = "127.0.0.1:8000";
axios.defaults.withCredentials=true;
window.onbeforeunload= function(event) {
var flag = confirm("确定离开此页面吗?");
if(flag==true){
vm.chatSocket.close();
}
return flag
};
var vm = new Vue({
el: '.kefu',
delimiters: ['[[', ']]'], // 修改vue模板符号,防止与django冲突
data: {
'resultShow': true,
'defaultPage': '',
'clientImg': '',
'userImg': 'http://47.75.156.19:8020/static/chat/images/user02.jpg',
'name': "",
// "senders": senders,
'iscur': '',
"chatList": "",
"next": "",
"value": '',
'chatSocket': '',
'senderLis': '',
'current_id': '',
},
methods: {
getCookie (name) {
var value = '; ' + document.cookie;
var parts = value.split('; ' + name + '=');
if (parts.length === 2) return parts.pop().split(';').shift()
},
getData(timeout){
var url = 'http://'+ global_host +'/api/chat/unread/records/';
axios.post(url, {timeout:timeout}, {headers:{'X-CSRFToken': this.getCookie('csrftoken')}}).then(res =>{
this.senderLis = res.data;
setTimeout(this.getData(30),300)
})
},
handleSelect(item){
this.clientImg = item.user_pic;
if(this.current_id === item.sender_id){
return
}
else {this.current_id = item.sender_id}
if(this.chatSocket){
this.chatSocket.close()
}
this.chatSocket = new WebSocket('ws://'+ global_host +'/ws/chat/?receiver=' + item.username );
$('.content').html('');
this.chatList = '';
this.resultShow = false;
this.name = item.username;
axios.get('http://'+ global_host +'/api/chat/records/' + item.sender_id + "/?page_size=10").then(res => {
var data = res.data;
this.chatList = data.results;
this.next = data.next;
this.$nextTick(function () {
obj = new zoom('mask', 'bigimg','text-cont img');
obj.init();
$('.text-cont img').click(function () {
$('.bigimg').attr('src', $(this).attr('src'))
})
})
}).then(function () {
// 滚动到底部
var h = $('.content')[0].scrollHeight;
$('.content').scrollTop(h);
});
this.chatSocket.onmessage = function (e) {
// 接收消息
var data = JSON.parse(e.data);
// 发起请求删除redis中记录
axios({
method:'delete',
url:'http://'+ global_host +'/api/chat/records/' + item.sender_id + "/",
headers: {'X-CSRFToken': vm.getCookie('csrftoken')}
}).then().catch(error =>{
console.log(error.response.data)
});
var message = data['message'];
$('.content').append('<div class="message"><div class="reply"><div class="img-box"><img src="'+ vm.clientImg +'"></div><div class="text-cont" >'+ message +'</div></div></div>');
obj.init();
$('.text-cont img').click(function () {
$('.bigimg').attr('src', $(this).attr('src'))});
// 滚动到底部
var h = $('.content')[0].scrollHeight;
$('.content').scrollTop(h);
};
this.chatSocket.onclose = function (e) {
console.error('Chat socket closed unexpectedly');
};
// 发起请求删除redis中记录
axios({
method:'delete',
url:'http://'+ global_host +'/api/chat/records/' + item.sender_id + "/",
headers: {'X-CSRFToken': this.getCookie('csrftoken')}
}).then().catch(error =>{
console.log(error.response.data)
})
},
getMoreMsg(){
var url = this.next;
// alert(url)
axios.get(url).then(res =>{
var data = res.data;
this.next = data.next;
this.chatList = data.results.concat(this.chatList)
})
},
sendMsg(){
if(!this.value||this.value ===''){
alert("请输入消息");
return
}
this.chatSocket.send(JSON.stringify({
'message': '<span class="text" >'+ this.value +'</span>'
}));
$('.content').append('<div class="message"><div class="user"><div class="text-cont"><span class="text">' + this.value + '</span></div> <div class="img-box"><img src="'+ this.userImg +'"></div></div></div>' );
this.value = '';
// 滚动到底部
var h = $('.content')[0].scrollHeight;
$('.content').scrollTop(h);
}
},
created(){
this.getData(0);
},
mounted(){
}
})
var dropBox;
window.onload=function(){
dropBox = document.getElementById("dropBox");
// 鼠标进入放置区时
dropBox.ondragenter = ignoreDrag;
// 拖动文件的鼠标指针位置放置区之上时发生
dropBox.ondragover = ignoreDrag;
dropBox.ondrop = drop;
}
function ignoreDrag(e){
// 确保其他元素不会取得该事件
e.stopPropagation();
e.preventDefault();
}
function drop(e){
e.stopPropagation();
e.preventDefault();
// 取得拖放进来的文件
var data = e.dataTransfer;
var files = data.files;
// 将其传给真正的处理文件的函数
var file = files[0];
var reader = new FileReader();
reader.onload=function(e){
$('.content').append('<div class="message"><div class="user"><div class="text-cont"><img style="width: 80px; height: 60px;" src="'+e.target.result+'" data-preview-src=""></div> <div class="img-box"><img src="'+ vm.userImg +'"></div></div></div>' );
obj.init();
$('.text-cont img').click(function () {
$('.bigimg').attr('src', $(this).attr('src'))});
vm.chatSocket.send(JSON.stringify({
'message': e.target.result
}));
};
reader.readAsDataURL(file);
}
</script>
</html>
之后为django Xadmin后台添加一个模块, 把后台聊天页面添加到xadmin中.
首先在xadmin全局配置中添加get_site_menu方法, 为聊天页面添加一个导航框, 代码如下:
之后进行注册全局配置类
from xadmin import views
xadmin.site.register(views.CommAdminView, GlobalSetting)
运行manage.py, 打开谷歌浏览器输入127.0.0.1:8000/xadmin, 打开截图如下:
多了一个chat模块, 点开进去, 进入聊天界面(小编的这个界面是模仿了微信界面), 怎么进入到了微信网页界面呢? 哈哈哈.
由于这个是app端和后台客服管理端的聊天应用, 所以要把本地代码部署到服务器上, 然后借助前端混合开发小哥的移动端才能进行消息通讯, 不过各位朋友也可以相应的制作一款网页版的聊天系统来玩玩.
下一期内容就是部署这个channels到服务器上, 敬请期待...