【苍穹外卖 day10|Spring Task、WebSocket 与来单提醒、催单铃声实现】

前言

订单系统里有两类事情很容易被忽略:一类是到了时间必须处理的超时订单,另一类是商家页面需要立刻收到的来单和催单消息。前者适合交给 Spring Task,后者适合通过 WebSocket 推送到管理端。

这一篇结合苍穹外卖的订单代码,整理 Spring Task 的定时处理、WebSocket 的连接与群发,以及管理端根据消息类型播放来单/催单铃声的实现。

一、Spring Task:让订单状态按时间自动推进

Spring Task 是 Spring 提供的任务调度能力。给方法加上 @Scheduled,并在应用中开启调度后,Spring 会按设定的时间规则调用该方法。

当前 Spring 文档中,周期任务可以使用 cron、fixedRate 或 fixedDelay 三种方式之一;被调度的方法不能声明参数,通常返回 void。项目中处理订单超时的场景需要按日历时间触发,使用 cron 更直观。

1. 开启任务调度

项目启动类已经开启了定时任务能力:

java 复制代码
@SpringBootApplication
@EnableTransactionManagement
@EnableCaching
@EnableScheduling
public class SkyApplication {
    public static void main(String[] args) {
        SpringApplication.run(SkyApplication.class, args);
    }
}

@EnableScheduling 会注册对 @Scheduled 的处理。没有这项配置,即使任务类和 cron 表达式都写好了,方法也不会被调度执行。

2. 看懂 cron 表达式

Spring Task 常用 cron 由秒、分、时、日、月、周六个字段组成,字段之间以空格分隔。项目里的两个表达式如下:

表达式 含义 使用位置
0 * * * * ? 每分钟的第 0 秒执行一次 取消超时未支付订单
0 1 * * * ? 每天凌晨 1:00 执行一次 处理长时间派送中的订单

常见通配符可以这样记:* 表示任意值;? 表示该字段不指定值;- 表示范围;, 表示多个值;/ 表示步长。例如 0/5 放在秒字段,表示从第 0 秒开始每 5 秒触发一次。

日和周两个字段通常不要同时写具体值。当前代码中的 ? 就是为了避免两个字段同时限定。

3. 超时订单的定时处理

订单任务类被 Spring 扫描为组件后,定时器会调用其中的方法:

java 复制代码
@Component
@Slf4j
public class OrderTask {

    @Autowired
    private OrderMapper orderMapper;

    @Scheduled(cron = "0 * * * * ?")
    public void processOutOrder() {
        LocalDateTime time = LocalDateTime.now().plusMinutes(-15);
        List<Orders> orders = orderMapper.getBystatusAndOrderTimeLT(
                Orders.PENDING_PAYMENT, time);

        if (orders != null && !orders.isEmpty()) {
            for (Orders order : orders) {
                order.setStatus(Orders.CANCELLED);
                order.setCancelReason("订单超时,自动取消");
                order.setCancelTime(LocalDateTime.now());
                orderMapper.update(order);
            }
        }
    }
}

这里的查询条件由两个部分组成:订单状态必须是待支付,订单创建时间必须早于当前时间减 15 分钟。查到后再更新状态、取消原因和取消时间。这样即使用户关闭了页面,订单数据也能被后台任务兜底处理。

另一段 processOutOrder3 使用每天凌晨一次的规则,查询派送中且超过 60 分钟的订单并取消。具体的状态是否应取消、还是应完成,需要以产品规则为准;定时任务只负责在设定时间执行既定业务规则。

4. 定时任务怎么验证

开发阶段可以暂时把 cron 改成每 5 秒执行一次,或者构造一条符合查询条件的测试订单。观察日志、订单状态和取消时间是否一同发生变化即可。验证完成后要恢复正式 cron,避免高频扫描订单表。

如果任务没有执行,优先检查三处:启动类是否存在 @EnableScheduling、任务类是否被 Spring 扫描、cron 是否有 6 个字段且合法。

二、WebSocket:保持连接,让服务端主动发消息

HTTP 很适合"客户端发请求,服务端返回响应"的业务,例如提交订单、查询订单详情。但商家正在打开管理端页面时,服务端不能等到下一次 HTTP 请求才告诉它有新订单。

WebSocket 建立握手后会保留一条连接,客户端和服务端都可以主动发送消息。因此它适合来单提醒、催单提醒、聊天和实时状态变化这类场景。它同样基于 TCP,但需要处理断线、重连和在线连接管理,不能替代普通的 HTTP 接口。

