ソースを参照

Merge branch 'master' of http://1.14.104.71:10880/root/ylrz_his_scrm_java

# Conflicts:
#	fs-service/src/main/java/com/fs/company/mapper/CompanyUserMapper.java
#	fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java
#	fs-service/src/main/resources/mapper/company/CompanyUserMapper.xml
caoliqin 1 週間 前
コミット
dfdf5df841
100 ファイル変更7926 行追加35 行削除
  1. 14 0
      fs-admin/src/main/java/com/fs/company/controller/CompanyMenuController.java
  2. 109 11
      fs-admin/src/main/java/com/fs/company/controller/CompanyRoleController.java
  3. 56 3
      fs-admin/src/main/java/com/fs/company/controller/CompanyUserAllController.java
  4. 2 2
      fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreOrderScrmController.java
  5. 3 0
      fs-admin/src/main/java/com/fs/qw/FsCourseTask.java
  6. 1 0
      fs-admin/src/main/java/com/fs/qw/qwTask/qwTask.java
  7. 6 0
      fs-common/src/main/java/com/fs/common/constant/LiveKeysConstant.java
  8. 35 0
      fs-company-app/src/main/java/com/fs/app/controller/CompanyUserController.java
  9. 123 0
      fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSopCallKnowledgeBaseController.java
  10. 129 0
      fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSopCallRoleController.java
  11. 98 0
      fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSopCallStaffModelsController.java
  12. 209 0
      fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSopCallUserController.java
  13. 8 0
      fs-company/src/main/java/com/fs/company/controller/company/CompanyUserController.java
  14. 6 1
      fs-company/src/main/java/com/fs/company/controller/course/qw/FsQwCourseWatchLogController.java
  15. 29 0
      fs-company/src/main/java/com/fs/company/controller/qw/QwExternalContactController.java
  16. 70 14
      fs-company/src/main/java/com/fs/company/controller/qw/QwSopTempVoiceController.java
  17. 1 0
      fs-ipad-task/src/main/java/com/fs/app/service/IpadSendServer.java
  18. 88 0
      fs-live-ws/pom.xml
  19. 23 0
      fs-live-ws/src/main/java/com/fs/FsLiveWsApplication.java
  20. 182 0
      fs-live-ws/src/main/java/com/fs/framework/aspectj/DataScopeAspect.java
  21. 73 0
      fs-live-ws/src/main/java/com/fs/framework/aspectj/DataSourceAspect.java
  22. 93 0
      fs-live-ws/src/main/java/com/fs/framework/aspectj/LiveWatchUserAspect.java
  23. 245 0
      fs-live-ws/src/main/java/com/fs/framework/aspectj/LogAspect.java
  24. 117 0
      fs-live-ws/src/main/java/com/fs/framework/aspectj/RateLimiterAspect.java
  25. 58 0
      fs-live-ws/src/main/java/com/fs/framework/aspectj/lock/DistributeLock.java
  26. 113 0
      fs-live-ws/src/main/java/com/fs/framework/aspectj/lock/DistributeLockAspect.java
  27. 13 0
      fs-live-ws/src/main/java/com/fs/framework/aspectj/lock/DistributeLockConstant.java
  28. 24 0
      fs-live-ws/src/main/java/com/fs/framework/aspectj/lock/DistributeLockException.java
  29. 31 0
      fs-live-ws/src/main/java/com/fs/framework/config/ApplicationConfig.java
  30. 85 0
      fs-live-ws/src/main/java/com/fs/framework/config/CaptchaConfig.java
  31. 98 0
      fs-live-ws/src/main/java/com/fs/framework/config/DataSourceConfig.java
  32. 123 0
      fs-live-ws/src/main/java/com/fs/framework/config/DruidConfig.java
  33. 72 0
      fs-live-ws/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  34. 59 0
      fs-live-ws/src/main/java/com/fs/framework/config/FilterConfig.java
  35. 76 0
      fs-live-ws/src/main/java/com/fs/framework/config/KaptchaTextCreator.java
  36. 150 0
      fs-live-ws/src/main/java/com/fs/framework/config/MyBatisConfig.java
  37. 159 0
      fs-live-ws/src/main/java/com/fs/framework/config/RedisConfig.java
  38. 65 0
      fs-live-ws/src/main/java/com/fs/framework/config/ResourcesConfig.java
  39. 50 0
      fs-live-ws/src/main/java/com/fs/framework/config/SecurityConfig.java
  40. 33 0
      fs-live-ws/src/main/java/com/fs/framework/config/ServerConfig.java
  41. 121 0
      fs-live-ws/src/main/java/com/fs/framework/config/SwaggerConfig.java
  42. 63 0
      fs-live-ws/src/main/java/com/fs/framework/config/ThreadPoolConfig.java
  43. 77 0
      fs-live-ws/src/main/java/com/fs/framework/config/properties/DruidProperties.java
  44. 27 0
      fs-live-ws/src/main/java/com/fs/framework/datasource/DynamicDataSource.java
  45. 45 0
      fs-live-ws/src/main/java/com/fs/framework/datasource/DynamicDataSourceContextHolder.java
  46. 56 0
      fs-live-ws/src/main/java/com/fs/framework/interceptor/RepeatSubmitInterceptor.java
  47. 126 0
      fs-live-ws/src/main/java/com/fs/framework/interceptor/impl/SameUrlDataInterceptor.java
  48. 56 0
      fs-live-ws/src/main/java/com/fs/framework/manager/AsyncManager.java
  49. 40 0
      fs-live-ws/src/main/java/com/fs/framework/manager/ShutdownManager.java
  50. 103 0
      fs-live-ws/src/main/java/com/fs/framework/manager/factory/AsyncFactory.java
  51. 72 0
      fs-live-ws/src/main/java/com/fs/live/task/LiveCompletionPointsTask.java
  52. 1125 0
      fs-live-ws/src/main/java/com/fs/live/task/Task.java
  53. 17 0
      fs-live-ws/src/main/java/com/fs/live/vo/LotteryVo.java
  54. 26 0
      fs-live-ws/src/main/java/com/fs/live/ws/bean/WsSendMsgVo.java
  55. 27 0
      fs-live-ws/src/main/java/com/fs/live/ws/config/LiveWsProperties.java
  56. 22 0
      fs-live-ws/src/main/java/com/fs/live/ws/config/RedisPubSubConfig.java
  57. 14 0
      fs-live-ws/src/main/java/com/fs/live/ws/config/TomcatWebSocketConfig.java
  58. 24 0
      fs-live-ws/src/main/java/com/fs/live/ws/constant/WsAttrConstant.java
  59. 14 0
      fs-live-ws/src/main/java/com/fs/live/ws/constant/WsRedisKeys.java
  60. 103 0
      fs-live-ws/src/main/java/com/fs/live/ws/controller/WsEndpointController.java
  61. 84 0
      fs-live-ws/src/main/java/com/fs/live/ws/handler/WsAuthHandler.java
  62. 30 0
      fs-live-ws/src/main/java/com/fs/live/ws/handler/WsHeartbeatHandler.java
  63. 91 0
      fs-live-ws/src/main/java/com/fs/live/ws/handler/WsLiveChatHandler.java
  64. 72 0
      fs-live-ws/src/main/java/com/fs/live/ws/listener/LiveWsRoomMessageListener.java
  65. 105 0
      fs-live-ws/src/main/java/com/fs/live/ws/netty/NettyWsServer.java
  66. 13 0
      fs-live-ws/src/main/java/com/fs/live/ws/service/ILiveWsBroadcastService.java
  67. 13 0
      fs-live-ws/src/main/java/com/fs/live/ws/service/ILiveWsConnectionService.java
  68. 8 0
      fs-live-ws/src/main/java/com/fs/live/ws/service/ILiveWsMessageService.java
  69. 15 0
      fs-live-ws/src/main/java/com/fs/live/ws/service/IWsNodeRegistryService.java
  70. 59 0
      fs-live-ws/src/main/java/com/fs/live/ws/service/LiveWsRoomBroadcastFacade.java
  71. 423 0
      fs-live-ws/src/main/java/com/fs/live/ws/service/impl/LiveWsAdminMessageHandler.java
  72. 140 0
      fs-live-ws/src/main/java/com/fs/live/ws/service/impl/LiveWsAutoTaskHandler.java
  73. 65 0
      fs-live-ws/src/main/java/com/fs/live/ws/service/impl/LiveWsBroadcastServiceImpl.java
  74. 121 0
      fs-live-ws/src/main/java/com/fs/live/ws/service/impl/LiveWsConnectionServiceImpl.java
  75. 109 0
      fs-live-ws/src/main/java/com/fs/live/ws/service/impl/LiveWsMessageServiceImpl.java
  76. 95 0
      fs-live-ws/src/main/java/com/fs/live/ws/service/impl/WsNodeRegistryServiceImpl.java
  77. 52 0
      fs-live-ws/src/main/java/com/fs/live/ws/session/ChannelWsSender.java
  78. 104 0
      fs-live-ws/src/main/java/com/fs/live/ws/session/LiveWsRoomManager.java
  79. 66 0
      fs-live-ws/src/main/java/com/fs/live/ws/session/SessionWsSender.java
  80. 13 0
      fs-live-ws/src/main/java/com/fs/live/ws/session/WsSender.java
  81. 66 0
      fs-live-ws/src/main/java/com/fs/live/ws/task/LiveWsLikeBroadcastTask.java
  82. 52 0
      fs-live-ws/src/main/java/com/fs/live/ws/task/LiveWsUserCountBroadcastTask.java
  83. 124 0
      fs-live-ws/src/main/java/com/fs/live/ws/tomcat/TomcatLiveWsEndpoint.java
  84. 44 0
      fs-live-ws/src/main/java/com/fs/live/ws/tomcat/TomcatWsConfigurator.java
  85. 66 0
      fs-live-ws/src/main/java/com/fs/live/ws/util/WsJwtUtils.java
  86. 38 0
      fs-live-ws/src/main/java/com/fs/live/ws/util/WsVerifyUtils.java
  87. 43 0
      fs-live-ws/src/main/resources/application.yml
  88. 15 0
      fs-live-ws/src/main/resources/mybatis/mybatis-config.xml
  89. 33 3
      fs-qw-mq/src/main/java/com/fs/app/mq/courseFinishRtyTaskOne.java
  90. 223 0
      fs-qw-mq/src/main/java/com/fs/app/mq/courseFinishTagRtyTask.java
  91. 1 1
      fs-qw-task/src/main/java/com/fs/app/controller/CommonController.java
  92. 34 0
      fs-qwhook-sop/src/main/java/com/fs/app/controller/ApisFsUserCourseVideoController.java
  93. 29 0
      fs-qwhook-sop/src/main/java/com/fs/app/controller/ApisQwUserController.java
  94. 35 0
      fs-qwhook-sop/src/main/java/com/fs/app/controller/FsUserCourseVideoController.java
  95. 30 0
      fs-qwhook-sop/src/main/java/com/fs/app/controller/QwUserController.java
  96. 70 0
      fs-service/src/main/java/com/fs/aiCallSop/commonConstant/AiSopCallRemoteUrlConstant.java
  97. 71 0
      fs-service/src/main/java/com/fs/aiCallSop/domain/AiSopCallKnowledgeBase.java
  98. 70 0
      fs-service/src/main/java/com/fs/aiCallSop/domain/AiSopCallLineConfig.java
  99. 63 0
      fs-service/src/main/java/com/fs/aiCallSop/domain/AiSopCallMsgLog.java
  100. 54 0
      fs-service/src/main/java/com/fs/aiCallSop/domain/AiSopCallRole.java

+ 14 - 0
fs-admin/src/main/java/com/fs/company/controller/CompanyMenuController.java

@@ -118,4 +118,18 @@ public class CompanyMenuController extends BaseController
         return AjaxResult.success(companyMenuService.buildMenuTreeSelect(menus));
     }
 
+    /**
+     * 加载对应角色菜单列表树
+     */
+    @GetMapping(value = "/roleMenuTreeselect/{roleId}")
+    public AjaxResult roleMenuTreeselect(@PathVariable("roleId") Long roleId)
+    {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        CompanyMenu companyMenu = new CompanyMenu();
+        List<CompanyMenu> menus = companyMenuService.selectMenuList(companyMenu,loginUser.getUser().getUserId(),"00");
+        AjaxResult ajax = AjaxResult.success();
+        ajax.put("checkedKeys", companyMenuService.selectMenuListByRoleId(roleId));
+        ajax.put("menus", companyMenuService.buildMenuTreeSelect(menus));
+        return ajax;
+    }
 }

+ 109 - 11
fs-admin/src/main/java/com/fs/company/controller/CompanyRoleController.java

@@ -1,17 +1,28 @@
 package com.fs.company.controller;
 
 import com.fs.common.annotation.Log;
+import com.fs.common.constant.UserConstants;
 import com.fs.common.core.controller.BaseController;
 import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.domain.model.LoginUser;
 import com.fs.common.core.page.TableDataInfo;
 import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.SecurityUtils;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.company.domain.CompanyMenu;
 import com.fs.company.domain.CompanyRole;
