Przeglądaj źródła

修改GATEWAY白名单搜索模式,

lutong 1 miesiąc temu
rodzic
commit
67092cefcf

+ 60 - 0
SSE前后端使用说明书.md

@@ -140,6 +140,66 @@ sseService.pushCartUpdate(tableId, new CartDTO());
 
 ---
 
+## 线上环境部署说明(SSE 必读)
+
+项目发到线上时,SSE 长连接需要以下配置,否则容易出现连接被提前断开、收不到推送等问题。
+
+### 1. 网关(Spring Cloud Gateway)
+
+- **问题**:网关对后端响应有默认或全局 `response-timeout`,会主动断开长时间无“完成”的响应,导致 SSE 长连接被踢掉。
+- **做法**:对 SSE 路径**单独配一条路由**,并关闭该路由的响应超时(`response-timeout: -1`),且该路由需放在点餐通用路由**之前**,保证先匹配 SSE。
+- **配置示例**(加入 Nacos 中 `alien-gateway` 的网关路由配置,如 `alien-gateway.yml`):
+
+```yaml
+# 此条 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` 即可。
+
+### 2. Nginx / 反向代理(若存在)
+
+若网关前还有 Nginx(或其它反向代理),需避免对 SSE 做缓冲并拉长超时:
+
+- **关闭对 event-stream 的缓冲**:  
+  `proxy_buffering off;`(或对 `location ~ /aliendining/store/order/sse` 单独关闭)
+- **拉长读超时**:  
+  `proxy_read_timeout` 建议 ≥ 30 分钟(如 `1800s`),或略大于业务侧 SSE 超时(当前为 30 分钟)。
+- **可选**:  
+  `proxy_connect_timeout`、`proxy_send_timeout` 也可适当调大,避免代理层先断连。
+
+### 3. 多实例部署(alien-dining 多节点)
+
+- 当前 SSE 连接是**按实例内存**维护的(每个实例一份 `ConcurrentHashMap`)。同一桌号若被负载均衡到不同实例,只有“写操作发生的那台实例”上的 SSE 连接会收到推送,其它实例上的同桌连接收不到。
+- **建议**:
+  - **单实例**:无需改代码,按上面 1、2 配置即可。
+  - **多实例**:要么对该 SSE 路径做**会话保持**(同一桌号固定到同一实例),要么后续改造为基于 **Redis 等中间件** 的跨实例广播(需改 `SseServiceImpl` 与 Nacos/配置)。
+
+### 4. 前端线上地址
+
+- 线上建立 SSE 时,请使用**经过网关的完整路径**,例如:  
+  `https://你的域名/aliendining/store/order/sse/{tableId}`  
+  具体以你们网关的 `Path` 与 `StripPrefix` 为准,保证最终能路由到 `alien-dining` 的 `/store/order/sse/{tableId}`。
+- 若网关或 Nacos 中给点餐服务配了统一前缀(如 `/api`),则 SSE 地址中也要带上该前缀。
+
+### 5. 小结
+
+| 环境         | 必做项 |
+|--------------|--------|
+| 网关         | 为 SSE 路径单独路由并设置 `response-timeout: -1`,且路由顺序优先 |
+| Nginx/反向代理 | `proxy_buffering off`,`proxy_read_timeout` ≥ 30 分钟 |
+| 多实例       | 会话保持或改为 Redis 等跨实例推送 |
+| 前端         | 使用经网关的完整 SSE URL |
+
+---
+
 ## 三、前端实现说明
 
 ### 3.1 基础使用(原生JavaScript)

+ 30 - 1
alien-dining/doc/gateway-route-example.yml

@@ -1,11 +1,25 @@
 # 网关路由配置示例
 # 将此配置添加到 Nacos 的 alien-gateway.yml 配置文件中
+#
+# 若前端调用 /sse/{tableId} 出现 504 Gateway Timeout:
+# 原因:SSE 是长连接,网关默认响应超时(约 30s)会主动断开。
+# 解决:必须为 SSE 路径单独配一条路由,且 metadata.response-timeout: -1(见下)。
 
 spring:
   cloud:
     gateway:
       routes:
