本文档面向前端开发人员,说明如何将三端(微信小程序、PC 平台坐席端、PC 商家端)接入在线客服系统后端,实现实时消息通信。
┌─────────────────┐ WebSocket + REST API ┌──────────────────────┐
│ 微信小程序端 │ ◄──────────────────────► │ │
│ pages/chat/ │ │ Java 后端服务 │
│ chat.vue │ │ Spring Boot 2.7 │
└─────────────────┘ │ 端口: 8080 │
│ │
┌─────────────────┐ WebSocket + REST API │ ws://host:8080/ │
│ PC 平台坐席端 │ ◄──────────────────────► │ api/chat/ws/chat │
│ ChatWorkbench │ │ │
│ .vue │ └──────────────────────┘
└─────────────────┘
│
┌─────────────────┐ WebSocket + REST API │
│ PC 商家端 │ ◄──────────────────────────────────┘
│ merchant Chat │
│ .vue │
└─────────────────┘
三端的差异说明:
| 特性 | 小程序用户端 | PC 坐席端 | PC 商家端 |
|---|---|---|---|
| 会话类型(sessionType) | 1 |
— | 2 |
| 可发送岗位卡片 | ❌ 仅接收 | ✅ 可发送 | ❌ 仅接收 |
| 可发送结算单 | ❌ 仅查看/支付 | ✅ 可发送 | ❌ |
| 可发图片/附件 | ✅ | ✅ | ✅ |
| 右侧显示订单 | 显示测评/培训订单 | 显示对应用户/商家订单 | — |
后端服务地址(开发环境):
REST API: http://localhost:8080/api/chat
WebSocket: ws://localhost:8080/api/chat/ws/chat
API 文档: http://localhost:8080/api/chat/doc.html
请求头(认证):
Authorization: Bearer {JWT_TOKEN}
数据格式: application/json
在 pc-web-preview 和 merchant-chat-pc 项目中安装 STOMP 客户端库:
npm install @stomp/stompjs sockjs-client
UniApp 中通过 uni.connectSocket 原生 API 建立 WebSocket,无需额外安装库。
建议在项目中创建 src/utils/chatSocket.js:
import { Client } from '@stomp/stompjs';
import SockJS from 'sockjs-client';
let stompClient = null;
let onMessageCallback = null; // 收到消息的回调
/**
* 建立 WebSocket 连接
* @param {string} token JWT 登录令牌
* @param {string} sessionNo 会话编号(从创建会话接口获取)
* @param {Function} onMessage 收到消息时的回调函数
*/
export function connectChat(token, sessionNo, onMessage) {
onMessageCallback = onMessage;
stompClient = new Client({
// 使用 SockJS 兼容层连接
webSocketFactory: () => new SockJS('http://localhost:8080/api/chat/ws/chat'),
// 携带认证 Token
connectHeaders: {
Authorization: 'Bearer ' + token
},
// 重连间隔(毫秒)
reconnectDelay: 5000,
// 连接建立后订阅频道
onConnect: () => {
console.log('[ChatSocket] 连接成功');
// 订阅本会话的消息频道(双端共用同一频道)
stompClient.subscribe(`/topic/session/${sessionNo}`, (frame) => {
const data = JSON.parse(frame.body);
if (onMessageCallback) {
onMessageCallback(data);
}
});
// 订阅个人通知频道(新消息提醒、已读回执等)
stompClient.subscribe('/user/queue/notify', (frame) => {
const data = JSON.parse(frame.body);
handleNotify(data);
});
},
onDisconnect: () => {
console.log('[ChatSocket] 连接已断开');
},
onStompError: (frame) => {
console.error('[ChatSocket] 错误:', frame.headers['message']);
}
});
stompClient.activate();
}
/**
* 通过 WebSocket 发送文本消息
* @param {number} sessionId 会话ID
* @param {string} content 消息内容
*/
export function sendTextByWs(sessionId, content) {
if (!stompClient || !stompClient.connected) {
console.warn('[ChatSocket] 未连接,无法发送消息');
return;
}
stompClient.publish({
destination: '/app/chat/send',
body: JSON.stringify({
msgType: 'text',
sessionId: sessionId,
msgNo: generateMsgNo(), // 客户端生成唯一消息编号(防重发)
content: content
})
});
}
/**
* 断开连接(页面销毁时调用)
*/
export function disconnectChat() {
if (stompClient) {
stompClient.deactivate();
stompClient = null;
}
}
/** 处理系统通知(结算单失效、已读回执等) */
function handleNotify(data) {
console.log('[ChatSocket] 收到通知:', data);
// 根据 type 分发处理
// ORDER_CARD_EXPIRE: 结算单失效
// READ_RECEIPT: 消息已读
}
/** 生成唯一消息编号 */
function generateMsgNo() {
return 'MSG_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
}
<script setup>
import { onMounted, onUnmounted } from 'vue';
import { connectChat, sendTextByWs, disconnectChat } from '@/utils/chatSocket';
const props = defineProps({ sessionNo: String, sessionId: Number });
const messages = ref([]);
onMounted(() => {
const token = localStorage.getItem('token'); // 获取登录 token
// 建立 WebSocket 连接
connectChat(token, props.sessionNo, (msg) => {
handleIncomingMessage(msg);
});
// 加载历史消息
loadHistory();
});
onUnmounted(() => {
disconnectChat(); // 页面销毁时断开连接
});
/** 处理收到的消息(统一分发) */
function handleIncomingMessage(data) {
switch (data.type) {
case 'MSG':
// 普通消息,添加到消息列表
messages.value.push(data);
break;
case 'ORDER_CARD_EXPIRE':
// 结算单失效,找到对应消息并更新状态
updateOrderCardStatus(data.orderCardId, 'expired');
break;
case 'ORDER_CARD_PAID':
// 结算单已支付
updateOrderCardStatus(data.orderCardId, 'paid');
break;
case 'READ_RECEIPT':
// 对方已读
console.log('消息已读到:', data.lastReadMsgId);
break;
}
}
/** 更新结算单卡片状态 */
function updateOrderCardStatus(orderCardId, status) {
const msg = messages.value.find(m =>
m.msgType === 'order_card' && m.payload?.orderCardId === orderCardId
);
if (msg) {
msg.payload.status = status;
msg.payload.countdown = 0;
}
}
</script>
// pages/chat/chat.vue
let socketTask = null;
/** 建立 WebSocket 连接(小程序原生 API) */
function connectSocket(sessionNo, token) {
socketTask = uni.connectSocket({
// 注意:小程序不支持 SockJS,直接使用原生 WebSocket
url: `ws://你的服务器IP:8080/api/chat/ws/chat/${sessionNo}?token=${token}`,
header: { Authorization: 'Bearer ' + token }
});
socketTask.onOpen(() => {
console.log('WebSocket 连接成功');
// 发送订阅信令(STOMP 格式)
socketTask.send({
data: `SUBSCRIBE\ndestination:/topic/session/${sessionNo}\n\n\0`
});
});
socketTask.onMessage((res) => {
const data = JSON.parse(res.data);
handleIncomingMessage(data);
});
socketTask.onClose(() => {
console.log('WebSocket 已关闭,尝试重连...');
setTimeout(() => connectSocket(sessionNo, token), 3000);
});
}
/** 发送文本消息 */
function sendMessage(sessionId, content) {
const payload = JSON.stringify({
msgType: 'text',
sessionId: sessionId,
msgNo: 'MSG_' + Date.now(),
content: content
});
socketTask.send({ data: payload });
}
/** 页面卸载时关闭连接 */
onUnmounted(() => {
if (socketTask) socketTask.close();
});
小程序用户进入聊天页时调用:
// sessionType: 1 = 小程序用户
async function createOrGetSession(userId, userName, avatar) {
const res = await fetch('http://localhost:8080/api/chat/session/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token
},
body: JSON.stringify({
sessionType: 1, // 小程序用户
fromUserId: userId,
fromUserName: userName,
fromUserAvatar: avatar
})
});
const data = await res.json();
// data.data.sessionNo → 用于建立 WebSocket 订阅
// data.data.sessionId → 用于后续消息发送
return data.data;
}
商家端进入聊天时调用(sessionType 传 2):
body: JSON.stringify({
sessionType: 2, // PC 商家
fromUserId: merchantId,
fromUserName: merchantName,
fromUserAvatar: merchantLogo
})
async function loadHistory(sessionId, beforeMsgId = null) {
const params = new URLSearchParams({
sessionId,
page: 1,
pageSize: 50,
...(beforeMsgId ? { beforeMsgId } : {})
});
const res = await fetch(`http://localhost:8080/api/chat/message/history?${params}`, {
headers: { 'Authorization': 'Bearer ' + token }
});
const data = await res.json();
// data.data.rows → 消息列表(按时间倒序)
return data.data.rows.reverse(); // 翻转为正序显示
}
// PC 端
async function sendImage(sessionId, file) {
const formData = new FormData();
formData.append('sessionId', sessionId);
formData.append('msgNo', 'MSG_' + Date.now());
formData.append('file', file);
const res = await fetch('http://localhost:8080/api/chat/message/send/image', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
body: formData
});
return res.json();
}
// 小程序端(UniApp)
function sendImageMini(sessionId) {
uni.chooseImage({
count: 1,
success: (res) => {
const tempFilePath = res.tempFilePaths[0];
uni.uploadFile({
url: 'http://你的服务器IP:8080/api/chat/message/send/image',
filePath: tempFilePath,
name: 'file',
formData: {
sessionId: sessionId,
msgNo: 'MSG_' + Date.now()
},
header: { Authorization: 'Bearer ' + token },
success: (uploadRes) => {
console.log('图片发送成功', JSON.parse(uploadRes.data));
}
});
}
});
}
仅 PC 坐席端可调用此接口,商家端无此功能。
async function sendJobCard(sessionId, jobInfo) {
const res = await fetch('http://localhost:8080/api/chat/message/send/job-card', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token
},
body: JSON.stringify({
sessionId: sessionId,
msgNo: 'MSG_' + Date.now(),
senderId: currentWaiterId, // 当前坐席客服 ID
payload: {
jobId: jobInfo.id,
title: jobInfo.title,
salary: jobInfo.salary,
tags: jobInfo.tags,
company: jobInfo.company,
location: jobInfo.location,
urgency: jobInfo.urgency,
quota: jobInfo.quota,
deadline: jobInfo.deadline
}
})
});
return res.json();
}
仅 PC 坐席端向小程序用户发送,商家沟通中无结算单功能。
async function sendOrderCard(sessionId, orderInfo) {
const res = await fetch('http://localhost:8080/api/chat/message/send/order-card', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token
},
body: JSON.stringify({
sessionId: sessionId,
msgNo: 'MSG_' + Date.now(),
senderId: currentWaiterId,
payload: {
orderName: orderInfo.name, // 结算单项目名称
orderPrice: orderInfo.price, // 金额(数字类型)
orderType: orderInfo.type // 类型描述(可选)
}
})
});
const data = await res.json();
// 响应中包含 expireTime(过期时间)和 countdownSeconds(60)
// 前端开始本地倒计时显示
return data.data;
}
async function payOrderCard(orderCardId, userId) {
const res = await fetch(
`http://localhost:8080/api/chat/message/order-card/${orderCardId}/pay?userId=${userId}`,
{
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token }
}
);
const data = await res.json();
if (data.code === 200) {
// 支付成功,更新本地 UI 状态为"已支付"
} else {
// 支付失败(可能已失效)
uni.showToast({ title: data.msg, icon: 'error' });
}
}
/**
* 启动结算单本地倒计时(接收到 order_card 消息后调用)
* @param {object} msg 消息对象
*/
function startOrderCardCountdown(msg) {
if (msg.msgType !== 'order_card') return;
if (msg.payload.status !== 'pending') return;
const expireTime = new Date(msg.payload.expireTime).getTime();
const timer = setInterval(() => {
const remaining = Math.floor((expireTime - Date.now()) / 1000);
if (remaining <= 0) {
clearInterval(timer);
msg.payload.countdown = 0;
// 若服务端尚未推送失效通知,本地先更新 UI
if (msg.payload.status === 'pending') {
msg.payload.status = 'expired';
}
} else {
msg.payload.countdown = remaining;
}
}, 1000);
// 组件销毁时清除定时器(防内存泄漏)
onUnmounted(() => clearInterval(timer));
}
ORDER_CARD_EXPIRE 事件| msgType | 说明 | 渲染组件 |
|---|---|---|
text |
文本消息 | 气泡文字 |
emoji |
表情消息 | 表情图标气泡 |
image |
图片消息 | 图片缩略图,点击放大 |
file |
附件消息 | 文件卡片(图标+名称+大小),支持预览/下载 |
job_card |
岗位推荐卡片 | 卡片组件(仅坐席发送,小程序接收) |
order_card |
结算单卡片 | 含倒计时/状态标签+支付按钮 |
| senderType | 含义 | 气泡方向 |
|---|---|---|
1 |
用户(小程序)或商家 | 右侧显示 |
2 |
坐席客服 | 左侧显示 |
3 |
系统消息 | 居中灰色小字 |
| status(payload内) | 中文显示 | 颜色 | 按钮 |
|---|---|---|---|
pending |
倒计时(60s → 0s) | 橙色 | 显示"立即支付" |
expired |
已失效 | 灰色 | 显示"已失效"(不可点击) |
paid |
已支付 | 绿色 | 显示"已支付" |
cancelled |
已取消 | 灰色 | — |
仅 PC 坐席端后台管理使用(
SeatConfig.vue)
const BASE = 'http://localhost:8080/api/chat/seat';
const headers = { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
// 获取坐席列表
const list = await fetch(`${BASE}/list?page=1&pageSize=10`, { headers }).then(r => r.json());
// 新增坐席
await fetch(BASE, {
method: 'POST', headers,
body: JSON.stringify({ seatName: '客服小D', module: '小程序', status: 1, waiterIds: [1001] })
});
// 上传坐席头像(先上传获取URL,再提交表单)
const formData = new FormData();
formData.append('file', avatarFile);
const avatarRes = await fetch(`${BASE}/avatar/upload`, {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token }, // 注意:不加 Content-Type,让浏览器自动设置边界
body: formData
}).then(r => r.json());
// avatarRes.data.avatarUrl → 头像 URL,存入表单后一起提交
根据业务规则,商家端(PC)与小程序用户端在功能上有明确隔离:
商家端(sessionType = 2):
小程序用户端(sessionType = 1):
坐席端(PC):
// 根据当前会话类型控制发送卡片按钮的显示
const isMerchantSession = computed(() => activeSession.value?.type === 'merchant');
// 模板中:
// <el-button v-if="!isMerchantSession" @click="sendCard">发送卡片</el-button>
| 配置项 | 开发环境 | 生产环境 |
|---|---|---|
| 后端地址 | http://localhost:8080/api/chat |
https://你的域名/api/chat |
| WebSocket 地址 | ws://localhost:8080/api/chat/ws/chat |
wss://你的域名/api/chat/ws/chat |
| 小程序合法域名 | — | 需在微信公众平台配置 request、socket 合法域名 |
| HTTPS | 不需要 | 必须(微信小程序要求) |
| JWT 密钥 | 开发用密钥 | 修改为强密钥(≥32位随机字符串) |
在各前端项目中创建或修改环境变量文件:
# pc-web-preview/.env.production
VITE_API_BASE = https://你的域名/api/chat
VITE_WS_URL = wss://你的域名/api/chat/ws/chat
// 在 chatSocket.js 中:
webSocketFactory: () => new SockJS(import.meta.env.VITE_WS_URL || 'http://localhost:8080/api/chat/ws/chat')
完成接入后,请按以下流程验证功能:
基础消息流程:
结算单流程:
商家沟通流程:
坐席管理流程:
文档版本:v1.0 · 2026-03-31