五、前端实现
5.1 新增文件
| 文件 |
说明 |
src/utils/websocket.js |
WebSocket 连接管理工具(单例、自动重连、心跳) |
src/components/ChatBox.vue |
聊天窗口组件 |
5.2 WebSocket 工具类
// websocket.js
class ChatWebSocket {
constructor() {
this.ws = null;
this.messageHandlers = [];
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 10;
this.reconnectDelay = 1000; // 初始 1s,指数退避
this.pingInterval = null;
}
connect(bizId, token) {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = location.host;
const url = `${protocol}//${host}/ws/chat/${bizId}?token=${token}`;
this.ws = new WebSocket(url);
this.ws.onopen = () => {
this.reconnectAttempts = 0;
this.startHeartbeat();
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.messageHandlers.forEach(handler => handler(data));
};
this.ws.onclose = () => {
this.stopHeartbeat();
this.reconnect(bizId, token);
};
this.ws.onerror = () => {
this.ws.close();
};
}
reconnect(bizId, token) {
if (this.reconnectAttempts >= this.maxReconnectAttempts) return;
const delay = Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts), 30000);
setTimeout(() => {
this.reconnectAttempts++;
this.connect(bizId, token);
}, delay);
}
startHeartbeat() {
this.pingInterval = setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'ping' }));
}
}, 30000);
}
stopHeartbeat() {
if (this.pingInterval) {
clearInterval(this.pingInterval);
this.pingInterval = null;
}
}
sendMessage(msg) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(msg));
}
}
onMessage(handler) {
this.messageHandlers.push(handler);
}
close() {
this.stopHeartbeat();
this.reconnectAttempts = this.maxReconnectAttempts; // 阻止重连
this.ws?.close();
}
}
export default ChatWebSocket;
5.3 聊天组件
<!-- ChatBox.vue -->
<template>
<div class="chat-box">
<!-- 消息列表 -->
<div class="message-list" ref="messageList">
<div v-for="msg in messages" :key="msg.msgNo"
:class="['message', msg.isSelf ? 'self' : 'other']">
<div class="message-content">
<img v-if="msg.msgType === 2" :src="msg.fileUrl" class="chat-image" />
<p v-else>{{ msg.textContent }}</p>
<span class="message-time">{{ msg.createTime }}</span>
</div>
</div>
</div>
<!-- 输入区域 -->
<div class="input-area">
<el-input v-model="inputText" type="textarea" :rows="2"
placeholder="输入消息..." @keyup.enter="sendText" />
<div class="input-actions">
<el-upload :action="uploadUrl" :on-success="handleUploadSuccess" :show-file-list="false">
<el-button>📎 图片</el-button>
</el-upload>
<el-button type="primary" @click="sendText">发送</el-button>
</div>
</div>
</div>
</template>
<script>
import ChatWebSocket from '@/utils/websocket';
export default {
props: {
bizId: { type: Number, required: true },
token: { type: String, required: true }
},
data() {
return {
messages: [],
inputText: '',
wsClient: null
};
},
created() {
// 加载历史消息
this.fetchHistory();
// 建立 WebSocket
this.initWebSocket();
},
beforeUnmount() {
this.wsClient?.close();
},
methods: {
initWebSocket() {
this.wsClient = new ChatWebSocket();
this.wsClient.connect(this.bizId, this.token);
this.wsClient.onMessage((data) => {
if (data.type === 'chat') {
this.messages.push({ ...data, isSelf: false });
this.scrollToBottom();
} else if (data.type === 'ack') {
this.handleAck(data);
}
});
},
async fetchHistory() {
const res = await this.$http.get(`/chat/history/${this.bizId}`);
this.messages = res.data.records.map(msg => ({
...msg,
isSelf: msg.senderId === this.currentUserId
})).reverse();
},
sendText() {
if (!this.inputText.trim()) return;
const msg = {
type: 'chat',
bizId: this.bizId,
msgType: 1,
textContent: this.inputText
};
this.wsClient.sendMessage(msg);
this.inputText = '';
},
handleUploadSuccess(res) {
const msg = {
type: 'chat',
bizId: this.bizId,
msgType: 2,
fileUrl: res.data
};
this.wsClient.sendMessage(msg);
},
handleAck(ack) {
// 将对应的乐观消息标记为已发送
},
scrollToBottom() {
this.$nextTick(() => {
const el = this.$refs.messageList;
el.scrollTop = el.scrollHeight;
});
}
}
};
</script>
<style scoped>
.chat-box { display: flex; flex-direction: column; height: 100%; }
.message-list { flex: 1; overflow-y: auto; padding: 10px; }
.message { margin-bottom: 10px; }
.message.self { text-align: right; }
.message.other { text-align: left; }
.message-content { display: inline-block; max-width: 70%; padding: 8px 12px; border-radius: 8px; }
.message.self .message-content { background: #409eff; color: #fff; }
.message.other .message-content { background: #f0f0f0; }
.message-time { font-size: 12px; opacity: 0.7; margin-top: 4px; display: block; }
.chat-image { max-width: 200px; max-height: 200px; border-radius: 4px; cursor: pointer; }
.input-area { border-top: 1px solid #eee; padding: 10px; }
.input-actions { display: flex; justify-content: space-between; margin-top: 8px; }
</style>
5.4 组件集成
<!-- 在业务页面嵌入 -->
<el-dialog v-model="chatVisible" title="聊天" :width="500">
<ChatBox :biz-id="currentBizId" :token="jwtToken" style="height: 500px" />
</el-dialog>
5.5 数据格式
发送消息
{
"type": "chat",
"bizId": 123,
"msgType": 1,
"textContent": "你好",
"fileUrl": ""
}
广播消息(服务端 → 其他用户)
{
"type": "chat",
"msgNo": "uuid-nodash",
"bizId": 123,
"senderId": 456,
"senderType": 1,
"msgType": 1,
"textContent": "你好",
"fileUrl": "",
"createTime": "2026-07-20 14:30:00"
}
ACK(服务端 → 发送方)
{
"type": "ack",
"msgNo": "uuid-nodash",
"createTime": "2026-07-20 14:30:00",
"status": "ok"
}
心跳
// 客户端 → 服务端
{ "type": "ping" }
// 服务端 → 客户端
{ "type": "pong" }
六、安全措施
| 措施 |
说明 |
| JWT 认证 |
WebSocket 握手时通过 query 参数传递 token,握手阶段校验 |
| 业务权限校验 |
用户只能连接到与自己相关的业务(如订单的雇主/接单方) |
| 消息长度限制 |
text_content 限制最大 2048 字符 |
| 文件类型限制 |
只允许图片/文档类型,限制大小(建议 10MB) |
| MQ 幂等 |
msg_no 唯一索引防止重复入库 |
| HTTP 路径排除 |
Web 拦截器需要排除 /ws/**,避免握手被拦截 |
七、通用开发步骤
- 准备阶段
- 建表(
chat_msg)
- 添加 WebSocket 依赖
- 在 HTTP 拦截器中排除
/ws/** 路径
- 后端开发
- 创建
ChatMsg 实体和 ChatMsgMapper
- 创建
ChatMessageDTO
- 创建
WebSocketConfig 配置
- 创建
ChatWebSocketInterceptor(JWT 校验)
- 创建
ChatWebSocketHandler(核心处理)
- 实现消息持久化(MQ 消费者 / 线程池)
- 实现
ChatService(历史、会话、上传)
- 实现
ChatController(REST 接口)
- 前端开发
- 创建
websocket.js 工具类
- 创建
ChatBox.vue 组件
- 在业务页面集成 ChatBox
- 测试验证
- WebSocket 连接/断线重连测试
- 消息发送/接收/ACK 测试
- 历史消息分页加载测试
- 图片发送查看测试
- 多用户同房间广播测试
八、关键配置
8.1 WebSocket 端点
ws://host:port/ws/chat/{bizId}?token={JWT}
8.2 心跳机制
| 参数 |
值 |
| Ping 间隔 |
30 秒(客户端发送) |
| 服务端超时 |
60 秒无消息自动断开 |
8.3 Nginx 代理配置
location /ws/ {
proxy_pass http://backend-server:port;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
8.4 断线重连策略
- 指数退避:1s → 2s → 4s → 8s → ... → 30s(上限)
- 最大重试次数:10 次(可配置)
九、扩展思路
| 场景 |
改造点 |
| 已读/未读 |
chat_msg 表加 is_read字段,增加已读回执消息类型 |
| 消息撤回 |
增加撤回消息类型,后端做时效校验(如 2 分钟内可撤回) |
| 输入中提示 |
增加 typing消息类型,前端 debounce 发送 |
| @某人 |
text_content 中解析 @ 语法,extra 字段存 @ 的用户ID列表 |
| 消息转发 |
batchInsert 消息记录,关联原消息 ID |
| 文件/语音 |
msg_type 扩展,file_url 存对象存储地址,前端适配渲染 |
文档结束 — 可根据项目实际情况调整字段、类型和实现方式。
评论