Sfoglia il codice sorgente

netty连接和cacheLive

yuhongqi 4 giorni fa
parent
commit
0c835010f5
86 ha cambiato i file con 7774 aggiunte e 2 eliminazioni
  1. 84 0
      fs-live-ws/pom.xml
  2. 23 0
      fs-live-ws/src/main/java/com/fs/FsLiveWsApplication.java
  3. 182 0
      fs-live-ws/src/main/java/com/fs/framework/aspectj/DataScopeAspect.java
  4. 73 0
      fs-live-ws/src/main/java/com/fs/framework/aspectj/DataSourceAspect.java
  5. 93 0
      fs-live-ws/src/main/java/com/fs/framework/aspectj/LiveWatchUserAspect.java
  6. 245 0
      fs-live-ws/src/main/java/com/fs/framework/aspectj/LogAspect.java
  7. 117 0
      fs-live-ws/src/main/java/com/fs/framework/aspectj/RateLimiterAspect.java
  8. 58 0
      fs-live-ws/src/main/java/com/fs/framework/aspectj/lock/DistributeLock.java
  9. 113 0
      fs-live-ws/src/main/java/com/fs/framework/aspectj/lock/DistributeLockAspect.java
  10. 13 0
      fs-live-ws/src/main/java/com/fs/framework/aspectj/lock/DistributeLockConstant.java
  11. 24 0
      fs-live-ws/src/main/java/com/fs/framework/aspectj/lock/DistributeLockException.java
  12. 31 0
      fs-live-ws/src/main/java/com/fs/framework/config/ApplicationConfig.java
  13. 85 0
      fs-live-ws/src/main/java/com/fs/framework/config/CaptchaConfig.java
  14. 98 0
      fs-live-ws/src/main/java/com/fs/framework/config/DataSourceConfig.java
  15. 123 0
      fs-live-ws/src/main/java/com/fs/framework/config/DruidConfig.java
  16. 72 0
      fs-live-ws/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  17. 59 0
      fs-live-ws/src/main/java/com/fs/framework/config/FilterConfig.java
  18. 76 0
      fs-live-ws/src/main/java/com/fs/framework/config/KaptchaTextCreator.java
  19. 150 0
      fs-live-ws/src/main/java/com/fs/framework/config/MyBatisConfig.java
  20. 159 0
      fs-live-ws/src/main/java/com/fs/framework/config/RedisConfig.java
  21. 65 0
      fs-live-ws/src/main/java/com/fs/framework/config/ResourcesConfig.java
  22. 50 0
      fs-live-ws/src/main/java/com/fs/framework/config/SecurityConfig.java
  23. 33 0
      fs-live-ws/src/main/java/com/fs/framework/config/ServerConfig.java
  24. 121 0
      fs-live-ws/src/main/java/com/fs/framework/config/SwaggerConfig.java
  25. 63 0
      fs-live-ws/src/main/java/com/fs/framework/config/ThreadPoolConfig.java
  26. 77 0
      fs-live-ws/src/main/java/com/fs/framework/config/properties/DruidProperties.java
  27. 27 0
      fs-live-ws/src/main/java/com/fs/framework/datasource/DynamicDataSource.java
  28. 45 0
      fs-live-ws/src/main/java/com/fs/framework/datasource/DynamicDataSourceContextHolder.java
  29. 56 0
      fs-live-ws/src/main/java/com/fs/framework/interceptor/RepeatSubmitInterceptor.java
  30. 126 0
      fs-live-ws/src/main/java/com/fs/framework/interceptor/impl/SameUrlDataInterceptor.java
  31. 56 0
      fs-live-ws/src/main/java/com/fs/framework/manager/AsyncManager.java
  32. 40 0
      fs-live-ws/src/main/java/com/fs/framework/manager/ShutdownManager.java
  33. 103 0
      fs-live-ws/src/main/java/com/fs/framework/manager/factory/AsyncFactory.java
  34. 72 0
      fs-live-ws/src/main/java/com/fs/live/task/LiveCompletionPointsTask.java
  35. 1123 0
      fs-live-ws/src/main/java/com/fs/live/task/Task.java
  36. 17 0
      fs-live-ws/src/main/java/com/fs/live/vo/LotteryVo.java
  37. 26 0
      fs-live-ws/src/main/java/com/fs/live/ws/bean/WsSendMsgVo.java
  38. 23 0
      fs-live-ws/src/main/java/com/fs/live/ws/config/LiveWsProperties.java
  39. 22 0
      fs-live-ws/src/main/java/com/fs/live/ws/config/RedisPubSubConfig.java
  40. 22 0
      fs-live-ws/src/main/java/com/fs/live/ws/constant/WsAttrConstant.java
  41. 14 0
      fs-live-ws/src/main/java/com/fs/live/ws/constant/WsRedisKeys.java
  42. 93 0
      fs-live-ws/src/main/java/com/fs/live/ws/controller/WsEndpointController.java
  43. 111 0
      fs-live-ws/src/main/java/com/fs/live/ws/handler/WsAuthHandler.java
  44. 30 0
      fs-live-ws/src/main/java/com/fs/live/ws/handler/WsHeartbeatHandler.java
  45. 84 0
      fs-live-ws/src/main/java/com/fs/live/ws/handler/WsLiveChatHandler.java
  46. 72 0
      fs-live-ws/src/main/java/com/fs/live/ws/listener/LiveWsRoomMessageListener.java
  47. 104 0
      fs-live-ws/src/main/java/com/fs/live/ws/netty/NettyWsServer.java
  48. 13 0
      fs-live-ws/src/main/java/com/fs/live/ws/service/ILiveWsBroadcastService.java
  49. 13 0
      fs-live-ws/src/main/java/com/fs/live/ws/service/ILiveWsConnectionService.java
  50. 8 0
      fs-live-ws/src/main/java/com/fs/live/ws/service/ILiveWsMessageService.java
  51. 15 0
      fs-live-ws/src/main/java/com/fs/live/ws/service/IWsNodeRegistryService.java
  52. 61 0
      fs-live-ws/src/main/java/com/fs/live/ws/service/LiveWsRoomBroadcastFacade.java
  53. 426 0
      fs-live-ws/src/main/java/com/fs/live/ws/service/impl/LiveWsAdminMessageHandler.java
  54. 140 0
      fs-live-ws/src/main/java/com/fs/live/ws/service/impl/LiveWsAutoTaskHandler.java
  55. 55 0
      fs-live-ws/src/main/java/com/fs/live/ws/service/impl/LiveWsBroadcastServiceImpl.java
  56. 122 0
      fs-live-ws/src/main/java/com/fs/live/ws/service/impl/LiveWsConnectionServiceImpl.java
  57. 117 0
      fs-live-ws/src/main/java/com/fs/live/ws/service/impl/LiveWsMessageServiceImpl.java
  58. 93 0
      fs-live-ws/src/main/java/com/fs/live/ws/service/impl/WsNodeRegistryServiceImpl.java
  59. 100 0
      fs-live-ws/src/main/java/com/fs/live/ws/session/LiveWsRoomManager.java
  60. 66 0
      fs-live-ws/src/main/java/com/fs/live/ws/task/LiveWsLikeBroadcastTask.java
  61. 52 0
      fs-live-ws/src/main/java/com/fs/live/ws/task/LiveWsUserCountBroadcastTask.java
  62. 66 0
      fs-live-ws/src/main/java/com/fs/live/ws/util/WsJwtUtils.java
  63. 15 0
      fs-live-ws/src/main/resources/mybatis/mybatis-config.xml
  64. 24 0
      fs-user-app/src/main/java/com/fs/app/config/LiveWsClientProperties.java
  65. 119 0
      fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveAfterSalesController.java
  66. 29 0
      fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveBaseController.java
  67. 54 0
      fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveCartController.java
  68. 135 0
      fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveCompletionPointsController.java
  69. 416 0
      fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveController.java
  70. 40 0
      fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveCouponController.java
  71. 85 0
      fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveDataController.java
  72. 170 0
      fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveGoodsController.java
  73. 50 0
      fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveLotteryController.java
  74. 101 0
      fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveMergedOrderController.java
  75. 38 0
      fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveMsgController.java
  76. 219 0
      fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveOrderController.java
  77. 35 0
      fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveOrderLogsController.java
  78. 36 0
      fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveOrderPaymentController.java
  79. 54 0
      fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveRedController.java
  80. 30 0
      fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveRedPacketLogController.java
  81. 36 0
      fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveUserFavoriteController.java
  82. 36 0
      fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveUserFollowController.java
  83. 36 0
      fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveUserLikeController.java
  84. 38 0
      fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveVideoController.java
  85. 70 0
      fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveWatchUserController.java
  86. 8 2
      pom.xml

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

@@ -0,0 +1,84 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <parent>
+        <artifactId>fs</artifactId>
+        <groupId>com.fs</groupId>
+        <version>1.1.0</version>
+    </parent>
+    <modelVersion>4.0.0</modelVersion>
+
+    <artifactId>fs-live-ws</artifactId>
+    <description>直播 Netty WebSocket + cacheLive HTTP 服务</description>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-aop</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-data-redis</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.netty</groupId>
+            <artifactId>netty-all</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>mysql</groupId>
+            <artifactId>mysql-connector-java</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.alibaba</groupId>
+            <artifactId>druid-spring-boot-starter</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.github.penggle</groupId>
+            <artifactId>kaptcha</artifactId>
+            <exclusions>
+                <exclusion>
+                    <artifactId>javax.servlet-api</artifactId>
+                    <groupId>javax.servlet</groupId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+        <dependency>
+            <groupId>com.fs</groupId>
+            <artifactId>fs-service</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.projectlombok</groupId>
+            <artifactId>lombok</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.springfox</groupId>
+            <artifactId>springfox-swagger2</artifactId>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+                <version>2.1.1.RELEASE</version>
+                <configuration>
+                    <fork>true</fork>
+                </configuration>
+                <executions>
+                    <execution>
+                        <goals>
+                            <goal>repackage</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+        <finalName>${project.artifactId}</finalName>
+    </build>
+</project>

+ 23 - 0
fs-live-ws/src/main/java/com/fs/FsLiveWsApplication.java

@@ -0,0 +1,23 @@
+package com.fs;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
+import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.transaction.annotation.EnableTransactionManagement;
+
+/**
+ * 直播 Netty WebSocket + cacheLive HTTP 启动入口。
+ */
+@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
+@EnableTransactionManagement
+@EnableAsync
+@EnableScheduling
+public class FsLiveWsApplication {
+
+    public static void main(String[] args) {
+        SpringApplication.run(FsLiveWsApplication.class, args);
+        System.out.println("FsLiveWs 启动成功");
+    }
+}

+ 182 - 0
fs-live-ws/src/main/java/com/fs/framework/aspectj/DataScopeAspect.java

@@ -0,0 +1,182 @@
+package com.fs.framework.aspectj;
+
+import com.fs.common.annotation.DataScope;
+import com.fs.common.core.domain.BaseEntity;
+import com.fs.common.core.domain.entity.SysRole;
+import com.fs.common.core.domain.entity.SysUser;
+import com.fs.common.core.domain.model.LoginUser;
+import com.fs.common.utils.SecurityUtils;
+import com.fs.common.utils.StringUtils;
+import org.aspectj.lang.JoinPoint;
+import org.aspectj.lang.Signature;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.annotation.Before;
+import org.aspectj.lang.annotation.Pointcut;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.springframework.stereotype.Component;
+
+import java.lang.reflect.Method;
+
+/**
+ * 数据过滤处理
+ *
+
+ */
+@Aspect
+@Component
+public class DataScopeAspect
+{
+    /**
+     * 全部数据权限
+     */
+    public static final String DATA_SCOPE_ALL = "1";
+
+    /**
+     * 自定数据权限
+     */
+    public static final String DATA_SCOPE_CUSTOM = "2";
+
+    /**
+     * 部门数据权限
+     */
+    public static final String DATA_SCOPE_DEPT = "3";
+
+    /**
+     * 部门及以下数据权限
+     */
+    public static final String DATA_SCOPE_DEPT_AND_CHILD = "4";
+
+    /**
+     * 仅本人数据权限
+     */
+    public static final String DATA_SCOPE_SELF = "5";
+
+    /**
+     * 数据权限过滤关键字
+     */
+    public static final String DATA_SCOPE = "dataScope";
+
+    // 配置织入点
+    @Pointcut("@annotation(com.fs.common.annotation.DataScope)")
+    public void dataScopePointCut()
+    {
+    }
+
+    @Before("dataScopePointCut()")
+    public void doBefore(JoinPoint point) throws Throwable
+    {
+        clearDataScope(point);
+        handleDataScope(point);
+    }
+
+    protected void handleDataScope(final JoinPoint joinPoint)
+    {
+        // 获得注解
+        DataScope controllerDataScope = getAnnotationLog(joinPoint);
+        if (controllerDataScope == null)
+        {
+            return;
+        }
+        // 获取当前的用户
+        LoginUser loginUser = SecurityUtils.getLoginUser();
+        if (StringUtils.isNotNull(loginUser))
+        {
+            SysUser currentUser = loginUser.getUser();
+            // 如果是超级管理员,则不过滤数据
+            if (StringUtils.isNotNull(currentUser) && !currentUser.isAdmin())
+            {
+                dataScopeFilter(joinPoint, currentUser, controllerDataScope.deptAlias(),
+                        controllerDataScope.userAlias());
+            }
+        }
+    }
+
+    /**
+     * 数据范围过滤
+     *
+     * @param joinPoint 切点
+     * @param user 用户
+     * @param userAlias 别名
+     */
+    public static void dataScopeFilter(JoinPoint joinPoint, SysUser user, String deptAlias, String userAlias)
+    {
+        StringBuilder sqlString = new StringBuilder();
+
+        for (SysRole role : user.getRoles())
+        {
+            String dataScope = role.getDataScope();
+            if (DATA_SCOPE_ALL.equals(dataScope))
+            {
+                sqlString = new StringBuilder();
+                break;
+            }
+            else if (DATA_SCOPE_CUSTOM.equals(dataScope))
+            {
+                sqlString.append(StringUtils.format(
+                        " OR {}.dept_id IN ( SELECT dept_id FROM sys_role_dept WHERE role_id = {} ) ", deptAlias,
+                        role.getRoleId()));
+            }
+            else if (DATA_SCOPE_DEPT.equals(dataScope))
+            {
+                sqlString.append(StringUtils.format(" OR {}.dept_id = {} ", deptAlias, user.getDeptId()));
+            }
+            else if (DATA_SCOPE_DEPT_AND_CHILD.equals(dataScope))
+            {
+                sqlString.append(StringUtils.format(
+                        " OR {}.dept_id IN ( SELECT dept_id FROM sys_dept WHERE dept_id = {} or find_in_set( {} , ancestors ) )",
+                        deptAlias, user.getDeptId(), user.getDeptId()));
+            }
+            else if (DATA_SCOPE_SELF.equals(dataScope))
+            {
+                if (StringUtils.isNotBlank(userAlias))
+                {
+                    sqlString.append(StringUtils.format(" OR {}.user_id = {} ", userAlias, user.getUserId()));
+                }
+                else
+                {
+                    // 数据权限为仅本人且没有userAlias别名不查询任何数据
+                    sqlString.append(" OR 1=0 ");
+                }
+            }
+        }
+
+        if (StringUtils.isNotBlank(sqlString.toString()))
+        {
+            Object params = joinPoint.getArgs()[0];
+            if (StringUtils.isNotNull(params) && params instanceof BaseEntity)
+            {
+                BaseEntity baseEntity = (BaseEntity) params;
+                baseEntity.getParams().put(DATA_SCOPE, " AND (" + sqlString.substring(4) + ")");
+            }
+        }
+    }
+
+    /**
+     * 是否存在注解,如果存在就获取
+     */
+    private DataScope getAnnotationLog(JoinPoint joinPoint)
+    {
+        Signature signature = joinPoint.getSignature();
+        MethodSignature methodSignature = (MethodSignature) signature;
+        Method method = methodSignature.getMethod();
+
+        if (method != null)
+        {
+            return method.getAnnotation(DataScope.class);
+        }
+        return null;
+    }
+
+    /**
+     * 拼接权限sql前先清空params.dataScope参数防止注入
+     */
+    private void clearDataScope(final JoinPoint joinPoint)
+    {
+        Object params = joinPoint.getArgs()[0];
+        if (StringUtils.isNotNull(params) && params instanceof BaseEntity)
+        {
+            BaseEntity baseEntity = (BaseEntity) params;
+            baseEntity.getParams().put(DATA_SCOPE, "");
+        }
+    }
+}

+ 73 - 0
fs-live-ws/src/main/java/com/fs/framework/aspectj/DataSourceAspect.java

@@ -0,0 +1,73 @@
+package com.fs.framework.aspectj;
+
+import com.fs.common.annotation.DataSource;
+import com.fs.common.utils.StringUtils;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.Around;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.annotation.Pointcut;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.core.annotation.AnnotationUtils;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+
+import java.util.Objects;
+
+/**
+ * 多数据源处理
+ * 
+
+ */
+@Aspect
+@Order(1)
+@Component
+public class DataSourceAspect
+{
+    protected Logger logger = LoggerFactory.getLogger(getClass());
+
+    @Pointcut("@annotation(com.fs.common.annotation.DataSource)"
+            + "|| @within(com.fs.common.annotation.DataSource)")
+    public void dsPointCut()
+    {
+
+    }
+
+    @Around("dsPointCut()")
+    public Object around(ProceedingJoinPoint point) throws Throwable
+    {
+        DataSource dataSource = getDataSource(point);
+
+        if (StringUtils.isNotNull(dataSource))
+        {
+            DynamicDataSourceContextHolder.setDataSourceType(dataSource.value().name());
+        }
+
+        try
+        {
+            return point.proceed();
+        }
+        finally
+        {
+            // 销毁数据源 在执行方法之后
+            DynamicDataSourceContextHolder.clearDataSourceType();
+        }
+    }
+
+    /**
+     * 获取需要切换的数据源
+     */
+    public DataSource getDataSource(ProceedingJoinPoint point)
+    {
+        MethodSignature signature = (MethodSignature) point.getSignature();
+        DataSource dataSource = AnnotationUtils.findAnnotation(signature.getMethod(), DataSource.class);
+        if (Objects.nonNull(dataSource))
+        {
+            return dataSource;
+        }
+
+        return AnnotationUtils.findAnnotation(signature.getDeclaringType(), DataSource.class);
+    }
+}

+ 93 - 0
fs-live-ws/src/main/java/com/fs/framework/aspectj/LiveWatchUserAspect.java

@@ -0,0 +1,93 @@
+package com.fs.framework.aspectj;
+
+import cn.hutool.core.util.ObjectUtil;
+import com.fs.live.domain.LiveWatchUser;
+import com.fs.live.service.ILiveWatchUserService;
+import lombok.extern.slf4j.Slf4j;
+import org.aspectj.lang.JoinPoint;
+import org.aspectj.lang.annotation.AfterReturning;
+import org.aspectj.lang.annotation.Aspect;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+@Aspect
+@Component
+@Slf4j
+@Order(Ordered.LOWEST_PRECEDENCE - 1)  // 调整切面优先级
+public class LiveWatchUserAspect {
+
+
+
+    @Autowired
+    @Lazy
+    private ILiveWatchUserService liveWatchUserService;
+
+    @AfterReturning(pointcut = "execution(* com.fs.live.service.impl.LiveWatchUserServiceImpl.insertLiveWatchUser(..)) || " +
+            "execution(* com.fs.live.service.impl.LiveWatchUserServiceImpl.updateLiveWatchUser(..)) || " +
+            "execution(* com.fs.live.service.impl.LiveWatchUserServiceImpl.deleteLiveWatchUserById(..)) || " +
+            "execution(* com.fs.live.service.impl.LiveWatchUserServiceImpl.deleteLiveWatchUserByIds(..))",
+            returning = "result")
+    public void afterLiveWatchUserOperation(JoinPoint joinPoint, Object result) {
+        try {
+            String methodName = joinPoint.getSignature().getName();
+            Object[] args = joinPoint.getArgs();
+            // 提取liveId并处理缓存更新
+            Set<Long> liveIds = extractLiveIds(methodName, args);
+            for (Long liveId : liveIds) {
+                liveWatchUserService.asyncToCache(liveId);
+            }
+        } catch (Exception e) {
+            log.error("执行直播观看用户变更后逻辑失败", e);
+        }
+    }
+
+    private Set<Long> extractLiveIds(String methodName, Object[] args) {
+        Set<Long> liveIds = new HashSet<>();
+        if (args == null || args.length == 0) {
+            return liveIds;
+        }
+        switch (methodName) {
+            case "insertLiveWatchUser":
+            case "updateLiveWatchUser":
+                // 参数是LiveWatchUser对象
+                if (args[0] instanceof LiveWatchUser) {
+                    LiveWatchUser liveWatchUser = (LiveWatchUser) args[0];
+                    if (liveWatchUser.getLiveId() != null) {
+                        liveIds.add(liveWatchUser.getLiveId());
+                    }
+                }
+                break;
+            case "deleteLiveWatchUserById":
+                // 参数是Long类型的id,需要先查询获取liveId
+                if (args[0] instanceof Long) {
+                    LiveWatchUser liveWatchUser = liveWatchUserService.selectLiveWatchUserById((Long) args[0]);
+                    if (ObjectUtil.isNotEmpty(liveWatchUser)) {
+                        liveIds.add(liveWatchUser.getLiveId());
+                    }
+                }
+                break;
+            case "deleteLiveWatchUserByIds":
+                // 参数是Long[]数组
+                if (args[0] instanceof Long[]) {
+                    Long[] ids = (Long[]) args[0];
+                    LiveWatchUser liveWatchUser = liveWatchUserService.selectLiveWatchUserById(ids[0]);
+                    if (ObjectUtil.isNotEmpty(liveWatchUser)) {
+                        liveIds.add(liveWatchUser.getLiveId());
+                    }
+                }
+                break;
+            default:
+                log.warn("未处理的方法: {}", methodName);
+        }
+        return liveIds;
+    }
+
+
+}

+ 245 - 0
fs-live-ws/src/main/java/com/fs/framework/aspectj/LogAspect.java

@@ -0,0 +1,245 @@
+package com.fs.framework.aspectj;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.common.annotation.Log;
+import com.fs.common.core.domain.model.LoginUser;
+import com.fs.common.enums.BusinessStatus;
+import com.fs.common.enums.HttpMethod;
+import com.fs.common.utils.SecurityUtils;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.ip.IpUtils;
+
+import com.fs.framework.manager.AsyncManager;
+import com.fs.framework.manager.factory.AsyncFactory;
+import com.fs.system.domain.SysOperLog;
+import org.aspectj.lang.JoinPoint;
+import org.aspectj.lang.Signature;
+import org.aspectj.lang.annotation.AfterReturning;
+import org.aspectj.lang.annotation.AfterThrowing;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.annotation.Pointcut;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+import org.springframework.validation.BindingResult;
+import org.springframework.web.multipart.MultipartFile;
+import org.springframework.web.servlet.HandlerMapping;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.lang.reflect.Method;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.Map;
+
+/**
+ * 操作日志记录处理
+ * 
+
+ */
+@Aspect
+@Component
+public class LogAspect
+{
+    private static final Logger log = LoggerFactory.getLogger(LogAspect.class);
+
+    // 配置织入点
+    @Pointcut("@annotation(com.fs.common.annotation.Log)")
+    public void logPointCut()
+    {
+    }
+
+    /**
+     * 处理完请求后执行
+     *
+     * @param joinPoint 切点
+     */
+    @AfterReturning(pointcut = "logPointCut()", returning = "jsonResult")
+    public void doAfterReturning(JoinPoint joinPoint, Object jsonResult)
+    {
+        handleLog(joinPoint, null, jsonResult);
+    }
+
+    /**
+     * 拦截异常操作
+     * 
+     * @param joinPoint 切点
+     * @param e 异常
+     */
+    @AfterThrowing(value = "logPointCut()", throwing = "e")
+    public void doAfterThrowing(JoinPoint joinPoint, Exception e)
+    {
+        handleLog(joinPoint, e, null);
+    }
+
+    protected void handleLog(final JoinPoint joinPoint, final Exception e, Object jsonResult)
+    {
+        try
+        {
+            // 获得注解
+            Log controllerLog = getAnnotationLog(joinPoint);
+            if (controllerLog == null)
+            {
+                return;
+            }
+
+            // 获取当前的用户
+            LoginUser loginUser = SecurityUtils.getLoginUser();
+
+            // *========数据库日志=========*//
+            SysOperLog operLog = new SysOperLog();
+            operLog.setStatus(BusinessStatus.SUCCESS.ordinal());
+            // 请求的地址
+            String ip = IpUtils.getIpAddr(ServletUtils.getRequest());
+            operLog.setOperIp(ip);
+            // 返回参数
+            operLog.setJsonResult(JSON.toJSONString(jsonResult));
+
+            operLog.setOperUrl(ServletUtils.getRequest().getRequestURI());
+            if (loginUser != null)
+            {
+                operLog.setOperName(loginUser.getUsername());
+            }
+
+            if (e != null)
+            {
+                operLog.setStatus(BusinessStatus.FAIL.ordinal());
+                operLog.setErrorMsg(StringUtils.substring(e.getMessage(), 0, 2000));
+            }
+            // 设置方法名称
+            String className = joinPoint.getTarget().getClass().getName();
+            String methodName = joinPoint.getSignature().getName();
+            operLog.setMethod(className + "." + methodName + "()");
+            // 设置请求方式
+            operLog.setRequestMethod(ServletUtils.getRequest().getMethod());
+            // 处理设置注解上的参数
+            getControllerMethodDescription(joinPoint, controllerLog, operLog);
+            // 保存数据库
+            AsyncManager.me().execute(AsyncFactory.recordOper(operLog));
+        }
+        catch (Exception exp)
+        {
+            // 记录本地异常日志
+            log.error("==前置通知异常==");
+            log.error("异常信息:{}", exp.getMessage());
+            exp.printStackTrace();
+        }
+    }
+
+    /**
+     * 获取注解中对方法的描述信息 用于Controller层注解
+     * 
+     * @param log 日志
+     * @param operLog 操作日志
+     * @throws Exception
+     */
+    public void getControllerMethodDescription(JoinPoint joinPoint, Log log, SysOperLog operLog) throws Exception
+    {
+        // 设置action动作
+        operLog.setBusinessType(log.businessType().ordinal());
+        // 设置标题
+        operLog.setTitle(log.title());
+        // 设置操作人类别
+        operLog.setOperatorType(log.operatorType().ordinal());
+        // 是否需要保存request,参数和值
+        if (log.isSaveRequestData())
+        {
+            // 获取参数的信息,传入到数据库中。
+            setRequestValue(joinPoint, operLog);
+        }
+    }
+
+    /**
+     * 获取请求的参数,放到log中
+     * 
+     * @param operLog 操作日志
+     * @throws Exception 异常
+     */
+    private void setRequestValue(JoinPoint joinPoint, SysOperLog operLog) throws Exception
+    {
+        String requestMethod = operLog.getRequestMethod();
+        if (HttpMethod.PUT.name().equals(requestMethod) || HttpMethod.POST.name().equals(requestMethod))
+        {
+            String params = argsArrayToString(joinPoint.getArgs());
+            operLog.setOperParam(StringUtils.substring(params, 0, 2000));
+        }
+        else
+        {
+            Map<?, ?> paramsMap = (Map<?, ?>) ServletUtils.getRequest().getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE);
+            operLog.setOperParam(StringUtils.substring(paramsMap.toString(), 0, 2000));
+        }
+    }
+
+    /**
+     * 是否存在注解,如果存在就获取
+     */
+    private Log getAnnotationLog(JoinPoint joinPoint) throws Exception
+    {
+        Signature signature = joinPoint.getSignature();
+        MethodSignature methodSignature = (MethodSignature) signature;
+        Method method = methodSignature.getMethod();
+
+        if (method != null)
+        {
+            return method.getAnnotation(Log.class);
+        }
+        return null;
+    }
+
+    /**
+     * 参数拼装
+     */
+    private String argsArrayToString(Object[] paramsArray)
+    {
+        String params = "";
+        if (paramsArray != null && paramsArray.length > 0)
+        {
+            for (int i = 0; i < paramsArray.length; i++)
+            {
+                if (StringUtils.isNotNull(paramsArray[i]) && !isFilterObject(paramsArray[i]))
+                {
+                    Object jsonObj = JSON.toJSON(paramsArray[i]);
+                    params += jsonObj.toString() + " ";
+                }
+            }
+        }
+        return params.trim();
+    }
+
+    /**
+     * 判断是否需要过滤的对象。
+     * 
+     * @param o 对象信息。
+     * @return 如果是需要过滤的对象,则返回true;否则返回false。
+     */
+    @SuppressWarnings("rawtypes")
+    public boolean isFilterObject(final Object o)
+    {
+        Class<?> clazz = o.getClass();
+        if (clazz.isArray())
+        {
+            return clazz.getComponentType().isAssignableFrom(MultipartFile.class);
+        }
+        else if (Collection.class.isAssignableFrom(clazz))
+        {
+            Collection collection = (Collection) o;
+            for (Iterator iter = collection.iterator(); iter.hasNext();)
+            {
+                return iter.next() instanceof MultipartFile;
+            }
+        }
+        else if (Map.class.isAssignableFrom(clazz))
+        {
+            Map map = (Map) o;
+            for (Iterator iter = map.entrySet().iterator(); iter.hasNext();)
+            {
+                Map.Entry entry = (Map.Entry) iter.next();
+                return entry.getValue() instanceof MultipartFile;
+            }
+        }
+        return o instanceof MultipartFile || o instanceof HttpServletRequest || o instanceof HttpServletResponse
+                || o instanceof BindingResult;
+    }
+}

+ 117 - 0
fs-live-ws/src/main/java/com/fs/framework/aspectj/RateLimiterAspect.java

@@ -0,0 +1,117 @@
+package com.fs.framework.aspectj;
+
+import com.fs.common.annotation.RateLimiter;
+import com.fs.common.enums.LimitType;
+import com.fs.common.exception.ServiceException;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.ip.IpUtils;
+import org.aspectj.lang.JoinPoint;
+import org.aspectj.lang.Signature;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.annotation.Before;
+import org.aspectj.lang.annotation.Pointcut;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.script.RedisScript;
+import org.springframework.stereotype.Component;
+
+import java.lang.reflect.Method;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * 限流处理
+ *
+
+ */
+@Aspect
+@Component
+public class RateLimiterAspect
+{
+    private static final Logger log = LoggerFactory.getLogger(RateLimiterAspect.class);
+
+    private RedisTemplate<Object, Object> redisTemplate;
+
+    private RedisScript<Long> limitScript;
+
+    @Autowired
+    public void setRedisTemplate1(RedisTemplate<Object, Object> redisTemplate)
+    {
+        this.redisTemplate = redisTemplate;
+    }
+
+    @Autowired
+    public void setLimitScript(RedisScript<Long> limitScript)
+    {
+        this.limitScript = limitScript;
+    }
+
+    // 配置织入点
+    @Pointcut("@annotation(com.fs.common.annotation.RateLimiter)")
+    public void rateLimiterPointCut()
+    {
+    }
+
+    @Before("rateLimiterPointCut()")
+    public void doBefore(JoinPoint point) throws Throwable
+    {
+        RateLimiter rateLimiter = getAnnotationRateLimiter(point);
+        String key = rateLimiter.key();
+        int time = rateLimiter.time();
+        int count = rateLimiter.count();
+
+        String combineKey = getCombineKey(rateLimiter, point);
+        List<Object> keys = Collections.singletonList(combineKey);
+        try
+        {
+            Long number = redisTemplate.execute(limitScript, keys, count, time);
+            if (StringUtils.isNull(number) || number.intValue() > count)
+            {
+                throw new ServiceException("访问过于频繁,请稍后再试");
+            }
+
+        }
+        catch (ServiceException e)
+        {
+            throw e;
+        }
+        catch (Exception e)
+        {
+            throw new RuntimeException("服务器限流异常,请稍后再试");
+        }
+    }
+
+    /**
+     * 是否存在注解,如果存在就获取
+     */
+    private RateLimiter getAnnotationRateLimiter(JoinPoint joinPoint)
+    {
+        Signature signature = joinPoint.getSignature();
+        MethodSignature methodSignature = (MethodSignature) signature;
+        Method method = methodSignature.getMethod();
+
+        if (method != null)
+        {
+            return method.getAnnotation(RateLimiter.class);
+        }
+        return null;
+    }
+
+    public String getCombineKey(RateLimiter rateLimiter, JoinPoint point)
+    {
+        StringBuffer stringBuffer = new StringBuffer(rateLimiter.key());
+        if (rateLimiter.limitType() == LimitType.IP)
+        {
+            stringBuffer.append(IpUtils.getIpAddr(ServletUtils.getRequest()));
+        }
+        MethodSignature signature = (MethodSignature) point.getSignature();
+        Method method = signature.getMethod();
+        Class<?> targetClass = method.getDeclaringClass();
+        stringBuffer.append("-").append(targetClass.getName()).append("- ").append(method.getName());
+        return stringBuffer.toString();
+    }
+}

+ 58 - 0
fs-live-ws/src/main/java/com/fs/framework/aspectj/lock/DistributeLock.java

@@ -0,0 +1,58 @@
+package com.fs.framework.aspectj.lock;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * 分布式锁注解
+ *
+ * @author Hollis
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface DistributeLock {
+
+    /**
+     * 锁的场景
+     *
+     * @return
+     */
+    public String scene();
+
+    /**
+     * 加锁的key,优先取key(),如果没有,则取keyExpression()
+     *
+     * @return
+     */
+    public String key() default DistributeLockConstant.NONE_KEY;
+
+    /**
+     * SPEL表达式:
+     * <pre>
+     *     #id
+     *     #insertResult.id
+     * </pre>
+     *
+     * @return
+     */
+    public String keyExpression() default DistributeLockConstant.NONE_KEY;
+
+    /**
+     * 超时时间,毫秒
+     * 默认情况下不设置超时时间,会自动续期
+     *
+     * @return
+     */
+    public int expireTime() default DistributeLockConstant.DEFAULT_EXPIRE_TIME;
+
+    public String errorMsg() default DistributeLockConstant.ERROR_MSG;
+
+    /**
+     * 加锁等待时长,毫秒
+     * 默认情况下不设置等待时长,会一直等待直到获取到锁
+     * @return
+     */
+    public int waitTime() default DistributeLockConstant.DEFAULT_WAIT_TIME;
+}

+ 113 - 0
fs-live-ws/src/main/java/com/fs/framework/aspectj/lock/DistributeLockAspect.java

@@ -0,0 +1,113 @@
+package com.fs.framework.aspectj.lock;
+
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.Around;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.core.StandardReflectionParameterNameDiscoverer;
+import org.springframework.core.annotation.Order;
+import org.springframework.expression.EvaluationContext;
+import org.springframework.expression.Expression;
+import org.springframework.expression.spel.standard.SpelExpressionParser;
+import org.springframework.expression.spel.support.StandardEvaluationContext;
+import org.springframework.stereotype.Component;
+
+import java.lang.reflect.Method;
+import java.util.concurrent.TimeUnit;
+
+@Aspect
+@Component
+@Order(Integer.MIN_VALUE + 1)
+public class DistributeLockAspect {
+
+    private RedissonClient redissonClient;
+
+    public DistributeLockAspect(RedissonClient redissonClient) {
+        this.redissonClient = redissonClient;
+    }
+
+    private static final Logger LOG = LoggerFactory.getLogger(DistributeLockAspect.class);
+
+    @Around("@annotation(com.fs.framework.aspectj.lock.DistributeLock)")
+    public Object process(ProceedingJoinPoint pjp) throws Throwable {
+        Object response = null;
+        Method method = ((MethodSignature) pjp.getSignature()).getMethod();
+        DistributeLock distributeLock = method.getAnnotation(DistributeLock.class);
+
+        String key = distributeLock.key();
+        if (DistributeLockConstant.NONE_KEY.equals(key)) {
+            if (DistributeLockConstant.NONE_KEY.equals(distributeLock.keyExpression())) {
+                throw new DistributeLockException("no lock key found...");
+            }
+            SpelExpressionParser parser = new SpelExpressionParser();
+            Expression expression = parser.parseExpression(distributeLock.keyExpression());
+
+            EvaluationContext context = new StandardEvaluationContext();
+            // 获取参数值
+            Object[] args = pjp.getArgs();
+
+            // 获取运行时参数的名称
+            StandardReflectionParameterNameDiscoverer discoverer
+                    = new StandardReflectionParameterNameDiscoverer();
+            String[] parameterNames = discoverer.getParameterNames(method);
+
+            // 将参数绑定到context中
+            if (parameterNames != null) {
+                for (int i = 0; i < parameterNames.length; i++) {
+                    context.setVariable(parameterNames[i], args[i]);
+                }
+            }
+
+            // 解析表达式,获取结果
+            key = String.valueOf(expression.getValue(context));
+        }
+
+        String scene = distributeLock.scene();
+
+        String lockKey = scene + "#" + key;
+
+        int expireTime = distributeLock.expireTime();
+        int waitTime = distributeLock.waitTime();
+        RLock rLock= redissonClient.getLock(lockKey);
+        try {
+            boolean lockResult = false;
+            if (waitTime == DistributeLockConstant.DEFAULT_WAIT_TIME) {
+                if (expireTime == DistributeLockConstant.DEFAULT_EXPIRE_TIME) {
+//                    LOG.info(String.format("lock for key : %s", lockKey));
+                    rLock.lock();
+                } else {
+//                    LOG.info(String.format("lock for key : %s , expire : %s", lockKey, expireTime));
+                    rLock.lock(expireTime, TimeUnit.MILLISECONDS);
+                }
+                lockResult = true;
+            } else {
+                if (expireTime == DistributeLockConstant.DEFAULT_EXPIRE_TIME) {
+//                    LOG.info(String.format("try lock for key : %s , wait : %s", lockKey, waitTime));
+                    lockResult = rLock.tryLock(waitTime, TimeUnit.MILLISECONDS);
+                } else {
+//                    LOG.info(String.format("try lock for key : %s , expire : %s , wait : %s", lockKey, expireTime, waitTime));
+                    lockResult = rLock.tryLock(waitTime, expireTime, TimeUnit.MILLISECONDS);
+                }
+            }
+
+            if (!lockResult) {
+//                LOG.warn(String.format("lock failed for key : %s , expire : %s", lockKey, expireTime));
+                throw new DistributeLockException(distributeLock.errorMsg());
+            }
+
+
+//            LOG.info(String.format("lock success for key : %s , expire : %s", lockKey, expireTime));
+            response = pjp.proceed();
+        }  finally {
+            if (rLock.isHeldByCurrentThread()) {
+                rLock.unlock();
+//                LOG.info(String.format("unlock for key : %s , expire : %s", lockKey, expireTime));
+            }
+        }
+        return response;
+    }
+}

