跳到主要内容

WS-Websocket

2024年10月14日
柏拉文
越努力,越幸运

一、认识


客服对话功能 提供了一个健壮的实时聊天系统,具备良好的可维护性和扩展性,可以支持更复杂的需求。

Server: 客服对话实现实时对话,通过使用优先队列和状态管理来管理客服状态和用户排队机制,系统能有效响应用户请求,减少等待时间,也客服分配更加智能化。通过反馈机制和动态状态更新, 系统会在用户等待时提供状态反馈,如“正在连接客服,请稍候”,提升用户体验。定期检查连接的健康状态,及时处理断开连接的情况,提升系统的稳定性。

  1. 用户和客服连接管理: 通过 WebSocket 实现实时双向通信, 支持用户和客服的连接状态跟踪,确保双方能及时交流。

  2. 客服状态管理: 客服状态分为“空闲”、“忙碌”和“离线”,便于动态调整和分配。在客服连接关闭后,系统会尝试重新分配等待中的用户。每个客服都有一个状态,标记为FREE, BUSY, 或 OFFLINE,以便管理其可用性。客服状态一旦改变,系统会及时更新用户的连接状态。

  3. 客服分配机制: 优先使用空闲的客服,通过状态管理避免用户总是连接到同一个客服。当没有可用客服时,将用户添加到等待队列,并给予适当的反馈。通过 availableSupportAgentssupportAgentStates 管理可用客服的状态,确保空闲客服被优先分配。客服关闭连接时,会自动尝试将等待的用户分配给其他客服。

  4. 用户排队机制: 用户在没有可用客服时被添加到等待队列,并收到相应的反馈。

  5. 自动重连机制: 实现了心跳机制,定期检查连接的健康状态,及时处理断开连接的情况,提升系统的稳定性。

  6. 连接反馈: 在用户等待连接客服时,用户会收到状态更新,比如“正在连接客服,请稍候”。

Client: 支持文字、图片、音频的发送和接收,确保逻辑完整。

二、Server


const Koa = require("koa");
const HTTP = require("http");
const WebSocket = require("ws");
const KoaRouter = require("koa-router");

const app = new Koa();
const router = new KoaRouter();
const server = HTTP.createServer(app.callback());
const wss = new WebSocket.Server({ server });

const HEARTBEAT_INTERVAL = 30000; // 心跳间隔时间
const connectedClients = new Map(); // 存储用户和客服的连接
const waitingUsersQueue = []; // 存储等待的用户
const availableSupportAgents = new Map(); // 存储客服
const supportAgentStates = new Map(); // 存储客服状态

// 客服状态常量
const SUPPORT_STATES = {
FREE: "free",
BUSY: "busy",
OFFLINE: "offline",
};

// 分配客服给用户
function assignSupportAgent(userId, userSocket) {
// 检查可用客服
const availableAgents = Array.from(availableSupportAgents.keys()).filter(
(agentId) => supportAgentStates.get(agentId) === SUPPORT_STATES.FREE
);

if (availableAgents.length === 0) {
waitingUsersQueue.push(userId); // 将用户添加到等待队列
userSocket.send(
JSON.stringify({
type: "waiting",
message: "No support available, please wait.",
})
);
return;
}

// 分配一个空闲客服
const supportId = availableAgents[0];
const supportSocket = availableSupportAgents.get(supportId);
supportAgentStates.set(supportId, SUPPORT_STATES.BUSY); // 更新客服状态
supportSocket.send(JSON.stringify({ userId, type: "new_user" }));
userSocket.send(JSON.stringify({ type: "connected", supportId }));
}

// 将客服与等待用户配对
function assignUserToSupportAgent(supportId) {
const userId = waitingUsersQueue.shift(); // 获取并移除第一个等待用户
if (!userId) return;

const userSocket = connectedClients.get(userId);
userSocket.send(JSON.stringify({ type: "connected", supportId }));
const supportSocket = availableSupportAgents.get(supportId);
supportSocket.send(JSON.stringify({ userId, type: "new_user" }));
supportAgentStates.set(supportId, SUPPORT_STATES.BUSY); // 更新客服状态
}

