Procházet zdrojové kódy

酒店端 移动端代码功能

徐滕 před 1 týdnem
rodič
revize
33c6948a44

+ 1 - 1
docker/Dockerfile.admin

@@ -11,7 +11,7 @@ FROM eclipse-temurin:17-jre
 WORKDIR /app
 COPY --from=builder /build/forge-admin/target/forge-admin.jar app.jar
 
-ENV JAVA_OPTS="-Xms256m -Xmx512m"
+ENV JAVA_OPTS="-Xms128m -Xmx256m -XX:+UseSerialGC"
 ENV SPRING_PROFILES_ACTIVE=prod
 ENV MYSQL_HOST=mysql
 ENV MYSQL_PORT=3306

+ 1 - 1
docker/Dockerfile.flow

@@ -11,7 +11,7 @@ FROM eclipse-temurin:17-jre
 WORKDIR /app
 COPY --from=builder /build/forge-flow/target/forge-flow.jar app.jar
 
-ENV JAVA_OPTS="-Xms256m -Xmx512m"
+ENV JAVA_OPTS="-Xms128m -Xmx256m -XX:+UseSerialGC"
 ENV SPRING_PROFILES_ACTIVE=prod
 ENV MYSQL_HOST=mysql
 ENV MYSQL_PORT=3306

+ 6 - 2
docker/docker-compose.yml

@@ -30,6 +30,10 @@ services:
       --character-set-server=utf8mb4
       --collation-server=utf8mb4_unicode_ci
       --default-authentication-plugin=mysql_native_password
+      --innodb_buffer_pool_size=256M
+      --max_connections=50
+      --table_open_cache=64
+      --performance_schema=OFF
     healthcheck:
       test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_PASSWORD:-forge123456}"]
       interval: 10s
@@ -81,7 +85,7 @@ services:
       REDIS_HOST: redis
       REDIS_PORT: 6379
       REDIS_PASSWORD: ${REDIS_PASSWORD:-forge123456}
-      JAVA_OPTS: -Xms256m -Xmx512m
+      JAVA_OPTS: -Xms128m -Xmx256m -XX:+UseSerialGC
     ports:
       - "${FLOW_PORT:-8081}:8081"
     networks:
@@ -123,7 +127,7 @@ services:
       FORGE_CRYPTO_PERSISTENCE_LEGACY_KEY: "${FORGE_CRYPTO_PERSISTENCE_LEGACY_KEY:-}"
       FORGE_CRYPTO_PERSISTENCE_ACTIVE_KEY_ID: "${FORGE_CRYPTO_PERSISTENCE_ACTIVE_KEY_ID:-}"
       FORGE_CRYPTO_PERSISTENCE_ACTIVE_KEY: "${FORGE_CRYPTO_PERSISTENCE_ACTIVE_KEY:-}"
-      JAVA_OPTS: -Xms256m -Xmx512m
+      JAVA_OPTS: -Xms128m -Xmx256m -XX:+UseSerialGC
     volumes:
       - crypto_secrets:/var/lib/forge/secrets
     ports:

+ 87 - 14
forge-h5-ui/src/pages/hotel/customer/order-status.vue

@@ -168,7 +168,9 @@ const loading = ref(true)
 const showRefundModal = ref(false)
 const refundReason = ref('')
 let orderId = ''