+ 13 - 0
fs-live-ws/src/main/java/com/fs/framework/aspectj/lock/DistributeLockConstant.java

@@ -0,0 +1,13 @@
+package com.fs.framework.aspectj.lock;
+
+public class DistributeLockConstant {
+
+    public static final String NONE_KEY = "NONE";
+
+    public static final String DEFAULT_OWNER = "DEFAULT";
+
+    public static final int DEFAULT_EXPIRE_TIME = -1;
+
+    public static final int DEFAULT_WAIT_TIME = Integer.MAX_VALUE;
+    public static final String ERROR_MSG  = "请勿重复操作";
+}

+ 24 - 0
fs-live-ws/src/main/java/com/fs/framework/aspectj/lock/DistributeLockException.java

@@ -0,0 +1,24 @@
+package com.fs.framework.aspectj.lock;
+
+
+public class DistributeLockException extends RuntimeException {
+
+    public DistributeLockException() {
+    }
+
+    public DistributeLockException(String message) {
+        super(message);
+    }
+
+    public DistributeLockException(String message, Throwable cause) {
+        super(message, cause);
+    }
+
+    public DistributeLockException(Throwable cause) {
+        super(cause);
+    }
+
+    public DistributeLockException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
+        super(message, cause, enableSuppression, writableStackTrace);
+    }
+}

+ 31 - 0
fs-live-ws/src/main/java/com/fs/framework/config/ApplicationConfig.java

@@ -0,0 +1,31 @@
+package com.fs.framework.config;
+
+import org.mybatis.spring.annotation.MapperScan;
+import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.EnableAspectJAutoProxy;
+
+import java.util.TimeZone;
+
+/**
+ * 程序注解配置
+ *
+
+ */
+@Configuration
+// 表示通过aop框架暴露该代理对象,AopContext能够访问
+@EnableAspectJAutoProxy(exposeProxy = true)
+// 指定要扫描的Mapper类的包的路径
+@MapperScan("com.fs.**.mapper")
+public class ApplicationConfig
+{
+    /**
+     * 时区配置
+     */
+    @Bean
+    public Jackson2ObjectMapperBuilderCustomizer jacksonObjectMapperCustomization()
+    {
+        return jacksonObjectMapperBuilder -> jacksonObjectMapperBuilder.timeZone(TimeZone.getDefault());
+    }
+}

+ 85 - 0
fs-live-ws/src/main/java/com/fs/framework/config/CaptchaConfig.java

@@ -0,0 +1,85 @@
+package com.fs.framework.config;
+
+import com.google.code.kaptcha.impl.DefaultKaptcha;
+import com.google.code.kaptcha.util.Config;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.util.Properties;
+
+import static com.google.code.kaptcha.Constants.*;
+
+/**
+ * 验证码配置
+ * 
+
+ */
+@Configuration
+public class CaptchaConfig
+{
+    @Bean(name = "captchaProducer")
+    public DefaultKaptcha getKaptchaBean()
+    {
+        DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
+        Properties properties = new Properties();
+        // 是否有边框 默认为true 我们可以自己设置yes,no
+        properties.setProperty(KAPTCHA_BORDER, "yes");
+        // 验证码文本字符颜色 默认为Color.BLACK
+        properties.setProperty(KAPTCHA_TEXTPRODUCER_FONT_COLOR, "black");
+        // 验证码图片宽度 默认为200
+        properties.setProperty(KAPTCHA_IMAGE_WIDTH, "160");
+        // 验证码图片高度 默认为50
+        properties.setProperty(KAPTCHA_IMAGE_HEIGHT, "60");
+        // 验证码文本字符大小 默认为40
+        properties.setProperty(KAPTCHA_TEXTPRODUCER_FONT_SIZE, "38");
+        // KAPTCHA_SESSION_KEY
+        properties.setProperty(KAPTCHA_SESSION_CONFIG_KEY, "kaptchaCode");
+        // 验证码文本字符长度 默认为5
+        properties.setProperty(KAPTCHA_TEXTPRODUCER_CHAR_LENGTH, "4");
+        // 验证码文本字体样式 默认为new Font("Arial", 1, fontSize), new Font("Courier", 1, fontSize)
+        properties.setProperty(KAPTCHA_TEXTPRODUCER_FONT_NAMES, "Arial,Courier");
+        // 图片样式 水纹com.google.code.kaptcha.impl.WaterRipple 鱼眼com.google.code.kaptcha.impl.FishEyeGimpy 阴影com.google.code.kaptcha.impl.ShadowGimpy
+        properties.setProperty(KAPTCHA_OBSCURIFICATOR_IMPL, "com.google.code.kaptcha.impl.ShadowGimpy");
+        Config config = new Config(properties);
+        defaultKaptcha.setConfig(config);
+        return defaultKaptcha;
+    }
+
+    @Bean(name = "captchaProducerMath")
+    public DefaultKaptcha getKaptchaBeanMath()
+    {
+        DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
+        Properties properties = new Properties();
+        // 是否有边框 默认为true 我们可以自己设置yes,no
+        properties.setProperty(KAPTCHA_BORDER, "yes");
+        // 边框颜色 默认为Color.BLACK
+        properties.setProperty(KAPTCHA_BORDER_COLOR, "105,179,90");
+        // 验证码文本字符颜色 默认为Color.BLACK
+        properties.setProperty(KAPTCHA_TEXTPRODUCER_FONT_COLOR, "blue");
+        // 验证码图片宽度 默认为200
+        properties.setProperty(KAPTCHA_IMAGE_WIDTH, "160");
+        // 验证码图片高度 默认为50
+        properties.setProperty(KAPTCHA_IMAGE_HEIGHT, "60");
+        // 验证码文本字符大小 默认为40
+        properties.setProperty(KAPTCHA_TEXTPRODUCER_FONT_SIZE, "35");
+        // KAPTCHA_SESSION_KEY
+        properties.setProperty(KAPTCHA_SESSION_CONFIG_KEY, "kaptchaCodeMath");
+        // 验证码文本生成器
+        properties.setProperty(KAPTCHA_TEXTPRODUCER_IMPL, "com.fs.framework.config.KaptchaTextCreator");
+        // 验证码文本字符间距 默认为2
+        properties.setProperty(KAPTCHA_TEXTPRODUCER_CHAR_SPACE, "3");
+        // 验证码文本字符长度 默认为5
+        properties.setProperty(KAPTCHA_TEXTPRODUCER_CHAR_LENGTH, "6");
+        // 验证码文本字体样式 默认为new Font("Arial", 1, fontSize), new Font("Courier", 1, fontSize)
+        properties.setProperty(KAPTCHA_TEXTPRODUCER_FONT_NAMES, "Arial,Courier");
+        // 验证码噪点颜色 默认为Color.BLACK
+        properties.setProperty(KAPTCHA_NOISE_COLOR, "white");
+        // 干扰实现类
+        properties.setProperty(KAPTCHA_NOISE_IMPL, "com.google.code.kaptcha.impl.NoNoise");
+        // 图片样式 水纹com.google.code.kaptcha.impl.WaterRipple 鱼眼com.google.code.kaptcha.impl.FishEyeGimpy 阴影com.google.code.kaptcha.impl.ShadowGimpy
+        properties.setProperty(KAPTCHA_OBSCURIFICATOR_IMPL, "com.google.code.kaptcha.impl.ShadowGimpy");
+        Config config = new Config(properties);
+        defaultKaptcha.setConfig(config);
+        return defaultKaptcha;
+    }
+}

+ 98 - 0
fs-live-ws/src/main/java/com/fs/framework/config/DataSourceConfig.java

@@ -0,0 +1,98 @@
+package com.fs.framework.config;
+
+import com.alibaba.druid.pool.DruidDataSource;
+import com.alibaba.druid.spring.boot.autoconfigure.properties.DruidStatProperties;
+import com.alibaba.druid.util.Utils;
+import com.fs.common.enums.DataSourceType;
+import com.fs.framework.datasource.DynamicDataSource;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.boot.web.servlet.FilterRegistrationBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Primary;
+
+import javax.servlet.*;
+import javax.sql.DataSource;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+@Configuration
+public class DataSourceConfig {
+
+    @Bean
+    @ConfigurationProperties(prefix = "spring.datasource.sop.druid.master")
+    public DataSource sopDataSource() {
+        return new DruidDataSource();
+    }
+
+    @Bean
+    @ConfigurationProperties(prefix = "spring.datasource.mysql.druid.master")
+    public DataSource masterDataSource() {
+        return new DruidDataSource();
+    }
+
+    @Bean
+    @ConfigurationProperties(prefix = "spring.datasource.mysql.druid.slave")
+    public DataSource slaveDataSource() {
+        return new DruidDataSource();
+    }
+
+
+    @Bean
+    @Primary
+    public DynamicDataSource dataSource(@Qualifier("masterDataSource") DataSource masterDataSource, @Qualifier("sopDataSource") DataSource sopDataSource
+            , @Qualifier("slaveDataSource") DataSource slaveDataSource) {
+        Map<Object, Object> targetDataSources = new HashMap<>();
+        targetDataSources.put(DataSourceType.SLAVE.name(), slaveDataSource);
+        targetDataSources.put(DataSourceType.SOP.name(), sopDataSource);
+        return new DynamicDataSource(masterDataSource, targetDataSources);
+    }
+    /**
+     * 去除监控页面底部的广告
+     */
+    @SuppressWarnings({ "rawtypes", "unchecked" })
+    @Bean
+    @ConditionalOnProperty(name = "spring.datasource.mysql.druid.statViewServlet.enabled", havingValue = "true")
+    public FilterRegistrationBean removeDruidFilterRegistrationBean(DruidStatProperties properties)
+    {
+        // 获取web监控页面的参数
+        DruidStatProperties.StatViewServlet config = properties.getStatViewServlet();
+        // 提取common.js的配置路径
+        String pattern = config.getUrlPattern() != null ? config.getUrlPattern() : "/druid/*";
+        String commonJsPattern = pattern.replaceAll("\\*", "js/common.js");
+        final String filePath = "support/http/resources/js/common.js";
+        // 创建filter进行过滤
+        Filter filter = new Filter()
+        {
+            @Override
+            public void init(javax.servlet.FilterConfig filterConfig) throws ServletException
+            {
+            }
+            @Override
+            public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+                    throws IOException, ServletException
+            {
+                chain.doFilter(request, response);
+                // 重置缓冲区,响应头不会被重置
+                response.resetBuffer();
+                // 获取common.js
+                String text = Utils.readFromResource(filePath);
+                // 正则替换banner, 除去底部的广告信息
+                text = text.replaceAll("<a.*?banner\"></a><br/>", "");
+                text = text.replaceAll("powered.*?shrek.wang</a>", "");
+                response.getWriter().write(text);
+            }
+            @Override
+            public void destroy()
+            {
+            }
+        };
+        FilterRegistrationBean registrationBean = new FilterRegistrationBean();
+        registrationBean.setFilter(filter);
+        registrationBean.addUrlPatterns(commonJsPattern);
+        return registrationBean;
+    }
+}

+ 123 - 0
fs-live-ws/src/main/java/com/fs/framework/config/DruidConfig.java

@@ -0,0 +1,123 @@
+package com.fs.framework.config;//package com.fs.framework.config;
+//
+//import com.alibaba.druid.pool.DruidDataSource;
+//import com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceBuilder;
+//import com.alibaba.druid.spring.boot.autoconfigure.properties.DruidStatProperties;
+//import com.alibaba.druid.util.Utils;
+//import com.fs.common.enums.DataSourceType;
+//import com.fs.common.utils.spring.SpringUtils;
+//import com.fs.framework.config.properties.DruidProperties;
+//import com.fs.framework.datasource.DynamicDataSource;
+//import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+//import org.springframework.boot.context.properties.ConfigurationProperties;
+//import org.springframework.boot.web.servlet.FilterRegistrationBean;
+//import org.springframework.context.annotation.Bean;
+//import org.springframework.context.annotation.Configuration;
+//import org.springframework.context.annotation.Primary;
+//
+//import javax.servlet.*;
+//import javax.sql.DataSource;
+//import java.io.IOException;
+//import java.util.HashMap;
+//import java.util.Map;
+//
+///**
+// * druid 配置多数据源
+// *
+//
+// */
+//@Configuration
+//public class DruidConfig
+//{
+//    @Bean
+//    @ConfigurationProperties("spring.datasource.mysql.druid.master")
+//    public DataSource masterDataSource(DruidProperties druidProperties)
+//    {
+//        DruidDataSource dataSource = DruidDataSourceBuilder.create().build();
+//        return druidProperties.dataSource(dataSource);
+//    }
+//
+//    @Bean
+//    @ConfigurationProperties("spring.datasource.mysql.druid.slave")
+//    @ConditionalOnProperty(prefix = "spring.datasource.mysql.druid.slave", name = "enabled", havingValue = "true")
+//    public DataSource slaveDataSource(DruidProperties druidProperties)
+//    {
+//        DruidDataSource dataSource = DruidDataSourceBuilder.create().build();
+//        return druidProperties.dataSource(dataSource);
+//    }
+//
+//    @Bean(name = "dynamicDataSource")
+//    @Primary
+//    public DynamicDataSource dataSource(DataSource masterDataSource)
+//    {
+//        Map<Object, Object> targetDataSources = new HashMap<>();
+//        targetDataSources.put(DataSourceType.MASTER.name(), masterDataSource);
+//        setDataSource(targetDataSources, DataSourceType.SLAVE.name(), "slaveDataSource");
+//        return new DynamicDataSource(masterDataSource, targetDataSources);
+//    }
+//
+//    /**
+//     * 设置数据源
+//     *
+//     * @param targetDataSources 备选数据源集合
+//     * @param sourceName 数据源名称
+//     * @param beanName bean名称
+//     */
+//    public void setDataSource(Map<Object, Object> targetDataSources, String sourceName, String beanName)
+//    {
+//        try
+//        {
+//            DataSource dataSource = SpringUtils.getBean(beanName);
+//            targetDataSources.put(sourceName, dataSource);
+//        }
+//        catch (Exception e)
+//        {
+//        }
+//    }
+//
+//    /**
+//     * 去除监控页面底部的广告
+//     */
+//    @SuppressWarnings({ "rawtypes", "unchecked" })
+//    @Bean
+//    @ConditionalOnProperty(name = "spring.datasource.mysql.druid.statViewServlet.enabled", havingValue = "true")
+//    public FilterRegistrationBean removeDruidFilterRegistrationBean(DruidStatProperties properties)
+//    {
+//        // 获取web监控页面的参数
+//        DruidStatProperties.StatViewServlet config = properties.getStatViewServlet();
+//        // 提取common.js的配置路径
+//        String pattern = config.getUrlPattern() != null ? config.getUrlPattern() : "/druid/*";
+//        String commonJsPattern = pattern.replaceAll("\\*", "js/common.js");
+//        final String filePath = "support/http/resources/js/common.js";
+//        // 创建filter进行过滤
+//        Filter filter = new Filter()
+//        {
+//            @Override
+//            public void init(javax.servlet.FilterConfig filterConfig) throws ServletException
+//            {
+//            }
+//            @Override
+//            public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+//                    throws IOException, ServletException
+//            {
+//                chain.doFilter(request, response);
+//                // 重置缓冲区,响应头不会被重置
+//                response.resetBuffer();
+//                // 获取common.js
+//                String text = Utils.readFromResource(filePath);
+//                // 正则替换banner, 除去底部的广告信息
+//                text = text.replaceAll("<a.*?banner\"></a><br/>", "");
+//                text = text.replaceAll("powered.*?shrek.wang</a>", "");
+//                response.getWriter().write(text);
+//            }
+//            @Override
+//            public void destroy()
+//            {
+//            }
+//        };
+//        FilterRegistrationBean registrationBean = new FilterRegistrationBean();
+//        registrationBean.setFilter(filter);
+//        registrationBean.addUrlPatterns(commonJsPattern);
+//        return registrationBean;
+//    }
+//}

+ 72 - 0
fs-live-ws/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -0,0 +1,72 @@
+package com.fs.framework.config;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.parser.ParserConfig;
+import com.alibaba.fastjson.serializer.SerializerFeature;
+import com.fasterxml.jackson.databind.JavaType;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.type.TypeFactory;
+import org.springframework.data.redis.serializer.RedisSerializer;
+import org.springframework.data.redis.serializer.SerializationException;
+import org.springframework.util.Assert;
+
+import java.nio.charset.Charset;
+
+/**
+ * Redis使用FastJson序列化
+ * 
+
+ */
+public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
+{
+    @SuppressWarnings("unused")
+    private ObjectMapper objectMapper = new ObjectMapper();
+
+    public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");
+
+    private Class<T> clazz;
+
+    static
+    {
+        ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
+    }
+
+    public FastJson2JsonRedisSerializer(Class<T> clazz)
+    {
+        super();
+        this.clazz = clazz;
+    }
+
+    @Override
+    public byte[] serialize(T t) throws SerializationException
+    {
+        if (t == null)
+        {
+            return new byte[0];
+        }
+        return JSON.toJSONString(t, SerializerFeature.WriteClassName).getBytes(DEFAULT_CHARSET);
+    }
+
+    @Override
+    public T deserialize(byte[] bytes) throws SerializationException
+    {
+        if (bytes == null || bytes.length <= 0)
+        {
+            return null;
+        }
+        String str = new String(bytes, DEFAULT_CHARSET);
+
+        return JSON.parseObject(str, clazz);
+    }
+
+    public void setObjectMapper(ObjectMapper objectMapper)
+    {
+        Assert.notNull(objectMapper, "'objectMapper' must not be null");
+        this.objectMapper = objectMapper;
+    }
+
+    protected JavaType getJavaType(Class<?> clazz)
+    {
+        return TypeFactory.defaultInstance().constructType(clazz);
+    }
+}

+ 59 - 0
fs-live-ws/src/main/java/com/fs/framework/config/FilterConfig.java

@@ -0,0 +1,59 @@
+package com.fs.framework.config;
+
+import com.fs.common.filter.RepeatableFilter;
+import com.fs.common.filter.XssFilter;
+import com.fs.common.utils.StringUtils;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.web.servlet.FilterRegistrationBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import javax.servlet.DispatcherType;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Filter配置
+ *
+
+ */
+@Configuration
+@ConditionalOnProperty(value = "xss.enabled", havingValue = "true")
+public class FilterConfig
+{
+    @Value("${xss.excludes}")
+    private String excludes;
+
+    @Value("${xss.urlPatterns}")
+    private String urlPatterns;
+
+    @SuppressWarnings({ "rawtypes", "unchecked" })
+    @Bean
+    public FilterRegistrationBean xssFilterRegistration()
+    {
+        FilterRegistrationBean registration = new FilterRegistrationBean();
+        registration.setDispatcherTypes(DispatcherType.REQUEST);
+        registration.setFilter(new XssFilter());
+        registration.addUrlPatterns(StringUtils.split(urlPatterns, ","));
+        registration.setName("xssFilter");
+        registration.setOrder(FilterRegistrationBean.HIGHEST_PRECEDENCE);
+        Map<String, String> initParameters = new HashMap<String, String>();
+        initParameters.put("excludes", excludes);
+        registration.setInitParameters(initParameters);
+        return registration;
+    }
+
+    @SuppressWarnings({ "rawtypes", "unchecked" })
+    @Bean
+    public FilterRegistrationBean someFilterRegistration()
+    {
+        FilterRegistrationBean registration = new FilterRegistrationBean();
+        registration.setFilter(new RepeatableFilter());
+        registration.addUrlPatterns("/*");
+        registration.setName("repeatableFilter");
+        registration.setOrder(FilterRegistrationBean.LOWEST_PRECEDENCE);
+        return registration;
+    }
+
+}

+ 76 - 0
fs-live-ws/src/main/java/com/fs/framework/config/KaptchaTextCreator.java

@@ -0,0 +1,76 @@
+package com.fs.framework.config;
+
+import com.google.code.kaptcha.text.impl.DefaultTextCreator;
+
+import java.util.Random;
+
+/**
+ * 验证码文本生成器
+ * 
+
+ */
+public class KaptchaTextCreator extends DefaultTextCreator
+{
+    private static final String[] CNUMBERS = "0,1,2,3,4,5,6,7,8,9,10".split(",");
+
+    @Override
+    public String getText()
+    {
+        Integer result = 0;
+        Random random = new Random();
+        int x = random.nextInt(10);
+        int y = random.nextInt(10);
+        StringBuilder suChinese = new StringBuilder();
+        int randomoperands = (int) Math.round(Math.random() * 2);
+        if (randomoperands == 0)
+        {
+            result = x * y;
+            suChinese.append(CNUMBERS[x]);
+            suChinese.append("*");
+            suChinese.append(CNUMBERS[y]);
+        }
+        else if (randomoperands == 1)
+        {
+            if (!(x == 0) && y % x == 0)
+            {
+                result = y / x;
+                suChinese.append(CNUMBERS[y]);
+                suChinese.append("/");
+                suChinese.append(CNUMBERS[x]);
+            }
+            else
+            {
+                result = x + y;
+                suChinese.append(CNUMBERS[x]);
+                suChinese.append("+");
+                suChinese.append(CNUMBERS[y]);
+            }
+        }
+        else if (randomoperands == 2)
+        {
+            if (x >= y)
+            {
+                result = x - y;
+                suChinese.append(CNUMBERS[x]);
+                suChinese.append("-");
+                suChinese.append(CNUMBERS[y]);
+            }
+            else
+            {
+                result = y - x;
+                suChinese.append(CNUMBERS[y]);
+                suChinese.append("-");
+                suChinese.append(CNUMBERS[x]);
+            }
+        }
+        else
+        {
+            result = x + y;
+            suChinese.append(CNUMBERS[x]);
+            suChinese.append("+");
+            suChinese.append(CNUMBERS[y]);
+        }
+        suChinese.append("=?@" + result);
+        return suChinese.toString();
+    }
+}

+ 150 - 0
fs-live-ws/src/main/java/com/fs/framework/config/MyBatisConfig.java

@@ -0,0 +1,150 @@
+package com.fs.framework.config;
+
+import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
+import com.fs.common.utils.StringUtils;
+import org.apache.ibatis.io.VFS;
+import org.apache.ibatis.session.SqlSessionFactory;
+import org.mybatis.spring.SqlSessionFactoryBean;
+import org.mybatis.spring.boot.autoconfigure.SpringBootVFS;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.env.Environment;
+import org.springframework.core.io.DefaultResourceLoader;
+import org.springframework.core.io.Resource;
+import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
+import org.springframework.core.io.support.ResourcePatternResolver;
+import org.springframework.core.type.classreading.CachingMetadataReaderFactory;
+import org.springframework.core.type.classreading.MetadataReader;
+import org.springframework.core.type.classreading.MetadataReaderFactory;
+import org.springframework.util.ClassUtils;
+
+import javax.sql.DataSource;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+
+/**
+ * Mybatis支持*匹配扫描包
+ *
+
+ */
+@Configuration
+public class MyBatisConfig
+{
+    @Autowired
+    private Environment env;
+
+    static final String DEFAULT_RESOURCE_PATTERN = "**/*.class";
+
+    public static String setTypeAliasesPackage(String typeAliasesPackage)
+    {
+        ResourcePatternResolver resolver = (ResourcePatternResolver) new PathMatchingResourcePatternResolver();
+        MetadataReaderFactory metadataReaderFactory = new CachingMetadataReaderFactory(resolver);
+        List<String> allResult = new ArrayList<String>();
+        try
+        {
+            for (String aliasesPackage : typeAliasesPackage.split(","))
+            {
+                List<String> result = new ArrayList<String>();
+                aliasesPackage = ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX
+                        + ClassUtils.convertClassNameToResourcePath(aliasesPackage.trim()) + "/" + DEFAULT_RESOURCE_PATTERN;
+                Resource[] resources = resolver.getResources(aliasesPackage);
+                if (resources != null && resources.length > 0)
+                {
+                    MetadataReader metadataReader = null;
+                    for (Resource resource : resources)
+                    {
+                        if (resource.isReadable())
+                        {
+                            metadataReader = metadataReaderFactory.getMetadataReader(resource);
+                            try
+                            {
+                                result.add(Class.forName(metadataReader.getClassMetadata().getClassName()).getPackage().getName());
+                            }
+                            catch (ClassNotFoundException e)
+                            {
+                                e.printStackTrace();
+                            }
+                        }
+                    }
+                }
+                if (result.size() > 0)
+                {
+                    HashSet<String> hashResult = new HashSet<String>(result);
+                    allResult.addAll(hashResult);
+                }
+            }
+            if (allResult.size() > 0)
+            {
+                typeAliasesPackage = String.join(",", (String[]) allResult.toArray(new String[0]));
+            }
+            else
+            {
+                throw new RuntimeException("mybatis typeAliasesPackage 路径扫描错误,参数typeAliasesPackage:" + typeAliasesPackage + "未找到任何包");
+            }
+        }
+        catch (IOException e)
+        {
+            e.printStackTrace();
+        }
+        return typeAliasesPackage;
+    }
+
+    public Resource[] resolveMapperLocations(String[] mapperLocations)
+    {
+        ResourcePatternResolver resourceResolver = new PathMatchingResourcePatternResolver();
+        List<Resource> resources = new ArrayList<Resource>();
+        if (mapperLocations != null)
+        {
+            for (String mapperLocation : mapperLocations)
+            {
+                try
+                {
+                    Resource[] mappers = resourceResolver.getResources(mapperLocation);
+                    resources.addAll(Arrays.asList(mappers));
+                }
+                catch (IOException e)
+                {
+                    // ignore
+                }
+            }
+        }
+        return resources.toArray(new Resource[resources.size()]);
+    }
+
+//    @Bean
+//    public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception
+//    {
+//        String typeAliasesPackage = env.getProperty("mybatis.typeAliasesPackage");
+//        String mapperLocations = env.getProperty("mybatis.mapperLocations");
+//        String configLocation = env.getProperty("mybatis.configLocation");
+//        typeAliasesPackage = setTypeAliasesPackage(typeAliasesPackage);
+//        VFS.addImplClass(SpringBootVFS.class);
+//
+//        final SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean();
+//        sessionFactory.setDataSource(dataSource);
+//        sessionFactory.setTypeAliasesPackage(typeAliasesPackage);
+//        sessionFactory.setMapperLocations(resolveMapperLocations(StringUtils.split(mapperLocations, ",")));
+//        sessionFactory.setConfigLocation(new DefaultResourceLoader().getResource(configLocation));
+//        return sessionFactory.getObject();
+//    }
+    @Bean
+    public SqlSessionFactory sqlSessionFactorys(DataSource dataSource) throws Exception
+    {
+        String typeAliasesPackage = env.getProperty("mybatis-plus.typeAliasesPackage");
+        String mapperLocations = env.getProperty("mybatis-plus.mapperLocations");
+        String configLocation = env.getProperty("mybatis-plus.configLocation");
+        typeAliasesPackage = setTypeAliasesPackage(typeAliasesPackage);
+        VFS.addImplClass(SpringBootVFS.class);
+
+        final MybatisSqlSessionFactoryBean sessionFactory = new MybatisSqlSessionFactoryBean();
+        sessionFactory.setDataSource(dataSource);
+        sessionFactory.setTypeAliasesPackage(typeAliasesPackage);
+        sessionFactory.setMapperLocations(new PathMatchingResourcePatternResolver().getResources(mapperLocations));
+        sessionFactory.setConfigLocation(new DefaultResourceLoader().getResource(configLocation));
+        return sessionFactory.getObject();
+    }
+}

+ 159 - 0
fs-live-ws/src/main/java/com/fs/framework/config/RedisConfig.java

@@ -0,0 +1,159 @@
+package com.fs.framework.config;
+
+import com.fasterxml.jackson.annotation.JsonAutoDetect;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import com.fasterxml.jackson.annotation.PropertyAccessor;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
+import org.springframework.cache.annotation.CachingConfigurerSupport;
+import org.springframework.cache.annotation.EnableCaching;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.script.DefaultRedisScript;
+import org.springframework.data.redis.serializer.GenericToStringSerializer;
+import org.springframework.data.redis.serializer.StringRedisSerializer;
+
+import java.math.BigDecimal;
+
+/**
+ * redis配置
+ *
+
+ */
+@Configuration
+@EnableCaching
+public class RedisConfig extends CachingConfigurerSupport
+{
+    @Bean
+    @SuppressWarnings(value = { "unchecked", "rawtypes" })
+    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory)
+    {
+        RedisTemplate<Object, Object> template = new RedisTemplate<>();
+        template.setConnectionFactory(connectionFactory);
+
+        FastJson2JsonRedisSerializer serializer = new FastJson2JsonRedisSerializer(Object.class);
+
+        ObjectMapper mapper = new ObjectMapper();
+        mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
+        mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
+        serializer.setObjectMapper(mapper);
+
+        // 使用StringRedisSerializer来序列化和反序列化redis的key值
+        template.setKeySerializer(new StringRedisSerializer());
+        template.setValueSerializer(serializer);
+
+        // Hash的key也采用StringRedisSerializer的序列化方式
+        template.setHashKeySerializer(new StringRedisSerializer());
+        template.setHashValueSerializer(serializer);
+
+        template.afterPropertiesSet();
+        return template;
+    }
+    @Bean
+    public RedisTemplate<String, Boolean> redisTemplateForBoolean(RedisConnectionFactory connectionFactory) {
+        RedisTemplate<String, Boolean> template = new RedisTemplate<>();
+        template.setConnectionFactory(connectionFactory);
+
+        // 使用StringRedisSerializer来序列化和反序列化redis的key值
+        template.setKeySerializer(new StringRedisSerializer());
+        template.setValueSerializer(new GenericToStringSerializer<>(Boolean.class));
+
+        // Hash的key也采用StringRedisSerializer的序列化方式
+        template.setHashKeySerializer(new StringRedisSerializer());
+        template.setHashValueSerializer(new GenericToStringSerializer<>(Boolean.class));
+
+        template.afterPropertiesSet();
+        return template;
+    }
+    @Bean
+    public RedisTemplate<String, Integer> redisTemplateForInteger(RedisConnectionFactory connectionFactory) {
+        RedisTemplate<String, Integer> template = new RedisTemplate<>();
+        template.setConnectionFactory(connectionFactory);
+
+        // 使用StringRedisSerializer来序列化和反序列化redis的key值
+        template.setKeySerializer(new StringRedisSerializer());
+
+        // 使用GenericToStringSerializer保证BigDecimal精度不丢失
+        template.setValueSerializer(new GenericToStringSerializer<>(Integer.class));
+
+        // Hash的key也采用StringRedisSerializer的序列化方式
+        template.setHashKeySerializer(new StringRedisSerializer());
+        template.setHashValueSerializer(new GenericToStringSerializer<>(Integer.class));
+
+        template.afterPropertiesSet();
+        return template;
+    }
+
+    @Bean
+    @SuppressWarnings(value = { "unchecked", "rawtypes" })
+    public RedisTemplate<String, Object> redisTemplateForObject(RedisConnectionFactory connectionFactory) {
+        RedisTemplate<String, Object> template = new RedisTemplate<>();
+        template.setConnectionFactory(connectionFactory);
+
+        FastJson2JsonRedisSerializer serializer = new FastJson2JsonRedisSerializer(Object.class);
+
+        ObjectMapper mapper = new ObjectMapper();
+        mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
+        mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
+        serializer.setObjectMapper(mapper);
+
+        // 使用StringRedisSerializer来序列化和反序列化redis的key值
+        template.setKeySerializer(new StringRedisSerializer());
+        template.setValueSerializer(serializer);
+
+        // Hash的key也采用StringRedisSerializer的序列化方式
+        template.setHashKeySerializer(new StringRedisSerializer());
+        template.setHashValueSerializer(serializer);
+
+        template.afterPropertiesSet();
+        return template;
+    }
+
+    @Bean
+    public RedisTemplate<String, BigDecimal> redisTemplateForBigDecimal(RedisConnectionFactory connectionFactory) {
+        RedisTemplate<String, BigDecimal> template = new RedisTemplate<>();
+        template.setConnectionFactory(connectionFactory);
+
+        // 使用StringRedisSerializer来序列化和反序列化redis的key值
+        template.setKeySerializer(new StringRedisSerializer());
+
+        // 使用GenericToStringSerializer保证BigDecimal精度不丢失
+        template.setValueSerializer(new GenericToStringSerializer<>(BigDecimal.class));
+
+        // Hash的key也采用StringRedisSerializer的序列化方式
+        template.setHashKeySerializer(new StringRedisSerializer());
+        template.setHashValueSerializer(new GenericToStringSerializer<>(BigDecimal.class));
+
+        template.afterPropertiesSet();
+        return template;
+    }
+    @Bean
+    public DefaultRedisScript<Long> limitScript()
+    {
+        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
+        redisScript.setScriptText(limitScriptText());
+        redisScript.setResultType(Long.class);
+        return redisScript;
+    }
+
+    /**
+     * 限流脚本
+     */
+    private String limitScriptText()
+    {
+        return "local key = KEYS[1]\n" +
+                "local count = tonumber(ARGV[1])\n" +
+                "local time = tonumber(ARGV[2])\n" +
+                "local current = redis.call('get', key);\n" +
+                "if current and tonumber(current) > count then\n" +
+                "    return current;\n" +
+                "end\n" +
+                "current = redis.call('incr', key)\n" +
+                "if tonumber(current) == 1 then\n" +
+                "    redis.call('expire', key, time)\n" +
+                "end\n" +
+                "return current;";
+    }
+}

+ 65 - 0
fs-live-ws/src/main/java/com/fs/framework/config/ResourcesConfig.java

@@ -0,0 +1,65 @@
+package com.fs.framework.config;
+
+import com.fs.common.config.FSConfig;
+import com.fs.common.constant.Constants;
+import com.fs.framework.interceptor.RepeatSubmitInterceptor;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
+import org.springframework.web.filter.CorsFilter;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+/**
+ * 通用配置
+ * 
+
+ */
+@Configuration
+public class ResourcesConfig implements WebMvcConfigurer
+{
+    @Autowired
+    private RepeatSubmitInterceptor repeatSubmitInterceptor;
+
+    @Override
+    public void addResourceHandlers(ResourceHandlerRegistry registry)
+    {
+        /** 本地文件上传路径 */
+        registry.addResourceHandler(Constants.RESOURCE_PREFIX + "/**").addResourceLocations("file:" + FSConfig.getProfile() + "/");
+
+        /** swagger配置 */
+        registry.addResourceHandler("/swagger-ui/**").addResourceLocations("classpath:/META-INF/resources/webjars/springfox-swagger-ui/");
+    }
+
+    /**
+     * 自定义拦截规则
+     */
+    @Override
+    public void addInterceptors(InterceptorRegistry registry)
+    {
+        registry.addInterceptor(repeatSubmitInterceptor).addPathPatterns("/**");
+    }
+
+    /**
+     * 跨域配置
+     */
+    @Bean
+    public CorsFilter corsFilter()
+    {
+        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+        CorsConfiguration config = new CorsConfiguration();
+        config.setAllowCredentials(true);
+        // 设置访问源地址
+        config.addAllowedOrigin("*");
+        // 设置访问源请求头
+        config.addAllowedHeader("*");
+        // 设置访问源请求方法
+        config.addAllowedMethod("*");
+        // 对接口配置跨域设置
+        source.registerCorsConfiguration("/**", config);
+        return new CorsFilter(source);
+    }
+}

+ 50 - 0
fs-live-ws/src/main/java/com/fs/framework/config/SecurityConfig.java

@@ -0,0 +1,50 @@
+package com.fs.framework.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.config.BeanIds;
+import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
+
+/**
+ * spring security配置
+ * 
+
+ */
+@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
+public class SecurityConfig extends WebSecurityConfigurerAdapter
+{
+
+    /**
+     * anyRequest          |   匹配所有请求路径
+     * access              |   SpringEl表达式结果为true时可以访问
+     * anonymous           |   匿名可以访问
+     * denyAll             |   用户不能访问
+     * fullyAuthenticated  |   用户完全认证可以访问(非remember-me下自动登录)
+     * hasAnyAuthority     |   如果有参数,参数表示权限,则其中任何一个权限可以访问
+     * hasAnyRole          |   如果有参数,参数表示角色,则其中任何一个角色可以访问
+     * hasAuthority        |   如果有参数,参数表示权限,则其权限可以访问
+     * hasIpAddress        |   如果有参数,参数表示IP地址,如果用户IP和参数匹配,则可以访问
+     * hasRole             |   如果有参数,参数表示角色,则其角色可以访问
+     * permitAll           |   用户可以任意访问
+     * rememberMe          |   允许通过remember-me登录的用户访问
+     * authenticated       |   用户登录后可访问
+     */
+    @Override
+    protected void configure(HttpSecurity http) throws Exception
+    {
+        http.authorizeRequests()
+                .antMatchers("/**").permitAll()
+                .anyRequest().authenticated()
+                .and().csrf().disable();
+    }
+
+    @Bean(name = BeanIds.AUTHENTICATION_MANAGER)
+    @Override
+    public AuthenticationManager authenticationManagerBean() throws Exception {
+        return super.authenticationManagerBean();
+    }
+
+
+}