+import com.fs.company.service.ICompanyMenuService;
 import com.fs.company.service.ICompanyRoleService;
+import com.fs.company.service.ICompanyUserService;
+import com.fs.framework.web.service.TokenService;
+import liquibase.pro.packaged.R;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
+import java.util.Date;
 import java.util.List;
 
 /**
@@ -27,15 +38,28 @@ public class CompanyRoleController extends BaseController
     @Autowired
     private ICompanyRoleService companyRoleService;
 
+    @Autowired
+    private ICompanyRoleService roleService;
+
+    @Autowired
+    private ICompanyUserService userService;
+
+    @Autowired
+    private ICompanyMenuService menuService;
+
+    @Autowired
+    private TokenService tokenService;
+
     /**
      * 查询角色信息列表
      */
     @PreAuthorize("@ss.hasPermi('company:companyRole:list')")
     @GetMapping("/list")
-    public TableDataInfo list(CompanyRole companyRole)
+    public TableDataInfo list(CompanyRole role)
     {
+
         startPage();
-        List<CompanyRole> list = companyRoleService.selectCompanyRoleList(companyRole);
+        List<CompanyRole> list = roleService.selectCompanyRoleList(role);
         return getDataTable(list);
     }
 
@@ -45,11 +69,11 @@ public class CompanyRoleController extends BaseController
     @PreAuthorize("@ss.hasPermi('company:companyRole:export')")
     @Log(title = "角色信息", businessType = BusinessType.EXPORT)
     @GetMapping("/export")
-    public AjaxResult export(CompanyRole companyRole)
+    public AjaxResult export(CompanyRole role)
     {
-        List<CompanyRole> list = companyRoleService.selectCompanyRoleList(companyRole);
+        List<CompanyRole> list = roleService.selectCompanyRoleList(role);
         ExcelUtil<CompanyRole> util = new ExcelUtil<CompanyRole>(CompanyRole.class);
-        return util.exportExcel(list, "companyRole");
+        return util.exportExcel(list, "销售角色数据");
     }
 
     /**
@@ -68,20 +92,80 @@ public class CompanyRoleController extends BaseController
     @PreAuthorize("@ss.hasPermi('company:companyRole:add')")
     @Log(title = "角色信息", businessType = BusinessType.INSERT)
     @PostMapping
-    public AjaxResult add(@RequestBody CompanyRole companyRole)
+    public AjaxResult add(@RequestBody CompanyRole role)
     {
-        return toAjax(companyRoleService.insertCompanyRole(companyRole));
+        role.setCreateTime(new Date());
+        if (UserConstants.NOT_UNIQUE.equals(roleService.checkRoleNameUnique(role)))
+        {
+            return AjaxResult.error("新增角色'" + role.getRoleName() + "'失败,角色名称已存在");
+        }
+        else if (UserConstants.NOT_UNIQUE.equals(roleService.checkRoleKeyUnique(role)))
+        {
+            return AjaxResult.error("新增角色'" + role.getRoleName() + "'失败,角色权限已存在");
+        }
+        role.setCreateBy(SecurityUtils.getUsername());
+        return toAjax(roleService.insertRole(role));
     }
 
     /**
      * 修改角色信息
      */
     @PreAuthorize("@ss.hasPermi('company:companyRole:edit')")
-    @Log(title = "角色信息", businessType = BusinessType.UPDATE)
+    @Log(title = "角色管理", businessType = BusinessType.UPDATE)
     @PutMapping
-    public AjaxResult edit(@RequestBody CompanyRole companyRole)
+    public AjaxResult edit(@RequestBody CompanyRole role)
+    {
+        if (UserConstants.NOT_UNIQUE.equals(roleService.checkRoleNameUnique(role)))
+        {
+            return AjaxResult.error("修改角色'" + role.getRoleName() + "'失败,角色名称已存在");
+        }
+        else if (UserConstants.NOT_UNIQUE.equals(roleService.checkRoleKeyUnique(role)))
+        {
+            return AjaxResult.error("修改角色'" + role.getRoleName() + "'失败,角色权限已存在");
+        }
+        role.setUpdateBy(SecurityUtils.getUsername());
+
+        if (roleService.updateRole(role) > 0)
+        {
+            // 更新缓存用户权限(销售端的token不行)
+//            if (StringUtils.isNotNull(loginUser.getUser()) && !loginUser.getUser().isAdmin())
+//            {
+//                loginUser.setPermissions(permissionService.getMenuPermission(loginUser.getUser()));
+//                loginUser.setUser(userService.selectUserByUserName(loginUser.getUser().getUserName()));
+//                tokenService.setLoginUser(loginUser);
+//            }
+            return AjaxResult.success();
+        }
+        return AjaxResult.error("修改角色'" + role.getRoleName() + "'失败,请联系管理员");
+    }
+
+
+    /**
+     * 修改保存数据权限
+     */
+    @PreAuthorize("@ss.hasPermi('company:companyRole:edit')")
+    @Log(title = "角色管理", businessType = BusinessType.UPDATE)
+    @PutMapping("/dataScope")
+    public AjaxResult dataScope(@RequestBody CompanyRole role)
+    {
+        return toAjax(roleService.authDataScope(role));
+    }
+
+
+    /**
+     * 状态修改
+     */
+    @PreAuthorize("@ss.hasPermi('company:role:edit')")
+    @Log(title = "角色管理", businessType = BusinessType.UPDATE)
+    @PutMapping("/changeStatus")
+    public AjaxResult changeStatus(@RequestBody CompanyRole role)
     {
-        return toAjax(companyRoleService.updateCompanyRole(companyRole));
+        CompanyRole companyRole=roleService.selectCompanyRoleById(role.getRoleId());
+        if(companyRole.getRoleKey().equals("admin")){
+            return AjaxResult.error("管理员角色不能修改状态");
+        }
+        role.setUpdateBy(SecurityUtils.getUsername());
+        return toAjax(roleService.updateRoleStatus(role));
     }
 
     /**
@@ -92,6 +176,20 @@ public class CompanyRoleController extends BaseController
 	@DeleteMapping("/{roleIds}")
     public AjaxResult remove(@PathVariable Long[] roleIds)
     {
-        return toAjax(companyRoleService.deleteCompanyRoleByIds(roleIds));
+        return toAjax(roleService.deleteRoleByIds(roleIds));
     }
+
+    /**
+     * 获取角色选择框列表
+     */
+    @PreAuthorize("@ss.hasPermi('company:role:query')")
+    @GetMapping("/optionselect/{companyId}")
+    public AjaxResult optionselect(@PathVariable Long companyId)
+    {
+        CompanyRole role=new CompanyRole();
+        role.setCompanyId(companyId);
+        return AjaxResult.success(roleService.selectCompanyRoleList(role));
+    }
+
+
 }

+ 56 - 3
fs-admin/src/main/java/com/fs/company/controller/CompanyUserAllController.java

@@ -19,9 +19,8 @@ import com.fs.common.utils.ServletUtils;
 import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.poi.ExcelUtil;
 import com.fs.company.domain.*;
-import com.fs.company.param.CompanyUserAreaParam;
-import com.fs.company.param.CompanyUserCodeParam;
-import com.fs.company.param.CompanyUserQwParam;
+import com.fs.company.mapper.CompanyFsUserMapper;
+import com.fs.company.param.*;
 import com.fs.company.service.*;
 import com.fs.company.vo.BatchUserRolesVO;
 import com.fs.company.vo.CompanyUserImportVO;
@@ -47,6 +46,7 @@ import com.fs.voice.utils.StringUtil;
 import com.github.pagehelper.PageHelper;
 import com.github.pagehelper.PageInfo;
 import io.swagger.annotations.ApiOperation;
