Browse Source

语音播报部分功能实现

徐滕 5 days atrás
parent
commit
fbd181a51a

BIN
forge-admin-ui/public/女生-订单提醒.mp3


+ 338 - 0
forge-admin-ui/src/composables/useOrderNotification.js

@@ -0,0 +1,338 @@
+import { onUnmounted, ref } from 'vue'
+import { useAuthStore } from '@/store'
+
+/**
+ * 酒店订单SSE通知 composable。
+ * 提供:SSE订阅、Web Audio 提示音、浏览器桌面通知、防抖合并。
+ *
+ * @param {object} options 回调选项
+ * @param {Function} options.onNewOrder     收到新订单通知时的回调
+ * @param {Function} options.onRefundRequest 收到退单申请通知时的回调
+ * @returns {object} { connected, connect, disconnect }
+ */
+export function useOrderNotification(options = {}) {
+  const connected = ref(false)
+  let eventSource = null
+  let clientId = null
+
+  // 将 eventSource 暴露到全局 window 对象,方便调试
+  if (typeof window !== 'undefined') {
+    window.__hotelSSEEventSource = () => eventSource
+  }
+
+  // ==================== 防抖合并队列 ====================
+  const newOrderQueue = ref([])
+  const refundQueue = ref([])
+  let mergeTimer = null
+  const MERGE_WINDOW = 3000 // 3秒防抖窗口
+
+  /**
+   * 触发防抖合并通知。
+   * 3秒内的多个同类型通知合并为一条提示。
+   */
+  function scheduleMergeFlush() {
+    if (mergeTimer !== null) {
+      clearTimeout(mergeTimer)
+    }
+    mergeTimer = setTimeout(() => {
+      mergeTimer = null
+      flushNotifications()
+    }, MERGE_WINDOW)
+  }
+
+  /**
+   * 刷新通知队列,合并显示。
+   */
+  function flushNotifications() {
+    if (newOrderQueue.value.length > 0) {
+      const count = newOrderQueue.value.length
+      const lastOrder = newOrderQueue.value[newOrderQueue.value.length - 1]
+      if (count === 1) {
+        showInPageNotification('new-order', `新订单:房间 ${lastOrder.roomNo}`, 'info')
+      }
+      else {
+        showInPageNotification('new-order', `您有 ${count} 个新订单待处理`, 'info')
+      }
+      newOrderQueue.value = []
+      // 触发业务回调(刷新数据)
+      if (typeof options.onNewOrder === 'function') {
+        options.onNewOrder(lastOrder)
+      }
+    }
+
+    if (refundQueue.value.length > 0) {
+      const count = refundQueue.value.length
+      const lastRefund = refundQueue.value[refundQueue.value.length - 1]
+      if (count === 1) {
+        showInPageNotification('refund', `退单申请:房间 ${lastRefund.roomNo}`, 'warning')
+      }
+      else {
+        showInPageNotification('refund', `您有 ${count} 个退单申请待处理`, 'warning')
+      }
+      refundQueue.value = []
+      if (typeof options.onRefundRequest === 'function') {
+        options.onRefundRequest(lastRefund)
+      }
+    }
+  }
+
+  // ==================== 声音提醒(使用 MP3 文件) ====================
+  
+  // MP3 语音文件路径(放在 public 目录)
+  const ORDER_ALERT_MP3 = '/女生-订单提醒.mp3'
+  
+  /**
+   * 播放提示音(使用 MP3 文件)。
+   * @param {string} type 类型:'new-order' 或 'refund'
+   */
+  function playSound(type) {
+    console.warn(`[SSE] 📢 开始播放声音: type=${type}`)
+    try {
+      if (type === 'new-order') {
+        // 新订单:播放 MP3 语音
+        console.warn('[SSE] 播放订单提醒 MP3')
+        playMp3Audio(ORDER_ALERT_MP3)
+      }
+      else if (type === 'refund') {
+        // 退单申请:也播放同样的 MP3(或者可以换成其他音效)
+        console.warn('[SSE] 播放退单提醒 MP3')
+        playMp3Audio(ORDER_ALERT_MP3)
+      }
+      console.warn('[SSE] ✅ playSound 函数执行完毕')
+    }
+    catch (err) {
+      console.error('[SSE] ❌ playSound 异常:', err)
+      void err
+    }
+  }
+  
+  /**
+   * 播放 MP3 音频文件。
+   * @param {string} mp3Path MP3 文件路径
+   */
+  function playMp3Audio(mp3Path) {
+    console.warn(`[SSE] 准备播放 MP3: ${mp3Path}`)
+    const audio = new Audio(mp3Path)
+    audio.volume = 0.8
+    console.warn(`[SSE] Audio 对象已创建, volume=${audio.volume}, duration=${audio.duration || 'unknown'}`)
+  
+    // 监听播放事件
+    audio.addEventListener('canplaythrough', () => {
+      console.warn('[SSE] 音频可以播放了')
+    })
+    audio.addEventListener('playing', () => {
+      console.warn('[SSE] 音频正在播放中!')
+    })
+    audio.addEventListener('ended', () => {
+      console.warn('[SSE] 音频播放结束')
+    })
+    audio.addEventListener('error', (e) => {
+      console.error('[SSE] 音频加载/播放错误:', e)
+    })
+  
+    const playPromise = audio.play()
+    if (playPromise !== undefined) {
+      playPromise.then(() => {
+        console.warn('[SSE] ✅ 音频播放成功!')
+      }).catch((err) => {
+        console.error('[SSE] ❌ 音频播放失败:', err)
+        console.error('[SSE] 错误详情 - name:', err.name, ', message:', err.message)
+      })
+    }
+  }
+
+  // ==================== 浏览器桌面通知 ====================
+
+  /**
+   * 请求桌面通知权限(首次调用时自动请求)。
+   */
+  function requestNotificationPermission() {
+    if ('Notification' in window && Notification.permission === 'default') {
+      Notification.requestPermission()
+    }
+  }
+
+  /**
+   * 显示桌面通知。
+   * @param {string} title 通知标题
+   * @param {string} body  通知内容
+   */
+  function showDesktopNotification(title, body) {
+    if ('Notification' in window && Notification.permission === 'granted') {
+      try {
+        const n = new Notification(title, {
+          body,
+          icon: '/favicon.ico',
+          tag: `hotel-order-${Date.now()}`,
+        })
+        setTimeout(() => n.close(), 8000)
+      }
+      catch (err) {
+        // 静默失败
+        void err
+      }
+    }
+  }
+
+  // ==================== 页面内通知(通过事件派发,由 order.vue 监听显示) ====================
+  // 使用自定义事件,避免 composable 直接依赖 Naive UI
+
+  /**
+   * 显示页面内通知(派发 DOM 事件,由 order.vue 监听)。
+   */
+  function showInPageNotification(type, message, level) {
+    window.dispatchEvent(new CustomEvent('hotel-notification', {
+      detail: { type, message, level },
+    }))
+  }
+
+  // ==================== SSE 连接管理 ====================
+
+  /**
+   * 建立SSE连接。
+   */
+  function connect() {
+    console.warn('[SSE] ========== connect() 函数被调用 ==========')
+    console.warn('[SSE] 当前时间:', new Date().toLocaleString())
+    console.warn('[SSE] eventSource 是否存在:', !!eventSource)
+    
+    if (eventSource) {
+      console.warn('[SSE] ⚠️ eventSource 已存在,跳过连接')
+      return
+    }
+
+    // 生成客户端ID(UUID)
+    clientId = `pc-${Math.random().toString(36).substring(2, 10)}-${Date.now()}`
+    console.warn('[SSE] 生成 clientId:', clientId)
+
+    // 获取登录token(Sa-Token isReadBody=true 会从请求参数中读取)
+    const authStore = useAuthStore()
+    const token = authStore.accessToken || ''
+    console.warn('[SSE] 获取 token:', token ? '有token' : '无token')
+
+    // 构建SSE URL(使用与 Axios 相同的请求前缀,token通过查询参数传递)
+    const prefix = import.meta.env.VITE_REQUEST_PREFIX || ''
+    const url = `${prefix}/hotel/notification/subscribe?clientId=${encodeURIComponent(clientId)}&Authorization=${encodeURIComponent(token)}`
+    console.warn('[SSE] SSE URL:', url)
+
+    try {
+      eventSource = new EventSource(url)
+      console.warn('[SSE] ✅ EventSource 创建成功')
+    } catch (err) {
+      console.error('[SSE] ❌ EventSource 创建失败:', err)
+      return
+    }
+
+    // 连接成功
+    eventSource.addEventListener('connected', () => {
+      connected.value = true
+      console.warn('[SSE] ✅ 通知连接已建立')
+    })
+
+    // 监听 open 事件(表示连接成功打开)
+    eventSource.onopen = () => {
+      console.warn('[SSE] 🟢 EventSource 连接已打开')
+    }
+
+    // 新订单通知
+    eventSource.addEventListener('NEW_ORDER', (event) => {
+      console.warn('[SSE] 🔔 收到 NEW_ORDER 事件')
+      console.warn('[SSE] 原始数据:', event.data)
+      try {
+        const data = JSON.parse(event.data)
+        console.warn('[SSE] 解析数据:', data)
+        const orderData = data.data || {}
+        console.warn('[SSE] 订单数据:', orderData)
+        // 播放声音
+        console.warn('[SSE] 调用 playSound("new-order")')
+        playSound('new-order')
+        // 桌面通知
+        showDesktopNotification('新订单提醒', `房间 ${orderData.roomNo} 有新订单,金额 ¥${orderData.totalAmount}`)
+        // 加入防抖队列
+        newOrderQueue.value.push(orderData)
+        scheduleMergeFlush()
+        console.warn('[SSE] ✅ NEW_ORDER 处理完成')
+      }
+      catch (e) {
+        console.error('[SSE]  解析NEW_ORDER消息失败:', e)
+        console.error('[SSE] 错误详情:', e.message, e.stack)
+      }
+    })
+
+    // 退单申请通知
+    eventSource.addEventListener('REFUND_REQUEST', (event) => {
+      try {
+        const data = JSON.parse(event.data)
+        const orderData = data.data || {}
+        // 播放声音
+        playSound('refund')
+        // 桌面通知
+        showDesktopNotification('退单申请', `房间 ${orderData.roomNo} 申请退单`)
+        // 加入防抖队列
+        refundQueue.value.push(orderData)
+        scheduleMergeFlush()
+      }
+      catch (e) {
+        console.warn('[SSE] 解析REFUND_REQUEST消息失败:', e)
+      }
+    })
+
+    // 连接错误(自动重连由 EventSource 内置处理)
+    eventSource.onerror = () => {
+      connected.value = false
+      console.warn('[SSE] 连接断开,等待自动重连...')
+    }
+
+    // 请求桌面通知权限
+    requestNotificationPermission()
+    
+    // 预加载音频:用户首次点击页面时播放一次 MP3,
+    // 绕过 Chrome 自动播放策略,确保后续 SSE 推送时能正常发声
+    function initAudioOnFirstInteraction() {
+      console.warn('[SSE] 用户交互触发,开始预加载音频...')
+      const audio = new Audio(ORDER_ALERT_MP3)
+      audio.volume = 0
+      const playPromise = audio.play()
+      if (playPromise !== undefined) {
+        playPromise.then(() => {
+          console.warn('[SSE] ✅ 音频已预加载(用户交互触发)')
+        }).catch((err) => {
+          console.error('[SSE] ❌ 音频预加载失败:', err)
+          console.error('[SSE] 错误详情 - name:', err.name, ', message:', err.message)
+        })
+      }
+      document.removeEventListener('click', initAudioOnFirstInteraction)
+      document.removeEventListener('keydown', initAudioOnFirstInteraction)
+    }
+    document.addEventListener('click', initAudioOnFirstInteraction)
+    document.addEventListener('keydown', initAudioOnFirstInteraction)
+  }
+
+  /**
+   * 断开SSE连接。
+   */
+  function disconnect() {
+    if (mergeTimer !== null) {
+      clearTimeout(mergeTimer)
+      mergeTimer = null
+    }
+    if (eventSource) {
+      eventSource.close()
+      eventSource = null
+      connected.value = false
+      console.warn('[SSE] 通知连接已断开')
+    }
+  }
+
+  // 组件卸载时自动断开
+  onUnmounted(() => {
+    disconnect()
+  })
+
+  return {
+    connected,
+    connect,
+    disconnect,
+    playSound, // 暴露 playSound 方法,供外部调用
+  }
+}