+ 33 - 0
fs-live-ws/src/main/java/com/fs/framework/config/ServerConfig.java

@@ -0,0 +1,33 @@
+package com.fs.framework.config;
+
+import com.fs.common.utils.ServletUtils;
+import org.springframework.stereotype.Component;
+
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * 服务相关配置
+ * 
+
+ */
+@Component
+public class ServerConfig
+{
+    /**
+     * 获取完整的请求路径,包括:域名,端口,上下文访问路径
+     * 
+     * @return 服务地址
+     */
+    public String getUrl()
+    {
+        HttpServletRequest request = ServletUtils.getRequest();
+        return getDomain(request);
+    }
+
+    public static String getDomain(HttpServletRequest request)
+    {
+        StringBuffer url = request.getRequestURL();
+        String contextPath = request.getServletContext().getContextPath();
+        return url.delete(url.length() - request.getRequestURI().length(), url.length()).append(contextPath).toString();
+    }
+}

+ 121 - 0
fs-live-ws/src/main/java/com/fs/framework/config/SwaggerConfig.java

@@ -0,0 +1,121 @@
+package com.fs.framework.config;
+
+import com.fs.common.config.FSConfig;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.models.auth.In;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import springfox.documentation.builders.ApiInfoBuilder;
+import springfox.documentation.builders.PathSelectors;
+import springfox.documentation.builders.RequestHandlerSelectors;
+import springfox.documentation.service.*;
+import springfox.documentation.spi.DocumentationType;
+import springfox.documentation.spi.service.contexts.SecurityContext;
+import springfox.documentation.spring.web.plugins.Docket;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Swagger2的接口配置
+ * 
+
+ */
+@Configuration
+public class SwaggerConfig
+{
+    /** 系统基础配置 */
+    @Autowired
+    private FSConfig fsConfig;
+
+    /** 是否开启swagger */
+    @Value("${swagger.enabled}")
+    private boolean enabled;
+
+    /** 设置请求的统一前缀 */
+    @Value("${swagger.pathMapping}")
+    private String pathMapping;
+
+    /**
+     * 创建API
+     */
+    @Bean
+    public Docket createRestApi()
+    {
+        return new Docket(DocumentationType.SWAGGER_2)
+                // 是否启用Swagger
+                .enable(enabled)
+                // 用来创建该API的基本信息,展示在文档的页面中(自定义展示的信息)
+                .apiInfo(apiInfo())
+                // 设置哪些接口暴露给Swagger展示
+                .select()
+                // 扫描所有有注解的api,用这种方式更灵活
+                .apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class))
+                // 扫描指定包中的swagger注解
+                // .apis(RequestHandlerSelectors.basePackage("com.fs.project.tool.swagger"))
+                // 扫描所有 .apis(RequestHandlerSelectors.any())
+                .paths(PathSelectors.any())
+                .build()
+                /* 设置安全模式,swagger可以设置访问token */
+                .securitySchemes(securitySchemes())
+                .securityContexts(securityContexts())
+                .pathMapping(pathMapping);
+    }
+
+    /**
+     * 安全模式,这里指定token通过Authorization头请求头传递
+     */
+    private List<ApiKey> securitySchemes()
+    {
+        List<ApiKey> apiKeyList = new ArrayList<ApiKey>();
+        apiKeyList.add(new ApiKey("Authorization", "Authorization", "header"));
+        return apiKeyList;
+    }
+
+    /**
+     * 安全上下文
+     */
+    private List<SecurityContext> securityContexts()
+    {
+        List<SecurityContext> securityContexts = new ArrayList<>();
+        securityContexts.add(
+                SecurityContext.builder()
+                        .securityReferences(defaultAuth())
+                        .forPaths(PathSelectors.regex("^(?!auth).*$"))
+                        .build());
+        return securityContexts;
+    }
+
+    /**
+     * 默认的安全上引用
+     */
+    private List<SecurityReference> defaultAuth()
+    {
+        AuthorizationScope authorizationScope = new AuthorizationScope("global", "accessEverything");
+        AuthorizationScope[] authorizationScopes = new AuthorizationScope[1];
+        authorizationScopes[0] = authorizationScope;
+        List<SecurityReference> securityReferences = new ArrayList<>();
+        securityReferences.add(new SecurityReference("Authorization", authorizationScopes));
+        return securityReferences;
+    }
+
+    /**
+     * 添加摘要信息
+     */
+    private ApiInfo apiInfo()
+    {
+        // 用ApiInfoBuilder进行定制
+        return new ApiInfoBuilder()
+                // 设置标题
+                .title("标题:FS管理系统_接口文档")
+                // 描述
+                .description("描述:用于管理集团旗下公司的人员信息,具体包括XXX,XXX模块...")
+                // 作者信息
+                .contact(new Contact(fsConfig.getName(), null, null))
+                // 版本
+                .version("版本号:" + fsConfig.getVersion())
+                .build();
+    }
+}

+ 63 - 0
fs-live-ws/src/main/java/com/fs/framework/config/ThreadPoolConfig.java

@@ -0,0 +1,63 @@
+package com.fs.framework.config;
+
+import com.fs.common.utils.Threads;
+import org.apache.commons.lang3.concurrent.BasicThreadFactory;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.ThreadPoolExecutor;
+
+/**
+ * 线程池配置
+ *
+
+ **/
+@Configuration
+public class ThreadPoolConfig
+{
+    // 核心线程池大小
+    private int corePoolSize = 50;
+
+    // 最大可创建的线程数
+    private int maxPoolSize = 200;
+
+    // 队列最大长度
+    private int queueCapacity = 1000;
+
+    // 线程池维护线程所允许的空闲时间
+    private int keepAliveSeconds = 300;
+
+    @Bean(name = "threadPoolTaskExecutor")
+    public ThreadPoolTaskExecutor threadPoolTaskExecutor()
+    {
+        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+        executor.setMaxPoolSize(maxPoolSize);
+        executor.setCorePoolSize(corePoolSize);
+        executor.setQueueCapacity(queueCapacity);
+        executor.setKeepAliveSeconds(keepAliveSeconds);
+        // 线程池对拒绝任务(无线程可用)的处理策略
+        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
+        return executor;
+    }
+
+    /**
+     * 执行周期性或定时任务
+     */
+    @Bean(name = "scheduledExecutorService")
+    protected ScheduledExecutorService scheduledExecutorService()
+    {
+        return new ScheduledThreadPoolExecutor(corePoolSize,
+                new BasicThreadFactory.Builder().namingPattern("schedule-pool-%d").daemon(true).build())
+        {
+            @Override
+            protected void afterExecute(Runnable r, Throwable t)
+            {
+                super.afterExecute(r, t);
+                Threads.printException(r, t);
+            }
+        };
+    }
+}

+ 77 - 0
fs-live-ws/src/main/java/com/fs/framework/config/properties/DruidProperties.java

@@ -0,0 +1,77 @@
+package com.fs.framework.config.properties;
+
+import com.alibaba.druid.pool.DruidDataSource;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * druid 配置属性
+ *
+
+ */
+@Configuration
+public class DruidProperties
+{
+    @Value("${spring.datasource.mysql.druid.initialSize}")
+    private int initialSize;
+
+    @Value("${spring.datasource.mysql.druid.minIdle}")
+    private int minIdle;
+
+    @Value("${spring.datasource.mysql.druid.maxActive}")
+    private int maxActive;
+
+    @Value("${spring.datasource.mysql.druid.maxWait}")
+    private int maxWait;
+
+    @Value("${spring.datasource.mysql.druid.timeBetweenEvictionRunsMillis}")
+    private int timeBetweenEvictionRunsMillis;
+
+    @Value("${spring.datasource.mysql.druid.minEvictableIdleTimeMillis}")
+    private int minEvictableIdleTimeMillis;
+
+    @Value("${spring.datasource.mysql.druid.maxEvictableIdleTimeMillis}")
+    private int maxEvictableIdleTimeMillis;
+
+    @Value("${spring.datasource.mysql.druid.validationQuery}")
+    private String validationQuery;
+
+    @Value("${spring.datasource.mysql.druid.testWhileIdle}")
+    private boolean testWhileIdle;
+
+    @Value("${spring.datasource.mysql.druid.testOnBorrow}")
+    private boolean testOnBorrow;
+
+    @Value("${spring.datasource.mysql.druid.testOnReturn}")
+    private boolean testOnReturn;
+
+    public DruidDataSource dataSource(DruidDataSource datasource)
+    {
+        /** 配置初始化大小、最小、最大 */
+        datasource.setInitialSize(initialSize);
+        datasource.setMaxActive(maxActive);
+        datasource.setMinIdle(minIdle);
+
+        /** 配置获取连接等待超时的时间 */
+        datasource.setMaxWait(maxWait);
+
+        /** 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒 */
+        datasource.setTimeBetweenEvictionRunsMillis(timeBetweenEvictionRunsMillis);
+
+        /** 配置一个连接在池中最小、最大生存的时间,单位是毫秒 */
+        datasource.setMinEvictableIdleTimeMillis(minEvictableIdleTimeMillis);
+        datasource.setMaxEvictableIdleTimeMillis(maxEvictableIdleTimeMillis);
+
+        /**
+         * 用来检测连接是否有效的sql,要求是一个查询语句,常用select 'x'。如果validationQuery为null,testOnBorrow、testOnReturn、testWhileIdle都不会起作用。
+         */
+        datasource.setValidationQuery(validationQuery);
+        /** 建议配置为true,不影响性能,并且保证安全性。申请连接的时候检测,如果空闲时间大于timeBetweenEvictionRunsMillis,执行validationQuery检测连接是否有效。 */
+        datasource.setTestWhileIdle(testWhileIdle);
+        /** 申请连接时执行validationQuery检测连接是否有效,做了这个配置会降低性能。 */
+        datasource.setTestOnBorrow(testOnBorrow);
+        /** 归还连接时执行validationQuery检测连接是否有效,做了这个配置会降低性能。 */
+        datasource.setTestOnReturn(testOnReturn);
+        return datasource;
+    }
+}

+ 27 - 0
fs-live-ws/src/main/java/com/fs/framework/datasource/DynamicDataSource.java

@@ -0,0 +1,27 @@
+package com.fs.framework.datasource;
+
+import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
+
+import javax.sql.DataSource;
+import java.util.Map;
+
+/**
+ * 动态数据源
+ * 
+
+ */
+public class DynamicDataSource extends AbstractRoutingDataSource
+{
+    public DynamicDataSource(DataSource defaultTargetDataSource, Map<Object, Object> targetDataSources)
+    {
+        super.setDefaultTargetDataSource(defaultTargetDataSource);
+        super.setTargetDataSources(targetDataSources);
+        super.afterPropertiesSet();
+    }
+
+    @Override
+    protected Object determineCurrentLookupKey()
+    {
+        return DynamicDataSourceContextHolder.getDataSourceType();
+    }
+}

+ 45 - 0
fs-live-ws/src/main/java/com/fs/framework/datasource/DynamicDataSourceContextHolder.java

@@ -0,0 +1,45 @@
+package com.fs.framework.datasource;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * 数据源切换处理
+ * 
+
+ */
+public class DynamicDataSourceContextHolder
+{
+    public static final Logger log = LoggerFactory.getLogger(DynamicDataSourceContextHolder.class);
+
+    /**
+     * 使用ThreadLocal维护变量,ThreadLocal为每个使用该变量的线程提供独立的变量副本,
+     *  所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程所对应的副本。
+     */
+    private static final ThreadLocal<String> CONTEXT_HOLDER = new ThreadLocal<>();
+
+    /**
+     * 设置数据源的变量
+     */
+    public static void setDataSourceType(String dsType)
+    {
+//        log.info("切换到{}数据源", dsType);
+        CONTEXT_HOLDER.set(dsType);
+    }
+
+    /**
+     * 获得数据源的变量
+     */
+    public static String getDataSourceType()
+    {
+        return CONTEXT_HOLDER.get();
+    }
+
+    /**
+     * 清空数据源变量
+     */
+    public static void clearDataSourceType()
+    {
+        CONTEXT_HOLDER.remove();
+    }
+}

+ 56 - 0
fs-live-ws/src/main/java/com/fs/framework/interceptor/RepeatSubmitInterceptor.java

@@ -0,0 +1,56 @@
+package com.fs.framework.interceptor;
+
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.annotation.RepeatSubmit;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.utils.ServletUtils;
+import org.springframework.stereotype.Component;
+import org.springframework.web.method.HandlerMethod;
+import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.lang.reflect.Method;
+
+/**
+ * 防止重复提交拦截器
+ *
+
+ */
+@Component
+public abstract class RepeatSubmitInterceptor extends HandlerInterceptorAdapter
+{
+    @Override
+    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception
+    {
+        if (handler instanceof HandlerMethod)
+        {
+            HandlerMethod handlerMethod = (HandlerMethod) handler;
+            Method method = handlerMethod.getMethod();
+            RepeatSubmit annotation = method.getAnnotation(RepeatSubmit.class);
+            if (annotation != null)
+            {
+                if (this.isRepeatSubmit(request))
+                {
+                    AjaxResult ajaxResult = AjaxResult.error("不允许重复提交,请稍后再试");
+                    ServletUtils.renderString(response, JSONObject.toJSONString(ajaxResult));
+                    return false;
+                }
+            }
+            return true;
+        }
+        else
+        {
+            return super.preHandle(request, response, handler);
+        }
+    }
+
+    /**
+     * 验证是否重复提交由子类实现具体的防重复提交的规则
+     *
+     * @param request
+     * @return
+     * @throws Exception
+     */
+    public abstract boolean isRepeatSubmit(HttpServletRequest request);
+}

+ 126 - 0
fs-live-ws/src/main/java/com/fs/framework/interceptor/impl/SameUrlDataInterceptor.java

@@ -0,0 +1,126 @@
+package com.fs.framework.interceptor.impl;
+
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.constant.Constants;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.filter.RepeatedlyRequestWrapper;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.http.HttpHelper;
+import com.fs.framework.interceptor.RepeatSubmitInterceptor;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 判断请求url和数据是否和上一次相同,
+ * 如果和上次相同,则是重复提交表单。 有效时间为10秒内。
+ * 
+
+ */
+@Component
+public class SameUrlDataInterceptor extends RepeatSubmitInterceptor
+{
+    public final String REPEAT_PARAMS = "repeatParams";
+
+    public final String REPEAT_TIME = "repeatTime";
+
+    // 令牌自定义标识
+    @Value("${token.header}")
+    private String header;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    /**
+     * 间隔时间,单位:秒 默认10秒
+     * 
+     * 两次相同参数的请求,如果间隔时间大于该参数,系统不会认定为重复提交的数据
+     */
+    private int intervalTime = 10;
+
+    public void setIntervalTime(int intervalTime)
+    {
+        this.intervalTime = intervalTime;
+    }
+
+    @SuppressWarnings("unchecked")
+    @Override
+    public boolean isRepeatSubmit(HttpServletRequest request)
+    {
+        String nowParams = "";
+        if (request instanceof RepeatedlyRequestWrapper)
+        {
+            RepeatedlyRequestWrapper repeatedlyRequest = (RepeatedlyRequestWrapper) request;
+            nowParams = HttpHelper.getBodyString(repeatedlyRequest);
+        }
+
+        // body参数为空,获取Parameter的数据
+        if (StringUtils.isEmpty(nowParams))
+        {
+            nowParams = JSONObject.toJSONString(request.getParameterMap());
+        }
+        Map<String, Object> nowDataMap = new HashMap<String, Object>();
+        nowDataMap.put(REPEAT_PARAMS, nowParams);
+        nowDataMap.put(REPEAT_TIME, System.currentTimeMillis());
+
+        // 请求地址(作为存放cache的key值)
+        String url = request.getRequestURI();
+
+        // 唯一值(没有消息头则使用请求地址)
+        String submitKey = request.getHeader(header);
+        if (StringUtils.isEmpty(submitKey))
+        {
+            submitKey = url;
+        }
+
+        // 唯一标识(指定key + 消息头)
+        String cacheRepeatKey = Constants.REPEAT_SUBMIT_KEY + submitKey;
+
+        Object sessionObj = redisCache.getCacheObject(cacheRepeatKey);
+        if (sessionObj != null)
+        {
+            Map<String, Object> sessionMap = (Map<String, Object>) sessionObj;
+            if (sessionMap.containsKey(url))
+            {
+                Map<String, Object> preDataMap = (Map<String, Object>) sessionMap.get(url);
+                if (compareParams(nowDataMap, preDataMap) && compareTime(nowDataMap, preDataMap))
+                {
+                    return true;
+                }
+            }
+        }
+        Map<String, Object> cacheMap = new HashMap<String, Object>();
+        cacheMap.put(url, nowDataMap);
+        redisCache.setCacheObject(cacheRepeatKey, cacheMap, intervalTime, TimeUnit.SECONDS);
+        return false;
+    }
+
+    /**
+     * 判断参数是否相同
+     */
+    private boolean compareParams(Map<String, Object> nowMap, Map<String, Object> preMap)
+    {
+        String nowParams = (String) nowMap.get(REPEAT_PARAMS);
+        String preParams = (String) preMap.get(REPEAT_PARAMS);
+        return nowParams.equals(preParams);
+    }
+
+    /**
+     * 判断两次间隔时间
+     */
+    private boolean compareTime(Map<String, Object> nowMap, Map<String, Object> preMap)
+    {
+        long time1 = (Long) nowMap.get(REPEAT_TIME);
+        long time2 = (Long) preMap.get(REPEAT_TIME);
+        if ((time1 - time2) < (this.intervalTime * 1000))
+        {
+            return true;
+        }
+        return false;
+    }
+}

+ 56 - 0
fs-live-ws/src/main/java/com/fs/framework/manager/AsyncManager.java

@@ -0,0 +1,56 @@
+package com.fs.framework.manager;
+
+import com.fs.common.utils.Threads;
+import com.fs.common.utils.spring.SpringUtils;
+
+import java.util.TimerTask;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 异步任务管理器
+ * 
+
+ */
+public class AsyncManager
+{
+    /**
+     * 操作延迟10毫秒
+     */
+    private final int OPERATE_DELAY_TIME = 10;
+
+    /**
+     * 异步操作任务调度线程池
+     */
+    private ScheduledExecutorService executor = SpringUtils.getBean("scheduledExecutorService");
+
+    /**
+     * 单例模式
+     */
+    private AsyncManager(){}
+
+    private static AsyncManager me = new AsyncManager();
+
+    public static AsyncManager me()
+    {
+        return me;
+    }
+
+    /**
+     * 执行任务
+     * 
+     * @param task 任务
+     */
+    public void execute(TimerTask task)
+    {
+        executor.schedule(task, OPERATE_DELAY_TIME, TimeUnit.MILLISECONDS);
+    }
+
+    /**
+     * 停止任务线程池
+     */
+    public void shutdown()
+    {
+        Threads.shutdownAndAwaitTermination(executor);
+    }
+}

+ 40 - 0
fs-live-ws/src/main/java/com/fs/framework/manager/ShutdownManager.java

@@ -0,0 +1,40 @@
+package com.fs.framework.manager;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.PreDestroy;
+
+/**
+ * 确保应用退出时能关闭后台线程
+ *
+
+ */
+@Component
+public class ShutdownManager
+{
+    private static final Logger logger = LoggerFactory.getLogger("sys-user");
+
+    @PreDestroy
+    public void destroy()
+    {
+        shutdownAsyncManager();
+    }
+
+    /**
+     * 停止异步执行任务
+     */
+    private void shutdownAsyncManager()
+    {
+        try
+        {
+            logger.info("====关闭后台任务任务线程池====");
+            AsyncManager.me().shutdown();
+        }
+        catch (Exception e)
+        {
+            logger.error(e.getMessage(), e);
+        }
+    }
+}

+ 103 - 0
fs-live-ws/src/main/java/com/fs/framework/manager/factory/AsyncFactory.java

@@ -0,0 +1,103 @@
+package com.fs.framework.manager.factory;
+
+import com.fs.common.constant.Constants;
+import com.fs.common.utils.LogUtils;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.ip.AddressUtils;
+import com.fs.common.utils.ip.IpUtils;
+import com.fs.common.utils.spring.SpringUtils;
+import com.fs.system.domain.SysLogininfor;
+import com.fs.system.domain.SysOperLog;
+import com.fs.system.service.ISysLogininforService;
+import com.fs.system.service.ISysOperLogService;
+import eu.bitwalker.useragentutils.UserAgent;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.TimerTask;
+
+/**
+ * 异步工厂(产生任务用)
+ * 
+
+ */
+public class AsyncFactory
+{
+    private static final Logger sys_user_logger = LoggerFactory.getLogger("sys-user");
+
+    /**
+     * 记录登录信息
+     * 
+     * @param username 用户名
+     * @param status 状态
+     * @param message 消息
+     * @param args 列表
+     * @return 任务task
+     */
+    public static TimerTask recordLogininfor(final String username, final String status, final String message,
+            final Object... args)
+    {
+        final UserAgent userAgent = UserAgent.parseUserAgentString(ServletUtils.getRequest().getHeader("User-Agent"));
+        final String ip = IpUtils.getIpAddr(ServletUtils.getRequest());
+        return new TimerTask()
+        {
+            @Override
+            public void run()
+            {
+                String address = AddressUtils.getRealAddressByIP(ip);
+                StringBuilder s = new StringBuilder();
+                s.append(LogUtils.getBlock(ip));
+                s.append(address);
+                s.append(LogUtils.getBlock(username));
+                s.append(LogUtils.getBlock(status));
+                s.append(LogUtils.getBlock(message));
+                // 打印信息到日志
+                sys_user_logger.info(s.toString(), args);
+                // 获取客户端操作系统
+                String os = userAgent.getOperatingSystem().getName();
+                // 获取客户端浏览器
+                String browser = userAgent.getBrowser().getName();
+                // 封装对象
+                SysLogininfor logininfor = new SysLogininfor();
+                logininfor.setUserName(username);
+                logininfor.setIpaddr(ip);
+                logininfor.setLoginLocation(address);
+                logininfor.setBrowser(browser);
+                logininfor.setOs(os);
+                logininfor.setMsg(message);
+                // 日志状态
+                if (StringUtils.equalsAny(status, Constants.LOGIN_SUCCESS, Constants.LOGOUT, Constants.REGISTER))
+                {
+                    logininfor.setStatus(Constants.SUCCESS);
+                }
+                else if (Constants.LOGIN_FAIL.equals(status))
+                {
+                    logininfor.setStatus(Constants.FAIL);
+                }
+                // 插入数据
+                SpringUtils.getBean(ISysLogininforService.class).insertLogininfor(logininfor);
+            }
+        };
+    }
+
+    /**
+     * 操作日志记录
+     * 
+     * @param operLog 操作日志信息
+     * @return 任务task
+     */
+    public static TimerTask recordOper(final SysOperLog operLog)
+    {
+        return new TimerTask()
+        {
+            @Override
+            public void run()
+            {
+                // 远程查询操作地点
+                operLog.setOperLocation(AddressUtils.getRealAddressByIP(operLog.getOperIp()));
+                SpringUtils.getBean(ISysOperLogService.class).insertOperlog(operLog);
+            }
+        };
+    }
+}

+ 72 - 0
fs-live-ws/src/main/java/com/fs/live/task/LiveCompletionPointsTask.java

@@ -0,0 +1,72 @@
+package com.fs.live.task;
+
+import com.fs.common.core.redis.RedisCache;
+import com.fs.live.domain.Live;
+import com.fs.live.service.ILiveCompletionPointsRecordService;
+import com.fs.live.service.ILiveService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 直播完课积分定时任务(迁移自 fs-live-app)
+ */
+@Slf4j
+@Component
+public class LiveCompletionPointsTask {
+
+    @Autowired
+    private RedisCache redisCache;
+
+    @Autowired
+    private ILiveCompletionPointsRecordService completionPointsRecordService;
+
+    @Autowired
+    private ILiveService liveService;
+
+    @Scheduled(cron = "0 */1 * * * ?")
+    public void checkCompletionStatus() {
+        try {
+            List<Live> activeLives = liveService.selectLiveListWithCompletionPointsEnabled();
+
+            if (activeLives == null || activeLives.isEmpty()) {
+                log.debug("当前没有开启完课积分的直播间");
+                return;
+            }
+
+            for (Live live : activeLives) {
+                try {
+                    Long liveId = live.getLiveId();
+
+                    String hashKey = "live:watch:duration:hash:" + liveId;
+                    Map<Object, Object> userDurations = redisCache.hashEntries(hashKey);
+
+                    if (userDurations == null || userDurations.isEmpty()) {
+                        continue;
+                    }
+                    for (Map.Entry<Object, Object> entry : userDurations.entrySet()) {
+                        try {
+                            Long userId = Long.parseLong(entry.getKey().toString());
+                            Long duration = Long.parseLong(entry.getValue().toString());
+
+                            completionPointsRecordService.checkAndCreateCompletionRecord(liveId, userId, duration);
+
+                        } catch (Exception e) {
+                            log.error("处理用户完课状态失败, liveId={}, userId={}", liveId, entry.getKey(), e);
+                        }
+                    }
+
+                } catch (Exception e) {
+                    log.error("处理直播间完课状态失败, liveId={}", live.getLiveId(), e);
+                }
+            }
+
+        } catch (Exception e) {
+            log.error("检查完课状态定时任务执行失败", e);
+        }
+    }
+}

+ 1123 - 0
fs-live-ws/src/main/java/com/fs/live/task/Task.java