+import liquibase.pro.packaged.S;
 import org.json.JSONArray;
 import org.json.JSONObject;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -103,6 +103,7 @@ public class CompanyUserAllController extends BaseController {
     @Autowired
     private TokenService tokenService;
 
+
     private static final String appLink = "https://jump.ylrztop.com/jumpapp/pages/index/index?link=";
 
 
@@ -124,6 +125,13 @@ public class CompanyUserAllController extends BaseController {
             for (CompanyUserQwListVO companyUserQwListVO : list) {
                 CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
                     CompanyUserDelayTime companyUserDelayTime = companyUserDelayTimeService.selectCompanyUserDelayTimeByCompanyUser(companyUserQwListVO.getCompanyId(), companyUserQwListVO.getUserId());
+                    VcCompanyUser vcCompanyUser = companyUserService.selectVcCompanyUser(String.valueOf(companyUserQwListVO.getUserId()));
+
+                    if (vcCompanyUser!=null){
+                        companyUserQwListVO.setIsBindVoiceSeat(1);
+                        companyUserQwListVO.setIsRecordVoiceSeat(!StringUtil.strIsNullOrEmpty(vcCompanyUser.getUploadUrl()) ? 1 : 2);
+                    }
+
                     if (ObjectUtil.isEmpty(companyUserDelayTime)) {
                         companyUserQwListVO.setSendDelayTime(sendDelayTime);
                     } else {
@@ -147,6 +155,43 @@ public class CompanyUserAllController extends BaseController {
         return getDataTable(list);
     }
 
+
+
+    /**
+     * 获取所有 没有 有语音席位的账号
+     */
+    @GetMapping("/getAllNotVoiceList")
+    public TableDataInfo getAllNotVoiceList(VcCompanyUserParam param) {
+        startPage();
+
+        List<VcCompanyUser> vcCompanyUsers = companyUserService.getAllNotVoiceList(param.getCompanyUserId());
+
+        return getDataTable(vcCompanyUsers);
+    }
+
+    /**
+    * 获取所有 已经 有语音席位的账号
+    */
+    @GetMapping("/getAllVoiceList")
+    public TableDataInfo getAllVoiceList(VcCompanyUserParam param) {
+        startPage();
+
+        List<VcCompanyUser> vcCompanyUsers = companyUserService.selectAllVcCompanyUser(param.getCompanyUserId());
+
+        return getDataTable(vcCompanyUsers);
+    }
+
+    /**
+    * 转换语音席位
+    */
+    @PostMapping("/transferVoiceCompanyUserId")
+    public R transferVoiceCompanyUserId(@RequestBody VcCompanyUserParam param) {
+
+        return   companyUserService.transferVoiceCompanyUserId(param);
+
+    }
+
+
     @Log(title = "用户管理导出", businessType = BusinessType.EXPORT)
     @PreAuthorize("@ss.hasPermi('company:user:export')")
     @GetMapping("/export")
@@ -264,6 +309,14 @@ public class CompanyUserAllController extends BaseController {
         user.setCreateBy(SecurityUtils.getUsername());
         user.setPassword(SecurityUtils.encryptPassword(user.getPassword()));
         user.setCreateTime(new Date());
+
+        Date createTime = new Date();
+        Calendar cal = Calendar.getInstance();
+        cal.setTime(createTime);
+        cal.add(Calendar.YEAR, 1); // 加1年
+        Date expirationTime = cal.getTime();
+        user.setExpirationTime(expirationTime);
+
         user.setUserType("01");//一般用户
         user.setIsAudit(1);
         return toAjax(companyUserService.insertUser(user));

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

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

+ 3 - 0
fs-admin/src/main/java/com/fs/qw/FsCourseTask.java

@@ -126,7 +126,9 @@ public class FsCourseTask {
      */
     public void qwWorkTask1() throws Exception
     {
+        //看课中断
         qwWorkTaskService.addQwWorkByCourse4();
+        //待看课
         qwWorkTaskService.addQwWorkByCourseLastTime();
     }
     /**
@@ -135,6 +137,7 @@ public class FsCourseTask {
      */
     public void qwWorkTask2() throws Exception
     {
+
         qwWorkTaskService.addQwWorkByCourse();
         qwWorkTaskService.addQwWorkByFirstCourse();
     }

+ 1 - 0
fs-admin/src/main/java/com/fs/qw/qwTask/qwTask.java

@@ -460,6 +460,7 @@ public class qwTask {
                 item.setVoiceUrl(audioVO.getUrl());
                 item.setDuration(audioVO.getDuration());
                 item.setUserVoiceUrl(audioVO.getWavUrl());
+                item.setRecordType(1);
                 QwUser user = qwUserMapper.selectQwUserconvertSopVoice(item.getCompanyUserId());
                 ttsServiceImpl.ttsChargeByCount(vcCompanyUser,audioVO,user);
 

+ 6 - 0
fs-common/src/main/java/com/fs/common/constant/LiveKeysConstant.java

@@ -55,4 +55,10 @@ public class LiveKeysConstant {
     public static final int TTL_LIVE_MSG = 30;
     public static final int TTL_LIVE_DATA = 30;
 
+    /** 商品足迹异步写入队列 */
+    public static final String FOOTPRINT_QUEUE_KEY = "live:footprint:queue";
+    /** 足迹任务处理锁前缀,后缀为 taskId */
+    public static final String FOOTPRINT_PROCESSING_KEY = "live:footprint:processing:";
+    public static final int FOOTPRINT_PROCESSING_LOCK_EXPIRE = 300;
+
 }

+ 35 - 0
fs-company-app/src/main/java/com/fs/app/controller/CompanyUserController.java

@@ -740,6 +740,41 @@ public class CompanyUserController extends AppBaseController {
         }
         return R.ok().put("data", audioVO);
     }
+
+    /**
+     * 当只有user_voice_url时,生成表中对应条的voice_url 无语音席位
+     * @param userVoiceUrl  wav格式的语音文件
+     * @param id            qw_sop_temp_voice的id
+     * @return
+     */
+    @GetMapping("/companyUserVoiceNotSeat")
+    public R companyUserVoiceNotSeat( @RequestParam("id") Long id,@RequestParam("userVoiceUrl") String userVoiceUrl){
+
+        AudioVO audioVO = new AudioVO();
+        Long companyUserId = getCompanyUserId();
+        List<QwSopTempVoice> sopTempVoices = redisCache.getVoiceAllList(SOP_TEMP_VOICE_KEY + ":" + companyUserId);
+        if(sopTempVoices != null && !sopTempVoices.isEmpty()){
+            List<Long> collect = sopTempVoices.stream().map(QwSopTempVoice::getId).collect(Collectors.toList());
+            if (collect.contains(id)){
+                return R.ok().put("code",202).put("msg","该语音已进入转换,请完成后再试。");
+            }
+        }
+
+        QwSopTempVoice qwSopTempVoice = voiceService.selectQwSopTempVoiceByIdAndUserVoiceUrl(id);
+        if(qwSopTempVoice != null && qwSopTempVoice.getId() != null){
+            audioVO = AudioUtils.transferAudioSilkFromUrl(userVoiceUrl,  false);
+            if(audioVO != null && audioVO.getUrl() != null){
+                qwSopTempVoice.setVoiceUrl(audioVO.getUrl());
+                qwSopTempVoice.setUserVoiceUrl(userVoiceUrl);
+                qwSopTempVoice.setDuration(audioVO.getDuration());
+                qwSopTempVoice.setRecordType(1);
+                voiceService.updateQwSopTempVoice(qwSopTempVoice);
+            }
+        }
+        return R.ok().put("data", audioVO);
+    }
+
+
     @GetMapping("/querySopVoiceList")
     public TableDataInfo querySopVoiceList(@Param("recordType") Integer recordType){
         startPage();

+ 123 - 0
fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSopCallKnowledgeBaseController.java

@@ -0,0 +1,123 @@
+package com.fs.company.controller.aiSipCall;
+
+import com.fs.aiCallSop.domain.AiSopCallKnowledgeBase;
+import com.fs.aiCallSop.service.IAiSopCallKnowledgeBaseService;
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * AI外呼知识库Controller
+ *
+ * @author fs
+ * @date 2026-01-30
+ */
+@RestController
+@RequestMapping("/company/aiSopCallKnowledgeBase")
+public class AiSopCallKnowledgeBaseController extends BaseController
+{
+    @Autowired
+    private IAiSopCallKnowledgeBaseService aiSopCallKnowledgeBaseService;
+    @Autowired
+    private TokenService tokenService;
+
+    /**
+     * 查询AI外呼知识库列表
+     */
+    @PreAuthorize("@ss.hasPermi('company:aiSopCallKnowledgeBase:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(AiSopCallKnowledgeBase aiSopCallKnowledgeBase)
+    {
+        startPage();
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        aiSopCallKnowledgeBase.setCompanyId(loginUser.getCompany().getCompanyId());
+        aiSopCallKnowledgeBase.setCompanyUserId(loginUser.getUser().getUserId());
+        List<AiSopCallKnowledgeBase> list = aiSopCallKnowledgeBaseService.selectAiSopCallKnowledgeBaseList(aiSopCallKnowledgeBase);
+        return getDataTable(list);
+    }
+    /**
+     * 查询AI外呼知识库名称和id
+     */
+    @GetMapping("/idAndNameList")
+    public R idAndNameList()
+    {
+        return aiSopCallKnowledgeBaseService.idAndNameList();
+    }
+
+    /**
+     * 导出AI外呼知识库列表
+     */
+    @PreAuthorize("@ss.hasPermi('company:aiSopCallKnowledgeBase:export')")
+    @Log(title = "AI外呼知识库", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(AiSopCallKnowledgeBase aiSopCallKnowledgeBase)
+    {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        aiSopCallKnowledgeBase.setCompanyId(loginUser.getCompany().getCompanyId());
+        aiSopCallKnowledgeBase.setCompanyUserId(loginUser.getUser().getUserId());
+        List<AiSopCallKnowledgeBase> list = aiSopCallKnowledgeBaseService.selectAiSopCallKnowledgeBaseList(aiSopCallKnowledgeBase);
+        ExcelUtil<AiSopCallKnowledgeBase> util = new ExcelUtil<>(AiSopCallKnowledgeBase.class);
+        return util.exportExcel(list, "AI外呼知识库数据");
+    }
+
+    /**
+     * 获取AI外呼知识库详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('company:aiSopCallKnowledgeBase:query')")
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id)
+    {
+        return AjaxResult.success(aiSopCallKnowledgeBaseService.selectAiSopCallKnowledgeBaseById(id));
+    }
+
+    /**
+     * 新增AI外呼知识库
+     */
+    @PreAuthorize("@ss.hasPermi('company:aiSopCallKnowledgeBase:add')")
+    @Log(title = "AI外呼知识库", businessType = BusinessType.INSERT)
+    @PostMapping
+    public R add(@RequestBody AiSopCallKnowledgeBase aiSopCallKnowledgeBase)
+    {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        aiSopCallKnowledgeBase.setCompanyId(loginUser.getCompany().getCompanyId());
+        aiSopCallKnowledgeBase.setCompanyUserId(loginUser.getUser().getUserId());
+        aiSopCallKnowledgeBase.setCreateBy(loginUser.getUser().getNickName());
+        return aiSopCallKnowledgeBaseService.insertAiSopCallKnowledgeBase(aiSopCallKnowledgeBase);
+    }
+
+    /**
+     * 修改AI外呼知识库
+     */
+    @PreAuthorize("@ss.hasPermi('company:aiSopCallKnowledgeBase:edit')")
+    @Log(title = "AI外呼知识库", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public R edit(@RequestBody AiSopCallKnowledgeBase aiSopCallKnowledgeBase)
+    {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        aiSopCallKnowledgeBase.setUpdateBy(loginUser.getUser().getNickName());
+        return aiSopCallKnowledgeBaseService.updateAiSopCallKnowledgeBase(aiSopCallKnowledgeBase);
+    }
+
+    /**
+     * 删除AI外呼知识库
+     */
+    @PreAuthorize("@ss.hasPermi('company:aiSopCallKnowledgeBase:remove')")
+    @Log(title = "AI外呼知识库", businessType = BusinessType.DELETE)
+	@DeleteMapping("/{ids}")
+    public R remove(@PathVariable Long[] ids)
+    {
+        return aiSopCallKnowledgeBaseService.deleteAiSopCallKnowledgeBaseByIds(ids);
+    }
+}

+ 129 - 0
fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSopCallRoleController.java

@@ -0,0 +1,129 @@
+package com.fs.company.controller.aiSipCall;
+
+import com.fs.aiCallSop.domain.AiSopCallRole;
+import com.fs.aiCallSop.service.IAiSopCallRoleService;
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.DateUtils;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * AI外呼角色Controller
+ *
+ * @author fs
+ * @date 2026-01-14
+ */
+@RestController
+@RequestMapping("/aiSopCall/role")
+public class AiSopCallRoleController extends BaseController
+{
+    @Autowired
+    private IAiSopCallRoleService aiSopCallRoleService;
+    @Autowired
+    private TokenService tokenService;
+    /**
+     * 查询AI外呼角色列表
+     */
+    @PreAuthorize("@ss.hasPermi('aiSopCall:role:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(AiSopCallRole aiSopCallRole)
+    {
+        startPage();
+        List<AiSopCallRole> list = aiSopCallRoleService.selectAiSopCallRoleList(aiSopCallRole);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出AI外呼角色列表
+     */
+    @PreAuthorize("@ss.hasPermi('aiSopCall:role:export')")
+    @Log(title = "AI外呼角色导出", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(AiSopCallRole aiSopCallRole)
+    {
+        List<AiSopCallRole> list = aiSopCallRoleService.selectAiSopCallRoleList(aiSopCallRole);
+        ExcelUtil<AiSopCallRole> util = new ExcelUtil<AiSopCallRole>(AiSopCallRole.class);
+        return util.exportExcel(list, "AI外呼角色数据");
+    }
+
+    /**
+     * 获取AI外呼角色详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('aiSopCall:role:query')")
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id)
+    {
+        return AjaxResult.success(aiSopCallRoleService.selectAiSopCallRoleById(id));
+    }
+
+    /**
+     * 新增AI外呼角色
+     */
+    @PreAuthorize("@ss.hasPermi('aiSopCall:role:add')")
+    @Log(title = "AI外呼角色新增", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody AiSopCallRole aiSopCallRole)
+    {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        aiSopCallRole.setCreateBy(loginUser.getUsername());
+        aiSopCallRole.setCreateTime(DateUtils.getNowDate());
+        return toAjax(aiSopCallRoleService.insertAiSopCallRole(aiSopCallRole));
+    }
+
+    /**
+     * 修改AI外呼角色
+     */
+    @PreAuthorize("@ss.hasPermi('aiSopCall:role:edit')")
+    @Log(title = "AI外呼角色修改", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody AiSopCallRole aiSopCallRole)
+    {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        aiSopCallRole.setUpdateBy(loginUser.getUsername());
+        aiSopCallRole.setUpdateTime(DateUtils.getNowDate());
+        return toAjax(aiSopCallRoleService.updateAiSopCallRole(aiSopCallRole));
+    }
+
+    /**
+     * 删除AI外呼角色
+     */
+    @PreAuthorize("@ss.hasPermi('aiSopCall:role:remove')")
+    @Log(title = "AI外呼角色删除", businessType = BusinessType.DELETE)
+	@DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids)
+    {
+        return toAjax(aiSopCallRoleService.deleteAiSopCallRoleByIds(ids));
+    }
+
+    /**
+     * 复制AI外呼角色
+     */
+    @PreAuthorize("@ss.hasPermi('aiSopCall:role:copyRole')")
+    @Log(title = "AI外呼角色复制", businessType = BusinessType.INSERT)
+    @GetMapping("copyRole/{id}")
+    public AjaxResult copyRole(@PathVariable Long id)
+    {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        AiSopCallRole aiSopCallRole = aiSopCallRoleService.selectAiSopCallRoleById(id);
+        if(aiSopCallRole == null){
+            return AjaxResult.error("当前复制角色信息不存在");
+        }
+        aiSopCallRole.setId(null);
+        aiSopCallRole.setCreateTime(DateUtils.getNowDate());
+        aiSopCallRole.setCreateBy(loginUser.getUsername());
+        aiSopCallRole.setUpdateBy(null);
+        aiSopCallRole.setUpdateTime(null);
+        return toAjax(aiSopCallRoleService.insertAiSopCallRole(aiSopCallRole));
+    }
+}

+ 98 - 0
fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSopCallStaffModelsController.java

@@ -0,0 +1,98 @@
+package com.fs.company.controller.aiSipCall;
+
+
+import com.fs.aiCallSop.domain.AiSopCallStaffModels;
+import com.fs.aiCallSop.service.IAiSopCallStaffModelsService;
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.poi.ExcelUtil;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 外呼模型模型Controller
+ *
+ * @author fs
+ * @date 2025-12-23
+ */
+@RestController
+@RequestMapping("/aiSopCallStaffModels/aiSopCallStaffModels")
+public class AiSopCallStaffModelsController extends BaseController
+{
+    @Autowired
+    private IAiSopCallStaffModelsService aiSopCallStaffModelsService;
+
+    /**
+     * 查询外呼模型模型列表
+     */
+    @PreAuthorize("@ss.hasPermi('aiSopCallStaffModels:aiSopCallStaffModels:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(AiSopCallStaffModels aiSopCallStaffModels)
+    {
+        startPage();
+        List<AiSopCallStaffModels> list = aiSopCallStaffModelsService.selectAiSopCallStaffModelsList(aiSopCallStaffModels);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出外呼模型模型列表
+     */
+    @PreAuthorize("@ss.hasPermi('aiSopCallStaffModels:aiSopCallStaffModels:export')")
+    @Log(title = "外呼模型模型", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(AiSopCallStaffModels aiSopCallStaffModels)
+    {
+        List<AiSopCallStaffModels> list = aiSopCallStaffModelsService.selectAiSopCallStaffModelsList(aiSopCallStaffModels);
+        ExcelUtil<AiSopCallStaffModels> util = new ExcelUtil<AiSopCallStaffModels>(AiSopCallStaffModels.class);
+        return util.exportExcel(list, "外呼模型模型数据");
+    }
+
+    /**
+     * 获取外呼模型模型详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('aiSopCallStaffModels:aiSopCallStaffModels:query')")
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id)
+    {
+        return AjaxResult.success(aiSopCallStaffModelsService.selectAiSopCallStaffModelsById(id));
+    }
+
+    /**
+     * 新增外呼模型模型
+     */
+    @PreAuthorize("@ss.hasPermi('aiSopCallStaffModels:aiSopCallStaffModels:add')")
+    @Log(title = "外呼模型模型", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody AiSopCallStaffModels aiSopCallStaffModels)
+    {
+        return toAjax(aiSopCallStaffModelsService.insertAiSopCallStaffModels(aiSopCallStaffModels));
+    }
+
+    /**
+     * 修改外呼模型模型
+     */
+    @PreAuthorize("@ss.hasPermi('aiSopCallStaffModels:aiSopCallStaffModels:edit')")
+    @Log(title = "外呼模型模型", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody AiSopCallStaffModels aiSopCallStaffModels)
+    {
+        return toAjax(aiSopCallStaffModelsService.updateAiSopCallStaffModels(aiSopCallStaffModels));
+    }
+
+    /**
+     * 删除外呼模型模型
+     */
+    @PreAuthorize("@ss.hasPermi('aiSopCallStaffModels:aiSopCallStaffModels:remove')")
+    @Log(title = "外呼模型模型", businessType = BusinessType.DELETE)
+	@DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids)
+    {
+        return toAjax(aiSopCallStaffModelsService.deleteAiSopCallStaffModelsByIds(ids));
+    }
+}

+ 209 - 0
fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSopCallUserController.java

@@ -0,0 +1,209 @@
+package com.fs.company.controller.aiSipCall;
+
+
+import com.fs.aiCallSop.domain.AiSopCallUser;
+import com.fs.aiCallSop.param.ModelListRequest;
+import com.fs.aiCallSop.param.ModelListResponse;
+import com.fs.aiCallSop.param.VoicesDictRes;
+import com.fs.aiCallSop.param.VoicesDictResPage;
+import com.fs.aiCallSop.service.IAiSopCallUserService;
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
+import com.fs.system.oss.CloudStorageService;
+import com.fs.system.oss.OSSFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * 员工角色Controller
+ *
+ * @author fs
+ * @date 2025-12-23
+ */
+@RestController
+@RequestMapping("/aiSopCallUser/aiSopCallUser")
+public class AiSopCallUserController extends BaseController
+{
+    @Autowired
+    private IAiSopCallUserService aiSopCallUserService;
+    @Autowired
+    private TokenService tokenService;
+    /**
+     * 查询员工角色列表
+     */
+    @PreAuthorize("@ss.hasPermi('aiSopCallUser:aiSopCallUser:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(AiSopCallUser aiSopCallUser)
+    {
+        startPage();
+        List<AiSopCallUser> list = aiSopCallUserService.selectAiSopCallUserList(aiSopCallUser);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出员工角色列表
+     */
+    @PreAuthorize("@ss.hasPermi('aiSopCallUser:aiSopCallUser:export')")
+    @Log(title = "员工角色", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(AiSopCallUser aiSopCallUser)
+    {
+        List<AiSopCallUser> list = aiSopCallUserService.selectAiSopCallUserList(aiSopCallUser);
+        ExcelUtil<AiSopCallUser> util = new ExcelUtil<AiSopCallUser>(AiSopCallUser.class);
+        return util.exportExcel(list, "员工角色数据");
+    }
+
+    /**
+     * 获取员工角色详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('aiSopCallUser:aiSopCallUser:query')")
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id)
+    {
+        return AjaxResult.success(aiSopCallUserService.selectAiSopCallUserById(id));
+    }
+
+    /**
+     * 获取员工角色详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('aiSopCallUser:aiSopCallUser:query')")
+    @GetMapping(value = "/staffNumber/{staffNumber}")
+    public R staffNumber(@PathVariable("staffNumber") String staffNumber)
+    {
+        return aiSopCallUserService.selectAiSopCallUserByStaffNumber(staffNumber);
+    }
+
+    /**
+     * 新增员工角色
+     */
+    @PreAuthorize("@ss.hasPermi('aiSopCallUser:aiSopCallUser:add')")
+    @Log(title = "员工角色", businessType = BusinessType.INSERT)
+    @PostMapping
+    public R add(@RequestBody AiSopCallUser aiSopCallUser)
+    {
+        return aiSopCallUserService.insertAiSopCallUser(aiSopCallUser);
+    }
+
+    /**
+     * 修改员工角色
+     */
+    @PreAuthorize("@ss.hasPermi('aiSopCallUser:aiSopCallUser:edit')")
+    @Log(title = "员工角色", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public R edit(@RequestBody AiSopCallUser aiSopCallUser)
+    {
+        return aiSopCallUserService.updateAiSopCallUser(aiSopCallUser);
+    }
+
+    /**
+     * 删除员工角色
+     */
+    @PreAuthorize("@ss.hasPermi('aiSopCallUser:aiSopCallUser:remove')")
+    @Log(title = "员工角色", businessType = BusinessType.DELETE)
+	@DeleteMapping("/{id}")
+    public R remove(@PathVariable Long id)
+    {
+        return aiSopCallUserService.deleteAiSopCallUserById(id);
+    }
+
+
+    @PreAuthorize("@ss.hasPermi('aiSopCallUser:aiSopCallUser:getMode')")
+    @GetMapping("/getMode")
+    public R getMode(@RequestParam(defaultValue = "1") Integer pageNum,
+                     @RequestParam(defaultValue = "10") Integer pageSize,
+                     @RequestParam(required = false) String modelName) {
+
+        // 构建请求参数
+        ModelListRequest request = new ModelListRequest();
+        request.setPage(pageNum);
+        request.setSize(pageSize);
+        request.setLike_key("model_name");
+
+        // 设置查询条件
+        ModelListRequest.Condition condition = new ModelListRequest.Condition();
+        condition.setModel_name(modelName != null ? modelName : "");
+        request.setCondition(condition);
+
+        // 发送请求
+        ModelListResponse response = aiSopCallUserService.getModeList(request);
+
+        if (response.getCode() == 0) {
+            return R.ok().put("data",response.getData());
+        } else {
+            return R.error(response.getMsg());
+        }
+    }
+
+    @GetMapping("/getVoicesDict")
+    public R getMode() {
+
+        ModelListRequest request = new ModelListRequest();
+        request.setDisabled(1);
+        request.setPage(1);
+        request.setSize(10);
+        // 发送请求
+        VoicesDictRes response = aiSopCallUserService.getVoicesDict(request);
+
+        if (response.getCode() == 0) {
+            return R.ok().put("data",response.getData());
+        } else {
+            return R.error(response.getMsg());
+        }
+    }
+    @GetMapping("/getVoicesDictByModelId")
+    public R getVoicesDictByModelId(@RequestParam(defaultValue = "1") Integer pageNum,
+                                    @RequestParam(defaultValue = "10") Integer pageSize,
+                                    @RequestParam(required = false) Integer staffModelsId) {
+        ModelListRequest request = new ModelListRequest();
+        request.setDisabled(1);
+        request.setPage(pageNum);
+        request.setSize(pageSize);
+        ModelListRequest.Condition condition = new ModelListRequest.Condition();
+        condition.setModel_id(staffModelsId);
+        request.setCondition(condition);
+        // 发送请求
+        VoicesDictResPage response = aiSopCallUserService.getVoicesDictByModelId(request);
+
+        if (response.getCode() == 0) {
+            return R.ok().put("data",response.getData());
+        } else {
+            return R.error(response.getMsg());
+        }
+    }
+
+    /**
+     * 上传文件
+     */
+    @PostMapping("/saveUploadOssFile")
+    public R saveUploadOssFile(@RequestParam("file") MultipartFile file,
+                           @RequestParam("fileName") String fileName) {
+        try {
+            LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+            // 新文件名称
+            fileName = loginUser.getUser().getUserId()+"_"+fileName;
+            // 上传文件
+            String suffix = fileName.substring(fileName.lastIndexOf("."));
+            CloudStorageService storage = OSSFactory.build();
+            String url = storage.uploadSuffix(file.getBytes(), suffix);
+            return Objects.requireNonNull(R.ok().put("message", "文件保存成功")).put("url", url);
+        }
+        catch (Exception e)
+        {
+            return R.error("文件保存失败: " + e.getMessage());
+        }
+    }
+
+}

+ 8 - 0
fs-company/src/main/java/com/fs/company/controller/company/CompanyUserController.java

@@ -365,6 +365,14 @@ public class CompanyUserController extends BaseController {
         user.setCreateBy(SecurityUtils.getUsername());
         user.setPassword(SecurityUtils.encryptPassword(user.getPassword()));
         user.setCreateTime(new Date());
+
+        Date createTime = new Date();
+        Calendar cal = Calendar.getInstance();
+        cal.setTime(createTime);
+        cal.add(Calendar.YEAR, 1); // 加1年
+        Date updateTime = cal.getTime();
+        user.setUpdateTime(updateTime);
+
         user.setUserType("01");//一般用户
         user.setIsAudit(1);
         return toAjax(companyUserService.insertUser(user));

+ 6 - 1
fs-company/src/main/java/com/fs/company/controller/course/qw/FsQwCourseWatchLogController.java

@@ -116,12 +116,17 @@ public class FsQwCourseWatchLogController extends BaseController
             return qwWatchLogService.selectQwWatchLogStatisticsListVO(param);
         }
     }
-
     @GetMapping("/myQwWatchLogStatisticsList")
     public TableDataInfo myQwWatchLogStatisticsList(QwWatchLogStatisticsListParam param) {
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
         param.setCompanyId(loginUser.getCompany().getCompanyId());
         param.setCompanyUserId(loginUser.getUser().getUserId());
+
+        if (param.getSearchType()==2){
+            List<Long> deptIds=new ArrayList<>();
+            deptIds.add(loginUser.getUser().getDeptId());
+            param.setDeptIds(deptIds);
+        }
         if (param.getSTime() == null || param.getETime() == null) {
             return getDataTable(new ArrayList<>());
         }

+ 29 - 0
fs-company/src/main/java/com/fs/company/controller/qw/QwExternalContactController.java

@@ -31,6 +31,7 @@ import com.fs.qw.domain.QwContactWay;
 import com.fs.qw.domain.QwExternalContact;
 import com.fs.qw.domain.QwTag;
 import com.fs.qw.dto.CompanyTransferDTO;
+import com.fs.qw.dto.SalesAppDownloadStatDTO;
 import com.fs.qw.param.*;
 import com.fs.qw.service.*;
 import com.fs.qw.vo.QwExternalContactVO;
@@ -1150,4 +1151,32 @@ public class QwExternalContactController extends BaseController
         return getDataTable(list);
     }
 
+    @GetMapping("/getSalesAppDownloadStats")
+    public TableDataInfo getSalesAppDownloadStats(AppDownloadParam param) {
+        // 设置企业ID(实际项目中从登录信息获取)
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId = loginUser.getCompany().getCompanyId();
+        param.setCompanyId(companyId);
+
+        TableDataInfo salesAppDownloadStats = qwExternalContactService.getSalesAppDownloadStats(param);
+
+        return salesAppDownloadStats;
+    }
+
+    @GetMapping("/exportSalesAppDownloadStats")
+    public AjaxResult exportSalesAppDownloadStats(AppDownloadParam param) {
+        // 设置企业ID(实际项目中从登录信息获取)
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId = loginUser.getCompany().getCompanyId();
+        param.setCompanyId(companyId);
+
+        // 获取所有统计数据(不分页)
+        List<SalesAppDownloadStatDTO> list = qwExternalContactService.exportSalesAppDownloadStats(param);
+
+        // 用 ExcelUtil 导出
+        ExcelUtil<SalesAppDownloadStatDTO> util = new ExcelUtil<>(SalesAppDownloadStatDTO.class);
+        return util.exportExcel(list, "销售APP下载统计");
+    }
+
+
 }

+ 70 - 14
fs-company/src/main/java/com/fs/company/controller/qw/QwSopTempVoiceController.java

@@ -1,36 +1,39 @@
 package com.fs.company.controller.qw;
 
+import com.fs.common.annotation.Log;
 import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
 import com.fs.common.core.domain.R;
 import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
 import com.fs.common.utils.ServletUtils;
 import com.fs.course.param.FsCourseListBySidebarParam;
 import com.fs.framework.security.LoginUser;
 import com.fs.framework.service.TokenService;
-import com.fs.qw.domain.QwUser;
-import com.fs.sop.domain.QwSop;
 import com.fs.sop.domain.QwSopTempVoice;
 import com.fs.sop.service.IQwSopTempVoiceService;
-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.PostMapping;
-import org.springframework.web.bind.annotation.RequestBody;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RestController;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
 
 import java.util.List;
 
+/**
+ * 模板对应的销售语音文件Controller
+ *
+ * @author fs
+ * @date 2026-03-12
+ */
 @RestController
-@RequestMapping("/qw/SopTempVoice")
-public class QwSopTempVoiceController extends BaseController {
-
+@RequestMapping("/qw/QwSopTempVoice")
+public class QwSopTempVoiceController extends BaseController
+{
     @Autowired
-    private TokenService tokenService;
+    private IQwSopTempVoiceService qwSopTempVoiceService;
 
     @Autowired
-    private IQwSopTempVoiceService sopTempVoiceService;
+    private TokenService tokenService;
 
 
     /**
@@ -43,8 +46,61 @@ public class QwSopTempVoiceController extends BaseController {
         startPage();
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
 
-        List<QwSopTempVoice> voiceList = sopTempVoiceService.selectQwSopTempVoiceByCompanyUserId(loginUser.getUser().getUserId(),param.getKeyword());
+        List<QwSopTempVoice> voiceList = qwSopTempVoiceService.selectQwSopTempVoiceByCompanyUserId(loginUser.getUser().getUserId(),param.getKeyword());
         return getDataTable(voiceList);
     }
 
+    /**
+     * 查询模板对应的销售语音文件列表
+     */
+    @PreAuthorize("@ss.hasPermi('qw:QwSopTempVoice:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(QwSopTempVoice qwSopTempVoice)
+    {
+
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        qwSopTempVoice.setCompanyId(loginUser.getCompany().getCompanyId());
+
+        startPage();
+        List<QwSopTempVoice> list = qwSopTempVoiceService.selectQwSopTempVoiceBySidberList(qwSopTempVoice);
+        return getDataTable(list);
+    }
+
+    /**
+     * 新增模板对应的销售语音文件
+     */
+    @PreAuthorize("@ss.hasPermi('qw:QwSopTempVoice:add')")
+    @Log(title = "模板对应的销售语音文件", businessType = BusinessType.INSERT)
+    @PostMapping
+    public R add(@RequestBody QwSopTempVoice qwSopTempVoice)
+    {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        qwSopTempVoice.setCompanyId(loginUser.getCompany().getCompanyId());
+        qwSopTempVoiceService.insertQwSopTempVoiceByCompany(qwSopTempVoice);
+        return R.ok();
+    }
+
+//    /**
+//     * 修改模板对应的销售语音文件
+//     */
+//    @PreAuthorize("@ss.hasPermi('qw:QwSopTempVoice:edit')")
+//    @Log(title = "模板对应的销售语音文件", businessType = BusinessType.UPDATE)
+//    @PutMapping
+//    public AjaxResult edit(@RequestBody QwSopTempVoice qwSopTempVoice)
+//    {
+//        return toAjax(qwSopTempVoiceService.updateQwSopTempVoice(qwSopTempVoice));
+//    }
+
+    /**
+     * 删除模板对应的销售语音文件
+     */
+    @PreAuthorize("@ss.hasPermi('qw:QwSopTempVoice:remove')")
+    @Log(title = "模板对应的销售语音文本", businessType = BusinessType.DELETE)
+    @PostMapping("/removeSidebar")
+    public AjaxResult remove(@RequestBody QwSopTempVoice qwSopTempVoice)
+    {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        qwSopTempVoice.setCompanyId(loginUser.getUser().getCompanyId());
+        return toAjax(qwSopTempVoiceService.deleteQwSopTempVoiceByIdSidebar(qwSopTempVoice));
+    }
 }

+ 1 - 0
fs-ipad-task/src/main/java/com/fs/app/service/IpadSendServer.java

@@ -696,6 +696,7 @@ public class IpadSendServer {
         vo.setCorpCode(parentVo.getCorpCode());
         vo.setCorpId(parentVo.getCorpId());
         vo.setQwUserId(qwUser.getId());
+
         try {
             content.setSendStatus(1);
             if(qwSopLogs.getSendType() == 21 && "11".equals(content.getContentType())){

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

@@ -0,0 +1,88 @@
+<?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-websocket</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);
+        }
+    }
+}

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

@@ -0,0 +1,1125 @@
+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:*";
+        final String redTaskPrefix = "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 {
+                // 广播红包关闭消息
+                Long liveId = Long.valueOf(liveKey.substring(redTaskPrefix.length()));
+                WsSendMsgVo sendMsgVo = new WsSendMsgVo();
+                sendMsgVo.setLiveId(liveId);
+                sendMsgVo.setCmd("red");
+                sendMsgVo.setStatus(-1);
+                liveService.asyncToCacheLiveConfig(liveId);
+                liveWsRoomBroadcastFacade.broadcastMessage(liveId, 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;
+}

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

@@ -0,0 +1,27 @@
+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 {
+
+    /** 是否开启 Netty WebSocket(默认开启) */
+    private boolean enabled = true;
+    /** Netty WebSocket 端口(与 server.port=7114 均可连接) */
+    private int nettyPort = 7116;
+    private String path = "/ws/app/webSocket";
+    private int maxConnectionsPerNode = 25000;
+    private String nodeId = "fs-live-ws-local";
+    private String publicHost = "127.0.0.1";
+    private String publicScheme = "ws";
+    /** 对外推荐端口(默认 Tomcat 7114,也可改为 nettyPort) */
+    private int publicPort = 7114;
+    private int heartbeatTimeoutSeconds = 120;
+    private int nodeHeartbeatSeconds = 10;
+    /** 直播间在线人数 userCount 广播间隔(秒) */
+    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;
+    }
+}

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

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

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

@@ -0,0 +1,24 @@
+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 TIMESTAMP = "timestamp";
+    public static final String SIGNATURE = "signature";
+    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";
+}

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

@@ -0,0 +1,103 @@
+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.beans.factory.annotation.Value;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+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;
+    @Value("${server.port:7114}")
+    private int serverPort;
+
+    @ApiOperation("WebSocket 服务健康检查")
+    @GetMapping("/wsHealth")
+    public R wsHealth() {
+        return R.ok()
+                .put("httpPort", serverPort)
+                .put("wsPort", properties.getPublicPort())
+                .put("nettyPort", properties.getNettyPort())
+                .put("wsPath", properties.getPath())
+                .put("wsEnabled", properties.isEnabled())
+                .put("wsUrlExample", buildWsUrl(properties.getPublicHost(),
+                        String.valueOf(properties.getPublicPort()), 0L).replace("liveId=0", "liveId={liveId}"))
+                .put("nettyWsUrlExample", buildWsUrl(properties.getPublicHost(),
+                        String.valueOf(properties.getNettyPort()), 0L).replace("liveId=0", "liveId={liveId}"));
+    }
+
+    @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.getPublicPort()),
+                    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));
+        }
+        // 同节点额外回退 Netty 端口
+        fallback.add(buildWsUrl(String.valueOf(best.get("host")),
+                String.valueOf(properties.getNettyPort()), liveId));
+
+        return buildResponse(String.valueOf(best.get("host")), String.valueOf(best.get("port")),
+                String.valueOf(best.get("nodeId")), liveId, fallback);
+    }
+
+    private R buildResponse(String host, String port, String nodeId, Long liveId, List<String> fallback) {
+        return R.ok()
+                .put("wsUrl", buildWsUrl(host, port, liveId))
+                .put("nettyWsUrl", buildWsUrl(host, String.valueOf(properties.getNettyPort()), liveId))
+                .put("nodeId", nodeId)
+                .put("fallback", fallback == null ? new ArrayList<>() : fallback);
+    }
+
+    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));
+    }
+}

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

@@ -0,0 +1,84 @@
+package com.fs.live.ws.handler;
+
+import com.fs.live.ws.constant.WsAttrConstant;
+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.stereotype.Component;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * WebSocket 握手鉴权。
+ * 已关闭 token / signature 校验,仅校验 liveId、userId。
+ */
+@Component
+@ChannelHandler.Sharable
+public class WsAuthHandler extends ChannelInboundHandlerAdapter {
+
+    private static final Logger log = LoggerFactory.getLogger(WsAuthHandler.class);
+
+    @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;
+        }
+
+        Long liveId = Long.valueOf(params.get(WsAttrConstant.LIVE_ID).get(0));
+        Long userId = Long.valueOf(params.get(WsAttrConstant.USER_ID).get(0));
+        long userType = 0L;
+        if (params.containsKey(WsAttrConstant.USER_TYPE)) {
+            try {
+                userType = Long.parseLong(params.get(WsAttrConstant.USER_TYPE).get(0));
+            } catch (NumberFormatException ex) {
+                log.warn("WS userType 解析失败,默认按观众处理 liveId={}, userId={}", liveId, userId);
+            }
+        }
+
+        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();
+    }
+
+    /** 握手完成前必须返回 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);
+    }
+}

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

@@ -0,0 +1,91 @@
+package com.fs.live.ws.handler;
+
+import com.fs.live.ws.constant.WsAttrConstant;
+import com.fs.live.ws.service.ILiveWsConnectionService;
+import com.fs.live.ws.service.ILiveWsMessageService;
+import com.fs.live.ws.session.ChannelWsSender;
+import com.fs.live.ws.session.WsSender;
+import io.netty.channel.Channel;
+import io.netty.channel.ChannelHandler;
+import io.netty.channel.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();
+            WsSender sender = new ChannelWsSender(channel);
+
+            if (!connectionService.tryAcceptConnection()) {
+                sender.sendText("Error: 4503 节点连接已满");
+                channel.close();
+                return;
+            }
+            connectionExecutor.execute(() -> {
+                try {
+                    connectionService.onConnected(liveId, userId, userType, sender, location);
+                } catch (Exception ex) {
+                    log.warn("WS 连接建立失败 liveId={}, userId={}", liveId, userId, ex);
+                    sender.sendText("Error: " + ex.getMessage());
+                    channel.close();
+                }
+            });
+            return;
+        }
+        super.userEventTriggered(ctx, evt);
+    }
+
+    @Override
+    protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) {
+        Channel channel = ctx.channel();
+        messageService.handleTextMessage(
+                new ChannelWsSender(channel),
+                channel.attr(WsAttrConstant.ATTR_LIVE_ID).get(),
+                channel.attr(WsAttrConstant.ATTR_USER_ID).get(),
+                channel.attr(WsAttrConstant.ATTR_USER_TYPE).get(),
+                frame.text());
+    }
+
+    @Override
+    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, new ChannelWsSender(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;
+        }
+    }
+}

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

@@ -0,0 +1,105 @@
+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://{}:{}{} (Tomcat HTTP/WS 在 server.port={}{},两端口均可连接)",
+                    properties.getPublicHost(), properties.getNettyPort(), properties.getPath(),
+                    properties.getPublicPort(), properties.getPath());
+            serverChannel.closeFuture().sync();
+        } catch (Exception ex) {
+            log.error("Netty WS 启动失败", ex);
+        } 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 com.fs.live.ws.session.WsSender;
+
+public interface ILiveWsConnectionService {
+
+    /** @return true 允许连接;false 节点已满 */
+    boolean tryAcceptConnection();
+
+    void onConnected(Long liveId, Long userId, Long userType, WsSender sender, String location);
+
+    void onDisconnected(Long liveId, Long userId, Long userType, WsSender sender);
+}

+ 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 com.fs.live.ws.session.WsSender;
+
+public interface ILiveWsMessageService {
+
+    void handleTextMessage(WsSender sender, Long liveId, Long userId, Long userType, 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();
+}

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

@@ -0,0 +1,59 @@
+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.session.WsSender;
+import com.fs.live.ws.task.LiveWsLikeBroadcastTask;
+import com.fs.live.ws.task.LiveWsUserCountBroadcastTask;
+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) {
+        WsSender sender = roomManager.getUserSender(liveId, userId);
+        if (sender == null || !sender.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));
+        sender.sendText(JSONObject.toJSONString(R.ok().put("data", sendMsgVo)));
+    }
+}

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

@@ -0,0 +1,423 @@
+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.live.ws.session.WsSender;
+import com.fs.sensitive.ProductionWordFilter;
+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(WsSender sender, 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(sender, 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(WsSender adminSender, 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 (adminSender != null && adminSender.isActive()) {
+            if (sent) {
+                adminSender.sendText(JSONObject.toJSONString(R.ok().put("msg", "回复已发送")));
+            } else {
+                adminSender.sendText(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);
+        }
+    }
+}

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

@@ -0,0 +1,65 @@
+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 com.fs.live.ws.session.WsSender;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+@Service
+public class LiveWsBroadcastServiceImpl implements ILiveWsBroadcastService {
+
+    private static final Logger log = LoggerFactory.getLogger(LiveWsBroadcastServiceImpl.class);
+
+    @Autowired
+    private LiveWsRoomManager roomManager;
+    @Autowired
+    private StringRedisTemplate stringRedisTemplate;
+
+    /**
+     * 向直播间所有连接广播(含本节点与其它节点)。
+     * 仅走 Redis Pub/Sub,由 {@link com.fs.live.ws.listener.LiveWsRoomMessageListener} 在各节点 broadcastLocal,
+     * 避免本节点「先 local 再 Redis 回调 local」导致客户端收到重复推送。
+     */
+    @Override
+    public void broadcastToRoom(Long liveId, String message) {
+        try {
+            stringRedisTemplate.convertAndSend(String.format(WsRedisKeys.WS_ROOM_CHANNEL, liveId), message);
+        } catch (Exception ex) {
+            log.warn("Redis 广播失败 liveId={},降级为本节点广播", liveId, ex);
+            broadcastLocal(liveId, message);
+        }
+    }
+
+    @Override
+    public void broadcastLocal(Long liveId, String message) {
+        List<WsSender> group = roomManager.getRoomGroup(liveId);
+        if (group == null || group.isEmpty()) {
+            return;
+        }
+        for (WsSender sender : group) {
+            if (sender != null && sender.isActive()) {
+                sender.sendText(message);
+            }
+        }
+    }
+
+    @Override
+    public boolean sendToUser(Long liveId, Long userId, String message) {
+        if (liveId == null || userId == null || message == null) {
+            return false;
+        }
+        WsSender sender = roomManager.getUserSender(liveId, userId);
+        if (sender == null || !sender.isActive()) {
+            return false;
+        }
+        sender.sendText(message);
+        return true;
+    }
+}

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

@@ -0,0 +1,121 @@
+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 com.fs.live.ws.session.WsSender;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.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, WsSender sender, 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, sender);
+
+            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, sender);
+        }
+    }
+
+    @Override
+    public void onDisconnected(Long liveId, Long userId, Long userType, WsSender sender) {
+        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, sender);
+            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;
+    }
+}

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