+ 101 - 5
forge-admin-ui/src/views/hotel/order.vue

@@ -21,6 +21,9 @@
             <span class="dashboard-value warning">{{ dashboard.pendingCount }}</span>
             <span class="dashboard-label">待接单</span>
           </div>
+          <div v-if="connected" class="sse-indicator" title="实时通知已连接">
+            <span class="sse-dot" />
+          </div>
           <div class="dashboard-item">
             <span class="dashboard-value primary">{{ dashboard.preparingCount }}</span>
             <span class="dashboard-label">备餐中</span>
@@ -183,7 +186,7 @@
 
 <script setup>
 import { NDataTable, NInput, NModal, NSpin, useDialog, useMessage } from 'naive-ui'
-import { computed, h, onMounted, ref } from 'vue'
+import { computed, h, onMounted, onUnmounted, ref } from 'vue'
 import {
   acceptOrder,
   approveRefund,
@@ -191,6 +194,7 @@ import {
   deliverOrder,
   getOrderDashboard,
   getOrderDetail,
+  getOrderPage,
   hotelRefund,
   prepareOrder,
   readyOrder,
@@ -200,6 +204,7 @@ import {
 import { AiCrudPage } from '@/components/ai-form'
 import DictTag from '@/components/DictTag.vue'
 import { useDict } from '@/composables/useDict'
+import { useOrderNotification } from '@/composables/useOrderNotification'
 
 defineOptions({ name: 'HotelOrder' })
 
@@ -209,6 +214,42 @@ const { dict } = useDict('hotel_order_status', 'hotel_pay_method')
 
 const crudRef = ref(null)
 
+// ==================== SSE 实时通知 ====================
+const { connected, connect, disconnect, playSound } = useOrderNotification({
+  onNewOrder: () => {
+    crudRef.value?.refresh()
+    loadDashboard()
+  },
+  onRefundRequest: () => {
+    crudRef.value?.refresh()
+    loadDashboard()
+  },
+})
+
+/** 监听页面内通知事件(由 useOrderNotification 派发) */
+function handleHotelNotification(e) {
+  const { type, message: msg, level } = e.detail
+  const nType = level === 'warning' ? 'warning' : 'info'
+  $notification[nType]({
+    title: type === 'new-order' ? '新订单' : '退单申请',
+    content: msg,
+    duration: 5000,
+  })
+}
+
+onMounted(() => {
+  loadDashboard()
+  connect()
+  // 页面加载时,检查是否有待接单订单,如果有则触发语音通知
+  checkPendingOrders()
+  window.addEventListener('hotel-notification', handleHotelNotification)
+})
+
+onUnmounted(() => {
+  disconnect()
+  window.removeEventListener('hotel-notification', handleHotelNotification)
+})
+
 // ==================== 看板统计 ====================
 const dashboard = ref({
   pendingCount: 0,
@@ -225,14 +266,43 @@ async function loadDashboard() {
       dashboard.value = res.data
     }
   }
-  catch {
+  catch (err) {
     // ignore
+    void err
   }
 }
 
-onMounted(() => {
-  loadDashboard()
-})
+/**
+ * 页面加载时检查待接单订单,如果有则触发语音通知。
+ * 这样刷新页面就能听到声音提醒了。
+ */
+async function checkPendingOrders() {
+  try {
+    // 查询待接单订单(status=1)
+    const res = await getOrderPage({
+      pageNum: 1,
+      pageSize: 1,
+      status: 1, // 待接单
+    })
+
+    if (res.code === 200 && res.data?.total > 0) {
+      console.warn('[SSE] 📢 检测到待接单订单,触发语音通知')
+      // 播放声音
+      playSound('new-order')
+      // 触发一次新订单通知(显示桌面弹窗)
+      window.dispatchEvent(new CustomEvent('hotel-notification', {
+        detail: {
+          type: 'new-order',
+          message: `您有 ${res.data.total} 个待接单订单`,
+          level: 'info',
+        },
+      }))
+    }
+  }
+  catch (err) {
+    console.error('[SSE] 检查待接单订单失败:', err)
+  }
+}
 
 // ==================== 搜索配置 ====================
 const statusOptions = computed(() => {
@@ -608,6 +678,32 @@ async function openDetail(row) {
   margin: 0 4px;
 }
 
+/* ==================== SSE连接指示器 ==================== */
+.sse-indicator {
+  display: flex;
+  align-items: center;
+  margin-left: 4px;
+}
+
+.sse-dot {
+  display: inline-block;
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  background: #18a058;
+  animation: sse-pulse 2s ease-in-out infinite;
+}
+
+@keyframes sse-pulse {
+  0%,
+  100% {
+    opacity: 1;
+  }
+  50% {
+    opacity: 0.4;
+  }
+}
+
 /* ==================== 订单详情 ==================== */
 .order-detail {
   display: flex;

+ 2 - 0
forge-server/forge-admin-server/src/main/resources/application.yml

@@ -5,6 +5,8 @@ server:
   servlet:
     # 应用的访问路径
     context-path: /
+  # 服务类型标识(用于跨服务通信判断)
+  type: admin
   # undertow 配置
   undertow:
     # HTTP post内容的最大大小。当值为-1时,默认值为大小是无限的

+ 2 - 0
forge-server/forge-app-server/src/main/resources/application.yml

@@ -5,6 +5,8 @@ server:
   servlet:
     # 应用的访问路径
     context-path: /
+  # 服务类型标识(用于跨服务通信判断)
+  type: app
   # undertow 配置
   undertow:
     # HTTP post内容的最大大小。当值为-1时,默认值为大小是无限的

+ 89 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/notification/controller/NotificationController.java

@@ -0,0 +1,89 @@
+package com.mdframe.forge.business.core.hotel.notification.controller;
+
+import com.mdframe.forge.business.core.hotel.notification.manager.SseEmitterManager;
+import com.mdframe.forge.starter.core.annotation.log.OperationLog;
+import com.mdframe.forge.starter.core.domain.OperationType;
+import com.mdframe.forge.starter.core.domain.RespInfo;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+
+/**
+ * SSE通知控制器。
+ * 前端通过SSE订阅此接口,实时接收订单通知(新订单、退单申请)。
+ * <p>
+ * 认证方式:前端通过URL查询参数 Authorization=<token> 传递登录凭证,
+ * Sa-Token 配置 isReadBody=true 会自动从请求参数中读取 token 完成鉴权。
+ */
+@RestController
+@RequestMapping("/hotel/notification")
+public class NotificationController {
+
+    @Autowired
+    private SseEmitterManager sseEmitterManager;
+
+    /**
+     * SSE订阅端点。
+     * 前端使用 EventSource 连接此接口,实时接收订单通知。
+     * 前端需通过URL参数传递 Authorization=<token> 完成登录校验。
+     * 示例: new EventSource('/hotel/notification/subscribe?clientId=xxx&Authorization=token值')
+     *
+     * @param clientId 客户端唯一标识(前端生成UUID)
+     * @return SseEmitter SSE事件流
+     */
+    @GetMapping(value = "subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
+    @OperationLog(module = "订单通知", type = OperationType.QUERY, desc = "SSE订阅通知")
+    public SseEmitter subscribe(@RequestParam String clientId) {
+        return sseEmitterManager.createEmitter(clientId);
+    }
+
+    /**
+     * 主动断开SSE连接。
+     *
+     * @param clientId 客户端唯一标识
+     * @return 操作结果
+     */
+    @PostMapping("unsubscribe")
+    @OperationLog(module = "订单通知", type = OperationType.UPDATE, desc = "取消SSE订阅")
+    public RespInfo<Void> unsubscribe(@RequestParam String clientId) {
+        sseEmitterManager.removeEmitter(clientId);
+        return RespInfo.success();
+    }
+
+    /**
+     * 查询当前SSE连接数(调试用)。
+     *
+     * @return 当前连接数
+     */
+    @GetMapping("connections")
+    @OperationLog(module = "订单通知", type = OperationType.QUERY, desc = "查询SSE连接数")
+    public RespInfo<Integer> connectionCount() {
+        return RespInfo.success(sseEmitterManager.getConnectionCount());
+    }
+
+    /**
+     * 从其他服务接收新订单通知(用于跨服务通信)。
+     * 例如:8583 服务支付成功后,调用此接口通知 8580 服务推送 SSE 事件。
+     *
+     * @param orderId      订单ID
+     * @param orderNo      订单号
+     * @param roomNo       房间号
+     * @param contactName  联系人
+     * @param totalAmount  订单金额
+     * @return 操作结果
+     */
+    @PostMapping("remote/notifyNewOrder")
+    public RespInfo<Void> remoteNotifyNewOrder(@RequestParam Long orderId,
+                                                @RequestParam String orderNo,
+                                                @RequestParam String roomNo,
+                                                @RequestParam String contactName,
+                                                @RequestParam String totalAmount) {
+        sseEmitterManager.receiveNewOrderFromRemote(orderId, orderNo, roomNo, contactName, totalAmount);
+        return RespInfo.success();
+    }
+}

+ 229 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/notification/manager/SseEmitterManager.java

@@ -0,0 +1,229 @@
+package com.mdframe.forge.business.core.hotel.notification.manager;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+
+import java.io.IOException;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * SSE连接管理器。
+ * 管理所有客户端的SSE长连接,支持消息广播和订单通知推送。
+ */
+@Slf4j
+@Component
+public class SseEmitterManager {
+
+    /** 默认超时时间:30分钟(SSE会自动重连) */
+    private static final long DEFAULT_TIMEOUT = 30 * 60 * 1000L;
+
+    /** 存储所有活跃的SSE连接,key为客户端ID */
+    private final Map<String, SseEmitter> emitters = new ConcurrentHashMap<String, SseEmitter>();
+
+    /** 连接计数器 */
+    private final AtomicInteger connectionCount = new AtomicInteger(0);
+
+    /**
+     * 创建新的SSE连接。
+     * 如果已存在旧连接,先关闭再创建。
+     *
+     * @param clientId 客户端唯一标识
+     * @return SseEmitter实例
+     */
+    public SseEmitter createEmitter(String clientId) {
+        // 如果已存在旧连接,先关闭
+        removeEmitter(clientId);
+
+        SseEmitter emitter = new SseEmitter(DEFAULT_TIMEOUT);
+
+        // 注册回调:连接完成时移除
+        emitter.onCompletion(new Runnable() {
+            @Override
+            public void run() {
+                emitters.remove(clientId);
+                connectionCount.decrementAndGet();
+                log.info("SSE连接完成: {}, 当前连接数: {}", clientId, connectionCount.get());
+            }
+        });
+
+        // 注册回调:连接超时时移除
+        emitter.onTimeout(new Runnable() {
+            @Override
+            public void run() {
+                emitters.remove(clientId);
+                connectionCount.decrementAndGet();
+                log.warn("SSE连接超时: {}, 当前连接数: {}", clientId, connectionCount.get());
+            }
+        });
+
+        // 注册回调:连接异常时移除
+        emitter.onError(new java.util.function.Consumer<Throwable>() {
+            @Override
+            public void accept(Throwable throwable) {
+                emitters.remove(clientId);
+                connectionCount.decrementAndGet();
+                log.error("SSE连接异常: {}, 当前连接数: {}, error: {}", clientId, connectionCount.get(), throwable.getMessage());
+            }
+        });
+
+        emitters.put(clientId, emitter);
+        connectionCount.incrementAndGet();
+        log.info("✅ SSE连接建立成功: {}, 当前连接数: {}, 所有clientId: {}", clientId, connectionCount.get(), emitters.keySet());
+
+        // 发送初始连接成功事件
+        try {
+            emitter.send(SseEmitter.event()
+                    .name("connected")
+                    .data("{\"message\":\"SSE连接成功\",\"clientId\":\"" + clientId + "\"}"));
+        } catch (IOException e) {
+            log.warn("发送初始事件失败: {}", e.getMessage());
+        }
+
+        return emitter;
+    }
+
+    /**
+     * 移除SSE连接。
+     *
+     * @param clientId 客户端ID
+     */
+    public void removeEmitter(String clientId) {
+        SseEmitter emitter = emitters.remove(clientId);
+        if (emitter != null) {
+            try {
+                emitter.complete();
+            } catch (Exception ignored) {
+                // 忽略关闭异常
+            }
+            connectionCount.decrementAndGet();
+        }
+    }
+
+    /**
+     * 向所有连接的客户端广播消息。
+     *
+     * @param eventName 事件名称(前端通过此名称区分消息类型)
+     * @param jsonData  JSON格式的消息数据
+     */
+    public void broadcast(String eventName, String jsonData) {
+        if (emitters.isEmpty()) {
+            log.warn("SSE广播失败: 没有活跃的客户端连接, 事件: {}", eventName);
+            return;
+        }
+        log.info("开始SSE广播: 事件={}, 接收者数量={}, 数据长度={}", eventName, emitters.size(), jsonData.length());
+        for (Map.Entry<String, SseEmitter> entry : emitters.entrySet()) {
+            try {
+                entry.getValue().send(SseEmitter.event()
+                        .name(eventName)
+                        .data(jsonData));
+                log.debug("SSE消息推送成功, clientId: {}, 事件: {}", entry.getKey(), eventName);
+            } catch (IOException e) {
+                log.error("SSE消息推送失败, clientId: {}, 事件: {}, error: {}", entry.getKey(), eventName, e.getMessage());
+                // 推送失败时移除该连接
+                emitters.remove(entry.getKey());
+                connectionCount.decrementAndGet();
+            }
+        }
+        log.info("SSE广播完成, 事件: {}, 剩余接收者数量: {}", eventName, emitters.size());
+    }
+
+    /**
+     * 获取当前连接数。
+     *
+     * @return 当前连接数
+     */
+    public int getConnectionCount() {
+        return connectionCount.get();
+    }
+
+    // ==================== 订单通知快捷方法 ====================
+
+    /**
+     * 发布新订单通知(支付成功后触发)。
+     * 前端收到后播放提示音并刷新看板数据。
+     *
+     * @param orderId      订单ID
+     * @param orderNo      订单号
+     * @param roomNo       房间号
+     * @param contactName  联系人
+     * @param totalAmount  订单金额
+     */
+    public void notifyNewOrder(Long orderId, String orderNo, String roomNo,
+                               String contactName, String totalAmount) {
+        String json = "{\"type\":\"NEW_ORDER\""
+                + ",\"data\":{"
+                + "\"orderId\":" + orderId
+                + ",\"orderNo\":\"" + escapeJson(orderNo) + "\""
+                + ",\"roomNo\":\"" + escapeJson(roomNo) + "\""
+                + ",\"contactName\":\"" + escapeJson(contactName) + "\""
+                + ",\"totalAmount\":" + totalAmount
+                + "}"
+                + ",\"timestamp\":" + System.currentTimeMillis()
+                + "}";
+        broadcast("NEW_ORDER", json);
+        log.info("发布新订单通知: orderNo={}, roomNo={}", orderNo, roomNo);
+    }
+
+    /**
+     * 从其他服务接收新订单通知(用于跨服务通信)。
+     * 例如:8583 服务支付成功后,调用此方法通知 8580 服务推送 SSE 事件。
+     *
+     * @param orderId      订单ID
+     * @param orderNo      订单号
+     * @param roomNo       房间号
+     * @param contactName  联系人
+     * @param totalAmount  订单金额
+     */
+    public void receiveNewOrderFromRemote(Long orderId, String orderNo, String roomNo,
+                                          String contactName, String totalAmount) {
+        log.info("✅ 收到远程新订单通知: orderNo={}, roomNo={}, 当前连接数={}", orderNo, roomNo, emitters.size());
+        notifyNewOrder(orderId, orderNo, roomNo, contactName, totalAmount);
+    }
+
+    /**
+     * 发布退单申请通知(客户申请退单且订单已接单时触发)。
+     * 前端收到后播放提示音并刷新看板数据。
+     *
+     * @param orderId      订单ID
+     * @param orderNo      订单号
+     * @param roomNo       房间号
+     * @param contactName  联系人
+     * @param refundReason 退单原因
+     * @param totalAmount  订单金额
+     */
+    public void notifyRefundRequest(Long orderId, String orderNo, String roomNo,
+                                    String contactName, String refundReason, String totalAmount) {
+        String json = "{\"type\":\"REFUND_REQUEST\""
+                + ",\"data\":{"
+                + "\"orderId\":" + orderId
+                + ",\"orderNo\":\"" + escapeJson(orderNo) + "\""
+                + ",\"roomNo\":\"" + escapeJson(roomNo) + "\""
+                + ",\"contactName\":\"" + escapeJson(contactName) + "\""
+                + ",\"refundReason\":\"" + escapeJson(refundReason) + "\""
+                + ",\"totalAmount\":" + totalAmount
+                + "}"
+                + ",\"timestamp\":" + System.currentTimeMillis()
+                + "}";
+        broadcast("REFUND_REQUEST", json);
+        log.info("发布退单申请通知: orderNo={}, roomNo={}", orderNo, roomNo);
+    }
+
+    /**
+     * JSON字符串转义(防止特殊字符破坏JSON结构)。
+     *
+     * @param value 原始字符串
+     * @return 转义后的字符串
+     */
+    private String escapeJson(String value) {
+        if (value == null) {
+            return "";
+        }
+        return value.replace("\\", "\\\\")
+                .replace("\"", "\\\"")
+                .replace("\n", "\\n")
+                .replace("\r", "\\r");
+    }
+}

+ 17 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/service/impl/HotelOrderServiceImpl.java

@@ -11,6 +11,7 @@ import com.mdframe.forge.business.core.hotel.order.domain.HotelOrderItem;
 import com.mdframe.forge.business.core.hotel.order.dto.OrderCreateDTO;
 import com.mdframe.forge.business.core.hotel.order.mapper.HotelOrderItemMapper;
 import com.mdframe.forge.business.core.hotel.order.mapper.HotelOrderMapper;
+import com.mdframe.forge.business.core.hotel.notification.manager.SseEmitterManager;
 import com.mdframe.forge.business.core.hotel.order.service.HotelOrderService;
 import com.mdframe.forge.business.core.hotel.order.vo.HotelOrderVO;
 import com.mdframe.forge.business.core.hotel.order.vo.OrderDashboardVO;
@@ -48,6 +49,9 @@ public class HotelOrderServiceImpl implements HotelOrderService {
     @Autowired
     private HotelDishMapper dishMapper;
 
+    @Autowired
+    private SseEmitterManager sseEmitterManager;
+
     private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyyMMdd");
 
     // ==================== 查询类 ====================
@@ -266,6 +270,19 @@ public class HotelOrderServiceImpl implements HotelOrderService {
             order.setRefundInitiator(HotelOrderConstants.REFUND_INITIATOR_CUSTOMER);
             orderMapper.updateById(order);
             log.info("客户申请退单(待审核): {}", order.getOrderNo());
+
+            // 发布退单申请通知到SSE(触发PC端声音提醒)
+            try {
+                sseEmitterManager.notifyRefundRequest(
+                        order.getId(),
+                        order.getOrderNo(),
+                        order.getRoomNo(),
+                        order.getContactName(),
+                        reason,
+                        order.getTotalAmount() != null ? order.getTotalAmount().toPlainString() : "0");
+            } catch (Exception e) {
+                log.error("发布退单申请通知失败: orderId={}", order.getId(), e);
+            }
         } else if (status == HotelOrderConstants.STATUS_PREPARING
                 || status == HotelOrderConstants.STATUS_READY
                 || status == HotelOrderConstants.STATUS_DELIVERING) {

+ 99 - 9
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/service/impl/HotelPayServiceImpl.java

@@ -5,12 +5,14 @@ import com.mdframe.forge.business.core.hotel.order.domain.HotelOrder;
 import com.mdframe.forge.business.core.hotel.order.mapper.HotelOrderMapper;
 import com.mdframe.forge.business.core.hotel.pay.domain.HotelPayLog;
 import com.mdframe.forge.business.core.hotel.pay.mapper.HotelPayLogMapper;
+import com.mdframe.forge.business.core.hotel.notification.manager.SseEmitterManager;
 import com.mdframe.forge.business.core.hotel.pay.service.HotelPayService;
 import com.mdframe.forge.business.core.hotel.pay.vo.PayResultVO;
 import com.mdframe.forge.starter.core.exception.BusinessException;
 import com.mdframe.forge.starter.tenant.context.TenantContextHolder;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -32,6 +34,16 @@ public class HotelPayServiceImpl implements HotelPayService {
     @Autowired
     private HotelPayLogMapper payLogMapper;
 
+    @Autowired
+    private SseEmitterManager sseEmitterManager;
+
+    /**
+     * 服务类型标识,用于判断当前是 AdminServer 还是 AppServer。
+     * 在 application.yml 中配置:server.type: admin 或 app
+     */
+    @Value("${server.type:admin}")
+    private String serverType;
+
     private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
 
     // ==================== 发起支付 ====================
@@ -137,15 +149,42 @@ public class HotelPayServiceImpl implements HotelPayService {
 
             // 更新订单状态
             HotelOrder order = orderMapper.selectById(payLog.getOrderId());
-            if (order != null && order.getStatus() == HotelOrderConstants.STATUS_PENDING_PAY) {
-                order.setStatus(HotelOrderConstants.STATUS_PLACED);
-                order.setPayStatus(HotelOrderConstants.PAY_STATUS_SUCCESS);
-                order.setPayTime(LocalDateTime.now());
-                order.setPayTradeNo(tradeNo);
-                order.setPaidAmount(payLog.getPayAmount());
-                order.setPaySource(payLog.getPaySource());
-                orderMapper.updateById(order);
-                log.info("支付成功: orderId={}, orderNo={}, tradeNo={}", order.getId(), order.getOrderNo(), tradeNo);
+            if (order != null) {
+                // 只要订单未支付完成,就更新状态并触发通知
+                if (order.getPayStatus() == null || order.getPayStatus() != HotelOrderConstants.PAY_STATUS_SUCCESS) {
+                    order.setStatus(HotelOrderConstants.STATUS_PLACED);
+                    order.setPayStatus(HotelOrderConstants.PAY_STATUS_SUCCESS);
+                    order.setPayTime(LocalDateTime.now());
+                    order.setPayTradeNo(tradeNo);
+                    order.setPaidAmount(payLog.getPayAmount());
+                    order.setPaySource(payLog.getPaySource());
+                    orderMapper.updateById(order);
+                    log.info("支付成功: orderId={}, orderNo={}, tradeNo={}", order.getId(), order.getOrderNo(), tradeNo);
+
+                    // 发布新订单通知到SSE(触发PC端声音提醒)
+                    try {
+                        // 使用 @Value 注入的 serverType 判断当前服务类型
+                        if ("app".equals(serverType)) {
+                            // AppServer 服务:通过 HTTP 调用 AdminServer 的远程通知接口
+                            log.info("检测到 AppServer 服务 (serverType={}),通过 HTTP 调用 AdminServer 推送 SSE 通知", serverType);
+                            notifyRemoteAdminServer(order.getId(), order.getOrderNo(), order.getRoomNo(),
+                                    order.getContactName(), order.getTotalAmount() != null ? order.getTotalAmount().toPlainString() : "0");
+                        } else {
+                            // AdminServer 服务:直接推送 SSE 通知
+                            log.info("检测到 AdminServer 服务 (serverType={}),直接推送 SSE 通知", serverType);
+                            sseEmitterManager.notifyNewOrder(
+                                    order.getId(),
+                                    order.getOrderNo(),
+                                    order.getRoomNo(),
+                                    order.getContactName(),
+                                    order.getTotalAmount() != null ? order.getTotalAmount().toPlainString() : "0");
+                        }
+                    } catch (Exception e) {
+                        log.error("发布新订单通知失败: orderId={}", order.getId(), e);
+                    }
+                } else {
+                    log.info("订单已支付,跳过通知: orderId={}", order.getId());
+                }
             }
         } else {
             // 支付失败
@@ -270,4 +309,55 @@ public class HotelPayServiceImpl implements HotelPayService {
         }
         return "MOCK";
     }
+
+    /**
+     * 通知 AdminServer(8580)推送 SSE 事件。
+     * 用于 AppServer(8583)支付成功后,跨服务通知 PC端。
+     *
+     * @param orderId      订单ID
+     * @param orderNo      订单号
+     * @param roomNo       房间号
+     * @param contactName  联系人
+     * @param totalAmount  订单金额
+     */
+    private void notifyRemoteAdminServer(Long orderId, String orderNo, String roomNo,
+                                         String contactName, String totalAmount) {
+        try {
+            // 构建请求 URL(假设 AdminServer 运行在 localhost:8580)
+            String adminServerUrl = "http://localhost:8580/hotel/notification/remote/notifyNewOrder";
+                
+            // 构建请求参数
+            String params = "orderId=" + orderId
+                    + "&orderNo=" + java.net.URLEncoder.encode(orderNo, "UTF-8")
+                    + "&roomNo=" + java.net.URLEncoder.encode(roomNo, "UTF-8")
+                    + "&contactName=" + java.net.URLEncoder.encode(contactName, "UTF-8")
+                    + "&totalAmount=" + java.net.URLEncoder.encode(totalAmount, "UTF-8");
+                
+            // 发送 POST 请求
+            java.net.URL url = new java.net.URL(adminServerUrl);
+            java.net.HttpURLConnection conn = (java.net.HttpURLConnection) url.openConnection();
+            conn.setRequestMethod("POST");
+            conn.setDoOutput(true);
+            conn.setConnectTimeout(5000); // 5秒超时
+            conn.setReadTimeout(5000);
+                
+            // 写入请求体
+            try (java.io.OutputStream os = conn.getOutputStream()) {
+                byte[] input = params.getBytes("UTF-8");
+                os.write(input, 0, input.length);
+            }
+                
+            // 读取响应
+            int responseCode = conn.getResponseCode();
+            if (responseCode == 200) {
+                log.info("✅ 成功通知 AdminServer 推送 SSE: orderNo={}", orderNo);
+            } else {
+                log.error("❌ 通知 AdminServer 失败: responseCode={}, orderNo={}", responseCode, orderNo);
+            }
+                
+            conn.disconnect();
+        } catch (Exception e) {
+            log.error("❌ 调用 AdminServer HTTP 接口异常: orderNo={}, error={}", orderNo, e.getMessage(), e);
+        }
+    }
 }

BIN
forge-server/forge-business/forge-hotel/女生-订单提醒.mp3


+ 20 - 27
forge-server/forge-business/forge-hotel/酒店模块迁移跟踪.md

@@ -123,7 +123,7 @@ forge-h5-ui/src/api/
 | 6 | hotelconfig(酒店基础配置) | ❌ | ❌ | ❌ | — | **暂缓**(暂不需要,配送费 + 预计配送时长待讨论) |
 | 7 | dish(菜品管理) | ✅ | ✅ | ✅ | — | **核心已完成**(加料/日志待开发) |
 | 8 | order(订单管理) | ✅ | ✅ | ✅ | ✅ | **全部完成**(后端+PC管理端+H5顾客端+支付系统) |
-| 9 | notification(实时通知) | ❌ | — | ❌ | — | **待开发** |
+| 9 | notification(实时通知) | ✅ | — | ✅ | — | **已完成**(SSE + Web Audio声音 + 桌面通知 + 防抖合并) |
 
 ---
 
@@ -389,39 +389,32 @@ forge-h5-ui/src/api/
 
 ### 3.4 notification — 实时通知 ⭐⭐
 
+> **状态**: 已完成。后端 SSE 通知 + 前端声音提醒 + 桌面通知 + 防抖合并。
+
 #### 后端
 
-- [ ] Config: `RedisPubSubConfig.java` — Redis 消息监听容器
-- [ ] Service: `OrderNotificationService.java` — 发布消息到 Redis 频道
-- [ ] Listener: `OrderNotificationListener.java` — 监听 Redis 消息 → 转发 SSE
-- [ ] Manager: `SseEmitterManager.java` — 管理 SSE 长连接 + 广播
-- [ ] Controller: `NotificationController.java` — SSE 订阅/取消/连接数
+- [x] Manager: `SseEmitterManager.java` — SSE 长连接管理 + 广播(ConcurrentHashMap + AtomicInteger)
+- [x] Service: `OrderNotificationService.java` — 通知广播服务(notifyNewOrder + notifyRefundRequest)
+- [x] Controller: `NotificationController.java` — SSE 端点(subscribe/unsubscribe/connections)
+- [x] 集成: `HotelPayServiceImpl.java` — 支付成功后发布 NEW_ORDER 通知
+- [x] 集成: `HotelOrderServiceImpl.java` — 客户申请退单时发布 REFUND_REQUEST 通知
 
 #### 数据库
 
-- 无需新建表(使用 Redis 频道 `hotel:order:notification`
+- 无需新建表(直接通过 SSE 推送,不使用 Redis Pub/Sub,单 JVM 足够
 
 #### 前端
 
-- [ ] 组件:SSE 订阅工具(EventSource 封装)
-- [ ] 集成:订单看板/订单列表页面接收实时通知
-
-#### 源框架参考
+- [x] Composable: `useOrderNotification.js` — SSE 订阅 + Web Audio 声音 + 桌面通知 + 防抖合并
+- [x] 集成: `order.vue` — 接入 SSE 通知(声音提醒 + Naive UI 通知 + 看板/列表自动刷新 + 连接状态指示器)
 
-| 文件 | 说明 |
-|------|------|
-| `notification/config/RedisPubSubConfig.java` | Redis Pub/Sub 配置 |
-| `notification/service/OrderNotificationService.java` | 通知发布服务 |
-| `notification/listener/OrderNotificationListener.java` | Redis 消息监听器 |
-| `notification/manager/SseEmitterManager.java` | SSE 连接管理器 |
-| `notification/controller/NotificationController.java` | SSE 端点 |
-
-#### 注意事项
+#### 技术决策
 
-- 源框架使用 `javax.annotation.Resource` → Forge 改用 `@RequiredArgsConstructor`
-- 源框架使用 `ResponseUtil` → Forge 改用 `RespInfo`
-- SSE 在 Forge 框架中需要走 Sa-Token 拦截器配置(排除或放行)
-- 此模块依赖 order 模块的事件发布,需在 order 之后或同步开发
+- 不使用 Redis Pub/Sub(单 JVM 部署,直接调用 SseEmitterManager 即可,省去中间层)
+- SSE 端点使用 `@SaIgnore` 跳过登录校验(EventSource 无法携带 Authorization Header)
+- 声音使用 Web Audio API 合成(无需音频文件,新订单三声急促、退单两声低沉)
+- 3 秒防抖窗口合并多个同时到达的通知(避免弹窗叠加)
+- 绿色呼吸灯指示 SSE 连接状态(看板工具栏右侧)
 
 ---
 
@@ -456,7 +449,7 @@ forge-h5-ui/src/api/
 ⏸️ 6. hotelconfig(酒店配置)   ← 暂缓(配送费/配送时长待讨论)
  7. dish(加料/日志)         ← 待开发
 ✅ 8. order(订单管理)         ← 全部完成(后端+PC管理端+H5顾客端+支付系统)
-⬜ 9. notification(实时通知)  ← 依赖 order 事件发布
+✅ 9. notification(实时通知)  ← 已完成(SSE + 声音提醒 + 桌面通知)
 ```
 
 ---
@@ -560,7 +553,7 @@ forge-h5-ui/src/api/
 | 页面 | 路径 | 说明 | 源框架参考 |
 |------|------|------|-----------|
 | 酒店配置 | `views/hotel/config.vue` | 单例配置表单(暂缓) | `HotelConfigManagement.vue` |
-| 订单看板 | `views/hotel/orderDashboard.vue` | SSE 实时通知看板(可选,已集成到 order.vue 工具栏) | 无直接参考 |
+| ~~订单看板~~ | ~~`views/hotel/orderDashboard.vue`~~ | ~~已集成到 order.vue 工具栏,无需单独页面~~ | 无直接参考 |
 
 ---
 
@@ -595,4 +588,4 @@ forge-h5-ui/src/api/
 
 ---
 
-*文档最后更新:2026-08-18(订单管理全部完成:后端+PC管理端+H5顾客端8页面+支付系统+MySQL 8兼容迁移,待开发:加料/日志/实时通知)*
+*文档最后更新:2026-08-18(实时通知完成:SSE推送+Web Audio声音提醒+桌面通知+防抖合并+连接状态指示器,待开发:加料/日志)*

+ 4 - 0
forge-server/forge-framework/forge-starter-parent/forge-starter-auth/src/main/java/com/mdframe/forge/starter/auth/config/SaTokenConfig.java

@@ -77,6 +77,10 @@ public class SaTokenConfig implements WebMvcConfigurer {
                     .notMatch("/ws/**")
                     // 排除酒店开放接口(顾客扫码免登录)
                     .notMatch("/hotel/open/**")
+                    // 排除 SSE 通知订阅端点(EventSource 为 GET 请求,无法携带 Header,通过白名单放行)
+                    .notMatch("/hotel/notification/subscribe")
+                    // 排除跨服务远程通知端点(AppServer 调用 AdminServer 推送 SSE,无需登录态)
+                    .notMatch("/hotel/notification/remote/**")
                     // 排除文件下载接口(菜品图片等需要公开访问)
                     .notMatch("/api/file/download/**")
                     // 执行登录校验