@@ -0,0 +1,1123 @@
+package com.fs.live.task;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.json.JSONUtil;
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.constant.LiveKeysConstant;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.utils.StringUtils;
+import com.fs.framework.aspectj.lock.DistributeLock;
+import com.fs.erp.service.FsJstAftersalePushService;
+import com.fs.his.service.IFsUserService;
+import com.fs.live.domain.*;
+import com.fs.live.mapper.LiveLotteryRegistrationMapper;
+import com.fs.live.param.LiveReplayParam;
+import com.fs.live.service.*;
+import com.fs.live.vo.LiveLotteryConfVo;
+import com.fs.live.vo.LiveLotteryProductListVo;
+import com.fs.live.vo.LotteryVo;
+import com.fs.live.ws.bean.WsSendMsgVo;
+import com.fs.live.ws.service.LiveWsRoomBroadcastFacade;
+import lombok.AllArgsConstructor;
+import org.apache.commons.collections4.CollectionUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.PostConstruct;
+import java.math.BigDecimal;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+import static com.fs.common.constant.LiveKeysConstant.*;
+import static com.fs.common.constant.LiveKeysConstant.LIVE_COUPON_NUM;
+import static com.fs.live.ws.constant.WsRedisKeys.USER_ENTRY_TIME_KEY;
+
+@Component
+@AllArgsConstructor
+public class Task {
+
+    private static final Logger log = LoggerFactory.getLogger(Task.class);
+    private final ILiveService liveService;
+
+    private final ILiveDataService liveDataService;
+
+    private final RedisCache redisCache;
+
+    @Autowired
+    private ILiveWatchUserService liveWatchUserService;
+    @Autowired
+    private IFsUserService fsUserService;
+    @Autowired
+    private ILiveRewardRecordService liveRewardRecordService;
+    @Autowired
+    private LiveWsRoomBroadcastFacade liveWsRoomBroadcastFacade;
+    @Autowired
+    private ILiveAutoTaskService liveAutoTaskService;
+    @Autowired
+    private ILiveLotteryConfService liveLotteryConfService;
+    @Autowired
+    private ILiveUserLotteryRecordService liveUserLotteryRecordService;
+    @Autowired
+    private LiveLotteryRegistrationMapper liveLotteryRegistrationMapper;
+    @Autowired
+    private ILiveRedConfService liveRedConfService;
+    @Autowired
+    private ILiveCouponIssueService liveCouponIssueService;
+    @Autowired
+    private ILiveVideoService liveVideoService;
+    @Autowired
+    private ILiveWatchLogService liveWatchLogService;
+    @Autowired
+    private ILiveUserFirstEntryService liveUserFirstEntryService;
+
+    @Autowired
+    public FsJstAftersalePushService fsJstAftersalePushService;
+
+    @Scheduled(cron = "0 0/1 * * * ?")
+    @DistributeLock(key = "updateLiveStatusByTime", scene = "task")
+    public void updateLiveStatusByTime() {
+        List<Live> list = liveService.selectNoEndLiveList();
+        if (list.isEmpty())
+            return;
+        List<Long> liveIdLists = list.stream().map(Live::getLiveId).collect(Collectors.toList());
+        List<LiveAutoTask> liveAutoTasks = liveAutoTaskService.selectLiveAutoTaskByLiveIds(liveIdLists);
+        List<Live> liveList = new ArrayList<>();
+        LocalDateTime now = LocalDateTime.now().plusSeconds(2L);
+        List<Live> startLiveList = new ArrayList<>();
+        List<Live> endLiveList = new ArrayList<>();
+        list.forEach(live -> {
+            if (live.getLiveType() != 3) {
+                if (live.getFinishTime() == null) {
+                    if (now.isAfter(live.getStartTime().minusSeconds(2L))){
+                        if(live.getStatus() != 2){
+                            redisCache.zSetRemove(LiveKeysConstant.LIVE_HOME_PAGE_LIST, JSON.toJSONString(live));
+                            live.setStatus(2);
+                            startLiveList.add(live);
+                            liveList.add(live);
+                        }
+                    } else if (now.isBefore(live.getStartTime())) {
+                        if (live.getStatus() != 1) {
+                            live.setStatus(1);
+                            liveList.add(live);
+                        }
+
+                    }
+                } else {
+                    if (now.isAfter(live.getStartTime().minusSeconds(2L)) && now.isBefore(live.getFinishTime())) {
+                        if(live.getStatus() != 2){
+                            redisCache.zSetRemove(LiveKeysConstant.LIVE_HOME_PAGE_LIST, JSON.toJSONString(live));
+                            startLiveList.add(live);
+                            live.setStatus(2);
+                            liveList.add(live);
+                        }
+                        live.setStatus(2);
+                    } else if (now.isBefore(live.getStartTime().minusSeconds(2L))) {
+                        if (live.getStatus() != 1) {
+                            live.setStatus(1);
+                            liveList.add(live);
+                        }
+                    } else if (now.isAfter(live.getFinishTime().minusSeconds(2L))) {
+                        if(live.getStatus() != 3){
+                            endLiveList.add(live);
+                            live.setStatus(3);
+                            liveList.add(live);
+                        }
+                    }
+                }
+            } else {
+                // 直播回放只需要检测结束时间就好了
+                LiveReplayParam liveReplayParam = JSON.parseObject(live.getLiveConfig(), LiveReplayParam.class);
+                if (liveReplayParam.getIsPlaybackOpen()) {
+                    if (liveReplayParam.getFinishTime() != null) {
+                        if (now.isAfter(liveReplayParam.getFinishTime().minusSeconds(2L))) {
+                            if(live.getStatus() != 3){
+                                endLiveList.add(live);
+                                live.setStatus(3);
+                                liveList.add(live);
+                            }
+                        }
+                    }
+                }
+            }
+        });
+        if(!liveList.isEmpty()){
+            for (Live live : liveList) {
+                liveService.updateLiveEntity(live);
+            }
+        }
+        String key = "live:auto_task:";
+        if (!startLiveList.isEmpty()) {
+            for (Live live : startLiveList) {
+                WsSendMsgVo sendMsgVo = new WsSendMsgVo();
+                sendMsgVo.setMsg("开始直播");
+                sendMsgVo.setCmd("live_start");
+                liveWsRoomBroadcastFacade.broadcastMessage(live.getLiveId(), JSONObject.toJSONString(R.ok().put("data",sendMsgVo)));
+                List<LiveAutoTask> collect = liveAutoTasks.stream().filter(liveAutoTask -> liveAutoTask.getLiveId().equals(live.getLiveId())).collect(Collectors.toList());
+                if (!collect.isEmpty()) {
+                    collect.forEach(liveAutoTask -> {
+                        liveAutoTask.setCreateTime(null);
+                        liveAutoTask.setUpdateTime(null);
+                        redisCache.zSetAdd(key + live.getLiveId(), JSON.toJSONString(liveAutoTask),liveAutoTask.getAbsValue().getTime());
+                        redisCache.expire(key+live.getLiveId(), 1, TimeUnit.DAYS);
+                    });
+                }
+                // 清理小程序缓存 和 直播标签缓存
+                String cacheKey = String.format(LiveKeysConstant.LIVE_DATA_CACHE, live.getLiveId());
+                redisCache.deleteObject(cacheKey);
+                liveWatchUserService.clearLiveFlagCache(live.getLiveId());
+                // 将开启的直播间信息写入Redis缓存,用于打标签定时任务
+                try {
+                    // 获取视频时长
+                    Long videoDuration = 0L;
+                    List<LiveVideo> videos = liveVideoService.listByLiveId(live.getLiveId(), 1);
+                    if (CollUtil.isNotEmpty(videos)) {
+                        videoDuration = videos.stream()
+                                .filter(v -> v.getDuration() != null)
+                                .mapToLong(LiveVideo::getDuration)
+                                .sum();
+                    }
+
+                    // 如果视频时长大于0,将直播间信息存入Redis
+                    if (videoDuration > 0 && live.getStartTime() != null) {
+                        Map<String, Object> tagMarkInfo = new HashMap<>();
+                        tagMarkInfo.put("liveId", live.getLiveId());
+                        tagMarkInfo.put("startTime", live.getStartTime().atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli());
+                        tagMarkInfo.put("videoDuration", videoDuration);
+
+                        String tagMarkKey = String.format(LiveKeysConstant.LIVE_TAG_MARK_CACHE, live.getLiveId());
+                        redisCache.setCacheObject(tagMarkKey, JSON.toJSONString(tagMarkInfo), 24, TimeUnit.HOURS);
+                        log.info("直播间开启,已加入打标签缓存: liveId={}, startTime={}, videoDuration={}",
+                                live.getLiveId(), live.getStartTime(), videoDuration);
+                    }
+                } catch (Exception e) {
+                    log.error("写入直播间打标签缓存失败: liveId={}, error={}", live.getLiveId(), e.getMessage(), e);
+                }
+            }
+            // 重新更新所有在直播的缓存
+            liveService.asyncToCache();
+        }
+        if (!endLiveList.isEmpty()) {
+            for (Live live : endLiveList) {
+                WsSendMsgVo sendMsgVo = new WsSendMsgVo();
+                sendMsgVo.setMsg("结束直播");
+                sendMsgVo.setCmd("live_end");
+                liveWsRoomBroadcastFacade.broadcastMessage(live.getLiveId(), JSONObject.toJSONString(R.ok().put("data",sendMsgVo)));
+                List<LiveAutoTask> collect = liveAutoTasks.stream().filter(liveAutoTask -> liveAutoTask.getLiveId().equals(live.getLiveId())).collect(Collectors.toList());
+                if (!collect.isEmpty()) {
+                    redisCache.deleteObject(key + live.getLiveId());
+                    collect.forEach(liveAutoTask -> {
+                        liveAutoTask.setCreateTime(null);
+                        liveAutoTask.setUpdateTime(null);
+                        redisCache.redisTemplate.opsForZSet().remove(key + live.getLiveId(), JSON.toJSONString(liveAutoTask),liveAutoTask.getAbsValue().getTime());
+                    });
+                }
+                String cacheKey = String.format(LiveKeysConstant.LIVE_DATA_CACHE, live.getLiveId());
+                redisCache.deleteObject(cacheKey);
+                liveWsRoomBroadcastFacade.removeLikeCountCache(live.getLiveId());
+
+                // 删除打标签缓存
+                try {
+                    String tagMarkKey = String.format(LiveKeysConstant.LIVE_TAG_MARK_CACHE, live.getLiveId());
+                    redisCache.deleteObject(tagMarkKey);
+                    log.info("直播间结束,已删除打标签缓存: liveId={}", live.getLiveId());
+                } catch (Exception e) {
+                    log.error("删除直播间打标签缓存失败: liveId={}, error={}", live.getLiveId(), e.getMessage(), e);
+                }
+            }
+            // 重新更新所有在直播的缓存
+            liveService.asyncToCache();
+        }
+
+    }
+    @Scheduled(cron = "0/1 * * * * ?")
+    @DistributeLock(key = "liveLotteryTask", scene = "task")
+    public void liveLotteryTask() {
+        long currentTime = Instant.now().toEpochMilli(); // 当前时间戳(毫秒)
+        String lotteryKey = "live:lottery_task:*";
+        Set<String> allLiveKeys = redisCache.redisTemplate.keys(lotteryKey);
+        if (allLiveKeys != null && !allLiveKeys.isEmpty()) {
+            for (String liveKey : allLiveKeys) {
+                Set<String> range = redisCache.redisTemplate.opsForZSet().rangeByScore(liveKey, 0, currentTime);
+                if (range == null || range.isEmpty()) {
+                    continue;
+                }
+                processLotteryTask(range);
+                redisCache.redisTemplate.opsForZSet()
+                        .removeRangeByScore(liveKey, 0, currentTime);
+            }
+        }
+
+        String redKey = "live:red_task:*";
+        allLiveKeys = redisCache.redisTemplate.keys(redKey);
+        if (allLiveKeys == null || allLiveKeys.isEmpty()) {
+            return;
+        }
+        for (String liveKey : allLiveKeys) {
+            Set<String> range = redisCache.redisTemplate.opsForZSet().rangeByScore(liveKey, 0, currentTime);
+            if (range == null || range.isEmpty()) {
+                continue;
+            }
+
+            updateRedStatus(range);
+            redisCache.redisTemplate.opsForZSet()
+                    .removeRangeByScore(liveKey, 0, currentTime);
+            try {
+                // 广播红包关闭消息
+                WsSendMsgVo sendMsgVo = new WsSendMsgVo();
+                sendMsgVo.setLiveId(Long.valueOf(liveKey));
+                sendMsgVo.setCmd("red");
+                sendMsgVo.setStatus(-1);
+                liveService.asyncToCacheLiveConfig(Long.parseLong(liveKey));
+                liveWsRoomBroadcastFacade.broadcastMessage(Long.valueOf(liveKey), JSONObject.toJSONString(R.ok().put("data", sendMsgVo)));
+            } catch (Exception e) {
+                log.error("更新红包状态异常", e);
+            }
+        }
+    }
+
+    private void updateRedStatus(Set<String> range) {
+
+        liveRedConfService.finishRedStatusBySetIds(range);
+    }
+
+    private void processLotteryTask(Set<String> range) {
+        List<LiveLotteryConfVo> liveLotteries = liveLotteryConfService.selectVoListByLotteryIds(range);
+        if(liveLotteries.isEmpty()) return;
+        Date now = new Date();
+        for (LiveLotteryConfVo liveLottery : liveLotteries) {
+            // 查询抽奖数量
+            List<LiveLotteryProductListVo> products = liveLottery.getProducts();
+            Integer totalLots = products.stream().mapToInt(liveLotteryProductListVo -> Math.toIntExact(liveLotteryProductListVo.getTotalLots())).sum();
+            if(totalLots <= 0) continue;
+            // 先将参与记录插入数据库
+            String hashKey = String.format(LiveKeysConstant.LIVE_HOME_PAGE_CONFIG_DRAW, liveLottery.getLiveId(), liveLottery.getLotteryId());
+            Map<Object, Object> hashEntries = redisCache.hashEntries(hashKey);
+            List<LiveLotteryRegistration> registrationList = new ArrayList<>();
+            if (CollUtil.isNotEmpty(hashEntries)) {
+                registrationList = hashEntries.values().stream()
+                        .map(value -> JSONUtil.toBean(JSONUtil.parseObj(value), LiveLotteryRegistration.class))
+                        .collect(Collectors.toList());
+                liveLotteryRegistrationMapper.insertLiveLotteryRegistrationBatch(registrationList);
+            }
+
+            // 查询在线用户 并且参与了抽奖的用户
+            List<LiveWatchUser> liveWatchUsers = liveWatchUserService.selectLiveWatchAndRegisterUser(liveLottery.getLiveId(),liveLottery.getLotteryId());
+            if(liveWatchUsers.isEmpty()) continue;
+            LiveLotteryRegistration liveLotteryRegistration;
+            // 收集中奖信息
+            List<LotteryVo> lotteryVos = new ArrayList<>();
+            for (LiveLotteryProductListVo liveLotteryProductListVo : products) {
+                // 随机抽奖一个用户获取奖品
+                Long totalLotsPerProduct = liveLotteryProductListVo.getTotalLots();
+                for (int i = 0; i < totalLotsPerProduct && !liveWatchUsers.isEmpty(); i++) {
+                    // 随机选择一个用户
+                    int randomIndex = new Random().nextInt(liveWatchUsers.size());
+                    LiveWatchUser winningUser = liveWatchUsers.get(randomIndex);
+
+                    // 创建中奖记录
+                    LiveUserLotteryRecord record = new LiveUserLotteryRecord();
+                    record.setLotteryId(liveLottery.getLotteryId());
+                    record.setLiveId(liveLottery.getLiveId());
+                    record.setUserId(winningUser.getUserId());
+                    record.setProductId(liveLotteryProductListVo.getProductId());
+                    record.setCreateTime(new Date());
+                    record.setOrderStatus(-9);
+
+                    // 保存中奖记录
+                    liveUserLotteryRecordService.insertLiveUserLotteryRecord(record);
+                    liveLotteryRegistration = new LiveLotteryRegistration();
+                    liveLotteryRegistration.setLotteryId(liveLottery.getLotteryId());
+                    liveLotteryRegistration.setLiveId(liveLottery.getLotteryId());
+                    liveLotteryRegistration.setUserId(winningUser.getUserId());
+                    liveLotteryRegistration.setIsWin(1L);
+                    liveLotteryRegistration.setUpdateTime(now);
+                    liveLotteryRegistration.setRizeLevel(liveLotteryProductListVo.getPrizeLevel());
+                    liveLotteryRegistrationMapper.updateLiveLotteryRegistrationNoId(liveLotteryRegistration);
+                    // 从候选列表中移除该用户,确保每人只能中奖一次
+                    liveWatchUsers.remove(randomIndex);
+                    LotteryVo lotteryVo = new LotteryVo();
+                    lotteryVo.setUserId(winningUser.getUserId());
+                    lotteryVo.setUserName(winningUser.getNickName());
+                    lotteryVo.setPrizeLevel(liveLotteryProductListVo.getPrizeLevel());
+                    lotteryVo.setProductName(liveLotteryProductListVo.getProductName());
+                    lotteryVo.setProductId(liveLotteryProductListVo.getProductId());
+                    //设置中奖记录id
+                    lotteryVo.setRecordId(record.getId());
+                    lotteryVos.add(lotteryVo);
+                }
+            }
+            WsSendMsgVo sendMsgVo = new WsSendMsgVo();
+            sendMsgVo.setLiveId(liveLottery.getLiveId());
+            sendMsgVo.setCmd("LotteryDetail");
+            sendMsgVo.setData(JSON.toJSONString(lotteryVos));
+            liveWsRoomBroadcastFacade.broadcastMessage(liveLottery.getLiveId(), JSONObject.toJSONString(R.ok().put("data", sendMsgVo)));
+
+            liveService.asyncToCacheLiveConfig(liveLottery.getLiveId());
+            // 删除缓存 同步抽奖记录
+            redisCache.deleteObject(hashKey);
+        }
+
+        List<Long> collect = liveLotteries.stream().map(LiveLotteryConfVo::getLotteryId).collect(Collectors.toList());
+        liveLotteryConfService.finishStatusByLotteryIds(collect);
+    }
+
+    @Scheduled(cron = "0/1 * * * * ?")
+    @DistributeLock(key = "liveAutoTask", scene = "task")
+    public void liveAutoTask() {
+        long currentTime = Instant.now().toEpochMilli(); // 当前时间戳(毫秒)
+
+        Set<String> allLiveKeys = redisCache.redisTemplate.keys("live:auto_task:*");
+        if (allLiveKeys == null || allLiveKeys.isEmpty()) {
+            return; // 没有数据,直接返回
+        }
+        // 2. 遍历每个直播间的ZSet键
+        for (String liveKey : allLiveKeys) {
+            // 3. 获取当前直播间ZSet中所有元素(按score排序)
+            // range方法:0表示第一个元素,-1表示最后一个元素,即获取全部
+            Set<String> range = redisCache.redisTemplate.opsForZSet().rangeByScore(liveKey, 0, currentTime);
+            if (range == null || range.isEmpty()) {
+                continue; // 没有数据,直接返回
+            }
+            redisCache.redisTemplate.opsForZSet()
+                    .removeRangeByScore(liveKey, 0, currentTime);
+            processAutoTask(range);
+        }
+    }
+
+    private void processAutoTask(Set<String> range) {
+        for (String liveAutoTask : range) {
+            LiveAutoTask task = JSON.parseObject(liveAutoTask, LiveAutoTask.class);
+            liveWsRoomBroadcastFacade.handleAutoTask(task);
+            task.setFinishStatus(1L);
+            liveAutoTaskService.finishLiveAutoTask(task);
+        }
+    }
+
+    @Scheduled(cron = "0 0/1 * * * ?")
+    @DistributeLock(key = "autoUpdateWatchReward", scene = "task")
+    @Transactional
+    public void autoUpdateWatchReward() {
+
+        // 1.查询所有直播中的直播间
+        List<Live> lives = liveService.liveList();
+
+
+        // 2.检查是否开启观看奖励
+        List<Live> openRewardLives = lives.stream().filter(live -> StringUtils.isNotEmpty(live.getConfigJson())).collect(Collectors.toList());
+        Date now = new Date();
+
+        for (Live openRewardLive : openRewardLives) {
+            String configJson = openRewardLive.getConfigJson();
+            LiveWatchConfig config = JSON.parseObject(configJson, LiveWatchConfig.class);
+            if (config.getEnabled() && 1 == config.getParticipateCondition()) {
+                List<LiveWatchUser> liveWatchUsers = liveWatchUserService.checkOnlineNoRewardUser(openRewardLive.getLiveId(), now);
+                if (liveWatchUsers == null || liveWatchUsers.isEmpty()) {
+                    continue;
+                }
+                // 3.检查当前直播间的在线用户(可以传入一个时间,然后查出来当天没领取奖励的用户)
+                List<LiveWatchUser> onlineUser = liveWatchUsers
+                        .stream().filter(user -> (now.getTime() - user.getUpdateTime().getTime() + ( user.getOnlineSeconds() == null ? 0L : user.getOnlineSeconds())) > config.getWatchDuration() * 60 * 1000)
+                        .collect(Collectors.toList());
+                if(onlineUser.isEmpty()) continue;
+
+                List<Long> userIds = onlineUser.stream().map(LiveWatchUser::getUserId).collect(Collectors.toList());
+                // 4.保存用户领取记录
+                saveUserRewardRecord(openRewardLive, userIds,config.getScoreAmount());
+                // 5.更新用户积分(积分
+                fsUserService.increaseIntegral(userIds,config.getScoreAmount());
+                // 6.发送websocket事件消息 通知用户自动领取成功
+                userIds.forEach(userId -> liveWsRoomBroadcastFacade.sendIntegralMessage(openRewardLive.getLiveId(),userId,config.getScoreAmount()));
+
+            }
+        }
+    }
+    private void saveUserRewardRecord(Live live, List<Long> userIds,Long scoreAmount) {
+        for (Long userId : userIds) {
+            LiveRewardRecord record = new LiveRewardRecord();
+            record.setLiveId(live.getLiveId());
+            record.setUserId(userId);
+            record.setIncomeType(1L);
+            record.setSourceType(3L);
+            record.setSourceId(live.getCompanyId() == null ? 0L : live.getCompanyId());
+            record.setRewardType(2L);
+            record.setNum(BigDecimal.valueOf(scoreAmount));
+            record.setRewardType(2L);
+            record.setCreateTime(new Date());
+            record.setCreateBy(String.valueOf(userId));
+            liveRewardRecordService.insertLiveRewardRecord(record);
+        }
+    }
+    /**
+     * 从Redis获取对象并转换为Long类型
+     * @param redisCache Redis缓存操作对象
+     * @param key 缓存键
+     * @return 转换后的Long值(若为null或转换失败则返回0L)
+     */
+    public static Long getAsLong(RedisCache redisCache, String key) {
+        // 从Redis获取原始对象
+        Object value = redisCache.getCacheObject(key);
+        if (value == null) {
+            return 0L; // 若缓存不存在,返回默认值0
+        }
+
+        // 根据实际类型转换为Long
+        if (value instanceof Long) {
+            return (Long) value;
+        } else if (value instanceof Integer) {
+            return ((Integer) value).longValue();
+        } else if (value instanceof String) {
+            // 处理字符串类型(可能是数字字符串,如"123")
+            try {
+                return Long.parseLong((String) value);
+            } catch (NumberFormatException e) {
+                // 若字符串无法转为数字,返回0(或根据业务抛异常)
+                return 0L;
+            }
+        } else {
+            // 其他类型(如Double等),根据业务需求处理,这里默认返回0
+            return 0L;
+        }
+    }
+
+    @PostConstruct
+    public void initLiveDatasOnStartup() {
+        log.info("项目启动,开始初始化直播点赞数据...");
+        try {
+            List<LiveData> liveDatas = liveDataService.getAllLiveDatas();
+            if (CollectionUtils.isNotEmpty(liveDatas)) {
+                for (LiveData liveData : liveDatas) {
+                    redisCache.deleteObject("live:like:" + liveData.getLiveId());
+                    redisCache.setCacheObject("live:like:" + liveData.getLiveId(), liveData.getLikes().intValue());
+                }
+            }
+        } catch (Exception e) {
+            log.error("项目启动时加载直播数据失败", e);
+        }
+    }
+
+
+
+    /**
+     *定期将缓存的数据写入数据库
+     */
+    @Scheduled(cron = "0 0/1 * * * ?")// 每分钟执行一次
+    public void syncLiveDataToDB() {
+        List<LiveData> liveDatas = liveDataService.getAllLiveDatas(); // 获取所有正在直播的直播间数据
+        if(liveDatas == null)
+            return;
+        liveDatas.forEach(liveData ->{
+
+            Map<String, Integer> flagMap = liveWatchUserService.getLiveFlagWithCache(liveData.getLiveId());
+            Integer liveFlag = flagMap.get("liveFlag");
+
+            // 判断是直播还是回放
+            if (liveFlag != null && liveFlag == 1) {
+                // 直播:更新 likes 和 totalViews
+                Long resultLikeCount = getAsLong(redisCache, "live:like:" + liveData.getLiveId());
+                resultLikeCount = resultLikeCount > 0L ? resultLikeCount : liveData.getLikes();
+                redisCache.setCacheObject("live:like:" + liveData.getLiveId(), resultLikeCount.intValue());
+                liveData.setLikes(resultLikeCount);
+
+                // 从 redis 获取数据,并提供默认值,避免 NPE
+                liveData.setPageViews(
+                        Math.max( liveData.getPageViews(), Optional.ofNullable(redisCache.incr(PAGE_VIEWS_KEY + liveData.getLiveId(),0)).orElse(0L))
+                );
+                liveData.setTotalViews(
+                        Math.max( liveData.getTotalViews(), Optional.ofNullable(redisCache.incr(TOTAL_VIEWS_KEY + liveData.getLiveId(),0)).orElse(0L))
+                );
+            } else {
+                // 回放:使用 Redis 中的数据减去直播的数据,得到回放的数据
+                String likeKey = "live:like:" + liveData.getLiveId();
+                String totalViewsKey = TOTAL_VIEWS_KEY + liveData.getLiveId();
+
+                // 从 Redis 获取总数据(直播+回放)
+                Long totalLikeCount = getAsLong(redisCache, likeKey);
+                Long totalViewCount = getAsLong(redisCache, totalViewsKey);
+
+                // 获取数据库中直播的数据
+                Long liveLikeCount = liveData.getLikes() != null ? liveData.getLikes() : 0L;
+                Long liveViewCount = liveData.getTotalViews() != null ? liveData.getTotalViews() : 0L;
+
+                // 回放数据 = Redis总数据 - 直播数据
+                Long replayLikeNum = totalLikeCount - liveLikeCount;
+                Long replayViewNum = totalViewCount - liveViewCount;
+
+                // 确保回放数据不为负数
+                if (replayLikeNum < 0L) {
+                    replayLikeNum = 0L;
+                }
+                if (replayViewNum < 0L) {
+                    replayViewNum = 0L;
+                }
+
+                // 更新回放数据
+                liveData.setReplayLikeNum(replayLikeNum);
+                liveData.setReplayViewNum(replayViewNum);
+
+                // 从 redis 获取数据,并提供默认值,避免 NPE
+                liveData.setPageViews(
+                        Math.max( liveData.getPageViews(), Optional.ofNullable(redisCache.incr(PAGE_VIEWS_KEY + liveData.getLiveId(),0)).orElse(0L))
+                );
+            }
+            liveData.setUniqueVisitors(
+                    /*Optional.ofNullable(redisCache.getCacheSet(UNIQUE_VISITORS_KEY + liveId))
+                            .map(Set::size)  // 获取集合大小
+                            .map(Long::valueOf)  // 转换为 Long 类型
+                            .orElse(0L)*/
+                    Math.max( liveData.getUniqueVisitors(), Optional.ofNullable(redisCache.incr(UNIQUE_VISITORS_KEY + liveData.getLiveId(),0)).orElse(0L))
+            );
+            liveData.setUniqueViewers(
+                    /*Optional.ofNullable(redisCache.getCacheSet(UNIQUE_VIEWERS_KEY + liveId))
+                            .map(Set::size)  // 获取集合大小
+                            .map(Long::valueOf)  // 转换为 Long 类型
+                            .orElse(0L)*/
+                    Math.max( liveData.getUniqueViewers(), Optional.ofNullable(redisCache.incr(UNIQUE_VIEWERS_KEY + liveData.getLiveId(),0)).orElse(0L))
+            );
+            // 使用Set大小来获取最大同时在线人数
+            String onlineUsersSetKey = ONLINE_USERS_SET_KEY + liveData.getLiveId();
+            Long currentSetSize = redisCache.redisTemplate.opsForSet().size(onlineUsersSetKey);
+            Long maxOnlineFromRedis = Optional.ofNullable(redisCache.getCacheObject(MAX_ONLINE_USERS_KEY + liveData.getLiveId()))
+                    .map(obj -> {
+                        if (obj instanceof Number) {
+                            return ((Number) obj).longValue();
+                        }
+                        try {
+                            return Long.parseLong(obj.toString());
+                        } catch (NumberFormatException e) {
+                            return 0L;
+                        }
+                    })
+                    .orElse(0L);
+            // 取Set大小和Redis中记录的最大在线人数的较大值
+            Long maxOnlineCount = Math.max(
+                    currentSetSize != null ? currentSetSize : 0L,
+                    maxOnlineFromRedis
+            );
+            liveData.setPeakConcurrentViewers(
+                    Math.max(liveData.getPeakConcurrentViewers(), maxOnlineCount)
+            );
+        });
+        if(!liveDatas.isEmpty())
+            for (LiveData liveData : liveDatas) {
+                liveDataService.updateLiveData(liveData);
+            }
+            /*// 更新数据库
+            liveDataService.updateLiveData(liveData);*/
+        Set<String> keys = redisCache.redisTemplate.keys(String.format(LIVE_COUPON_NUM, "*"));
+        if (keys != null && !keys.isEmpty()) {
+            for (String key : keys) {
+                Object o = redisCache.redisTemplate.opsForValue().get(String.format(LIVE_COUPON_NUM, key));
+                if (o != null) {
+                    LiveCouponIssue updateEntity = new LiveCouponIssue();
+                    updateEntity.setId(Long.valueOf(key));
+                    updateEntity.setRemainCount(Long.parseLong(o.toString()));
+                    liveCouponIssueService.updateLiveCouponIssue(updateEntity);
+                }
+            }
+        }
+    }
+
+    /**
+     * 更新红包领取数量
+     */
+    @Scheduled(cron = "0/30 * * * * ?")
+    @DistributeLock(key = "updateRedQuantityNum", scene = "task")
+    public void updateRedQuantityNum() {
+        liveRedConfService.updateRedQuantityNum();
+    }
+
+    /**
+     * 定时扫描开启的直播间,检查是否到了打标签的时间,然后把正在看直播的用户拆分为 直播用户和回放用户
+     * 每10秒执行一次
+     */
+    @Scheduled(cron = "0/10 * * * * ?")
+    @DistributeLock(key = "scanLiveTagMark", scene = "task")
+    public void scanLiveTagMark() {
+        try {
+
+            // 获取所有打标签缓存的key
+            String pattern = String.format(LiveKeysConstant.LIVE_TAG_MARK_CACHE, "*");
+            Set<String> keys = redisCache.redisTemplate.keys(pattern);
+
+            if (keys == null || keys.isEmpty()) {
+                return;
+            }
+
+            long currentTimeMillis = System.currentTimeMillis();
+
+            List<Long> processedLiveIds = new ArrayList<>();
+            Date nowDate = new Date();
+            for (String key : keys) {
+                try {
+                    // 从Redis获取直播间信息
+                    Object cacheValue = redisCache.getCacheObject(key);
+                    if (cacheValue == null) {
+                        continue;
+                    }
+
+                    String jsonStr = cacheValue.toString();
+                    JSONObject tagMarkInfo = JSON.parseObject(jsonStr);
+                    Long liveId = tagMarkInfo.getLong("liveId");
+                    Long startTimeMillis = tagMarkInfo.getLong("startTime");
+                    Long videoDuration = tagMarkInfo.getLong("videoDuration");
+
+                    if (liveId == null || startTimeMillis == null || videoDuration == null || videoDuration <= 0) {
+                        log.info("直播间打标签缓存信息不完整: key={}, liveId={}, startTime={}, videoDuration={}",
+                                key, liveId, startTimeMillis, videoDuration);
+                        continue;
+                    }
+
+                    // 查询直播间信息
+                    Live live = liveService.selectLiveDbByLiveId(liveId);
+                    if (live == null || live.getStartTime() == null) {
+                        log.info("没查到直播间-{}",liveId);
+                        continue;
+                    }
+                    // 计算结束时间:开始时间 + 视频时长(秒转毫秒)
+                    long endTimeMillis = startTimeMillis + (videoDuration * 1000);
+
+                    // 如果当前时间已经超过了结束时间,执行打标签操作(约等于直播完成)
+                    if (currentTimeMillis >= endTimeMillis) {
+                        // 查询当前直播间的在线用户(liveFlag = 1, replayFlag = 0)
+                        LiveWatchUser queryUser = new LiveWatchUser();
+                        queryUser.setLiveId(liveId);
+                        queryUser.setLiveFlag(1);
+                        queryUser.setReplayFlag(0);
+                        queryUser.setOnline(0);
+                        List<LiveWatchUser> liveUsers = liveWatchUserService.selectAllWatchUser(queryUser);
+
+                        if (liveUsers != null && !liveUsers.isEmpty()) {
+
+                            List<LiveWatchUser> updateLiveUsers = new ArrayList<>(); // 需要更新的直播用户
+                            List<LiveWatchUser> replayUsers = new ArrayList<>(); // 回放用户数据
+
+                            for (LiveWatchUser liveUser : liveUsers) {
+                                Long userId = liveUser.getUserId();
+                                if (userId == null) {
+                                    continue;
+                                }
+
+                                // 1. 计算并更新直播用户的在线时长
+                                // 优先从 Redis 获取进入时间
+                                String entryTimeKey = String.format("live:user:entry:time:%s:%s", liveId, userId);
+                                Long entryTime = redisCache.getCacheObject(entryTimeKey);
+
+                                // 如果没有 Redis 记录,使用数据库中的 updateTime
+                                if (entryTime == null) {
+                                    if (liveUser.getUpdateTime() != null) {
+                                        entryTime = liveUser.getUpdateTime().getTime();
+                                    } else if (liveUser.getCreateTime() != null) {
+                                        entryTime = liveUser.getCreateTime().getTime();
+                                    }
+                                }
+
+                                // 计算当前观看时长(秒)
+                                long currentWatchDuration = 0L;
+                                if (entryTime != null) {
+                                    currentWatchDuration = (currentTimeMillis - entryTime) / 1000;
+                                    if (currentWatchDuration < 0) {
+                                        currentWatchDuration = 0L;
+                                    }
+                                }
+
+                                // 加上历史在线时长
+                                Long historyOnlineSeconds = liveUser.getOnlineSeconds();
+                                if (historyOnlineSeconds == null) {
+                                    historyOnlineSeconds = 0L;
+                                }
+                                long totalOnlineSeconds = historyOnlineSeconds + currentWatchDuration;
+
+                                log.info("更新直播用户的在线时长 用户直播离线 录播在线");
+                                // 更新直播用户的在线时长 用户直播离线 录播在线
+                                liveUser.setOnlineSeconds(totalOnlineSeconds);
+                                liveUser.setUpdateTime(nowDate);
+                                liveUser.setOnline(1);
+                                updateLiveUsers.add(liveUser);
+
+                                log.info(" 生成回放用户数据(liveFlag = 0, replayFlag = 1),在线时长从0开始");
+                                // 2. 生成回放用户数据(liveFlag = 0, replayFlag = 1),在线时长从0开始
+                                LiveWatchUser replayUser = new LiveWatchUser();
+                                replayUser.setLiveId(liveUser.getLiveId());
+                                replayUser.setUserId(liveUser.getUserId());
+                                replayUser.setMsgStatus(liveUser.getMsgStatus());
+                                replayUser.setOnline(0);
+                                replayUser.setOnlineSeconds(0L); // 回放观看时长从0开始,重新计时
+                                replayUser.setGlobalVisible(liveUser.getGlobalVisible());
+                                replayUser.setSingleVisible(liveUser.getSingleVisible());
+                                replayUser.setLiveFlag(0); // 回放标记
+                                replayUser.setReplayFlag(1); // 回放标记
+                                replayUser.setLocation(liveUser.getLocation());
+                                replayUser.setCreateTime(nowDate);
+                                replayUser.setUpdateTime(nowDate);
+                                replayUsers.add(replayUser);
+                                redisCache.setCacheObject(entryTimeKey,currentTimeMillis);
+                            }
+
+                            // 批量更新直播用户的在线时长
+                            if (!updateLiveUsers.isEmpty()) {
+                                int batchSize = 500;
+                                for (int i = 0; i < updateLiveUsers.size(); i += batchSize) {
+                                    int end = Math.min(i + batchSize, updateLiveUsers.size());
+                                    List<LiveWatchUser> batch = updateLiveUsers.subList(i, end);
+                                    liveWatchUserService.batchUpdateLiveWatchUser(batch);
+                                }
+                            }
+
+                            // 批量插入回放用户数据
+                            if (!replayUsers.isEmpty()) {
+                                int batchSize = 500;
+                                for (int i = 0; i < replayUsers.size(); i += batchSize) {
+                                    int end = Math.min(i + batchSize, replayUsers.size());
+                                    List<LiveWatchUser> batch = replayUsers.subList(i, end);
+                                    liveWatchUserService.batchInsertLiveWatchUser(batch);
+                                }
+                            }
+
+                            // 清理直播间状态缓存
+                            liveWatchUserService.clearLiveFlagCache(liveId);
+                        }
+
+                        // 标记为已处理,稍后删除缓存
+                        processedLiveIds.add(liveId);
+                        // 调用打标签方法
+                        liveWatchUserService.qwTagMarkByLiveWatchLog(liveId);
+                    }
+                } catch (Exception e) {
+                    log.error("处理直播间打标签缓存异常: key={}, error={}", key, e.getMessage(), e);
+                }
+            }
+
+            // 删除已处理的直播间缓存
+            for (Long liveId : processedLiveIds) {
+                try {
+                    String tagMarkKey = String.format(LiveKeysConstant.LIVE_TAG_MARK_CACHE, liveId);
+                    redisCache.deleteObject(tagMarkKey);
+                } catch (Exception e) {
+                    log.error("删除直播间打标签缓存失败: liveId={}, error={}", liveId, e.getMessage(), e);
+                }
+            }
+        } catch (Exception e) {
+            log.error("扫描直播间打标签任务异常: error={}", e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 实时扫描用户直播数据,根据用户的直播在线时长更新观看记录状态
+     * 每30秒执行一次
+     */
+    @Scheduled(cron = "0/30 * * * * ?")
+    @DistributeLock(key = "scanLiveWatchUserStatus", scene = "task")
+    public void scanLiveWatchUserStatus() {
+        try {
+            DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+            // 查询所有正在直播的直播间
+            List<Live> activeLives = liveService.selectNoEndLiveList();
+            if (activeLives == null || activeLives.isEmpty()) {
+                return;
+            }
+            for (Live live : activeLives) {
+                try {
+                    Long liveId = live.getLiveId();
+                    if (liveId == null) {
+                        continue;
+                    }
+                    // 获取直播间的直播/回放状态
+                    Map<String, Integer> flagMap = liveWatchUserService.getLiveFlagWithCache(liveId);
+                    Integer liveFlag = flagMap.get("liveFlag");
+                    // 只处理直播状态的用户(liveFlag = 1)
+                    if (liveFlag == null || liveFlag != 1) {
+                        continue;
+                    }
+                    // 查询该直播间的在线用户(liveFlag = 1, replayFlag = 0)
+                    LiveWatchUser queryUser = new LiveWatchUser();
+                    queryUser.setLiveId(liveId);
+                    queryUser.setLiveFlag(1);
+                    queryUser.setReplayFlag(0);
+                    List<LiveWatchUser> onlineUsers = liveWatchUserService.selectAllWatchUser(queryUser);
+                    if (onlineUsers == null || onlineUsers.isEmpty()) {
+                        continue;
+                    }
+                    // 获取直播视频总时长
+                    List<LiveVideo> videos = liveVideoService.listByLiveIdWithCache(liveId, 1);
+                    long totalVideoDuration = 0L;
+                    if (videos != null && !videos.isEmpty()) {
+                        totalVideoDuration = videos.stream()
+                                .filter(v -> v.getDuration() != null)
+                                .mapToLong(LiveVideo::getDuration)
+                                .sum();
+                    }
+
+                    // 处理每个在线用户
+                    List<LiveWatchLog> updateLog = new ArrayList<>();
+                    for (LiveWatchUser user : onlineUsers) {
+                        try {
+                            Long userId = user.getUserId();
+                            if (userId == null) {
+                                continue;
+                            }
+
+                            // 获取用户的在线观看时长
+                            String entryTimeKey = String.format(USER_ENTRY_TIME_KEY, liveId, userId);
+                            Long existingEntryTime = redisCache.getCacheObject(entryTimeKey);
+                            Long onlineSeconds = user.getOnlineSeconds() ==null ? 0L : user.getOnlineSeconds();
+                            if(null != existingEntryTime){
+                                onlineSeconds = onlineSeconds + ((System.currentTimeMillis() - existingEntryTime)/1000);
+                            }
+                            if (onlineSeconds == null || onlineSeconds <= 0) {
+                                continue;
+                            }
+
+                            // 获取用户的 companyId 和 companyUserId
+                            LiveUserFirstEntry liveUserFirstEntry =
+                                    liveUserFirstEntryService.selectEntityByLiveIdUserIdWithCache(liveId, userId);
+                            if (liveUserFirstEntry == null) {
+                                continue;
+                            }
+
+                            Long qwUserId = liveUserFirstEntry.getQwUserId();
+                            Long externalContactId = liveUserFirstEntry.getExternalContactId();
+
+                            if (qwUserId == null || qwUserId <= 0 || externalContactId == null || externalContactId <= 0) {
+                                continue;
+                            }
+                            //更新最新用户活跃时间
+                            String liveUserWatchLogKey = String.format(LIVE_USER_WATCH_LOG_CACHE, liveId, userId,externalContactId,qwUserId);
+                            LocalDateTime now = LocalDateTime.now();
+                            redisCache.setCacheObject(liveUserWatchLogKey,formatter.format(now),5,TimeUnit.MINUTES);
+                            // 使用 updateLiveWatchLogTypeByDuration 的逻辑更新观看记录状态
+                            updateLiveWatchLogTypeByDuration(liveId, userId, qwUserId, externalContactId,
+                                    onlineSeconds, totalVideoDuration, updateLog);
+
+                        } catch (Exception e) {
+                            log.error("处理用户观看记录状态异常: liveId={}, userId={}, error={}",
+                                    liveId, user.getUserId(), e.getMessage(), e);
+                        }
+                    }
+                    // 批量插入回放用户数据
+                    if (!updateLog.isEmpty()) {
+                        int batchSize = 500;
+                        for (int i = 0; i < updateLog.size(); i += batchSize) {
+                            int end = Math.min(i + batchSize, updateLog.size());
+                            List<LiveWatchLog> batch = updateLog.subList(i, end);
+                            liveWatchLogService.batchUpdateLiveWatchLog(batch);
+                        }
+                        for (LiveWatchLog liveWatchLog : updateLog) {
+                            redisCache.setCacheObject("live:watch:log:cache:" + liveWatchLog.getLogId(), liveWatchLog, 1, TimeUnit.HOURS);
+                        }
+                    }
+
+                } catch (Exception e) {
+                    log.error("处理直播间观看记录状态异常: liveId={}, error={}",
+                            live.getLiveId(), e.getMessage(), e);
+                }
+            }
+        } catch (Exception e) {
+            log.error("实时扫描用户直播数据任务异常: error={}", e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 根据在线时长更新 LiveWatchLog 的 logType(复用 WebSocketServer 中的逻辑)
+     * @param liveId 直播间ID
+     * @param userId 用户ID
+     * @param qwUserId 邀请人id
+     * @param  exId 外部人id
+     * @param onlineSeconds 在线时长(秒)
+     * @param totalVideoDuration 视频总时长(秒)
+     */
+    private void updateLiveWatchLogTypeByDuration(Long liveId, Long userId, Long qwUserId,
+                                                   Long exId, Long onlineSeconds, long totalVideoDuration, List<LiveWatchLog> updateLog) {
+        try {
+            // 查询 LiveWatchLog
+            LiveWatchLog queryLog = new LiveWatchLog();
+            queryLog.setLiveId(liveId);
+            queryLog.setQwUserId(String.valueOf(qwUserId));
+            queryLog.setExternalContactId(exId);
+
+            List<LiveWatchLog> logs = liveWatchLogService.selectLiveWatchLogByLogIdWithCache(queryLog);
+            if (logs == null || logs.isEmpty()) {
+                return;
+            }
+
+            Date now = new Date();
+            for (LiveWatchLog log : logs) {
+                if (log.getLogType() != null && log.getLogType() == 2) {
+                    continue;
+                }
+                boolean needUpdate = false;
+                Integer newLogType = log.getLogType();
+
+                // ① 如果在线时长 <= 3分钟,修改 logType 为 4(看课中断) lmx-这个逻辑不合理,不能这样判定看课中断
+//                if (onlineSeconds <= 180) { // 3分钟 = 180秒
+//                    newLogType = 4;
+//                    needUpdate = true;
+//                } else
+                    // ③ 如果直播视频 >= 40分钟,在线时长 >= 30分钟,logType 设置为 2(完课)
+                if (totalVideoDuration >= 2400 && onlineSeconds >= 1800) { // 40分钟 = 2400秒,30分钟 = 1800秒
+                    newLogType = 2;
+                    log.setFinishTime(now);
+                    needUpdate = true;
+                }
+                // 如果直播视频 >= 20分钟且 < 40分钟,在线时长 >= 20分钟,logType 设置为 2(完课)
+                else if (totalVideoDuration >= 1200 && totalVideoDuration < 2400 && onlineSeconds >= 1200) { // 20分钟 = 1200秒
+                    newLogType = 2;
+                    log.setFinishTime(now);
+                    needUpdate = true;
+                }
+
+                // 如果 logType 已经是 2(完课),不再更新
+                if (needUpdate) {
+                    log.setLogType(newLogType);
+                    updateLog.add(log);
+                }
+            }
+        } catch (Exception e) {
+            log.error("根据在线时长更新 LiveWatchLog logType 异常:liveId={}, userId={}, error={}",
+                    liveId, userId, e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 每分钟扫描一次用户在线状态用于更新用户观看记录值
+     */
+    @Scheduled(cron = "0 0/1 * * * ?")
+    @DistributeLock(key = "updateLiveWatchUserStatus", scene = "task")
+    public void updateLiveWatchUserStatus() {
+        try {
+            Set<String> keys = redisCache.redisTemplate.keys("live:user:watch:log:*");
+            LocalDateTime now = LocalDateTime.now();
+            DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+            List<LiveWatchLog> updateLog = new ArrayList<>();
+            if (keys != null && !keys.isEmpty()) {
+                for (String key : keys) {
+                    String[] split = key.split(":");
+                    String cacheTime = redisCache.getCacheObject(key);
+                    //判断缓存的值是否已经距离现在超过一分钟
+                    if (StringUtils.isNotBlank(cacheTime)) {
+                        try {
+                            LocalDateTime cachedDateTime = LocalDateTime.parse(cacheTime, formatter);
+                            // 比较时间,判断是否超过1分钟(60秒)
+                            long secondsBetween = java.time.Duration.between(cachedDateTime, now).getSeconds();
+                            if (secondsBetween >= 60) {
+                                // 距离上次记录已超过1分钟,更新状态为看课中断
+                                // 查询 LiveWatchLog
+                                LiveWatchLog queryLog = new LiveWatchLog();
+                                queryLog.setLiveId(Long.valueOf(split[4]));
+                                queryLog.setQwUserId(String.valueOf(split[7]));
+                                queryLog.setExternalContactId(Long.valueOf(split[6]));
+                                queryLog.setLogType(1);
+                                List<LiveWatchLog> logs = liveWatchLogService.selectLiveWatchLogList(queryLog);
+                                if (logs != null && !logs.isEmpty()) {
+                                    for (LiveWatchLog log : logs) {
+                                        if (log.getLogType() != null && log.getLogType() == 2) {
+                                            continue;
+                                        }
+                                        log.setLogType(4);
+                                        updateLog.add(log);
+                                    }
+                                }
+                            }
+                        } catch (Exception e) {
+                            log.error("解析缓存时间失败: cacheTime={}, error={}", cacheTime, e.getMessage());
+                        }
+                    }
+                }
+                // 批量插入回放用户数据
+                if (!updateLog.isEmpty()) {
+                    int batchSize = 500;
+                    for (int i = 0; i < updateLog.size(); i += batchSize) {
+                        int end = Math.min(i + batchSize, updateLog.size());
+                        List<LiveWatchLog> batch = updateLog.subList(i, end);
+                        liveWatchLogService.batchUpdateLiveWatchLog(batch);
+                    }
+                    for (LiveWatchLog liveWatchLog : updateLog) {
+                        redisCache.setCacheObject("live:watch:log:cache:" + liveWatchLog.getLogId(), liveWatchLog, 1, TimeUnit.HOURS);
+                    }
+                }
+            }
+        } catch (Exception ex) {
+            log.error("每分钟扫描一次用户在线状态用于更新用户观看记录值: error={}", ex.getMessage(), ex);
+        }
+    }
+
+    /**
+     * 批量同步Redis中的观看时长到数据库
+     * 每2分钟执行一次,减少数据库压力
+     */
+//    @Scheduled(cron = "0 0/2 * * * ?")
+//    @DistributeLock(key = "batchSyncWatchDuration", scene = "task")
+//    public void batchSyncWatchDuration() {
+//        try {
+//            log.info("开始批量同步观看时长到数据库");
+//
+//            // 优化:从所有直播间的Hash中批量获取数据
+//            List<Live> activeLives = liveService.selectNoEndLiveList();
+//
+//            if (activeLives == null || activeLives.isEmpty()) {
+//                log.debug("当前没有活跃的直播间");
+//                return;
+//            }
+//
+//            int totalCount = 0;
+//            int successCount = 0;
+//            int failCount = 0;
+//
+//            // 逐个直播间处理
+//            for (Live live : activeLives) {
+//                try {
+//                    Long liveId = live.getLiveId();
+//
+//                    // 使用Hash结构存储每个直播间的观看时长
+//                    String hashKey = "live:watch:duration:hash:" + liveId;
+//                    Map<Object, Object> userDurations = redisCache.redisTemplate.opsForHash().entries(hashKey);
+//
+//                    if (userDurations == null || userDurations.isEmpty()) {
+//                        continue;
+//                    }
+//
+//                    // 获取直播/回放标记(一次查询,所有用户复用)
+//                    Map<String, Integer> flagMap = liveWatchUserService.getLiveFlagWithCache(liveId);
+//                    Integer liveFlag = flagMap.get("liveFlag");
+//                    Integer replayFlag = flagMap.get("replayFlag");
+//
+//                    // 批量处理该直播间的所有用户
+//                    for (Map.Entry<Object, Object> entry : userDurations.entrySet()) {
+//                        try {
+//                            Long userId = Long.parseLong(entry.getKey().toString());
+//                            Long duration = Long.parseLong(entry.getValue().toString());
+//
+//                            totalCount++;
+//
+//                            // 异步更新数据库
+//                            liveWatchUserService.updateWatchDuration(liveId, userId, liveFlag, replayFlag, duration);
+//                            successCount++;
+//
+//                        } catch (Exception e) {
+//                            failCount++;
+//                            log.error("同步用户观看时长失败: liveId={}, userId={}, error={}",
+//                                    liveId, entry.getKey(), e.getMessage());
+//                        }
+//                    }
+//
+//                } catch (Exception e) {
+//                    log.error("处理直播间观看时长失败: liveId={}, error={}", live.getLiveId(), e.getMessage());
+//                }
+//            }
+//
+//            log.info("批量同步观看时长完成: 总数={}, 成功={}, 失败={}", totalCount, successCount, failCount);
+//
+//        } catch (Exception e) {
+//            log.error("批量同步观看时长任务异常", e);
+//        }
+//    }
+}

+ 17 - 0
fs-live-ws/src/main/java/com/fs/live/vo/LotteryVo.java

@@ -0,0 +1,17 @@
+package com.fs.live.vo;
+
+import lombok.Getter;
+import lombok.Setter;
+import lombok.ToString;
+
+@Getter
+@Setter
+@ToString
+public class LotteryVo {
+    private String userName;
+    private Long userId;
+    private String productName;
+    private Long prizeLevel;
+    private Long productId;
+    private Long recordId;
+}

+ 26 - 0
fs-live-ws/src/main/java/com/fs/live/ws/bean/WsSendMsgVo.java

@@ -0,0 +1,26 @@
+package com.fs.live.ws.bean;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@Builder
+@AllArgsConstructor
+@NoArgsConstructor
+public class WsSendMsgVo {
+    private Long liveId;
+    private Long userId;
+    private Long companyId;
+    private Long companyUserId;
+    private Long userType;
+    private String cmd;
+    private String msg;
+    private String data;
+    private String nickName;
+    private String avatar;
+    private boolean on = false;
+    private Integer status;
+    private Integer duration;
+}

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

@@ -0,0 +1,23 @@
+package com.fs.live.ws.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@Data
+@Component
+@ConfigurationProperties(prefix = "live.ws")
+public class LiveWsProperties {
+
+    private boolean enabled = true;
+    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";
+    private int heartbeatTimeoutSeconds = 120;
+    private int nodeHeartbeatSeconds = 10;
+    /** 直播间在线人数 userCount 广播间隔(秒) */
+    private int userCountBroadcastSeconds = 10;
+}

+ 22 - 0
fs-live-ws/src/main/java/com/fs/live/ws/config/RedisPubSubConfig.java

@@ -0,0 +1,22 @@
+package com.fs.live.ws.config;
+
+import com.fs.live.ws.listener.LiveWsRoomMessageListener;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.listener.PatternTopic;
+import org.springframework.data.redis.listener.RedisMessageListenerContainer;
+
+@Configuration
+public class RedisPubSubConfig {
+
+    @Bean
+    RedisMessageListenerContainer redisMessageListenerContainer(
+            RedisConnectionFactory factory,
+            LiveWsRoomMessageListener roomMessageListener) {
+        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
+        container.setConnectionFactory(factory);
+        container.addMessageListener(roomMessageListener, new PatternTopic("live:ws:room:*"));
+        return container;
+    }
+}

+ 22 - 0
fs-live-ws/src/main/java/com/fs/live/ws/constant/WsAttrConstant.java

@@ -0,0 +1,22 @@
+package com.fs.live.ws.constant;
+
+import io.netty.util.AttributeKey;
+
+public final class WsAttrConstant {
+
+    private WsAttrConstant() {
+    }
+
+    public static final String LIVE_ID = "liveId";
+    public static final String USER_ID = "userId";
+    public static final String USER_TYPE = "userType";
+    public static final String TOKEN = "APPToken";
+    public static final String LOCATION = "location";
+    public static final String COMPANY_ID = "companyId";
+    public static final String COMPANY_USER_ID = "companyUserId";
+
+    public static final AttributeKey<Long> ATTR_LIVE_ID = AttributeKey.valueOf(LIVE_ID);
+    public static final AttributeKey<Long> ATTR_USER_ID = AttributeKey.valueOf(USER_ID);
+    public static final AttributeKey<Long> ATTR_USER_TYPE = AttributeKey.valueOf(USER_TYPE);
+    public static final AttributeKey<String> ATTR_LOCATION = AttributeKey.valueOf(LOCATION);
+}

+ 14 - 0
fs-live-ws/src/main/java/com/fs/live/ws/constant/WsRedisKeys.java

@@ -0,0 +1,14 @@
+package com.fs.live.ws.constant;
+
+public final class WsRedisKeys {
+
+    private WsRedisKeys() {
+    }
+
+    /** live:ws:node:{nodeId} Hash: connections, maxConnections, host, port, updateTime */
+    public static final String WS_NODE = "live:ws:node:%s";
+    /** Pub/Sub 频道:live:ws:room:{liveId} */
+    public static final String WS_ROOM_CHANNEL = "live:ws:room:%s";
+    public static final int WS_NODE_TTL_SECONDS = 30;
+    public static final String USER_ENTRY_TIME_KEY = "live:user:entry:time:%s:%s";
+}

+ 93 - 0
fs-live-ws/src/main/java/com/fs/live/ws/controller/WsEndpointController.java

@@ -0,0 +1,93 @@
+package com.fs.live.ws.controller;
+
+import com.fs.common.core.domain.R;
+import com.fs.live.ws.config.LiveWsProperties;
+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.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@Api("WebSocket 节点发现")
+@RestController
+@RequestMapping("/app/live")
+public class WsEndpointController {
+
+    @Autowired
+    private IWsNodeRegistryService nodeRegistryService;
+    @Autowired
+    private LiveWsProperties properties;
+
+    @ApiOperation("WebSocket 服务健康检查")
+    @GetMapping("/wsHealth")
+    public R wsHealth() {
+        return R.ok()
+                .put("httpPort", 7115)
+                .put("wsPort", properties.getNettyPort())
+                .put("wsPath", properties.getPath())
+                .put("wsEnabled", properties.isEnabled())
+                .put("wsUrlExample", buildWsUrl(properties.getPublicHost(),
+                        String.valueOf(properties.getNettyPort()), 0L).replace("liveId=0", "liveId={liveId}"));
+    }
+
+    @ApiOperation("获取 WebSocket 连接地址")
+    @GetMapping("/wsEndpoint")
+    public R wsEndpoint(@RequestParam Long liveId) {
+        List<Map<String, Object>> nodes = nodeRegistryService.listActiveNodes();
+        if (nodes.isEmpty()) {
+            return buildResponse(properties.getPublicHost(), String.valueOf(properties.getNettyPort()),
+                    properties.getNodeId(), liveId, null);
+        }
+
+        List<Map<String, Object>> sorted = nodes.stream()
+                .sorted(Comparator.comparingDouble(this::connectionRatio))
+                .collect(Collectors.toList());
+
+        Map<String, Object> best = sorted.get(0);
+        List<String> fallback = new ArrayList<>();
+        for (int i = 1; i < sorted.size() && i <= 3; i++) {
+            Map<String, Object> node = sorted.get(i);
+            fallback.add(buildWsUrl(String.valueOf(node.get("host")), String.valueOf(node.get("port")), liveId));
+        }
+
+        return buildResponse(String.valueOf(best.get("host")), String.valueOf(best.get("port")),
+                String.valueOf(best.get("nodeId")), liveId, fallback);
+    }
+
+    private R buildResponse(String host, String port, String nodeId, Long liveId, List<String> fallback) {
+        return R.ok()
+                .put("wsUrl", buildWsUrl(host, port, liveId))
+                .put("nodeId", nodeId)
+                .put("fallback", fallback == null ? new ArrayList<>() : fallback);
+    }
+
+    private String buildWsUrl(String host, String port, Long liveId) {
+        return properties.getPublicScheme() + "://" + host + ":" + port
+                + properties.getPath() + "?liveId=" + liveId;
+    }
+
+    private double connectionRatio(Map<String, Object> node) {
+        int conn = toInt(node.get("connections"));
+        int max = toInt(node.get("maxConnections"));
+        if (max <= 0) {
+            return 1.0;
+        }
+        return (double) conn / max;
+    }
+
+    private int toInt(Object value) {
+        if (value == null) {
+            return 0;
+        }
+        return Integer.parseInt(String.valueOf(value));
+    }
+}

+ 111 - 0
fs-live-ws/src/main/java/com/fs/live/ws/handler/WsAuthHandler.java

@@ -0,0 +1,111 @@
+package com.fs.live.ws.handler;
+
+import com.fs.live.ws.constant.WsAttrConstant;
+import com.fs.live.ws.util.WsJwtUtils;
+import io.jsonwebtoken.Claims;
+import io.netty.buffer.Unpooled;
+import io.netty.channel.ChannelFutureListener;
+import io.netty.channel.ChannelHandler;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelInboundHandlerAdapter;
+import io.netty.handler.codec.http.DefaultFullHttpResponse;
+import io.netty.handler.codec.http.FullHttpRequest;
+import io.netty.handler.codec.http.FullHttpResponse;
+import io.netty.handler.codec.http.HttpHeaderNames;
+import io.netty.handler.codec.http.HttpResponseStatus;
+import io.netty.handler.codec.http.HttpVersion;
+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;
+
+@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)) {
+            ctx.close();
+            return;
+        }
+        FullHttpRequest req = (FullHttpRequest) msg;
+        QueryStringDecoder decoder = new QueryStringDecoder(req.uri());
+        Map<String, List<String>> params = decoder.parameters();
+
+        if (!params.containsKey(WsAttrConstant.LIVE_ID) || !params.containsKey(WsAttrConstant.USER_ID)) {
+            reject(ctx, req, HttpResponseStatus.BAD_REQUEST, "缺少 liveId 或 userId");
+            return;
+        }
+
+        String token = resolveToken(params);
+        if (token == null) {
+            reject(ctx, req, HttpResponseStatus.UNAUTHORIZED, "缺少 token");
+            return;
+        }
+
+        Long liveId = Long.valueOf(params.get(WsAttrConstant.LIVE_ID).get(0));
+        Long userId = Long.valueOf(params.get(WsAttrConstant.USER_ID).get(0));
+        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;
+        }
+
+        long userType = 0L;
+        if (params.containsKey(WsAttrConstant.USER_TYPE)) {
+            userType = Long.parseLong(params.get(WsAttrConstant.USER_TYPE).get(0));
+        }
+
+        ctx.channel().attr(WsAttrConstant.ATTR_LIVE_ID).set(liveId);
+        ctx.channel().attr(WsAttrConstant.ATTR_USER_ID).set(userId);
+        ctx.channel().attr(WsAttrConstant.ATTR_USER_TYPE).set(userType);
+        if (params.containsKey(WsAttrConstant.LOCATION)) {
+            ctx.channel().attr(WsAttrConstant.ATTR_LOCATION).set(params.get(WsAttrConstant.LOCATION).get(0));
+        }
+
+        ctx.pipeline().remove(this);
+        ctx.fireChannelRead(req.retain());
+        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);
+        FullHttpResponse response = new DefaultFullHttpResponse(
+                HttpVersion.HTTP_1_1,
+                status,
+                Unpooled.wrappedBuffer(body));
+        response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain; charset=UTF-8");
+        response.headers().set(HttpHeaderNames.CONTENT_LENGTH, body.length);
+        ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
+        req.release();
+    }
+}

+ 30 - 0
fs-live-ws/src/main/java/com/fs/live/ws/handler/WsHeartbeatHandler.java

@@ -0,0 +1,30 @@
+package com.fs.live.ws.handler;
+
+import io.netty.channel.ChannelHandler;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelInboundHandlerAdapter;
+import io.netty.handler.timeout.IdleState;
+import io.netty.handler.timeout.IdleStateEvent;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+@Component
+@ChannelHandler.Sharable
+public class WsHeartbeatHandler extends ChannelInboundHandlerAdapter {
+
+    private static final Logger log = LoggerFactory.getLogger(WsHeartbeatHandler.class);
+
+    @Override
+    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
+        if (evt instanceof IdleStateEvent) {
+            IdleStateEvent event = (IdleStateEvent) evt;
+            if (event.state() == IdleState.READER_IDLE) {
+                log.debug("WS 读空闲超时,关闭连接 channelId={}", ctx.channel().id());
+                ctx.close();
+                return;
+            }
+        }
+        super.userEventTriggered(ctx, evt);
+    }
+}

+ 84 - 0
fs-live-ws/src/main/java/com/fs/live/ws/handler/WsLiveChatHandler.java

@@ -0,0 +1,84 @@
+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 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;
+import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
+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;
+
+@Component
+@ChannelHandler.Sharable
+public class WsLiveChatHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
+
+    private static final Logger log = LoggerFactory.getLogger(WsLiveChatHandler.class);
+
+    @Autowired
+    private ILiveWsConnectionService connectionService;
+    @Autowired
+    private ILiveWsMessageService messageService;
+    @Autowired
+    @Qualifier("threadPoolTaskExecutor")
+    private ThreadPoolTaskExecutor connectionExecutor;
+
+    @Override
+    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
+        if (evt instanceof WebSocketServerProtocolHandler.HandshakeComplete) {
+            Channel channel = ctx.channel();
+            Long liveId = channel.attr(WsAttrConstant.ATTR_LIVE_ID).get();
+            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();
+
+            if (!connectionService.tryAcceptConnection()) {
+                channel.writeAndFlush(new TextWebSocketFrame("Error: 4503 节点连接已满"))
+                        .addListener(ChannelFutureListener.CLOSE);
+                return;
+            }
+            // 握手已完成(101),DB/Redis 逻辑放业务线程,避免阻塞 Netty IO 线程
+            connectionExecutor.execute(() -> {
+                try {
+                    connectionService.onConnected(liveId, userId, userType, channel, location);
+                } catch (Exception ex) {
+                    log.warn("WS 连接建立失败 liveId={}, userId={}", liveId, userId, ex);
+                    channel.writeAndFlush(new TextWebSocketFrame("Error: " + ex.getMessage()))
+                            .addListener(ChannelFutureListener.CLOSE);
+                }
+            });
+            return;
+        }
+        super.userEventTriggered(ctx, evt);
+    }
+
+    @Override
+    protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) {
+        messageService.handleTextMessage(ctx.channel(), frame.text());
+    }
+
+    @Override
+    public void channelInactive(ChannelHandlerContext ctx) {
+        Channel channel = ctx.channel();
+        Long liveId = channel.attr(WsAttrConstant.ATTR_LIVE_ID).get();
+        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);
+        }
+    }
+
+    @Override
+    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
+        log.error("WS 通道异常", cause);
+        ctx.close();
+    }
+}