1. 注册服务端端点

项目已经引入 spring-boot-starter-websocket,并通过配置类注册 ServerEndpointExporter:

java 复制代码
@Configuration
public class WebSocketConfiguration {

    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }
}

该 Bean 会注册使用 @ServerEndpoint 标记的端点。当前项目的连接地址为 /ws/{sid},sid 用于区分浏览器会话。

2. 管理在线会话并群发

java 复制代码
@Component
@ServerEndpoint("/ws/{sid}")
@Slf4j
public class WebSocketServer {

    private static Map<String, Session> sessionMap = new ConcurrentHashMap<>();

    @OnOpen
    public void onOpen(Session session, @PathParam("sid") String sid) {
        sessionMap.put(sid, session);
    }

    @OnClose
    public void onClose(@PathParam("sid") String sid) {
        sessionMap.remove(sid);
    }

    public void sendToAllClient(String message) {
        for (Session session : sessionMap.values()) {
            try {
                session.getBasicRemote().sendText(message);
            } catch (Exception e) {
                log.error("WebSocket发送消息失败: {}", e.getMessage());
            }
        }
    }
}

onOpen 在浏览器连接成功时保存 Session,onClose 在连接断开时删除 Session。sessionMap 使用 ConcurrentHashMap,适合多个连接同时建立、关闭或推送的场景。sendToAllClient 遍历在线 Session 后调用 sendText,把同一条文本消息推给当前在线的管理端。

这里采用的是群发策略,适合单门店、单后台账号或所有管理端都需要获知订单的练习场景。多门店或多角色系统应按门店 ID、员工 ID 等维度维护会话集合,避免无关人员收到消息。

三、支付成功后的来单提醒

用户提交订单后,真正应提醒商家的时机是支付成功、订单进入待接单状态。项目在 paySuccess 中完成状态更新后,构造了一条 type=1 的 WebSocket 消息:

java 复制代码
public void paySuccess(String outTradeNo) {
    Orders ordersDB = orderMapper.getByNumber(outTradeNo);
    if (ordersDB == null) {
        throw new OrderBusinessException(MessageConstant.ORDER_NOT_FOUND);
    }

    Orders orders = Orders.builder()
            .id(ordersDB.getId())
            .status(Orders.TO_BE_CONFIRMED)
            .payStatus(Orders.PAID)
            .checkoutTime(LocalDateTime.now())
            .build();
    orderMapper.update(orders);

    Map<String, Object> websocketMap = new HashMap<>();
    websocketMap.put("type", 1);
    websocketMap.put("orderId", ordersDB.getId());
    websocketMap.put("content", "订单号" + outTradeNo);
    webSocketServer.sendToAllClient(JSON.toJSONString(websocketMap));
}

消息字段的约定很简单:type=1 表示来单提醒,orderId 方便管理端跳转订单详情,content 用于弹窗或通知栏展示订单号。先更新订单状态,再推送消息,能避免管理端收到提醒后查询到旧状态。

四、用户催单:用 type=2 区分提醒类别

用户端催单接口为 GET /user/order/reminder/{id}。项目先根据订单 ID 查询订单,再组装 type=2 的消息并通过同一个 WebSocket 服务群发:

java 复制代码
@GetMapping("/reminder/{id}")
public Result reminder(@PathVariable Long id) {
    Orders order = orderMapper.getById(id);
    if (orderService.orderDetail(id) == null) {
        throw new OrderBusinessException(MessageConstant.ORDER_STATUS_ERROR);
    }

    Map<String, Object> map = new HashMap<>();
    map.put("type", 2);
    map.put("orderId", id);
    map.put("content", "订单号" + order.getNumber());
    webSocketServer.sendToAllClient(JSON.toJSONString(map));
    return Result.success();
}

来单和催单复用一条长连接,前端只需要根据 type 决定提示文案和铃声。这样比为每一种提醒再建一个 WebSocket 地址更容易维护。

这段代码还应注意业务校验:当前实现通过 orderDetail 判断订单是否存在。正式项目建议同时校验订单归属和可催单状态,避免用户对不属于自己的订单或已完成订单发起提醒。

五、管理端接收消息并播放不同铃声

当前仓库只包含后端模块,下面这段是管理端页面可以直接接入的客户端代码。将 order.mp3 和 reminder.mp3 放到管理端静态资源目录,并按实际构建工具调整资源地址。

