Spring Boot WebSocket 来单提醒怎么给外网验收?用 cpolar 临时公网地址跑通手机测试 Spring Boot WebSocket 来单提醒怎么给外网验收用 cpolar 临时公网地址跑通手机测试来单提醒这类功能最怕只在开发电脑上“看起来正常”。电脑浏览器能收到消息不代表手机能响本地 localhost 能连上也不代表外网同事能验收页面开着有声音更不代表移动端锁屏、切后台、断线重连都符合预期。这篇不写 WebSocket 泛教程只做一件具体的事搭一个 Spring Boot 来单提醒 Demo本地跑在8080端口用 cpolar 临时 HTTPS 公网地址给手机和外网同事打开测试页验收消息推送、断线重连、移动端声音策略。边界先说清楚下面只用测试订单和测试账号不接生产消息通道不暴露后台管理端、数据库和任何真实订单接口。验收结束后关闭 cpolar。正式上线要走正式域名、鉴权、网关策略和完整的日志审计。1 这次验收到底要验证什么很多团队做来单提醒时开发阶段会把重点放在“后端有没有推消息”。这当然重要但验收现场真正容易翻车的点通常在外网和移动端。这次 Demo 只围绕 4 个结果验收手机通过外网 HTTPS 地址打开测试页后端推送测试订单后页面立刻出现新订单提醒WebSocket 断开后能自动重连不需要用户刷新页面移动端声音必须先点击“开启声音”再触发铃声提醒为什么要强调声音策略因为移动端浏览器不允许网页在用户没有交互时自动播放声音。开发电脑上随便一响到了手机上经常没声音。正确做法不是绕过限制而是在页面上给一个明确按钮让验收人员先点击“开启声音”。这一步不是为了做一个漂亮 Demo而是把验收口径提前固定下来。只要测试页能稳定跑通后面接真实订单消息时排查范围就会小很多。2 环境准备本地只开一个 8080 测试服务这次用 Spring Boot 3 Java 17 做 Demo。你本机需要准备JDK 17Maven 3.8cpolar 账号一台手机建议关闭 Wi-Fi 后用移动网络访问一次本地服务统一监听8080测试页路径是/WebSocket 地址是/ws/orders。端口不要一会儿写8080一会儿写3000后面 cpolar 也会直接映射这个端口。如果你已经有自己的 Spring Boot 项目可以只参考 WebSocket 和前端测试页部分。为了让文章里的步骤能直接复现下面从一个最小 Demo 项目开始。3 创建 Spring Boot WebSocket 来单提醒 Demo先建目录mkdir order-ws-demo cd order-ws-demo mkdir -p src/main/java/com/example/orderws src/main/resources/static写pom.xmlproject xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.3.2/version relativePath/ /parent groupIdcom.example/groupId artifactIdorder-ws-demo/artifactId version0.0.1-SNAPSHOT/version properties java.version17/java.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency /dependencies /project这里没有接数据库也没有接真实订单系统。验收阶段先把链路跑通后面再把“测试订单生成器”替换成业务消息来源。3.1 写启动类和 WebSocket 配置新建OrderWsDemoApplication.javapackage com.example.orderws; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.scheduling.annotation.EnableScheduling; EnableScheduling SpringBootApplication public class OrderWsDemoApplication { public static void main(String[] args) { SpringApplication.run(OrderWsDemoApplication.class, args); } }新建WebSocketConfig.javapackage com.example.orderws; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.config.annotation.WebSocketConfigurer; import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; Configuration EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { private final OrderNotifyHandler orderNotifyHandler; public WebSocketConfig(OrderNotifyHandler orderNotifyHandler) { this.orderNotifyHandler orderNotifyHandler; } Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(orderNotifyHandler, /ws/orders) .setAllowedOrigins(*); } }这里为了临时验收使用setAllowedOrigins(*)方便手机和外网同事通过 cpolar 地址访问。正式环境不要这么放开要按正式域名配置允许来源并在网关层补上鉴权和访问控制。3.2 写订单推送 Handler新建OrderNotifyHandler.javapackage com.example.orderws; import org.springframework.stereotype.Component; import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.handler.TextWebSocketHandler; import java.io.IOException; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; Component public class OrderNotifyHandler extends TextWebSocketHandler { private final SetWebSocketSession sessions ConcurrentHashMap.newKeySet(); private static final DateTimeFormatter FORMATTER DateTimeFormatter.ofPattern(HH:mm:ss); Override public void afterConnectionEstablished(WebSocketSession session) { sessions.add(session); } Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { sessions.remove(session); } public void broadcastTestOrder(int orderNo) { String now LocalDateTime.now().format(FORMATTER); String json {\type\:\NEW_ORDER\,\orderNo\:\TEST- orderNo \,\tableNo\:\A08\,\amount\:\58.00\,\time\:\ now \}; for (WebSocketSession session : sessions) { if (!session.isOpen()) { sessions.remove(session); continue; } try { session.sendMessage(new TextMessage(json)); } catch (IOException e) { sessions.remove(session); } } } public int onlineCount() { return sessions.size(); } }这个 Handler 只保留在线连接并广播测试订单。提醒一下Demo 里直接拼 JSON 是为了少引入代码业务项目里建议用对象序列化别手写长 JSON 字符串。3.3 用定时任务模拟来单新建TestOrderScheduler.javapackage com.example.orderws; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import java.util.concurrent.atomic.AtomicInteger; Component public class TestOrderScheduler { private final OrderNotifyHandler orderNotifyHandler; private final AtomicInteger counter new AtomicInteger(1000); public TestOrderScheduler(OrderNotifyHandler orderNotifyHandler) { this.orderNotifyHandler orderNotifyHandler; } Scheduled(fixedRate 8000) public void createTestOrder() { if (orderNotifyHandler.onlineCount() 0) { return; } orderNotifyHandler.broadcastTestOrder(counter.incrementAndGet()); } }这里每 8 秒推送一条测试订单。有同事在旁边验收时这个频率比较舒服不会等太久也不会刷屏。要测催单铃声可以把时间改短验收结束记得改回去别把测试页弄成噪音制造机。4 做一个手机能用的测试页测试页重点不是好看而是把状态讲清楚当前连接状态、收到的订单、声音是否已开启、断线后是否在重连。新建src/main/resources/static/index.html!doctype html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1 / title来单提醒测试页/title style body { font-family: -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif; margin: 0; background: #f6f7fb; color: #1f2937; } .wrap { max-width: 720px; margin: 0 auto; padding: 18px; } .card { background: #fff; border-radius: 16px; padding: 18px; box-shadow: 0 8px 24px rgba(15, 23, 42, .08); margin-bottom: 14px; } .status { display: inline-block; padding: 6px 10px; border-radius: 999px; font-size: 14px; background: #fee2e2; color: #991b1b; } .status.ok { background: #dcfce7; color: #166534; } button { width: 100%; border: 0; border-radius: 12px; padding: 14px 16px; font-size: 16px; background: #2563eb; color: #fff; } .order { border-left: 4px solid #2563eb; padding: 10px 12px; background: #eff6ff; border-radius: 10px; margin-top: 10px; } .tip { color: #6b7280; font-size: 14px; line-height: 1.7; } /style /head body div classwrap div classcard h2来单提醒验收页/h2 p连接状态span idstatus classstatus未连接/span/p p classtip手机端先点“开启声音”再等待测试订单推送。断网或切换网络后页面会自动重连。/p button idsoundBtn开启声音/button /div div classcard h3收到的订单/h3 div idorders/div /div /div script const statusEl document.getElementById(status); const ordersEl document.getElementById(orders); const soundBtn document.getElementById(soundBtn); let ws; let reconnectTimer; let audioCtx; let soundReady false; soundBtn.onclick async () { audioCtx new (window.AudioContext || window.webkitAudioContext)(); await audioCtx.resume(); soundReady true; soundBtn.textContent 声音已开启等待来单; beep(); }; function beep() { if (!soundReady || !audioCtx) return; const oscillator audioCtx.createOscillator(); const gain audioCtx.createGain(); oscillator.type sine; oscillator.frequency.value 880; gain.gain.setValueAtTime(0.001, audioCtx.currentTime); gain.gain.exponentialRampToValueAtTime(0.4, audioCtx.currentTime 0.02); gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime 0.45); oscillator.connect(gain); gain.connect(audioCtx.destination); oscillator.start(); oscillator.stop(audioCtx.currentTime 0.5); } function connect() { const protocol location.protocol https: ? wss: : ws:; ws new WebSocket(${protocol}//${location.host}/ws/orders); ws.onopen () { statusEl.textContent 已连接; statusEl.classList.add(ok); }; ws.onmessage (event) { const order JSON.parse(event.data); const div document.createElement(div); div.className order; div.innerHTML b${order.orderNo}/b 桌号${order.tableNo}br金额¥${order.amount}时间${order.time}; ordersEl.prepend(div); beep(); }; ws.onclose () { statusEl.textContent 已断开重连中; statusEl.classList.remove(ok); clearTimeout(reconnectTimer); reconnectTimer setTimeout(connect, 2000); }; ws.onerror () { ws.close(); }; } connect(); /script /body /html划重点WebSocket 地址不要写死成ws://localhost:8080/ws/orders。页面从 cpolar 的 HTTPS 地址打开时浏览器会使用wss://公网域名/ws/orders连接页面在本地http://127.0.0.1:8080打开时会使用ws://127.0.0.1:8080/ws/orders连接。这也是很多本地 Demo 外网验收失败的坑页面能打开但 WebSocket 还在连 localhost手机当然连不上开发电脑里的本地端口。5 本地先跑通再交给外网验收启动服务mvn spring-boot:run浏览器打开http://127.0.0.1:8080看到页面后先点一次“开启声音”。等 8 秒左右页面会出现TEST-1001这类测试订单并响一声短提示音。如果本地没出现订单先查 3 件事控制台有没有 Spring Boot 启动成功日志浏览器访问的端口是不是8080页面连接状态是不是“已连接”本地没跑通之前不要急着开 cpolar。外网地址只是入口不能替你修业务代码。6 用 cpolar 临时 HTTPS 地址给手机访问本地8080正常后再开 cpolar HTTP 隧道。Linux 可以按官方一键脚本安装curl -L https://www.cpolar.com/static/downloads/install-release-cpolar.sh | sudo bashmacOS 可以用 Homebrew 安装brew tap probezy/core brew install cpolar sudo cpolar service install sudo cpolar service start安装后确认本地管理端能打开curl -s http://127.0.0.1:9200 || echo cpolar 服务未启动有图形界面的机器可以打开http://127.0.0.1:9200登录 cpolar。纯命令行环境可以使用后台提供的 tokencpolar authtoken 你的authtoken账号绑定好以后在项目运行状态下执行cpolar http 8080终端会输出公网访问地址选择https://开头的地址复制出来发给手机或外网同事。免费随机地址适合这种临时验收24 小时内会变化不适合作为长期入口。手机打开这个 HTTPS 地址后按下面顺序验收页面能打开连接状态显示“已连接”点“开启声音”听到一次短提示音等待测试订单出现确认每条新订单都有提醒关闭手机网络再打开页面显示重连并恢复收消息把地址发给外网同事让对方用同样步骤测一遍如果页面能打开但一直显示“未连接”或“重连中”优先检查浏览器控制台里的 WebSocket 地址是不是wss://你的cpolar域名/ws/orders。如果它还在连localhost说明前端地址写死了。如果手机没声音先确认验收人员已经点过“开启声音”。移动端浏览器的声音策略就是这样不能指望页面一打开就自动响。7 验收断线重连和催单铃声来单提醒真正上线后网络抖动是常态。前端测试页里已经做了最简单的断线重连连接关闭后 2 秒重新连接。验收时可以直接做两个动作手机切一次飞行模式再关闭飞行模式从 Wi-Fi 切到移动网络观察连接状态是否恢复恢复后不用刷新页面继续收到下一条测试订单就说明重连链路可用。这里别只看“页面没报错”要等新订单真的进来。催单铃声可以复用同一个 WebSocket 通道只是消息类型换成ORDER_URGE。Demo 里为了控制篇幅只发NEW_ORDER业务项目可以把消息结构扩展成这样{ type: ORDER_URGE, orderNo: TEST-1008, tableNo: A08, time: 14:30:05 }前端收到ORDER_URGE后可以播放更急促的声音或者在页面上给催单消息加红色样式。提醒一句验收声音时不要只测电脑浏览器移动端一定要单独测因为声音权限策略不一样。8 安全边界临时公网入口不要越界cpolar 在这篇里的角色很明确给本地8080测试服务一个临时 HTTPS 入口方便外网验收 WebSocket 来单提醒。不要顺手把下面这些东西也暴露出去后台管理端数据库端口生产订单接口生产消息队列控制台带真实用户数据的测试环境验收账号也要单独准备订单号使用TEST-前缀金额、桌号、用户信息都用假数据。外网同事只拿测试页地址不拿服务器账号不拿数据库连接串。验收结束后直接停止 cpolar 前台命令。如果你是通过 Web UI 创建的隧道也要在 cpolar 管理页面停掉对应隧道。临时地址用完就关这是最省心的安全习惯。正式环境不要沿用这套临时链路。生产入口应该使用正式域名、HTTPS 证书、登录鉴权、接口签名、网关限流、来源校验和日志审计WebSocket 服务也要考虑心跳、会话绑定、消息确认和离线补偿。9 总结到这里我们已经把一个本地 Spring Boot WebSocket 来单提醒 Demo 跑起来了本机8080提供测试页后端每 8 秒生成测试订单手机通过 cpolar 临时 HTTPS 地址访问页面并完成来单提醒、声音开启、断线重连这几个关键验收点。这条链路里最关键的是三件事本地先确认http://127.0.0.1:8080能收到测试订单不把业务问题带到外网排查里前端 WebSocket 地址跟随当前页面协议和域名生成HTTPS 页面下使用wss://连接移动端声音必须先由用户点击开启验收时把这个动作写进测试步骤cpolar 适合做这种短时外网验收入口尤其是开发机还没部署到测试服务器、客户又想先看效果的时候。等功能进入正式环境再换成固定域名、鉴权和网关策略临时验收负责提速生产链路负责安全和稳定。