+ 72 - 0
fs-live-ws/src/main/java/com/fs/live/ws/listener/LiveWsRoomMessageListener.java

@@ -0,0 +1,72 @@
+package com.fs.live.ws.listener;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.fs.live.ws.service.ILiveWsBroadcastService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.connection.Message;
+import org.springframework.data.redis.connection.MessageListener;
+import org.springframework.stereotype.Component;
+
+import java.nio.charset.StandardCharsets;
+
+/**
+ * Redis Pub/Sub 直播间消息监听(频道 live:ws:room:{liveId})
+ */
+@Component
+public class LiveWsRoomMessageListener implements MessageListener {
+
+    private static final Logger log = LoggerFactory.getLogger(LiveWsRoomMessageListener.class);
+    private static final String CHANNEL_PREFIX = "live:ws:room:";
+
+    @Autowired
+    private ILiveWsBroadcastService broadcastService;
+
+    @Override
+    public void onMessage(Message message, byte[] pattern) {
+        message.getBody();
+        String body = new String(message.getBody(), StandardCharsets.UTF_8);
+        message.getChannel();
+        String channel = new String(message.getChannel(), StandardCharsets.UTF_8);
+
+        Long liveId = resolveLiveId(channel, body);
+        if (liveId == null) {
+            log.warn("无法解析 liveId channel={}, bodyLen={}", channel, body.length());
+            return;
+        }
+        broadcastService.broadcastLocal(liveId, body);
+    }
+
+    private Long resolveLiveId(String channel, String body) {
+        if (channel != null && channel.startsWith(CHANNEL_PREFIX)) {
+            String liveIdStr = channel.substring(CHANNEL_PREFIX.length());
+            if (!liveIdStr.isEmpty() && !"*".equals(liveIdStr)) {
+                try {
+                    return Long.valueOf(liveIdStr);
+                } catch (NumberFormatException ignored) {
+                    // fall through to JSON
+                }
+            }
+        }
+        return parseLiveIdFromBody(body);
+    }
+
+    private Long parseLiveIdFromBody(String body) {
+        try {
+            JSONObject root = JSON.parseObject(body);
+            if (root == null) {
+                return null;
+            }
+            JSONObject data = root.getJSONObject("data");
+            if (data != null && data.getLong("liveId") != null) {
+                return data.getLong("liveId");
+            }
+            return root.getLong("liveId");
+        } catch (Exception ex) {
+            log.debug("从消息体解析 liveId 失败", ex);
+            return null;
+        }
+    }
+}

+ 104 - 0
fs-live-ws/src/main/java/com/fs/live/ws/netty/NettyWsServer.java

@@ -0,0 +1,104 @@
+package com.fs.live.ws.netty;
+
+import com.fs.live.ws.config.LiveWsProperties;
+import com.fs.live.ws.handler.WsAuthHandler;
+import com.fs.live.ws.handler.WsHeartbeatHandler;
+import com.fs.live.ws.handler.WsLiveChatHandler;
+import io.netty.bootstrap.ServerBootstrap;
+import io.netty.channel.Channel;
+import io.netty.channel.ChannelInitializer;
+import io.netty.channel.ChannelOption;
+import io.netty.channel.EventLoopGroup;
+import io.netty.channel.nio.NioEventLoopGroup;
+import io.netty.channel.socket.SocketChannel;
+import io.netty.channel.socket.nio.NioServerSocketChannel;
+import io.netty.handler.codec.http.HttpObjectAggregator;
+import io.netty.handler.codec.http.HttpServerCodec;
+import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
+import io.netty.handler.timeout.IdleStateHandler;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.context.event.ApplicationReadyEvent;
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.PreDestroy;
+import java.util.concurrent.TimeUnit;
+
+@Component
+public class NettyWsServer {
+
+    private static final Logger log = LoggerFactory.getLogger(NettyWsServer.class);
+
+    @Autowired
+    private LiveWsProperties properties;
+    @Autowired
+    private WsAuthHandler wsAuthHandler;
+    @Autowired
+    private WsLiveChatHandler wsLiveChatHandler;
+    @Autowired
+    private WsHeartbeatHandler wsHeartbeatHandler;
+
+    private EventLoopGroup bossGroup;
+    private EventLoopGroup workerGroup;
+    private Channel serverChannel;
+
+    @EventListener(ApplicationReadyEvent.class)
+    public void start() {
+        if (!properties.isEnabled()) {
+            log.info("Netty WS 已关闭");
+            return;
+        }
+        new Thread(this::bind, "netty-ws-starter").start();
+    }
+
+    private void bind() {
+        bossGroup = new NioEventLoopGroup(1);
+        workerGroup = new NioEventLoopGroup();
+        try {
+            ServerBootstrap bootstrap = new ServerBootstrap();
+            bootstrap.group(bossGroup, workerGroup)
+                    .channel(NioServerSocketChannel.class)
+                    .option(ChannelOption.SO_BACKLOG, 8192)
+                    .childOption(ChannelOption.SO_KEEPALIVE, true)
+                    .childOption(ChannelOption.TCP_NODELAY, true)
+                    .childHandler(new ChannelInitializer<SocketChannel>() {
+                        @Override
+                        protected void initChannel(SocketChannel ch) {
+                            ch.pipeline()
+                                    .addLast(new HttpServerCodec())
+                                    .addLast(new HttpObjectAggregator(65536))
+                                    .addLast(wsAuthHandler)
+                                    .addLast(new WebSocketServerProtocolHandler(
+                                            properties.getPath(), null, true, 65536, false, true))
+                                    .addLast(new IdleStateHandler(
+                                            properties.getHeartbeatTimeoutSeconds(), 0, 0, TimeUnit.SECONDS))
+                                    .addLast(wsHeartbeatHandler)
+                                    .addLast(wsLiveChatHandler);
+                        }
+                    });
+            serverChannel = bootstrap.bind(properties.getNettyPort()).sync().channel();
+            log.info("Netty WebSocket 已启动: ws://{}:{}{} (HTTP 接口在 server.port=7115,勿混用)",
+                    properties.getPublicHost(), properties.getNettyPort(), properties.getPath());
+            serverChannel.closeFuture().sync();
+        } catch (Exception ex) {
+            log.error("Netty WS 启动失败", ex);
+        } finally {
+            shutdown();
+        }
+    }
+
+    @PreDestroy
+    public void shutdown() {
+        if (bossGroup != null) {
+            bossGroup.shutdownGracefully();
+        }
+        if (workerGroup != null) {
+            workerGroup.shutdownGracefully();
+        }
+        if (serverChannel != null) {
+            serverChannel.close();
+        }
+    }
+}

+ 13 - 0
fs-live-ws/src/main/java/com/fs/live/ws/service/ILiveWsBroadcastService.java

@@ -0,0 +1,13 @@
+package com.fs.live.ws.service;
+
+public interface ILiveWsBroadcastService {
+
+    /** 本节点推送 + Redis 广播到其他节点 */
+    void broadcastToRoom(Long liveId, String message);
+
+    /** 仅本节点推送 */
+    void broadcastLocal(Long liveId, String message);
+
+    /** 向指定用户推送(本节点);返回是否找到在线连接 */
+    boolean sendToUser(Long liveId, Long userId, String message);
+}

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

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

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

@@ -0,0 +1,8 @@
+package com.fs.live.ws.service;
+
+import io.netty.channel.Channel;
+
+public interface ILiveWsMessageService {
+
+    void handleTextMessage(Channel channel, String payload);
+}

+ 15 - 0
fs-live-ws/src/main/java/com/fs/live/ws/service/IWsNodeRegistryService.java

@@ -0,0 +1,15 @@
+package com.fs.live.ws.service;
+
+import java.util.List;
+import java.util.Map;
+
+public interface IWsNodeRegistryService {
+
+    void heartbeat();
+
+    int getLocalConnectionCount();
+
+    List<Map<String, Object>> listActiveNodes();
+
+    String selectBestNodeHost();
+}

+ 61 - 0
fs-live-ws/src/main/java/com/fs/live/ws/service/LiveWsRoomBroadcastFacade.java

@@ -0,0 +1,61 @@
+package com.fs.live.ws.service;
+
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.core.domain.R;
+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.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;
+
+/**
+ * 供直播定时任务调用的 WS 广播门面(替代 fs-live-app WebSocketServer 中的同名能力)。
+ */
+@Service
+public class LiveWsRoomBroadcastFacade {
+
+    @Autowired
+    private ILiveWsBroadcastService broadcastService;
+    @Autowired
+    private LiveWsRoomManager roomManager;
+    @Autowired
+    private LiveWsAutoTaskHandler autoTaskHandler;
+    @Autowired
+    private LiveWsLikeBroadcastTask likeBroadcastTask;
+    @Autowired
+    private LiveWsUserCountBroadcastTask userCountBroadcastTask;
+
+    public void broadcastMessage(Long liveId, String message) {
+        broadcastService.broadcastToRoom(liveId, message);
+    }
+
+    public void handleAutoTask(LiveAutoTask task) {
+        autoTaskHandler.handle(task);
+    }
+
+    public void removeLikeCountCache(Long liveId) {
+        likeBroadcastTask.removeLikeCountCache(liveId);
+        userCountBroadcastTask.removeUserCountCache(liveId);
+    }
+
+    public void sendIntegralMessage(Long liveId, Long userId, Long scoreAmount) {
+        Channel channel = roomManager.getUserChannel(liveId, userId);
+        if (channel == null || !channel.isActive()) {
+            return;
+        }
+        WsSendMsgVo sendMsgVo = new WsSendMsgVo();
+        sendMsgVo.setLiveId(liveId);
+        sendMsgVo.setUserId(userId);
+        sendMsgVo.setUserType(0L);
+        sendMsgVo.setCmd("Integral");
+        sendMsgVo.setMsg("恭喜你成功获得观看奖励:" + scoreAmount + "积分");
+        sendMsgVo.setData(String.valueOf(scoreAmount));
+        channel.writeAndFlush(new TextWebSocketFrame(
+                JSONObject.toJSONString(R.ok().put("data", sendMsgVo))));
+    }
+}

+ 426 - 0
fs-live-ws/src/main/java/com/fs/live/ws/service/impl/LiveWsAdminMessageHandler.java

@@ -0,0 +1,426 @@
+package com.fs.live.ws.service.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.constant.LiveKeysConstant;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.utils.DateUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.course.domain.FsCourseQuestionBank;
+import com.fs.live.mapper.LiveCourseQuestionRelMapper;
+import com.fs.course.service.IFsCourseQuestionBankService;
+import com.fs.live.domain.Live;
+import com.fs.live.domain.LiveMsg;
+import com.fs.live.domain.LiveRedConf;
+import com.fs.live.domain.LiveLotteryConf;
+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.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;
+import org.springframework.stereotype.Service;
+
+import java.util.Date;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 管理端 WebSocket 指令(迁移自 fs-live-app WebSocketServer 管理端分支)
+ */
+@Service
+public class LiveWsAdminMessageHandler {
+
+    private static final Logger log = LoggerFactory.getLogger(LiveWsAdminMessageHandler.class);
+
+    @Autowired
+    private ILiveWsBroadcastService broadcastService;
+    @Autowired
+    private ILiveMsgService liveMsgService;
+    @Autowired
+    private ILiveService liveService;
+    @Autowired
+    private ILiveWatchUserService liveWatchUserService;
+    @Autowired
+    private ILiveGoodsService liveGoodsService;
+    @Autowired
+    private ILiveRedConfService liveRedConfService;
+    @Autowired
+    private ILiveLotteryConfService liveLotteryConfService;
+    @Autowired
+    private ILiveCouponIssueService liveCouponIssueService;
+    @Autowired
+    private RedisCache redisCache;
+    @Autowired(required = false)
+    private ProductionWordFilter productionWordFilter;
+    @Autowired(required = false)
+    private LiveCourseQuestionRelMapper liveCourseQuestionRelMapper;
+    @Autowired(required = false)
+    private IFsCourseQuestionBankService fsCourseQuestionBankService;
+
+    public boolean handle(Channel channel, Long liveId, WsSendMsgVo msg) {
+        if (msg == null || StringUtils.isEmpty(msg.getCmd())) {
+            return false;
+        }
+        try {
+            switch (msg.getCmd()) {
+                case "sendNormalMsg":
+                    handleSendNormalMsg(liveId, msg);
+                    return true;
+                case "sendPopMsg":
+                    handleSendPopMsg(liveId, msg);
+                    return true;
+                case "sendTopMsg":
+                    handleSendTopMsg(liveId, msg);
+                    return true;
+                case "globalVisible":
+                    handleGlobalVisible(liveId, msg);
+                    return true;
+                case "singleVisible":
+                    handleSingleVisible(liveId, msg);
+                    return true;
+                case "blockUser":
+                    handleBlockUser(liveId, msg.getUserId());
+                    return true;
+                case "goods":
+                    handleGoods(liveId, msg);
+                    return true;
+                case "deleteMsg":
+                    handleDeleteMsg(liveId, msg);
+                    return true;
+                case "replyUser":
+                    handleReplyUser(channel, liveId, msg);
+                    return true;
+                case "red":
+                    handleRed(liveId, msg);
+                    return true;
+                case "lottery":
+                    handleLottery(liveId, msg);
+                    return true;
+                case "coupon":
+                    handleCoupon(liveId, msg);
+                    return true;
+                case "liveQuizStart":
+                    handleLiveQuizStart(liveId, msg);
+                    return true;
+                case "liveQuizClose":
+                    handleLiveQuizClose(liveId, msg);
+                    return true;
+                default:
+                    return false;
+            }
+        } catch (Exception ex) {
+            log.error("管理端 WS 指令处理失败 liveId={}, cmd={}", liveId, msg.getCmd(), ex);
+            return true;
+        }
+    }
+
+    private void filterAndRequireMsg(WsSendMsgVo msg) {
+        if (productionWordFilter != null && StringUtils.isNotEmpty(msg.getMsg())) {
+            msg.setMsg(productionWordFilter.filter(msg.getMsg()).getFilteredText());
+        }
+    }
+
+    private void broadcastAdmin(Long liveId, WsSendMsgVo msg) {
+        msg.setOn(true);
+        broadcastService.broadcastToRoom(liveId, JSONObject.toJSONString(R.ok().put("data", msg)));
+    }
+
+    private void handleSendNormalMsg(Long liveId, WsSendMsgVo msg) {
+        filterAndRequireMsg(msg);
+        if (StringUtils.isEmpty(msg.getMsg())) {
+            return;
+        }
+        LiveMsg liveMsg = buildLiveMsg(liveId, msg);
+        MapFlags flags = resolveLiveFlags(liveId);
+        liveMsg.setLiveFlag(flags.liveFlag);
+        liveMsg.setReplayFlag(flags.replayFlag);
+        liveMsgService.insertLiveMsg(liveMsg);
+        msg.setCmd("sendMsg");
+        msg.setData(JSONObject.toJSONString(liveMsg));
+        broadcastAdmin(liveId, msg);
+    }
+
+    private void handleSendPopMsg(Long liveId, WsSendMsgVo msg) {
+        filterAndRequireMsg(msg);
+        if (StringUtils.isEmpty(msg.getMsg())) {
+            return;
+        }
+        LiveMsg liveMsg = buildLiveMsg(liveId, msg);
+        msg.setData(JSONObject.toJSONString(liveMsg));
+        broadcastAdmin(liveId, msg);
+    }
+
+    private void handleSendTopMsg(Long liveId, WsSendMsgVo msg) {
+        filterAndRequireMsg(msg);
+        if (StringUtils.isEmpty(msg.getMsg())) {
+            return;
+        }
+        LiveMsg liveMsg = buildLiveMsg(liveId, msg);
+        if (msg.getDuration() != null) {
+            liveMsg.setEndTime(DateUtils.addMinutes(new Date(), msg.getDuration()).toString());
+        }
+        msg.setData(JSONObject.toJSONString(liveMsg));
+        broadcastAdmin(liveId, msg);
+
+        String topKey = String.format(LiveKeysConstant.LIVE_HOME_PAGE_CONFIG, liveId, LiveKeysConstant.TOP_MSG);
+        redisCache.deleteObject(topKey);
+        redisCache.setCacheObject(topKey, JSONObject.toJSONString(liveMsg));
+        if (msg.getDuration() != null) {
+            redisCache.expire(topKey, msg.getDuration(), TimeUnit.MINUTES);
+        }
+    }
+
+    private void handleGlobalVisible(Long liveId, WsSendMsgVo msg) {
+        if (msg.getStatus() != null) {
+            liveWatchUserService.updateGlobalVisible(liveId, msg.getStatus());
+            liveService.updateGlobalVisible(liveId, msg.getStatus());
+        }
+        broadcastAdmin(liveId, msg);
+    }
+
+    private void handleSingleVisible(Long liveId, WsSendMsgVo msg) {
+        if (msg.getStatus() != null && msg.getUserId() != null) {
+            liveWatchUserService.updateSingleVisible(liveId, msg.getStatus(), msg.getUserId());
+        }
+        broadcastAdmin(liveId, msg);
+    }
+
+    private void handleBlockUser(Long liveId, Long userId) {
+        if (userId == null) {
+            return;
+        }
+        WsSendMsgVo sendMsgVo = new WsSendMsgVo();
+        sendMsgVo.setLiveId(liveId);
+        sendMsgVo.setUserId(userId);
+        sendMsgVo.setUserType(0L);
+        sendMsgVo.setCmd("blockUser");
+        sendMsgVo.setMsg("账号已被停用");
+        sendMsgVo.setOn(true);
+        broadcastService.sendToUser(liveId, userId,
+                JSONObject.toJSONString(R.ok().put("data", sendMsgVo)));
+    }
+
+    private void handleGoods(Long liveId, WsSendMsgVo msg) {
+        if (StringUtils.isEmpty(msg.getData())) {
+            return;
+        }
+        JSONObject jsonObject = JSON.parseObject(msg.getData());
+        Long goodsId = jsonObject.getLong("goodsId");
+        Long dataLiveId = jsonObject.getLong("liveId");
+        Integer status = jsonObject.getInteger("status");
+        if (goodsId == null) {
+            return;
+        }
+        Long targetLiveId = dataLiveId != null ? dataLiveId : liveId;
+        msg.setStatus(status);
+        LiveGoodsVo liveGoods = liveGoodsService.selectLiveGoodsVoByGoodsId(goodsId);
+        if (liveGoods == null) {
+            return;
+        }
+        liveService.asyncToCacheLiveConfig(targetLiveId);
+        msg.setLiveId(targetLiveId);
+        msg.setData(JSONObject.toJSONString(liveGoods));
+        broadcastAdmin(targetLiveId, msg);
+    }
+
+    private void handleDeleteMsg(Long liveId, WsSendMsgVo msg) {
+        WsSendMsgVo sendMsgVo = new WsSendMsgVo();
+        sendMsgVo.setLiveId(liveId);
+        sendMsgVo.setUserType(0L);
+        sendMsgVo.setCmd("deleteMsg");
+        sendMsgVo.setMsg(msg.getMsg());
+        sendMsgVo.setOn(true);
+        broadcastService.broadcastToRoom(liveId, JSONObject.toJSONString(R.ok().put("data", sendMsgVo)));
+    }
+
+    private void handleReplyUser(Channel adminChannel, Long liveId, WsSendMsgVo msg) {
+        if (msg.getUserId() == null || StringUtils.isEmpty(msg.getMsg())) {
+            return;
+        }
+        filterAndRequireMsg(msg);
+        if (StringUtils.isEmpty(msg.getMsg())) {
+            return;
+        }
+        Long targetUserId = msg.getUserId();
+        WsSendMsgVo replyVo = new WsSendMsgVo();
+        replyVo.setLiveId(liveId);
+        replyVo.setUserId(targetUserId);
+        replyVo.setUserType(1L);
+        replyVo.setCmd("replyUser");
+        replyVo.setMsg(msg.getMsg());
+        replyVo.setNickName(msg.getNickName());
+        replyVo.setAvatar(msg.getAvatar());
+        replyVo.setOn(true);
+
+        String payload = JSONObject.toJSONString(R.ok().put("data", replyVo));
+        boolean sent = broadcastService.sendToUser(liveId, targetUserId, payload);
+        if (adminChannel != null && adminChannel.isActive()) {
+            if (sent) {
+                adminChannel.writeAndFlush(new TextWebSocketFrame(
+                        JSONObject.toJSONString(R.ok().put("msg", "回复已发送"))));
+            } else {
+                adminChannel.writeAndFlush(new TextWebSocketFrame(
+                        JSONObject.toJSONString(R.error("用户不在线,无法回复"))));
+            }
+        }
+    }
+
+    private void handleRed(Long liveId, WsSendMsgVo msg) {
+        if (StringUtils.isEmpty(msg.getData())) {
+            return;
+        }
+        JSONObject jsonObject = JSON.parseObject(msg.getData());
+        msg.setStatus(jsonObject.getInteger("status"));
+        LiveRedConf liveRedConf = liveRedConfService.selectLiveRedConfByRedId(jsonObject.getLong("redId"));
+        if (liveRedConf != null) {
+            liveService.asyncToCacheLiveConfig(liveId);
+            msg.setData(JSONObject.toJSONString(liveRedConf));
+            broadcastAdmin(liveId, msg);
+        }
+    }
+
+    private void handleLottery(Long liveId, WsSendMsgVo msg) {
+        if (StringUtils.isEmpty(msg.getData())) {
+            return;
+        }
+        JSONObject jsonObject = JSON.parseObject(msg.getData());
+        msg.setStatus(jsonObject.getInteger("status"));
+        LiveLotteryConf liveLotteryConf = liveLotteryConfService.selectLiveLotteryConfByLotteryId(jsonObject.getLong("lotteryId"));
+        if (liveLotteryConf != null) {
+            liveService.asyncToCacheLiveConfig(liveId);
+            msg.setData(JSONObject.toJSONString(liveLotteryConf));
+            broadcastAdmin(liveId, msg);
+        }
+    }
+
+    private void handleCoupon(Long liveId, WsSendMsgVo msg) {
+        if (StringUtils.isEmpty(msg.getData())) {
+            return;
+        }
+        JSONObject jsonObject = JSON.parseObject(msg.getData());
+        Integer status = jsonObject.getInteger("status");
+        msg.setStatus(status);
+        Long couponIssueId = jsonObject.getLong("couponIssueId");
+        if (status != null && status == 1) {
+            Object cacheObject = redisCache.getCacheObject(String.format(LiveKeysConstant.LIVE_COUPON_NUM, couponIssueId));
+            if (cacheObject == null && couponIssueId != null) {
+                com.fs.live.domain.LiveCouponIssue liveCoupon = liveCouponIssueService.selectLiveCouponIssueById(couponIssueId);
+                if (liveCoupon != null) {
+                    redisCache.setCacheObject(String.format(LiveKeysConstant.LIVE_COUPON_NUM, couponIssueId),
+                            liveCoupon.getRemainCount().intValue(), 30, TimeUnit.MINUTES);
+                }
+            }
+        } else if (couponIssueId != null) {
+            redisCache.deleteObject(String.format(LiveKeysConstant.LIVE_COUPON_NUM, couponIssueId));
+        }
+        broadcastAdmin(liveId, msg);
+    }
+
+    private void handleLiveQuizStart(Long liveId, WsSendMsgVo msg) {
+        if (liveCourseQuestionRelMapper == null || fsCourseQuestionBankService == null) {
+            log.warn("liveQuizStart: 题库服务未注入 liveId={}", liveId);
+            return;
+        }
+        if (StringUtils.isEmpty(msg.getData())) {
+            return;
+        }
+        JSONObject body = JSON.parseObject(msg.getData());
+        Long relId = body.getLong("relId");
+        if (relId == null) {
+            return;
+        }
+        Long questionBankId = liveCourseQuestionRelMapper.selectQuestionBankIdByLiveAndRel(liveId, relId);
+        if (questionBankId == null) {
+            log.warn("liveQuizStart: 未找到关联 liveId={} relId={}", liveId, relId);
+            return;
+        }
+        FsCourseQuestionBank bank = fsCourseQuestionBankService.selectFsCourseQuestionBankById(questionBankId);
+        if (bank == null) {
+            return;
+        }
+        JSONArray optionsOut = new JSONArray();
+        String qStr = bank.getQuestion();
+        if (StringUtils.isNotEmpty(qStr)) {
+            JSONArray arr = JSON.parseArray(qStr);
+            String alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+            for (int i = 0; i < arr.size(); i++) {
+                JSONObject opt = arr.getJSONObject(i);
+                JSONObject row = new JSONObject();
+                row.put("key", i < alphabet.length() ? String.valueOf(alphabet.charAt(i)) : String.valueOf(i + 1));
+                row.put("name", opt.getString("name"));
+                optionsOut.add(row);
+            }
+        }
+        JSONObject data = new JSONObject();
+        data.put("relId", relId);
+        data.put("questionBankId", bank.getId());
+        data.put("title", bank.getTitle());
+        data.put("type", bank.getType());
+        data.put("options", optionsOut);
+
+        WsSendMsgVo out = new WsSendMsgVo();
+        out.setLiveId(liveId);
+        out.setUserType(1L);
+        out.setCmd("liveQuizStart");
+        out.setData(data.toJSONString());
+        broadcastAdmin(liveId, out);
+    }
+
+    private void handleLiveQuizClose(Long liveId, WsSendMsgVo msg) {
+        JSONObject data = new JSONObject();
+        if (StringUtils.isNotEmpty(msg.getData())) {
+            JSONObject body = JSON.parseObject(msg.getData());
+            Long relId = body.getLong("relId");
+            if (relId != null) {
+                data.put("relId", relId);
+            }
+        }
+        WsSendMsgVo out = new WsSendMsgVo();
+        out.setLiveId(liveId);
+        out.setUserType(1L);
+        out.setCmd("liveQuizClose");
+        out.setData(data.toJSONString());
+        broadcastAdmin(liveId, out);
+    }
+
+    private LiveMsg buildLiveMsg(Long liveId, WsSendMsgVo msg) {
+        LiveMsg liveMsg = new LiveMsg();
+        liveMsg.setLiveId(msg.getLiveId() != null ? msg.getLiveId() : liveId);
+        liveMsg.setUserId(msg.getUserId());
+        liveMsg.setNickName(msg.getNickName());
+        liveMsg.setAvatar(msg.getAvatar());
+        liveMsg.setMsg(msg.getMsg());
+        liveMsg.setCreateTime(new Date());
+        return liveMsg;
+    }
+
+    private MapFlags resolveLiveFlags(Long liveId) {
+        MapFlags flags = new MapFlags();
+        Live live = liveService.selectLiveByLiveId(liveId);
+        if (live != null && live.getFinishTime() != null) {
+            Date finishTime = java.sql.Timestamp.valueOf(live.getFinishTime());
+            if (new Date().after(finishTime)) {
+                flags.replayFlag = 1;
+                flags.liveFlag = 0;
+            } else {
+                flags.liveFlag = 1;
+                flags.replayFlag = 0;
+            }
+        } else {
+            flags.liveFlag = 1;
+            flags.replayFlag = 0;
+        }
+        return flags;
+    }
+
+    private static class MapFlags {
+        int liveFlag = 1;
+        int replayFlag = 0;
+    }
+}