javascript 复制代码
const orderAudio = new Audio('/audio/order.mp3');
const reminderAudio = new Audio('/audio/reminder.mp3');
let socket;

function connectWebSocket() {
  const clientId = crypto.randomUUID();
  socket = new WebSocket(`ws://localhost:8080/ws/${clientId}`);

  socket.onopen = () => {
    console.log('WebSocket 已连接');
  };

  socket.onmessage = (event) => {
    const message = JSON.parse(event.data);

    if (message.type === 1) {
      orderAudio.currentTime = 0;
      orderAudio.play().catch(() => {
        console.warn('浏览器尚未允许自动播放,请先点击页面一次');
      });
      showNotice(`您有新的订单:${message.content}`);
      return;
    }

    if (message.type === 2) {
      reminderAudio.currentTime = 0;
      reminderAudio.play().catch(() => {
        console.warn('浏览器尚未允许自动播放,请先点击页面一次');
      });
      showNotice(`用户正在催单:${message.content}`);
    }
  };

  socket.onclose = () => {
    setTimeout(connectWebSocket, 3000);
  };
}

connectWebSocket();

function showNotice(text) {
  // 替换为管理端实际的 Message、Notification 或弹窗组件
  console.log(text);
}

message.type 是服务端约定的消息类型;type=1 播放来单铃声,type=2 播放催单铃声。每次播放前把 currentTime 设为 0,连续来单时铃声会从头开始。

浏览器通常会拦截没有用户交互时的音频自动播放。因此管理端登录成功或首次进入订单页后,应让用户主动点击一次"开启提醒"按钮;在点击事件中调用一次 orderAudio.play() 后马上 pause() 并归零,浏览器便会把后续音频播放视为已授权。生产环境还应将 ws:// 根据 HTTPS 部署切换为 wss://,否则浏览器会拦截混合内容。

六、从下单到铃声响起的调用链

整个来单提醒链路可以按下面顺序排查:

text 复制代码
用户支付成功
  -> OrderServiceImpl.paySuccess 更新订单为待接单、已支付
  -> 组装 type=1 的 JSON 消息
  -> WebSocketServer.sendToAllClient 群发
  -> 管理端 socket.onmessage 接收
  -> 播放 order.mp3 并展示来单通知

用户点击催单
  -> GET /user/order/reminder/{id}
  -> 组装 type=2 的 JSON 消息
  -> WebSocketServer.sendToAllClient 群发
  -> 管理端播放 reminder.mp3 并展示催单通知

排查时先打开管理端浏览器控制台,确认 WebSocket 是否连接成功;再确认服务端日志中的在线客户端数是否大于 0;最后检查推送的 JSON 是否包含正确的 type 和 orderId。若消息已到达但没有声音,优先检查音频文件地址和浏览器自动播放权限。

总结

这一部分把订单的两种后台能力接起来了:Spring Task 负责按时间扫描并修正订单状态,WebSocket 负责把支付成功和用户催单实时送到管理端。通过 type=1 与 type=2 区分消息类型,管理端就能播放不同铃声,并给出对应的订单提示。

相关推荐
倒流时光三十年7 小时前
Logback 系列(7):常用 Appender(控制台 / 文件 / 滚动文件)
java·前端·logback
极客先躯8 小时前
高级java每日一道面试题-2026年04月18日-实战篇[Docker]-如何处理金融行业的时序数据容器化?
java·运维·docker·容器·金融·时序数据·高级面试
hdsoft_huge8 小时前
SpringBoot系列18:SpringSecurity登录鉴权,JWT无状态Token认证完整流程(生产级落地)
java·spring boot·后端
野蛮人6号8 小时前
黑马天机学堂系列问题之选集8本地服务报错
java·spring cloud·黑马程序员·天机学堂·黑马天机学堂
L-影8 小时前
单体项目结构
java·开发语言
wuqingshun3141598 小时前
MySQL 如何解决深度分页问题?
java·mysql·面试
buhuizhiyuci8 小时前
【算法篇】位运算 —— 基础篇
java·数据库·算法
laboratory agent开发9 小时前
企业AI Agent落地前,先回答四个工程问题
java·前端·人工智能
微三云 - 廖会灵 (私域系统开发)9 小时前
电商系统国际化架构设计:多语言、多币种、多时区、多税制的全链路实现
java·前端·数据库
XS0301069 小时前
Spring AI 第二课-流式输出 & 运行时动态参数配置
java·人工智能·spring