@@ -0,0 +1,109 @@
+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.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.service.ILiveWsBroadcastService;
+import com.fs.live.ws.service.ILiveWsMessageService;
+import com.fs.live.ws.session.WsSender;
+import com.fs.sensitive.ProductionWordFilter;
+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(WsSender sender, Long liveId, Long userId, Long userType, String payload) {
+        if (liveId == null || sender == 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":
+                    sender.sendText(JSONObject.toJSONString(R.ok().put("data", msg)));
+                    break;
+                case "sendMsg":
+                    handleSendMsg(sender, liveId, userType, msg);
+                    break;
+                default:
+                    if (userType != null && userType == 1L
+                            && adminMessageHandler.handle(sender, 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(WsSender sender, 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) {
+                sender.sendText(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)));
+    }
+}

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

@@ -0,0 +1,95 @@
+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());
+        // 对外优先宣传 Tomcat 7114;Netty 端口作为备用
+        node.put("port", properties.getPublicPort());
+        node.put("nettyPort", properties.getNettyPort());
+        node.put("connections", roomManager.getConnectionCount());
+        node.put("maxConnections", properties.getMaxConnectionsPerNode());
+        node.put("updateTime", System.currentTimeMillis());
+        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.getPublicPort();
+        }
+        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));
+    }
+}

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

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

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