+ 140 - 0
fs-live-ws/src/main/java/com/fs/live/ws/service/impl/LiveWsAutoTaskHandler.java

@@ -0,0 +1,140 @@
+package com.fs.live.ws.service.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.constant.LiveKeysConstant;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.live.domain.*;
+import com.fs.live.mapper.LiveCouponMapper;
+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 org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.Date;
+import java.util.HashMap;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 直播自动任务(商品/红包/抽奖/消息/优惠券)处理,迁移自 fs-live-app WebSocketServer#handleAutoTask。
+ */
+@Service
+public class LiveWsAutoTaskHandler {
+
+    private static final Logger log = LoggerFactory.getLogger(LiveWsAutoTaskHandler.class);
+
+    @Autowired
+    private ILiveWsBroadcastService broadcastService;
+    @Autowired
+    private ILiveService liveService;
+    @Autowired
+    private ILiveGoodsService liveGoodsService;
+    @Autowired
+    private ILiveRedConfService liveRedConfService;
+    @Autowired
+    private ILiveLotteryConfService liveLotteryConfService;
+    @Autowired
+    private ILiveMsgService liveMsgService;
+    @Autowired
+    private ILiveCouponIssueService liveCouponIssueService;
+    @Autowired
+    private LiveCouponMapper liveCouponMapper;
+    @Autowired
+    private RedisCache redisCache;
+
+    public void handle(LiveAutoTask task) {
+        WsSendMsgVo msg = new WsSendMsgVo();
+        msg.setLiveId(task.getLiveId());
+        msg.setData(task.getContent());
+        Date now = new Date();
+        try {
+            if (task.getTaskType() == 1L) {
+                msg.setCmd("goods");
+                LiveGoodsVo liveGoodsVo = JSON.parseObject(task.getContent(), LiveGoodsVo.class);
+                liveGoodsService.updateLiveIsShow(liveGoodsVo.getGoodsId(), task.getLiveId());
+                liveService.asyncToCacheLiveConfig(task.getLiveId());
+            } else if (task.getTaskType() == 2L) {
+                msg.setCmd("red");
+                LiveRedConf liveRedConf = JSON.parseObject(task.getContent(), LiveRedConf.class);
+                if (liveRedConf.getRedStatus() != 0) {
+                    log.error("红包状态应该为:未发放");
+                    return;
+                }
+                liveRedConf.setRedStatus(1L);
+                liveRedConf.setUpdateTime(now);
+                msg.setData(JSON.toJSONString(liveRedConf));
+                liveRedConfService.updateLiveRedConf(liveRedConf);
+                liveService.asyncToCacheLiveConfig(task.getLiveId());
+            } else if (task.getTaskType() == 4L) {
+                msg.setCmd("lottery");
+                LiveLotteryConf liveLotteryConf = JSON.parseObject(task.getContent(), LiveLotteryConf.class);
+                if (!"0".equals(liveLotteryConf.getLotteryStatus())) {
+                    log.error("抽奖状态应该为:未发放");
+                    return;
+                }
+                liveLotteryConf.setLotteryStatus("1");
+                liveLotteryConf.setUpdateTime(now);
+                msg.setData(JSON.toJSONString(liveLotteryConf));
+                liveLotteryConfService.updateLiveLotteryConf(liveLotteryConf);
+                liveService.asyncToCacheLiveConfig(task.getLiveId());
+            } else if (task.getTaskType() == 3L) {
+                msg.setCmd("sendMsg");
+                msg.setMsg(task.getContent());
+                msg.setUserType(1L);
+                msg.setNickName(task.getTaskName());
+                LiveMsg liveMsg = new LiveMsg();
+                liveMsg.setLiveId(task.getLiveId());
+                liveMsg.setUserId(0L);
+                liveMsg.setNickName(task.getTaskName());
+                liveMsg.setMsg(task.getContent());
+                liveMsg.setCreateTime(now);
+                liveMsg.setUpdateTime(now);
+                liveMsgService.insertLiveMsg(liveMsg);
+            } else if (task.getTaskType() == 5L) {
+                msg.setCmd("coupon");
+                LiveCoupon liveCoupon = JSON.parseObject(task.getContent(), LiveCoupon.class);
+                if (liveCoupon.getStatus() != 1) {
+                    log.error("优惠券状态已经关闭");
+                    return;
+                }
+                LiveCouponIssue liveCouponIssue = liveCouponIssueService.selectLiveCouponIssueByCouponId(liveCoupon.getCouponId());
+                LiveCouponIssueRelation relation = liveCouponMapper.selectCouponRelation(task.getLiveId(), liveCouponIssue.getId());
+                redisCache.setCacheObject(
+                        String.format(LiveKeysConstant.LIVE_COUPON_NUM, liveCouponIssue.getId()),
+                        liveCouponIssue.getRemainCount().intValue(), 30, TimeUnit.MINUTES);
+                HashMap<String, Object> data = new HashMap<>();
+                data.put("liveId", task.getLiveId());
+                data.put("couponIssueId", liveCouponIssue.getId());
+                data.put("status", 1);
+                data.put("goodsId", relation.getGoodsId());
+                data.put("couponName", liveCoupon.getTitle());
+                data.put("couponPrice", liveCoupon.getCouponPrice());
+                data.put("useMinPrice", liveCoupon.getUseMinPrice());
+                data.put("couponTime", liveCoupon.getCouponTime());
+                msg.setData(JSON.toJSONString(data));
+                liveCouponMapper.updateChangeShow(task.getLiveId(), liveCouponIssue.getId());
+            } else if (task.getTaskType() == 6L) {
+                msg.setCmd("goods");
+                JSONObject jsonObject = JSON.parseObject(task.getContent());
+                Long goodsId = jsonObject.getLong("goodsId");
+                Integer status = jsonObject.getInteger("status");
+                if (goodsId == null || status == null) {
+                    log.error("商品ID或状态为空");
+                    return;
+                }
+                liveGoodsService.updateLiveGoodsStatus(goodsId, status);
+                return;
+            }
+            msg.setStatus(1);
+            broadcastService.broadcastToRoom(task.getLiveId(),
+                    JSONObject.toJSONString(R.ok().put("data", msg)));
+        } catch (Exception e) {
+            log.error("定时任务执行异常:{}", e.getMessage(), e);
+        }
+    }
+}

+ 55 - 0
fs-live-ws/src/main/java/com/fs/live/ws/service/impl/LiveWsBroadcastServiceImpl.java

@@ -0,0 +1,55 @@
+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 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;
+
+@Service
+public class LiveWsBroadcastServiceImpl implements ILiveWsBroadcastService {
+
+    private static final Logger log = LoggerFactory.getLogger(LiveWsBroadcastServiceImpl.class);
+
+    @Autowired
+    private LiveWsRoomManager roomManager;
+    @Autowired
+    private StringRedisTemplate stringRedisTemplate;
+
+    @Override
+    public void broadcastToRoom(Long liveId, String message) {
+        broadcastLocal(liveId, message);
+        try {
+            stringRedisTemplate.convertAndSend(String.format(WsRedisKeys.WS_ROOM_CHANNEL, liveId), message);
+        } catch (Exception ex) {
+            log.warn("Redis 广播失败 liveId={}", liveId, ex);
+        }
+    }
+
+    @Override
+    public void broadcastLocal(Long liveId, String message) {
+        ChannelGroup group = roomManager.getRoomGroup(liveId);
+        if (group == null || group.isEmpty()) {
+            return;
+        }
+        group.writeAndFlush(new TextWebSocketFrame(message));
+    }
+
+    @Override
+    public boolean sendToUser(Long liveId, Long userId, String message) {
+        if (liveId == null || userId == null || message == null) {
+            return false;
+        }
+        io.netty.channel.Channel channel = roomManager.getUserChannel(liveId, userId);
+        if (channel == null || !channel.isActive()) {
+            return false;
+        }
+        channel.writeAndFlush(new TextWebSocketFrame(message));
+        return true;
+    }
+}

+ 122 - 0
fs-live-ws/src/main/java/com/fs/live/ws/service/impl/LiveWsConnectionServiceImpl.java

@@ -0,0 +1,122 @@
+package com.fs.live.ws.service.impl;
+
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.constant.LiveKeysConstant;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.hisStore.domain.FsUserScrm;
+import com.fs.hisStore.service.IFsUserScrmService;
+import com.fs.live.domain.Live;
+import com.fs.live.domain.LiveWatchUser;
+import com.fs.live.service.ILiveService;
+import com.fs.live.service.ILiveWatchUserService;
+import com.fs.live.ws.bean.WsSendMsgVo;
+import com.fs.live.ws.config.LiveWsProperties;
+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 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
+public class LiveWsConnectionServiceImpl implements ILiveWsConnectionService {
+
+    private static final Logger log = LoggerFactory.getLogger(LiveWsConnectionServiceImpl.class);
+
+    @Autowired
+    private LiveWsProperties properties;
+    @Autowired
+    private LiveWsRoomManager roomManager;
+    @Autowired
+    private ILiveWsBroadcastService broadcastService;
+    @Autowired
+    private ILiveService liveService;
+    @Autowired
+    private ILiveWatchUserService liveWatchUserService;
+    @Autowired
+    private IFsUserScrmService fsUserService;
+    @Autowired
+    private RedisCache redisCache;
+
+    @Override
+    public boolean tryAcceptConnection() {
+        return roomManager.getConnectionCount() < properties.getMaxConnectionsPerNode();
+    }
+
+    @Override
+    public void onConnected(Long liveId, Long userId, Long userType, Channel channel, String location) {
+        roomManager.incrementAndGetConnections();
+        Live live = liveService.selectLiveByLiveId(liveId);
+        if (live == null) {
+            roomManager.decrementAndGetConnections();
+            throw new IllegalStateException("未找到直播间");
+        }
+
+        if (userType == 0L) {
+            FsUserScrm fsUser = fsUserService.selectFsUserById(userId);
+            if (fsUser == null) {
+                roomManager.decrementAndGetConnections();
+                throw new IllegalStateException("用户信息错误");
+            }
+            LiveWatchUser liveWatchUser = liveWatchUserService.join(fsUser, liveId, userId, location);
+            roomManager.bindUser(liveId, userId, channel);
+
+            String entryTimeKey = String.format(WsRedisKeys.USER_ENTRY_TIME_KEY, liveId, userId);
+            if (redisCache.getCacheObject(entryTimeKey) == null) {
+                redisCache.setCacheObject(entryTimeKey, System.currentTimeMillis(), 24, TimeUnit.HOURS);
+            }
+            redisCache.incr(LiveKeysConstant.PAGE_VIEWS_KEY + liveId, 1);
+            redisCache.incr(LiveKeysConstant.TOTAL_VIEWS_KEY + liveId, 1);
+
+            WsSendMsgVo sendMsgVo = buildEntryMsg(liveId, userId, userType, liveWatchUser, fsUser);
+            broadcastService.broadcastToRoom(liveId, JSONObject.toJSONString(R.ok().put("data", sendMsgVo)));
+        } else {
+            roomManager.bindAdmin(liveId, channel);
+        }
+    }
+
+    @Override
+    public void onDisconnected(Long liveId, Long userId, Long userType, Channel channel) {
+        try {
+            if (userType != null && userType == 0L && userId != null) {
+                FsUserScrm fsUser = fsUserService.selectFsUserById(userId);
+                if (fsUser != null) {
+                    LiveWatchUser close = liveWatchUserService.close(fsUser, liveId, userId);
+                    WsSendMsgVo sendMsgVo = new WsSendMsgVo();
+                    sendMsgVo.setLiveId(liveId);
+                    sendMsgVo.setUserId(userId);
+                    sendMsgVo.setUserType(userType);
+                    sendMsgVo.setCmd("out");
+                    sendMsgVo.setMsg("用户离开");
+                    sendMsgVo.setData(JSONObject.toJSONString(close));
+                    sendMsgVo.setNickName(fsUser.getNickname());
+                    sendMsgVo.setAvatar(fsUser.getAvatar());
+                    broadcastService.broadcastToRoom(liveId, JSONObject.toJSONString(R.ok().put("data", sendMsgVo)));
+                }
+            }
+        } finally {
+            roomManager.unbind(liveId, userId, userType, channel);
+            roomManager.decrementAndGetConnections();
+        }
+    }
+
+    private WsSendMsgVo buildEntryMsg(Long liveId, Long userId, Long userType, LiveWatchUser liveWatchUser, FsUserScrm fsUser) {
+        WsSendMsgVo sendMsgVo = new WsSendMsgVo();
+        sendMsgVo.setLiveId(liveId);
+        sendMsgVo.setUserId(userId);
+        sendMsgVo.setUserType(userType);
+        sendMsgVo.setCmd("entry");
+        sendMsgVo.setMsg("用户进入");
+        sendMsgVo.setData(JSONObject.toJSONString(liveWatchUser));
+        sendMsgVo.setNickName(fsUser.getNickname());
+        sendMsgVo.setAvatar(fsUser.getAvatar());
+        return sendMsgVo;
+    }
+}

+ 117 - 0
fs-live-ws/src/main/java/com/fs/live/ws/service/impl/LiveWsMessageServiceImpl.java

@@ -0,0 +1,117 @@
+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.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;
+import org.springframework.stereotype.Service;
+
+import java.util.Date;
+import java.util.Map;
+
+@Service
+public class LiveWsMessageServiceImpl implements ILiveWsMessageService {
+
+    private static final Logger log = LoggerFactory.getLogger(LiveWsMessageServiceImpl.class);
+
+    @Autowired
+    private ILiveWsBroadcastService broadcastService;
+    @Autowired
+    private ILiveMsgService liveMsgService;
+    @Autowired
+    private ILiveWatchUserService liveWatchUserService;
+    @Autowired(required = false)
+    private ProductionWordFilter productionWordFilter;
+    @Autowired
+    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) {
+            return;
+        }
+        try {
+            WsSendMsgVo msg = JSONObject.parseObject(payload, WsSendMsgVo.class);
+            if (msg == null || StringUtils.isEmpty(msg.getCmd())) {
+                log.warn("WS 消息缺少 cmd liveId={}, payload={}", liveId, payload);
+                return;
+            }
+            if (msg.isOn()) {
+                return;
+            }
+            switch (msg.getCmd()) {
+                case "heartbeat":
+                    channel.writeAndFlush(new TextWebSocketFrame(
+                            JSONObject.toJSONString(R.ok().put("data", msg))));
+                    break;
+                case "sendMsg":
+                    handleSendMsg(channel, liveId, userType, msg);
+                    break;
+                default:
+                    if (userType != null && userType == 1L
+                            && adminMessageHandler.handle(channel, liveId, msg)) {
+                        break;
+                    }
+                    log.debug("WS 忽略未知 cmd={} liveId={} userType={}", msg.getCmd(), liveId, userType);
+                    break;
+            }
+        } catch (Exception ex) {
+            log.error("WS 消息处理失败 liveId={}, payload={}", liveId, payload, ex);
+        }
+    }
+
+    private void handleSendMsg(Channel channel, Long liveId, Long userType, WsSendMsgVo msg) {
+        if (productionWordFilter != null && StringUtils.isNotEmpty(msg.getMsg())) {
+            msg.setMsg(productionWordFilter.filter(msg.getMsg()).getFilteredText());
+        }
+        if (StringUtils.isEmpty(msg.getMsg())) {
+            return;
+        }
+
+        LiveMsg liveMsg = new LiveMsg();
+        liveMsg.setLiveId(msg.getLiveId() != null ? msg.getLiveId() : liveId);
+        liveMsg.setUserId(msg.getUserId());
+        liveMsg.setNickName(msg.getNickName());
+        liveMsg.setAvatar(msg.getAvatar());
+        liveMsg.setMsg(msg.getMsg());
+        liveMsg.setCreateTime(new Date());
+
+        Map<String, Integer> liveFlagWithCache = liveWatchUserService.getLiveFlagWithCache(liveId);
+        Integer liveFlag = liveFlagWithCache.get("liveFlag");
+        Integer replayFlag = liveFlagWithCache.get("replayFlag");
+        liveMsg.setLiveFlag(liveFlag);
+        liveMsg.setReplayFlag(replayFlag);
+
+        if (userType != null && userType == 0L) {
+            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("你已被禁言"))));
+                return;
+            }
+            liveMsgService.insertLiveMsg(liveMsg);
+        }
+
+        msg.setOn(true);
+        msg.setData(JSONObject.toJSONString(liveMsg));
+        broadcastService.broadcastToRoom(liveId, JSONObject.toJSONString(R.ok().put("data", msg)));
+    }
+}

+ 93 - 0
fs-live-ws/src/main/java/com/fs/live/ws/service/impl/WsNodeRegistryServiceImpl.java

@@ -0,0 +1,93 @@
+package com.fs.live.ws.service.impl;
+
+import com.fs.common.core.redis.RedisCache;
+import com.fs.live.ws.config.LiveWsProperties;
+import com.fs.live.ws.constant.WsRedisKeys;
+import com.fs.live.ws.service.IWsNodeRegistryService;
+import com.fs.live.ws.session.LiveWsRoomManager;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+
+@Service
+public class WsNodeRegistryServiceImpl implements IWsNodeRegistryService {
+
+    @Autowired
+    private LiveWsProperties properties;
+    @Autowired
+    private LiveWsRoomManager roomManager;
+    @Autowired
+    private RedisCache redisCache;
+    @Autowired
+    private RedisTemplate<String, Object> redisTemplate;
+
+    @Override
+    @Scheduled(fixedDelayString = "${live.ws.node-heartbeat-seconds:10}000")
+    public void heartbeat() {
+        String key = String.format(WsRedisKeys.WS_NODE, properties.getNodeId());
+        Map<String, Object> node = new HashMap<>(8);
+        node.put("nodeId", properties.getNodeId());
+        node.put("host", properties.getPublicHost());
+        node.put("port", properties.getNettyPort());
+        node.put("connections", roomManager.getConnectionCount());
+        node.put("maxConnections", properties.getMaxConnectionsPerNode());
+        node.put("updateTime", System.currentTimeMillis());
+        redisCache.setCacheMap(key, node);
+        redisCache.expire(key, WsRedisKeys.WS_NODE_TTL_SECONDS, TimeUnit.SECONDS);
+    }
+
+    @Override
+    public int getLocalConnectionCount() {
+        return roomManager.getConnectionCount();
+    }
+
+    @Override
+    public List<Map<String, Object>> listActiveNodes() {
+        Set<String> keys = redisTemplate.keys("live:ws:node:*");
+        if (keys == null || keys.isEmpty()) {
+            return Collections.emptyList();
+        }
+        List<Map<String, Object>> nodes = new ArrayList<>();
+        for (String key : keys) {
+            Map<String, Object> map = redisCache.getCacheMap(key);
+            if (map != null && !map.isEmpty()) {
+                nodes.add(map);
+            }
+        }
+        return nodes;
+    }
+
+    @Override
+    public String selectBestNodeHost() {
+        List<Map<String, Object>> nodes = listActiveNodes();
+        Map<String, Object> best = null;
+        double bestRatio = Double.MAX_VALUE;
+        for (Map<String, Object> node : nodes) {
+            int conn = toInt(node.get("connections"));
+            int max = toInt(node.get("maxConnections"));
+            if (max <= 0) {
+                continue;
+            }
+            double ratio = (double) conn / max;
+            if (ratio < bestRatio) {
+                bestRatio = ratio;
+                best = node;
+            }
+        }
+        if (best == null) {
+            return properties.getPublicHost() + ":" + properties.getNettyPort();
+        }
+        return String.valueOf(best.get("host")) + ":" + best.get("port");
+    }
+
+    private int toInt(Object value) {
+        if (value == null) {
+            return 0;
+        }
+        return Integer.parseInt(String.valueOf(value));
+    }
+}

+ 100 - 0
fs-live-ws/src/main/java/com/fs/live/ws/session/LiveWsRoomManager.java

@@ -0,0 +1,100 @@
+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.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * 本节点 WebSocket 连接注册表(仅本机 Channel,跨节点靠 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<>();
+
+    public int incrementAndGetConnections() {
+        return connectionCount.incrementAndGet();
+    }
+
+    public int decrementAndGetConnections() {
+        return connectionCount.decrementAndGet();
+    }
+
+    public int getConnectionCount() {
+        return connectionCount.get();
+    }
+
+    public ConcurrentHashMap<Long, Channel> getUserRoom(Long liveId) {
+        return userRooms.computeIfAbsent(liveId, k -> new ConcurrentHashMap<>());
+    }
+
+    public CopyOnWriteArrayList<Channel> 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 int getUserOnlineCount(Long liveId) {
+        ConcurrentHashMap<Long, Channel> room = userRooms.get(liveId);
+        return room == null ? 0 : room.size();
+    }
+
+    public Set<Long> getActiveLiveIds() {
+        return userRooms.keySet();
+    }
+
+    public Channel getUserChannel(Long liveId, Long userId) {
+        ConcurrentHashMap<Long, Channel> 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 bindAdmin(Long liveId, Channel channel) {
+        getAdminRoom(liveId).add(channel);
+        getRoomGroup(liveId).add(channel);
+    }
+
+    public void unbind(Long liveId, Long userId, Long userType, Channel channel) {
+        if (userType != null && userType == 0L && userId != null) {
+            Map<Long, Channel> room = userRooms.get(liveId);
+            if (room != null) {
+                room.remove(userId);
+                if (room.isEmpty()) {
+                    userRooms.remove(liveId);
+                }
+            }
+        } else {
+            CopyOnWriteArrayList<Channel> admins = adminRooms.get(liveId);
+            if (admins != null) {
+                admins.remove(channel);
+                if (admins.isEmpty()) {
+                    adminRooms.remove(liveId);
+                }
+            }
+        }
+        ChannelGroup group = roomGroups.get(liveId);
+        if (group != null) {
+            group.remove(channel);
+            if (group.isEmpty()) {
+                roomGroups.remove(liveId);
+            }
+        }
+    }
+}

+ 66 - 0
fs-live-ws/src/main/java/com/fs/live/ws/task/LiveWsLikeBroadcastTask.java

@@ -0,0 +1,66 @@
+package com.fs.live.ws.task;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.live.ws.bean.WsSendMsgVo;
+import com.fs.live.ws.service.ILiveWsBroadcastService;
+import com.fs.live.ws.session.LiveWsRoomManager;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 点赞数变更广播(cmd=likeDetail),迁移自 fs-live-app WebSocketServer#broadcastLikeMessage。
+ */
+@Component
+public class LiveWsLikeBroadcastTask {
+
+    private final ConcurrentHashMap<Long, Integer> lastLikeCountCache = new ConcurrentHashMap<>();
+
+    @Autowired
+    private LiveWsRoomManager roomManager;
+    @Autowired
+    private RedisCache redisCache;
+    @Autowired
+    private ILiveWsBroadcastService broadcastService;
+
+    @Scheduled(fixedRateString = "${live.ws.like-broadcast-ms:300}")
+    public void broadcastLikeMessage() {
+        Set<Long> activeLiveIds = new HashSet<>();
+        for (Long liveId : roomManager.getActiveLiveIds()) {
+            activeLiveIds.add(liveId);
+            String likeKey = "live:like:" + liveId;
+            Object cacheObject = redisCache.getCacheObject(likeKey);
+            if (cacheObject == null) {
+                continue;
+            }
+            Integer current;
+            try {
+                current = Integer.parseInt(cacheObject.toString().trim());
+            } catch (NumberFormatException e) {
+                continue;
+            }
+            Integer last = lastLikeCountCache.getOrDefault(liveId, 0);
+            if (!current.equals(last)) {
+                WsSendMsgVo sendMsgVo = new WsSendMsgVo();
+                sendMsgVo.setLiveId(liveId);
+                sendMsgVo.setCmd("likeDetail");
+                sendMsgVo.setData(String.valueOf(current));
+                broadcastService.broadcastLocal(liveId,
+                        JSONObject.toJSONString(R.ok().put("data", sendMsgVo)));
+                lastLikeCountCache.put(liveId, current);
+            }
+        }
+        lastLikeCountCache.keySet().removeIf(liveId -> !activeLiveIds.contains(liveId));
+    }
+
+    public void removeLikeCountCache(Long liveId) {
+        lastLikeCountCache.remove(liveId);
+    }
+}

+ 52 - 0
fs-live-ws/src/main/java/com/fs/live/ws/task/LiveWsUserCountBroadcastTask.java

@@ -0,0 +1,52 @@
+package com.fs.live.ws.task;
+
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.core.domain.R;
+import com.fs.live.ws.bean.WsSendMsgVo;
+import com.fs.live.ws.service.ILiveWsBroadcastService;
+import com.fs.live.ws.session.LiveWsRoomManager;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 定时广播直播间在线人数(cmd=userCount),默认每 10 秒一次。
+ */
+@Component
+public class LiveWsUserCountBroadcastTask {
+
+    @Autowired
+    private LiveWsRoomManager roomManager;
+    @Autowired
+    private ILiveWsBroadcastService broadcastService;
+
+    /** 各直播间展示人数峰值(只增不减,与旧 WebSocketServer 一致) */
+    private final ConcurrentHashMap<Long, Integer> maxUserCountCache = new ConcurrentHashMap<>();
+
+    @Scheduled(fixedRateString = "${live.ws.user-count-broadcast-seconds:10}000")
+    public void broadcastUserCount() {
+        Set<Long> activeLiveIds = new HashSet<>();
+        for (Long liveId : roomManager.getActiveLiveIds()) {
+            activeLiveIds.add(liveId);
+            int currentOnline = roomManager.getUserOnlineCount(liveId);
+            int displayCount = maxUserCountCache.merge(liveId, currentOnline, Math::max);
+
+            WsSendMsgVo sendMsgVo = new WsSendMsgVo();
+            sendMsgVo.setLiveId(liveId);
+            sendMsgVo.setCmd("userCount");
+            sendMsgVo.setData(String.valueOf(displayCount));
+
+            // 本节点广播即可(人数按本机连接统计,不走 Redis 避免高频 fan-out)
+            broadcastService.broadcastLocal(liveId, JSONObject.toJSONString(R.ok().put("data", sendMsgVo)));
+        }
+        maxUserCountCache.keySet().removeIf(liveId -> !activeLiveIds.contains(liveId));
+    }
+
+    public void removeUserCountCache(Long liveId) {
+        maxUserCountCache.remove(liveId);
+    }
+}

+ 66 - 0
fs-live-ws/src/main/java/com/fs/live/ws/util/WsJwtUtils.java

@@ -0,0 +1,66 @@
+package com.fs.live.ws.util;
+
+import com.fs.common.utils.StringUtils;
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.SignatureAlgorithm;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.Date;
+
+@Component
+@ConfigurationProperties(prefix = "fs.jwt")
+public class WsJwtUtils {
+
+    private static final Logger log = LoggerFactory.getLogger(WsJwtUtils.class);
+
+    private String secret;
+    private long expire;
+    private String header;
+
+    public Claims getClaimByToken(String token) {
+        if (StringUtils.isEmpty(token)) {
+            return null;
+        }
+        try {
+            return Jwts.parser()
+                    .setSigningKey(secret)
+                    .parseClaimsJws(token)
+                    .getBody();
+        } catch (Exception ex) {
+            log.debug("token parse error", ex);
+            return null;
+        }
+    }
+
+    public boolean isTokenExpired(Date expiration) {
+        return expiration != null && expiration.before(new Date());
+    }
+
+    public String getSecret() {
+        return secret;
+    }
+
+    public void setSecret(String secret) {
+        this.secret = secret;
+    }
+
+    public long getExpire() {
+        return expire;
+    }
+
+    public void setExpire(long expire) {
+        this.expire = expire;
+    }
+
+    public String getHeader() {
+        return header;
+    }
+
+    public void setHeader(String header) {
+        this.header = header;
+    }
+}

+ 15 - 0
fs-live-ws/src/main/resources/mybatis/mybatis-config.xml

@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE configuration
+PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
+"http://mybatis.org/dtd/mybatis-3-config.dtd">
+<configuration>
+	
+	<settings>
+		<setting name="cacheEnabled"             value="true" />  <!-- 全局映射器启用缓存 -->
+		<setting name="useGeneratedKeys"         value="true" />  <!-- 允许 JDBC 支持自动生成主键 -->
+		<setting name="defaultExecutorType"      value="REUSE" /> <!-- 配置默认的执行器 -->
+		<setting name="logImpl"                  value="SLF4J" /> <!-- 指定 MyBatis 所用日志的具体实现 -->
+		 <setting name="mapUnderscoreToCamelCase" value="true"/>
+	</settings>
+	
+</configuration>

+ 24 - 0
fs-user-app/src/main/java/com/fs/app/config/LiveWsClientProperties.java

@@ -0,0 +1,24 @@
+package com.fs.app.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * fs-live-ws 连接配置(与 fs-live-ws application.yml live.ws 保持一致)
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "live.ws")
+public class LiveWsClientProperties {
+
+    private String publicScheme = "ws";
+    private String publicHost = "127.0.0.1";
+    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;
+    }
+}

+ 119 - 0
fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveAfterSalesController.java

@@ -0,0 +1,119 @@
+package com.fs.app.controller.cacheLive;
+
+import com.fs.app.annotation.Login;
+import com.fs.common.constant.LiveKeysConstant;
+import com.fs.common.core.domain.R;
+import com.fs.common.utils.ParseUtils;
+import com.fs.live.domain.LiveAfterSales;
+import com.fs.live.domain.LiveAfterSalesItem;
+import com.fs.live.domain.LiveOrder;
+import com.fs.live.domain.LiveOrderItem;
+import com.fs.live.param.LiveAfterSalesQueryParam;
+import com.fs.live.service.ILiveAfterSalesItemService;
+import com.fs.live.service.ILiveAfterSalesService;
+import com.fs.live.service.ILiveOrderItemService;
+import com.fs.live.service.ILiveOrderService;
+import com.fs.live.vo.LiveAfterSalesQueryVO;
+import com.github.pagehelper.PageHelper;
+import com.github.pagehelper.PageInfo;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@Api("售后缓存读接口")
+@RestController
+@RequestMapping("/app/cacheLive/storeAfterSales")
+public class CacheLiveAfterSalesController extends CacheLiveBaseController {
+
+    @Autowired
+    private ILiveAfterSalesService storeAfterSalesService;
+    @Autowired
+    private ILiveAfterSalesItemService salesItemService;
+    @Autowired
+    private ILiveOrderItemService itemService;
+    @Autowired
+    private ILiveOrderService orderService;
+
+    @Login
+    @ApiOperation("获取订单项列表")
+    @GetMapping("/getStoreOrderItems")
+    public R getStoreOrderItems(@RequestParam("orderId") Long orderId, HttpServletRequest request) {
+        String userId = getUserId();
+        String cacheKey = buildCacheKey(LiveKeysConstant.CACHE_USER_ORDER_LIST, userId, "afterSales:items:" + orderId);
+        Map<String, Object> data = cacheAsideR(cacheKey, LiveKeysConstant.TTL_USER_ORDER, () -> {
+            List<LiveOrderItem> list = itemService.selectCheckedByOrderId(orderId);
+            LiveOrder order = orderService.selectLiveOrderByOrderId(String.valueOf(orderId));
+            order.setUserPhone(ParseUtils.parsePhone(order.getUserPhone()));
+            order.setUserAddress(ParseUtils.parseIdCard(order.getUserAddress()));
+            Map<String, Object> result = new HashMap<>();
+            result.put("order", order);
+            result.put("items", list);
+            return result;
+        });
+        return R.ok().put("order", data.get("order")).put("items", data.get("items"));
+    }
+
+    @Login
+    @GetMapping("/getStoreAfterSalesList")
+    @ApiOperation(value = "获取售后列表", notes = "获取售后列表")
+    public R getAfterSalesList(LiveAfterSalesQueryParam param) {
+        String userId = getUserId();
+        String cacheKey = buildCacheKey(LiveKeysConstant.CACHE_USER_ORDER_LIST, userId,
+                "afterSales:list:" + param.getPage() + ":" + param.getPageSize());
+        PageInfo<LiveAfterSalesQueryVO> listPageInfo = cacheAsideR(cacheKey, LiveKeysConstant.TTL_USER_ORDER, () -> {
+            PageHelper.startPage(param.getPage(), param.getPageSize());
+            param.setUserId(Long.parseLong(userId));
+            List<LiveAfterSalesQueryVO> list = storeAfterSalesService.selectLiveAfterSalesListQuery(param);
+            return new PageInfo<>(list);
+        });
+        return R.ok().put("data", listPageInfo);
+    }
+
+    @Login
+    @GetMapping("/getAfterSalesDetails")
+    @ApiOperation(value = "获取售后详情", notes = "获取售后详情")
+    public R getAfterSalesDetails(@RequestParam("salesId") Long id) {
+        String userId = getUserId();
+        String cacheKey = buildCacheKey(LiveKeysConstant.CACHE_USER_ORDER_LIST, userId, "afterSales:detail:" + id);
+        Map<String, Object> data = cacheAsideR(cacheKey, LiveKeysConstant.TTL_USER_ORDER, () -> {
+            LiveAfterSales sales = storeAfterSalesService.selectLiveAfterSalesById(id);
+            List<LiveAfterSalesItem> items = salesItemService.selectLiveAfterSalesItemByAfterId(id);
+            Map<String, Object> result = new HashMap<>();
+            result.put("sales", sales);
+            result.put("items", items);
+            return result;
+        });
+        return R.ok().put("sales", data.get("sales")).put("items", data.get("items"));
+    }
+
+    @Login
+    @GetMapping("/getStoreAfterSalesById")
+    @ApiOperation(value = "获取售后详情", notes = "获取售后详情")
+    public R getStoreAfterSalesById(@RequestParam("id") Long id) {
+        String userId = getUserId();
+        String cacheKey = buildCacheKey(LiveKeysConstant.CACHE_USER_ORDER_LIST, userId, "afterSales:store:" + id);
+        Map<String, Object> data = cacheAsideR(cacheKey, LiveKeysConstant.TTL_USER_ORDER, () -> {
+            LiveAfterSales sales = storeAfterSalesService.selectLiveAfterSalesById(id);
+            List<LiveAfterSalesItem> items = salesItemService.selectLiveAfterSalesItemByAfterId(id);
+            LiveOrder order = orderService.selectLiveOrderByOrderId(String.valueOf(sales.getOrderId()));
+            Map<String, Object> result = new HashMap<>();
+            result.put("sales", sales);
+            result.put("items", items);
+            result.put("order", order);
+            return result;
+        });
+        return R.ok()
+                .put("sales", data.get("sales"))
+                .put("items", data.get("items"))
+                .put("order", data.get("order"));
+    }
+}