-        # 微信点餐模块路由配置
+        # 【重要】SSE 长连接路由:必须放在 aliendining 通用路由之前,并关闭响应超时
+        - id: aliendining-sse
+          uri: http://${route_or_local_ip}:30014
+          predicates:
+            - Path=/aliendining/store/order/sse/**
+          filters:
+            - StripPrefix=1
+          metadata:
+            response-timeout: -1   # -1 表示不超时,避免 SSE 长连接被网关提前断开
+
+        # 微信点餐模块通用路由
         - id: aliendining
           uri: http://${route_or_local_ip}:30014
           predicates:
@@ -13,3 +27,18 @@ spring:
           filters:
             - StripPrefix=1
 
+# ---------------------------------------------------------------------------
+# 网关白名单(免登录)配置
+# 在 Nacos 的 alien-gateway 使用的配置中增加 jwt.skip-auth-urls,以下路径不校验 Token。
+#
+# 示例:根据商铺ID查询店铺信息和首页展示美食价目表 GET /store/info/detail/{storeId}
+# - 经网关访问路径为:/aliendining/store/info/detail/{storeId}(以实际网关 Path 前缀为准)
+# - 支持前缀匹配:配置项以 ** 结尾表示该路径及其子路径均放行
+#
+# jwt:
+#   skip-auth-urls:
+#     - /aliendining/store/info/detail/**    # 店铺详情+首页价目表,无需登录
+#     - /aliendining/store/info/tables        # 精确匹配示例
+#     - /other/exact/path
+# ---------------------------------------------------------------------------
+

+ 31 - 3
alien-dining/src/main/java/shop/alien/dining/service/impl/SseServiceImpl.java

@@ -52,7 +52,11 @@ public class SseServiceImpl implements shop.alien.dining.service.SseService {
         });
 
         emitter.onError((ex) -> {
-            log.error("SSE连接错误, tableId={}, connectionId={}, error={}", tableId, connectionId, ex.getMessage(), ex);
+            if (isClientDisconnect(ex)) {
+                log.info("SSE客户端已断开, tableId={}, connectionId={}", tableId, connectionId);
+            } else {
+                log.error("SSE连接错误, tableId={}, connectionId={}, error={}", tableId, connectionId, ex.getMessage(), ex);
+            }
             removeConnection(tableId, connectionId);
         });
 
@@ -90,7 +94,11 @@ public class SseServiceImpl implements shop.alien.dining.service.SseService {
                         .data(messageJson));
                 log.info("推送购物车更新成功, tableId={}, connectionId={}", tableId, connectionId);
             } catch (IOException e) {
-                log.error("推送购物车更新失败, tableId={}, connectionId={}, error={}", tableId, connectionId, e.getMessage(), e);
+                if (isClientDisconnect(e)) {
+                    log.debug("推送时客户端已断开, tableId={}, connectionId={}", tableId, connectionId);
+                } else {
+                    log.error("推送购物车更新失败, tableId={}, connectionId={}, error={}", tableId, connectionId, e.getMessage(), e);
+                }
                 removeConnection(tableId, connectionId);
             }
         });
@@ -113,6 +121,22 @@ public class SseServiceImpl implements shop.alien.dining.service.SseService {
     }
 
     /**
+     * 判断是否为客户端主动断开(Broken pipe、Connection reset 等),此类情况属正常,无需打 ERROR。
+     */
+    private boolean isClientDisconnect(Throwable ex) {
+        if (ex == null) return false;
+        String msg = ex.getMessage();
+        if (msg != null) {
+            String lower = msg.toLowerCase();
+            if (lower.contains("broken pipe") || lower.contains("connection reset")
+                    || lower.contains("connection closed") || lower.contains("an established connection was aborted")) {
+                return true;
+            }
+        }
+        return isClientDisconnect(ex.getCause());
+    }
+
+    /**
      * 移除连接
      */
     private void removeConnection(Integer tableId, String connectionId) {
@@ -144,7 +168,11 @@ public class SseServiceImpl implements shop.alien.dining.service.SseService {
                             .data("ping"));
                 }
             } catch (IOException e) {
-                log.error("发送心跳失败, tableId={}, connectionId={}", tableId, connectionId, e);
+                if (isClientDisconnect(e)) {
+                    log.debug("心跳时客户端已断开, tableId={}, connectionId={}", tableId, connectionId);
+                } else {
+                    log.error("发送心跳失败, tableId={}, connectionId={}", tableId, connectionId, e);
+                }
                 removeConnection(tableId, connectionId);
             }
         }, 30, 30, TimeUnit.SECONDS); // 每30秒发送一次心跳

