SSE(Server-Sent Events)是单向通信,只能从服务器推送到客户端,不能从客户端发送消息到服务器。
如果您需要前端向服务器发送购物车变化,有两种方案:
方案一:SSE + HTTP API(推荐)
方案二:WebSocket(双向通信)
本系统同时支持两种方案,您可以根据需求选择使用。
SSE(Server-Sent Events)是一种服务器推送技术,允许服务器主动向客户端推送数据。与WebSocket相比,SSE更简单、更轻量,特别适合单向数据推送场景。
GET /api/store/order/sse/{tableId}
Content-Type: text/event-stream
参数说明:
tableId:桌号ID(路径参数,必填)响应类型: text/event-stream
@ApiOperation(value = "建立SSE连接", notes = "建立SSE连接,用于接收购物车更新消息")
@GetMapping(value = "/sse/{tableId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter createSseConnection(
@ApiParam(value = "桌号ID", required = true) @PathVariable Integer tableId
) {
log.info("建立SSE连接, tableId={}", tableId);
return sseService.createConnection(tableId);
}
接口定义:
public interface SseService {
/**
* 创建SSE连接
* @param tableId 桌号ID
* @return SSE连接对象
*/
SseEmitter createConnection(Integer tableId);
/**
* 推送购物车更新消息
* @param tableId 桌号ID
* @param message 消息内容
*/
void pushCartUpdate(Integer tableId, Object message);
/**
* 关闭SSE连接
* @param tableId 桌号ID
*/
void closeConnection(Integer tableId);
}
在购物车操作后推送更新:
// 添加商品到购物车后
CartDTO cart = cartService.addItem(dto);
sseService.pushCartUpdate(dto.getTableId(), cart);
// 更新购物车商品数量后
CartDTO cart = cartService.updateQuantity(tableId, cuisineId, quantity);
sseService.pushCartUpdate(tableId, cart);
// 删除购物车商品后
CartDTO cart = cartService.removeItem(tableId, cuisineId);
sseService.pushCartUpdate(tableId, cart);
// 清空购物车后
cartService.clearCart(tableId);
sseService.pushCartUpdate(tableId, new CartDTO());
后端会发送以下类型的事件:
| 事件名称 | 说明 | 数据格式 |
|---|---|---|
connected |
连接成功 | 字符串:"连接成功" |
cart_update |
购物车更新 | JSON格式的CartDTO对象 |
heartbeat |
心跳消息 | 字符串:"ping" |
项目发到线上时,SSE 长连接需要以下配置,否则容易出现连接被提前断开、收不到推送等问题。
response-timeout,会主动断开长时间无“完成”的响应,导致 SSE 长连接被踢掉。response-timeout: -1),且该路由需放在点餐通用路由之前,保证先匹配 SSE。配置示例(加入 Nacos 中 alien-gateway 的网关路由配置,如 alien-gateway.yml):
# 此条 SSE 路由必须放在 aliendining 通用路由前面
- id: aliendining-sse
uri: http://${route_or_local_ip}:30014 # 与现有 aliendining 保持一致
predicates:
- Path=/aliendining/store/order/sse/**
filters:
- StripPrefix=1
metadata:
response-timeout: -1 # -1 表示不超时,避免 SSE 被网关提前断开
若使用 lb 负载均衡(如 lb://alien-dining),同样给上述 SSE 路由加上 metadata.response-timeout: -1 即可。
若网关前还有 Nginx(或其它反向代理),需避免对 SSE 做缓冲并拉长超时:
proxy_buffering off;(或对 location ~ /aliendining/store/order/sse 单独关闭)proxy_read_timeout 建议 ≥ 30 分钟(如 1800s),或略大于业务侧 SSE 超时(当前为 30 分钟)。proxy_connect_timeout、proxy_send_timeout 也可适当调大,避免代理层先断连。ConcurrentHashMap)。同一桌号若被负载均衡到不同实例,只有“写操作发生的那台实例”上的 SSE 连接会收到推送,其它实例上的同桌连接收不到。SseServiceImpl 与 Nacos/配置)。https://你的域名/aliendining/store/order/sse/{tableId}Path 与 StripPrefix 为准,保证最终能路由到 alien-dining 的 /store/order/sse/{tableId}。/api),则 SSE 地址中也要带上该前缀。| 环境 | 必做项 |
|---|---|
| 网关 | 为 SSE 路径单独路由并设置 response-timeout: -1,且路由顺序优先 |
| Nginx/反向代理 | proxy_buffering off,proxy_read_timeout ≥ 30 分钟 |
| 多实例 | 会话保持或改为 Redis 等跨实例推送 |
| 前端 | 使用经网关的完整 SSE URL |
// 建立SSE连接
const tableId = 123; // 桌号ID
const eventSource = new EventSource(`/api/store/order/sse/${tableId}`);
// 监听连接成功事件
eventSource.addEventListener('connected', function(event) {
console.log('SSE连接成功:', event.data);
// event.data = "连接成功"
});
// 监听购物车更新事件
eventSource.addEventListener('cart_update', function(event) {
console.log('购物车更新:', event.data);
// event.data 是JSON字符串,需要解析
const cart = JSON.parse(event.data);
updateCartUI(cart); // 更新购物车UI
});
// 监听心跳事件
eventSource.addEventListener('heartbeat', function(event) {
console.log('收到心跳:', event.data);
// event.data = "ping"
});
// 监听错误事件
eventSource.onerror = function(event) {
console.error('SSE连接错误:', event);
// 连接断开或出错时会触发
// 浏览器会自动尝试重连
};
// 关闭连接
// eventSource.close();
<template>
<div>
<div v-if="!connected">正在连接...</div>
<div v-else>已连接</div>
<!-- 购物车UI -->
<div v-for="item in cart.items" :key="item.cuisineId">
{{ item.cuisineName }} x {{ item.quantity }}
</div>
</div>
</template>
<script>
export default {
data() {
return {
eventSource: null,
connected: false,
cart: {
items: [],
totalAmount: 0,
totalQuantity: 0
}
}
},
mounted() {
this.initSSE();
},
beforeDestroy() {
this.closeSSE();
},
methods: {
// 初始化SSE连接
initSSE() {
const tableId = this.$route.params.tableId || 123;
const url = `/api/store/order/sse/${tableId}`;
this.eventSource = new EventSource(url);
// 连接成功
this.eventSource.addEventListener('connected', (event) => {
console.log('SSE连接成功');
this.connected = true;
});
// 购物车更新
this.eventSource.addEventListener('cart_update', (event) => {
try {
const cart = JSON.parse(event.data);
this.cart = cart;
this.$message.success('购物车已更新');
} catch (error) {
console.error('解析购物车数据失败:', error);
}
});
// 心跳
this.eventSource.addEventListener('heartbeat', (event) => {
console.log('收到心跳');
});
// 错误处理
this.eventSource.onerror = (error) => {
console.error('SSE连接错误:', error);
this.connected = false;
// 可以在这里实现重连逻辑
};
},
// 关闭SSE连接
closeSSE() {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
this.connected = false;
}
}
}
}
</script>
import React, { useEffect, useState, useRef } from 'react';
function CartComponent({ tableId }) {
const [connected, setConnected] = useState(false);
const [cart, setCart] = useState({ items: [], totalAmount: 0, totalQuantity: 0 });
const eventSourceRef = useRef(null);
useEffect(() => {
// 建立SSE连接
const url = `/api/store/order/sse/${tableId}`;
const eventSource = new EventSource(url);
eventSourceRef.current = eventSource;
// 连接成功
eventSource.addEventListener('connected', (event) => {
console.log('SSE连接成功');
setConnected(true);
});
// 购物车更新
eventSource.addEventListener('cart_update', (event) => {
try {
const cartData = JSON.parse(event.data);
setCart(cartData);
console.log('购物车已更新');
} catch (error) {
console.error('解析购物车数据失败:', error);
}
});
// 心跳
eventSource.addEventListener('heartbeat', (event) => {
console.log('收到心跳');
});
// 错误处理
eventSource.onerror = (error) => {
console.error('SSE连接错误:', error);
setConnected(false);
};
// 清理函数
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
};
}, [tableId]);
return (
<div>
<div>{connected ? '已连接' : '正在连接...'}</div>
<div>
{cart.items.map(item => (
<div key={item.cuisineId}>
{item.cuisineName} x {item.quantity}
</div>
))}
</div>
<div>总金额: {cart.totalAmount}</div>
</div>
);
}
export default CartComponent;
// 微信小程序不支持EventSource,需要使用wx.request或第三方库
// 方案1:使用wx.request轮询(不推荐,但可用)
// 方案2:使用WebSocket(推荐)
// 方案3:使用第三方SSE库
// 这里提供一个简单的轮询方案作为备选
let pollingTimer = null;
function startSSEPolling(tableId, onMessage) {
const poll = () => {
wx.request({
url: `https://your-api.com/api/store/order/cart/${tableId}`,
method: 'GET',
success: (res) => {
if (res.data && res.data.code === 200) {
onMessage(res.data.data); // 购物车数据
}
},
complete: () => {
// 每2秒轮询一次
pollingTimer = setTimeout(poll, 2000);
}
});
};
poll();
}
function stopSSEPolling() {
if (pollingTimer) {
clearTimeout(pollingTimer);
pollingTimer = null;
}
}
// 使用
Page({
onLoad(options) {
const tableId = options.tableId;
startSSEPolling(tableId, (cart) => {
this.setData({ cart });
});
},
onUnload() {
stopSSEPolling();
}
});
数据格式: JSON字符串,解析后为CartDTO对象
{
"tableId": 123,
"tableNumber": "A01",
"storeId": 1,
"items": [
{
"cuisineId": 1001,
"cuisineName": "宫保鸡丁",
"cuisineType": 1,
"cuisineImage": "https://example.com/image.jpg",
"unitPrice": 38.00,
"quantity": 2,
"subtotalAmount": 76.00,
"addUserId": 100,
"addUserPhone": "13800138000",
"remark": "不要花生"
}
],
"totalAmount": 76.00,
"totalQuantity": 2
}
字段说明:
| 字段 | 类型 | 说明 |
|---|---|---|
| tableId | Integer | 桌号ID |
| tableNumber | String | 桌号 |
| storeId | Integer | 门店ID |
| items | Array | 购物车商品列表 |
| totalAmount | BigDecimal | 总金额 |
| totalQuantity | Integer | 商品总数量 |
CartItemDTO字段说明:
| 字段 | 类型 | 说明 |
|---|---|---|
| cuisineId | Integer | 菜品ID |
| cuisineName | String | 菜品名称 |
| cuisineType | Integer | 菜品类型(1:单品, 2:套餐) |
| cuisineImage | String | 菜品图片 |
| unitPrice | BigDecimal | 单价 |
| quantity | Integer | 数量 |
| subtotalAmount | BigDecimal | 小计金额 |
| addUserId | Integer | 添加该菜品的用户ID |
| addUserPhone | String | 添加该菜品的用户手机号 |
| remark | String | 备注 |
"连接成功"
"ping"
eventSource.onerror = function(event) {
console.error('SSE连接错误:', event);
// 检查连接状态
if (eventSource.readyState === EventSource.CLOSED) {
console.log('连接已关闭');
// 可以在这里实现重连逻辑
reconnect();
} else if (eventSource.readyState === EventSource.CONNECTING) {
console.log('正在重连...');
}
};
let reconnectAttempts = 0;
const maxReconnectAttempts = 5;
let reconnectTimer = null;
function reconnect() {
if (reconnectAttempts >= maxReconnectAttempts) {
console.error('达到最大重连次数,停止重连');
return;
}
reconnectAttempts++;
console.log(`尝试重连 (${reconnectAttempts}/${maxReconnectAttempts})`);
reconnectTimer = setTimeout(() => {
initSSE(); // 重新初始化SSE连接
}, 3000 * reconnectAttempts); // 指数退避
}
function initSSE() {
const eventSource = new EventSource(url);
eventSource.addEventListener('connected', () => {
reconnectAttempts = 0; // 重置重连次数
clearTimeout(reconnectTimer);
});
eventSource.onerror = () => {
reconnect();
};
}
eventSource.addEventListener('cart_update', function(event) {
try {
const cart = JSON.parse(event.data);
updateCartUI(cart);
} catch (error) {
console.error('解析购物车数据失败:', error);
// 可以显示错误提示或使用默认值
showError('购物车数据解析失败,请刷新页面');
}
});
页面加载时建立连接
mounted() {
this.initSSE();
}
页面卸载时关闭连接
beforeDestroy() {
this.closeSSE();
}
路由切换时关闭旧连接
watch: {
'$route'(to, from) {
if (to.params.tableId !== from.params.tableId) {
this.closeSSE();
this.initSSE();
}
}
}
避免频繁更新UI
let updateTimer = null;
eventSource.addEventListener('cart_update', function(event) {
// 防抖处理,避免频繁更新
clearTimeout(updateTimer);
updateTimer = setTimeout(() => {
const cart = JSON.parse(event.data);
updateCartUI(cart);
}, 100);
});
使用虚拟滚动(如果商品很多)
// 使用vue-virtual-scroll-list等库
显示连接状态
<div v-if="connected" class="status connected">已连接</div>
<div v-else class="status disconnected">连接中...</div>
显示更新提示
eventSource.addEventListener('cart_update', function(event) {
const cart = JSON.parse(event.data);
updateCartUI(cart);
showToast('购物车已更新'); // 显示提示
});
处理网络异常
eventSource.onerror = function(event) {
if (navigator.onLine === false) {
showError('网络连接已断开,请检查网络设置');
}
};
验证tableId权限
防止XSS攻击
可能原因:
Content-Type: text/event-stream解决方案:
produces属性可能原因:
解决方案:
cart_update)说明:
解决方案:
// 不监听heartbeat事件即可
// eventSource.addEventListener('heartbeat', ...); // 注释掉
说明:
实现:
连接测试
消息推送测试
多用户测试
连接数测试
消息推送测试
浏览器兼容性
移动端测试
GET /api/store/order/sse/{tableId}
请求参数:
tableId (路径参数): 桌号ID响应:
text/event-streamPOST /api/store/order/cart/add # 添加商品到购物车
PUT /api/store/order/cart/update # 更新购物车商品数量
DELETE /api/store/order/cart/remove # 删除购物车商品
DELETE /api/store/order/cart/clear # 清空购物车
GET /api/store/order/cart/{tableId} # 获取购物车
SSE是一种简单高效的服务器推送技术,特别适合购物车实时更新场景。通过本文档,您可以:
如有问题,请参考代码实现或联系开发团队。
WebSocket是一种全双工通信协议,允许客户端和服务器之间进行双向实时通信。与SSE相比,WebSocket支持客户端主动向服务器发送消息。
ws://your-domain/ws/cart/{tableId}
或
wss://your-domain/ws/cart/{tableId} (HTTPS环境)
参数说明:
tableId:桌号ID(路径参数,必填)后端已实现 CartWebSocketProcess 类,位于:
shop.alien.dining.config.CartWebSocketProcess
主要功能:
| 消息类型 | 说明 | 数据格式 |
|---|---|---|
heartbeat |
心跳消息 | {"type": "heartbeat"} |
add_item |
添加商品 | 见下方示例 |
update_quantity |
更新数量 | 见下方示例 |
remove_item |
删除商品 | 见下方示例 |
clear_cart |
清空购物车 | {"type": "clear_cart"} |
| 消息类型 | 说明 | 数据格式 |
|---|---|---|
connected |
连接成功 | 见下方示例 |
cart_update |
购物车更新 | 见下方示例 |
add_item_success |
添加商品成功 | 见下方示例 |
update_quantity_success |
更新数量成功 | 见下方示例 |
remove_item_success |
删除商品成功 | 见下方示例 |
clear_cart_success |
清空购物车成功 | 见下方示例 |
error |
错误消息 | 见下方示例 |
heartbeat |
心跳回复 | {"type": "heartbeat", "message": "pong"} |
// 建立WebSocket连接
const tableId = 123; // 桌号ID
const ws = new WebSocket(`ws://your-domain/ws/cart/${tableId}`);
// 连接成功
ws.onopen = function(event) {
console.log('WebSocket连接成功');
};
// 接收消息
ws.onmessage = function(event) {
const message = JSON.parse(event.data);
console.log('收到消息:', message);
switch(message.type) {
case 'connected':
console.log('连接成功:', message.message);
break;
case 'cart_update':
// 更新购物车UI
updateCartUI(message.data);
break;
case 'add_item_success':
console.log('添加商品成功');
updateCartUI(message.data);
break;
case 'update_quantity_success':
console.log('更新数量成功');
updateCartUI(message.data);
break;
case 'remove_item_success':
console.log('删除商品成功');
updateCartUI(message.data);
break;
case 'clear_cart_success':
console.log('清空购物车成功');
updateCartUI(message.data);
break;
case 'error':
console.error('错误:', message.message);
showError(message.message);
break;
case 'heartbeat':
console.log('收到心跳');
break;
}
};
// 连接错误
ws.onerror = function(error) {
console.error('WebSocket错误:', error);
};
// 连接关闭
ws.onclose = function(event) {
console.log('WebSocket连接关闭');
// 可以实现重连逻辑
reconnect();
};
// 发送消息:添加商品
function addItem(cuisineId, quantity, remark) {
const message = {
type: 'add_item',
data: {
cuisineId: cuisineId,
quantity: quantity,
remark: remark || null
}
};
ws.send(JSON.stringify(message));
}
// 发送消息:更新数量
function updateQuantity(cuisineId, quantity) {
const message = {
type: 'update_quantity',
data: {
cuisineId: cuisineId,
quantity: quantity
}
};
ws.send(JSON.stringify(message));
}
// 发送消息:删除商品
function removeItem(cuisineId) {
const message = {
type: 'remove_item',
data: {
cuisineId: cuisineId
}
};
ws.send(JSON.stringify(message));
}
// 发送消息:清空购物车
function clearCart() {
const message = {
type: 'clear_cart'
};
ws.send(JSON.stringify(message));
}
// 发送心跳
function sendHeartbeat() {
const message = {
type: 'heartbeat'
};
ws.send(JSON.stringify(message));
}
// 每30秒发送一次心跳
setInterval(sendHeartbeat, 30000);
// 关闭连接
// ws.close();
<template>
<div>
<div v-if="!connected">正在连接...</div>
<div v-else>已连接</div>
<!-- 购物车UI -->
<div v-for="item in cart.items" :key="item.cuisineId">
{{ item.cuisineName }} x {{ item.quantity }}
<button @click="updateQuantity(item.cuisineId, item.quantity + 1)">+</button>
<button @click="updateQuantity(item.cuisineId, item.quantity - 1)">-</button>
<button @click="removeItem(item.cuisineId)">删除</button>
</div>
<button @click="clearCart">清空购物车</button>
</div>
</template>
<script>
export default {
data() {
return {
ws: null,
connected: false,
cart: {
items: [],
totalAmount: 0,
totalQuantity: 0
}
}
},
mounted() {
this.initWebSocket();
},
beforeDestroy() {
this.closeWebSocket();
},
methods: {
// 初始化WebSocket连接
initWebSocket() {
const tableId = this.$route.params.tableId || 123;
const wsUrl = `ws://your-domain/ws/cart/${tableId}`;
this.ws = new WebSocket(wsUrl);
// 连接成功
this.ws.onopen = () => {
console.log('WebSocket连接成功');
this.connected = true;
};
// 接收消息
this.ws.onmessage = (event) => {
const message = JSON.parse(event.data);
this.handleMessage(message);
};
// 连接错误
this.ws.onerror = (error) => {
console.error('WebSocket错误:', error);
this.connected = false;
};
// 连接关闭
this.ws.onclose = () => {
console.log('WebSocket连接关闭');
this.connected = false;
// 可以在这里实现重连逻辑
this.reconnect();
};
// 定时发送心跳
setInterval(() => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.sendMessage({ type: 'heartbeat' });
}
}, 30000);
},
// 处理消息
handleMessage(message) {
switch(message.type) {
case 'connected':
this.$message.success('连接成功');
break;
case 'cart_update':
case 'add_item_success':
case 'update_quantity_success':
case 'remove_item_success':
case 'clear_cart_success':
this.cart = message.data || this.cart;
break;
case 'error':
this.$message.error(message.message);
break;
}
},
// 发送消息
sendMessage(message) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
}
},
// 添加商品
addItem(cuisineId, quantity, remark) {
this.sendMessage({
type: 'add_item',
data: {
cuisineId: cuisineId,
quantity: quantity,
remark: remark
}
});
},
// 更新数量
updateQuantity(cuisineId, quantity) {
if (quantity <= 0) {
this.removeItem(cuisineId);
return;
}
this.sendMessage({
type: 'update_quantity',
data: {
cuisineId: cuisineId,
quantity: quantity
}
});
},
// 删除商品
removeItem(cuisineId) {
this.sendMessage({
type: 'remove_item',
data: {
cuisineId: cuisineId
}
});
},
// 清空购物车
clearCart() {
this.$confirm('确定要清空购物车吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
this.sendMessage({
type: 'clear_cart'
});
});
},
// 关闭连接
closeWebSocket() {
if (this.ws) {
this.ws.close();
this.ws = null;
}
},
// 重连
reconnect() {
setTimeout(() => {
this.initWebSocket();
}, 3000);
}
}
}
</script>
{
"type": "add_item",
"data": {
"cuisineId": 1001,
"quantity": 2,
"remark": "不要花生"
}
}
{
"type": "update_quantity",
"data": {
"cuisineId": 1001,
"quantity": 3
}
}
{
"type": "remove_item",
"data": {
"cuisineId": 1001
}
}
{
"type": "clear_cart"
}
{
"type": "connected",
"message": "连接成功",
"data": null,
"timestamp": 1704067200000
}
{
"type": "cart_update",
"message": "购物车更新",
"data": {
"tableId": 123,
"tableNumber": "A01",
"storeId": 1,
"items": [
{
"cuisineId": 1001,
"cuisineName": "宫保鸡丁",
"cuisineType": 1,
"cuisineImage": "https://example.com/image.jpg",
"unitPrice": 38.00,
"quantity": 2,
"subtotalAmount": 76.00,
"addUserId": 100,
"addUserPhone": "13800138000",
"remark": "不要花生"
}
],
"totalAmount": 76.00,
"totalQuantity": 2
},
"timestamp": 1704067200000
}
本系统同时支持SSE和WebSocket两种方式:
两种方式可以同时使用,互不冲突。
WebSocket提供了双向实时通信能力,特别适合需要前端主动发送消息的场景。通过本文档,您可以:
如有问题,请参考代码实现或联系开发团队。