Browse Source

积分查询接口

yuhongqi 1 tuần trước cách đây
mục cha
commit
449b837dc9
25 tập tin đã thay đổi với 642 bổ sung156 xóa
  1. 2 2
      fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreOrderScrmController.java
  2. 4 0
      fs-live-ws/pom.xml
  3. 4 0
      fs-live-ws/src/main/java/com/fs/live/ws/config/LiveWsProperties.java
  4. 14 0
      fs-live-ws/src/main/java/com/fs/live/ws/config/TomcatWebSocketConfig.java
  5. 13 3
      fs-live-ws/src/main/java/com/fs/live/ws/controller/WsEndpointController.java
  6. 8 57
      fs-live-ws/src/main/java/com/fs/live/ws/handler/WsAuthHandler.java
  7. 16 9
      fs-live-ws/src/main/java/com/fs/live/ws/handler/WsLiveChatHandler.java
  8. 3 2
      fs-live-ws/src/main/java/com/fs/live/ws/netty/NettyWsServer.java
  9. 3 3
      fs-live-ws/src/main/java/com/fs/live/ws/service/ILiveWsConnectionService.java
  10. 2 2
      fs-live-ws/src/main/java/com/fs/live/ws/service/ILiveWsMessageService.java
  11. 4 6
      fs-live-ws/src/main/java/com/fs/live/ws/service/LiveWsRoomBroadcastFacade.java
  12. 7 10
      fs-live-ws/src/main/java/com/fs/live/ws/service/impl/LiveWsAdminMessageHandler.java
  13. 12 7
      fs-live-ws/src/main/java/com/fs/live/ws/service/impl/LiveWsBroadcastServiceImpl.java
  14. 6 7
      fs-live-ws/src/main/java/com/fs/live/ws/service/impl/LiveWsConnectionServiceImpl.java
  15. 8 16
      fs-live-ws/src/main/java/com/fs/live/ws/service/impl/LiveWsMessageServiceImpl.java
  16. 4 2
      fs-live-ws/src/main/java/com/fs/live/ws/service/impl/WsNodeRegistryServiceImpl.java
  17. 52 0
      fs-live-ws/src/main/java/com/fs/live/ws/session/ChannelWsSender.java
  18. 33 29
      fs-live-ws/src/main/java/com/fs/live/ws/session/LiveWsRoomManager.java
  19. 66 0
      fs-live-ws/src/main/java/com/fs/live/ws/session/SessionWsSender.java
  20. 13 0
      fs-live-ws/src/main/java/com/fs/live/ws/session/WsSender.java
  21. 124 0
      fs-live-ws/src/main/java/com/fs/live/ws/tomcat/TomcatLiveWsEndpoint.java
  22. 44 0
      fs-live-ws/src/main/java/com/fs/live/ws/tomcat/TomcatWsConfigurator.java
  23. 43 0
      fs-live-ws/src/main/resources/application.yml
  24. 4 1
      fs-user-app/src/main/java/com/fs/app/config/LiveWsClientProperties.java
  25. 153 0
      fs-user-app/src/main/java/com/fs/app/controller/live/LiveCartController.java

+ 2 - 2
fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreOrderScrmController.java

@@ -62,7 +62,7 @@ import com.fs.hisStore.domain.FsStoreOrderScrm;
 import com.fs.hisStore.domain.FsStoreOrderStatusScrm;
 import com.fs.hisStore.domain.FsStorePaymentScrm;
 import com.fs.hisStore.domain.FsStoreAfterSalesScrm;
-import com.fs.his.dto.ExpressInfoDTO;
+import com.fs.hisStore.dto.ExpressInfoDTO;
 import com.fs.hisStore.dto.FsStoreOrderPayDeliveryDTO;
 import com.fs.hisStore.dto.FsStoreOrderPayPostageEditDTO;
 import com.fs.hisStore.dto.StoreOrderExpressExportDTO;