// 处理消息
function processIncomingMessage(message, senderId) {
try {
const { text, type, recipientId } = JSON.parse(message);
const recipientSocket = connectedClients.get(recipientId);
if (recipientSocket) {
recipientSocket.send(JSON.stringify({ text, type, senderId }));
} else {
console.error(`Recipient ${recipientId} not found`);
}
} catch (error) {
console.error("Message handling error", error);
}
}

// 清理用户连接
function removeUserConnection(userId, role) {
connectedClients.delete(userId); // 从 connectedClients 中删除
if (role === "user") {
const index = waitingUsersQueue.indexOf(userId);
if (index > -1) {
waitingUsersQueue.splice(index, 1); // 从等待队列中移除
}
} else if (role === "support") {
availableSupportAgents.delete(userId); // 从可用客服中移除
supportAgentStates.delete(userId); // 从客服状态中移除
// 尝试分配等待用户
if (waitingUsersQueue.length > 0) {
assignUserToSupportAgent(userId); // 尝试分配用户
}
}
}

// 心跳机制,定期检查连接
function setupHeartbeat(webSocket) {
webSocket.isAlive = true; // 初始化心跳状态

const interval = setInterval(() => {
if (webSocket.isAlive === false) {
console.log("Terminating WebSocket due to no heartbeat response.");
return webSocket.terminate(); // 终止连接
}
webSocket.isAlive = false; // 设置为 false,等待响应
webSocket.ping(); // 发送 ping
}, HEARTBEAT_INTERVAL); // 每隔 HEARTBEAT_INTERVAL 秒检查一次

webSocket.on("pong", () => {
console.log("WebSockete pong received.");
webSocket.isAlive = true; // 收到心跳回应
});

webSocket.on("close", () => {
console.log("WebSocket closed 002");
clearInterval(interval); // 清除心跳检查
});
}

// WebSocket 连接处理
wss.on("connection", (webSocket, req) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const role = url.searchParams.get("role");
const userId = url.searchParams.get("user-id");

if (!userId || !role) {
webSocket.close(4000, "User Id and Role are required");
return;
}

connectedClients.set(userId, webSocket); // 存储连接

setupHeartbeat(webSocket); // 设置心跳机制

if (role === "user") {
assignSupportAgent(userId, webSocket); // 分配客服
} else {
availableSupportAgents.set(userId, webSocket); // 存储客服连接
supportAgentStates.set(userId, SUPPORT_STATES.FREE); // 初始化客服状态为空闲
assignUserToSupportAgent(userId); // 尝试分配用户
}

webSocket.on("message", (message) => {
processIncomingMessage(message, userId); // 处理消息
});

webSocket.on("close", () => {
removeUserConnection(userId, role); // 清理连接
});
});

// 路由设置
router.get("/", (ctx) => {
ctx.body = "Hello World";
});

// 应用中间件
app.use(router.routes()).use(router.allowedMethods());

// 启动服务器
server.listen(3000, () => {
console.log("Server started on port 3000");
});

三、Client


3.1 通用

function createNameElement(isme, name) {
const nammeEl = document.createElement("strong");
nammeEl.innerText = isme ? `:${name}` : `${name}:`;
return nammeEl;
}

function createMessageContentElement(type, message) {
switch (type) {
case "text":
return document.createTextNode(message);
case "image":
const img = document.createElement("img");
img.src = message;
img.style.maxWidth = "200px";
return img;
case "audio":
const audio = document.createElement("audio");
audio.src = message;
audio.controls = true;
return audio;
default:
return document.createTextNode("未知消息类型");
}
}

