五、前端实现

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/**,避免握手被拦截

七、通用开发步骤

  1. 准备阶段
    • 建表(chat_msg
    • 添加 WebSocket 依赖
    • 在 HTTP 拦截器中排除 /ws/** 路径
  2. 后端开发
    • 创建 ChatMsg 实体和 ChatMsgMapper
    • 创建 ChatMessageDTO
    • 创建 WebSocketConfig 配置
    • 创建 ChatWebSocketInterceptor(JWT 校验)
    • 创建 ChatWebSocketHandler(核心处理)
    • 实现消息持久化(MQ 消费者 / 线程池)
    • 实现 ChatService(历史、会话、上传)
    • 实现 ChatController(REST 接口)
  3. 前端开发
    • 创建 websocket.js 工具类
    • 创建 ChatBox.vue 组件
    • 在业务页面集成 ChatBox
  4. 测试验证
    • 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 存对象存储地址,前端适配渲染

文档结束 — 可根据项目实际情况调整字段、类型和实现方式。