+ 26 - 3
alien-gateway/src/main/java/shop/alien/gateway/config/JwtTokenFilter.java

@@ -33,7 +33,6 @@ import shop.alien.util.common.JwtUtil;
 
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
-import java.util.Arrays;
 import java.util.List;
 import java.util.Objects;
 import java.util.stream.Collectors;
@@ -81,8 +80,8 @@ public class JwtTokenFilter implements GlobalFilter, Ordered {
         if (Objects.equals(exchange.getRequest().getMethod(), HttpMethod.OPTIONS)) {
             return allowChain(exchange, chain);
         }
-        //跳过不需要验证的路径
-        if (null != skipAuthUrls && Arrays.asList(skipAuthUrls).contains(url)) {
+        // 跳过不需要验证的路径(支持精确匹配与前缀匹配:配置项以 ** 结尾表示前缀白名单)
+        if (isSkipAuthUrl(url)) {
             return allowChain(exchange, chain);
         }
         if (null != skipAuthUrls && url.contains("/alienStore/socket/")) {
@@ -214,6 +213,30 @@ public class JwtTokenFilter implements GlobalFilter, Ordered {
         }
     }
 
+    /**
+     * 判断请求路径是否在白名单内(免登录)。
+     * 配置项以 "**" 结尾时做前缀匹配,否则做精确匹配。
+     * 例如:/aliendining/store/info/detail/** 可放行 /aliendining/store/info/detail/1、/aliendining/store/info/detail/123 等。
+     */
+    private boolean isSkipAuthUrl(String requestPath) {
+        if (skipAuthUrls == null || skipAuthUrls.length == 0) {
+            return false;
+        }
+        for (String skip : skipAuthUrls) {
+            if (skip == null) continue;
+            String trimmed = skip.trim();
+            if (trimmed.endsWith("**")) {
+                String prefix = trimmed.substring(0, trimmed.length() - 2);
+                if (requestPath.startsWith(prefix)) {
+                    return true;
+                }
+            } else if (requestPath.equals(trimmed)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
     private Mono<Void> allowChain(ServerWebExchange exchange, GatewayFilterChain chain) {
         return chain.filter(exchange).then(Mono.fromRunnable(() -> {
             exchange.getResponse().getHeaders().entrySet().stream()

+ 5 - 10
alien-store-platform/src/main/java/shop/alien/storeplatform/service/impl/StoreManageServiceImpl.java

@@ -456,22 +456,17 @@ public class StoreManageServiceImpl implements StoreManageService {
             }
         }
         
-        // 处理空集合字段,设置为 null
-        if (StringUtils.isNotEmpty(storeInfoDraft.getBusinessLicenseUrl()) 
-                && storeInfoDraft.getBusinessLicenseUrl().isEmpty()) {
+        // 将空字符串规范为 null,避免存空串
+        if (storeInfoDraft.getBusinessLicenseUrl() != null && storeInfoDraft.getBusinessLicenseUrl().trim().isEmpty()) {
             storeInfoDraft.setBusinessLicenseUrl(null);
         }
-        
-        if (StringUtils.isNotEmpty(storeInfoDraft.getContractUrl()) 
-                && storeInfoDraft.getContractUrl().isEmpty()) {
+        if (storeInfoDraft.getContractUrl() != null && storeInfoDraft.getContractUrl().trim().isEmpty()) {
             storeInfoDraft.setContractUrl(null);
         }
-        
-        if (StringUtils.isNotEmpty(storeInfoDraft.getFoodLicenceUrl()) 
-                && storeInfoDraft.getFoodLicenceUrl().isEmpty()) {
+        if (storeInfoDraft.getFoodLicenceUrl() != null && storeInfoDraft.getFoodLicenceUrl().trim().isEmpty()) {
             storeInfoDraft.setFoodLicenceUrl(null);
         }
-        
+
         int result = storeInfoDraftMapper.insert(storeInfoDraft);
         log.info("StoreManageServiceImpl.saveStoreDraft - 草稿保存完成,影响行数: {}", result);
         return result;