+ 29 - 0
fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveBaseController.java

@@ -0,0 +1,29 @@
+package com.fs.app.controller.cacheLive;
+
+import cn.hutool.core.util.ObjectUtil;
+import com.fs.app.controller.AppBaseController;
+
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+
+public class CacheLiveBaseController extends AppBaseController {
+
+    protected <T> T cacheAsideR(String key, int ttlSeconds, Supplier<T> loader) {
+        T cached = redisCache.getCacheObject(key);
+        if (ObjectUtil.isNotEmpty(cached)) {
+            return cached;
+        }
+        T value = loader.get();
+        if (ObjectUtil.isNotEmpty(value)) {
+            redisCache.setCacheObject(key, value, ttlSeconds, TimeUnit.SECONDS);
+        }
+        return value;
+    }
+
+    protected String buildCacheKey(String domain, Object... parts) {
+        if (parts == null || parts.length == 0) {
+            return domain;
+        }
+        return String.format(domain, parts);
+    }
+}

+ 54 - 0
fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveCartController.java

@@ -0,0 +1,54 @@
+package com.fs.app.controller.cacheLive;
+
+import com.fs.app.annotation.Login;
+import com.fs.common.constant.LiveKeysConstant;
+import com.fs.common.core.domain.R;
+import com.fs.live.domain.LiveCart;
+import com.fs.live.service.ILiveCartService;
+import com.fs.live.vo.LiveCartVo;
+import com.github.pagehelper.PageHelper;
+import com.github.pagehelper.PageInfo;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/app/cacheLive/liveCart")
+public class CacheLiveCartController extends CacheLiveBaseController {
+
+    @Autowired
+    private ILiveCartService liveCartService;
+
+    @GetMapping("/list")
+    @Login
+    public R list(LiveCart liveCart) {
+        String userId = getUserId();
+        String cacheKey = buildCacheKey(LiveKeysConstant.CACHE_USER_ORDER_LIST,
+                userId, "cart:list:" + liveCart.getPage() + ":" + liveCart.getPageSize());
+        PageInfo<LiveCartVo> result = cacheAsideR(cacheKey, LiveKeysConstant.TTL_USER_ORDER, () -> {
+            PageHelper.startPage(liveCart.getPage(), liveCart.getPageSize());
+            liveCart.setUserId(userId);
+            List<LiveCartVo> list = liveCartService.selectLiveCartListVo(liveCart);
+            return new PageInfo<>(list);
+        });
+        return R.ok().put("data", result);
+    }
+
+    @GetMapping("/checked")
+    @Login
+    public R getChecked(LiveCart liveCart) {
+        String userId = getUserId();
+        String cacheKey = buildCacheKey(LiveKeysConstant.CACHE_USER_ORDER_LIST,
+                userId, "cart:checked:" + liveCart.getPage() + ":" + liveCart.getPageSize());
+        PageInfo<LiveCartVo> result = cacheAsideR(cacheKey, LiveKeysConstant.TTL_USER_ORDER, () -> {
+            PageHelper.startPage(liveCart.getPage(), liveCart.getPageSize());
+            liveCart.setUserId(userId);
+            List<LiveCartVo> list = liveCartService.getChecked(liveCart);
+            return new PageInfo<>(list);
+        });
+        return R.ok().put("data", result);
+    }
+}

+ 135 - 0
fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveCompletionPointsController.java

@@ -0,0 +1,135 @@
+package com.fs.app.controller.cacheLive;
+
+import com.fs.app.vo.RemainingTimeVO;
+import com.fs.common.constant.LiveKeysConstant;
+import com.fs.common.core.domain.R;
+import com.fs.his.domain.FsUser;
+import com.fs.his.domain.FsUserIntegralLogs;
+import com.fs.his.service.IFsUserIntegralLogsService;
+import com.fs.his.service.IFsUserService;
+import com.fs.live.domain.Live;
+import com.fs.live.domain.LiveCompletionPointsRecord;
+import com.fs.live.mapper.LiveCompletionPointsRecordMapper;
+import com.fs.live.service.ILiveCompletionPointsRecordService;
+import com.fs.live.service.ILiveService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/app/cacheLive/completion")
+public class CacheLiveCompletionPointsController extends CacheLiveBaseController {
+
+    @Autowired
+    private ILiveCompletionPointsRecordService completionPointsRecordService;
+    @Autowired
+    private IFsUserService fsUserService;
+    @Autowired
+    private ILiveService liveService;
+    @Autowired
+    private IFsUserIntegralLogsService fsUserIntegralLogsService;
+    @Autowired
+    private LiveCompletionPointsRecordMapper completionPointsRecordMapper;
+
+    @GetMapping("/unreceived")
+    public R getUnreceived(@RequestParam Long liveId) {
+        Long userId = Long.parseLong(getUserId());
+        String cacheKey = buildCacheKey("live:cache:completion:unreceived:%s:%s", liveId, userId);
+        List<LiveCompletionPointsRecord> records = cacheAsideR(cacheKey, LiveKeysConstant.TTL_LIVE_DATA,
+                () -> completionPointsRecordService.getUserUnreceivedRecords(liveId, userId));
+        return R.ok().put("data", records);
+    }
+
+    @GetMapping("/records")
+    public R getRecords(@RequestParam Long liveId) {
+        Long userId = Long.parseLong(getUserId());
+        String cacheKey = buildCacheKey("live:cache:completion:records:%s:%s", liveId, userId);
+        List<LiveCompletionPointsRecord> records = cacheAsideR(cacheKey, LiveKeysConstant.TTL_LIVE_DATA,
+                () -> completionPointsRecordService.getUserRecords(liveId, userId));
+        return R.ok().put("data", records);
+    }
+
+    @GetMapping("/info")
+    public R getInfo(@RequestParam Long liveId) {
+        Long userId = Long.parseLong(getUserId());
+        String cacheKey = buildCacheKey("live:cache:completion:info:%s:%s", liveId, userId);
+        Map<String, Object> result = cacheAsideR(cacheKey, LiveKeysConstant.TTL_LIVE_DATA, () -> {
+            FsUser user = fsUserService.selectFsUserByUserId(userId);
+            Long integral = user != null && user.getIntegral() != null ? user.getIntegral() : 0L;
+            List<LiveCompletionPointsRecord> records = completionPointsRecordService.getUserRecords(liveId, userId);
+            long totalPoints = records.stream()
+                    .filter(r -> r.getReceiveStatus() == 1)
+                    .mapToLong(LiveCompletionPointsRecord::getPointsAwarded)
+                    .sum();
+            long unreceivedCount = records.stream()
+                    .filter(r -> r.getReceiveStatus() == 0)
+                    .count();
+            Map<String, Object> data = new HashMap<>();
+            data.put("integral", integral);
+            data.put("totalPoints", totalPoints);
+            data.put("totalDays", records.size());
+            data.put("unreceivedCount", unreceivedCount);
+            data.put("records", records);
+            return data;
+        });
+        return R.ok().put("data", result);
+    }
+
+    @GetMapping("/remaining-time")
+    public R getRemainingTime(@RequestParam Long liveId) {
+        Long userId = Long.parseLong(getUserId());
+        String cacheKey = buildCacheKey("live:cache:completion:remaining:%s:%s", liveId, userId);
+        RemainingTimeVO vo = cacheAsideR(cacheKey, LiveKeysConstant.TTL_LIVE_DATA, () -> {
+            Live live = liveService.selectLiveByLiveId(liveId);
+            if (live == null) {
+                return null;
+            }
+            LiveCompletionPointsRecord record = completionPointsRecordMapper.selectLatestByUserAndLiveId(liveId, userId);
+            if (record == null) {
+                record = completionPointsRecordService.createCompletionRecord(liveId, userId);
+            }
+            RemainingTimeVO result = new RemainingTimeVO();
+            Long videoDuration = live.getDuration() != null ? live.getDuration() : 0L;
+            Long watchDuration = record != null && record.getWatchDuration() != null
+                    ? record.getWatchDuration() : 0L;
+            result.setVideoDuration(videoDuration);
+            if (record != null) {
+                result.setCompletionRate(record.getCompletionRate());
+            }
+            result.setWatchDuration(watchDuration);
+            result.setRemainingTime(Math.max(0, videoDuration - watchDuration));
+            result.setHasReceived(record != null && record.getReceiveStatus() != null && record.getReceiveStatus() == 1);
+            return result;
+        });
+        if (vo == null) {
+            return R.error("直播间不存在");
+        }
+        return R.ok().put("data", vo);
+    }
+
+    @GetMapping("/integral-logs")
+    public R getIntegralLogs(@RequestParam(required = false) Integer type) {
+        Long userId = Long.parseLong(getUserId());
+        String cacheKey = buildCacheKey("live:cache:completion:integral-logs:%s:%s", userId, type);
+        List<FsUserIntegralLogs> logs = cacheAsideR(cacheKey, LiveKeysConstant.TTL_LIVE_DATA, () -> {
+            FsUserIntegralLogs query = new FsUserIntegralLogs();
+            query.setUserId(userId);
+            List<FsUserIntegralLogs> result = fsUserIntegralLogsService.selectFsUserIntegralLogsList(query);
+            if (type != null) {
+                if (type == 1) {
+                    result.removeIf(log -> log.getIntegral() == null || log.getIntegral() <= 0);
+                } else if (type == 2) {
+                    result.removeIf(log -> log.getIntegral() == null || log.getIntegral() >= 0);
+                }
+            }
+            return result;
+        });
+        return R.ok().put("data", logs);
+    }
+}

+ 416 - 0
fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveController.java

@@ -0,0 +1,416 @@
+package com.fs.app.controller.cacheLive;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.util.ObjectUtil;
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.fs.app.annotation.Login;
+import com.fs.app.config.LiveWsClientProperties;
+import com.fs.app.vo.LiveInfoVo;
+import com.fs.common.constant.LiveKeysConstant;
+import com.fs.common.core.domain.BaseEntity;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.page.PageRequest;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.bean.BeanUtils;
+import com.fs.common.utils.http.HttpUtils;
+import com.fs.company.domain.CompanyUser;
+import com.fs.company.service.ICompanyUserService;
+import com.fs.his.domain.FsUser;
+import com.fs.his.service.IFsUserService;
+import com.fs.live.domain.Live;
+import com.fs.live.domain.LiveCompletionPointsRecord;
+import com.fs.live.domain.LiveMsg;
+import com.fs.live.domain.LiveRedConf;
+import com.fs.live.service.ILiveCompletionPointsRecordService;
+import com.fs.live.service.ILiveMsgService;
+import com.fs.live.service.ILiveService;
+import com.fs.live.vo.LiveConfigVo;
+import com.fs.live.vo.LiveLotteryConfVo;
+import com.fs.live.vo.LiveMsgVo;
+import com.fs.live.vo.LiveVo;
+import com.fs.wx.miniapp.config.WxMaProperties;
+import com.github.pagehelper.PageHelper;
+import com.github.pagehelper.PageInfo;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.time.ZoneId;
+import java.util.Calendar;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@Api("直播缓存读接口")
+@RestController
+@RequestMapping("/app/cacheLive")
+public class CacheLiveController extends CacheLiveBaseController {
+
+    private static final Logger log = LoggerFactory.getLogger(CacheLiveController.class);
+    private static final ZoneId ZONE_ID = ZoneId.of("GMT+8");
+
+    @Autowired
+    private ILiveService liveService;
+    @Autowired
+    private ILiveMsgService liveMsgService;
+    @Autowired
+    private ILiveCompletionPointsRecordService completionPointsRecordService;
+    @Autowired
+    private IFsUserService userService;
+    @Autowired
+    private ICompanyUserService companyUserService;
+    @Autowired
+    private WxMaProperties properties;
+    @Autowired
+    private LiveWsClientProperties liveWsClientProperties;
+
+    @ApiOperation("直播封面信息")
+    @GetMapping("/liveInfo")
+    @ApiResponse(code = 200, message = "", response = LiveInfoVo.class)
+    public R liveInfo(Long id) {
+        Live live = liveService.selectLiveByLiveId(id);
+        if (live == null) {
+            return R.error("未找到直播");
+        }
+        if (live.getIsShow() == 2) {
+            return R.error("直播未开放");
+        }
+        LiveInfoVo vo = new LiveInfoVo();
+        BeanUtils.copyProperties(live, vo);
+        vo.setId(live.getLiveId());
+        return R.ok().put("data", vo);
+    }
+
+    @Login
+    @ApiOperation("直播间(含 WebSocket 连接地址)")
+    @GetMapping("/live")
+    @ApiResponse(code = 200, message = "", response = LiveVo.class)
+    public R live(Long id) {
+        Long userId = parseUserId();
+        LiveVo liveVo = loadLiveVoFromCache(id);
+        if (ObjectUtil.isEmpty(liveVo)) {
+            return R.error("未找到直播");
+        }
+        if (liveVo.getIsShow() == 2) {
+            return R.error("直播未开放");
+        }
+        fillTodayRewardReceived(liveVo, id, userId);
+        String wsUrl = liveWsClientProperties.buildWsBaseUrl(id);
+        return R.ok()
+                .put("serviceTime", System.currentTimeMillis())
+                .put("serviceStartTime", liveVo.getStartTime() == null ? null : liveVo.getStartTime().atZone(ZONE_ID).toInstant().toEpochMilli())
+                .put("serviceEndTime", liveVo.getFinishTime() == null ? null : liveVo.getFinishTime().atZone(ZONE_ID).toInstant().toEpochMilli())
+                .put("wsUrl", wsUrl)
+                .put("wsNodeId", liveWsClientProperties.getNodeId())
+                .put("wsFallback", Collections.emptyList())
+                .put("data", liveVo);
+    }
+
+    @Login
+    @ApiOperation("直播间列表")
+    @GetMapping("/liveList")
+    @ApiResponse(code = 200, message = "", response = LiveInfoVo.class)
+    public R liveList(PageRequest pageRequest) {
+        int start = (pageRequest.getCurrentPage() - 1) * pageRequest.getPageSize();
+        int end = pageRequest.getCurrentPage() * pageRequest.getPageSize() - 1;
+
+        Set<Object> liveList = redisCache.zSetReverseRange(LiveKeysConstant.LIVE_HOME_PAGE_LIST, start, end);
+        if (CollUtil.isNotEmpty(liveList)) {
+            List<Live> lives = liveList.stream()
+                    .map(String.class::cast)
+                    .map(json -> JSON.parseObject(json, Live.class))
+                    .collect(Collectors.toList());
+            PageInfo<Live> result = new PageInfo<>(lives);
+            result.setTotal(redisCache.zSetSize(LiveKeysConstant.LIVE_HOME_PAGE_LIST));
+            return R.ok().put("data", result);
+        }
+
+        List<Live> lives = liveService.asyncToCache();
+        List<Live> pageLives = lives.stream()
+                .skip(start)
+                .limit(pageRequest.getPageSize())
+                .collect(Collectors.toList());
+        PageInfo<Live> result = new PageInfo<>(pageLives);
+        result.setTotal(lives.size());
+        return R.ok().put("data", result);
+    }
+
+    @GetMapping("/listToLiveNoEnd")
+    public TableDataInfo listToLiveNoEnd(Live live) {
+        startPage();
+        List<Live> list = liveService.listToLiveNoEnd(live);
+        return getDataTable(list);
+    }
+
+    @Login
+    @ApiOperation("直播间列表")
+    @GetMapping("/liveListAll")
+    @ApiResponse(code = 200, message = "", response = LiveInfoVo.class)
+    public R liveListAll(PageRequest pageRequest) {
+        String pageNum = ServletUtils.getParameter("pageNum");
+        String pageSize = ServletUtils.getParameter("pageSize");
+        String cacheKey = buildCacheKey("live:cache:list:all:%s:%s", pageNum, pageSize);
+        PageInfo<Live> result = cacheAsideR(cacheKey, LiveKeysConstant.TTL_LIVE_INFO, () -> {
+            PageHelper.startPage(Integer.parseInt(pageNum), Integer.parseInt(pageSize));
+            List<Live> list = liveService.liveList();
+            return new PageInfo<>(list);
+        });
+        return R.ok().put("data", result);
+    }
+
+    @Login
+    @ApiOperation("直播间列表")
+    @GetMapping("/liveList/{companyId}")
+    @ApiResponse(code = 200, message = "", response = LiveInfoVo.class)
+    public R liveCompanyList(PageRequest pageRequest, @PathVariable Long companyId) {
+        String pageNum = ServletUtils.getParameter("pageNum");
+        String pageSize = ServletUtils.getParameter("pageSize");
+        String cacheKey = buildCacheKey("live:cache:list:company:%s:%s:%s", companyId, pageNum, pageSize);
+        PageInfo<Live> result = cacheAsideR(cacheKey, LiveKeysConstant.TTL_LIVE_INFO, () -> {
+            PageHelper.startPage(Integer.parseInt(pageNum), Integer.parseInt(pageSize));
+            List<Live> list = liveService.liveCompanyList(companyId);
+            return new PageInfo<>(list);
+        });
+        return R.ok().put("data", result);
+    }
+
+    @Login
+    @ApiOperation("直播间列表")
+    @PostMapping("/newLiveList")
+    @ApiResponse(code = 200, message = "", response = LiveInfoVo.class)
+    public R newLiveList(@RequestBody Live live) {
+        String cacheKey = buildCacheKey("live:cache:list:new:%s:%s:%s",
+                live.getCompanyId(), live.getPageNum(), live.getPageSize());
+        PageInfo<Live> result = cacheAsideR(cacheKey, LiveKeysConstant.TTL_LIVE_INFO, () -> {
+            PageHelper.startPage(live.getPageNum(), live.getPageSize());
+            List<Live> list = liveService.selectLiveListNew(live);
+            return new PageInfo<>(list);
+        });
+        return R.ok().put("data", result);
+    }
+
+    @Login
+    @ApiOperation("聊天记录(最新30条)")
+    @GetMapping("/msgList")
+    @ApiResponse(code = 200, message = "", response = LiveInfoVo.class)
+    public R msgList(Long id) {
+        String cacheKey = buildCacheKey(LiveKeysConstant.CACHE_LIVE_MSG_LIST, id);
+        List<LiveMsg> list = cacheAsideR(cacheKey, LiveKeysConstant.TTL_LIVE_MSG, () -> {
+            List<LiveMsg> msgs = liveMsgService.listRecentMsg(id);
+            msgs.sort(Comparator.comparing(BaseEntity::getCreateTime));
+            return msgs;
+        });
+        return R.ok().put("data", list);
+    }
+
+    @Login
+    @GetMapping("/currentActivities")
+    public R currentActivities(Long liveId) {
+        String userId = getUserId();
+        LiveConfigVo liveConfigVo = loadLiveConfigFromCache(liveId);
+        if (ObjectUtil.isEmpty(liveConfigVo)) {
+            return R.error("未找到直播活动配置");
+        }
+
+        List<LiveRedConf> redConfs = CollUtil.emptyIfNull(liveConfigVo.getLiveRedConfs()).stream()
+                .filter(item -> ObjectUtil.isEmpty(redisCache.hashGet(
+                        String.format(LiveKeysConstant.LIVE_HOME_PAGE_CONFIG_RED, liveId, item.getRedId()), userId)))
+                .collect(Collectors.toList());
+        List<LiveLotteryConfVo> lotteryConfs = CollUtil.emptyIfNull(liveConfigVo.getLiveLotteryConfs()).stream()
+                .filter(item -> ObjectUtil.isEmpty(redisCache.hashGet(
+                        String.format(LiveKeysConstant.LIVE_HOME_PAGE_CONFIG_RED, liveId, item.getLotteryId()), userId)))
+                .collect(Collectors.toList());
+
+        Object cacheObject = redisCache.getCacheObject(
+                String.format(LiveKeysConstant.LIVE_HOME_PAGE_CONFIG, liveId, LiveKeysConstant.TOP_MSG));
+        LiveMsgVo liveMsg = parseTopMsgCache(cacheObject, liveId);
+
+        return R.ok()
+                .put("red", redConfs)
+                .put("lottery", lotteryConfs)
+                .put("goods", liveConfigVo.getLiveGoodsVo())
+                .put("topMsg", liveMsg);
+    }
+
+    @ApiOperation("微信直播间urlScheme")
+    @GetMapping("/getAppletScheme")
+    public R getAppletScheme(@RequestParam(value = "liveId") Long liveId,
+                             @RequestParam(value = "companyUserId") Long companyUserId) {
+        try {
+            String userId = getUserId();
+            Live live = liveService.selectLiveDbByLiveId(liveId);
+            if (live == null) {
+                return R.error("未找到直播间");
+            }
+            FsUser fsUser = userService.selectFsUserById(Long.valueOf(userId));
+            if (fsUser == null) {
+                return R.error("未查询到用户数据");
+            }
+            CompanyUser companyUser = companyUserService.selectCompanyUserById(companyUserId);
+            if (companyUser == null) {
+                return R.error("未查询到销售信息,链接生成失败");
+            }
+            String param = "liveId=" + liveId + "&companyUserId=" + companyUser.getUserId() + "&companyId=" + companyUser.getCompanyId();
+            String appId = properties.getConfigs().get(0).getAppid();
+            String secret = properties.getConfigs().get(0).getSecret();
+
+            String cacheKey = "wx:access_token:" + appId;
+            String accessToken = redisCache.getCacheObject(cacheKey);
+
+            if (StringUtils.isEmpty(accessToken)) {
+                String rspStr = HttpUtils.sendGet("https://api.weixin.qq.com/cgi-bin/token",
+                        "grant_type=client_credential&appid=" + appId + "&secret=" + secret);
+                JSONObject obj = JSONObject.parseObject(rspStr);
+                accessToken = obj.getString("access_token");
+                if (StringUtils.isEmpty(accessToken)) {
+                    log.error("获取微信 access_token 失败: {}", obj);
+                    return R.error("获取微信 access_token 失败");
+                }
+                redisCache.setCacheObject(cacheKey, accessToken, 7200, java.util.concurrent.TimeUnit.SECONDS);
+            }
+
+            JSONObject jumpWxaObj = new JSONObject();
+            jumpWxaObj.put("page_url", "pages_course/living.html?" + param);
+            String paramStr = jumpWxaObj.toJSONString();
+            String postStr = HttpUtils.sendPost(
+                    "https://api.weixin.qq.com/wxa/genwxashortlink?access_token=" + accessToken, paramStr);
+            JSONObject obj = JSONObject.parseObject(postStr);
+
+            if (obj != null && obj.getInteger("errcode") != null && obj.getInteger("errcode") == 40001) {
+                redisCache.deleteObject(cacheKey);
+                String rspStr = HttpUtils.sendGet("https://api.weixin.qq.com/cgi-bin/token",
+                        "grant_type=client_credential&appid=" + appId + "&secret=" + secret);
+                JSONObject tokenObj = JSONObject.parseObject(rspStr);
+                accessToken = tokenObj.getString("access_token");
+                if (StringUtils.isNotEmpty(accessToken)) {
+                    redisCache.setCacheObject(cacheKey, accessToken, 7200, java.util.concurrent.TimeUnit.SECONDS);
+                    postStr = HttpUtils.sendPost(
+                            "https://api.weixin.qq.com/wxa/genwxashortlink?access_token=" + accessToken, paramStr);
+                    obj = JSONObject.parseObject(postStr);
+                }
+            }
+            return R.ok().put("result", obj);
+        } catch (Exception e) {
+            log.error("生成小程序 Scheme 失败", e);
+            return R.error("操作失败");
+        }
+    }
+
+    private Long parseUserId() {
+        String userId = getUserId();
+        return userId == null ? null : Long.parseLong(userId);
+    }
+
+    private LiveVo loadLiveVoFromCache(Long id) {
+        String cacheKey = String.format(LiveKeysConstant.LIVE_HOME_PAGE_DETAIL, id);
+        try {
+            LiveVo cached = redisCache.getCacheObject(cacheKey);
+            if (ObjectUtil.isNotEmpty(cached)) {
+                return cached;
+            }
+        } catch (Exception e) {
+            log.warn("LiveVo 缓存反序列化失败,清除脏数据 key={}", cacheKey, e);
+            redisCache.deleteObject(cacheKey);
+        }
+        return liveService.asyncToCacheLiveDetail(id);
+    }
+
+    private LiveConfigVo loadLiveConfigFromCache(Long liveId) {
+        String configKey = String.format(LiveKeysConstant.LIVE_HOME_PAGE_CONFIG, liveId, liveId);
+        try {
+            Object cached = redisCache.getCacheObject(configKey);
+            if (cached instanceof LiveConfigVo) {
+                return (LiveConfigVo) cached;
+            }
+            if (cached instanceof String) {
+                String json = (String) cached;
+                if (json.trim().startsWith("{")) {
+                    return JSON.parseObject(json, LiveConfigVo.class);
+                }
+            }
+            if (cached != null) {
+                log.warn("LiveConfigVo 缓存类型/格式异常,清除脏数据 key={}, type={}", configKey, cached.getClass().getName());
+                redisCache.deleteObject(configKey);
+            }
+        } catch (Exception e) {
+            log.warn("LiveConfigVo 缓存反序列化失败,清除脏数据 key={}", configKey, e);
+            redisCache.deleteObject(configKey);
+        }
+        return liveService.asyncToCacheLiveConfig(liveId);
+    }
+
+    private LiveMsgVo parseTopMsgCache(Object cached, Long liveId) {
+        if (ObjectUtil.isEmpty(cached)) {
+            return null;
+        }
+        String key = String.format(LiveKeysConstant.LIVE_HOME_PAGE_CONFIG, liveId, LiveKeysConstant.TOP_MSG);
+        try {
+            if (cached instanceof LiveMsgVo) {
+                return (LiveMsgVo) cached;
+            }
+            if (cached instanceof String) {
+                String json = (String) cached;
+                if (json.trim().startsWith("{")) {
+                    return JSON.parseObject(json, LiveMsgVo.class);
+                }
+            }
+            log.warn("LiveMsgVo 缓存类型/格式异常,清除脏数据 key={}, type={}", key, cached.getClass().getName());
+            redisCache.deleteObject(key);
+        } catch (Exception e) {
+            log.warn("LiveMsgVo 缓存反序列化失败 key={}", key, e);
+            redisCache.deleteObject(key);
+        }
+        return null;
+    }
+
+    private void fillTodayRewardReceived(LiveVo liveVo, Long liveId, Long userId) {
+        if (userId == null) {
+            liveVo.setTodayRewardReceived(false);
+            return;
+        }
+        try {
+            List<LiveCompletionPointsRecord> unreceivedRecords =
+                    completionPointsRecordService.getUserUnreceivedRecords(liveId, userId);
+            if (unreceivedRecords != null && !unreceivedRecords.isEmpty()) {
+                liveVo.setTodayRewardReceived(false);
+                return;
+            }
+            List<LiveCompletionPointsRecord> allRecords =
+                    completionPointsRecordService.getUserRecords(liveId, userId);
+            if (allRecords == null || allRecords.isEmpty()) {
+                liveVo.setTodayRewardReceived(false);
+                return;
+            }
+            LiveCompletionPointsRecord latestRecord = allRecords.get(0);
+            Date recordDate = latestRecord.getCurrentCompletionDate();
+            if (recordDate != null && isSameDay(recordDate, new Date())
+                    && latestRecord.getReceiveStatus() == 1) {
+                liveVo.setTodayRewardReceived(true);
+            } else {
+                liveVo.setTodayRewardReceived(false);
+            }
+        } catch (Exception e) {
+            log.error("查询用户完课奖励领取状态失败, liveId={}, userId={}", liveId, userId, e);
+            liveVo.setTodayRewardReceived(false);
+        }
+    }
+
+    private boolean isSameDay(Date date1, Date date2) {
+        Calendar cal1 = Calendar.getInstance();
+        Calendar cal2 = Calendar.getInstance();
+        cal1.setTime(date1);
+        cal2.setTime(date2);
+        return cal1.get(Calendar.YEAR) == cal2.get(Calendar.YEAR)
+                && cal1.get(Calendar.DAY_OF_YEAR) == cal2.get(Calendar.DAY_OF_YEAR);
+    }
+}

+ 40 - 0
fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveCouponController.java

@@ -0,0 +1,40 @@
+package com.fs.app.controller.cacheLive;
+
+import com.fs.app.annotation.Login;
+import com.fs.common.constant.LiveKeysConstant;
+import com.fs.common.core.domain.R;
+import com.fs.live.param.CouponPO;
+import com.fs.live.service.ILiveCouponService;
+import org.springframework.beans.factory.annotation.Autowired;
+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.RestController;
+
+@RestController
+@RequestMapping("/app/cacheLive/coupon")
+public class CacheLiveCouponController extends CacheLiveBaseController {
+
+    @Autowired
+    private ILiveCouponService liveCouponService;
+
+    @Login
+    @PostMapping("/list")
+    public R list(@RequestBody CouponPO coupon) {
+        coupon.setUserId(Long.parseLong(getUserId()));
+        String cacheKey = buildCacheKey("live:cache:coupon:list:%s:%s",
+                coupon.getUserId(), coupon.getLiveId());
+        return cacheAsideR(cacheKey, LiveKeysConstant.TTL_LIVE_DATA,
+                () -> liveCouponService.userCouponList(coupon));
+    }
+
+    @Login
+    @PostMapping("/curCoupon")
+    public R curCoupon(@RequestBody CouponPO coupon) {
+        coupon.setUserId(Long.parseLong(getUserId()));
+        String cacheKey = buildCacheKey("live:cache:coupon:cur:%s:%s:%s",
+                coupon.getUserId(), coupon.getLiveId(), coupon.getGoodsId());
+        return cacheAsideR(cacheKey, LiveKeysConstant.TTL_LIVE_GOODS,
+                () -> liveCouponService.curCoupon(coupon));
+    }
+}

+ 85 - 0
fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveDataController.java

@@ -0,0 +1,85 @@
+package com.fs.app.controller.cacheLive;
+
+import com.fs.app.annotation.Login;
+import com.fs.common.constant.LiveKeysConstant;
+import com.fs.common.core.domain.R;
+import com.fs.his.domain.FsUser;
+import com.fs.his.service.IFsUserService;
+import com.fs.live.domain.LiveData;
+import com.fs.live.service.ILiveDataService;
+import com.fs.live.service.ILiveWatchUserService;
+import com.fs.live.vo.HisFsUserVO;
+import com.fs.live.vo.LiveWatchUserVO;
+import com.hc.openapi.tool.util.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/app/cacheLive/liveData")
+public class CacheLiveDataController extends CacheLiveBaseController {
+
+    @Autowired
+    private ILiveDataService liveDataService;
+    @Autowired
+    private ILiveWatchUserService liveWatchUserService;
+    @Autowired
+    private IFsUserService fsUserService;
+
+    @Login
+    @GetMapping("/list")
+    public List<LiveData> list(LiveData liveData) {
+        String cacheKey = buildCacheKey("live:cache:data:list:%s", liveData.getLiveId());
+        return cacheAsideR(cacheKey, LiveKeysConstant.TTL_LIVE_DATA,
+                () -> liveDataService.selectLiveDataList(liveData));
+    }
+
+    @Login
+    @GetMapping("/get/{liveId}")
+    public R getLiveData(@PathVariable("liveId") Long liveId) {
+        String cacheKey = buildCacheKey("live:cache:data:get:%s", liveId);
+        LiveData liveData = cacheAsideR(cacheKey, LiveKeysConstant.TTL_LIVE_DATA,
+                () -> liveDataService.selectLiveDataByLiveId(liveId));
+        return R.ok().put("liveData", liveData);
+    }
+
+    @GetMapping("/getLiveViewData/{liveId}")
+    public R getLiveViewData(@PathVariable Long liveId) {
+        String cacheKey = buildCacheKey(LiveKeysConstant.CACHE_LIVE_VIEW_DATA, liveId);
+        Map<String, Object> liveViewData = cacheAsideR(cacheKey, LiveKeysConstant.TTL_LIVE_DATA,
+                () -> liveDataService.getLiveViewData(liveId));
+        return R.ok(liveViewData);
+    }
+
+    @GetMapping("/getRecentLiveViewers/{liveId}")
+    public R getRecentLiveViewers(@PathVariable Long liveId) {
+        String cacheKey = buildCacheKey(LiveKeysConstant.CACHE_LIVE_RECENT_VIEWERS, liveId);
+        List<LiveWatchUserVO> recentLiveViewers = cacheAsideR(cacheKey, LiveKeysConstant.TTL_LIVE_DATA, () -> {
+            Map<String, Object> params = new HashMap<>();
+            params.put("liveId", liveId);
+            return liveWatchUserService.selectWatchUserList(params);
+        });
+        return R.ok().put("recentLiveViewers", recentLiveViewers);
+    }
+
+    @Login
+    @GetMapping("/getUserIntegralInfo")
+    public R getUserIntegralInfo() {
+        String userId = getUserId();
+        if (null == userId || StringUtils.isBlank(userId)) {
+            return R.error("未找到用户信息");
+        }
+        String cacheKey = buildCacheKey("live:cache:integral:info:%s", userId);
+        HisFsUserVO hisUserIntegral = cacheAsideR(cacheKey, LiveKeysConstant.TTL_LIVE_DATA, () -> {
+            FsUser fsUser = fsUserService.selectFsUserById(Long.parseLong(userId));
+            return fsUserService.getHisUserIntegralWithLogs(fsUser);
+        });
+        return R.ok().put("data", hisUserIntegral);
+    }
+}

+ 170 - 0
fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveGoodsController.java