@@ -0,0 +1,104 @@
+package com.fs.live.ws.session;
+
+import org.springframework.stereotype.Component;
+
+import java.util.HashSet;
+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 连接注册表(兼容 Netty Channel / Tomcat Session,跨节点靠 Redis Pub/Sub)。
+ */
+@Component
+public class LiveWsRoomManager {
+
+    private final AtomicInteger connectionCount = new AtomicInteger(0);
+    private final ConcurrentHashMap<Long, ConcurrentHashMap<Long, WsSender>> userRooms = new ConcurrentHashMap<>();
+    private final ConcurrentHashMap<Long, CopyOnWriteArrayList<WsSender>> adminRooms = new ConcurrentHashMap<>();
+    private final ConcurrentHashMap<Long, CopyOnWriteArrayList<WsSender>> roomGroups = new ConcurrentHashMap<>();
+
+    public int incrementAndGetConnections() {
+        return connectionCount.incrementAndGet();
+    }
+
+    public int decrementAndGetConnections() {
+        return connectionCount.decrementAndGet();
+    }
+
+    public int getConnectionCount() {
+        return connectionCount.get();
+    }
+
+    public ConcurrentHashMap<Long, WsSender> getUserRoom(Long liveId) {
+        return userRooms.computeIfAbsent(liveId, k -> new ConcurrentHashMap<>());
+    }
+
+    public CopyOnWriteArrayList<WsSender> getAdminRoom(Long liveId) {
+        return adminRooms.computeIfAbsent(liveId, k -> new CopyOnWriteArrayList<>());
+    }
+
+    public CopyOnWriteArrayList<WsSender> getRoomGroup(Long liveId) {
+        return roomGroups.computeIfAbsent(liveId, k -> new CopyOnWriteArrayList<>());
+    }
+
+    public int getUserOnlineCount(Long liveId) {
+        ConcurrentHashMap<Long, WsSender> room = userRooms.get(liveId);
+        return room == null ? 0 : room.size();
+    }
+
+    public Set<Long> getActiveLiveIds() {
+        Set<Long> ids = new HashSet<>();
+        ids.addAll(userRooms.keySet());
+        ids.addAll(roomGroups.keySet());
+        return ids;
+    }
+
+    public WsSender getUserSender(Long liveId, Long userId) {
+        ConcurrentHashMap<Long, WsSender> room = userRooms.get(liveId);
+        return room == null ? null : room.get(userId);
+    }
+
+    public void bindUser(Long liveId, Long userId, WsSender sender) {
+        getUserRoom(liveId).put(userId, sender);
+        CopyOnWriteArrayList<WsSender> group = getRoomGroup(liveId);
+        group.remove(sender);
+        group.add(sender);
+    }
+
+    public void bindAdmin(Long liveId, WsSender sender) {
+        getAdminRoom(liveId).add(sender);
+        CopyOnWriteArrayList<WsSender> group = getRoomGroup(liveId);
+        group.remove(sender);
+        group.add(sender);
+    }
+
+    public void unbind(Long liveId, Long userId, Long userType, WsSender sender) {
+        if (userType != null && userType == 0L && userId != null) {
+            Map<Long, WsSender> room = userRooms.get(liveId);
+            if (room != null) {
+                room.remove(userId, sender);
+                if (room.isEmpty()) {
+                    userRooms.remove(liveId);
+                }
+            }
+        } else {
+            CopyOnWriteArrayList<WsSender> admins = adminRooms.get(liveId);
+            if (admins != null) {
+                admins.remove(sender);
+                if (admins.isEmpty()) {
+                    adminRooms.remove(liveId);
+                }
+            }
+        }
+        CopyOnWriteArrayList<WsSender> group = roomGroups.get(liveId);
+        if (group != null) {
+            group.remove(sender);
+            if (group.isEmpty()) {
+                roomGroups.remove(liveId);
+            }
+        }
+    }
+}

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

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

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

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