export default class Dialogue {
constructor(params) {
const { role, senderId, containerMap } = params;

this.role = role;
this.senderId = senderId;

// WebSocket
this.socket = null;

// 消息列表
this.messagesList = [];

// 录制功能
this.recordedBlobs = [];
this.mediaRecorder = null;

// 容器配置
this.containerMap = containerMap;
}

start() {
this.socket = new WebSocket(
`ws://localhost:3000?role=${this.role}&user-id=${this.senderId}`
);

this.socket.onopen = () => {
this.handleSocketOpen();
};

this.socket.onmessage = (event) => {
this.handleSocketMessage(event);
};

this.socket.onclose = () => {
this.handleSocketClose();
};

this.initializeEventListeners();
}

initializeEventListeners() {
const {
sendTextBtnEl,
sendImageBtnEl,
startRecordEl,
stopRecordEl,
sendRecordEl,
} = this.containerMap || {};
sendTextBtnEl?.addEventListener("click", this.sendText.bind(this));
sendImageBtnEl?.addEventListener("click", this.sendImage.bind(this));
startRecordEl?.addEventListener("click", this.startRecord.bind(this));
stopRecordEl?.addEventListener("click", this.stopRecord.bind(this));
sendRecordEl?.addEventListener("click", this.sendRecord.bind(this));
}

handleSocketOpen() {
console.log(`${this.role} WebSocket Connected`);
}

handleSocketClose() {
console.log(`${this.role} WebSocket Closed`);
}

handleSocketMessage(event) {
const message = JSON.parse(event.data);

this.receiveMessage(message);
}

receiveMessage(message) {
const { type, userId, supportId } = message;

switch (type) {
case "connected":
this.receiverId = supportId;
break;
case "new_user":
this.receiverId = userId;
break;
default:
break;
}

const normalizedName =
message.senderId ||
message.recipientId ||
message.supportId ||
message.userId;

this.appendMessageElement({
isme: false,
type: message.type,
name: normalizedName,
message: message.text,
});
this.messagesList.push({
isme: false,
type: message.type,
name: normalizedName,
message: message.text,
});
}

sendMessage(type, message) {
this.socket.send(
JSON.stringify({ type, text: message, recipientId: this.receiverId })
);
this.appendMessageElement({
type,
message,
name: "我",
isme: true,
});
this.messagesList.push({
type,
message,
name: "我",
isme: true,
});
}

appendMessageElement(params) {
const { name, isme, type, message } = params;
const { dialogueContainerEl } = this.containerMap;

if (!dialogueContainerEl) {
return console.log("dialogueContainerEl not found");
}

const messageDiv = document.createElement("div");
messageDiv.className = "message";

switch (type) {
case "connected":
case "new_user":
messageDiv.classList.add("header-message");

const innerHTMLMap = {
user: `<p>客服 ${name} 已连接</p>`,
support: `<p>用户 ${name} 已连接</p>`,
};

messageDiv.innerHTML = innerHTMLMap[this.role];
break;
case "text":
case "image":
case "audio":
messageDiv.classList.add(`${type}-message`);
messageDiv.classList.add(isme ? "self-message" : "other-message");
const nameElement = createNameElement(isme, name);
const messageContentElement = createMessageContentElement(
type,
message
);

if (isme) {
messageDiv.appendChild(messageContentElement);
messageDiv.appendChild(nameElement);
} else {
messageDiv.appendChild(nameElement);
messageDiv.appendChild(messageContentElement);
}

break;
default:
console.log("未知类型");
}

dialogueContainerEl.appendChild(messageDiv);

setTimeout(() => {
dialogueContainerEl.scrollTop = dialogueContainerEl.scrollHeight;
});
}

sendText() {
const { textInputEl } = this.containerMap;

if (!textInputEl) {
return console.log("textInputEl not found");
}

const text = textInputEl.value;
this.sendMessage("text", text);
textInputEl.value = "";
}

sendImage() {
const { imageInputEl } = this.containerMap;

if (!imageInputEl) {
return console.log("imageInputEl not found");
}

const fileInput = imageInputEl;
const file = fileInput.files[0];
const reader = new FileReader();

reader.onload = () => {
this.sendMessage("image", reader.result);
imageInputEl.value = "";
};

if (file) reader.readAsDataURL(file);
}

async prepareRecord() {
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
});
this.mediaRecorder = new MediaRecorder(stream);
this.mediaRecorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) {
this.recordedBlobs.push(event.data);
}
};
}