-let pollTimer = null
+let eventSource = null
+let fallbackTimer = null
+let sseConnected = false
 
 onMounted(() => {
   const pages = getCurrentPages()
@@ -184,7 +186,8 @@ onMounted(() => {
 })
 
 onUnmounted(() => {
-  stopPolling()
+  disconnectSSE()
+  stopFallbackPolling()
 })
 
 async function loadOrder() {
@@ -193,9 +196,10 @@ async function loadOrder() {
     order.value = res?.data || res
     loading.value = false
 
-    // 订单未完成则启动轮询
+    // 订单未完成则启动 SSE + 兑底轮询
     if (order.value && !isFinished(order.value.status)) {
-      startPolling()
+      connectSSE()
+      startFallbackPolling()
     }
   } catch (e) {
     console.error('加载订单详情失败:', e)
@@ -204,25 +208,92 @@ async function loadOrder() {
   }
 }
 
-function startPolling() {
-  stopPolling()
-  pollTimer = setInterval(async () => {
+// ==================== SSE 实时推送 ====================
+
+function connectSSE() {
+  disconnectSSE()
+  try {
+    const clientId = 'customer_' + orderId + '_' + Date.now()
+    const baseUrl = import.meta.env.VITE_REQUEST_PREFIX || '/dev-api'
+    const url = baseUrl + '/hotel/open/customer/sse/subscribe?clientId=' + encodeURIComponent(clientId) + '&tenantId=' + orderStore.tenantId
+    eventSource = new EventSource(url)
+
+    eventSource.addEventListener('CUSTOMER_ORDER_STATUS', function(event) {
+      try {
+        const payload = JSON.parse(event.data)
+        if (payload.data && payload.data.orderId.toString() === orderId.toString()) {
+          // 立即更新状态(界面秒级响应)
+          if (order.value) {
+            order.value.status = payload.data.status
+          }
+          // 静默拉取完整数据保持时间戳等字段一致
+          silentRefresh()
+        }
+      } catch (e) {
+        console.error('解析SSE事件失败:', e)
+      }
+    })
+
+    eventSource.onopen = function() {
+      sseConnected = true
+      console.log('[SSE] 顾客端连接成功')
+    }
+
+    eventSource.onerror = function() {
+      sseConnected = false
+      console.warn('[SSE] 连接断开,依赖兑底轮询')
+      // EventSource 会自动重连,无需手动处理
+    }
+  } catch (e) {
+    console.error('[SSE] 连接失败:', e)
+  }
+}
+
+function disconnectSSE() {
+  if (eventSource) {
+    eventSource.close()
+    eventSource = null
+  }
+  sseConnected = false
+}
+
+async function silentRefresh() {
+  try {
+    const res = await api.hotelOrderDetail(orderId, orderStore.tenantId)
+    order.value = res?.data || res
+    if (order.value && isFinished(order.value.status)) {
+      disconnectSSE()
+      stopFallbackPolling()
+    }
+  } catch (e) {
+    // 静默失败
+  }
+}
+
+// ==================== 兑底轮询(SSE 断开时保证最终一致) ====================
+
+function startFallbackPolling() {
+  stopFallbackPolling()
+  fallbackTimer = setInterval(async () => {
+    // SSE 已连接时不轮询,节省服务器资源
+    if (sseConnected) return
     try {
       const res = await api.hotelOrderDetail(orderId, orderStore.tenantId)
       order.value = res?.data || res
       if (order.value && isFinished(order.value.status)) {
-        stopPolling()
+        disconnectSSE()
+        stopFallbackPolling()
       }
     } catch (e) {
-      // 静默失败,继续轮询
+      // 静默失败
     }
-  }, 10000)
+  }, 30000)
 }
 
-function stopPolling() {
-  if (pollTimer) {
-    clearInterval(pollTimer)
-    pollTimer = null
+function stopFallbackPolling() {
+  if (fallbackTimer) {
+    clearInterval(fallbackTimer)
+    fallbackTimer = null
   }
 }
 
@@ -231,6 +302,8 @@ function isFinished(status) {
   return status === 6 || status === 7 || status === 9 || status === 10 || status === 11
 }
 
+// 退单/拒单后状态变更也需停止 SSE,loadOrder 的 isFinished 已覆盖
+
 const orderItems = computed(() => order.value?.items || [])
 
 const canRefund = computed(() => {

+ 1 - 1
forge-server/forge-admin-server/src/main/resources/application-dev.yml

@@ -113,6 +113,6 @@ hotel:
     # 沙箱网关地址
     gateway-url: https://openapi-sandbox.dl.alipaydev.com/gateway.do
     # 异步回调地址(内网穿透映射到本地8580端口)
-    notify-url: http://p6fb988a.natappfree.cc/hotel/open/pay/notify
+    notify-url: http://t2aaba9f.natappfree.cc/hotel/open/pay/notify
     # 是否沙箱环境
     sandbox: true

+ 1 - 1
forge-server/forge-app-server/src/main/resources/application-dev.yml

@@ -92,7 +92,7 @@ hotel:
     # 沙箱网关地址
     gateway-url: https://openapi-sandbox.dl.alipaydev.com/gateway.do
     # 异步回调地址(内网穿透映射到本地8583端口)
-    notify-url: http://p6fb988a.natappfree.cc/hotel/open/pay/notify
+    notify-url: http://t2aaba9f.natappfree.cc/hotel/open/pay/notify
     # 支付完成后跳转回 H5 页面(支付页会自动检测已支付状态并跳转订单详情页)
     return-url: http://192.168.10.4:3001/#/pages/hotel/customer/pay
     # 是否沙箱环境

+ 28 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/controller/open/HotelCustomerController.java

@@ -14,18 +14,21 @@ import com.mdframe.forge.business.core.hotel.service.HotelOrderPauseService;
 import com.mdframe.forge.business.core.hotel.vo.BusinessHoursStatusVO;
 import com.mdframe.forge.business.core.hotel.vo.HotelDishCategoryVO;
 import com.mdframe.forge.business.core.hotel.vo.HotelDishVO;
+import com.mdframe.forge.business.core.hotel.notification.manager.SseEmitterManager;
 import com.mdframe.forge.starter.core.domain.PageQuery;
 import com.mdframe.forge.starter.core.domain.RespInfo;
 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.http.MediaType;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
 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;
 
 import java.util.List;
 
@@ -55,6 +58,9 @@ public class HotelCustomerController {
     @Autowired
     private HotelOrderPauseService pauseService;
 
+    @Autowired
+    private SseEmitterManager sseEmitterManager;
+
     // ==================== 营业时段 ====================
 
     /**
@@ -353,6 +359,28 @@ public class HotelCustomerController {
         return RespInfo.success();
     }
 
+    // ==================== SSE 实时推送 ====================
+
+    /**
+     * 顾客端 SSE 订阅端点(免登录)。
+     * <p>
+     * 顾客 H5 订单状态页使用 EventSource 连接此接口,
+     * 实时接收订单状态变更通知,替代 10 秒轮询。
+     * <p>
+     * 路径 /hotel/open/** 已在 SaTokenConfig 中排除登录校验。
+     *
+     * @param clientId 客户端唯一标识(前端生成 UUID 或 orderId)
+     * @param tenantId 租户ID
+     * @return SseEmitter SSE 事件流
+     */
+    @GetMapping(value = "/sse/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
+    public SseEmitter sseSubscribe(@RequestParam String clientId,
+                                    @RequestParam Long tenantId) {
+        requireTenantId(tenantId);
+        log.info("[Customer] SSE订阅: clientId={}, tenantId={}", clientId, tenantId);
+        return sseEmitterManager.createEmitter(clientId);
+    }
+
     /**
      * 校验开放接口的租户ID入参。
      * <p>

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

@@ -238,6 +238,31 @@ public class SseEmitterManager {
     }
 
     /**
+     * 发布顾客订单状态变更通知。
+     * 顾客 H5 端收到后立即更新界面,无需轮询。
+     * <p>
+     * 广播给所有 SSE 连接的顾客端,前端按 orderId 过滤是否匹配当前页面。
+     *
+     * @param orderId  订单ID
+     * @param orderNo  订单号
+     * @param status   新状态码
+     * @param statusText 状态文案(如"已接单")
+     */
+    public void notifyCustomerOrderStatus(Long orderId, String orderNo, int status, String statusText) {
+        String json = "{\"type\":\"CUSTOMER_ORDER_STATUS\""
+                + ",\"data\":{"
+                + "\"orderId\":" + orderId
+                + ",\"orderNo\":\"" + escapeJson(orderNo) + "\""
+                + ",\"status\":" + status
+                + ",\"statusText\":\"" + escapeJson(statusText) + "\""
+                + "}"
+                + ",\"timestamp\":" + System.currentTimeMillis()
+                + "}";
+        broadcast("CUSTOMER_ORDER_STATUS", json);
+        log.info("发布顾客订单状态变更通知: orderNo={}, status={}, statusText={}", orderNo, status, statusText);
+    }
+
+    /**
      * JSON字符串转义(防止特殊字符破坏JSON结构)。
      *
      * @param value 原始字符串

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

@@ -407,6 +407,7 @@ public class HotelOrderServiceImpl implements HotelOrderService {
         order.setAcceptTime(new Date());
         orderMapper.updateById(order);
         log.info("订单接单: {}", order.getOrderNo());
+        notifyCustomerStatus(order, HotelOrderConstants.STATUS_ACCEPTED, "餐厅已接单");
     }
 
     @Override
@@ -429,6 +430,7 @@ public class HotelOrderServiceImpl implements HotelOrderService {
                 .set("update_time", now);
         orderMapper.update(null, updateWrapper);
         log.info("订单拒单: {}", order.getOrderNo());
+        notifyCustomerStatus(order, HotelOrderConstants.STATUS_REJECTED, "餐厅已拒单");
     }
 
     @Override
@@ -445,6 +447,7 @@ public class HotelOrderServiceImpl implements HotelOrderService {
         order.setPrepareTime(new Date());
         orderMapper.updateById(order);
         log.info("订单备餐: {}", order.getOrderNo());
+        notifyCustomerStatus(order, HotelOrderConstants.STATUS_PREPARING, "厨房备餐中");
     }
 
     @Override
@@ -459,6 +462,9 @@ public class HotelOrderServiceImpl implements HotelOrderService {
         orderMapper.updateById(order);
         log.info("订单出餐: {}", order.getOrderNo());
 
+        // 通知顾客端:已出餐
+        notifyCustomerStatus(order, HotelOrderConstants.STATUS_READY, "已出餐");
+
         // 发布出餐完成SSE通知,前台收到后刷新配送列表
         try {
             sseEmitterManager.notifyOrderReady(
@@ -483,6 +489,7 @@ public class HotelOrderServiceImpl implements HotelOrderService {
         order.setDeliverTime(new Date());
         orderMapper.updateById(order);
         log.info("订单配送: {}", order.getOrderNo());
+        notifyCustomerStatus(order, HotelOrderConstants.STATUS_DELIVERING, "配送中");
     }
 
     @Override
@@ -498,6 +505,7 @@ public class HotelOrderServiceImpl implements HotelOrderService {
         // 订单完成后累加菜品销量
         incrementDishSales(id);
         log.info("订单完成: {}", order.getOrderNo());
+        notifyCustomerStatus(order, HotelOrderConstants.STATUS_COMPLETED, "已送达");
     }
 
     // ==================== 退单相关 ====================
@@ -593,6 +601,7 @@ public class HotelOrderServiceImpl implements HotelOrderService {
                     .set("update_time", now);
             orderMapper.update(null, cancelWrapper);
             log.info("前台取消待支付订单: {}", order.getOrderNo());
+            notifyCustomerStatus(order, HotelOrderConstants.STATUS_CANCELLED, "订单已取消");
             return;
         }
 
@@ -608,6 +617,7 @@ public class HotelOrderServiceImpl implements HotelOrderService {
                 .set("update_time", now);
         orderMapper.update(null, updateWrapper);
         log.info("前台退单: {}", order.getOrderNo());
+        notifyCustomerStatus(order, HotelOrderConstants.STATUS_REFUNDED, "已退单");
     }
 
     @Override
@@ -628,6 +638,7 @@ public class HotelOrderServiceImpl implements HotelOrderService {
                 .set("update_time", now);
         orderMapper.update(null, updateWrapper);
         log.info("退单审核通过: {}", order.getOrderNo());
+        notifyCustomerStatus(order, HotelOrderConstants.STATUS_REFUNDED, "退单审核通过");
     }
 
     @Override
@@ -647,6 +658,7 @@ public class HotelOrderServiceImpl implements HotelOrderService {
                 .set("update_time", new Date());
         orderMapper.update(null, updateWrapper);
         log.info("退单审核驳回: {}", order.getOrderNo());
+        notifyCustomerStatus(order, HotelOrderConstants.STATUS_ACCEPTED, "退单审核驳回");
     }
 
     @Override
@@ -944,4 +956,24 @@ public class HotelOrderServiceImpl implements HotelOrderService {
         }
         return today.subtract(yesterday).divide(yesterday, 4, RoundingMode.HALF_UP).doubleValue();
     }
+
+    /**
+     * 通知顾客端订单状态变更(SSE 实时推送)。
+     * 顾客 H5 收到后立即更新界面,替代 10 秒轮询。
+     *
+     * @param order      订单
+     * @param newStatus  新状态码
+     * @param statusText 状态文案
+     */
+    private void notifyCustomerStatus(HotelOrder order, int newStatus, String statusText) {
+        try {
+            sseEmitterManager.notifyCustomerOrderStatus(
+                    order.getId(),
+                    order.getOrderNo(),
+                    newStatus,
+                    statusText);
+        } catch (Exception e) {
+            log.error("发布顾客订单状态通知失败: orderId={}, status={}", order.getId(), newStatus, e);
+        }
+    }
 }

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

@@ -13,7 +13,7 @@ forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business
 │   ├── HotelDishSpecController.java    ✅ 菜品规格管理(规格组 + 选项)
 │   └── open/                           ✅ 免登录开放接口(SaTokenConfig 已排除 /hotel/open/** 的登录与 API 权限校验)
 │       ├── HotelOpenController.java    ✅ 扫码查询 GET /hotel/open/scan
-│       ├── HotelCustomerController.java ✅ 顾客端 9 接口(categoryEnabled/dishPage/dishDetail/orderCreate/orderDetail/orderPage/customerRefund/abandonOrder/businessHours)
+│       ├── HotelCustomerController.java ✅ 顾客端 10 接口(categoryEnabled/dishPage/dishDetail/orderCreate/orderDetail/orderPage/customerRefund/abandonOrder/businessHours + SSE订阅
 │       ├── HotelPayController.java     ✅ 支付 5 接口(create/notify/status/alipayQuery/mockSuccess)
 │       └── AlipayAuthController.java   ✅ 支付宝小程序用户授权 getUserInfo(姓名 + 手机号)
 ├── domain/
@@ -79,7 +79,7 @@ forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business
     ├── controller/
     │   └── NotificationController.java ✅ SSE 端点(subscribe/unsubscribe/connections/remote/notifyNewOrder)
     └── manager/
-        └── SseEmitterManager.java      ✅ 长连接管理 + 广播(仅 NEW_ORDER / REFUND_REQUEST 两类事件)
+        └── SseEmitterManager.java      ✅ 长连接管理 + 广播(NEW_ORDER / ORDER_READY / REFUND_REQUEST / CUSTOMER_ORDER_STATUS 四类事件)
 
 > ⚠️ 文档修正:原记录的 `OrderNotificationService.java` **不存在**,通知广播能力由 `SseEmitterManager` 直接承担。
 
@@ -145,7 +145,7 @@ forge-admin-ui/src/views/hotel/
 ├── businessHours.vue                   ✅ 营业时段管理(AiCrudPage,时间段下拉选择)
 └── order.vue                           ✅ 订单管理(AiCrudPage + 看板统计 + 流程操作 + 详情弹窗 + 退单审核 + 退款状态列 + 手动退款 + SSE 通知)
 └── restaurantOrder.vue                 ✅ 餐厅前台订单管理(三 Tab:全部订单/订单看板/营业时间;SSE 实时通知;API 复用 hotel 模块接口;✅ 订单详情弹窗 + 按状态显示操作按钮)
-└── kitchenOrder.vue                    ✅ 厨房订单页面(日期筛选 + 统计卡片 + 分组展示:待备餐/制作中/已完成 + 详情弹窗 + 出餐操作)
+└── kitchenOrder.vue                    ✅ 厨房订单页面(日期筛选 + 统计卡片 + 分组展示:待备餐/制作中/已完成 + 详情弹窗 + 出餐操作 + SSE 自动刷新 + TTS 语音播报菜品名+房号
 
 forge-admin-ui/src/api/
 └── hotel.js                            ✅ 酒店模块 API(房间/房型/二维码/菜品/分类/规格/营业时段/订单/退款/在住会话/销量排行/厨房订单列表+统计)
@@ -170,7 +170,7 @@ forge-h5-ui/src/pages/hotel/
     ├── cart.vue                        ✅ 购物车页 Tab3(商品列表/数量控制/备注/费用汇总/结算)
     ├── order-confirm.vue               ✅ 确认下单页(配送信息/送达时间/明细/提交 + 🕐 提交前营业状态刷新阻断,已移除硬编码支付方式)
     ├── pay.vue                         ✅ 支付确认页(按后端 `payChannel` 分流 + 支付宝 H5 WAP Pay + 轮询 + 主动查询兜底 + 倒计时;else 已拆为显式报错,不再兜底调 Mock)
-    ├── order-status.vue                ✅ 订单状态页 Tab2(进度条/状态卡片/10秒轮询/自定义退单弹窗)
+    ├── order-status.vue                ✅ 订单状态页 Tab2(进度条/状态卡片/SSE 实时推送 + 30秒兜底轮询/自定义退单弹窗)
     └── orders.vue                      ✅ 订单列表页 Tab2(订单卡片列表/分页加载/状态标签/待支付去支付按钮)
 
 forge-h5-ui/src/components/hotel/

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 20 - 18
forge-server/forge-business/forge-hotel/酒店模块需求缺口清单.md


+ 1 - 1
forge-server/forge-business/forge-hotel/酒店订单支付系统开发文档.md

@@ -565,7 +565,7 @@ hotel:
   alipay:
     app-id: 9021000166699753                  # 沙箱 APPID
     gateway-url: https://openapi-sandbox.dl.alipaydev.com/gateway.do
-    notify-url: http://pc948923.natappfree.cc/hotel/open/pay/notify
+    notify-url: http://t2aaba9f.natappfree.cc/hotel/open/pay/notify
     sandbox: true
 ```