+ 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);
+    }
+}

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

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

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

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

+ 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;
+    }
+}

+ 38 - 0
fs-live-ws/src/main/java/com/fs/live/ws/util/WsVerifyUtils.java

@@ -0,0 +1,38 @@
+package com.fs.live.ws.util;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * 管理端 WebSocket 握手签名(与 adminUI liveWS.js HMAC 规则一致)
+ */
+public final class WsVerifyUtils {
+
+    private WsVerifyUtils() {
+    }
+
+    public static boolean verifySignature(String liveId, String userId, String userType,
+                                          String timestamp, String signature) throws Exception {
+        String expected = generateSignature(liveId, userId, userType, timestamp);
+        return expected.equals(signature);
+    }
+
+    private static String generateSignature(String liveId, String userId, String userType, String secret)
+            throws Exception {
+        String data = liveId + userId + userType + secret;
+        Mac mac = Mac.getInstance("HmacSHA256");
+        SecretKeySpec keySpec = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
+        mac.init(keySpec);
+        byte[] rawHmac = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
+        return bytesToHex(rawHmac);
+    }
+
+    private static String bytesToHex(byte[] bytes) {
+        StringBuilder sb = new StringBuilder();
+        for (byte b : bytes) {
+            sb.append(String.format("%02x", b));
+        }
+        return sb.toString();
+    }
+}

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

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

+ 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>

+ 33 - 3
fs-qw-mq/src/main/java/com/fs/app/mq/courseFinishRtyTaskOne.java

@@ -1,8 +1,11 @@
 package com.fs.app.mq;
 
 
+import com.alibaba.fastjson.JSON;
 import com.fs.common.core.redis.RedisCache;
 import com.fs.qw.domain.QwCourseFinishRemarkRty;
+import com.fs.qw.domain.QwExternalContact;
+import com.fs.qw.mapper.QwExternalContactMapper;
 import com.fs.qw.service.IQwCourseFinishRemarkRtyService;
 import com.fs.qwApi.domain.QwExternalContactRemarkResult;
 import com.fs.qwApi.param.QwExternalContactRemarkParam;