async startRecord() {
await this.prepareRecord();
this.mediaRecorder.start();
}

async stopRecord() {
this.mediaRecorder.stop();
this.recordedBlobs = [];
}

async sendRecord() {
const audioBlob = new Blob(this.recordedBlobs);
const reader = new FileReader();
reader.onload = () => {
this.sendMessage(
"audio",
reader.result.replace("data:application/octet-stream", "data:audio/wav")
);
};
reader.readAsDataURL(audioBlob);
}
}

3.2 客服

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>客服</title>
<style>
#chat {
border: 1px solid #ccc;
height: 300px;
overflow-y: scroll;
padding: 10px;
}
.message {
margin: 5px 0;
}
.header-message {
color: red;
text-align: center;
}
.other-message {
color: blue;
text-align: left;
}
.self-message {
color: green;
text-align: right;
}
.image-message {
gap: 12px;
display: flex;
}

.image-message.self-message {
justify-content: flex-end;
}
.audio-message.self-message {
justify-content: flex-end;
}
</style>
</head>
<body>
<div id="chat"></div>
<input type="text" id="textInput" placeholder="Type a message..." />
<button id="sendText">Send Text</button>
<input type="file" id="imageInput" accept="image/*" />
<button id="sendImage">Send Image</button>
<button id="startRecord">Start Record Voice</button>
<button id="stopRecord">Stop Record Voice</button>
<button id="sendRecord">Send Record Voice</button>

<script type="module">
import Dialogue from "./chat.js";

const chat = new Dialogue({
role: "support",
senderId: "support1",
containerMap: {
dialogueContainerEl: document.getElementById("chat"),
textInputEl: document.getElementById("textInput"),
imageInputEl: document.getElementById("imageInput"),
sendTextBtnEl: document.getElementById("sendText"),
sendImageBtnEl: document.getElementById("sendImage"),
startRecordEl: document.getElementById("startRecord"),
stopRecordEl: document.getElementById("stopRecord"),
sendRecordEl: document.getElementById("sendRecord"),
},
});

chat.start();
</script>
</body>
</html>

3.3 用户

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>用户</title>
<style>
#chat {
border: 1px solid #ccc;
height: 300px;
overflow-y: scroll;
padding: 10px;
}
.message {
margin: 5px 0;
}
.header-message {
color: red;
text-align: center;
}
.other-message {
color: blue;
text-align: left;
}
.self-message {
color: green;
text-align: right;
}
.image-message {
gap: 12px;
display: flex;
}
.audio-message {
gap: 12px;
display: flex;
}

.image-message.self-message {
justify-content: flex-end;
}
.audio-message.self-message {
justify-content: flex-end;
}
</style>
</head>
<body>
<div id="chat"></div>
<input type="text" id="textInput" placeholder="Type a message..." />
<button id="sendText">Send Text</button>
<input type="file" id="imageInput" accept="image/*" />
<button id="sendImage">Send Image</button>
<button id="startRecord">Start Record Voice</button>
<button id="stopRecord">Stop Record Voice</button>
<button id="sendRecord">Send Record Voice</button>

<script type="module">
import Dialogue from "./chat.js";

const chat = new Dialogue({
role: "user",
senderId: "user1",
containerMap: {
dialogueContainerEl: document.getElementById("chat"),
textInputEl: document.getElementById("textInput"),
imageInputEl: document.getElementById("imageInput"),
sendTextBtnEl: document.getElementById("sendText"),
sendImageBtnEl: document.getElementById("sendImage"),
startRecordEl: document.getElementById("startRecord"),
stopRecordEl: document.getElementById("stopRecord"),
sendRecordEl: document.getElementById("sendRecord"),
},
});

chat.start();
</script>
</body>
</html>