@@ -106,7 +106,7 @@ import static com.fs.his.utils.PhoneUtil.encryptPhone;
 @RequestMapping("/store/store/storeOrder")
 public class FsStoreOrderScrmController extends BaseController {
     @Autowired
-    private IFsExpressService expressService;
+    private IFsExpressScrmService expressService;
     @Autowired
     private IFsStoreOrderScrmService fsStoreOrderService;
     @Autowired

+ 4 - 0
fs-live-ws/pom.xml

@@ -17,6 +17,10 @@
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-starter-web</artifactId>
         </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-websocket</artifactId>
+        </dependency>
         <dependency>
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-starter-aop</artifactId>

+ 4 - 0
fs-live-ws/src/main/java/com/fs/live/ws/config/LiveWsProperties.java

@@ -9,13 +9,17 @@ import org.springframework.stereotype.Component;
 @ConfigurationProperties(prefix = "live.ws")
 public class LiveWsProperties {
 
+    /** 是否开启 Netty WebSocket(默认开启) */
     private boolean enabled = true;
+    /** Netty WebSocket 端口(与 server.port=7114 均可连接) */
     private int nettyPort = 7116;
     private String path = "/ws/app/webSocket";
     private int maxConnectionsPerNode = 25000;
     private String nodeId = "fs-live-ws-local";
     private String publicHost = "127.0.0.1";
     private String publicScheme = "ws";
+    /** 对外推荐端口(默认 Tomcat 7114,也可改为 nettyPort) */
+    private int publicPort = 7114;
     private int heartbeatTimeoutSeconds = 120;
     private int nodeHeartbeatSeconds = 10;
     /** 直播间在线人数 userCount 广播间隔(秒) */

+ 14 - 0
fs-live-ws/src/main/java/com/fs/live/ws/config/TomcatWebSocketConfig.java

@@ -0,0 +1,14 @@
+package com.fs.live.ws.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.socket.server.standard.ServerEndpointExporter;
+
+@Configuration
+public class TomcatWebSocketConfig {
+
+    @Bean
+    public ServerEndpointExporter serverEndpointExporter() {
+        return new ServerEndpointExporter();
+    }
+}

+ 13 - 3
fs-live-ws/src/main/java/com/fs/live/ws/controller/WsEndpointController.java

@@ -6,6 +6,7 @@ import com.fs.live.ws.service.IWsNodeRegistryService;
 import io.swagger.annotations.Api;
 import io.swagger.annotations.ApiOperation;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestParam;
@@ -26,16 +27,21 @@ public class WsEndpointController {
     private IWsNodeRegistryService nodeRegistryService;
     @Autowired
     private LiveWsProperties properties;
+    @Value("${server.port:7114}")
+    private int serverPort;
 
     @ApiOperation("WebSocket 服务健康检查")
     @GetMapping("/wsHealth")
     public R wsHealth() {
         return R.ok()
-                .put("httpPort", 7115)
-                .put("wsPort", properties.getNettyPort())
+                .put("httpPort", serverPort)
+                .put("wsPort", properties.getPublicPort())
+                .put("nettyPort", properties.getNettyPort())
                 .put("wsPath", properties.getPath())
                 .put("wsEnabled", properties.isEnabled())
                 .put("wsUrlExample", buildWsUrl(properties.getPublicHost(),
+                        String.valueOf(properties.getPublicPort()), 0L).replace("liveId=0", "liveId={liveId}"))
+                .put("nettyWsUrlExample", buildWsUrl(properties.getPublicHost(),
                         String.valueOf(properties.getNettyPort()), 0L).replace("liveId=0", "liveId={liveId}"));
     }
 
@@ -44,7 +50,7 @@ public class WsEndpointController {
     public R wsEndpoint(@RequestParam Long liveId) {
         List<Map<String, Object>> nodes = nodeRegistryService.listActiveNodes();
         if (nodes.isEmpty()) {
-            return buildResponse(properties.getPublicHost(), String.valueOf(properties.getNettyPort()),
+            return buildResponse(properties.getPublicHost(), String.valueOf(properties.getPublicPort()),
                     properties.getNodeId(), liveId, null);
         }
 
@@ -58,6 +64,9 @@ public class WsEndpointController {
             Map<String, Object> node = sorted.get(i);
             fallback.add(buildWsUrl(String.valueOf(node.get("host")), String.valueOf(node.get("port")), liveId));
         }
+        // 同节点额外回退 Netty 端口
+        fallback.add(buildWsUrl(String.valueOf(best.get("host")),
+                String.valueOf(properties.getNettyPort()), liveId));
 
         return buildResponse(String.valueOf(best.get("host")), String.valueOf(best.get("port")),
                 String.valueOf(best.get("nodeId")), liveId, fallback);
@@ -66,6 +75,7 @@ public class WsEndpointController {
     private R buildResponse(String host, String port, String nodeId, Long liveId, List<String> fallback) {
         return R.ok()
                 .put("wsUrl", buildWsUrl(host, port, liveId))
+                .put("nettyWsUrl", buildWsUrl(host, String.valueOf(properties.getNettyPort()), liveId))
                 .put("nodeId", nodeId)
                 .put("fallback", fallback == null ? new ArrayList<>() : fallback);
     }

+ 8 - 57
fs-live-ws/src/main/java/com/fs/live/ws/handler/WsAuthHandler.java

@@ -1,9 +1,6 @@
 package com.fs.live.ws.handler;
 
 import com.fs.live.ws.constant.WsAttrConstant;
-import com.fs.live.ws.util.WsJwtUtils;
-import com.fs.live.ws.util.WsVerifyUtils;
-import io.jsonwebtoken.Claims;
 import io.netty.buffer.Unpooled;
 import io.netty.channel.ChannelFutureListener;
 import io.netty.channel.ChannelHandler;
@@ -19,21 +16,21 @@ import io.netty.handler.codec.http.QueryStringDecoder;
 import io.netty.util.CharsetUtil;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
-import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Component;
 
 import java.util.List;
 import java.util.Map;
 
+/**
+ * WebSocket 握手鉴权。
+ * 已关闭 token / signature 校验,仅校验 liveId、userId。
+ */
 @Component
 @ChannelHandler.Sharable
 public class WsAuthHandler extends ChannelInboundHandlerAdapter {
 
     private static final Logger log = LoggerFactory.getLogger(WsAuthHandler.class);
 
-    @Autowired
-    private WsJwtUtils wsJwtUtils;
-
     @Override
     public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
         if (!(msg instanceof FullHttpRequest)) {
@@ -51,43 +48,13 @@ public class WsAuthHandler extends ChannelInboundHandlerAdapter {
 
         Long liveId = Long.valueOf(params.get(WsAttrConstant.LIVE_ID).get(0));
         Long userId = Long.valueOf(params.get(WsAttrConstant.USER_ID).get(0));
-
-        String token = resolveToken(params);
-        boolean hasSignature = params.containsKey(WsAttrConstant.SIGNATURE)
-                && params.containsKey(WsAttrConstant.TIMESTAMP)
-                && params.containsKey(WsAttrConstant.USER_TYPE);
-
         long userType = 0L;
-        if (token != null) {
-            Claims claims = wsJwtUtils.getClaimByToken(token);
-            if (claims == null || wsJwtUtils.isTokenExpired(claims.getExpiration())) {
-                log.warn("WS 鉴权失败 liveId={}, userId={}", liveId, userId);
-                reject(ctx, req, HttpResponseStatus.UNAUTHORIZED, "token 无效");
-                return;
-            }
-            if (params.containsKey(WsAttrConstant.USER_TYPE)) {
-                userType = Long.parseLong(params.get(WsAttrConstant.USER_TYPE).get(0));
-            }
-        } else if (hasSignature) {
+        if (params.containsKey(WsAttrConstant.USER_TYPE)) {
             try {
-                String userTypeStr = params.get(WsAttrConstant.USER_TYPE).get(0);
-                String timestampStr = params.get(WsAttrConstant.TIMESTAMP).get(0);
-                String signatureStr = params.get(WsAttrConstant.SIGNATURE).get(0);
-                if (!WsVerifyUtils.verifySignature(liveId.toString(), userId.toString(),
-                        userTypeStr, timestampStr, signatureStr)) {
-                    log.warn("WS 签名鉴权失败 liveId={}, userId={}", liveId, userId);
-                    reject(ctx, req, HttpResponseStatus.UNAUTHORIZED, "signature 无效");
-                    return;
-                }
-                userType = Long.parseLong(userTypeStr);
-            } catch (Exception ex) {
-                log.warn("WS 签名鉴权异常 liveId={}, userId={}", liveId, userId, ex);
-                reject(ctx, req, HttpResponseStatus.UNAUTHORIZED, "signature 无效");
-                return;
+                userType = Long.parseLong(params.get(WsAttrConstant.USER_TYPE).get(0));
+            } catch (NumberFormatException ex) {
+                log.warn("WS userType 解析失败,默认按观众处理 liveId={}, userId={}", liveId, userId);
             }
-        } else {
-            reject(ctx, req, HttpResponseStatus.UNAUTHORIZED, "缺少 token 或 signature");
-            return;
         }
 
         ctx.channel().attr(WsAttrConstant.ATTR_LIVE_ID).set(liveId);
@@ -102,22 +69,6 @@ public class WsAuthHandler extends ChannelInboundHandlerAdapter {
         req.release();
     }
 
-    /** 兼容 APPToken(小程序 WS)与 AppToken(HTTP 配置项)两种 query 参数名 */
-    private String resolveToken(Map<String, List<String>> params) {
-        String configured = wsJwtUtils.getHeader();
-        String[] keys = {WsAttrConstant.TOKEN, "AppToken", configured};
-        for (String key : keys) {
-            if (key == null || key.isEmpty()) {
-                continue;
-            }
-            List<String> values = params.get(key);
-            if (values != null && !values.isEmpty() && values.get(0) != null && !values.get(0).isEmpty()) {
-                return values.get(0);
-            }
-        }
-        return null;
-    }
-
     /** 握手完成前必须返回 HTTP 响应,否则客户端会一直 pending */
     private void reject(ChannelHandlerContext ctx, FullHttpRequest req, HttpResponseStatus status, String reason) {
         byte[] body = reason.getBytes(CharsetUtil.UTF_8);

+ 16 - 9
fs-live-ws/src/main/java/com/fs/live/ws/handler/WsLiveChatHandler.java

@@ -3,9 +3,10 @@ package com.fs.live.ws.handler;
 import com.fs.live.ws.constant.WsAttrConstant;
 import com.fs.live.ws.service.ILiveWsConnectionService;
 import com.fs.live.ws.service.ILiveWsMessageService;
+import com.fs.live.ws.session.ChannelWsSender;
+import com.fs.live.ws.session.WsSender;
 import io.netty.channel.Channel;
 import io.netty.channel.ChannelHandler;
-import io.netty.channel.ChannelFutureListener;
 import io.netty.channel.ChannelHandlerContext;
 import io.netty.channel.SimpleChannelInboundHandler;
 import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
@@ -39,20 +40,20 @@ public class WsLiveChatHandler extends SimpleChannelInboundHandler<TextWebSocket
             Long userId = channel.attr(WsAttrConstant.ATTR_USER_ID).get();
             Long userType = channel.attr(WsAttrConstant.ATTR_USER_TYPE).get();
             String location = channel.attr(WsAttrConstant.ATTR_LOCATION).get();
+            WsSender sender = new ChannelWsSender(channel);
 
             if (!connectionService.tryAcceptConnection()) {
-                channel.writeAndFlush(new TextWebSocketFrame("Error: 4503 节点连接已满"))
-                        .addListener(ChannelFutureListener.CLOSE);
+                sender.sendText("Error: 4503 节点连接已满");
+                channel.close();
                 return;
             }
-            // 握手已完成(101),DB/Redis 逻辑放业务线程,避免阻塞 Netty IO 线程
             connectionExecutor.execute(() -> {
                 try {
-                    connectionService.onConnected(liveId, userId, userType, channel, location);
+                    connectionService.onConnected(liveId, userId, userType, sender, location);
                 } catch (Exception ex) {
                     log.warn("WS 连接建立失败 liveId={}, userId={}", liveId, userId, ex);
-                    channel.writeAndFlush(new TextWebSocketFrame("Error: " + ex.getMessage()))
-                            .addListener(ChannelFutureListener.CLOSE);
+                    sender.sendText("Error: " + ex.getMessage());
+                    channel.close();
                 }
             });
             return;
@@ -62,7 +63,13 @@ public class WsLiveChatHandler extends SimpleChannelInboundHandler<TextWebSocket
 
     @Override
     protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) {
-        messageService.handleTextMessage(ctx.channel(), frame.text());
+        Channel channel = ctx.channel();
+        messageService.handleTextMessage(
+                new ChannelWsSender(channel),
+                channel.attr(WsAttrConstant.ATTR_LIVE_ID).get(),
+                channel.attr(WsAttrConstant.ATTR_USER_ID).get(),
+                channel.attr(WsAttrConstant.ATTR_USER_TYPE).get(),
+                frame.text());
     }
 
     @Override
@@ -72,7 +79,7 @@ public class WsLiveChatHandler extends SimpleChannelInboundHandler<TextWebSocket
         Long userId = channel.attr(WsAttrConstant.ATTR_USER_ID).get();
         Long userType = channel.attr(WsAttrConstant.ATTR_USER_TYPE).get();
         if (liveId != null) {
-            connectionService.onDisconnected(liveId, userId, userType, channel);
+            connectionService.onDisconnected(liveId, userId, userType, new ChannelWsSender(channel));
         }
     }
 

+ 3 - 2
fs-live-ws/src/main/java/com/fs/live/ws/netty/NettyWsServer.java

@@ -79,8 +79,9 @@ public class NettyWsServer {
                         }
                     });
             serverChannel = bootstrap.bind(properties.getNettyPort()).sync().channel();
-            log.info("Netty WebSocket 已启动: ws://{}:{}{} (HTTP 接口在 server.port=7115,勿混用)",
-                    properties.getPublicHost(), properties.getNettyPort(), properties.getPath());
+            log.info("Netty WebSocket 已启动: ws://{}:{}{} (Tomcat HTTP/WS 在 server.port={}{},两端口均可连接)",
+                    properties.getPublicHost(), properties.getNettyPort(), properties.getPath(),
+                    properties.getPublicPort(), properties.getPath());
             serverChannel.closeFuture().sync();
         } catch (Exception ex) {
             log.error("Netty WS 启动失败", ex);

+ 3 - 3
fs-live-ws/src/main/java/com/fs/live/ws/service/ILiveWsConnectionService.java

@@ -1,13 +1,13 @@
 package com.fs.live.ws.service;
 
-import io.netty.channel.Channel;
+import com.fs.live.ws.session.WsSender;
 
 public interface ILiveWsConnectionService {
 
     /** @return true 允许连接;false 节点已满 */
     boolean tryAcceptConnection();
 
-    void onConnected(Long liveId, Long userId, Long userType, Channel channel, String location);
+    void onConnected(Long liveId, Long userId, Long userType, WsSender sender, String location);
 
-    void onDisconnected(Long liveId, Long userId, Long userType, Channel channel);
+    void onDisconnected(Long liveId, Long userId, Long userType, WsSender sender);
 }

+ 2 - 2
fs-live-ws/src/main/java/com/fs/live/ws/service/ILiveWsMessageService.java

@@ -1,8 +1,8 @@
 package com.fs.live.ws.service;
 
-import io.netty.channel.Channel;
+import com.fs.live.ws.session.WsSender;
 
 public interface ILiveWsMessageService {
 
-    void handleTextMessage(Channel channel, String payload);
+    void handleTextMessage(WsSender sender, Long liveId, Long userId, Long userType, String payload);
 }

+ 4 - 6
fs-live-ws/src/main/java/com/fs/live/ws/service/LiveWsRoomBroadcastFacade.java

@@ -6,10 +6,9 @@ import com.fs.live.domain.LiveAutoTask;
 import com.fs.live.ws.bean.WsSendMsgVo;
 import com.fs.live.ws.service.impl.LiveWsAutoTaskHandler;
 import com.fs.live.ws.session.LiveWsRoomManager;
+import com.fs.live.ws.session.WsSender;
 import com.fs.live.ws.task.LiveWsLikeBroadcastTask;
 import com.fs.live.ws.task.LiveWsUserCountBroadcastTask;
-import io.netty.channel.Channel;
-import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
@@ -44,8 +43,8 @@ public class LiveWsRoomBroadcastFacade {
     }
 
     public void sendIntegralMessage(Long liveId, Long userId, Long scoreAmount) {
-        Channel channel = roomManager.getUserChannel(liveId, userId);
-        if (channel == null || !channel.isActive()) {
+        WsSender sender = roomManager.getUserSender(liveId, userId);
+        if (sender == null || !sender.isActive()) {
             return;
         }
         WsSendMsgVo sendMsgVo = new WsSendMsgVo();
@@ -55,7 +54,6 @@ public class LiveWsRoomBroadcastFacade {
         sendMsgVo.setCmd("Integral");
         sendMsgVo.setMsg("恭喜你成功获得观看奖励:" + scoreAmount + "积分");
         sendMsgVo.setData(String.valueOf(scoreAmount));
-        channel.writeAndFlush(new TextWebSocketFrame(
-                JSONObject.toJSONString(R.ok().put("data", sendMsgVo))));
+        sender.sendText(JSONObject.toJSONString(R.ok().put("data", sendMsgVo)));
     }
 }

+ 7 - 10
fs-live-ws/src/main/java/com/fs/live/ws/service/impl/LiveWsAdminMessageHandler.java

@@ -19,9 +19,8 @@ import com.fs.live.service.*;
 import com.fs.live.vo.LiveGoodsVo;
 import com.fs.live.ws.bean.WsSendMsgVo;
 import com.fs.live.ws.service.ILiveWsBroadcastService;
+import com.fs.live.ws.session.WsSender;
 import com.fs.sensitive.ProductionWordFilter;
-import io.netty.channel.Channel;
-import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -63,7 +62,7 @@ public class LiveWsAdminMessageHandler {
     @Autowired(required = false)
     private IFsCourseQuestionBankService fsCourseQuestionBankService;
 
-    public boolean handle(Channel channel, Long liveId, WsSendMsgVo msg) {
+    public boolean handle(WsSender sender, Long liveId, WsSendMsgVo msg) {
         if (msg == null || StringUtils.isEmpty(msg.getCmd())) {
             return false;
         }
@@ -94,7 +93,7 @@ public class LiveWsAdminMessageHandler {
                     handleDeleteMsg(liveId, msg);
                     return true;
                 case "replyUser":
-                    handleReplyUser(channel, liveId, msg);
+                    handleReplyUser(sender, liveId, msg);
                     return true;
                 case "red":
                     handleRed(liveId, msg);
@@ -239,7 +238,7 @@ public class LiveWsAdminMessageHandler {
         broadcastService.broadcastToRoom(liveId, JSONObject.toJSONString(R.ok().put("data", sendMsgVo)));
     }
 
-    private void handleReplyUser(Channel adminChannel, Long liveId, WsSendMsgVo msg) {
+    private void handleReplyUser(WsSender adminSender, Long liveId, WsSendMsgVo msg) {
         if (msg.getUserId() == null || StringUtils.isEmpty(msg.getMsg())) {
             return;
         }
@@ -260,13 +259,11 @@ public class LiveWsAdminMessageHandler {
 
         String payload = JSONObject.toJSONString(R.ok().put("data", replyVo));
         boolean sent = broadcastService.sendToUser(liveId, targetUserId, payload);
-        if (adminChannel != null && adminChannel.isActive()) {
+        if (adminSender != null && adminSender.isActive()) {
             if (sent) {
-                adminChannel.writeAndFlush(new TextWebSocketFrame(
-                        JSONObject.toJSONString(R.ok().put("msg", "回复已发送"))));
+                adminSender.sendText(JSONObject.toJSONString(R.ok().put("msg", "回复已发送")));
             } else {
-                adminChannel.writeAndFlush(new TextWebSocketFrame(
-                        JSONObject.toJSONString(R.error("用户不在线,无法回复"))));
+                adminSender.sendText(JSONObject.toJSONString(R.error("用户不在线,无法回复")));
             }
         }
     }

+ 12 - 7
fs-live-ws/src/main/java/com/fs/live/ws/service/impl/LiveWsBroadcastServiceImpl.java

@@ -3,14 +3,15 @@ package com.fs.live.ws.service.impl;
 import com.fs.live.ws.constant.WsRedisKeys;
 import com.fs.live.ws.service.ILiveWsBroadcastService;
 import com.fs.live.ws.session.LiveWsRoomManager;
-import io.netty.channel.group.ChannelGroup;
-import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
+import com.fs.live.ws.session.WsSender;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.data.redis.core.StringRedisTemplate;
 import org.springframework.stereotype.Service;
 
+import java.util.List;
+
 @Service
 public class LiveWsBroadcastServiceImpl implements ILiveWsBroadcastService {
 
@@ -38,11 +39,15 @@ public class LiveWsBroadcastServiceImpl implements ILiveWsBroadcastService {
 
     @Override
     public void broadcastLocal(Long liveId, String message) {
-        ChannelGroup group = roomManager.getRoomGroup(liveId);
+        List<WsSender> group = roomManager.getRoomGroup(liveId);
         if (group == null || group.isEmpty()) {
             return;
         }
-        group.writeAndFlush(new TextWebSocketFrame(message));
+        for (WsSender sender : group) {
+            if (sender != null && sender.isActive()) {
+                sender.sendText(message);
+            }
+        }
     }
 
     @Override
@@ -50,11 +55,11 @@ public class LiveWsBroadcastServiceImpl implements ILiveWsBroadcastService {
         if (liveId == null || userId == null || message == null) {
             return false;
         }
-        io.netty.channel.Channel channel = roomManager.getUserChannel(liveId, userId);
-        if (channel == null || !channel.isActive()) {
+        WsSender sender = roomManager.getUserSender(liveId, userId);
+        if (sender == null || !sender.isActive()) {
             return false;
         }
-        channel.writeAndFlush(new TextWebSocketFrame(message));
+        sender.sendText(message);
         return true;
     }
 }

+ 6 - 7
fs-live-ws/src/main/java/com/fs/live/ws/service/impl/LiveWsConnectionServiceImpl.java

@@ -16,13 +16,12 @@ import com.fs.live.ws.constant.WsRedisKeys;
 import com.fs.live.ws.service.ILiveWsBroadcastService;
 import com.fs.live.ws.service.ILiveWsConnectionService;
 import com.fs.live.ws.session.LiveWsRoomManager;
-import io.netty.channel.Channel;
+import com.fs.live.ws.session.WsSender;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
-import java.util.Objects;
 import java.util.concurrent.TimeUnit;
 
 @Service
@@ -51,7 +50,7 @@ public class LiveWsConnectionServiceImpl implements ILiveWsConnectionService {
     }
 
     @Override
-    public void onConnected(Long liveId, Long userId, Long userType, Channel channel, String location) {
+    public void onConnected(Long liveId, Long userId, Long userType, WsSender sender, String location) {
         roomManager.incrementAndGetConnections();
         Live live = liveService.selectLiveByLiveId(liveId);
         if (live == null) {
@@ -66,7 +65,7 @@ public class LiveWsConnectionServiceImpl implements ILiveWsConnectionService {
                 throw new IllegalStateException("用户信息错误");
             }
             LiveWatchUser liveWatchUser = liveWatchUserService.join(fsUser, liveId, userId, location);
-            roomManager.bindUser(liveId, userId, channel);
+            roomManager.bindUser(liveId, userId, sender);
 
             String entryTimeKey = String.format(WsRedisKeys.USER_ENTRY_TIME_KEY, liveId, userId);
             if (redisCache.getCacheObject(entryTimeKey) == null) {
@@ -78,12 +77,12 @@ public class LiveWsConnectionServiceImpl implements ILiveWsConnectionService {
             WsSendMsgVo sendMsgVo = buildEntryMsg(liveId, userId, userType, liveWatchUser, fsUser);
             broadcastService.broadcastToRoom(liveId, JSONObject.toJSONString(R.ok().put("data", sendMsgVo)));
         } else {
-            roomManager.bindAdmin(liveId, channel);
+            roomManager.bindAdmin(liveId, sender);
         }
     }
 
     @Override
-    public void onDisconnected(Long liveId, Long userId, Long userType, Channel channel) {
+    public void onDisconnected(Long liveId, Long userId, Long userType, WsSender sender) {
         try {
             if (userType != null && userType == 0L && userId != null) {
                 FsUserScrm fsUser = fsUserService.selectFsUserById(userId);
@@ -102,7 +101,7 @@ public class LiveWsConnectionServiceImpl implements ILiveWsConnectionService {
                 }
             }
         } finally {
-            roomManager.unbind(liveId, userId, userType, channel);
+            roomManager.unbind(liveId, userId, userType, sender);
             roomManager.decrementAndGetConnections();
         }
     }

+ 8 - 16
fs-live-ws/src/main/java/com/fs/live/ws/service/impl/LiveWsMessageServiceImpl.java

@@ -3,19 +3,15 @@ package com.fs.live.ws.service.impl;
 import com.alibaba.fastjson.JSONObject;
 import com.fs.common.core.domain.R;
 import com.fs.common.utils.StringUtils;
-import com.fs.hisStore.domain.FsUserScrm;
-import com.fs.hisStore.service.IFsUserScrmService;
 import com.fs.live.domain.LiveMsg;
 import com.fs.live.domain.LiveWatchUser;
 import com.fs.live.service.ILiveMsgService;
 import com.fs.live.service.ILiveWatchUserService;
 import com.fs.live.ws.bean.WsSendMsgVo;
-import com.fs.live.ws.constant.WsAttrConstant;
 import com.fs.live.ws.service.ILiveWsBroadcastService;
 import com.fs.live.ws.service.ILiveWsMessageService;
+import com.fs.live.ws.session.WsSender;
 import com.fs.sensitive.ProductionWordFilter;
-import io.netty.channel.Channel;
-import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -41,10 +37,8 @@ public class LiveWsMessageServiceImpl implements ILiveWsMessageService {
     private LiveWsAdminMessageHandler adminMessageHandler;
 
     @Override
-    public void handleTextMessage(Channel channel, String payload) {
-        Long liveId = channel.attr(WsAttrConstant.ATTR_LIVE_ID).get();
-        Long userType = channel.attr(WsAttrConstant.ATTR_USER_TYPE).get();
-        if (liveId == null) {
+    public void handleTextMessage(WsSender sender, Long liveId, Long userId, Long userType, String payload) {
+        if (liveId == null || sender == null) {
             return;
         }
         try {
@@ -58,15 +52,14 @@ public class LiveWsMessageServiceImpl implements ILiveWsMessageService {
             }
             switch (msg.getCmd()) {
                 case "heartbeat":
-                    channel.writeAndFlush(new TextWebSocketFrame(
-                            JSONObject.toJSONString(R.ok().put("data", msg))));
+                    sender.sendText(JSONObject.toJSONString(R.ok().put("data", msg)));
                     break;
                 case "sendMsg":
-                    handleSendMsg(channel, liveId, userType, msg);
+                    handleSendMsg(sender, liveId, userType, msg);
                     break;
                 default:
                     if (userType != null && userType == 1L
-                            && adminMessageHandler.handle(channel, liveId, msg)) {
+                            && adminMessageHandler.handle(sender, liveId, msg)) {
                         break;
                     }
                     log.debug("WS 忽略未知 cmd={} liveId={} userType={}", msg.getCmd(), liveId, userType);
@@ -77,7 +70,7 @@ public class LiveWsMessageServiceImpl implements ILiveWsMessageService {
         }
     }
 
-    private void handleSendMsg(Channel channel, Long liveId, Long userType, WsSendMsgVo msg) {
+    private void handleSendMsg(WsSender sender, Long liveId, Long userType, WsSendMsgVo msg) {
         if (productionWordFilter != null && StringUtils.isNotEmpty(msg.getMsg())) {
             msg.setMsg(productionWordFilter.filter(msg.getMsg()).getFilteredText());
         }
@@ -103,8 +96,7 @@ public class LiveWsMessageServiceImpl implements ILiveWsMessageService {
             LiveWatchUser liveWatchUser = liveWatchUserService.selectLiveWatchUserByFlag(
                     liveMsg.getLiveId(), msg.getUserId(), liveFlag, replayFlag);
             if (liveWatchUser != null && liveWatchUser.getMsgStatus() != null && liveWatchUser.getMsgStatus() == 1) {
-                channel.writeAndFlush(new TextWebSocketFrame(
-                        JSONObject.toJSONString(R.error("你已被禁言"))));
+                sender.sendText(JSONObject.toJSONString(R.error("你已被禁言")));
                 return;
             }
             liveMsgService.insertLiveMsg(liveMsg);

+ 4 - 2
fs-live-ws/src/main/java/com/fs/live/ws/service/impl/WsNodeRegistryServiceImpl.java

@@ -32,7 +32,9 @@ public class WsNodeRegistryServiceImpl implements IWsNodeRegistryService {
         Map<String, Object> node = new HashMap<>(8);
         node.put("nodeId", properties.getNodeId());
         node.put("host", properties.getPublicHost());
-        node.put("port", properties.getNettyPort());
+        // 对外优先宣传 Tomcat 7114;Netty 端口作为备用
+        node.put("port", properties.getPublicPort());
+        node.put("nettyPort", properties.getNettyPort());
         node.put("connections", roomManager.getConnectionCount());
         node.put("maxConnections", properties.getMaxConnectionsPerNode());
         node.put("updateTime", System.currentTimeMillis());
@@ -79,7 +81,7 @@ public class WsNodeRegistryServiceImpl implements IWsNodeRegistryService {
             }
         }
         if (best == null) {
-            return properties.getPublicHost() + ":" + properties.getNettyPort();
+            return properties.getPublicHost() + ":" + properties.getPublicPort();
         }
         return String.valueOf(best.get("host")) + ":" + best.get("port");
     }

+ 52 - 0
fs-live-ws/src/main/java/com/fs/live/ws/session/ChannelWsSender.java

@@ -0,0 +1,52 @@
+package com.fs.live.ws.session;
+
+import io.netty.channel.Channel;
+import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
+
+import java.util.Objects;
+
+public class ChannelWsSender implements WsSender {
+
+    private final Channel channel;
+
+    public ChannelWsSender(Channel channel) {
+        this.channel = Objects.requireNonNull(channel, "channel");
+    }
+
+    public Channel getChannel() {
+        return channel;
+    }
+
+    @Override
+    public void sendText(String text) {
+        if (channel.isActive()) {
+            channel.writeAndFlush(new TextWebSocketFrame(text));
+        }
+    }
+
+    @Override
+    public boolean isActive() {
+        return channel.isActive();
+    }
+
+    @Override
+    public void close() {
+        channel.close();
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) {
+            return true;
+        }
+        if (!(o instanceof ChannelWsSender)) {
+            return false;
+        }
+        return channel.equals(((ChannelWsSender) o).channel);
+    }
+
+    @Override
+    public int hashCode() {
+        return channel.hashCode();
+    }
+}

+ 33 - 29
fs-live-ws/src/main/java/com/fs/live/ws/session/LiveWsRoomManager.java

@@ -1,11 +1,8 @@
 package com.fs.live.ws.session;
 
-import io.netty.channel.Channel;
-import io.netty.channel.group.ChannelGroup;
-import io.netty.channel.group.DefaultChannelGroup;
-import io.netty.util.concurrent.GlobalEventExecutor;
 import org.springframework.stereotype.Component;
 
+import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
@@ -13,15 +10,15 @@ import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.atomic.AtomicInteger;
 
 /**
- * 本节点 WebSocket 连接注册表(仅本机 Channel,跨节点靠 Redis Pub/Sub)。
+ * 本节点 WebSocket 连接注册表(兼容 Netty Channel / Tomcat Session,跨节点靠 Redis Pub/Sub)。
  */
 @Component
 public class LiveWsRoomManager {
 
     private final AtomicInteger connectionCount = new AtomicInteger(0);
-    private final ConcurrentHashMap<Long, ConcurrentHashMap<Long, Channel>> userRooms = new ConcurrentHashMap<>();
-    private final ConcurrentHashMap<Long, CopyOnWriteArrayList<Channel>> adminRooms = new ConcurrentHashMap<>();
-    private final ConcurrentHashMap<Long, ChannelGroup> roomGroups = new ConcurrentHashMap<>();
+    private final ConcurrentHashMap<Long, ConcurrentHashMap<Long, WsSender>> userRooms = new ConcurrentHashMap<>();
+    private final ConcurrentHashMap<Long, CopyOnWriteArrayList<WsSender>> adminRooms = new ConcurrentHashMap<>();
+    private final ConcurrentHashMap<Long, CopyOnWriteArrayList<WsSender>> roomGroups = new ConcurrentHashMap<>();
 
     public int incrementAndGetConnections() {
         return connectionCount.incrementAndGet();
@@ -35,63 +32,70 @@ public class LiveWsRoomManager {
         return connectionCount.get();
     }
 
-    public ConcurrentHashMap<Long, Channel> getUserRoom(Long liveId) {
+    public ConcurrentHashMap<Long, WsSender> getUserRoom(Long liveId) {
         return userRooms.computeIfAbsent(liveId, k -> new ConcurrentHashMap<>());
     }
 
-    public CopyOnWriteArrayList<Channel> getAdminRoom(Long liveId) {
+    public CopyOnWriteArrayList<WsSender> getAdminRoom(Long liveId) {
         return adminRooms.computeIfAbsent(liveId, k -> new CopyOnWriteArrayList<>());
     }
 
-    public ChannelGroup getRoomGroup(Long liveId) {
-        return roomGroups.computeIfAbsent(liveId, k -> new DefaultChannelGroup(GlobalEventExecutor.INSTANCE));
+    public CopyOnWriteArrayList<WsSender> getRoomGroup(Long liveId) {
+        return roomGroups.computeIfAbsent(liveId, k -> new CopyOnWriteArrayList<>());
     }
 
     public int getUserOnlineCount(Long liveId) {
-        ConcurrentHashMap<Long, Channel> room = userRooms.get(liveId);
+        ConcurrentHashMap<Long, WsSender> room = userRooms.get(liveId);
         return room == null ? 0 : room.size();
     }
 
     public Set<Long> getActiveLiveIds() {
-        return userRooms.keySet();
+        Set<Long> ids = new HashSet<>();
+        ids.addAll(userRooms.keySet());
+        ids.addAll(roomGroups.keySet());
+        return ids;
     }
 
-    public Channel getUserChannel(Long liveId, Long userId) {
-        ConcurrentHashMap<Long, Channel> room = userRooms.get(liveId);
+    public WsSender getUserSender(Long liveId, Long userId) {
+        ConcurrentHashMap<Long, WsSender> room = userRooms.get(liveId);
         return room == null ? null : room.get(userId);
     }
 
-    public void bindUser(Long liveId, Long userId, Channel channel) {
-        getUserRoom(liveId).put(userId, channel);
-        getRoomGroup(liveId).add(channel);
+    public void bindUser(Long liveId, Long userId, WsSender sender) {
+        getUserRoom(liveId).put(userId, sender);
+        CopyOnWriteArrayList<WsSender> group = getRoomGroup(liveId);
+        group.remove(sender);
+        group.add(sender);
     }
 
-    public void bindAdmin(Long liveId, Channel channel) {
-        getAdminRoom(liveId).add(channel);
-        getRoomGroup(liveId).add(channel);
+    public void bindAdmin(Long liveId, WsSender sender) {
+        getAdminRoom(liveId).add(sender);
+        CopyOnWriteArrayList<WsSender> group = getRoomGroup(liveId);
+        group.remove(sender);
+        group.add(sender);
     }
 
-    public void unbind(Long liveId, Long userId, Long userType, Channel channel) {
+    public void unbind(Long liveId, Long userId, Long userType, WsSender sender) {
         if (userType != null && userType == 0L && userId != null) {
-            Map<Long, Channel> room = userRooms.get(liveId);
+            Map<Long, WsSender> room = userRooms.get(liveId);
             if (room != null) {
-                room.remove(userId);
+                room.remove(userId, sender);
                 if (room.isEmpty()) {
                     userRooms.remove(liveId);
                 }
             }
         } else {
-            CopyOnWriteArrayList<Channel> admins = adminRooms.get(liveId);
+            CopyOnWriteArrayList<WsSender> admins = adminRooms.get(liveId);
             if (admins != null) {
-                admins.remove(channel);
+                admins.remove(sender);
                 if (admins.isEmpty()) {
                     adminRooms.remove(liveId);
                 }
             }
         }
-        ChannelGroup group = roomGroups.get(liveId);
+        CopyOnWriteArrayList<WsSender> group = roomGroups.get(liveId);
         if (group != null) {
-            group.remove(channel);
+            group.remove(sender);
             if (group.isEmpty()) {
                 roomGroups.remove(liveId);
             }

+ 66 - 0
fs-live-ws/src/main/java/com/fs/live/ws/session/SessionWsSender.java

@@ -0,0 +1,66 @@
+package com.fs.live.ws.session;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.websocket.Session;
+import java.util.Objects;
+
+public class SessionWsSender implements WsSender {
+
+    private static final Logger log = LoggerFactory.getLogger(SessionWsSender.class);
+
+    private final Session session;
+
+    public SessionWsSender(Session session) {
+        this.session = Objects.requireNonNull(session, "session");
+    }
+
+    public Session getSession() {
+        return session;
+    }
+
+    @Override
+    public void sendText(String text) {
+        if (!session.isOpen()) {
+            return;
+        }
+        try {
+            synchronized (session) {
+                session.getBasicRemote().sendText(text);
+            }
+        } catch (Exception ex) {
+            log.warn("Tomcat WS 发送失败 sessionId={}", session.getId(), ex);
+        }
+    }
+
+    @Override
+    public boolean isActive() {
+        return session.isOpen();
+    }
+
+    @Override
+    public void close() {
+        try {
+            session.close();
+        } catch (Exception ignored) {
+            // ignore
+        }
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) {
+            return true;
+        }
+        if (!(o instanceof SessionWsSender)) {
+            return false;
+        }
+        return session.getId().equals(((SessionWsSender) o).session.getId());
+    }
+
+    @Override
+    public int hashCode() {
+        return session.getId().hashCode();
+    }
+}

+ 13 - 0
fs-live-ws/src/main/java/com/fs/live/ws/session/WsSender.java

@@ -0,0 +1,13 @@
+package com.fs.live.ws.session;
+
+/**
+ * WebSocket 出站抽象,兼容 Netty Channel 与 Tomcat Session。
+ */
+public interface WsSender {
+
+    void sendText(String text);
+
+    boolean isActive();
+
+    void close();
+}

+ 124 - 0
fs-live-ws/src/main/java/com/fs/live/ws/tomcat/TomcatLiveWsEndpoint.java

@@ -0,0 +1,124 @@
+package com.fs.live.ws.tomcat;
+
+import com.fs.live.ws.constant.WsAttrConstant;
+import com.fs.live.ws.service.ILiveWsConnectionService;
+import com.fs.live.ws.service.ILiveWsMessageService;
+import com.fs.live.ws.session.SessionWsSender;
+import com.fs.live.ws.session.WsSender;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+import org.springframework.stereotype.Component;
+
+import javax.websocket.OnClose;
+import javax.websocket.OnError;
+import javax.websocket.OnMessage;
+import javax.websocket.OnOpen;
+import javax.websocket.Session;
+import javax.websocket.server.ServerEndpoint;
+import java.util.Map;
+
+/**
+ * Tomcat(默认 7114) 上的 WebSocket 端点,与 Netty 端口共用同一业务层。
+ * Tomcat 每次连接会 new 实例,业务依赖通过静态字段注入。
+ */
+@Component
+@ServerEndpoint(value = "/ws/app/webSocket", configurator = TomcatWsConfigurator.class)
+public class TomcatLiveWsEndpoint {
+
+    private static final Logger log = LoggerFactory.getLogger(TomcatLiveWsEndpoint.class);
+
+    private static ILiveWsConnectionService connectionService;
+    private static ILiveWsMessageService messageService;
+    private static ThreadPoolTaskExecutor connectionExecutor;
+
+    @Autowired
+    public void setConnectionService(ILiveWsConnectionService connectionService) {
+        TomcatLiveWsEndpoint.connectionService = connectionService;
+    }
+
+    @Autowired
+    public void setMessageService(ILiveWsMessageService messageService) {
+        TomcatLiveWsEndpoint.messageService = messageService;
+    }
+
+    @Autowired
+    @Qualifier("threadPoolTaskExecutor")
+    public void setConnectionExecutor(ThreadPoolTaskExecutor connectionExecutor) {
+        TomcatLiveWsEndpoint.connectionExecutor = connectionExecutor;
+    }
+
+    @OnOpen
+    public void onOpen(Session session) {
+        Map<String, Object> props = session.getUserProperties();
+        Long liveId = (Long) props.get(WsAttrConstant.LIVE_ID);
+        Long userId = (Long) props.get(WsAttrConstant.USER_ID);
+        Long userType = (Long) props.get(WsAttrConstant.USER_TYPE);
+        String location = (String) props.get(WsAttrConstant.LOCATION);
+        if (userType == null) {
+            userType = 0L;
+        }
+
+        WsSender sender = new SessionWsSender(session);
+        props.put("wsSender", sender);
+
+        if (connectionService == null || !connectionService.tryAcceptConnection()) {
+            sender.sendText("Error: 4503 节点连接已满");
+            sender.close();
+            return;
+        }
+
+        Long finalUserType = userType;
+        Runnable joinTask = () -> {
+            try {
+                connectionService.onConnected(liveId, userId, finalUserType, sender, location);
+            } catch (Exception ex) {
+                log.warn("Tomcat WS 连接建立失败 liveId={}, userId={}", liveId, userId, ex);
+                sender.sendText("Error: " + ex.getMessage());
+                sender.close();
+            }
+        };
+        if (connectionExecutor != null) {
+            connectionExecutor.execute(joinTask);
+        } else {
+            joinTask.run();
+        }
+    }
+
+    @OnMessage
+    public void onMessage(Session session, String payload) {
+        Map<String, Object> props = session.getUserProperties();
+        WsSender sender = (WsSender) props.get("wsSender");
+        if (sender == null) {
+            sender = new SessionWsSender(session);
+        }
+        messageService.handleTextMessage(
+                sender,
+                (Long) props.get(WsAttrConstant.LIVE_ID),
+                (Long) props.get(WsAttrConstant.USER_ID),
+                (Long) props.get(WsAttrConstant.USER_TYPE),
+                payload);
+    }
+
+    @OnClose
+    public void onClose(Session session) {
+        Map<String, Object> props = session.getUserProperties();
+        Long liveId = (Long) props.get(WsAttrConstant.LIVE_ID);
+        Long userId = (Long) props.get(WsAttrConstant.USER_ID);
+        Long userType = (Long) props.get(WsAttrConstant.USER_TYPE);
+        WsSender sender = (WsSender) props.get("wsSender");
+        if (sender == null) {
+            sender = new SessionWsSender(session);
+        }
+        if (liveId != null && connectionService != null) {
+            connectionService.onDisconnected(liveId, userId, userType, sender);
+        }
+    }
+
+    @OnError
+    public void onError(Session session, Throwable error) {
+        log.error("Tomcat WS 异常 sessionId={}", session != null ? session.getId() : null, error);
+    }
+}

+ 44 - 0
fs-live-ws/src/main/java/com/fs/live/ws/tomcat/TomcatWsConfigurator.java

@@ -0,0 +1,44 @@
+package com.fs.live.ws.tomcat;
+
+import com.fs.live.ws.constant.WsAttrConstant;
+
+import javax.websocket.HandshakeResponse;
+import javax.websocket.server.HandshakeRequest;
+import javax.websocket.server.ServerEndpointConfig;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Tomcat WebSocket 握手:仅校验 liveId / userId。
+ * 不使用 SpringConfigurator(Boot 无 ContextLoaderListener 会报 Failed to find the root WebApplicationContext)。
+ * 业务 Bean 由 {@link TomcatLiveWsEndpoint} 静态注入。
+ */
+public class TomcatWsConfigurator extends ServerEndpointConfig.Configurator {
+
+    @Override
+    public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) {
+        Map<String, List<String>> params = request.getParameterMap();
+        if (!params.containsKey(WsAttrConstant.LIVE_ID) || !params.containsKey(WsAttrConstant.USER_ID)) {
+            throw new IllegalArgumentException("缺少 liveId 或 userId");
+        }
+
+        Long liveId = Long.valueOf(params.get(WsAttrConstant.LIVE_ID).get(0));
+        Long userId = Long.valueOf(params.get(WsAttrConstant.USER_ID).get(0));
+        long userType = 0L;
+        if (params.containsKey(WsAttrConstant.USER_TYPE)) {
+            try {
+                userType = Long.parseLong(params.get(WsAttrConstant.USER_TYPE).get(0));
+            } catch (NumberFormatException ignored) {
+                userType = 0L;
+            }
+        }
+
+        Map<String, Object> props = sec.getUserProperties();
+        props.put(WsAttrConstant.LIVE_ID, liveId);
+        props.put(WsAttrConstant.USER_ID, userId);
+        props.put(WsAttrConstant.USER_TYPE, userType);
+        if (params.containsKey(WsAttrConstant.LOCATION)) {
+            props.put(WsAttrConstant.LOCATION, params.get(WsAttrConstant.LOCATION).get(0));
+        }
+    }
+}

+ 43 - 0
fs-live-ws/src/main/resources/application.yml

@@ -0,0 +1,43 @@
+# fs-live-ws
+# HTTP/Tomcat WebSocket: 7114
+# Netty WebSocket: 7116(live.ws.enabled=true 时默认开启)
+# 两条通道路径相同:/ws/app/webSocket,任选其一即可连接
+server:
+  port: 7114
+  tomcat:
+    max-threads: 64
+
+spring:
+  profiles:
+    active: druid-bjzm-test
+  http:
+    encoding:
+      charset: UTF-8
+      enabled: true
+      force: true
+
+logging:
+  file:
+    path: /home/fs-live-ws/logs
+  logback:
+    rollingpolicy:
+      max-file-size: 200MB
+      max-history: 15
+      total-size-cap: 10GB
+
+live:
+  ws:
+    enabled: true
+    netty-port: 7116
+    public-port: 7114
+    path: /ws/app/webSocket
+    max-connections-per-node: 25000
+    node-id: ${HOSTNAME:fs-live-ws-local}
+    public-host: 127.0.0.1
+    public-scheme: ws
+    heartbeat-timeout-seconds: 120
+    node-heartbeat-seconds: 10
+    user-count-broadcast-seconds: 10
+    like-broadcast-ms: 300
+
+# fs.jwt.secret / expire 由 spring.profiles.active 对应 profile 提供(与 fs-user-app 一致)

+ 4 - 1
fs-user-app/src/main/java/com/fs/app/config/LiveWsClientProperties.java

@@ -6,6 +6,7 @@ import org.springframework.stereotype.Component;
 
 /**
  * fs-live-ws 连接配置(与 fs-live-ws application.yml live.ws 保持一致)
+ * 默认走 Tomcat 7114;也可改 public-port / netty-port 指向 Netty。
  */
 @Data
 @Component
@@ -14,11 +15,13 @@ public class LiveWsClientProperties {
 
     private String publicScheme = "ws";
     private String publicHost = "127.0.0.1";
+    /** 对外推荐 WS 端口(默认 Tomcat 7114) */
+    private int publicPort = 7114;
     private int nettyPort = 7116;
     private String path = "/ws/app/webSocket";
     private String nodeId = "fs-live-ws-local";
 
     public String buildWsBaseUrl(Long liveId) {
-        return publicScheme + "://" + publicHost + ":" + nettyPort + path + "?liveId=" + liveId;
+        return publicScheme + "://" + publicHost + ":" + publicPort + path + "?liveId=" + liveId;
     }
 }

+ 153 - 0
fs-user-app/src/main/java/com/fs/app/controller/live/LiveCartController.java

@@ -6,6 +6,7 @@ import com.fs.app.annotation.Login;
 import com.fs.app.controller.AppBaseController;
 import com.fs.common.annotation.Log;
 import com.fs.common.annotation.RepeatSubmit;
+import com.fs.common.constant.HttpStatus;
 import com.fs.common.core.domain.AjaxResult;
 import com.fs.common.core.domain.R;
 import com.fs.common.enums.BusinessType;
@@ -18,6 +19,7 @@ import com.fs.erp.service.impl.JSTErpOrderServiceImpl;
 import com.fs.his.domain.FsPayConfig;
 import com.fs.his.domain.MerchantAppConfig;
 import com.fs.his.mapper.MerchantAppConfigMapper;
+import com.fs.hisStore.enums.OrderInfoEnum;
 import com.fs.live.domain.LiveCart;
 import com.fs.live.service.ILiveCartService;
 import com.fs.live.vo.LiveCartVo;
@@ -59,8 +61,15 @@ import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.*;
 
 import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardOpenOption;
 import java.text.DecimalFormat;
 import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -78,6 +87,8 @@ import com.fs.common.utils.StringUtils;
 @Api(tags = "购物车管理")
 public class LiveCartController extends AppBaseController
 {
+    private static final String FINISH_ORDER_LOG_FILE = "积分文档.txt";
+
     @Autowired
     private ILiveCartService liveCartService;
     
@@ -266,6 +277,148 @@ public class LiveCartController extends AppBaseController
         }
     }
 
+    /**
+     * 外部传入商城订单号(逗号分隔),查询订单并校验状态,正常则完成结算(待收货 -> 已完成)
+     */
+    @GetMapping("/finishOrder")
+    @ApiOperation("商城订单结算完成")
+    @ApiImplicitParam(name = "orderCodes", value = "订单号,逗号分隔", required = true, dataType = "String", paramType = "query")
+    public R finishOrderByCodes(@RequestParam("orderCodes") String orderCodes)
+    {
+        try {
+            log.info("商城订单结算完成请求,订单号: {}", orderCodes);
+
+            if (StringUtils.isEmpty(orderCodes)) {
+                return R.error("订单号不能为空");
+            }
+
+            String[] codeArr = orderCodes.split(",");
+            List<Map<String, String>> successList = new ArrayList<>();
+            List<Map<String, String>> failList = new ArrayList<>();
+
+            for (String rawCode : codeArr) {
+                String orderCode = rawCode.trim();
+                if (StringUtils.isEmpty(orderCode)) {
+                    continue;
+                }
+
+                FsStoreOrderScrm order = fsStoreOrderScrmService.selectFsStoreOrderByOrderCode(orderCode);
+                if (order == null) {
+                    failList.add(buildRecordItem(orderCode, "订单不存在"));
+                    continue;
+                }
+
+                if (order.getIsDel() != null && order.getIsDel() == 1) {
+                    failList.add(buildRecordItem(orderCode, "订单已删除"));
+                    continue;
+                }
+
+                if (order.getPaid() == null || !OrderInfoEnum.PAY_STATUS_1.getValue().equals(order.getPaid())) {
+                    failList.add(buildRecordItem(orderCode, "订单未支付"));
+                    continue;
+                }
+
+                if (order.getStatus() != null && order.getStatus() < 0) {
+                    failList.add(buildRecordItem(orderCode, "订单处于退款或取消状态,无法结算"));
+                    continue;
+                }
+
+                if (OrderInfoEnum.STATUS_3.getValue().equals(order.getStatus())) {
+                    successList.add(buildRecordItem(orderCode, "订单已完成"));
+                    continue;
+                }
+
+                if (!OrderInfoEnum.STATUS_2.getValue().equals(order.getStatus())) {
+                    failList.add(buildRecordItem(orderCode, "订单状态异常,当前状态不允许结算(需为待收货)"));
+                    continue;
+                }
+
+                R result = fsStoreOrderScrmService.finishOrder(order.getId());
+                if (result.get("code") != null && HttpStatus.SUCCESS == (Integer) result.get("code")) {
+                    successList.add(buildRecordItem(orderCode, "结算完成"));
+                } else {
+                    failList.add(buildRecordItem(orderCode, String.valueOf(result.get("msg"))));
+                }
+            }
+
+            if (successList.isEmpty() && failList.isEmpty()) {
+                return R.error("未解析到有效订单号");
+            }
+
+            appendFinishOrderRecord(orderCodes, successList, failList);
+
+            return R.ok("处理完成")
+                    .put("successList", successList)
+                    .put("failList", failList)
+                    .put("successCount", successList.size())
+                    .put("failCount", failList.size());
+        } catch (Exception e) {
+            log.error("商城订单结算完成异常,订单号: {}", orderCodes, e);
+            appendFinishOrderRecord(orderCodes, new ArrayList<>(),
+                    java.util.Collections.singletonList(buildRecordItem(orderCodes, "系统异常: " + e.getMessage())));
+            return R.error("订单结算失败: " + e.getMessage());
+        }
+    }
+
+    private Map<String, String> buildRecordItem(String orderCode, String msg) {
+        Map<String, String> item = new HashMap<>(2);
+        item.put("orderCode", orderCode);
+        item.put("msg", msg);
+        return item;
+    }
+
+    private void appendFinishOrderRecord(String orderCodes, List<Map<String, String>> successList,
+                                         List<Map<String, String>> failList) {
+        try {
+            Path logPath = resolveIntegralDocPath();
+            Files.createDirectories(logPath.getParent());
+
+            String time = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
+            StringBuilder content = new StringBuilder();
+            content.append("========== ").append(time).append(" ==========\n");
+            content.append("接口: GET /live/liveCart/finishOrder\n");
+            content.append("请求订单号: ").append(orderCodes).append("\n");
+            content.append("成功(").append(successList.size()).append("):\n");
+            if (successList.isEmpty()) {
+                content.append("  无\n");
+            } else {
+                for (Map<String, String> item : successList) {
+                    content.append("  - ").append(item.get("orderCode"))
+                            .append(" | ").append(item.get("msg")).append("\n");
+                }
+            }
+            content.append("失败(").append(failList.size()).append("):\n");
+            if (failList.isEmpty()) {
+                content.append("  无\n");
+            } else {
+                for (Map<String, String> item : failList) {
+                    content.append("  - ").append(item.get("orderCode"))
+                            .append(" | ").append(item.get("msg")).append("\n");
+                }
+            }
+            content.append("\n");
+
+            Files.write(logPath, content.toString().getBytes(StandardCharsets.UTF_8),
+                    StandardOpenOption.CREATE, StandardOpenOption.APPEND);
+            log.info("商城订单结算记录已写入: {}", logPath.toAbsolutePath());
+        } catch (Exception e) {
+            log.warn("写入商城订单结算记录失败: {}", e.getMessage());
+        }
+    }
 
+    private Path resolveIntegralDocPath() {
+        Path dir = Paths.get(System.getProperty("user.dir")).toAbsolutePath().normalize();
+        for (int i = 0; i < 6; i++) {
+            Path candidate = dir.resolve("test").resolve(FINISH_ORDER_LOG_FILE);
+            if (Files.exists(dir.resolve("test"))) {
+                return candidate;
+            }
+            if (dir.getParent() == null) {
+                break;
+            }
+            dir = dir.getParent();
+        }
+        return Paths.get("c:/mycode/scrm/test", FINISH_ORDER_LOG_FILE);
+    }
 
 }