@@ -25,12 +28,13 @@ public class courseFinishRtyTaskOne {
     @Autowired
     private IQwCourseFinishRemarkRtyService finishRemarkRtyService;
 
-    @Autowired
-    private RedisCache redisCache;
 
     @Autowired
     private QwApiService qwApiService;
 
+    @Autowired
+    private QwExternalContactMapper qwExternalContactMapper;
+
     /**
      * 完课打备注失败的 重复打 一直到失败 (这个 是销售1的 启动项)
      */
@@ -54,6 +58,9 @@ public class courseFinishRtyTaskOne {
             // 使用线程安全的List来收集需要批量更新的数据
             List<Long> batchUpdateList = Collections.synchronizedList(new ArrayList<>());
 
+            // 使用线程安全的List来收集需要批量更新的数据
+            List<QwExternalContact> batchUpdateContactList = Collections.synchronizedList(new ArrayList<>());
+
             // 存储Future对象以便检查所有任务完成情况
             List<Future<?>> futures = new ArrayList<>();
 
@@ -83,6 +90,14 @@ public class courseFinishRtyTaskOne {
                         if (qwResult.getErrcode() == 0) {
                             // 添加到批量更新列表
                             batchUpdateList.add(remarkRty.getId());
+
+                            QwExternalContact qwExternal = new QwExternalContact();
+                            qwExternal.setRemark(remarkRty.getRemark());
+                            qwExternal.setId(remarkRty.getExternalId());
+
+                            // 添加到批量更新列表
+                            batchUpdateContactList.add(qwExternal);
+
                         }else {
                             log.error("客户添加备注失败task,userId: {}{}{}", remarkRty.getQwUserId(), remarkRty.getExternalUserId(), qwResult.getErrmsg());
                         }
@@ -121,6 +136,21 @@ public class courseFinishRtyTaskOne {
                 }
             }
 
+            // 批量更新数据库
+            if (!batchUpdateContactList.isEmpty()) {
+                try {
+                    // 分批处理,避免单次批量过大
+                    int batchSize = 500; // 根据数据库性能调整
+                    for (int i = 0; i < batchUpdateContactList.size(); i += batchSize) {
+                        int end = Math.min(i + batchSize, batchUpdateContactList.size());
+                        List<QwExternalContact> subList = batchUpdateContactList.subList(i, end);
+                        qwExternalContactMapper.batchUpdateQwExternalContactByTags(subList);
+                    }
+                } catch (Exception e) {
+                    log.error("批量更新失败: " + e.getMessage());
+                }
+            }
+
             // 关闭线程池
             executor.shutdown();
 
@@ -155,7 +185,7 @@ public class courseFinishRtyTaskOne {
 
 
     /**
-     * 批量删除 掉 前一天的没打上标签
+     * 批量删除 掉 前一天的没打上备注
      */
     @Scheduled(cron = "0 30 0 * * ?")
     public void deleteFinishCourseRemark() {

+ 223 - 0
fs-qw-mq/src/main/java/com/fs/app/mq/courseFinishTagRtyTask.java

@@ -0,0 +1,223 @@
+package com.fs.app.mq;
+
+
+import com.alibaba.fastjson.JSON;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.qw.domain.QwCourseFinishTagRty;
+import com.fs.qw.domain.QwExternalContact;
+import com.fs.qw.mapper.QwExternalContactMapper;
+import com.fs.qw.service.IQwCourseFinishTagRtyService;
+import com.fs.qwApi.domain.QwExternalContactRemarkResult;
+import com.fs.qwApi.domain.QwResult;
+import com.fs.qwApi.param.QwEditUserTagParam;
+import com.fs.qwApi.param.QwExternalContactRemarkParam;
+import com.fs.qwApi.service.QwApiService;
+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.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.*;
+
+@Component
+@Slf4j
+public class courseFinishTagRtyTask {
+
+    @Autowired
+    private IQwCourseFinishTagRtyService tagRtyService;
+
+    @Autowired
+    private QwApiService qwApiService;
+
+    @Autowired
+    private QwExternalContactMapper qwExternalContactMapper;
+
+
+    /**
+     * 完课打标签失败的 重复打 一直到失败
+     */
+
+    @Scheduled(cron = "0 0/12 * * * ?") // 每12分钟
+//    @Scheduled(cron = "0/1 * * * * ? ") // 每15分钟
+    public void selectSopUserLogsListByTimeOne() {
+
+        System.out.println("schedule.task-enabled-tag");
+
+        // 获取当前时间
+        LocalDateTime now = LocalDateTime.now();
+        // 计算10分钟前
+        LocalDateTime tenMinutesBefore = now.minusMinutes(10);
+
+        // 创建线程池
+        int threadCount = Math.min(8, Runtime.getRuntime().availableProcessors() * 2);
+        ExecutorService executor = Executors.newFixedThreadPool(threadCount);
+
+        try {
+            // 使用线程安全的List来收集需要批量更新的数据
+            List<Long> batchUpdateList = Collections.synchronizedList(new ArrayList<>());
+            // 使用线程安全的List来收集需要批量更新的数据
+            List<QwExternalContact> batchUpdateContactList = Collections.synchronizedList(new ArrayList<>());
+
+            // 存储Future对象以便检查所有任务完成情况
+            List<Future<?>> futures = new ArrayList<>();
+
+            // 1. 批量查询所有用户数据
+            List<QwCourseFinishTagRty> remarkRtyList=new ArrayList<>();
+            try {
+                remarkRtyList = tagRtyService.selectQwCourseFinishRemarkTagRtyListByTime(tenMinutesBefore,1);
+                if (remarkRtyList == null || remarkRtyList.isEmpty()) {
+                     return;
+                }
+            } catch (Exception e) {
+                e.printStackTrace();
+                log.info("标签-批量查询用户数据失败:" + e.getMessage());
+            }
+
+            // 直接遍历contacts而不是userIds
+            for (QwCourseFinishTagRty remarkRty : remarkRtyList) {
+                futures.add(executor.submit(() -> {
+                    try {
+
+                        //组装标签
+                        List<String> addTagId=new ArrayList<>();
+                        addTagId.add(remarkRty.getTagIds());
+
+                        QwEditUserTagParam qwEditUserTagParam = new QwEditUserTagParam();
+                        qwEditUserTagParam.setAdd_tag(addTagId);
+                        qwEditUserTagParam.setUserid(remarkRty.getQwUserId());
+                        qwEditUserTagParam.setExternal_userid(remarkRty.getExternalUserId());
+
+                        QwResult qwResult = qwApiService.editUserTag(qwEditUserTagParam, remarkRty.getCorpId());
+                        if (qwResult.getErrcode() == 0) {
+                            // 添加到批量更新列表
+                            batchUpdateList.add(remarkRty.getId());
+
+                            QwExternalContact qwExternal = new QwExternalContact();
+                            qwExternal.setTagIds(JSON.toJSONString(addTagId));
+                            qwExternal.setId(remarkRty.getExternalId());
+
+                            // 添加到批量更新列表
+                            batchUpdateContactList.add(qwExternal);
+
+                        }else {
+                            log.error("客户添加标签失败task,userId: {}{}{}", remarkRty.getQwUserId(), remarkRty.getExternalUserId(), qwResult.getErrmsg());
+                        }
+                    } catch (Exception e) {
+                        log.error("客户添加标签失败task,userId: " + remarkRty.getQwUserId() +
+                                ", externalUserId: " + remarkRty.getExternalUserId() +
+                                ", 错误信息: " + e.getMessage());
+                    }
+                }));
+            }
+
+
+            // 等待所有任务完成
+            for (Future<?> future : futures) {
+                try {
+                    future.get();
+                } catch (InterruptedException | ExecutionException e) {
+                    log.error("任务执行异常: " + e.getMessage());
+                    Thread.currentThread().interrupt();
+                }
+            }
+
+            // 批量更新数据库
+            if (!batchUpdateList.isEmpty()) {
+                try {
+                    // 分批处理,避免单次批量过大
+                    int batchSize = 500; // 根据数据库性能调整
+                    for (int i = 0; i < batchUpdateList.size(); i += batchSize) {
+                        int end = Math.min(i + batchSize, batchUpdateList.size());
+                        List<Long> subList = batchUpdateList.subList(i, end);
+
+                        tagRtyService.deleteQwCourseFinishTagRtyByIds(subList.toArray(new Long[0]));
+                    }
+                } catch (Exception e) {
+                    log.error("批量更新失败: " + e.getMessage());
+                }
+            }
+
+            // 批量更新数据库
+            if (!batchUpdateContactList.isEmpty()) {
+                try {
+                    // 分批处理,避免单次批量过大
+                    int batchSize = 500; // 根据数据库性能调整
+                    for (int i = 0; i < batchUpdateContactList.size(); i += batchSize) {
+                        int end = Math.min(i + batchSize, batchUpdateContactList.size());
+                        List<QwExternalContact> subList = batchUpdateContactList.subList(i, end);
+                        qwExternalContactMapper.batchUpdateQwExternalContactByTags(subList);
+                    }
+                } catch (Exception e) {
+                    log.error("批量更新失败: " + e.getMessage());
+                }
+            }
+
+            // 关闭线程池
+            executor.shutdown();
+
+        } catch (Exception e) {
+            log.error("打标签任务执行异常: " + e.getMessage());
+        } finally {
+            // 7. 确保线程池关闭
+            try {
+                // 先尝试正常关闭
+                executor.shutdown();
+                if (!executor.awaitTermination(15, TimeUnit.SECONDS)) {
+                    // 超时后强制关闭
+                    executor.shutdownNow();
+                    log.warn("线程池强制关闭");
+                }
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                executor.shutdownNow();
+            }
+        }
+        try {
+            Thread.sleep(3000);
+        } catch (InterruptedException ex) {
+            log.info("线程被中断");
+        }
+
+
+
+    }
+
+
+
+
+    /**
+     * 批量删除 掉 前一天的没打上标签的
+     */
+    @Scheduled(cron = "0 30 0 * * ?")
+    public void deleteFinishCourseRemark() {
+        long startTimeMillis = System.currentTimeMillis();
+        log.info("====== 批量删除 掉 前一天的没打上标签的 ======");
+
+        List<Long> batchUpdateList = tagRtyService.selectQwCourseFinishRemarkTagRtyListByOutTime();
+
+        // 批量更新数据库
+        if (!batchUpdateList.isEmpty()) {
+            try {
+                // 分批处理,避免单次批量过大
+                int batchSize = 500; // 根据数据库性能调整
+                for (int i = 0; i < batchUpdateList.size(); i += batchSize) {
+                    int end = Math.min(i + batchSize, batchUpdateList.size());
+                    List<Long> subList = batchUpdateList.subList(i, end);
+
+                    tagRtyService.deleteQwCourseFinishTagRtyByIds(subList.toArray(new Long[0]));
+                }
+            } catch (Exception e) {
+                log.error("批量更新失败: " + e.getMessage());
+            }
+        }
+
+        long endTimeMillis = System.currentTimeMillis();
+        log.info("====== 更批量删除 掉 前一天的没打上标签的,耗时 {} 毫秒 ======", (endTimeMillis - startTimeMillis));
+    }
+
+}
+

+ 1 - 1
fs-qw-task/src/main/java/com/fs/app/controller/CommonController.java

@@ -331,7 +331,7 @@ public class CommonController {
                         String userLimitJson = qwContactWay.getUserLimitJson();
                         List<QwContactWayUser> qwContactWayUsers = JSON.parseArray(userLimitJson, QwContactWayUser.class);
 
-                        String wayLimit = "qwContactWayLimit:" + corpId  + ":" + userID;
+                        String wayLimit = "qwContactWayLimit:" + corpId  + ":" + userID+":"+qwContactWay.getId();
 
                         QwContactWayUser matchedUser = qwContactWayUsers.stream()
                                 .filter(u -> userID.equals(u.getUserId()))

+ 34 - 0
fs-qwhook-sop/src/main/java/com/fs/app/controller/ApisFsUserCourseVideoController.java

@@ -3,9 +3,11 @@ package com.fs.app.controller;
 import cn.hutool.json.JSONUtil;
 import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONObject;
+import com.fs.common.annotation.Log;
 import com.fs.common.annotation.RepeatSubmit;
 import com.fs.common.core.controller.BaseController;
 import com.fs.common.core.domain.R;
+import com.fs.common.enums.BusinessType;
 import com.fs.common.utils.CloudHostUtils;
 import com.fs.course.config.CourseConfig;
 import com.fs.course.domain.FsUserCourse;
@@ -230,4 +232,36 @@ public class ApisFsUserCourseVideoController extends BaseController {
         return fsUserCourseVideoService.createRoomMiniLink(param);
     }
 
+    /**
+     * 创建 语音 链接
+     */
+
+    @RepeatSubmit
+    @PostMapping("/createVoiceLink")
+    @Log(title = "createVoiceLink", businessType = BusinessType.INSERT)
+    public R createVoiceLink(@RequestBody  FsCourseLinkMiniParam param) {
+
+        QwUser qwUser = qwExternalContactService.getQwUserByRedis(param.getCorpId().trim(), param.getQwUserId().trim());
+
+        if (StringUtil.strIsNullOrEmpty(param.getQwUserId())){
+            return R.error("用户id不能为空");
+        }
+        if (StringUtil.strIsNullOrEmpty(param.getCorpId())){
+            return R.error("企业id不能为空");
+        }
+
+        if (param.getExternalId()==null && param.getType()==1){
+            return R.error("客户id不能为空");
+        }
+
+        if (StringUtil.strIsNullOrEmpty(param.getVoiceTxt())){
+            return R.error("语音内容不能为空");
+        }
+
+        if (qwUser==null||qwUser.getCompanyId()==null||qwUser.getCompanyUserId()==null){
+            return R.error("员工未绑定 客服公司 或 客服 请先绑定");
+        }
+
+        return fsUserCourseVideoService.createVoiceLink(param,qwUser);
+    }
 }

+ 29 - 0
fs-qwhook-sop/src/main/java/com/fs/app/controller/ApisQwUserController.java

@@ -30,6 +30,8 @@ import com.fs.qw.service.IQwTagGroupService;
 import com.fs.qw.vo.ExternalContactDetailsVO;
 import com.fs.qw.vo.QwExternalListByHeavyVO;
 import com.fs.qw.vo.QwTagGroupListVO;
+import com.fs.sop.domain.QwSopTempVoice;
+import com.fs.sop.service.IQwSopTempVoiceService;
 import com.fs.sop.service.ISopUserLogsInfoService;
 import com.fs.sop.vo.ExtCourseSopWatchLogVO;
 import com.github.pagehelper.PageHelper;
@@ -77,6 +79,10 @@ public class ApisQwUserController extends BaseController {
     private IQwTagGroupService qwTagGroupService;
 
 
+    @Autowired
+    private IQwSopTempVoiceService sopTempVoiceService;
+
+
     @GetMapping("/details")
     @ApiOperation("会员看课详情")
     public R getUserDetails(@ApiParam(value = "外部联系人id", required = true) @RequestParam Long contactId,
@@ -222,4 +228,27 @@ public class ApisQwUserController extends BaseController {
         return R.error();
     }
 
+
+    /**
+     * 获取销售语音列表
+     */
+    @PostMapping("/getQwUserVoice")
+    @ApiOperation("获取销售语音列表")
+    public R getQwUserVoice(@RequestBody FsCourseListBySidebarParam param){
+        QwUser qwUser = qwExternalContactService.getQwUserByRedis(param.getCorpId().trim(),param.getQwUserId().trim());
+
+        if (qwUser == null) {
+            return R.error("未查询到企业微信账号信息!请重试");
+        }
+
+        if (qwUser.getCompanyUserId() == null) {
+            return R.error("查询到企业微信账号 未绑定销售!!!请先绑定");
+        }
+
+        PageHelper.startPage(param.getPageNum(), param.getPageSize());
+        List<QwSopTempVoice> voiceList = sopTempVoiceService.selectQwSopTempVoiceByCompanyUserId(qwUser.getCompanyUserId(),param.getKeyword());
+        PageInfo<QwSopTempVoice> result = new PageInfo<>(voiceList);
+        return R.ok().put("data", result);
+    }
+
 }

+ 35 - 0
fs-qwhook-sop/src/main/java/com/fs/app/controller/FsUserCourseVideoController.java

@@ -4,9 +4,11 @@ import cn.hutool.json.JSONUtil;
 import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONObject;
 import com.fs.app.annotation.Login;
+import com.fs.common.annotation.Log;
 import com.fs.common.annotation.RepeatSubmit;
 import com.fs.common.core.domain.R;
 import com.fs.common.core.domain.ResponseResult;
+import com.fs.common.enums.BusinessType;
 import com.fs.common.utils.CloudHostUtils;
 import com.fs.common.utils.ParseUtils;
 import com.fs.common.utils.StringUtils;
@@ -484,4 +486,37 @@ public class FsUserCourseVideoController {
             return fsUserCourseVideoService.createMiniLink(param);
         }
     }
+
+    /**
+     * 创建 语音 链接
+     */
+
+    @RepeatSubmit
+    @PostMapping("/createVoiceLink")
+    @Log(title = "createVoiceLink", businessType = BusinessType.INSERT)
+    public R createVoiceLink(@RequestBody  FsCourseLinkMiniParam param) {
+
+        QwUser qwUser = qwExternalContactService.getQwUserByRedis(param.getCorpId().trim(), param.getQwUserId().trim());
+
+        if (StringUtil.strIsNullOrEmpty(param.getQwUserId())){
+            return R.error("用户id不能为空");
+        }
+        if (StringUtil.strIsNullOrEmpty(param.getCorpId())){
+            return R.error("企业id不能为空");
+        }
+
+        if (param.getExternalId()==null && param.getType()==1){
+            return R.error("客户id不能为空");
+        }
+
+        if (StringUtil.strIsNullOrEmpty(param.getVoiceTxt())){
+            return R.error("语音内容不能为空");
+        }
+
+        if (qwUser==null||qwUser.getCompanyId()==null||qwUser.getCompanyUserId()==null){
+            return R.error("员工未绑定 客服公司 或 客服 请先绑定");
+        }
+
+        return fsUserCourseVideoService.createVoiceLink(param,qwUser);
+    }
 }

+ 30 - 0
fs-qwhook-sop/src/main/java/com/fs/app/controller/QwUserController.java

@@ -16,8 +16,10 @@ import com.fs.company.param.CompanyUserCodeParam;
 import com.fs.company.service.ICompanyPostService;
 import com.fs.company.service.ICompanyService;
 import com.fs.company.service.ICompanyUserService;
+import com.fs.course.param.FsCourseListBySidebarParam;
 import com.fs.qw.domain.QwExternalContactInfo;
 import com.fs.qw.domain.QwTagGroup;
+import com.fs.qw.domain.QwUser;
 import com.fs.qw.param.ExternalContactDetailsParam;
 import com.fs.qw.param.QwExtCourseSopWatchLog;
 import com.fs.qw.param.sidebar.ExternalContactInfoParam;
@@ -30,6 +32,8 @@ import com.fs.qw.vo.ExternalContactDetailsVO;
 import com.fs.qw.vo.QwTagGroupListVO;
 import com.fs.qw.vo.sidebar.ExternalContactInfoVO;
 import com.fs.qw.vo.sidebar.ExternalContactTagVO;
+import com.fs.sop.domain.QwSopTempVoice;
+import com.fs.sop.service.IQwSopTempVoiceService;
 import com.fs.sop.service.ISopUserLogsInfoService;
 import com.fs.sop.vo.ExtCourseSopWatchLogVO;
 import com.github.pagehelper.PageHelper;
@@ -73,6 +77,10 @@ public class QwUserController extends BaseController {
     @Autowired
     private ICompanyService companyService;
 
+
+    @Autowired
+    private IQwSopTempVoiceService sopTempVoiceService;
+
     @Autowired
     ICompanyPostService postService;
 
@@ -223,4 +231,26 @@ public class QwUserController extends BaseController {
         }
     }
 
+    /**
+     * 获取销售语音列表
+     */
+    @PostMapping("/getQwUserVoice")
+    @ApiOperation("获取销售语音列表")
+    public R getQwUserVoice(@RequestBody FsCourseListBySidebarParam param){
+        QwUser qwUser = qwExternalContactService.getQwUserByRedis(param.getCorpId().trim(),param.getQwUserId().trim());
+
+        if (qwUser == null) {
+            return R.error("未查询到企业微信账号信息!请重试");
+        }
+
+        if (qwUser.getCompanyUserId() == null) {
+            return R.error("查询到企业微信账号 未绑定销售!!!请先绑定");
+        }
+
+        PageHelper.startPage(param.getPageNum(), param.getPageSize());
+        List<QwSopTempVoice> voiceList = sopTempVoiceService.selectQwSopTempVoiceByCompanyUserId(qwUser.getCompanyUserId(),param.getKeyword());
+        PageInfo<QwSopTempVoice> result = new PageInfo<>(voiceList);
+        return R.ok().put("data", result);
+    }
+
 }

+ 70 - 0
fs-service/src/main/java/com/fs/aiCallSop/commonConstant/AiSopCallRemoteUrlConstant.java

@@ -0,0 +1,70 @@
+package com.fs.aiCallSop.commonConstant;
+
+import org.springframework.stereotype.Component;
+
+/**
+ * @Author:peicj
+ * @Description: ai外呼远程接口常量配置
+ * @Date:2026/1/30 16:31
+ */
+@Component
+public class AiSopCallRemoteUrlConstant {
+    /**
+     * 请求地址前缀
+     */
+    public static final String REQ_ADDRESS_PREFIX = "http://43.136.169.132/api/signal-service";
+    /**
+     * 获取员工
+     */
+    public static final String GET_STAFF_NUMBER_URL = REQ_ADDRESS_PREFIX + "/api/getStaffNumber";
+    /**
+     * 创建员工
+     */
+    public static final String GET_STAFF_CREATE_URL = REQ_ADDRESS_PREFIX + "/api/staff/create";
+    /**
+     * 修改和删除员工
+     */
+    public static final String GET_STAFF_URL = REQ_ADDRESS_PREFIX + "/api/staff";
+    /**
+     * 获取模型列表
+     */
+    public static final String GET_MODEL_LIST_URL = REQ_ADDRESS_PREFIX + "/api/models/list";
+    /**
+     * 获取所有语音
+     */
+    public static final String GET_VOICES_LIST_ALL_URL = REQ_ADDRESS_PREFIX + "/api/voices/list-all";
+    /**
+     * 获取语音列表
+     */
+    public static final String GET_VOICES_LIST_URL = REQ_ADDRESS_PREFIX + "/api/voices/list";
+
+    /**
+     * 获取聊天详情
+     */
+    public static final String GET_CHAT_SESSION_URL = REQ_ADDRESS_PREFIX + "/api/chat/session";
+
+    //===================知识库接口begin===================
+    /**
+     * 知识库分页列表 POST
+     */
+    public static final String GET_KNOWLEDGE_LIST_URL = REQ_ADDRESS_PREFIX + "/api/knowledge/list";
+    /**
+     * 知识库列表(不分页) POST
+     */
+    public static final String GET_KNOWLEDGE_LIST_ALL_URL = REQ_ADDRESS_PREFIX + "/api/knowledge/list-all";
+    /**
+     * 知识库创建 POST
+     */
+    public static final String GET_KNOWLEDGE_CREATE_URL = REQ_ADDRESS_PREFIX + "/api/knowledge/create";
+    /**
+     * 知识库修改 PUT
+     * 删除 DELETE
+     * 知识库详情查询 GET
+     */
+    public static final String GET_KNOWLEDGE_URL = REQ_ADDRESS_PREFIX + "/api/knowledge";
+    /**
+     * 知识库批量删除 DELETE
+     */
+    public static final String GET_KNOWLEDGE_DELETE_BATCH_URL = REQ_ADDRESS_PREFIX + "/api/knowledge/delete-batch";
+    //===================知识库接口end===================
+}

+ 71 - 0
fs-service/src/main/java/com/fs/aiCallSop/domain/AiSopCallKnowledgeBase.java

@@ -0,0 +1,71 @@
+package com.fs.aiCallSop.domain;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fs.common.annotation.Excel;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.util.Date;
+
+/**
+ * AI外呼知识库对象 ai_sop_call_knowledge_base
+ *
+ * @author fs
+ * @date 2026-01-30
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class AiSopCallKnowledgeBase extends BaseEntity{
+
+    /** 主键ID */
+    @Excel(name = "主键ID")
+    private Long id;
+
+    /** 知识库名称 */
+    @Excel(name = "知识库名称")
+    private String knowledgeName;
+
+    /** 提供商 可选值: openai、bytedance、alibaba、http
+     * openai OpenAI   bytedance 字节跳动  alibaba  阿里巴巴 http 第三方 HTTP调用
+     * */
+    @Excel(name = "提供商", readConverterExp = "openai=OpenAI,bytedance=字节跳动,alibaba=阿里巴巴,http=第三方 HTTP调用")
+    private String factoryName;
+
+    /** 是否启用 1: 启用 0: 禁用,默认 1 */
+    @Excel(name = "是否启用", readConverterExp = "1=启用,0=禁用")
+    private Integer enable;
+
+    /** 扩展配置,不同厂商字段不同,自由扩展 */
+    @Excel(name = "扩展配置,不同厂商字段不同,自由扩展")
+    private Object metadata;
+
+    /** 企业id */
+    private Long companyId;
+
+    /** 销售id */
+    private Long companyUserId;
+    /** 远程知识库id */
+    private Long remoteKnowledgeId;
+    /** 创建者 */
+    @Excel(name = "创建者")
+    private String createBy;
+
+    /** 创建时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @Excel(name = "创建时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss", type = Excel.Type.EXPORT)
+    private Date createTime;
+
+    /** 更新者 */
+    @Excel(name = "更新者")
+    private String updateBy;
+
+    /** 更新时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @Excel(name = "更新时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss", type = Excel.Type.EXPORT)
+    private Date updateTime;
+
+    /** 备注 */
+    @Excel(name = "备注")
+    private String remark;
+}

+ 70 - 0
fs-service/src/main/java/com/fs/aiCallSop/domain/AiSopCallLineConfig.java

@@ -0,0 +1,70 @@
+package com.fs.aiCallSop.domain;
+
+import com.fs.common.annotation.Excel;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * ai呼叫线路配置对象 ai_sop_call_line_config
+ *
+ * @author fs
+ * @date 2026-01-22
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class AiSopCallLineConfig extends BaseEntity{
+
+    /** $column.columnComment */
+    private Long id;
+
+    /** 网关名称 */
+    @Excel(name = "网关名称")
+    private String gatewayName;
+
+    /** 网关用途:1-外呼 */
+    @Excel(name = "网关用途:1-外呼")
+    private Long gatewayPurpose;
+
+    /** profile名称:1-external */
+    @Excel(name = "profile名称:1-external")
+    private Long profileName;
+
+    /** 主叫号码 */
+    @Excel(name = "主叫号码")
+    private String callingNnumber;
+
+    /** 被叫前缀 */
+    @Excel(name = "被叫前缀")
+    private String calledPrefix;
+
+    /** 网关地址 */
+    @Excel(name = "网关地址")
+    private String gatewayAddress;
+
+    /** 外呼语音编码 */
+    @Excel(name = "外呼语音编码")
+    private String outCallVoiceEncoding;
+
+    /** 网关名称描述 */
+    @Excel(name = "网关名称描述")
+    private String gatewayNameDescription;
+
+    /** 网关最大并发数 */
+    @Excel(name = "网关最大并发数")
+    private Long gatewayMaxConcurrency;
+
+    /** 是否认证注册:1-对接模式 */
+    @Excel(name = "是否认证注册:1-对接模式")
+    private Long isCertificationRegistration;
+
+    /** 使用优先级 */
+    @Excel(name = "使用优先级")
+    private Long priorityUsage;
+
+    /** ID */
+    @Excel(name = "ID")
+    private Long companyId;
+
+
+}

+ 63 - 0
fs-service/src/main/java/com/fs/aiCallSop/domain/AiSopCallMsgLog.java

@@ -0,0 +1,63 @@
+package com.fs.aiCallSop.domain;
+
+
+import com.fs.common.annotation.Excel;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * ai外呼记录对象 ai_sop_call_msg_log
+ *
+ * @author fs
+ * @date 2025-12-31
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class AiSopCallMsgLog extends BaseEntity {
+
+    /** $column.columnComment */
+    private Long id;
+
+    /** $column.columnComment */
+    @Excel(name = "${comment}", readConverterExp = "$column.readConverterExp()")
+    private String sessionId;
+
+    /** 销售 */
+    @Excel(name = "销售")
+    private String companyUserId;
+
+    /** 销售公司 */
+    @Excel(name = "销售公司")
+    private Long companyId;
+
+    /** 企业微信账号(非主键) */
+    @Excel(name = "企业微信账号(非主键)")
+    private String userId;
+
+    /** 企业微信主体 */
+    @Excel(name = "企业微信主体")
+    private String corpId;
+
+    /** 外部联系人 */
+    @Excel(name = "外部联系人")
+    private String externalUserId;
+
+    /** 外呼状态 */
+    @Excel(name = "外呼状态")
+    private Long status;
+
+    /** 通话文本内容 */
+    @Excel(name = "通话文本内容")
+    private String callContent;
+
+    /** 通话时长 */
+    @Excel(name = "通话时长")
+    private Long callDuration;
+
+    /** 语音地址 */
+    @Excel(name = "语音地址")
+    private String voiceUrl;
+
+
+}

+ 54 - 0
fs-service/src/main/java/com/fs/aiCallSop/domain/AiSopCallRole.java

@@ -0,0 +1,54 @@
+package com.fs.aiCallSop.domain;
+
+import com.fs.common.annotation.Excel;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * AI外呼角色对象 ai_sop_call_role
+ *
+ * @author fs
+ * @date 2026-01-14
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class AiSopCallRole extends BaseEntity{
+
+    /** 主键ID */
+    private Long id;
+
+    /** 外呼角色名称 */
+    @Excel(name = "外呼角色名称")
+    private String callRoleName;
+
+    /** 外呼实现类 1:deppSeek */
+    @Excel(name = "外呼实现类 1:deppSeek")
+    private Long implClass;
+
+    /** 模型并发数 */
+    @Excel(name = "模型并发数")
+    private Long modelConcurrency;
+
+    /** 服务地址 */
+    @Excel(name = "服务地址")
+    private String serviceAddress;
+
+    /** 角色key */
+    @Excel(name = "角色key")
+    private String callRoleKey;
+
+    /** 模型名称 */
+    @Excel(name = "模型名称")
+    private String modelName;
+
+    /** 大模型提示词 */
+    @Excel(name = "大模型提示词")
+    private String reminderWords;
+
+    /** 备注 */
+    @Excel(name = "备注")
+    private String remake;
+
+
+}

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません