上篇文章(Netty 入门 — 第一个完整的 demo 之智能客服)大明哥演示了强大的智能客服,它能够很好的解决用户的一些常见的问题,但是有些问题依然还需要人工客服来完成,所以这篇文章我们来实现人工坐席。
功能实现
当用户输入 0 的时候,智能客户需要将这个用户转发给某一个人工客服,如果没有人工客户在线需要提示用户是否继续等待。在等待期间若有人工客服上线则将其转接到该用户。
那么服务端需要做如下几件事情:
- 维护用户与人工坐席之间的关系,发的消息不能串。
- 将人工坐席分配给用户。若没有人工坐席,则在用户等待期间,人工坐席上线则将其转接给该用户。
- 消息的转发。
至于用户和人工坐席,则相对来说就比较简单了,接收服务端的消息,并发送消息。
代码实现
代码逻辑相比上篇文章智能 AI 来说有点儿复杂代码,所以大明哥将其分为两部分实现:
- 无人工坐席
- 有人工坐席
无人工坐席
无人工坐席的时候,用户就是与智能客服交互,在用户输入 0 的时候,智能客服需要去获取已经注册在线的人工坐席,如果没有需要提示用户是否继续等待。
服务端
- 服务端基础代码
public class ZzkfServer {
public static void main(String[] args) {
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(new NioEventLoopGroup(),new NioEventLoopGroup());
bootstrap.channel(NioServerSocketChannel.class);
bootstrap.childHandler(new ChannelInitializer<NioSocketChannel>() {
@Override
protected void initChannel(NioSocketChannel ch) throws Exception {
ch.pipeline().addLast(new StringDecoder()); // 解码
ch.pipeline().addLast(new StringEncoder()); // 编码
}
});
// 绑定 8081 端口
bootstrap.bind(8081);
}
}
- 用户注册 handler
因为这个时候已经有用户注册、人工坐席注册了,所以为了遵循单一职责原则,我们需要将用户注册拆分出来。
public class UserRegisterHandler extends SimpleChannelInboundHandler {
@Override
protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
String[] messages = ((String) msg).split("&");
// 消息体有两部分构成 key&value
// key 用来区分身份,value 是真正的消息
String key = messages[0];
Channel channel = ctx.channel();
if (MessageTypeEnum.USER_REGISTER.getType().equals(key)) {
// 注册用户
ChannelSessionMemory.putClientChannel(channel.id().toString(),channel);
// 用户注册成功后,立刻向用户发送问题列表
sendInitMessage(channel);
} else {
ctx.fireChannelRead(msg);
}
}
/**
* 发送消息
* @param channel
*/
private void sendInitMessage(Channel channel) {
StringBuilder message = new StringBuilder(channel.id().toString() + ",您好,我是智能客服小磕。请告诉你想咨询的服务").append("\r\n")
.append("1. 死磕 Java 目前哪些系列").append("\r\n")
.append("2. 死磕 Java 的网址哪个").append("\r\n")
.append("3. 投诉与建议").append("\r\n")
.append("0. 人工").append("\r\n")
.append("==================请输入咨询问题的编号==================");
channel.writeAndFlush(message);
}
}
用户注册 handler 比较简单,它只做用户注册工作,注册成功后,给用户发送初始化消息,告诉用户它能做哪些事。
- 智能客服回答 handler
public class AiAnswerHandler extends SimpleChannelInboundHandler {
@Override
protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
String[] messages = ((String) msg).split("&");
String key = messages[0];
Channel channel = ctx.channel();
String userChannelId = channel.id().toString();
if (MessageTypeEnum.USER_QUESTION.getType().equals(key)) {
String value = messages[1];
if ("0".equals(value)) {
// 转交给人工 handler 处理
String rgChannelId = ChannelSessionMemory.getRgChannelId();
if (rgChannelId == null) {
// 如果没有,先将用户添加到等待队列,然后返回给用户目前暂无人工坐席
ChannelSessionMemory.putWaitingUser(userChannelId);
channel.writeAndFlush("当前暂无人工坐席,继续等待请输入 0 ,退出请输入 9");
} else {
Channel rgChannel = ChannelSessionMemory.getClientChannel(rgChannelId);
rgChannel.writeAndFlush(MessageTypeEnum.USER_HELLO.getType() + "&" + userChannelId + "|有用户咨询");
}
} else if ("9".equals(value)) {
// 用户退出
ChannelSessionMemory.deleteWaitingUser(userChannelId);
channel.writeAndFlush("对不起,无法解决您的问题,深感抱歉");
} else {
String answer = ServiceAnswerSet.getAnswer(value);
channel.writeAndFlush(answer);
}
}
}
}
回答问题 handler 就需要区分用户输入的指令了。
-
0:判断当前是否有在线的人工坐席,如果没有,将用户加入等待队列,并通知用户暂无人工坐席
-
9:将用户从等待队列中删除
-
其他:从答案库里面找到问题集,返回给用户
-
添加到 ChannelPipeline 中
ch.pipeline().addLast(new UserRegisterHandler());
ch.pipeline().addLast(new AiAnswerHandler());
用户端
- 用户基础代码
public class UserClient {
public static void main(String[] args) throws Exception {
Bootstrap bootstrap = new Bootstrap();
bootstrap.group(new NioEventLoopGroup());
bootstrap.channel(NioSocketChannel.class);
bootstrap.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ch.pipeline().addLast(new StringDecoder());
ch.pipeline().addLast(new StringEncoder());
ch.pipeline().addLast(new UserHandler());
}
});
bootstrap.connect("127.0.0.1",8081).sync().channel();
}
}
- 用户处理 handler
public class UserHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
ctx.channel().writeAndFlush(MessageTypeEnum.USER_REGISTER.getType() + "&我是用户:" + ctx.channel().id().toString());
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
String message = (String) msg;
System.out.println(message);
Scanner scanner = new Scanner(System.in);
String key = scanner.nextLine();
ctx.channel().writeAndFlush(MessageTypeEnum.USER_QUESTION.getType() + "&" + key);
}
}
用户部分逻辑还是非常简单的,这里就阐述了。
运行结果
输入 0 的时候,需要等待 30 秒。
我相信这个截图完美呈现了无人工坐席的场景。
有人工坐席
这里我们就需要再增加一个人工坐席客户端了。
服务端
服务端逻辑比较复杂了,因为它不仅仅只是智能客服了,他需要维护人工坐席,用户与人工坐席的对话。部分已经将没有人工坐席的部分写好,现在我们来梳理人工坐席的逻辑。
- 人工坐席注册
当人工坐席上线后,他需要注册到服务端,告诉服务端它上线了。
public class RgRegisterHandler extends SimpleChannelInboundHandler {
@Override
protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
String[] messages = ((String) msg).split("&");
String key = messages[0];
if (MessageTypeEnum.RG_REGISTER.getType().equals(key)) {
Channel channel = ctx.channel();
String channelId = channel.id().toString();
// 注册
ChannelSessionMemory.putClientChannel(channelId,channel);
String waitingUser = ChannelSessionMemory.getWaitingUser();
if (waitingUser == null || "".equals(waitingUser)) {
// 无用户等待,则直接加入
ChannelSessionMemory.putRg(channelId);
} else {
// 这里的消息体 messageType&channelId|message
// -1 消息体表示用户刚刚进来,需要人工去打招呼
channel.writeAndFlush(MessageTypeEnum.USER_HELLO.getType() + "&" + waitingUser + "|-1");
}
} else {
ctx.fireChannelRead(msg);
}
}
}
对于人工坐席注册,它除了注册之外还需要做两件事:
- 判断这个时候是否有用户在等待,如果有客户在等待的,则服务端需要通知人工坐席与该用户打招呼。
- 如果没有用户,则他就加入到等待池中,等下一个用户来咨询的时候可以直接联系。大明哥在智能客服回答 handler:AiAnswerHandler中已经有这部分逻辑了,当用户输入 0 的时候就判断当前是否有人工坐席在线。
- 用户-人工坐席对话
当用户与人工坐席建立联系后,他们就可以对话了。
public class UserRgChatHandler extends SimpleChannelInboundHandler {
@Override
protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
String[] messages = ((String)msg).split("&");
String messageType = messages[0];
if (MessageTypeEnum.RG_USER_MESSAGE.getType().equals(messageType)) {
String[] values = messages[1].split("\\|");
// 转发的内容
String content = values[1];
// 发给谁
String toChannelId = values[0];
Channel toChannel = ChannelSessionMemory.getClientChannel(toChannelId);
// 从哪里来
String fromChannelId = ctx.channel().id().toString();
// message
content = MessageTypeEnum.RG_USER_MESSAGE.getType() + "&" + fromChannelId + "|" + content;
// 发送内容
toChannel.writeAndFlush(content);
} else {
ctx.fireChannelRead(msg);
}
}
}
这个 handler 一定要注意发送的消息体,大明哥采用的消息体为 messageType&toChannelId|content
。为什么要这么设计呢?因为用户与人工坐席是绑定的关系,他们不能串,他需要告诉服务端他是与谁进行交互。
-
对于用户来的消息:fromChannelId 则为用户 channelId,toChannelId 则为绑定的人工做些 channelId,同时服务端要使用 toChannel 来发送,因为要发给人工坐席。
-
对于人工坐席的消息:与上面一致。
-
添加到 ChannelPipeline 中
ch.pipeline().addLast(new UserRegisterHandler());
ch.pipeline().addLast(new RgRegisterHandler());
ch.pipeline().addLast(new AiAnswerHandler());
ch.pipeline().addLast(new UserRgChatHandler());
还是那句话,注意顺序。
人工坐席
对于人工坐席就稍微简单了,他和用户端一样,做两件事,1 是连接成功后,注册到服务端,2 是与用户端交互。
public class RgHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
ctx.channel().writeAndFlush(MessageTypeEnum.RG_REGISTER.getType() + "&我是人工坐席:" + ctx.channel().id().toString());
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
String[] message = ((String) msg).split("&");
String messageType = message[0];
String[] values = message[1].split("\\|");
String userChannelId = values[0];
String rgChannelId = ctx.channel().id().toString();
if (MessageTypeEnum.USER_HELLO.getType().equals(messageType)) {
System.out.println("有用户[" + userChannelId + "]在等待人工坐席,请尽快接入");
ctx.channel().writeAndFlush(MessageTypeEnum.RG_USER_MESSAGE.getType() + "&" + userChannelId + "|您好,我是工号[" + rgChannelId + "],请问您有什么问题需要咨询吗?");
} else if (MessageTypeEnum.RG_USER_MESSAGE.getType().equals(messageType)) {
String content = values[1];
System.out.println("用户说:" + content);
System.out.println();
Scanner scanner = new Scanner(System.in);
String rgContent = scanner.nextLine();
// 人工坐席 Channel
String toChannelId = values[0];
rgContent = MessageTypeEnum.RG_USER_MESSAGE.getType() + "&" + toChannelId + "|" + rgContent;
ctx.channel().writeAndFlush(rgContent);
}
}
}
- 添加到 ChannelPipeline 中
ch.pipeline().addLast(new StringDecoder());
ch.pipeline().addLast(new StringEncoder());
ch.pipeline().addLast(new RgHandler());
用户端
用户端的 handler 就需要改造下了,他需要区分是智能客服来的还是人工坐席的。两者的消息提不一样。当然这里也是可以拆分为两个 handler,这是没有必要了。
public class UserHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
ctx.channel().writeAndFlush(MessageTypeEnum.USER_REGISTER.getType() + "&我是用户:" + ctx.channel().id().toString());
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
String[] messages = ((String) msg).split("&");
String messageType = messages[0];
if (MessageTypeEnum.USER_QUESTION.getType().equals(messageType)) {
// 如果是智能客服
System.out.println(messages[1]);
System.out.println();
Scanner scanner = new Scanner(System.in);
String key = scanner.nextLine();
ctx.channel().writeAndFlush(MessageTypeEnum.USER_QUESTION.getType() + "&" + key);
} else if (MessageTypeEnum.RG_USER_MESSAGE.getType().equals(messageType)) {
// 人工坐席
String[] values = messages[1].split("\\|");
String content = values[1];
System.out.println("人工坐席说:" + content);
System.out.println();
Scanner scanner = new Scanner(System.in);
String userContent = scanner.nextLine();
// 人工坐席 Channel
String toChannelId = values[0];
userContent = MessageTypeEnum.RG_USER_MESSAGE.getType() + "&" + toChannelId + "|" + userContent;
ctx.channel().writeAndFlush(userContent);
}
}
}
运行结果
运行结果有两种结果,1 是用户先输入 0 ,然后等待人工坐席,2 是用户登录的时候有人工坐席。
- 用户输入 0 ,等待人工坐席
用户端日志
输入 0 后,其实用户一直在这里等待,小伙伴去看下 ChannelSessionMemory.getRgChannelId()
这个获取人工做些的代码,其实大明哥这里使用的是阻塞队列 ArrayBlockingQueue,在获取的时候会等待 30 秒。
当人工坐席注册后,日志如下:
人工坐席一注册,智能客服就告诉他有用户在等待,需要他尽快接入,这个时候,他会给用户发送一条消息:您好,我是工号[xxx],请问您有什么问题需要咨询吗?
,用户收到这个消息后就知道有人工坐席了,这个时候他们就可以对话了。
- 用户输入 0 时,有人工坐席在线
如果用户输入 0 的时候,有人工坐席在线的话,会立刻收到 您好,我是工号[xxx],请问您有什么问题需要咨询吗?
。
对于人工坐席而言,他不会收到有 xxx 用户在等待,而是直接收到用户发过来的消息。
到这里整个人工坐席就已经完成了,是不是超级牛逼(LJ),超级智能(ZZ)。当然里面还有很多问题没有解决,比如用户突然下线怎么处理?还有并发情况等等一系列的情况,但是这个毕竟是一个入门级别的 demo,我们的目的是掌握 Netty 核心组件的基本概念,以及使用方法。
文章代码只是部分,完整的代码见源码部分。
Java 面试宝典是大明哥全力打造的 Java 精品面试题,它是一份靠谱、强大、详细、经典的 Java 后端面试宝典。它不仅仅只是一道道面试题,而是一套完整的 Java 知识体系,一套你 Java 知识点的扫盲贴。
它的内容包括:
- 大厂真题:Java 面试宝典里面的题目都是最近几年的高频的大厂面试真题。
- 原创内容:Java 面试宝典内容全部都是大明哥原创,内容全面且通俗易懂,回答部分可以直接作为面试回答内容。
- 持续更新:一次购买,永久有效。大明哥会持续更新 3+ 年,累计更新 1000+,宝典会不断迭代更新,保证最新、最全面。
- 覆盖全面:本宝典累计更新 1000+,从 Java 入门到 Java 架构的高频面试题,实现 360° 全覆盖。
- 不止面试:内容包含面试题解析、内容详解、知识扩展,它不仅仅只是一份面试题,更是一套完整的 Java 知识体系。
- 宝典详情:https://www.yuque.com/chenssy/sike-java/xvlo920axlp7sf4k
- 宝典总览:https://www.yuque.com/chenssy/sike-java/yogsehzntzgp4ly1
- 宝典进展:https://www.yuque.com/chenssy/sike-java/en9ned7loo47z5aw
目前 Java 面试宝典累计更新 400+ 道,总字数 42w+。大明哥还在持续更新中,下图是大明哥在 2024-12 月份的更新情况:
想了解详情的小伙伴,扫描下面二维码加大明哥微信【daming091】咨询
同时,大明哥也整理一套目前市面最常见的热点面试题。微信搜[大明哥聊 Java]或扫描下方二维码关注大明哥的原创公众号[大明哥聊 Java] ,回复【面试题】 即可免费领取。