@@ -0,0 +1,170 @@
+package com.fs.app.controller.cacheLive;
+
+import com.fs.common.constant.LiveKeysConstant;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.domain.R;
+import com.fs.common.utils.ServletUtils;
+import com.fs.hisStore.domain.FsStoreProductAttrScrm;
+import com.fs.hisStore.domain.FsStoreProductAttrValueScrm;
+import com.fs.hisStore.domain.FsStoreProductRelationScrm;
+import com.fs.hisStore.domain.FsStoreProductScrm;
+import com.fs.hisStore.domain.FsStoreProductPurchaseLimitScrm;
+import com.fs.hisStore.service.IFsStoreProductAttrScrmService;
+import com.fs.hisStore.service.IFsStoreProductAttrValueScrmService;
+import com.fs.hisStore.service.IFsStoreProductPurchaseLimitScrmService;
+import com.fs.hisStore.service.IFsStoreProductRelationScrmService;
+import com.fs.hisStore.service.IFsStoreProductScrmService;
+import com.fs.live.domain.LiveGoods;
+import com.fs.live.service.ILiveGoodsService;
+import com.github.pagehelper.PageHelper;
+import com.github.pagehelper.PageInfo;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+@RestController
+@RequestMapping("/app/cacheLive/liveGoods")
+public class CacheLiveGoodsController extends CacheLiveBaseController {
+
+    @Autowired
+    private ILiveGoodsService liveGoodsService;
+    @Autowired
+    private IFsStoreProductScrmService fsStoreProductService;
+    @Autowired
+    private IFsStoreProductAttrScrmService attrService;
+    @Autowired
+    private IFsStoreProductAttrValueScrmService attrValueService;
+    @Autowired
+    private IFsStoreProductRelationScrmService productRelationService;
+    @Autowired
+    private IFsStoreProductPurchaseLimitScrmService purchaseLimitService;
+
+    @GetMapping("/list")
+    public R list(LiveGoods liveGoods) {
+        String pageNum = ServletUtils.getParameter("pageNum");
+        String pageSize = ServletUtils.getParameter("pageSize");
+        String cacheKey = buildCacheKey(LiveKeysConstant.CACHE_LIVE_GOODS_LIST,
+                liveGoods.getLiveId(), pageNum, pageSize);
+        PageInfo<LiveGoods> listPageInfo = cacheAsideR(cacheKey, LiveKeysConstant.TTL_LIVE_GOODS, () -> {
+            PageHelper.startPage(Integer.parseInt(pageNum), Integer.parseInt(pageSize));
+            List<LiveGoods> list = liveGoodsService.selectLiveGoodsList(liveGoods);
+            return new PageInfo<>(list);
+        });
+        return R.ok().put("data", listPageInfo);
+    }
+
+    @GetMapping("/liveStore/{liveId}")
+    public R liveStore(@PathVariable Long liveId, @RequestParam String key) {
+        String userId = getUserId();
+        String cacheKey = buildCacheKey(LiveKeysConstant.CACHE_LIVE_STORE, liveId, key);
+        return cacheAsideR(cacheKey, LiveKeysConstant.TTL_LIVE_GOODS,
+                () -> liveGoodsService.getStoreByLiveId(liveId, key, userId));
+    }
+
+    @GetMapping("/liveGoodsDetail/{productId}")
+    public R liveGoodsDetail(@PathVariable Long productId) {
+        String cacheKey = String.format(LiveKeysConstant.PRODUCT_DETAIL_CACHE, productId);
+        Map<String, Object> cachedData = redisCache.getCacheObject(cacheKey);
+
+        FsStoreProductScrm product;
+        List<FsStoreProductAttrScrm> productAttr;
+        List<FsStoreProductAttrValueScrm> productValues;
+
+        if (cachedData != null) {
+            product = (FsStoreProductScrm) cachedData.get("product");
+            productAttr = (List<FsStoreProductAttrScrm>) cachedData.get("productAttr");
+            productValues = (List<FsStoreProductAttrValueScrm>) cachedData.get("productValues");
+        } else {
+            product = fsStoreProductService.selectFsStoreProductById(productId);
+            if (product == null) {
+                return R.error("商品不存在或已下架");
+            }
+            productAttr = attrService.selectFsStoreProductAttrByProductId(productId);
+            productValues = attrValueService.selectFsStoreProductAttrValueByProductId(productId);
+
+            Map<String, Object> cacheData = new HashMap<>(3);
+            cacheData.put("product", product);
+            cacheData.put("productAttr", productAttr);
+            cacheData.put("productValues", productValues);
+            redisCache.setCacheObject(cacheKey, cacheData,
+                    LiveKeysConstant.PRODUCT_DETAIL_CACHE_EXPIRE, TimeUnit.SECONDS);
+        }
+
+        String userId = getUserId();
+        if (userId != null) {
+            saveFootprint(userId, product.getProductId());
+        }
+
+        Integer remainingPurchaseLimit = null;
+        int purchasedNum = 0;
+        if (product.getPurchaseLimit() != null && product.getPurchaseLimit() > 0) {
+            if (userId != null) {
+                FsStoreProductPurchaseLimitScrm purchaseLimit = purchaseLimitService.selectByProductIdAndUserId(
+                        product.getProductId(), Long.parseLong(userId));
+                if (purchaseLimit != null) {
+                    purchasedNum = purchaseLimit.getNum();
+                }
+                remainingPurchaseLimit = product.getPurchaseLimit() - purchasedNum;
+                if (remainingPurchaseLimit < 0) {
+                    remainingPurchaseLimit = 0;
+                }
+            } else {
+                remainingPurchaseLimit = product.getPurchaseLimit();
+            }
+        }
+
+        return R.ok()
+                .put("remainingPurchaseLimit", remainingPurchaseLimit)
+                .put("product", product)
+                .put("productAttr", productAttr)
+                .put("productValues", productValues);
+    }
+
+    @GetMapping("/showGoods/{liveId}")
+    public R showGoods(@PathVariable Long liveId) {
+        String cacheKey = buildCacheKey(LiveKeysConstant.CACHE_LIVE_SHOW_GOODS, liveId);
+        Object data = cacheAsideR(cacheKey, LiveKeysConstant.TTL_LIVE_GOODS,
+                () -> liveGoodsService.showGoods(liveId));
+        return R.ok().put("data", data);
+    }
+
+    @GetMapping(value = "/{goodsId}")
+    public AjaxResult getInfo(@PathVariable("goodsId") Long goodsId) {
+        String cacheKey = buildCacheKey("live:cache:goods:info:%s", goodsId);
+        LiveGoods goods = cacheAsideR(cacheKey, LiveKeysConstant.TTL_LIVE_GOODS,
+                () -> liveGoodsService.selectLiveGoodsByGoodsId(goodsId));
+        return AjaxResult.success(goods);
+    }
+
+    private void saveFootprint(String userId, Long productId) {
+        FsStoreProductRelationScrm query = new FsStoreProductRelationScrm();
+        query.setIsDel(0);
+        query.setUserId(Long.parseLong(userId));
+        query.setProductId(productId);
+        query.setType("foot");
+        List<FsStoreProductRelationScrm> relations = productRelationService.selectFsStoreProductRelationList(query);
+        if (relations != null && !relations.isEmpty()) {
+            FsStoreProductRelationScrm relation = relations.get(0);
+            relation.setUpdateTime(new Date());
+            productRelationService.updateFsStoreProductRelation(relation);
+        } else {
+            FsStoreProductRelationScrm relation = new FsStoreProductRelationScrm();
+            relation.setUserId(Long.parseLong(userId));
+            relation.setIsDel(0);
+            relation.setProductId(productId);
+            relation.setType("foot");
+            relation.setCreateTime(new Date());
+            relation.setUpdateTime(new Date());
+            productRelationService.insertFsStoreProductRelation(relation);
+        }
+    }
+}

+ 50 - 0
fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveLotteryController.java

@@ -0,0 +1,50 @@
+package com.fs.app.controller.cacheLive;
+
+import com.fs.app.annotation.Login;
+import com.fs.common.constant.LiveKeysConstant;
+import com.fs.common.core.domain.R;
+import com.fs.common.param.BaseQueryParam;
+import com.fs.live.param.LotteryPO;
+import com.fs.live.service.ILiveLotteryConfService;
+import com.fs.live.vo.LiveUserLotteryRecordVo;
+import com.github.pagehelper.PageHelper;
+import com.github.pagehelper.PageInfo;
+import org.springframework.beans.factory.annotation.Autowired;
+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.RestController;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/app/cacheLive/liveLottery")
+public class CacheLiveLotteryController extends CacheLiveBaseController {
+
+    @Autowired
+    private ILiveLotteryConfService liveLotteryConfService;
+
+    @Login
+    @PostMapping("/detail")
+    public R detail(@RequestBody LotteryPO lottery) {
+        String cacheKey = buildCacheKey("live:cache:lottery:detail:%s:%s",
+                lottery.getLiveId(), lottery.getLotteryId());
+        return cacheAsideR(cacheKey, LiveKeysConstant.TTL_LIVE_DATA,
+                () -> liveLotteryConfService.detail(lottery));
+    }
+
+    @Login
+    @GetMapping("/myLottery")
+    public R myLottery(BaseQueryParam param) {
+        String userId = getUserId();
+        String cacheKey = buildCacheKey("live:cache:lottery:my:%s:%s:%s",
+                userId, param.getPage(), param.getPageSize());
+        PageInfo<LiveUserLotteryRecordVo> listPageInfo = cacheAsideR(cacheKey, LiveKeysConstant.TTL_LIVE_DATA, () -> {
+            PageHelper.startPage(param.getPage(), param.getPageSize());
+            List<LiveUserLotteryRecordVo> list = liveLotteryConfService.myLottery(Long.parseLong(userId));
+            return new PageInfo<>(list);
+        });
+        return R.ok().put("data", listPageInfo);
+    }
+}

+ 101 - 0
fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveMergedOrderController.java

@@ -0,0 +1,101 @@
+package com.fs.app.controller.cacheLive;
+
+import com.fs.app.annotation.Login;
+import com.fs.common.constant.LiveKeysConstant;
+import com.fs.common.core.domain.R;
+import com.fs.hisStore.param.FsMyStoreOrderQueryParam;
+import com.fs.hisStore.param.MergedAfterSalesQueryParam;
+import com.fs.hisStore.service.IMergedOrderService;
+import com.fs.hisStore.vo.FsMergedOrderListQueryVO;
+import com.fs.hisStore.vo.MergedAfterSalesVO;
+import com.github.pagehelper.PageHelper;
+import com.github.pagehelper.PageInfo;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.List;
+
+@Api("合并订单缓存读接口")
+@RestController
+@RequestMapping("/app/cacheLive/order")
+public class CacheLiveMergedOrderController extends CacheLiveBaseController {
+
+    @Autowired
+    private IMergedOrderService mergedOrderService;
+
+    @Login
+    @ApiOperation("获取我的合并订单列表(商城订单+直播订单)")
+    @GetMapping("/getMyMergedOrderList")
+    public R getMyMergedOrderList(FsMyStoreOrderQueryParam param, HttpServletRequest request) {
+        if (param.getPage() == null || param.getPage() < 1) {
+            param.setPage(1);
+        }
+        if (param.getPageSize() == null || param.getPageSize() < 1) {
+            param.setPageSize(10);
+        }
+        String userId = getUserId();
+        String cacheKey = buildCacheKey(LiveKeysConstant.CACHE_USER_ORDER_LIST, userId,
+                "merged:" + param.getPage() + ":" + param.getPageSize());
+        PageInfo<FsMergedOrderListQueryVO> listPageInfo = cacheAsideR(cacheKey, LiveKeysConstant.TTL_USER_ORDER, () -> {
+            PageHelper.startPage(param.getPage(), param.getPageSize());
+            param.setUserId(Long.parseLong(userId));
+            List<FsMergedOrderListQueryVO> list = mergedOrderService.selectMergedOrderListVO(param);
+            return new PageInfo<>(list);
+        });
+        return R.ok().put("data", listPageInfo);
+    }
+
+    @Login
+    @ApiOperation("获取我的合并订单列表(商城订单+直播订单)")
+    @GetMapping("/getMyMergedOrderListNew")
+    public R getMyMergedOrderListNew(FsMyStoreOrderQueryParam param, HttpServletRequest request) {
+        if (param.getPage() == null || param.getPage() < 1) {
+            param.setPage(1);
+        }
+        if (param.getPageSize() == null || param.getPageSize() < 1) {
+            param.setPageSize(10);
+        }
+        String userId = getUserId();
+        String cacheKey = buildCacheKey(LiveKeysConstant.CACHE_USER_ORDER_LIST, userId,
+                "mergedNew:" + param.getPage() + ":" + param.getPageSize());
+        PageInfo<FsMergedOrderListQueryVO> listPageInfo = cacheAsideR(cacheKey, LiveKeysConstant.TTL_USER_ORDER, () -> {
+            PageHelper.startPage(param.getPage(), param.getPageSize());
+            param.setUserId(Long.parseLong(userId));
+            List<FsMergedOrderListQueryVO> list = mergedOrderService.selectMergedOrderListNewVO(param);
+            return new PageInfo<>(list);
+        });
+        return R.ok().put("data", listPageInfo);
+    }
+
+    @Login
+    @GetMapping("/getMergedAfterSalesList")
+    @ApiOperation(value = "获取合并售后列表", notes = "获取合并售后列表(商城售后+直播售后)")
+    public R getMergedAfterSalesList(MergedAfterSalesQueryParam param) {
+        String userId = getUserId();
+        String cacheKey = buildCacheKey(LiveKeysConstant.CACHE_USER_ORDER_LIST, userId,
+                "mergedAfterSales:" + param.getPage() + ":" + param.getPageSize());
+        PageInfo<MergedAfterSalesVO> listPageInfo = cacheAsideR(cacheKey, LiveKeysConstant.TTL_USER_ORDER, () -> {
+            PageHelper.startPage(param.getPage(), param.getPageSize());
+            param.setUserId(Long.parseLong(userId));
+            List<MergedAfterSalesVO> list = mergedOrderService.selectMergedAfterSalesList(param);
+            return new PageInfo<>(list);
+        });
+        return R.ok().put("data", listPageInfo);
+    }
+
+    @GetMapping("/getMergedAfterSalesDetails")
+    @ApiOperation(value = "获取合并售后详情", notes = "获取合并售后详情")
+    public R getMergedAfterSalesDetails(@RequestParam("salesId") Long salesId,
+                                        @RequestParam("afterSalesType") Integer afterSalesType) {
+        String cacheKey = buildCacheKey("live:cache:merged:afterSales:%s:%s", salesId, afterSalesType);
+        MergedAfterSalesVO vo = cacheAsideR(cacheKey, LiveKeysConstant.TTL_USER_ORDER,
+                () -> mergedOrderService.selectMergedAfterSalesById(salesId, afterSalesType));
+        return R.ok().put("sales", vo);
+    }
+}

+ 38 - 0
fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveMsgController.java

@@ -0,0 +1,38 @@
+package com.fs.app.controller.cacheLive;
+
+import com.fs.app.annotation.Login;
+import com.fs.common.constant.LiveKeysConstant;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.live.domain.LiveMsg;
+import com.fs.live.service.ILiveMsgService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/app/cacheLive/liveMsg")
+public class CacheLiveMsgController extends CacheLiveBaseController {
+
+    @Autowired
+    private ILiveMsgService liveMsgService;
+
+    @GetMapping("/list")
+    public TableDataInfo list(LiveMsg liveMsg) {
+        startPage();
+        List<LiveMsg> list = liveMsgService.selectLiveMsgList(liveMsg);
+        return getDataTable(list);
+    }
+
+    @Login
+    @GetMapping("/myList")
+    public TableDataInfo myList(LiveMsg liveMsg) {
+        String userId = getUserId();
+        startPage();
+        liveMsg.setUserId(Long.parseLong(userId));
+        List<LiveMsg> list = liveMsgService.selectLiveMsgList(liveMsg);
+        return getDataTable(list);
+    }
+}

+ 219 - 0
fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveOrderController.java

@@ -0,0 +1,219 @@
+package com.fs.app.controller.cacheLive;
+
+import cn.hutool.core.util.ObjectUtil;
+import cn.hutool.json.JSONUtil;
+import com.fs.app.annotation.Login;
+import com.fs.common.constant.LiveKeysConstant;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.domain.R;
+import com.fs.common.utils.ParseUtils;
+import com.fs.common.utils.ServletUtils;
+import com.fs.hisStore.enums.OrderInfoEnum;
+import com.fs.live.domain.LiveOrder;
+import com.fs.live.param.FsMyLiveOrderQueryParam;
+import com.fs.live.service.ILiveAfterSalesService;
+import com.fs.live.service.ILiveOrderItemService;
+import com.fs.live.service.ILiveOrderService;
+import com.fs.live.vo.FsMyLiveOrderListQueryVO;
+import com.fs.live.vo.LiveOrderItemListUVO;
+import com.fs.live.vo.LiveOrderListVo;
+import com.fs.store.config.StoreConfig;
+import com.fs.system.service.ISysConfigService;
+import com.github.pagehelper.PageHelper;
+import com.github.pagehelper.PageInfo;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.servlet.http.HttpServletRequest;
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.GregorianCalendar;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/app/cacheLive/liveOrder")
+public class CacheLiveOrderController extends CacheLiveBaseController {
+
+    @Autowired
+    private ILiveOrderService orderService;
+    @Autowired
+    private ILiveOrderItemService liveOrderItemService;
+    @Autowired
+    private ISysConfigService configService;
+    @Autowired
+    private ILiveAfterSalesService liveAfterSalesService;
+
+    @Login
+    @GetMapping("/list")
+    public R list(LiveOrder liveOrder) {
+        String userId = getUserId();
+        String cacheKey = buildCacheKey(LiveKeysConstant.CACHE_USER_ORDER_LIST, userId,
+                "list:" + ServletUtils.getParameter("pageNum") + ":" + ServletUtils.getParameter("pageSize"));
+        PageInfo<LiveOrderListVo> result = cacheAsideR(cacheKey, LiveKeysConstant.TTL_USER_ORDER, () -> {
+            PageHelper.startPage(
+                    Integer.parseInt(ServletUtils.getParameter("pageNum")),
+                    Integer.parseInt(ServletUtils.getParameter("pageSize")));
+            liveOrder.setUserId(userId);
+            List<LiveOrderListVo> list = orderService.selectLiveOrderListVo(liveOrder);
+            return new PageInfo<>(list);
+        });
+        return R.ok().put("data", result);
+    }
+
+    @GetMapping(value = "/liveOrderUser/{liveId}")
+    public R liveOrderUser(@PathVariable String liveId) {
+        String cacheKey = buildCacheKey("live:cache:order:user:%s", liveId);
+        return cacheAsideR(cacheKey, LiveKeysConstant.TTL_USER_ORDER,
+                () -> orderService.liveOrderUser(liveId));
+    }
+
+    @Login
+    @ApiOperation("获取我的订单列表")
+    @GetMapping("/getMyStoreOrderList")
+    public R getMyStoreOrderList(FsMyLiveOrderQueryParam param, HttpServletRequest request) {
+        String userId = getUserId();
+        String cacheKey = buildCacheKey(LiveKeysConstant.CACHE_USER_ORDER_LIST, userId,
+                "myStore:" + param.getPageNum() + ":" + param.getPageSize());
+        PageInfo<FsMyLiveOrderListQueryVO> listPageInfo = cacheAsideR(cacheKey, LiveKeysConstant.TTL_USER_ORDER, () -> {
+            PageHelper.startPage(param.getPageNum(), param.getPageSize());
+            param.setUserId(Long.parseLong(userId));
+            List<FsMyLiveOrderListQueryVO> list = orderService.selectFsMyLiveOrderListVO(param);
+            return new PageInfo<>(list);
+        });
+        return R.ok().put("data", listPageInfo);
+    }
+
+    @Login
+    @ApiOperation("获取销售公司订单列表")
+    @GetMapping("/getCompanyStoreOrderList")
+    public R getCompanyStoreOrderList(FsMyLiveOrderQueryParam param, HttpServletRequest request) {
+        String userId = getUserId();
+        String cacheKey = buildCacheKey(LiveKeysConstant.CACHE_USER_ORDER_LIST, userId,
+                "companyStore:" + param.getPageNum() + ":" + param.getPageSize());
+        PageInfo<FsMyLiveOrderListQueryVO> listPageInfo = cacheAsideR(cacheKey, LiveKeysConstant.TTL_USER_ORDER, () -> {
+            PageHelper.startPage(param.getPageNum(), param.getPageSize());
+            List<FsMyLiveOrderListQueryVO> list = orderService.selectFsCompanyLiveOrderListVO(param);
+            return new PageInfo<>(list);
+        });
+        return R.ok().put("data", listPageInfo);
+    }
+
+    @Login
+    @ApiOperation("获取我的订单详情")
+    @GetMapping("/getMyStoreOrderById")
+    public R getMyStoreOrderById(@RequestParam("orderId") Long orderId, HttpServletRequest request) {
+        String userId = getUserId();
+        String cacheKey = buildCacheKey(LiveKeysConstant.CACHE_USER_ORDER_LIST, userId, "myDetail:" + orderId);
+        Map<String, Object> data = cacheAsideR(cacheKey, LiveKeysConstant.TTL_USER_ORDER, () -> {
+            LiveOrder order = orderService.selectLiveOrderByOrderId(String.valueOf(orderId));
+            order.setUserPhone(ParseUtils.parsePhone(order.getUserPhone()));
+            order.setUserAddress(ParseUtils.parseIdCard(order.getUserAddress()));
+            List<LiveOrderItemListUVO> list = liveOrderItemService.selectLiveOrderItemListUVOByOrderId(orderId);
+            Calendar calendar = Calendar.getInstance();
+            calendar.setTime(order.getCreateTime());
+            String json = configService.selectConfigByKey("his.store");
+            StoreConfig config = JSONUtil.toBean(json, StoreConfig.class);
+            calendar.add(Calendar.MINUTE, config.getUnPayTime());
+            SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
+            String payLimitTime = format.format(calendar.getTime());
+            Integer isAfterSales = 0;
+            if (order.getStatus().equals(OrderInfoEnum.STATUS_3.getValue())) {
+                isAfterSales = 1;
+                if (order.getFinishTime() != null) {
+                    if (config.getStoreAfterSalesDay() != null && config.getStoreAfterSalesDay() > 0) {
+                        Calendar calendarAfterSales = new GregorianCalendar();
+                        calendarAfterSales.setTime(order.getFinishTime());
+                        calendarAfterSales.add(Calendar.DATE, config.getStoreAfterSalesDay());
+                        if (calendarAfterSales.getTime().getTime() < new Date().getTime()) {
+                            isAfterSales = 0;
+                        }
+                    }
+                }
+            } else if (order.getStatus() == 1 || order.getStatus() == 2) {
+                isAfterSales = 1;
+            }
+            Map<String, Object> result = new HashMap<>();
+            result.put("isAfterSales", isAfterSales);
+            result.put("order", order);
+            result.put("items", list);
+            result.put("payLimitTime", payLimitTime);
+            return result;
+        });
+        return R.ok()
+                .put("isAfterSales", data.get("isAfterSales"))
+                .put("order", data.get("order"))
+                .put("items", data.get("items"))
+                .put("payLimitTime", data.get("payLimitTime"));
+    }
+
+    @Login
+    @ApiOperation("获取订单")
+    @GetMapping("/getStoreOrderById")
+    public R getStoreOrderById(@RequestParam("orderId") Long orderId, HttpServletRequest request) {
+        String userId = getUserId();
+        String cacheKey = buildCacheKey(LiveKeysConstant.CACHE_USER_ORDER_LIST, userId, "storeDetail:" + orderId);
+        Map<String, Object> data = cacheAsideR(cacheKey, LiveKeysConstant.TTL_USER_ORDER, () -> {
+            LiveOrder order = orderService.selectLiveOrderByOrderId(String.valueOf(orderId));
+            order.setUserPhone(ParseUtils.parsePhone(order.getUserPhone()));
+            order.setUserAddress(ParseUtils.parseIdCard(order.getUserAddress()));
+            String json = configService.selectConfigByKey("his.store");
+            StoreConfig config = JSONUtil.toBean(json, StoreConfig.class);
+            Calendar calendar = Calendar.getInstance();
+            calendar.setTime(order.getCreateTime());
+            calendar.add(Calendar.MINUTE, config.getUnPayTime());
+            SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
+            String payLimitTime = format.format(calendar.getTime());
+            Map<String, Object> result = new HashMap<>();
+            result.put("order", order);
+            result.put("payLimitTime", payLimitTime);
+            return result;
+        });
+        return R.ok()
+                .put("order", data.get("order"))
+                .put("payLimitTime", data.get("payLimitTime"));
+    }
+
+    @Login
+    @GetMapping(value = "/info/{orderId}")
+    public AjaxResult getInfo(@PathVariable("orderId") String orderId) {
+        String userId = getUserId();
+        String cacheKey = buildCacheKey(LiveKeysConstant.CACHE_USER_ORDER_LIST, userId, "info:" + orderId);
+        LiveOrder liveOrder = cacheAsideR(cacheKey, LiveKeysConstant.TTL_USER_ORDER,
+                () -> orderService.selectLiveOrderByOrderId(orderId));
+        if (ObjectUtil.isNotEmpty(liveOrder)) {
+            liveOrder.setTotalPrice(liveOrder.getPayPrice()
+                    .subtract(liveOrder.getPayPostage())
+                    .add(liveOrder.getDiscountMoney()));
+        }
+        return AjaxResult.success(liveOrder);
+    }
+
+    @Login
+    @ApiOperation("获取订单总数")
+    @GetMapping("/getOrderCount")
+    public R getOrderCount(HttpServletRequest request) {
+        String userId = getUserId();
+        String cacheKey = buildCacheKey(LiveKeysConstant.CACHE_USER_ORDER_LIST, userId, "count");
+        Map<String, Integer> counts = cacheAsideR(cacheKey, LiveKeysConstant.TTL_USER_ORDER, () -> {
+            Map<String, Integer> result = new HashMap<>();
+            result.put("count0", orderService.selectFsStoreOrderCount(Long.parseLong(userId), 0));
+            result.put("count1", orderService.selectFsStoreOrderCount(Long.parseLong(userId), 1));
+            result.put("count2", orderService.selectFsStoreOrderCount(Long.parseLong(userId), 2));
+            result.put("afterSalesCount", liveAfterSalesService.selectLiveAfterSalesCount(Long.parseLong(userId), 0));
+            return result;
+        });
+        return R.ok()
+                .put("count0", counts.get("count0"))
+                .put("count1", counts.get("count1"))
+                .put("count2", counts.get("count2"))
+                .put("afterSalesCount", counts.get("afterSalesCount"));
+    }
+}

+ 35 - 0
fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveOrderLogsController.java

@@ -0,0 +1,35 @@
+package com.fs.app.controller.cacheLive;
+
+import com.fs.common.constant.LiveKeysConstant;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.live.domain.LiveOrderLogs;
+import com.fs.live.service.ILiveOrderLogsService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/app/cacheLive/liveOrderLogs")
+public class CacheLiveOrderLogsController extends CacheLiveBaseController {
+
+    @Autowired
+    private ILiveOrderLogsService liveOrderLogsService;
+
+    @GetMapping("/list")
+    public TableDataInfo list(LiveOrderLogs liveOrderLogs) {
+        String cacheKey = buildCacheKey("live:cache:orderLogs:list:%s", liveOrderLogs.getOrderId());
+        List<LiveOrderLogs> list = cacheAsideR(cacheKey, LiveKeysConstant.TTL_USER_ORDER, () -> {
+            startPage();
+            return liveOrderLogsService.selectLiveOrderLogsList(liveOrderLogs);
+        });
+        TableDataInfo rspData = new TableDataInfo();
+        rspData.setCode(200);
+        rspData.setMsg("查询成功");
+        rspData.setRows(list);
+        rspData.setTotal(list.size());
+        return rspData;
+    }
+}

+ 36 - 0
fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveOrderPaymentController.java

@@ -0,0 +1,36 @@
+package com.fs.app.controller.cacheLive;
+
+import com.fs.common.constant.LiveKeysConstant;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.live.domain.LiveOrderPayment;
+import com.fs.live.service.ILiveOrderPaymentService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/app/cacheLive/payment")
+public class CacheLiveOrderPaymentController extends CacheLiveBaseController {
+
+    @Autowired
+    private ILiveOrderPaymentService liveOrderPaymentService;
+
+    @GetMapping("/list")
+    public TableDataInfo list(LiveOrderPayment liveOrderPayment) {
+        String cacheKey = buildCacheKey("live:cache:payment:list:%s:%s",
+                liveOrderPayment.getBusinessId(), liveOrderPayment.getUserId());
+        List<LiveOrderPayment> list = cacheAsideR(cacheKey, LiveKeysConstant.TTL_USER_ORDER, () -> {
+            startPage();
+            return liveOrderPaymentService.selectLiveOrderPaymentList(liveOrderPayment);
+        });
+        TableDataInfo rspData = new TableDataInfo();
+        rspData.setCode(200);
+        rspData.setMsg("查询成功");
+        rspData.setRows(list);
+        rspData.setTotal(list.size());
+        return rspData;
+    }
+}

+ 54 - 0
fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveRedController.java

@@ -0,0 +1,54 @@
+package com.fs.app.controller.cacheLive;
+
+import com.fs.app.annotation.Login;
+import com.fs.common.constant.LiveKeysConstant;
+import com.fs.common.core.domain.R;
+import com.fs.common.utils.ServletUtils;
+import com.fs.live.domain.LiveUserRedRecord;
+import com.fs.live.service.ILiveUserRedRecordService;
+import com.github.pagehelper.PageHelper;
+import com.github.pagehelper.PageInfo;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@Slf4j
+@RestController
+@RequestMapping("/app/cacheLive/liveRed")
+public class CacheLiveRedController extends CacheLiveBaseController {
+
+    @Autowired
+    private ILiveUserRedRecordService liveUserRedRecordService;
+
+    @Login
+    @GetMapping("/list")
+    public R list() {
+        try {
+            String userId = getUserId();
+            if (userId == null || userId.isEmpty()) {
+                return R.error("用户未登录");
+            }
+            String pageNum = ServletUtils.getParameter("pageNum");
+            String pageSize = ServletUtils.getParameter("pageSize");
+            int page = pageNum != null && !pageNum.isEmpty() ? Integer.parseInt(pageNum) : 1;
+            int size = pageSize != null && !pageSize.isEmpty() ? Integer.parseInt(pageSize) : 10;
+
+            String cacheKey = buildCacheKey(LiveKeysConstant.CACHE_USER_ORDER_LIST, userId, page + ":" + size);
+            PageInfo<LiveUserRedRecord> pageInfo = cacheAsideR(cacheKey, LiveKeysConstant.TTL_USER_ORDER, () -> {
+                LiveUserRedRecord query = new LiveUserRedRecord();
+                query.setUserId(Long.parseLong(userId));
+                PageHelper.startPage(page, size);
+                List<LiveUserRedRecord> list = liveUserRedRecordService.selectLiveUserRedRecordList(query);
+                return new PageInfo<>(list);
+            });
+            return R.ok().put("data", pageInfo);
+        } catch (Exception e) {
+            log.error("查询用户红包记录失败", e);
+            return R.error("查询失败:" + e.getMessage());
+        }
+    }
+}

+ 30 - 0
fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveRedPacketLogController.java

@@ -0,0 +1,30 @@
+package com.fs.app.controller.cacheLive;
+
+import com.fs.app.annotation.Login;
+import com.fs.common.constant.LiveKeysConstant;
+import com.fs.common.core.domain.R;
+import com.fs.live.service.ILiveWatchUserService;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/app/cacheLive/liveRedPacketLog")
+public class CacheLiveRedPacketLogController extends CacheLiveBaseController {
+
+    @Autowired
+    private ILiveWatchUserService liveWatchUserService;
+
+    @Login
+    @ApiOperation("获取用户看课的直播状态")
+    @GetMapping("/getLiveStatusByUserID/{liveId}")
+    public R getLiveStatusByUserID(@PathVariable("liveId") Long liveId) {
+        long userId = Long.parseLong(getUserId());
+        String cacheKey = buildCacheKey("live:cache:redPacket:status:%s:%s", liveId, userId);
+        return cacheAsideR(cacheKey, LiveKeysConstant.TTL_LIVE_DATA,
+                () -> liveWatchUserService.getLiveStatusByUserID(liveId, userId));
+    }
+}

+ 36 - 0
fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveUserFavoriteController.java

@@ -0,0 +1,36 @@
+package com.fs.app.controller.cacheLive;
+
+import com.fs.common.constant.LiveKeysConstant;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.live.domain.LiveUserFavorite;
+import com.fs.live.service.ILiveUserFavoriteService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/app/cacheLive/liveUserFavorite")
+public class CacheLiveUserFavoriteController extends CacheLiveBaseController {
+
+    @Autowired
+    private ILiveUserFavoriteService liveUserFavoriteService;
+
+    @GetMapping("/list")
+    public TableDataInfo list(LiveUserFavorite liveUserFavorite) {
+        String cacheKey = buildCacheKey("live:cache:userFavorite:list:%s:%s",
+                liveUserFavorite.getLiveId(), liveUserFavorite.getUserId());
+        List<LiveUserFavorite> list = cacheAsideR(cacheKey, LiveKeysConstant.TTL_LIVE_DATA, () -> {
+            startPage();
+            return liveUserFavoriteService.selectLiveUserFavoriteList(liveUserFavorite);
+        });
+        TableDataInfo rspData = new TableDataInfo();
+        rspData.setCode(200);
+        rspData.setMsg("查询成功");
+        rspData.setRows(list);
+        rspData.setTotal(list.size());
+        return rspData;
+    }
+}

+ 36 - 0
fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveUserFollowController.java

@@ -0,0 +1,36 @@
+package com.fs.app.controller.cacheLive;
+
+import com.fs.common.constant.LiveKeysConstant;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.live.domain.LiveUserFollow;
+import com.fs.live.service.ILiveUserFollowService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/app/cacheLive/liveUserFollow")
+public class CacheLiveUserFollowController extends CacheLiveBaseController {
+
+    @Autowired
+    private ILiveUserFollowService liveUserFollowService;
+
+    @GetMapping("/list")
+    public TableDataInfo list(LiveUserFollow liveUserFollow) {
+        String cacheKey = buildCacheKey("live:cache:userFollow:list:%s:%s",
+                liveUserFollow.getLiveId(), liveUserFollow.getUserId());
+        List<LiveUserFollow> list = cacheAsideR(cacheKey, LiveKeysConstant.TTL_LIVE_DATA, () -> {
+            startPage();
+            return liveUserFollowService.selectLiveUserFollowList(liveUserFollow);
+        });
+        TableDataInfo rspData = new TableDataInfo();
+        rspData.setCode(200);
+        rspData.setMsg("查询成功");
+        rspData.setRows(list);
+        rspData.setTotal(list.size());
+        return rspData;
+    }
+}

+ 36 - 0
fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveUserLikeController.java

@@ -0,0 +1,36 @@
+package com.fs.app.controller.cacheLive;
+
+import com.fs.common.constant.LiveKeysConstant;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.live.domain.LiveUserLike;
+import com.fs.live.service.ILiveUserLikeService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/app/cacheLive/liveUserLike")
+public class CacheLiveUserLikeController extends CacheLiveBaseController {
+
+    @Autowired
+    private ILiveUserLikeService liveUserLikeService;
+
+    @GetMapping("/list")
+    public TableDataInfo list(LiveUserLike liveUserLike) {
+        String cacheKey = buildCacheKey("live:cache:userLike:list:%s:%s",
+                liveUserLike.getLiveId(), liveUserLike.getUserId());
+        List<LiveUserLike> list = cacheAsideR(cacheKey, LiveKeysConstant.TTL_LIVE_DATA, () -> {
+            startPage();
+            return liveUserLikeService.selectLiveUserLikeList(liveUserLike);
+        });
+        TableDataInfo rspData = new TableDataInfo();
+        rspData.setCode(200);
+        rspData.setMsg("查询成功");
+        rspData.setRows(list);
+        rspData.setTotal(list.size());
+        return rspData;
+    }
+}

+ 38 - 0
fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveVideoController.java

@@ -0,0 +1,38 @@
+package com.fs.app.controller.cacheLive;
+
+import com.fs.common.constant.LiveKeysConstant;
+import com.fs.common.core.domain.R;
+import com.fs.live.domain.LiveVideo;
+import com.fs.live.mapper.LiveVideoMapper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/app/cacheLive/liveVideo")
+public class CacheLiveVideoController extends CacheLiveBaseController {
+
+    @Autowired
+    private LiveVideoMapper liveVideoMapper;
+
+    @GetMapping("/{liveId}")
+    public R getVideo(@PathVariable("liveId") Long liveId, @RequestParam("videoType") Integer videoType) {
+        String cacheKey = buildCacheKey(LiveKeysConstant.CACHE_LIVE_VIDEO, liveId, videoType);
+        LiveVideo liveVideo = cacheAsideR(cacheKey, LiveKeysConstant.TTL_LIVE_INFO, () -> {
+            List<LiveVideo> liveVideos = liveVideoMapper.selectByLiveIdAndType(liveId, videoType);
+            if (liveVideos == null || liveVideos.isEmpty()) {
+                return null;
+            }
+            return liveVideos.get(0);
+        });
+        if (liveVideo == null) {
+            return R.error("没有该视频");
+        }
+        return R.ok().put("liveVideos", liveVideo);
+    }
+}

+ 70 - 0
fs-user-app/src/main/java/com/fs/app/controller/cacheLive/CacheLiveWatchUserController.java

@@ -0,0 +1,70 @@
+package com.fs.app.controller.cacheLive;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.json.JSONUtil;
+import com.fs.common.constant.LiveKeysConstant;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.live.domain.LiveWatchUser;
+import com.fs.live.service.ILiveWatchUserService;
+import com.fs.live.vo.LiveWatchUserVO;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+@Slf4j
+@RestController
+@RequestMapping("/app/cacheLive/liveWatchUser")
+public class CacheLiveWatchUserController extends CacheLiveBaseController {
+
+    @Autowired
+    private ILiveWatchUserService liveWatchUserService;
+
+    @GetMapping("/watchUserList")
+    public TableDataInfo watchUserList(LiveWatchUser param) {
+        String setKey = String.format(LiveKeysConstant.LIVE_WATCH_USERS, param.getLiveId());
+        Map<Object, Object> hashEntries = redisCache.hashEntries(setKey);
+        List<LiveWatchUserVO> liveWatchUserVOS;
+        if (CollUtil.isEmpty(hashEntries)) {
+            liveWatchUserVOS = liveWatchUserService.asyncToCache(param.getLiveId());
+        } else {
+            liveWatchUserVOS = hashEntries.values().stream()
+                    .map(value -> {
+                        try {
+                            return JSONUtil.toBean(JSONUtil.parseObj(value), LiveWatchUserVO.class);
+                        } catch (Exception e) {
+                            log.error("反序列化LiveWatchUserVO失败: {}", value, e);
+                            return null;
+                        }
+                    })
+                    .filter(Objects::nonNull)
+                    .collect(Collectors.toList());
+        }
+        if (liveWatchUserVOS == null) {
+            liveWatchUserVOS = Collections.emptyList();
+        }
+        TableDataInfo rspData = new TableDataInfo();
+        rspData.setCode(200);
+        rspData.setMsg("查询成功");
+        rspData.setRows(liveWatchUserVOS);
+        rspData.setTotal(liveWatchUserVOS.size());
+        return rspData;
+    }
+
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id) {
+        String cacheKey = buildCacheKey("live:cache:watch:user:%s", id);
+        LiveWatchUser user = cacheAsideR(cacheKey, LiveKeysConstant.TTL_LIVE_DATA,
+                () -> liveWatchUserService.selectLiveWatchUserById(id));
+        return AjaxResult.success(user);
+    }
+}

+ 8 - 2
pom.xml

@@ -249,10 +249,15 @@
                 <version>${fs.version}</version>
             </dependency>
 
-            <!-- WebSocket服务-->
             <dependency>
                 <groupId>com.fs</groupId>
-                <artifactId>fs-websocket</artifactId>
+                <artifactId>fs-live-app</artifactId>
+                <version>${fs.version}</version>
+            </dependency>
+
+            <dependency>
+                <groupId>com.fs</groupId>
+                <artifactId>fs-live-ws</artifactId>
                 <version>${fs.version}</version>
             </dependency>
 
@@ -313,6 +318,7 @@
         <module>fs-common-api</module>
         <module>fs-company-app</module>
         <module>fs-live-app</module>
+        <module>fs-live-ws</module>
         <module>fs-ad-api</module>
         <module>fs-qw-voice</module>
         <module>fs-qwhook-msg</module>