فهرست منبع

新增加微定时任务

吴树波 3 ماه پیش
والد
کامیت
983d61c58a
79فایلهای تغییر یافته به همراه10728 افزوده شده و 14 حذف شده
  1. 2 2
      fs-admin/src/main/resources/application.yml
  2. 2 0
      fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceRoboticController.java
  3. 1 1
      fs-company/src/main/java/com/fs/framework/config/SecurityConfig.java
  4. 1 1
      fs-service/src/main/java/com/fs/aicall/domain/apiresult/Notify.java
  5. 4 8
      fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticServiceImpl.java
  6. 2 2
      fs-service/src/main/resources/mapper/company/CompanyVoiceRoboticCalleesMapper.xml
  7. 141 0
      fs-wx-task/pom.xml
  8. 14 0
      fs-wx-task/src/main/java/com/fs/FSServletInitializer.java
  9. 24 0
      fs-wx-task/src/main/java/com/fs/FsWxTaskApplication.java
  10. 522 0
      fs-wx-task/src/main/java/com/fs/app/controller/CommonController.java
  11. 19 0
      fs-wx-task/src/main/java/com/fs/app/controller/VoiceController.java
  12. 51 0
      fs-wx-task/src/main/java/com/fs/app/exception/FSException.java
  13. 81 0
      fs-wx-task/src/main/java/com/fs/app/exception/FSExceptionHandler.java
  14. 155 0
      fs-wx-task/src/main/java/com/fs/app/task/CourseWatchLogScheduler.java
  15. 37 0
      fs-wx-task/src/main/java/com/fs/app/task/UserCourseWatchCountTask.java
  16. 484 0
      fs-wx-task/src/main/java/com/fs/app/task/qwTask.java
  17. 10 0
      fs-wx-task/src/main/java/com/fs/app/taskService/QwExternalContactRatingMoreSevenDaysService.java
  18. 10 0
      fs-wx-task/src/main/java/com/fs/app/taskService/QwExternalContactRatingService.java
  19. 8 0
      fs-wx-task/src/main/java/com/fs/app/taskService/SopLogsChatTaskService.java
  20. 22 0
      fs-wx-task/src/main/java/com/fs/app/taskService/SopLogsTaskService.java
  21. 11 0
      fs-wx-task/src/main/java/com/fs/app/taskService/SopLogsTestService.java
  22. 9 0
      fs-wx-task/src/main/java/com/fs/app/taskService/SopUserLogsInfoByIsDaysNotStudy.java
  23. 8 0
      fs-wx-task/src/main/java/com/fs/app/taskService/SopWxLogsService.java
  24. 10 0
      fs-wx-task/src/main/java/com/fs/app/taskService/SyncQwExternalContactService.java
  25. 374 0
      fs-wx-task/src/main/java/com/fs/app/taskService/impl/AsyncCourseWatchFinishService.java
  26. 333 0
      fs-wx-task/src/main/java/com/fs/app/taskService/impl/QwExternalContactRatingMoreSevenDaysServiceImpl.java
  27. 410 0
      fs-wx-task/src/main/java/com/fs/app/taskService/impl/QwExternalContactRatingServiceImpl.java
  28. 212 0
      fs-wx-task/src/main/java/com/fs/app/taskService/impl/SopLogsChatTaskServiceImpl.java
  29. 2336 0
      fs-wx-task/src/main/java/com/fs/app/taskService/impl/SopLogsTaskServiceImpl.java
  30. 965 0
      fs-wx-task/src/main/java/com/fs/app/taskService/impl/SopLogsTestServiceImpl.java
  31. 262 0
      fs-wx-task/src/main/java/com/fs/app/taskService/impl/SopUserLogsInfoByIsDaysNotStudyImpl.java
  32. 127 0
      fs-wx-task/src/main/java/com/fs/app/taskService/impl/SopWxLogsServiceImpl.java
  33. 78 0
      fs-wx-task/src/main/java/com/fs/app/taskService/impl/SyncQwExternalContactServiceImpl.java
  34. 171 0
      fs-wx-task/src/main/java/com/fs/framework/aspectj/DataScopeAspect.java
  35. 73 0
      fs-wx-task/src/main/java/com/fs/framework/aspectj/DataSourceAspect.java
  36. 219 0
      fs-wx-task/src/main/java/com/fs/framework/aspectj/LogAspect.java
  37. 117 0
      fs-wx-task/src/main/java/com/fs/framework/aspectj/RateLimiterAspect.java
  38. 31 0
      fs-wx-task/src/main/java/com/fs/framework/config/ApplicationConfig.java
  39. 58 0
      fs-wx-task/src/main/java/com/fs/framework/config/ArrayStringTypeHandler.java
  40. 85 0
      fs-wx-task/src/main/java/com/fs/framework/config/CaptchaConfig.java
  41. 92 0
      fs-wx-task/src/main/java/com/fs/framework/config/DataSourceConfig.java
  42. 123 0
      fs-wx-task/src/main/java/com/fs/framework/config/DruidConfig.java
  43. 72 0
      fs-wx-task/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  44. 59 0
      fs-wx-task/src/main/java/com/fs/framework/config/FilterConfig.java
  45. 76 0
      fs-wx-task/src/main/java/com/fs/framework/config/KaptchaTextCreator.java
  46. 150 0
      fs-wx-task/src/main/java/com/fs/framework/config/MyBatisConfig.java
  47. 161 0
      fs-wx-task/src/main/java/com/fs/framework/config/RedisConfig.java
  48. 76 0
      fs-wx-task/src/main/java/com/fs/framework/config/ResourcesConfig.java
  49. 11 0
      fs-wx-task/src/main/java/com/fs/framework/config/RetryConfig.java
  50. 157 0
      fs-wx-task/src/main/java/com/fs/framework/config/SecurityConfig.java
  51. 33 0
      fs-wx-task/src/main/java/com/fs/framework/config/ServerConfig.java
  52. 121 0
      fs-wx-task/src/main/java/com/fs/framework/config/SwaggerConfig.java
  53. 115 0
      fs-wx-task/src/main/java/com/fs/framework/config/ThreadPoolConfig.java
  54. 77 0
      fs-wx-task/src/main/java/com/fs/framework/config/properties/DruidProperties.java
  55. 27 0
      fs-wx-task/src/main/java/com/fs/framework/datasource/DynamicDataSource.java
  56. 45 0
      fs-wx-task/src/main/java/com/fs/framework/datasource/DynamicDataSourceContextHolder.java
  57. 115 0
      fs-wx-task/src/main/java/com/fs/framework/exception/GlobalExceptionHandler.java
  58. 56 0
      fs-wx-task/src/main/java/com/fs/framework/interceptor/RepeatSubmitInterceptor.java
  59. 126 0
      fs-wx-task/src/main/java/com/fs/framework/interceptor/impl/SameUrlDataInterceptor.java
  60. 56 0
      fs-wx-task/src/main/java/com/fs/framework/manager/AsyncManager.java
  61. 40 0
      fs-wx-task/src/main/java/com/fs/framework/manager/ShutdownManager.java
  62. 106 0
      fs-wx-task/src/main/java/com/fs/framework/manager/factory/AsyncFactory.java
  63. 69 0
      fs-wx-task/src/main/java/com/fs/framework/security/LoginBody.java
  64. 255 0
      fs-wx-task/src/main/java/com/fs/framework/security/LoginUser.java
  65. 89 0
      fs-wx-task/src/main/java/com/fs/framework/security/SecurityUtils.java
  66. 47 0
      fs-wx-task/src/main/java/com/fs/framework/security/filter/JwtAuthenticationTokenFilter.java
  67. 35 0
      fs-wx-task/src/main/java/com/fs/framework/security/handle/AuthenticationEntryPointImpl.java
  68. 54 0
      fs-wx-task/src/main/java/com/fs/framework/security/handle/LogoutSuccessHandlerImpl.java
  69. 92 0
      fs-wx-task/src/main/java/com/fs/framework/service/CompanyLoginService.java
  70. 66 0
      fs-wx-task/src/main/java/com/fs/framework/service/CompanyPermissionService.java
  71. 170 0
      fs-wx-task/src/main/java/com/fs/framework/service/PermissionService.java
  72. 236 0
      fs-wx-task/src/main/java/com/fs/framework/service/TokenService.java
  73. 75 0
      fs-wx-task/src/main/java/com/fs/framework/service/UserDetailsServiceImpl.java
  74. 1 0
      fs-wx-task/src/main/resources/META-INF/spring-devtools.properties
  75. 2 0
      fs-wx-task/src/main/resources/banner.txt
  76. 37 0
      fs-wx-task/src/main/resources/i18n/messages.properties
  77. 93 0
      fs-wx-task/src/main/resources/logback.xml
  78. 19 0
      fs-wx-task/src/main/resources/mybatis/mybatis-config.xml
  79. 15 0
      pom.xml

+ 2 - 2
fs-admin/src/main/resources/application.yml

@@ -4,11 +4,11 @@ server:
 # Spring配置
 spring:
   profiles:
-    active: druid-ylrz
+#    active: druid-ylrz
 #    active: druid-hdt
 #    active: druid-yzt
 #    active: druid-sxjz-test
 #    active: druid-sft
 #    active: druid-fby
-#    active: dev
+    active: dev
 

+ 2 - 0
fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceRoboticController.java

@@ -120,6 +120,8 @@ public class CompanyVoiceRoboticController extends BaseController
     public AjaxResult add(@RequestBody CompanyVoiceRobotic companyVoiceRobotic){
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
         companyVoiceRobotic.setCreateUser(loginUser.getUser().getUserId());
+        companyVoiceRobotic.setCompanyUserId(loginUser.getUser().getUserId());
+        companyVoiceRobotic.setCompanyId(loginUser.getCompany().getCompanyId());
         return toAjax(companyVoiceRoboticService.insertCompanyVoiceRobotic(companyVoiceRobotic));
     }
 

+ 1 - 1
fs-company/src/main/java/com/fs/framework/config/SecurityConfig.java

@@ -111,7 +111,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter
                         "/profile/**"
                 ).permitAll()
                 .antMatchers("/test").anonymous()
-                .antMatchers("**/callerResult").anonymous()
+                .antMatchers("/company/companyVoiceRobotic/callerResult").anonymous()
                 .antMatchers("/qw/getJsapiTicket/**").anonymous()
                 .antMatchers("/msg/**").anonymous()
                 .antMatchers("/baiduBack/**").anonymous()

+ 1 - 1
fs-service/src/main/java/com/fs/aicall/domain/apiresult/Notify.java

@@ -29,7 +29,7 @@ public class Notify {
     //录音地址
     private String recordFile;
     //客户资料自定义字段(可选参数)
-    private Map<String, Object> field;
+//    private Map<String, Object> field;
     //用户数据
     private String userData;
 }

+ 4 - 8
fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticServiceImpl.java

@@ -16,10 +16,7 @@ import com.fs.common.annotation.DataScope;
 import com.fs.common.core.domain.entity.SysDictData;
 import com.fs.common.core.domain.model.LoginUser;
 import com.fs.common.exception.base.BaseException;
-import com.fs.common.utils.PubFun;
-import com.fs.common.utils.SecurityUtils;
-import com.fs.common.utils.ServletUtils;
-import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.*;
 import com.fs.company.domain.*;
 import com.fs.company.mapper.CompanyVoiceRoboticCalleesMapper;
 import com.fs.company.mapper.CompanyVoiceRoboticMapper;
@@ -237,10 +234,9 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
 
     public void pushDialogContent(PushIIntentionResult result){
         Notify notify = result.getNotify();
-        SysDictData insData = sysDictDataMapper.selectDictDataByTypeAndValue("customer_intention_level", notify.getIntention());
-        String intention = "无";
-        if(insData != null){
-            intention = insData.getDictLabel();
+        String intention = notify.getIntention();
+        if(StringUtils.isEmpty(intention)){
+            intention = "无";
         }
         CompanyVoiceRoboticCallees callee = getResultCalleeInfo(notify);
         callee.setUuid(notify.getUuid());

+ 2 - 2
fs-service/src/main/resources/mapper/company/CompanyVoiceRoboticCalleesMapper.xml

@@ -14,10 +14,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     </resultMap>
 
     <sql id="selectCompanyVoiceRoboticCalleesVo">
-        select id, user_id, user_name, phone, robotic_id, params from company_voice_robotic_callees
+        select * from company_voice_robotic_callees
     </sql>
 
-    <select id="selectCompanyVoiceRoboticCalleesList" parameterType="CompanyVoiceRoboticCallees" resultMap="CompanyVoiceRoboticCalleesResult">
+    <select id="selectCompanyVoiceRoboticCalleesList" parameterType="CompanyVoiceRoboticCallees" resultType="CompanyVoiceRoboticCallees">
         <include refid="selectCompanyVoiceRoboticCalleesVo"/>
         <where>  
             <if test="userId != null "> and user_id = #{userId}</if>

+ 141 - 0
fs-wx-task/pom.xml

@@ -0,0 +1,141 @@
+<?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>
+    <version>1.1.0</version>
+    <modelVersion>4.0.0</modelVersion>
+
+    <groupId>com.fs</groupId>
+    <artifactId>fs-wx-task</artifactId>
+    <description>
+        微信定时任务
+    </description>
+
+    <dependencies>
+        <!-- spring-boot-devtools -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-devtools</artifactId>
+            <optional>true</optional> <!-- 表示依赖不会传递 -->
+        </dependency>
+        <!-- swagger2-->
+        <dependency>
+            <groupId>io.springfox</groupId>
+            <artifactId>springfox-swagger2</artifactId>
+        </dependency>
+
+        <!-- swagger2-UI-->
+        <dependency>
+            <groupId>io.springfox</groupId>
+            <artifactId>springfox-swagger-ui</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.github.xiaoymin</groupId>
+            <artifactId>swagger-bootstrap-ui</artifactId>
+            <version>1.9.3</version>
+        </dependency>
+
+
+        <!-- Mysql驱动包 -->
+        <dependency>
+            <groupId>mysql</groupId>
+            <artifactId>mysql-connector-java</artifactId>
+        </dependency>
+
+        <!-- SpringBoot Web容器 -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+
+        <!-- SpringBoot 拦截器 -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-aop</artifactId>
+        </dependency>
+
+        <!-- 阿里数据库连接池 -->
+        <dependency>
+            <groupId>com.alibaba</groupId>
+            <artifactId>druid-spring-boot-starter</artifactId>
+        </dependency>
+
+        <!--clickhouse-->
+        <dependency>
+            <groupId>com.clickhouse</groupId>
+            <artifactId>clickhouse-jdbc</artifactId>
+            <version>0.4.6</version>
+        </dependency>
+
+        <dependency>
+            <groupId>org.springframework.retry</groupId>
+            <artifactId>spring-retry</artifactId>
+            <version>1.3.1</version>
+        </dependency>
+
+        <!--        <dependency>-->
+<!--            <groupId>ru.yandex.clickhouse</groupId>-->
+<!--            <artifactId>clickhouse-jdbc</artifactId>-->
+<!--            <version>0.3.2</version>-->
+<!--        </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.github.oshi</groupId>
+            <artifactId>oshi-core</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.fs</groupId>
+            <artifactId>fs-service</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> <!-- 如果没有该配置,devtools不会生效 -->
+                </configuration>
+                <executions>
+                    <execution>
+                        <goals>
+                            <goal>repackage</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-war-plugin</artifactId>
+                <version>3.1.0</version>
+                <configuration>
+                    <failOnMissingWebXml>false</failOnMissingWebXml>
+                    <warName>${project.artifactId}</warName>
+                </configuration>
+            </plugin>
+        </plugins>
+        <finalName>${project.artifactId}</finalName>
+    </build>
+
+
+</project>

+ 14 - 0
fs-wx-task/src/main/java/com/fs/FSServletInitializer.java

@@ -0,0 +1,14 @@
+package com.fs;
+
+import org.springframework.boot.builder.SpringApplicationBuilder;
+import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
+
+
+public class FSServletInitializer extends SpringBootServletInitializer
+{
+    @Override
+    protected SpringApplicationBuilder configure(SpringApplicationBuilder application)
+    {
+        return application.sources(FsWxTaskApplication.class);
+    }
+}

+ 24 - 0
fs-wx-task/src/main/java/com/fs/FsWxTaskApplication.java

@@ -0,0 +1,24 @@
+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;
+
+/**
+ * 启动程序
+ */
+@SpringBootApplication(exclude = { DataSourceAutoConfiguration.class })
+@EnableTransactionManagement
+@EnableAsync
+@EnableScheduling
+public class FsWxTaskApplication
+{
+    public static void main(String[] args){
+        // System.setProperty("spring.devtools.restart.enabled", "false");
+        SpringApplication.run(FsWxTaskApplication.class, args);
+        System.out.println("QwTask启动成功");
+    }
+}

+ 522 - 0
fs-wx-task/src/main/java/com/fs/app/controller/CommonController.java

@@ -0,0 +1,522 @@
+package com.fs.app.controller;
+
+
+import cn.hutool.core.date.DateUtil;
+import com.alibaba.fastjson.JSON;
+import com.fs.app.task.qwTask;
+import com.fs.app.taskService.*;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.domain.ResponseResult;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.utils.StringUtils;
+import com.fs.company.service.ICompanyService;
+import com.fs.company.vo.RedPacketMoneyVO;
+import com.fs.course.mapper.FsCourseRedPacketLogMapper;
+import com.fs.course.mapper.FsCourseWatchLogMapper;
+import com.fs.course.param.newfs.FsUserCourseAddCompanyUserParam;
+import com.fs.course.service.*;
+import com.fs.course.vo.FsUserCourseVideoQVO;
+import com.fs.his.domain.FsUser;
+import com.fs.his.service.IFsInquiryOrderService;
+import com.fs.his.utils.qrcode.QRCodeUtils;
+import com.fs.qw.domain.QwCompany;
+import com.fs.qw.domain.QwExternalContact;
+import com.fs.qw.domain.QwIpadServerLog;
+import com.fs.qw.domain.QwUser;
+import com.fs.qw.mapper.QwExternalContactMapper;
+import com.fs.qw.mapper.QwUserMapper;
+import com.fs.qw.service.*;
+import com.fs.qwApi.domain.QwExternalContactResult;
+import com.fs.qwApi.service.QwApiService;
+import com.fs.sop.mapper.QwSopLogsMapper;
+import com.fs.sop.mapper.QwSopMapper;
+import com.fs.sop.mapper.SopUserLogsMapper;
+import com.fs.sop.service.*;
+import com.fs.sop.vo.QwSopLogsDoSendListTVO;
+import com.fs.store.service.IFsUserCourseCountService;
+import com.fs.wxwork.dto.WxWorkGetQrCodeDTO;
+import com.fs.wxwork.service.WxWorkService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import com.fs.app.task.qwTask;
+
+import java.time.Duration;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.*;
+
+@Api("公共接口")
+@RestController
+@RequestMapping(value="/app/common")
+@Slf4j
+public class CommonController {
+
+    @Autowired
+    private SopLogsTaskService service;
+    @Autowired
+    private IFsUserCourseVideoService courseVideoService;
+    @Autowired
+    private SopLogsTaskService sopLogsTaskService;
+    @Autowired
+    private SopWxLogsService sopWxLogsService;
+    @Autowired
+    private IQwExternalContactService qwExternalContactService;
+    @Autowired
+    private qwTask qwTask1;
+    @Autowired
+    private IFsUserVideoService fsUserVideoService;
+    @Autowired
+    private IHuaweiObsService huaweiObsService;
+    @Autowired
+    private IFsCourseWatchLogService watchLogService;
+    @Autowired
+    private QwExternalContactMapper qwExternalContactMapper;
+    @Autowired
+    private IFsCourseRedPacketLogService fsCourseRedPacketLogService;
+
+    @Autowired
+    private IQwSopLogsService qwSopLogsService;
+
+    @Autowired
+    private QwSopMapper qwSopMapper;
+
+    @Autowired
+    private FsCourseWatchLogMapper fsCourseWatchLogMapper;
+
+    @Autowired
+    private IFsCourseLinkService courseLinkService;
+    @Autowired
+    private FsCourseRedPacketLogMapper fsCourseRedPacketLogMapper;
+    @Autowired
+    private ICompanyService companyService;
+
+    @Autowired
+    private SopUserLogsMapper sopUserLogsMapper;
+
+    @Autowired
+    private QwSopLogsMapper qwSopLogsMapper;
+    @Autowired
+    private IQwSopTempRulesService tempRulesService;
+    @Autowired
+    private IQwSopTempVoiceService qwSopTempVoiceService;
+
+    @Autowired
+    private QwExternalContactRatingService qwExternalContactRatingService;
+
+    @Autowired
+    private ISopUserLogsService iSopUserLogsService;
+
+    @Autowired
+    private IFsUserCourseCountService userCourseCountService;
+
+    @Autowired
+    private ISopUserLogsInfoService iSopUserLogsInfoService;
+
+    @Autowired
+    private IFsInquiryOrderService inquiryOrderService;
+
+    @Autowired
+    private IQwMaterialService iQwMaterialService;
+
+    @Autowired
+    private IFsCourseLinkService iFsCourseLinkService;
+
+    @Autowired
+    private SyncQwExternalContactService syncQwExternalContactService;
+    @Autowired
+    private IFsUserCourseVideoService fsUserCourseVideoService;
+
+    @Autowired
+    public RedisCache redisCache;
+
+    @Autowired
+    private QwUserMapper qwUserMapper;
+
+
+    @Autowired
+    IQwIpadServerService ipadServerService;
+
+    @Autowired
+    IQwIpadServerLogService qwIpadServerLogService;
+    @Autowired
+    IQwIpadServerUserService qwIpadServerUserService;
+
+    @Autowired
+    IQwExternalContactService externalContactService;
+    @Autowired
+    WxWorkService wxWorkService;
+
+    /**
+     *
+     */
+    @GetMapping("/selectQwUserByTest")
+    public void selectQwUserByTest() {
+        try {
+            List<QwUser> list = qwUserMapper.selectQwUserByTest();
+            for (QwUser qwUser : list) {
+                try {
+
+                     Long serverId = qwUser.getServerId();
+
+                    if (serverId==null){
+                        System.out.println("serverId不存在");
+                    }else {
+                        //没绑定销售 或者 已经离职
+                        if (qwUser.getStatus()==0 || qwUser.getIsDel()==2){
+
+                            updateIpadStatus(qwUser,serverId);
+                        }
+
+                        //绑定了销售-也绑定了ipad,但是长时间离线的(离线状态,无操作超过2天的,也自动解绑)
+                        if(qwUser.getUpdateTime()!=null){
+                            Date createTime = qwUser.getUpdateTime();
+                            Integer serverStatus = qwUser.getServerStatus();
+                            Integer ipadStatus = qwUser.getIpadStatus();
+
+                            boolean result = isCreateTimeMoreThanDaysWithOptional(createTime, 2);
+                            //大于2天 ,绑定了ipad,离线
+                            if(result && serverStatus==1 && ipadStatus==0){
+                                updateIpadStatus(qwUser,serverId);
+
+                            }
+                        }
+
+
+                    }
+
+
+                } catch (Exception e) {
+                    System.out.println("解绑ipad报错"+e);
+
+                }
+            }
+        } catch (Exception e) {
+            log.error("定时处理未绑定员工企微异常",e);
+        }
+
+    }
+
+
+    public void updateIpadStatus(QwUser qwUser,Long serverId){
+        QwUser u = new QwUser();
+        u.setId(qwUser.getId());
+        u.setServerId(null);
+        u.setServerStatus(0);
+        qwUserMapper.updateQwUser(u);
+        ipadServerService.addServer(serverId);
+        QwIpadServerLog qwIpadServerLog = new QwIpadServerLog();
+        qwIpadServerLog.setType(2);
+        qwIpadServerLog.setTilie("解绑");
+        qwIpadServerLog.setServerId(serverId);
+        qwIpadServerLog.setQwUserId(qwUser.getId());
+        qwIpadServerLog.setCompanyUserId(qwUser.getCompanyUserId());
+        qwIpadServerLog.setCompanyId(qwUser.getCompanyId());
+        qwIpadServerLog.setCreateTime(new Date());
+        qwIpadServerLogService.insertQwIpadServerLog(qwIpadServerLog);
+        qwIpadServerUserService.deleteQwIpadServerUserByQwUserId(qwUser.getId());
+        WxWorkGetQrCodeDTO wxWorkGetQrCodeDTO = new WxWorkGetQrCodeDTO();
+        wxWorkGetQrCodeDTO.setUuid(qwUser.getUid());
+        wxWorkService.LoginOut(wxWorkGetQrCodeDTO,qwUser.getServerId());
+        updateIpadStatus(qwUser.getId(),0);
+    }
+
+    public static boolean isCreateTimeMoreThanDaysWithOptional(Date createTime, int days) {
+        return Optional.ofNullable(createTime)
+                .map(time -> {
+                    LocalDateTime createDateTime = time.toInstant()
+                            .atZone(ZoneId.systemDefault())
+                            .toLocalDateTime();
+                    LocalDateTime now = LocalDateTime.now();
+                    Duration duration = Duration.between(createDateTime, now);
+                    return duration.toDays() > days;
+                })
+                .orElse(false); // 为null时返回false,可根据需求调整
+    }
+
+    void updateIpadStatus(Long id ,Integer status){
+        QwUser u = new QwUser();
+        u.setId(id);
+        u.setIpadStatus(status);
+        qwUserMapper.updateQwUser(u);
+    }
+    /**
+     *
+     */
+    @GetMapping("/countQwApiAopLogToken")
+    public void countQwApiAopLogToken() {
+
+        DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+        // 获取当前日期(只包含年月日)
+        LocalDate currentDate = LocalDate.now();
+
+        String todayStr = currentDate.format(dateFormatter);
+        qwSopLogsService.countQwApiAopLogToken(todayStr);
+
+    }
+
+    /**
+     * 查询视频时长
+     */
+    @GetMapping("/getVideoDuration")
+    public Long getVideoDuration(Long videoId) {
+
+            String redisKey = "h5user:video:duration:" + videoId;
+            Long duration = redisCache.getCacheObject(redisKey);
+
+            if (duration == null) {
+                FsUserCourseVideoQVO videoInfo = fsUserCourseVideoService.selectFsUserCourseVideoByVideoIdVO(videoId,null);
+                if (videoInfo == null || videoInfo.getDuration() == null) {
+                    throw new IllegalArgumentException("视频时长信息不存在");
+                }
+                duration = videoInfo.getDuration();
+
+                // 将查询结果缓存到Redis,设置适当过期时间
+                redisCache.setCacheObject(redisKey, duration);
+            }
+
+            return duration;
+
+    }
+
+
+
+    /**
+     * 获取跳转微信小程序的链接地址
+     */
+    @GetMapping("/getGotoWxAppLink")
+    @ApiOperation("获取跳转微信小程序的链接地址")
+    public ResponseResult<String> getGotoWxAppLink(String linkStr,String appid) {
+        return ResponseResult.ok(courseLinkService.getGotoWxAppLink(linkStr,appid));
+    }
+
+    /**
+    * 发官方通连
+    */
+    @GetMapping("/sopguanfanone")
+    public R sopguanfanone(String dateTime) throws Exception {
+
+        LocalDateTime localDateTime = DateUtil.parseLocalDateTime(dateTime);
+
+        int currentHour = localDateTime.getHour();
+        LocalDate localDate = localDateTime.toLocalDate();
+
+        String taskStartTime = localDate.atTime(currentHour, 0, 0)
+                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
+        String taskEndTime = localDate.atTime(currentHour, 59, 59)
+                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
+
+        qwSopLogsService.createCorpMassSendingByUserLogs( taskStartTime, taskEndTime);
+        return R.ok();
+    }
+
+    /**
+    * 发一对一
+    */
+    @GetMapping("/sopguanfantwo")
+    public R sopguanfantwo(String dateTime) throws Exception {
+
+        LocalDateTime localDateTime = DateUtil.parseLocalDateTime(dateTime);
+
+
+        LocalDate localDate = localDateTime.toLocalDate();
+        String date = localDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
+
+        qwSopLogsService.createCorpMassSending(date);
+        return R.ok();
+    }
+
+    /**
+     * 查官方的执行结果
+     */
+    @GetMapping("/sopguanfantResult")
+    public R sopguanfantResult() throws Exception {
+        qwSopLogsService.qwSopLogsResultNew();
+        return R.ok();
+    }
+
+
+    @GetMapping("/testMaterial")
+    public void testMaterial() throws Exception {
+
+        iQwMaterialService.updateQwMaterialByQw();
+
+    }
+
+    @GetMapping("/testSop")
+    public R testSop() throws Exception {
+
+        return iFsCourseLinkService.getWxaCodeGenerateScheme("/pages_course/video.html?course={\"companyId\":100,\"companyUserId\":2020,\"corpId\":\"wweb0666cc79d79da5\",\"courseId\":61,\"link\":\"1950497651577323520\",\"linkType\":3,\"qwExternalId\":2356946,\"qwUserId\":\"1682\",\"uNo\":\"b8b010e1-ee0f-42ec-8ad8-06681d1b449a\",\"videoId\":366}","wx34bba1ae94d34986");
+    }
+
+    @GetMapping("/testRatingSop")
+    public R testRatingSop(String sopId) throws Exception {
+
+        long startTimeMillis = System.currentTimeMillis();
+        log.info("====== 开始选择和处理 sop营期-用户分级 ======");
+
+        iSopUserLogsService.ratingUserLogs(sopId);
+
+        long endTimeMillis = System.currentTimeMillis();
+        log.info("====== sop营期-用户分级处理完成,耗时 {} 毫秒 ======", (endTimeMillis - startTimeMillis));
+        return R.ok();
+    }
+
+    // 定义一个方法来批量处理插入逻辑,支持每 500 条数据一次的批量插入
+    private void processAndInsertQwSopLogs(List<QwSopLogsDoSendListTVO> logsByJsApiNotExtId) {
+        // 定义批量插入的大小
+        int batchSize = 500;
+
+        // 循环处理外部用户 ID,每次处理批量大小的子集
+        for (int i = 0; i < logsByJsApiNotExtId.size(); i += batchSize) {
+
+            int endIndex = Math.min(i + batchSize, logsByJsApiNotExtId.size());
+            List<QwSopLogsDoSendListTVO> batchList = logsByJsApiNotExtId.subList(i, endIndex);  // 获取当前批次的子集
+
+            // 直接使用批次数据进行批量更新,不需要额外的 List
+            try {
+                qwSopLogsMapper.batchUpdateQwSopLogsBySendTime(batchList);
+            } catch (Exception e) {
+                // 记录异常日志,方便后续排查问题
+                log.error("批量更新数据时发生异常,处理的批次起始索引为: " + i, e);
+            }
+        }
+    }
+
+    @GetMapping("/test")
+    public R test(String time, String sopId) throws Exception {
+        log.info("进入sop任务");
+//        LocalDateTime currentTime = DateUtil.parseLocalDateTime(time);
+//        // 计算下一个整点时间
+//        LocalDateTime nextHourTime = currentTime.withMinute(0).withSecond(0).withNano(0).plusHours(1);
+//
+//        // 打印日志,确认时间
+//        log.info("任务实际执行时间: {}", currentTime);
+//        log.info("传递给任务的时间参数: {}", nextHourTime);
+        List<String> sopidList = new ArrayList<>();
+        if(StringUtils.isNotEmpty(sopId)){
+            sopidList = Arrays.asList(sopId.split(","));
+        }
+        sopLogsTaskService.selectSopUserLogsListByTime(DateUtil.parseLocalDateTime(time), sopidList);
+        return R.ok();
+    }
+    @GetMapping("/testWx")
+    public R testWx(String time) throws Exception {
+        sopWxLogsService.wxSopLogsByTime(DateUtil.parseLocalDateTime(time));
+        return R.ok();
+    }
+
+
+    @GetMapping("/testVideo")
+    public R testVideo(String sopId) throws Exception {
+        qwSopTempVoiceService.synchronous(sopId, Arrays.asList(Arrays.asList(2020L, 100L), Arrays.asList(2758L, 170L)));
+        return R.ok();
+    }
+
+    @Autowired
+    IQwCompanyService iQwCompanyService;
+    @GetMapping("/testSop2")
+    public R testSop2() throws Exception {
+
+        String cropId="ww401085d7b785aae8";
+
+        QwCompany qwCompany = iQwCompanyService.getQwCompanyByRedis(cropId);
+
+        String status="100_asddas_6666";
+
+        String url="https://open.weixin.qq.com/connect/oauth2/authorize?appid="+cropId+"&redirect_uri=" +
+                "http://"+qwCompany.getRealmNameUrl()+"/qwh5/pages/user/index?corpId="+cropId +
+                "&response_type=code&scope=snsapi_base&state="+status+"&agentid="+qwCompany.getServerAgentId()+"#wechat_redirect";
+
+        R andUpload = QRCodeUtils.createAndUpload(url);
+
+        return R.ok().put("data",andUpload);
+    }
+
+    @Autowired
+    private QwApiService qwApiService;
+
+    @GetMapping("/testSop3")
+    public R testSop3(String date) throws Exception {
+//        qwSopLogsService.createCorpMassSending(date);
+//        QwGetGroupmsgSendParam qwGetGroupmsgSendParam = new QwGetGroupmsgSendParam();
+//        qwGetGroupmsgSendParam.setMsgid("msg7tWFCgAAjJC-HqurNKsOJif5oUHQiA");
+//        qwGetGroupmsgSendParam.setUserid("ZhangZhanYue");
+//
+//        QwGroupmsgSendResult groupmsgSendResult = qwApiService.getGroupmsgSendResult(qwGetGroupmsgSendParam, "ww5a88c4f879f204c5");
+        return R.ok();
+    }
+
+    @Autowired
+    IQwSopTagService qwSopTagService;
+    @GetMapping("/tag")
+    public R tag() throws Exception {
+        qwSopTagService.addTag();
+        return R.ok();
+    }
+
+
+    @Autowired
+    private SopLogsChatTaskService sopLogsChatTaskService;
+    @GetMapping("/test2")
+    public String selectChatSopUserLogsListByTime() throws Exception {
+        userCourseCountService.insertFsUserCourseCountTask();
+        return "s";
+    }
+    @GetMapping("/isAddkf")
+    public ResponseResult<FsUser> isAddkf(FsUserCourseAddCompanyUserParam param) throws Exception {
+        return courseVideoService.isAddCompanyUser(param);
+    }
+
+    @PostMapping("/updateUrl")
+    public R updateUrl()
+    {
+        log.info("开始更新URL");
+        try {
+            fsUserVideoService.updateVideoUrl();
+            huaweiObsService.uploadByCOS();
+            log.info("更新URL成功完成");
+
+
+        } catch (Exception e) {
+            log.error("开始更新URL执行失败", e);
+        }
+        return R.ok();
+    }
+    @GetMapping("/updateRedPack")
+    public R updateRedPack(String start , String end    ){
+        LocalDateTime startTime = DateUtil.parseLocalDateTime(start);
+        LocalDateTime endTime = DateUtil.parseLocalDateTime(end);
+        List<RedPacketMoneyVO> redPacketMoneyVOS = fsCourseRedPacketLogMapper.selectFsCourseRedPacketLogHourseByCompany(startTime, endTime);
+        for (RedPacketMoneyVO redPacketMoneyVO : redPacketMoneyVOS) {
+            companyService.subtractCompanyMoneyHourse(redPacketMoneyVO.getMoney(),redPacketMoneyVO.getCompanyId(), startTime.toLocalTime(), endTime.toLocalTime());
+        }
+        return R.ok();
+    }
+
+    @GetMapping("/syncQwExternalContactUnionid")
+    public R syncQwExternalContactUnionid(){
+        return syncQwExternalContactService.syncQwExternalContactUnionid();
+    }
+
+
+    @GetMapping("/queryRedPacketResult")
+    public R queryRedPacketResult(String startTime , String  endTime) {
+        fsCourseRedPacketLogService.queryRedPacketResult(startTime, endTime);
+        return R.ok();
+    }
+
+    @GetMapping("/autoPullGroup")
+    public R autoPullGroup(){
+        qwTask1.autoPullGroup();
+        return R.ok();
+    }
+
+}

+ 19 - 0
fs-wx-task/src/main/java/com/fs/app/controller/VoiceController.java

@@ -0,0 +1,19 @@
+package com.fs.app.controller;
+
+
+import io.swagger.annotations.Api;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@Api("公共接口")
+@RestController
+@RequestMapping(value="/app/common/voice")
+@Slf4j
+public class VoiceController {
+    @GetMapping
+    public void voice() {
+
+    }
+}

+ 51 - 0
fs-wx-task/src/main/java/com/fs/app/exception/FSException.java

@@ -0,0 +1,51 @@
+package com.fs.app.exception;
+
+/**
+ * 自定义异常
+ */
+public class FSException extends RuntimeException {
+	private static final long serialVersionUID = 1L;
+	
+    private String msg;
+    private int code = 500;
+    
+    public FSException(String msg) {
+		super(msg);
+		this.msg = msg;
+	}
+	
+	public FSException(String msg, Throwable e) {
+		super(msg, e);
+		this.msg = msg;
+	}
+	
+	public FSException(String msg, int code) {
+		super(msg);
+		this.msg = msg;
+		this.code = code;
+	}
+	
+	public FSException(String msg, int code, Throwable e) {
+		super(msg, e);
+		this.msg = msg;
+		this.code = code;
+	}
+
+	public String getMsg() {
+		return msg;
+	}
+
+	public void setMsg(String msg) {
+		this.msg = msg;
+	}
+
+	public int getCode() {
+		return code;
+	}
+
+	public void setCode(int code) {
+		this.code = code;
+	}
+	
+	
+}

+ 81 - 0
fs-wx-task/src/main/java/com/fs/app/exception/FSExceptionHandler.java

@@ -0,0 +1,81 @@
+package com.fs.app.exception;
+
+
+
+
+import com.fs.common.core.domain.R;
+import com.fs.common.exception.CustomException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.validation.BindException;
+import org.springframework.validation.FieldError;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.servlet.NoHandlerFoundException;
+
+
+/**
+ * 异常处理器
+ */
+@RestControllerAdvice
+public class FSExceptionHandler {
+	private Logger logger = LoggerFactory.getLogger(getClass());
+
+	/**
+	 * 处理自定义异常
+	 */
+	@ExceptionHandler(FSException.class)
+	public R handleRRException(FSException e){
+		R r = new R();
+		r.put("code", e.getCode());
+		r.put("msg", e.getMessage());
+
+		return r;
+	}
+
+	@ExceptionHandler(NoHandlerFoundException.class)
+	public R handlerNoFoundException(Exception e) {
+		logger.error(e.getMessage(), e);
+		return R.error(404, "路径不存在,请检查路径是否正确");
+	}
+
+	@ExceptionHandler(DuplicateKeyException.class)
+	public R handleDuplicateKeyException(DuplicateKeyException e){
+		logger.error(e.getMessage(), e);
+		return R.error("数据库中已存在该记录");
+	}
+
+
+	@ExceptionHandler(Exception.class)
+	public R handleException(Exception e){
+		logger.error(e.getMessage(), e);
+		return R.error();
+	}
+	@ExceptionHandler(AccessDeniedException.class)
+	public R handleAccessDeniedException(AccessDeniedException e){
+		logger.error(e.getMessage(), e);
+		return R.error("没有权限");
+	}
+
+	@ExceptionHandler(BindException.class)
+	public R bindExceptionHandler(BindException e) {
+		FieldError error = e.getFieldError();
+		String message = String.format("%s",  error.getDefaultMessage());
+		return R.error(message);
+	}
+
+	@ExceptionHandler(MethodArgumentNotValidException.class)
+	public R exceptionHandler(MethodArgumentNotValidException e) {
+		FieldError error = e.getBindingResult().getFieldError();
+		String message = String.format("%s",  error.getDefaultMessage());
+		return R.error(message);
+	}
+	@ExceptionHandler(CustomException.class)
+	public R handleException(CustomException e){
+
+		return R.error(e.getMessage());
+	}
+}

+ 155 - 0
fs-wx-task/src/main/java/com/fs/app/task/CourseWatchLogScheduler.java

@@ -0,0 +1,155 @@
+package com.fs.app.task;
+
+import com.fs.app.taskService.SopLogsTaskService;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.course.mapper.FsCourseWatchLogMapper;
+import com.fs.course.mapper.FsUserCourseVideoMapper;
+import com.fs.course.service.IFsCourseLinkService;
+import com.fs.course.service.IFsCourseWatchLogService;
+import com.fs.sop.mapper.QwSopLogsMapper;
+import com.fs.system.service.ISysConfigService;
+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.concurrent.atomic.AtomicBoolean;
+
+@Component
+@Slf4j
+public class CourseWatchLogScheduler {
+    private final AtomicBoolean isRunning1 = new AtomicBoolean(false);
+
+    private final AtomicBoolean isRunning2 = new AtomicBoolean(false);
+
+    private final AtomicBoolean isRunning3 = new AtomicBoolean(false);
+
+    private final AtomicBoolean isRunning4 = new AtomicBoolean(false);
+
+    @Autowired
+    private FsCourseWatchLogMapper courseWatchLogMapper;
+
+    @Autowired
+    private QwSopLogsMapper qwSopLogsMapper;
+
+    @Autowired
+    RedisCache redisCache;
+
+    @Autowired
+    private FsUserCourseVideoMapper courseVideoMapper;
+
+    @Autowired
+    private ISysConfigService configService;
+
+    @Autowired
+    private IFsCourseWatchLogService courseWatchLogService;
+
+    @Autowired
+    private SopLogsTaskService sopLogsTaskService;
+
+//    // 定时任务批量更新到数据库
+//    @Scheduled(fixedRate = 60000) // 每分钟执行一次
+//    public void scheduleBatchUpdateToDatabase() {
+//        courseWatchLogService.scheduleBatchUpdateToDatabase();
+//    }
+
+
+
+    /**
+     * 检查看课状态
+     */
+    @Scheduled(fixedRate = 60000) // 每分钟执行一次
+    public void checkWatchStatus() {
+        // 尝试设置标志为 true,表示任务开始执行
+        if (!isRunning1.compareAndSet(false, true)) {
+            log.warn("检查看课中任务执行 - 上一个任务尚未完成,跳过此次执行");
+            return;
+        }
+        try {
+            log.info("检查看课中任务执行>>>>>>>>>>>>");
+            courseWatchLogService.scheduleBatchUpdateToDatabase();
+            courseWatchLogService.scheduleBatchUpdateToDatabaseIsOpen();
+            courseWatchLogService.checkWatchStatus();
+            log.info("检查看课中任务执行完成>>>>>>>>>>>>");
+        }catch (Exception e) {
+            log.error("检查看课中任务执行完成 - 定时任务执行失败", e);
+        } finally {
+            // 重置标志为 false,表示任务已完成
+            isRunning1.set(false);
+        }
+
+    }
+
+
+
+    /**
+     * 创建完课消息
+     */
+    @Scheduled(fixedRate = 300000) // 每五分钟执行一次
+    public void createCourseFinishMsg() {
+        // 尝试设置标志为 true,表示任务开始执行
+        if (!isRunning3.compareAndSet(false, true)) {
+            log.warn("创建完课消息 - 上一个任务尚未完成,跳过此次执行");
+            return;
+        }
+
+        try {
+            log.info("创建完课消息 - 定时任务开始");
+            sopLogsTaskService.createCourseFinishMsg();
+            log.info("创建完课消息 - 定时任务成功完成");
+        } catch (Exception e) {
+            log.error("创建完课消息 - 定时任务执行失败", e);
+        } finally {
+            // 重置标志为 false,表示任务已完成
+            isRunning3.set(false);
+        }
+
+    }
+
+    @Autowired
+    private IFsCourseLinkService courseLinkService;
+
+
+    // 定时任务,每天0点执行
+
+    /**
+     * 每天删除过期短链
+     */
+    @Scheduled(cron = "0 0 0 * * ?")  // 0点0分0秒执行
+    public void delCourseExpireLink() {
+        try {
+            log.info("删除过期短链 - 定时任务开始");
+            courseLinkService.delCourseExpireLink();
+            log.info("删除过期短链 - 定时任务成功完成");
+        } catch (Exception e) {
+            log.error("删除过期短链 - 定时任务执行失败", e);
+        }
+
+    }
+
+    @Scheduled(fixedRate = 30000) // 每分钟执行一次
+    public void checkFsUserWatchStatus() {
+        // 尝试设置标志为 true,表示任务开始执行
+        if (!isRunning4.compareAndSet(false, true)) {
+            log.warn("WXH5-检查会员看课中任务执行 - 上一个任务尚未完成,跳过此次执行");
+            return;
+        }
+        try {
+            log.info("WXH5-检查会员看课中任务执行>>>>>>>>>>>>");
+            courseWatchLogService.scheduleUpdateDurationToDatabase();
+            courseWatchLogService.checkFsUserWatchStatus();
+            log.info("WXH5-检查会员看课中任务执行完成>>>>>>>>>>>>");
+        }catch (Exception e) {
+            log.error("WXH5-检查会员看课中任务执行完成 - 定时任务执行失败", e);
+        } finally {
+            // 重置标志为 false,表示任务已完成
+            isRunning4.set(false);
+        }
+
+    }
+
+
+
+
+
+}

+ 37 - 0
fs-wx-task/src/main/java/com/fs/app/task/UserCourseWatchCountTask.java

@@ -0,0 +1,37 @@
+package com.fs.app.task;
+
+import com.fs.store.service.IFsUserCourseCountService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+@Component
+@Slf4j
+public class UserCourseWatchCountTask {
+    @Autowired
+    private IFsUserCourseCountService userCourseCountService;
+
+
+    /**
+     * 每15分钟执行一次
+     */
+    @Scheduled(cron = "0 */10 * * * ?")  // 每10分钟执行一次
+    public void userCourseCountTask() {
+        try {
+            log.info("==============会员看课统计任务执行===============开始");
+            long startTime = System.currentTimeMillis();
+
+            userCourseCountService.insertFsUserCourseCountTask();
+
+            log.info("会员看课统计任务执行==============结束");
+            long endTime = System.currentTimeMillis();
+            log.info("会员看课统计任务执行----------执行时长:{}", (endTime - startTime));
+        } catch (Exception e) {
+            log.error("会员看课统计任务执行----------定时任务执行失败", e);
+        }
+
+    }
+
+
+}

+ 484 - 0
fs-wx-task/src/main/java/com/fs/app/task/qwTask.java

@@ -0,0 +1,484 @@
+package com.fs.app.task;
+
+import com.fs.app.taskService.*;
+import com.fs.common.utils.PubFun;
+import com.fs.ipad.IpadSendUtils;
+import com.fs.qw.domain.QwExternalContact;
+import com.fs.qw.domain.QwGroupChat;
+import com.fs.qw.domain.QwUser;
+import com.fs.qw.service.*;
+import com.fs.sop.domain.QwSop;
+import com.fs.sop.mapper.QwSopLogsMapper;
+import com.fs.sop.mapper.QwSopMapper;
+import com.fs.sop.service.IQwSopLogsService;
+import com.fs.sop.service.IQwSopTagService;
+import com.fs.sop.service.ISopUserLogsService;
+import com.fs.sop.service.impl.QwSopLogsServiceImpl;
+import com.fs.sop.service.impl.QwSopServiceImpl;
+import com.fs.sop.vo.QwSopLogsDoSendListTVO;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+import static com.fs.qw.service.impl.AsyncChatSopService.MAX_GROUP_USER_NUM;
+import static com.fs.qw.service.impl.AsyncChatSopService.MAX_GROUP_NUM;
+
+/**
+ * 企业微信SOP定时任务管理类
+ * 负责处理各种定时任务,包括SOP规则检查、消息发送、数据清理等
+ *
+ * @author 系统
+ * @version 1.0
+ */
+@Component
+@Slf4j
+public class qwTask {
+
+    @Autowired
+    private QwSopMapper qwSopMapper;
+
+    @Autowired
+    private IQwExternalContactService qwExternalContactService;
+
+    @Autowired
+    private IQwUserService qwUserService;
+    @Autowired
+    private IQwGroupChatService qwGroupChatService;
+
+    @Autowired
+    private IpadSendUtils ipadSendUtils;
+
+    @Autowired
+    private QwSopServiceImpl qwSopService;
+
+    @Autowired
+    private IQwSopLogsService iQwSopLogsService;
+
+    @Autowired
+    private QwSopLogsServiceImpl qwSopLogsService;
+
+    @Autowired
+    private IQwGroupMsgService qwGroupMsgService;
+
+    @Autowired
+    private ISopUserLogsService sopUserLogsService;
+
+    @Autowired
+    private SopLogsTaskService sopLogsTaskService;
+
+    @Autowired
+    private SopWxLogsService sopWxLogsService;
+
+    @Autowired
+    private SopLogsChatTaskService sopLogsTaskChatService;
+
+    @Autowired
+    private IQwExternalErrRetryService errRetryService;
+
+    @Autowired
+    private QwExternalContactRatingService qwExternalContactRatingService;
+
+    @Autowired
+    private IQwWorkUserService qwWorkUserService;
+
+    @Autowired
+    private QwSopLogsMapper qwSopLogsMapper;
+
+    @Autowired
+    private IQwSopTagService qwSopTagService;
+
+    @Autowired
+    private SopUserLogsInfoByIsDaysNotStudy logsInfoByIsDaysNotStudy;
+
+    @Autowired
+    private QwExternalContactRatingMoreSevenDaysService qwExternalContactRatingMoreSevenDaysService;
+
+    @Autowired
+    private SyncQwExternalContactService syncQwExternalContactService;
+
+    /**
+     * 定时任务:检查SOP规则时间
+     * 执行时间:每天凌晨 1:10:00
+     * 功能:将符合条件的qw_sop任务录入到sop_user_Logs(clickHouse)
+     */
+    @Scheduled(cron = "0 10 1 * * ?")
+    public void qwCheckSopRuleTime() {
+        qwSopService.checkSopRuleTime();
+    }
+
+    /**
+     * 定时任务:添加标签
+     * 执行时间:每20分钟执行一次
+     * 功能:自动为符合条件的记录添加标签
+     */
+    @Scheduled(cron = "0 0/20 * * * ?")
+    public void addTag() {
+        qwSopTagService.addTag();
+    }
+
+    /**
+     * 定时任务:根据营期生成sopLogs待发记录
+     * 执行时间:每小时的第5分钟执行
+     * 功能:根据营期时间生成需要发送的SOP日志记录
+     *
+     * @throws Exception 执行异常
+     */
+    @Scheduled(cron = "0 5 * * * ?") // 每小时的第5分钟触发
+    @Async
+    public void selectSopUserLogsListByTime() throws Exception {
+        // 获取当前时间,精确到小时
+        LocalDateTime currentTime = LocalDateTime.now().withMinute(0).withSecond(0).withNano(0);
+        // 打印日志,确认任务执行时间
+        log.info("任务实际执行时间: {}", currentTime);
+
+        // 调用服务方法处理SOP用户日志
+        sopLogsTaskService.selectSopUserLogsListByTime(currentTime, null);
+    }
+
+    /**
+     * 定时任务:微信SOP处理
+     * 执行时间:每小时的第5分钟执行
+     * 功能:处理微信相关的SOP日志
+     *
+     * @throws Exception 执行异常
+     */
+    @Scheduled(cron = "0 5 * * * ?") // 每小时的第5分钟触发
+    public void wxSop() throws Exception {
+        // 获取当前时间,精确到小时
+        LocalDateTime currentTime = LocalDateTime.now().withMinute(0).withSecond(0).withNano(0);
+        // 打印日志,确认任务执行时间
+        log.info("任务实际执行时间: {}", currentTime);
+
+        // 调用服务方法处理微信SOP日志
+        sopWxLogsService.wxSopLogsByTime(currentTime);
+    }
+
+    /**
+     * 定时任务:处理聊天SOP用户日志
+     * 执行时间:已注释,原为每分钟的第5秒执行
+     * 功能:将clickHouse的sopUserLogsChat(营期表)按每分钟巡回处理
+     *
+     * @throws Exception 执行异常
+     */
+//    @Scheduled(cron = "5 0/1 * * * ?")
+    public void selectChatSopUserLogsListByTime() throws Exception {
+        // 获取当前时间,精确到分钟
+        LocalDateTime today = LocalDateTime.now().withSecond(0).withNano(0);
+
+        // 创建AI聊天SOP日志
+        sopLogsTaskChatService.createAiChatSopLogs(today);
+    }
+
+    /**
+     * 定时 发送 通过调用 企业微信接口 发送的 SOP 群发消息(按单链发)
+     */
+    @Scheduled(cron = "0 20 1 * * ?")
+    public void SendQwApiSopLogTimer(){
+        log.info("zyp \n【企微官方接口群发开始-单链】");
+//        qwSopLogsService.checkQwSopLogs();
+        LocalDate localDate = LocalDateTime.now().withMinute(0).withSecond(0).withNano(0).toLocalDate();
+        String date = localDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
+
+        qwSopLogsService.createCorpMassSending(date);
+    }
+
+    /**
+     * 定时 发送 通过调用 企业微信接口 发送的 SOP 群发消息(新版-安装营期发)
+     */
+    @Scheduled(cron = "0 10 0,1 * * ?")
+    public void SendQwApiSopLogTimerNew(){
+
+        log.info("zyp \n【企微官方接口群发开始】");
+//        qwSopLogsService.checkQwSopLogs();
+//        LocalDate localDate = LocalDateTime.now().withMinute(0).withSecond(0).withNano(0).toLocalDate();
+//        String date = localDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
+
+        int currentHour = LocalDateTime.now().getHour();
+        String taskStartTime = LocalDate.now().atTime(currentHour, 0, 0)
+                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
+        String taskEndTime = LocalDate.now().atTime(currentHour, 59, 59)
+                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
+
+        qwSopLogsService.createCorpMassSendingByUserLogs(taskStartTime,taskEndTime);
+    }
+
+    /**
+     *
+     * 执行时间:每天上午 8:00:00
+     * 功能:获取通过企业微信接口发送的SOP客户群发消息的反馈结果
+     */
+    @Scheduled(cron = "0 0 8 * * ?")
+    public void GetQwApiSopLogResultTimerNew() {
+        qwSopLogsService.qwSopLogsResultNew();
+    }
+
+    /**
+     * 定时任务:群发API接口的客户/群群发
+     * 执行时间:每10分钟执行一次
+     * 功能:定时处理群发消息任务
+     */
+    @Scheduled(cron = "0 0/10 * * * ?")
+    public void sendQwGroupMsgTask() {
+        qwGroupMsgService.qwGroupMsgTask();
+    }
+
+    /**
+     * 定时任务:发送转换消息
+     * 执行时间:每天上午 8:00:00
+     * 功能:根据SOP规则发送转换消息
+     */
+    @Scheduled(cron = "0 0 8 * * ?")
+//    @Scheduled(cron = "0/10 * * * * ?") // 测试用:每10秒执行一次
+    public void sendQwBySop() {
+        sopUserLogsService.sendQwBySop();
+    }
+
+    /**
+     * 定时任务:企业微信自动打标签/备注补偿机制
+     * 执行时间:每3分钟执行一次
+     * 功能:对没有成功打标签或备注的记录进行补偿处理
+     */
+    @Scheduled(cron = "0 0/3 * * * ?")
+    public void qwExternalErrRetryTimer() {
+        log.info("补偿机制开始");
+        errRetryService.qwExternalErrRetryTimer();
+    }
+
+    /**
+     * 定时任务:补发过期完课消息
+     * 执行时间:每小时的第0分钟执行
+     * 功能:补发已过期但未发送的完课消息
+     */
+    @Scheduled(cron = "0 0 * * * ?")  // 每小时的第0分钟0秒执行
+    public void updateQwSopLogsByCancel() {
+        log.info("补发过期完课消息 - 定时任务开始");
+        try {
+            sopLogsTaskService.updateSopLogsByCancel();
+            log.info("补发过期完课消息 - 定时任务成功完成");
+        } catch (Exception e) {
+            log.error("补发过期完课消息 - 定时任务执行失败", e);
+        }
+    }
+
+    /**
+     * 定时任务:批量处理SOP待发送记录中已过期的消息
+     * 执行时间:每8分钟执行一次
+     * 功能:批量更新已过期的SOP待发送记录
+     */
+    @Scheduled(cron = "0 0/8 * * * ?")
+    public void batchProcessingExpiredMessages() {
+        log.info("批量处理sop待发送记录中已过期的消息");
+        try {
+            // 步骤1:批量获取已过期的记录
+            List<QwSopLogsDoSendListTVO> expireded = iQwSopLogsService.expiredMessagesByQwSopLogs();
+            if (!expireded.isEmpty()) {
+                // 步骤2:批量处理并插入记录
+                processAndInsertQwSopLogs(expireded);
+            }
+            log.info("处理已过期 - 定时任务成功完成");
+        } catch (Exception e) {
+            log.error("处理已过期 - 定时任务执行失败", e);
+        }
+    }
+
+    /**
+     * 批量处理插入逻辑,支持每500条数据一次的批量插入
+     *
+     * @param logsByJsApiNotExtId 需要处理的日志列表
+     */
+    private void processAndInsertQwSopLogs(List<QwSopLogsDoSendListTVO> logsByJsApiNotExtId) {
+        // 定义批量插入的大小
+        int batchSize = 500;
+
+        // 循环处理外部用户ID,每次处理批量大小的子集
+        for (int i = 0; i < logsByJsApiNotExtId.size(); i += batchSize) {
+            // 计算当前批次的结束索引
+            int endIndex = Math.min(i + batchSize, logsByJsApiNotExtId.size());
+            // 获取当前批次的子集
+            List<QwSopLogsDoSendListTVO> batchList = logsByJsApiNotExtId.subList(i, endIndex);
+
+            // 直接使用批次数据进行批量更新
+            try {
+                qwSopLogsMapper.batchUpdateQwSopLogsBySendTime(batchList);
+            } catch (Exception e) {
+                // 记录异常日志,方便后续排查问题
+                log.error("批量更新数据时发生异常,处理的批次起始索引为: " + i, e);
+            }
+        }
+    }
+
+    /**
+     * 定时任务:清除2天以前的SOP任务记录
+     * 执行时间:每天凌晨 0:10:00
+     * 功能:清理历史数据,保持数据库性能
+     */
+    @Scheduled(cron = "0 10 0 * * ?")
+    public void deleteQwSopLogsByDate() {
+        qwSopLogsMapper.deleteQwSopLogsByDate();
+    }
+
+    /**
+     * 定时任务:处理营期异常的数据
+     * 执行时间:每3小时的第30分钟执行
+     * 功能:修复营期相关的异常数据
+     */
+    @Scheduled(cron = "0 30 0/3 * * ? ")
+    public void processRepairQwSopLogsTimer() {
+        sopUserLogsService.repairSopUserLogsTimer();
+    }
+
+
+    /**
+     * 凌晨 2点35开始,将营期小于7天中标记为 是否7天未看课的(E级) 客户的 但是看课了的恢复一下
+     */
+    @Scheduled(cron = "0 35 2 * * ?")
+    @Async
+    public void processSopUserLogsInfoByIsDaysNotStudy() {
+        long startTimeMillis = System.currentTimeMillis();
+        log.info("====== 开始选择和处理 是否7天未看课的(E级) 客户的 恢复一下 ======");
+
+        logsInfoByIsDaysNotStudy.restoreByIsDaysNotStudy();
+
+        long endTimeMillis = System.currentTimeMillis();
+        log.info("====== 用户E级恢复处理完成,耗时 {} 毫秒 ======", (endTimeMillis - startTimeMillis));
+    }
+
+    /**
+     * 定时任务:客户评级处理
+     * 执行时间:每天凌晨 3:45:00
+     * 功能:对SOP营期用户进行分级评级
+     * 备注:异步执行,避免阻塞其他任务
+     */
+    @Scheduled(cron = "0 45 3 * * ?")
+    @Async
+    public void processQwSopExternalContactRatingTimer() {
+        // 记录任务开始时间
+        long startTimeMillis = System.currentTimeMillis();
+        log.info("====== 开始选择和处理 sop营期-用户分级 ======");
+
+        // 执行用户分级评级
+        qwExternalContactRatingService.ratingUserLogs();
+
+        // 计算并记录任务执行耗时
+        long endTimeMillis = System.currentTimeMillis();
+        log.info("====== sop营期-用户分级处理完成,耗时 {} 毫秒 ======", (endTimeMillis - startTimeMillis));
+    }
+
+    /**
+     * 凌晨4点35开始 客户超过7天没有看课的 标记E级
+     */
+    @Scheduled(cron = "0 30 3 * * ?")
+    @Async
+    public void processQwSopExternalContactRatingMoreSevenDaysTimer() {
+        long startTimeMillis = System.currentTimeMillis();
+        log.info("====== 开始选择和处理 sop营期-用户超7天的看课情况 ======");
+
+        qwExternalContactRatingMoreSevenDaysService.ratingMoreSevenDaysUserLogs();
+
+        long endTimeMillis = System.currentTimeMillis();
+        log.info("====== sop营期-用户超7天处理完成,耗时 {} 毫秒 ======", (endTimeMillis - startTimeMillis));
+    }
+
+
+    /**
+     * 更新掉所有前一天的所有待发送
+     */
+    @Scheduled(cron = "0 3 0 * * ?")
+    public void updateQwSopLogsDayBefore() {
+        long startTimeMillis = System.currentTimeMillis();
+        log.info("====== 更新掉所有前一天的所有待发送 ======");
+        qwSopLogsMapper.updateQwSopLogsByDayBefore();
+
+        long endTimeMillis = System.currentTimeMillis();
+        log.info("====== 更新掉所有前一天的所有待发送,耗时 {} 毫秒 ======", (endTimeMillis - startTimeMillis));
+    }
+
+    @Scheduled(cron = "0 1 0 */2 * ?")
+    public void updateQwExternalContactUnionid() {
+        long startTimeMillis = System.currentTimeMillis();
+        log.info("====== 同步外部联系人的UnionId ======");
+        syncQwExternalContactService.syncQwExternalContactUnionid();
+
+    }
+
+    /**
+     * 定时拉人进群
+     */
+    @Scheduled(cron = "0 0 16 * * ?")
+    public void autoPullGroup(){
+        //  拉群 ,①保持群号 ②每日拉群 ③创建建群记录
+        // 计算每个人最大拉人数量
+        long maxNum = (long) MAX_GROUP_NUM * MAX_GROUP_USER_NUM;
+        // 获取当前时间
+        LocalDate now = LocalDate.now();
+        // 获取需要自动拉群的SOP任务
+        List<QwSop> list = qwSopMapper.selectGroup(now);
+        if(list == null || list.isEmpty()) return;
+        list.forEach(sop -> {
+            // 获取这个SOP下面的企微ID
+            List<Long> qwUserIdList = Arrays.stream(sop.getQwUserIds().split(",")).map(Long::parseLong).distinct().collect(Collectors.toList());
+            // 获取企微ID下面的所有用户
+            List<QwExternalContact> qwExternalContactList = qwExternalContactService.selectQwUserAndLevel(qwUserIdList, Arrays.asList(sop.getAutoGroupLevel().split(",")), sop.getAutoUserReg() == 1);
+            // 根据企微ID进行分组
+            Map<Long, List<QwExternalContact>> qwUserMap = PubFun.listToMapByGroupList(qwExternalContactList, QwExternalContact::getQwUserId);
+            // 获取企微列表
+            List<QwUser> qwUserList = qwUserService.selectQwUserByIds(qwUserIdList);
+            try {
+                // 每个企微都拉人
+                qwUserList.stream().filter(qwUser -> qwUserMap.containsKey(qwUser.getId())).forEach(qwUser -> {
+                    List<QwExternalContact> userList = qwUserMap.get(qwUser.getId()).stream().limit(maxNum).collect(Collectors.toList());
+                    // 创建群 如果没人或者人数没达到满群的要求,不进行建群
+                    if(userList.isEmpty() || userList.size() < MAX_GROUP_USER_NUM) return;
+                    List<QwGroupChat> chatList = qwGroupChatService.selectSopAndQwUser(qwUser.getQwUserId(), sop.getId());
+                    int groupNum = 0;
+                    if (chatList != null && !chatList.isEmpty()) {
+                        groupNum = extractLastNumber(chatList.get(0).getName())  == null ? 0 : extractLastNumber(chatList.get(0).getName());
+                    }
+                    try {
+                        // 建群
+                        ipadSendUtils.createRoom(sop, sop.getGroupName(), qwUser, userList, MAX_GROUP_NUM, MAX_GROUP_USER_NUM,groupNum);
+                    }catch (Exception e){
+                        log.error("群聊拉人进群错误:{},企微ID:{},企微名称:{},外部联系人:{}", e.getMessage(), qwUser.getId(), qwUser.getQwUserName(), PubFun.listToNewList(userList, QwExternalContact::getId));
+                        log.error("群聊拉人进群错误", e);
+                    }
+                });
+            }catch (Exception e){
+                log.error("SOP拉人进群错误", e);
+            }
+        });
+    }
+    /**
+     * 提取字符串中最后的数字
+     * @param str 待处理的字符串
+     * @return 提取到的数字,若没有数字则返回null
+     */
+    public static Integer extractLastNumber(String str) {
+        if (str == null || str.isEmpty()) {
+            return null;
+        }
+
+        // 正则表达式:匹配字符串末尾的一个或多个数字
+        Pattern pattern = Pattern.compile("\\d+$");
+        Matcher matcher = pattern.matcher(str);
+
+        if (matcher.find()) {
+            String numberStr = matcher.group();
+            return Integer.parseInt(numberStr);
+        }
+
+        // 没有找到数字
+        return null;
+    }
+}

+ 10 - 0
fs-wx-task/src/main/java/com/fs/app/taskService/QwExternalContactRatingMoreSevenDaysService.java

@@ -0,0 +1,10 @@
+package com.fs.app.taskService;
+
+import com.fs.common.core.domain.R;
+
+public interface QwExternalContactRatingMoreSevenDaysService {
+    /**
+     * Sop客户超7天评次
+     */
+    public R ratingMoreSevenDaysUserLogs();
+}

+ 10 - 0
fs-wx-task/src/main/java/com/fs/app/taskService/QwExternalContactRatingService.java

@@ -0,0 +1,10 @@
+package com.fs.app.taskService;
+
+import com.fs.common.core.domain.R;
+
+public interface QwExternalContactRatingService {
+    /**
+     * Sop客户评级
+     */
+    public R ratingUserLogs();
+}

+ 8 - 0
fs-wx-task/src/main/java/com/fs/app/taskService/SopLogsChatTaskService.java

@@ -0,0 +1,8 @@
+package com.fs.app.taskService;
+
+import java.time.LocalDateTime;
+
+public interface SopLogsChatTaskService {
+
+    public void createAiChatSopLogs(LocalDateTime today) throws Exception;
+}

+ 22 - 0
fs-wx-task/src/main/java/com/fs/app/taskService/SopLogsTaskService.java

@@ -0,0 +1,22 @@
+package com.fs.app.taskService;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+public interface SopLogsTaskService {
+
+    public void selectSopUserLogsListByTime(LocalDateTime currentTime, List<String> sopidList) throws Exception;
+
+
+    /**
+     * 补发过期完课消息
+     */
+    void updateSopLogsByCancel();
+
+    /**
+     * 创建完课消息
+     */
+    void createCourseFinishMsg();
+
+//    void creatMessMessage(QwSopLogs logs);
+}

+ 11 - 0
fs-wx-task/src/main/java/com/fs/app/taskService/SopLogsTestService.java

@@ -0,0 +1,11 @@
+package com.fs.app.taskService;
+
+public interface SopLogsTestService {
+
+    /**
+     *手动生成sopLogs
+     * @throws Exception
+     */
+    public void selectSopUserLogsListByTest() throws Exception;
+
+}

+ 9 - 0
fs-wx-task/src/main/java/com/fs/app/taskService/SopUserLogsInfoByIsDaysNotStudy.java

@@ -0,0 +1,9 @@
+package com.fs.app.taskService;
+
+public interface SopUserLogsInfoByIsDaysNotStudy {
+
+    /**
+     * 将前7天营期中标记为 是否7天未看课的(E级) 客户的 恢复一下,突然有的恢复一下 (复刻版)
+     */
+    public void restoreByIsDaysNotStudy();
+}

+ 8 - 0
fs-wx-task/src/main/java/com/fs/app/taskService/SopWxLogsService.java

@@ -0,0 +1,8 @@
+package com.fs.app.taskService;
+
+import java.time.LocalDateTime;
+
+public interface SopWxLogsService {
+
+    void wxSopLogsByTime(LocalDateTime currentTime) throws Exception;
+}

+ 10 - 0
fs-wx-task/src/main/java/com/fs/app/taskService/SyncQwExternalContactService.java

@@ -0,0 +1,10 @@
+package com.fs.app.taskService;
+
+import com.fs.common.core.domain.R;
+
+public interface SyncQwExternalContactService {
+
+    R syncQwExternalContactUnionid();
+
+
+}

+ 374 - 0
fs-wx-task/src/main/java/com/fs/app/taskService/impl/AsyncCourseWatchFinishService.java

@@ -0,0 +1,374 @@
+package com.fs.app.taskService.impl;
+
+
+import com.alibaba.fastjson.JSON;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.course.domain.FsCourseWatchLog;
+import com.fs.qw.domain.QwCompany;
+import com.fs.qw.domain.QwUser;
+import com.fs.qw.service.IQwCompanyService;
+import com.fs.qw.service.impl.QwExternalContactServiceImpl;
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+import org.apache.rocketmq.client.exception.MQClientException;
+import org.apache.rocketmq.client.producer.SendCallback;
+import org.apache.rocketmq.client.producer.SendResult;
+import org.apache.rocketmq.common.message.MessageConst;
+import org.apache.rocketmq.spring.core.RocketMQTemplate;
+import org.apache.rocketmq.spring.support.RocketMQHeaders;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.messaging.support.MessageBuilder;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.PostConstruct;
+import javax.annotation.PreDestroy;
+import java.util.Optional;
+import java.util.concurrent.*;
+
+@Slf4j
+@Service
+@AllArgsConstructor
+public class AsyncCourseWatchFinishService {
+
+    @Autowired
+    private RocketMQTemplate rocketMQTemplate;
+
+    @Autowired
+    private IQwCompanyService iQwCompanyService;
+
+    @Autowired
+    private QwExternalContactServiceImpl qwExternalContactService;
+
+    @Autowired
+    RedisCache redisCache;
+
+    // 重试队列和调度器
+    private final BlockingQueue<RetryMessage> retryQueue = new LinkedBlockingQueue<>(10000);
+    private final ScheduledExecutorService retryExecutor = Executors.newSingleThreadScheduledExecutor();
+
+    // 主题映射配置
+    private static final String TOPIC = "course-finish-notes";
+
+    @PostConstruct
+    public void init() {
+        // 启动重试任务,每5秒处理一次重试队列
+        retryExecutor.scheduleWithFixedDelay(this::processRetryQueue, 10, 5, TimeUnit.SECONDS);
+        log.info("AsyncCourseWatchFinishService 重试队列处理器已启动");
+    }
+
+    /**
+    * 异步处理完课打备注的
+    */
+    @Async("scheduledExecutorService")
+    public void executeCourseWatchFinish(FsCourseWatchLog finishLog) {
+//        原代码
+//        FsCourseWatchLog watchLog = new FsCourseWatchLog();
+//        watchLog.setQwExternalContactId(finishLog.getQwExternalContactId());
+//        watchLog.setFinishTime(finishLog.getFinishTime());
+//        watchLog.setQwUserId(finishLog.getQwUserId());
+//
+//
+//        QwUser qwUserByRedis = qwExternalContactService.getQwUserByRedisForId(String.valueOf(finishLog.getQwUserId()));
+//        if (qwUserByRedis == null) {
+//            log.error("无企微员工信息 {} 跳过处理。", finishLog.getQwUserId());
+//            return;
+//        }
+//
+//        QwCompany qwCompany = iQwCompanyService.getQwCompanyByRedis(qwUserByRedis.getCorpId());
+//
+//        if (qwCompany == null) {
+//            log.error("企业微信主体为空 {} 跳过处理。{} ", qwUserByRedis.getCorpId(),watchLog);
+//            return;
+//        }
+//
+//        rocketMQTemplate.asyncSend("course-finish-notes", JSON.toJSONString(finishLog),     new SendCallback() {
+//            @Override public void onSuccess(SendResult sendResult) {
+//                log.info("推送完课打备注成功1:{},{}",JSON.toJSONString(finishLog),sendResult.getMsgId());
+//            }  // 空实现
+//            @Override public void onException(Throwable e) {log.error("推送完课打备注失败1:{},{}",JSON.toJSONString(finishLog),e.getMessage());}          // 空实现
+//        });
+
+
+//        // 定义默认值
+//         final Integer DEFAULT_SERVER_NUM = 99;
+//
+//        // 使用
+//        Integer companyServerNum = Optional.ofNullable(qwCompany.getCompanyServerNum())
+//                .orElse(DEFAULT_SERVER_NUM);
+//        switch (companyServerNum){
+//            case 1:
+//                rocketMQTemplate.asyncSend("course-finish-notes", JSON.toJSONString(finishLog),     new SendCallback() {
+//                    @Override public void onSuccess(SendResult sendResult) {
+//                     log.info("推送完课打备注成功1:{},{}",JSON.toJSONString(finishLog),sendResult.getMsgId());
+//                     }  // 空实现
+//                    @Override public void onException(Throwable e) {log.error("推送完课打备注失败1:{},{}",JSON.toJSONString(finishLog),e.getMessage());}          // 空实现
+//                });
+//                break;
+//            case 2:
+//
+//                rocketMQTemplate.asyncSend("course-finish-notesTwo", JSON.toJSONString(finishLog),     new SendCallback() {
+//                    @Override public void onSuccess(SendResult sendResult) {}  // 空实现
+//                    @Override public void onException(Throwable e) {log.error("推送完课打备注失败2:{},{}",JSON.toJSONString(finishLog),e.getMessage());}          // 空实现
+//                });
+//                break;
+//            case 3:
+//                rocketMQTemplate.asyncSend("course-finish-notesThree", JSON.toJSONString(finishLog),     new SendCallback() {
+//                    @Override public void onSuccess(SendResult sendResult) {}  // 空实现
+//                    @Override public void onException(Throwable e) {log.error("推送完课打备注失败3:{},{}",JSON.toJSONString(finishLog),e.getMessage());}          // 空实现
+//                });
+//                break;
+//            default:
+//                break;
+//        }
+
+
+        // 1. 数据验证和准备
+        ValidationResult validationResult = validateAndPrepareData(finishLog);
+        if (!validationResult.isValid()) {
+            return;
+        }
+
+
+        //  2. 发送消息(使用Tag区分)
+        sendWithFlowControl(finishLog, validationResult, 0);
+
+    }
+
+    /**
+     * 数据验证和准备
+     */
+    private ValidationResult validateAndPrepareData(FsCourseWatchLog finishLog) {
+        // 准备日志对象
+        FsCourseWatchLog watchLog = new FsCourseWatchLog();
+        watchLog.setQwExternalContactId(finishLog.getQwExternalContactId());
+        watchLog.setFinishTime(finishLog.getFinishTime());
+        watchLog.setQwUserId(finishLog.getQwUserId());
+
+        // 验证企微用户信息
+        QwUser qwUserByRedis = qwExternalContactService.getQwUserByRedisForId(String.valueOf(finishLog.getQwUserId()));
+        if (qwUserByRedis == null) {
+            log.error("无企微员工信息 {} 跳过处理。", finishLog.getQwUserId());
+            return ValidationResult.invalid();
+        }
+
+        // 验证企业主体
+        QwCompany qwCompany = iQwCompanyService.getQwCompanyByRedis(qwUserByRedis.getCorpId());
+        if (qwCompany == null) {
+            log.error("企业微信主体为空 {} 跳过处理。{} ", qwUserByRedis.getCorpId(), watchLog);
+            return ValidationResult.invalid();
+        }
+
+        return ValidationResult.valid(watchLog, qwUserByRedis, qwCompany);
+    }
+
+
+    /**
+     * 带流控处理的消息发送
+     */
+    private void sendWithFlowControl(FsCourseWatchLog finishLog,
+                                     ValidationResult validationResult, int retryCount) {
+        if (retryCount >= 3) {
+            log.warn("消息重试超过最大次数,转入重试队列: topic={}, qwUserId={}",
+                    TOPIC, finishLog.getQwUserId());
+            offerToRetryQueue(finishLog, validationResult);
+            return;
+        }
+
+        rocketMQTemplate.asyncSend(TOPIC, JSON.toJSONString(finishLog), new SendCallback() {
+            @Override
+            public void onSuccess(SendResult sendResult) {
+                log.info("推送完课打备注成功1:{},{}",JSON.toJSONString(finishLog),sendResult.getMsgId());
+            }
+
+            @Override
+            public void onException(Throwable e) {
+//                if (isFlowControlException(e)) {
+//                    // 流控异常处理
+//                    handleFlowControlRetry(TOPIC, finishLog, validationResult, retryCount, e);
+//                    log.error("推送完课打备注失败1流控异常:finishLog={},e={}",JSON.toJSONString(finishLog),e.getMessage());
+//                } else {
+//                    // 其他异常
+//                    log.error("推送完课打备注失败1:{},{}",JSON.toJSONString(finishLog),e.getMessage());
+//                }
+                if (isFlowControlException(e)) {
+                    // 流控异常处理
+                    handleFlowControlRetry(TOPIC, finishLog, validationResult, retryCount, e);
+                    log.error("推送完课打备注流控异常,准备重试。retryCount: {}, topic: {}, finishLogId: {}",
+                            retryCount, TOPIC, finishLog.getLogId()); // 只记录关键信息
+                } else {
+                    // 其他异常 - 记录完整堆栈
+                    log.error("推送完课打备注失败,非流控异常。finishLog: {}",
+                            JSON.toJSONString(finishLog), e); // 注意这里传 e 而不是 e.getMessage()
+                }
+            }
+        });
+    }
+
+    /**
+     * 放入重试队列
+     */
+    private void offerToRetryQueue(FsCourseWatchLog finishLog,
+                                   ValidationResult validationResult) {
+        RetryMessage retryMessage = new RetryMessage(finishLog, validationResult);
+        boolean offered = retryQueue.offer(retryMessage);
+        if (offered) {
+            log.info("消息已加入重试队列: topic={}, qwUserId={}", TOPIC, finishLog.getQwUserId());
+        } else {
+            log.error("重试队列已满,消息可能丢失: topic={}, qwUserId={}", TOPIC, finishLog.getQwUserId());
+            // 这里可以接入告警系统
+        }
+    }
+
+    /**
+     * 处理重试队列
+     */
+    private void processRetryQueue() {
+        try {
+            int processedCount = 0;
+            RetryMessage retryMessage;
+
+            while (processedCount < 100 && (retryMessage = retryQueue.poll()) != null) {
+                try {
+                    // 重新发送消息
+                    sendWithFlowControl(retryMessage.getFinishLog(),
+                            retryMessage.getValidationResult(), 0);
+                    processedCount++;
+
+                    Thread.sleep(10);
+                } catch (Exception e) {
+                    log.error("重试队列处理失败: {}", e.getMessage());
+                    offerToRetryQueue(retryMessage.getFinishLog(), retryMessage.getValidationResult());
+                }
+            }
+
+            if (processedCount > 0) {
+                log.debug("重试队列处理完成,本次处理数量: {}", processedCount);
+            }
+        } catch (Exception e) {
+            log.error("处理重试队列异常: {}", e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 判断是否为流控异常
+     */
+    private boolean isFlowControlException(Throwable e) {
+        // 检查异常消息中是否包含流控关键词(双重保障)
+        String errorMessage = e.getMessage();
+        if (errorMessage != null && (
+                errorMessage.contains("flow control") ||
+                errorMessage.contains("exhausted the send quota") ||
+                errorMessage.contains("CODE: 215"))) {
+            return true;
+        }
+
+        if (e instanceof MQClientException) {
+            return ((MQClientException) e).getResponseCode() == 215;
+        }
+        // 检查异常链
+        Throwable cause = e.getCause();
+        if (cause instanceof MQClientException) {
+            return ((MQClientException) cause).getResponseCode() == 215;
+        }
+        return false;
+    }
+
+    private static final int MAX_FLOW_CONTROL_RETRY = 5;
+    /**
+     * 流控重试处理
+     */
+    private void handleFlowControlRetry(String topic, FsCourseWatchLog finishLog,
+                                        ValidationResult validationResult, int retryCount, Throwable e) {
+        // 检查重试次数限制
+//        if (retryCount >= MAX_FLOW_CONTROL_RETRY) {
+//            log.error("流控重试达到最大次数 {},放弃重试。topic: {}, qwUserId: {}, logId: {}",
+//                    MAX_FLOW_CONTROL_RETRY, topic, finishLog.getQwUserId(), finishLog.getLogId());
+//            //todo 可以记录到数据库或发送告警
+//            return;
+//        }
+
+        long backoffTime = calculateBackoffTime(retryCount);
+        log.warn("流控触发,{}ms后第{}次重试: topic={}, qwUserId={}",
+                backoffTime, retryCount + 1, topic, finishLog.getQwUserId());
+
+        // 使用 ScheduledExecutorService 进行延迟执行
+        retryExecutor.schedule(() -> {
+            try {
+                sendWithFlowControl(finishLog, validationResult, retryCount + 1);
+            } catch (Exception ex) {
+                log.error("延迟重试执行异常 - qwUserId: {}, logId: {}, error: {}",
+                        finishLog.getQwUserId(), finishLog.getLogId(), ex.getMessage(), ex);
+            }
+        }, backoffTime, TimeUnit.MILLISECONDS);
+    }
+    /**
+     * 计算退避时间(指数退避)
+     */
+    private long calculateBackoffTime(int retryCount) {
+//        return Math.min(1000 * (long) Math.pow(2, retryCount), 10000); // 最大10秒
+        // 基础退避:1s, 2s, 4s, 8s, 16s, 32s
+        long baseDelay = Math.min(1000L * (1L << Math.min(retryCount, 5)), 32000L);
+        // 添加随机抖动 (0~2s),避免多个客户端同时重试
+        long jitter = (long) (Math.random() * 1000);
+
+        return baseDelay + jitter;
+    }
+
+    @PreDestroy
+    public void destroy() {
+        retryExecutor.shutdown();
+        try {
+            if (!retryExecutor.awaitTermination(10, TimeUnit.SECONDS)) {
+                retryExecutor.shutdownNow();
+            }
+        } catch (InterruptedException e) {
+            retryExecutor.shutdownNow();
+            Thread.currentThread().interrupt();
+        }
+        log.info("AsyncCourseWatchFinishService 已关闭");
+    }
+
+    // 内部辅助类
+    private static class ValidationResult {
+        private final boolean valid;
+        private final FsCourseWatchLog watchLog;
+        private final QwUser qwUser;
+        private final QwCompany qwCompany;
+
+        public ValidationResult(boolean valid, FsCourseWatchLog watchLog, QwUser qwUser, QwCompany qwCompany) {
+            this.valid = valid;
+            this.watchLog = watchLog;
+            this.qwUser = qwUser;
+            this.qwCompany = qwCompany;
+        }
+
+        public static ValidationResult valid(FsCourseWatchLog watchLog, QwUser qwUser, QwCompany qwCompany) {
+            return new ValidationResult(true, watchLog, qwUser, qwCompany);
+        }
+
+        public static ValidationResult invalid() {
+            return new ValidationResult(false, null, null, null);
+        }
+
+        public boolean isValid() { return valid; }
+        public FsCourseWatchLog getWatchLog() { return watchLog; }
+        public QwUser getQwUser() { return qwUser; }
+        public QwCompany getQwCompany() { return qwCompany; }
+    }
+
+    private static class RetryMessage {
+        private final FsCourseWatchLog finishLog;
+        private final ValidationResult validationResult;
+
+        public RetryMessage(FsCourseWatchLog finishLog, ValidationResult validationResult) {
+            this.finishLog = finishLog;
+            this.validationResult = validationResult;
+        }
+
+        public FsCourseWatchLog getFinishLog() { return finishLog; }
+        public ValidationResult getValidationResult() { return validationResult; }
+    }
+
+}

+ 333 - 0
fs-wx-task/src/main/java/com/fs/app/taskService/impl/QwExternalContactRatingMoreSevenDaysServiceImpl.java

@@ -0,0 +1,333 @@
+package com.fs.app.taskService.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.app.taskService.QwExternalContactRatingMoreSevenDaysService;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.course.mapper.FsCourseWatchLogMapper;
+import com.fs.qw.domain.QwExternalContact;
+import com.fs.qw.mapper.QwExternalContactMapper;
+import com.fs.sop.domain.SopUserLogs;
+import com.fs.sop.domain.SopUserLogsInfo;
+import com.fs.sop.mapper.SopUserLogsInfoMapper;
+import com.fs.sop.mapper.SopUserLogsMapper;
+import com.fs.sop.params.QwRatingConfig;
+import com.fs.sop.service.IQwSopTempDayService;
+import com.fs.sop.service.ISopUserLogsInfoService;
+import com.fs.sop.vo.QwRatingVO;
+import com.fs.system.service.ISysConfigService;
+import com.fs.voice.utils.StringUtil;
+import com.google.common.util.concurrent.AtomicDouble;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.PostConstruct;
+import javax.annotation.PreDestroy;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.*;
+import java.util.stream.Collectors;
+
+@Service
+@Slf4j
+public class QwExternalContactRatingMoreSevenDaysServiceImpl implements QwExternalContactRatingMoreSevenDaysService {
+
+
+
+    @Autowired
+    private ISysConfigService configService;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    @Autowired
+    private SopUserLogsMapper sopUserLogsMapper;
+
+    @Autowired
+    private IQwSopTempDayService qwSopTempDayService;
+
+    @Autowired
+    private SopUserLogsInfoMapper sopUserLogsInfoMapper;
+
+    @Autowired
+    private FsCourseWatchLogMapper fsCourseWatchLogMapper;
+
+    @Autowired
+    private QwExternalContactMapper qwExternalContactMapper;
+
+    @Autowired
+    private ISopUserLogsInfoService iSopUserLogsInfoService;
+
+    @Autowired
+    private ExecutorService sopRatingExecutor;  // 自定义线程池
+
+    // 任务队列
+    private final BlockingQueue<SopUserLogs> taskQueue = new LinkedBlockingQueue<>(10000);
+
+    private volatile boolean running = true;
+    //批量更新队列
+    private final List<CompletableFuture<Void>> updateFutures = Collections.synchronizedList(new ArrayList<>());
+
+    private final Object configLock = new Object();
+
+    // 启动时初始化消费者线程
+    @PostConstruct
+    public void init() {
+
+        loadCourseConfig();
+
+        int consumerCount = Runtime.getRuntime().availableProcessors(); // 消费者线程数,默认 CPU 核心数
+        for (int i = 0; i < consumerCount; i++) {
+            sopRatingExecutor.submit(this::consumeTasks); // 提交消费者任务
+        }
+
+        log.info("初始化 {} 个消费者线程", consumerCount);
+    }
+
+    private  volatile QwRatingConfig qwRatingConfig;
+
+    private void loadCourseConfig() {
+        try {
+            String json = configService.selectConfigByKey("qwRating:config");
+            QwRatingConfig config = JSON.parseObject(json, QwRatingConfig.class);
+            if (!StringUtil.strIsNullOrEmpty(json) && config != null) {
+                qwRatingConfig = config;
+                log.info("Loaded qwRating.config successfully.");
+            } else {
+                log.error("Failed to load course.config from configService.");
+            }
+        } catch (Exception e) {
+            log.error("Exception while loading qwRating.config: {}", e.getMessage(), e);
+        }
+    }
+
+
+    @Override
+    public R ratingMoreSevenDaysUserLogs() {
+        // 分页加载并放入队列
+        int pageSize = 1000;
+        int offset = 0;
+        List<SopUserLogs> sopUserLogs;
+
+        // 获取缓存的配置
+        QwRatingConfig config;
+        synchronized(configLock) {
+            config = qwRatingConfig;
+        }
+
+        do {
+            sopUserLogs = sopUserLogsMapper.meetsTheRatingByUserInfoWithPaginationStudyDays(offset, pageSize,config.getNotStudyDays());
+            if (!sopUserLogs.isEmpty()) {
+                sopUserLogs.forEach(item -> {
+                    try {
+                        taskQueue.put(item); // 将任务放入队列
+                    } catch (InterruptedException e) {
+                        log.error("任务放入队列失败,sopId: {}", item.getSopId(), e);
+                        Thread.currentThread().interrupt();
+                    }
+                });
+                offset += pageSize;
+            }
+        } while (!sopUserLogs.isEmpty());
+
+
+        // 等待队列处理完成
+        CompletableFuture.runAsync(() -> {
+            while (!taskQueue.isEmpty()) {
+                try {
+                    Thread.sleep(1000);
+                } catch (InterruptedException e) {
+                    log.error("等待队列处理时中断", e);
+                    Thread.currentThread().interrupt();
+                }
+            }
+        }).join(); // 等待任务完成
+
+        return R.ok();
+    }
+
+
+    private void consumeTasks() {
+
+        if (!running && taskQueue.isEmpty()) {
+            log.info("没有评级任务需要处理");
+            return; // 如果队列为空且没有正在运行的线程,则直接返回
+        }
+
+        while (running) {
+            try {
+                SopUserLogs item = taskQueue.poll(1, TimeUnit.SECONDS); // 等待 1 秒
+                if (item != null) {
+                    processSingleTask(item);
+                }
+            } catch (Exception e) {
+                log.error("消费者线程异常", e);
+            }
+        }
+    }
+
+    private void processSingleTask(SopUserLogs item) {
+
+        // 获取缓存的配置
+        QwRatingConfig config;
+        synchronized(configLock) {
+            config = qwRatingConfig;
+        }
+
+        List<SopUserLogsInfo> sopUserLogsInfosList = sopUserLogsInfoMapper
+                .selectSopUserLogsInfoListBySopId(item.getSopId(), item.getId());
+
+        if (sopUserLogsInfosList == null || sopUserLogsInfosList.isEmpty()) {
+            log.error("当前营期没有客户-sopId:{},营期id:{}", item.getSopId(), item.getId());
+            return;
+        }
+
+        List<QwExternalContact> batchQwExternalContact = sopUserLogsInfosList.stream()
+                .map(logsInfo -> processUserLog(logsInfo, config))
+                .filter(Objects::nonNull)
+                .collect(Collectors.toList());
+
+        if (!batchQwExternalContact.isEmpty()) {
+            batchUpdateQwExternalContact(batchQwExternalContact);
+        }
+    }
+
+    private QwExternalContact processUserLog(SopUserLogsInfo logsInfo, QwRatingConfig config) {
+        try {
+            Long externalId = logsInfo.getExternalId();
+            if (externalId == null) {
+                return null;
+            }
+
+            List<QwRatingVO> ratingVOS = fsCourseWatchLogMapper
+                    .selectFsCourseWatchLogByExtIdRatingMoreStudyDays(externalId, config.getNotStudyDays());
+
+            if (ratingVOS == null || ratingVOS.isEmpty() || ratingVOS.size() < 6) {
+                log.info("没有记录或不满足条件不评级或看课记录小于6 不评级,externalId: {}", externalId);
+                return null;
+            }
+
+
+            //判断 7天的时长是否大于0
+            boolean scoreMoreStudyLevel = getScoreMoreStudyLevel(ratingVOS);
+
+            if (!scoreMoreStudyLevel) {
+                QwExternalContact externalContact = new QwExternalContact();
+                externalContact.setId(externalId);
+                externalContact.setLevel(5);
+                externalContact.setIsDaysNotStudy(1);
+                return externalContact;
+            }else {
+                QwExternalContact externalContact = new QwExternalContact();
+                externalContact.setId(externalId);
+                externalContact.setLevel(ratingVOS.get(0).getLevel());
+                externalContact.setIsDaysNotStudy(0);
+                return externalContact;
+            }
+
+
+        } catch (Exception e) {
+            log.error("计算用户积分异常,用户:{}", logsInfo, e);
+            return null;
+        }
+    }
+
+    private void batchUpdateQwExternalContact(List<QwExternalContact> notInExternalUseridList) {
+        int batchSize = 300;
+
+        for (int i = 0; i < notInExternalUseridList.size(); i += batchSize) {
+            int endIndex = Math.min(i + batchSize, notInExternalUseridList.size());
+            List<QwExternalContact> batchList = notInExternalUseridList.subList(i, endIndex);
+
+            int finalI = i;
+            CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
+                try {
+                    qwExternalContactMapper.batchUpdateQwExternalContactByMoreStudy(batchList);
+                    iSopUserLogsInfoService.batchUpdateSopUserLogsInfoByMoreStudy(batchList);
+                    log.info("成功更新看课7天数据,起始索引: {}, 数量: {}", finalI, batchList.size());
+                } catch (Exception e) {
+                    log.error("批量更新异常,批次起始索引: {}", finalI, e);
+                }
+
+            }, sopRatingExecutor);
+
+            updateFutures.add(future);
+        }
+    }
+
+    @PreDestroy
+    public void shutdown() {
+        running = false;  // 标记消费者停止
+        log.info("正在关闭线程池...");
+
+        // **等待任务队列处理完毕**
+        while (!taskQueue.isEmpty()) {
+            try {
+                Thread.sleep(500);
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                log.warn("等待任务队列处理完成时被中断", e);
+            }
+        }
+
+        // **确保所有 `batchUpdateQwExternalContact` 的任务完成**
+        log.info("等待所有批量更新任务完成...");
+        CompletableFuture.allOf(updateFutures.toArray(new CompletableFuture[0])).join();
+
+        // 关闭线程池
+        sopRatingExecutor.shutdown();
+        try {
+            if (!sopRatingExecutor.awaitTermination(60, TimeUnit.SECONDS)) {
+                List<Runnable> pendingTasks = sopRatingExecutor.shutdownNow();
+                log.warn("强制关闭线程池,未完成任务数: {}", pendingTasks.size());
+            }
+        } catch (InterruptedException e) {
+            sopRatingExecutor.shutdownNow();
+            Thread.currentThread().interrupt();
+        }
+        log.info("线程池和消费者已完全关闭");
+    }
+
+
+    /**
+     * 每6小时更新一次
+     */
+    @Scheduled(cron = "0 50 0/6 * * ?")
+    public void refreshRatingConfig() {
+
+        synchronized(configLock) {
+            try {
+                String json = configService.selectConfigByKey("qwRating:config");
+                QwRatingConfig config = JSON.parseObject(json, QwRatingConfig.class);
+                if (!StringUtil.strIsNullOrEmpty(json) && config != null) {
+                    qwRatingConfig = config;
+                    log.info("LoadedTime qwRating.config successfully.");
+                } else {
+                    log.error("Failed to load course.config from configService.");
+                }
+            } catch (Exception e) {
+                log.error("Exception while refreshing course.config: {}", e.getMessage(), e);
+            }
+        }
+
+    }
+
+
+    //查 E级
+    public boolean getScoreMoreStudyLevel(List<QwRatingVO> qwRatingVOS) {
+
+        AtomicDouble watchCount= new AtomicDouble();
+
+        qwRatingVOS.forEach(vo -> {
+            watchCount.addAndGet(vo.getWatchDuration());
+        });
+
+        // 判断总 watchDuration 是否 > 0
+        return watchCount.get() > 0;
+    }
+
+}

+ 410 - 0
fs-wx-task/src/main/java/com/fs/app/taskService/impl/QwExternalContactRatingServiceImpl.java

@@ -0,0 +1,410 @@
+package com.fs.app.taskService.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.app.taskService.QwExternalContactRatingService;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.course.mapper.FsCourseWatchLogMapper;
+import com.fs.qw.domain.QwExternalContact;
+import com.fs.qw.mapper.QwExternalContactMapper;
+import com.fs.sop.domain.SopUserLogs;
+import com.fs.sop.domain.SopUserLogsInfo;
+import com.fs.sop.mapper.SopUserLogsInfoMapper;
+import com.fs.sop.mapper.SopUserLogsMapper;
+import com.fs.sop.params.QwRatingConfig;
+import com.fs.sop.service.IQwSopTempDayService;
+import com.fs.sop.service.ISopUserLogsInfoService;
+import com.fs.sop.vo.QwRatingVO;
+import com.fs.system.service.ISysConfigService;
+import com.fs.voice.utils.StringUtil;
+import com.google.common.util.concurrent.AtomicDouble;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.PostConstruct;
+import javax.annotation.PreDestroy;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.*;
+import java.util.stream.Collectors;
+
+@Service
+@Slf4j
+public class QwExternalContactRatingServiceImpl implements QwExternalContactRatingService {
+
+
+    @Autowired
+    private ISysConfigService configService;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    @Autowired
+    private SopUserLogsMapper sopUserLogsMapper;
+
+    @Autowired
+    private IQwSopTempDayService qwSopTempDayService;
+
+    @Autowired
+    private SopUserLogsInfoMapper sopUserLogsInfoMapper;
+
+    @Autowired
+    private FsCourseWatchLogMapper fsCourseWatchLogMapper;
+
+    @Autowired
+    private QwExternalContactMapper qwExternalContactMapper;
+
+    @Autowired
+    private ISopUserLogsInfoService iSopUserLogsInfoService;
+
+    @Autowired
+    private ExecutorService sopRatingExecutor;  // 自定义线程池
+
+    // 任务队列
+    private final BlockingQueue<SopUserLogs> taskQueue = new LinkedBlockingQueue<>(10000);
+    private volatile boolean running = true;
+    //批量更新队列
+    private final List<CompletableFuture<Void>> updateFutures = Collections.synchronizedList(new ArrayList<>());
+
+    private final Object configLock = new Object();
+
+    // 启动时初始化消费者线程
+    @PostConstruct
+    public void init() {
+
+        loadCourseConfig();
+
+        int consumerCount = Runtime.getRuntime().availableProcessors(); // 消费者线程数,默认 CPU 核心数
+        for (int i = 0; i < consumerCount; i++) {
+            sopRatingExecutor.submit(this::consumeTasks); // 提交消费者任务
+        }
+
+        log.info("初始化 {} 个消费者线程", consumerCount);
+    }
+
+    private  volatile QwRatingConfig qwRatingConfig;
+
+    private void loadCourseConfig() {
+        try {
+            String json = configService.selectConfigByKey("qwRating:config");
+            QwRatingConfig config = JSON.parseObject(json, QwRatingConfig.class);
+            if (!StringUtil.strIsNullOrEmpty(json) && config != null) {
+                qwRatingConfig = config;
+                log.info("Loaded qwRating.config successfully.");
+            } else {
+                log.error("Failed to load course.config from configService.");
+            }
+        } catch (Exception e) {
+            log.error("Exception while loading qwRating.config: {}", e.getMessage(), e);
+        }
+    }
+
+    @Override
+    public R ratingUserLogs() {
+
+        // 分页加载并放入队列
+        int pageSize = 1000;
+        int offset = 0;
+        List<SopUserLogs> sopUserLogs;
+
+        do {
+            sopUserLogs = sopUserLogsMapper.meetsTheRatingByUserInfoWithPagination(offset, pageSize);
+            if (!sopUserLogs.isEmpty()) {
+                sopUserLogs.forEach(item -> {
+                    try {
+                        taskQueue.put(item); // 将任务放入队列
+                    } catch (InterruptedException e) {
+                        log.error("任务放入队列失败,sopId: {}", item.getSopId(), e);
+                        Thread.currentThread().interrupt();
+                    }
+                });
+                offset += pageSize;
+            }
+        } while (!sopUserLogs.isEmpty());
+
+
+        // 等待队列处理完成
+        CompletableFuture.runAsync(() -> {
+            while (!taskQueue.isEmpty()) {
+                try {
+                    Thread.sleep(1000);
+                } catch (InterruptedException e) {
+                    log.error("等待队列处理时中断", e);
+                    Thread.currentThread().interrupt();
+                }
+            }
+        }).join(); // 等待任务完成
+
+        return R.ok();
+    }
+
+
+    private void consumeTasks() {
+
+        if (!running && taskQueue.isEmpty()) {
+            return; // 如果队列为空且没有正在运行的线程,则直接返回
+        }
+
+        while (running) {
+            try {
+                SopUserLogs item = taskQueue.poll(1, TimeUnit.SECONDS); // 等待 1 秒
+                if (item != null) {
+                    processSingleTask(item);
+                }
+            } catch (Exception e) {
+                log.error("消费者线程异常", e);
+            }
+        }
+    }
+
+    private void processSingleTask(SopUserLogs item) {
+
+        // 获取缓存的配置
+        QwRatingConfig config;
+        synchronized(configLock) {
+            config = qwRatingConfig;
+        }
+
+        Integer countDays = item.getCountDays();
+        String cacheKey = "sop-tempId:" + item.getSopTempId();
+        Integer sopTemIdNum = redisCache.getCacheObject(cacheKey);
+
+        if (sopTemIdNum == null) {
+            sopTemIdNum = qwSopTempDayService.getDayNumByIdLimitOne(item.getSopTempId());
+            redisCache.setCacheObject(cacheKey, sopTemIdNum, 3, TimeUnit.HOURS);
+        }
+
+        if (sopTemIdNum < countDays) {
+            log.info("当前营期的伦次中,模板天数不足。不评级:{}|sopId:{}", item.getSopTempId(), item.getSopId());
+            return;
+        }
+
+        List<SopUserLogsInfo> sopUserLogsInfosList = sopUserLogsInfoMapper
+                .selectSopUserLogsInfoListBySopId(item.getSopId(), item.getId());
+
+        if (sopUserLogsInfosList == null || sopUserLogsInfosList.isEmpty()) {
+            log.error("当前营期没有客户-sopId:{},营期id:{}", item.getSopId(), item.getId());
+            return;
+        }
+
+        List<QwExternalContact> batchQwExternalContact = sopUserLogsInfosList.stream()
+                .map(logsInfo -> processUserLog(logsInfo, config))
+                .filter(Objects::nonNull)
+                .collect(Collectors.toList());
+
+        if (!batchQwExternalContact.isEmpty()) {
+            batchUpdateQwExternalContact(batchQwExternalContact);
+        }
+    }
+
+    private QwExternalContact processUserLog(SopUserLogsInfo logsInfo, QwRatingConfig config) {
+        try {
+            Long externalId = logsInfo.getExternalId();
+            if (externalId == null) {
+                return null;
+            }
+
+            List<QwRatingVO> ratingVOS = fsCourseWatchLogMapper
+                    .selectFsCourseWatchLogByExtIdRating(externalId, config.getLevelDay());
+
+            if (ratingVOS == null || ratingVOS.isEmpty()) {
+                log.info("没有记录不评级,externalId: {}", externalId);
+                return null;
+            }
+
+            int scoreLevel = getScoreLevel(ratingVOS, config);
+            int latestTime = getLatestTime(ratingVOS);
+            int levelUpFall = ratingVOS.get(0).getLevelType() != null
+                    ? getLevelUpFall(scoreLevel, ratingVOS.get(0).getLevelType())
+                    : 3;
+
+            QwExternalContact externalContact = new QwExternalContact();
+            externalContact.setId(externalId);
+            externalContact.setLevel(scoreLevel);
+            externalContact.setLastWatchTime(latestTime);
+            externalContact.setLevelType(levelUpFall);
+
+            return externalContact;
+
+        } catch (Exception e) {
+            log.error("计算用户积分异常,用户:{}", logsInfo, e);
+            return null;
+        }
+    }
+
+    private void batchUpdateQwExternalContact(List<QwExternalContact> notInExternalUseridList) {
+        int batchSize = 300;
+
+        for (int i = 0; i < notInExternalUseridList.size(); i += batchSize) {
+            int endIndex = Math.min(i + batchSize, notInExternalUseridList.size());
+            List<QwExternalContact> batchList = notInExternalUseridList.subList(i, endIndex);
+
+            int finalI = i;
+            CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
+                try {
+                    qwExternalContactMapper.batchUpdateQwExternalContact(batchList);
+                    iSopUserLogsInfoService.batchUpdateSopUserLogsInfoByLevel(batchList);
+                    log.info("成功更新评级数据,起始索引: {}, 数量: {}", finalI, batchList.size());
+                } catch (Exception e) {
+                    log.error("批量更新异常,批次起始索引: {}", finalI, e);
+                }
+            }, sopRatingExecutor);
+
+            updateFutures.add(future);
+        }
+    }
+
+    @PreDestroy
+    public void shutdown() {
+        running = false;  // 标记消费者停止
+        log.info("正在关闭线程池...");
+
+        // **等待任务队列处理完毕**
+        while (!taskQueue.isEmpty()) {
+            try {
+                Thread.sleep(500);
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                log.warn("等待任务队列处理完成时被中断", e);
+            }
+        }
+
+        // **确保所有 `batchUpdateQwExternalContact` 的任务完成**
+        log.info("等待所有批量更新任务完成...");
+        CompletableFuture.allOf(updateFutures.toArray(new CompletableFuture[0])).join();
+
+        // 关闭线程池
+        sopRatingExecutor.shutdown();
+        try {
+            if (!sopRatingExecutor.awaitTermination(60, TimeUnit.SECONDS)) {
+                List<Runnable> pendingTasks = sopRatingExecutor.shutdownNow();
+                log.warn("强制关闭线程池,未完成任务数: {}", pendingTasks.size());
+            }
+        } catch (InterruptedException e) {
+            sopRatingExecutor.shutdownNow();
+            Thread.currentThread().interrupt();
+        }
+        log.info("线程池和消费者已完全关闭");
+    }
+
+
+    /**
+    * 每6小时更新一次
+    */
+    @Scheduled(cron = "0 50 0/6 * * ?")
+    public void refreshRatingConfig() {
+
+        synchronized(configLock) {
+            try {
+                String json = configService.selectConfigByKey("qwRating:config");
+                QwRatingConfig config = JSON.parseObject(json, QwRatingConfig.class);
+                if (!StringUtil.strIsNullOrEmpty(json) && config != null) {
+                    qwRatingConfig = config;
+                    log.info("LoadedTime qwRating.config successfully.");
+                } else {
+                    log.error("Failed to load course.config from configService.");
+                }
+            } catch (Exception e) {
+                log.error("Exception while refreshing course.config: {}", e.getMessage(), e);
+            }
+        }
+
+    }
+
+
+    //评级
+    public int getScoreLevel(List<QwRatingVO> qwRatingVOS, QwRatingConfig config) {
+
+        AtomicDouble watchCount= new AtomicDouble();
+
+        qwRatingVOS.forEach(vo -> {
+            // 计算 watchDuration 除以 allDuration,并保留2位小数,四舍五入
+            BigDecimal watchDuration = new BigDecimal(vo.getWatchDuration());
+            BigDecimal allDuration = new BigDecimal(vo.getAllDuration());
+
+            BigDecimal ratio = watchDuration.divide(allDuration, 3, RoundingMode.DOWN);
+
+            // 将结果四舍五入后加到 watchCount
+            watchCount.addAndGet(ratio.doubleValue());
+        });
+
+
+        // 计算 watchCount 除以 allSize 的结果并四舍五入为整数
+        BigDecimal result = new BigDecimal(watchCount.get())
+                .divide(new BigDecimal(qwRatingVOS.size()), 3, RoundingMode.DOWN);
+
+        // 将结果乘以 100
+        BigDecimal resultMultiplied = result.multiply(new BigDecimal(100));
+
+        // 四舍五入到整数
+        BigDecimal roundedResult = resultMultiplied.setScale(0, RoundingMode.HALF_UP);
+
+        // 转换为 int 类型
+        int score = roundedResult.intValue();
+
+        if (score >= config.getALevelMin()) {
+            return 1; // A 等级
+        } else if (score >= config.getBLevelMin() && score < config.getBLevelMax()) {
+            return 2; // B 等级
+        } else if (score >= config.getCLevelMin() && score < config.getCLevelMax()) {
+            return 3; // C 等级
+        } else if (score >= config.getDLevelMin() && score < config.getDLevelMax()) {
+            return 4; // D 等级
+        } else {
+            throw new IllegalArgumentException("分数不在任何等级范围内: " + score);
+        }
+    }
+    //升降等级
+    public int getLevelUpFall(int scoreLevel,int levelUpFall){
+
+        if (scoreLevel > levelUpFall) {
+            return  1;//升级
+        }else if (scoreLevel < levelUpFall) {
+            return  2;//降级
+        }else {
+            return  3;//不变
+        }
+    }
+
+    //计算最晚看课时间
+    public int getLatestTime(List<QwRatingVO> qwRatingVOS){
+
+        // 定义日期时间格式
+        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+        DateTimeFormatter outputFormatter = DateTimeFormatter.ofPattern("HHmm");
+
+        // 用于存储提取出的时分
+        List<String> timeOnlyList = new ArrayList<>();
+
+        qwRatingVOS.forEach(vos->{
+
+            String finishTime = vos.getFinishTime();
+
+            if (!StringUtil.strIsNullOrEmpty(finishTime)){
+                LocalTime localTime = LocalTime.parse(finishTime, formatter);
+                String formattedTime = localTime.format(outputFormatter);
+                timeOnlyList.add(formattedTime);
+            }
+
+        });
+        String latestTime=null;
+        if (!timeOnlyList.isEmpty()){
+            latestTime  = Collections.max(timeOnlyList);
+        }else {
+            latestTime = "0";
+        }
+
+
+        return Integer.parseInt(latestTime);
+    }
+
+
+}

+ 212 - 0
fs-wx-task/src/main/java/com/fs/app/taskService/impl/SopLogsChatTaskServiceImpl.java

@@ -0,0 +1,212 @@
+package com.fs.app.taskService.impl;
+
+import cn.hutool.json.JSONUtil;
+import com.alibaba.fastjson.JSON;
+import com.fs.app.taskService.SopLogsChatTaskService;
+import com.fs.fastGpt.param.SendHookAIParam;
+import com.fs.qw.domain.QwUser;
+import com.fs.qw.mapper.QwUserMapper;
+import com.fs.qw.service.IQwExternalContactInfoService;
+import com.fs.qw.vo.QwChatSopTempSetting;
+import com.fs.qw.vo.QwSopRuleTimeVO;
+import com.fs.qwHookApi.param.QwHookSendMsgParam;
+import com.fs.sop.domain.QwSop;
+import com.fs.sop.domain.QwSopTempRules;
+import com.fs.sop.domain.SopUserLogsInfo;
+import com.fs.sop.mapper.QwSopLogsMapper;
+import com.fs.sop.mapper.QwSopMapper;
+import com.fs.sop.mapper.SopUserLogsInfoMapper;
+import com.fs.sop.service.IQwSopLogsService;
+import com.fs.sop.service.IQwSopTempRulesService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.retry.annotation.Backoff;
+import org.springframework.retry.annotation.Retryable;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.stream.Collectors;
+
+@Service
+@Slf4j
+public class SopLogsChatTaskServiceImpl implements SopLogsChatTaskService {
+
+
+    @Autowired
+    private QwSopMapper sopMapper;
+
+    @Autowired
+    private IQwSopLogsService qwSopLogsService;
+
+    @Autowired
+    private IQwSopTempRulesService qwSopTempRulesService;
+
+    @Autowired
+    private QwSopLogsMapper qwSopLogsMapper;
+
+    @Autowired
+    private IQwExternalContactInfoService qwExternalContactInfoService;
+    @Autowired
+    private SopUserLogsInfoMapper sopUserLogsInfoMapper;
+    @Autowired
+    private QwUserMapper qwUserMapper;
+    @Autowired
+    RedisTemplate<String, String> redisTemplate;
+
+    /**
+     * 查询所有的AIsop任务
+     * @throws Exception
+     */
+    @Override
+    public void createAiChatSopLogs(LocalDateTime today) throws Exception {
+        long startTimeMillis = System.currentTimeMillis();
+        List<QwSop> sopByChats = sopMapper.selectChatQwSopList();
+
+        CountDownLatch sopLatch = new CountDownLatch(sopByChats.size());
+        if (sopByChats.isEmpty()) {
+            log.info("没有需要处理的 Ai对话SOP 任务。");
+            return;
+        }
+        for (QwSop sop : sopByChats) {
+            processAiChatSopAsync(sop, sopLatch,today);
+        }
+
+        // 等待所有 SOP 分组处理完成
+        sopLatch.await();
+
+        long endTimeMillis = System.currentTimeMillis();
+        log.info("====== Ai对话SOP 日志处理完成,耗时 {} 毫秒 ======", (endTimeMillis - startTimeMillis));
+    }
+
+    @Async("sopChatTaskExecutor")
+    @Retryable(
+            value = { Exception.class },
+            maxAttempts = 3,
+            backoff = @Backoff(delay = 2000)
+    )
+    public void processAiChatSopAsync(QwSop sop, CountDownLatch latch,LocalDateTime today) {
+        try {
+            processAiChatSop(sop,today);
+        } catch (Exception e) {
+            log.error("处理 SOP ID {} 时发生异常: {}", sop.getId(), e.getMessage(), e);
+        } finally {
+            latch.countDown();
+        }
+    }
+
+    /**
+     * 查询任务中对应模板
+     * @throws Exception
+     */
+    private void processAiChatSop(QwSop sop,LocalDateTime today) throws Exception {
+        QwSopRuleTimeVO ruleTimeVO = sopMapper.selectQwSopByClickHouseId(sop.getId());
+        List<QwSopTempRules> rulesList = qwSopTempRulesService.listByTempId(ruleTimeVO.getTempId());
+        if (rulesList.isEmpty()) {
+            log.warn("SOP ID {} 的 TempSetting 为空,跳过处理。", sop.getId());
+            return;
+        }
+        String[] userIdArray  = sop.getQwUserIds().split(",");
+
+        List<String> qwIds = Arrays.asList(userIdArray);
+
+        List<QwUser> qwUsers = qwUserMapper.selectQwUserByUserIds(qwIds);
+
+        CountDownLatch userLogsLatch = new CountDownLatch(qwUsers.size());
+        for (QwUser qwUser : qwUsers) {
+            processAiChatUserLogInfoAsync(qwUser, ruleTimeVO, rulesList, userLogsLatch,sop,today);
+        }
+
+        // 等待所有用户日志处理完成
+        try {
+            userLogsLatch.await();
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            log.error("等待用户日志处理完成时被中断: {}", e.getMessage(), e);
+        }
+        log.info("SOP ID {} 的所有用户日志已处理完毕。", sop.getId());
+    }
+
+
+    /**
+     * 处理chatSop消息
+     */
+    @Async("sopChatTaskExecutor")
+    @Retryable(
+            value = { Exception.class },
+            maxAttempts = 3,
+            backoff = @Backoff(delay = 2000)
+    )
+    public void processAiChatUserLogInfoAsync(QwUser qwUser, QwSopRuleTimeVO ruleTimeVO, List<QwSopTempRules> tempSettings, CountDownLatch latch,QwSop sop,LocalDateTime today) {
+        try {
+            processAiChatUserInfoLog(qwUser, tempSettings,today);
+        } catch (Exception e) {
+            log.error("处理用户日志 {} 时发生异常: {}", qwUser.getId(), e.getMessage(), e);
+        } finally {
+            latch.countDown();
+        }
+    }
+
+    private List<QwChatSopTempSetting.Content> getDay(List<QwSopTempRules> tempSettings, long days){
+        List<QwSopTempRules> collect = tempSettings.stream().filter(e -> e.getDayNum() == days).collect(Collectors.toList());
+        return collect.stream().map(e -> {
+            QwChatSopTempSetting.Content content = new QwChatSopTempSetting.Content();
+            content.setType(e.getType());
+            content.setContentType(e.getContentType() != null ? e.getContentType().toString() : null);
+            content.setSetting(e.getSettingList().stream().map(s -> JSON.parseObject(s.getContent(), QwChatSopTempSetting.Content.Setting.class)).collect(Collectors.toList()));
+            content.setTime(e.getTime());
+            return content;
+        }).collect(Collectors.toList());
+    }
+
+    private void processAiChatUserInfoLog(QwUser qwUser, List<QwSopTempRules> tempSettings,LocalDateTime today){
+        List<SopUserLogsInfo> sopUserLogsInfos = sopUserLogsInfoMapper.selectChatSopUserLogsByQwUserId(qwUser.getQwUserId(), qwUser.getCorpId());
+        for (SopUserLogsInfo sopUserLogsInfo : sopUserLogsInfos) {
+            String crtTime = sopUserLogsInfo.getCrtTime();
+            DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+            LocalDateTime createTime = LocalDateTime.parse(crtTime, formatter);
+
+            int size = tempSettings.size();
+            if (size>0){
+                    //第一天的
+                    List<QwChatSopTempSetting.Content> content = getDay(tempSettings, 1);
+                    for (QwChatSopTempSetting.Content settCont : content) {
+                        List<QwChatSopTempSetting.Content.Setting> setting = settCont.getSetting();
+                        for (QwChatSopTempSetting.Content.Setting s1 : setting) {
+                            LocalDateTime plusTime = createTime.plusMinutes(s1.getIntervalTime());
+                            boolean sameMinute = today.withSecond(0).withNano(0).isEqual(plusTime.withSecond(0).withNano(0));
+                            if(sameMinute) {
+//                            log.info("发送新客消息内容:"+s1.getValue());
+                            if (s1.getTalkType()!=null&& !s1.getTalkType().isEmpty()){
+                                qwExternalContactInfoService.updateQwExternalContactInfoBytalk(s1.getTalkType(),sopUserLogsInfo.getExternalId());
+                            }
+                            QwHookSendMsgParam sendMsgParam=new QwHookSendMsgParam();
+                            QwHookSendMsgParam.QwHookSendMsgData sendMsgData=new QwHookSendMsgParam.QwHookSendMsgData();
+                            sendMsgParam.setType(101003);
+                            sendMsgData.setMsg(s1.getValue());
+                            sendMsgData.setOpenId(sopUserLogsInfo.getExternalContactId());
+                            sendMsgData.setSyncKey("1");
+                            sendMsgParam.setData(sendMsgData);
+                            SendHookAIParam sendAIParam = new SendHookAIParam();
+                            sendAIParam.setCmd("aiReplyMsg");
+                            sendAIParam.setData(JSONUtil.toJsonStr(sendMsgParam));
+                            sendAIParam.setKey(qwUser.getAppKey());
+                            redisTemplate.opsForList().leftPush("AiMsg:"+qwUser.getAppKey(), JSON.toJSONString(sendAIParam));
+                            }
+                        }
+                    }
+
+
+            }
+
+
+        }
+
+    }
+
+}

+ 2336 - 0
fs-wx-task/src/main/java/com/fs/app/taskService/impl/SopLogsTaskServiceImpl.java

@@ -0,0 +1,2336 @@
+package com.fs.app.taskService.impl;
+
+import cn.hutool.core.util.ObjectUtil;
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.fs.app.taskService.SopLogsTaskService;
+import com.fs.common.config.FSSysConfig;
+import com.fs.common.utils.PubFun;
+import com.fs.common.utils.StringUtils;
+import com.fs.company.domain.Company;
+import com.fs.company.domain.CompanyMiniapp;
+import com.fs.company.domain.CompanyUser;
+import com.fs.company.mapper.CompanyMapper;
+import com.fs.company.service.ICompanyMiniappService;
+import com.fs.company.service.ICompanyUserService;
+import com.fs.config.cloud.CloudHostProper;
+import com.fs.course.config.CourseConfig;
+import com.fs.course.domain.*;
+import com.fs.course.mapper.*;
+import com.fs.course.service.IFsCourseLinkService;
+import com.fs.course.service.IFsUserCompanyBindService;
+import com.fs.qw.domain.*;
+import com.fs.qw.mapper.QwExternalContactMapper;
+import com.fs.qw.mapper.QwUserMapper;
+import com.fs.qw.service.IQwCompanyService;
+import com.fs.qw.service.IQwGroupChatService;
+import com.fs.qw.service.IQwGroupChatUserService;
+import com.fs.qw.service.impl.QwExternalContactServiceImpl;
+import com.fs.qw.vo.GroupUserExternalVo;
+import com.fs.qw.vo.QwSopCourseFinishTempSetting;
+import com.fs.qw.vo.QwSopRuleTimeVO;
+import com.fs.qw.vo.QwSopTempSetting;
+import com.fs.sop.domain.*;
+import com.fs.sop.mapper.*;
+import com.fs.sop.service.IQwSopLogsService;
+import com.fs.sop.service.IQwSopTempContentService;
+import com.fs.sop.service.IQwSopTempRulesService;
+import com.fs.sop.service.IQwSopTempVoiceService;
+import com.fs.sop.vo.QwCreateLinkByAppVO;
+import com.fs.sop.vo.SopUserLogsVo;
+import com.fs.system.service.ISysConfigService;
+import com.fs.voice.utils.StringUtil;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.BeanUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.retry.annotation.Backoff;
+import org.springframework.retry.annotation.Retryable;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.PostConstruct;
+import javax.annotation.PreDestroy;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.ChronoUnit;
+import java.util.*;
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+
+import static com.fs.course.utils.LinkUtil.generateRandomStringWithLock;
+
+@Service
+@Slf4j
+public class SopLogsTaskServiceImpl implements SopLogsTaskService {
+
+
+    private static final String REAL_LINK_PREFIX = "/courseH5/pages/course/learning?course=";
+    private static final String SHORT_LINK_PREFIX = "/courseH5/pages/course/learning?s=";
+    private static final String miniappRealLink = "/pages_course/video.html?course=";
+    private static final String appRealLink = "/pages/courseAnswer/index?link=";
+    private static final String appLink = "https://jump.ylrztop.com/jumpapp/pages/index/index?link=";
+
+//    private static final String miniappRealLink = "/pages/index/index?course=";
+
+    private static final String QWSOP_KEY_PREFIX = "qwsop:";
+    private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+    private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+    private static final DateTimeFormatter OUTPUT_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd 07:00:00");
+
+
+    // Cached configurations and domain names
+    private CourseConfig cachedCourseConfig;
+    private final Object configLock = new Object();
+
+    private List<FsCourseDomainName> cachedDomainNames;
+    private final Object domainLock = new Object();
+
+
+    // Batch size for database inserts, configurable via application.properties
+    private final int BATCH_SIZE = 500;
+
+    @Autowired
+    private IFsCourseLinkService courseLinkService;
+    @Autowired
+    private SopUserLogsMapper sopUserLogsMapper;
+    @Autowired
+    private QwSopTagMapper qwSopTagMapper ;
+    @Autowired
+    private QwSopMapper sopMapper;
+
+
+    @Autowired
+    private QwExternalContactServiceImpl qwExternalContactService;
+
+    @Autowired
+    private FsCourseWatchLogMapper fsCourseWatchLogMapper;
+
+    @Autowired
+    private IQwSopLogsService qwSopLogsService;
+
+    @Autowired
+    private QwSopLogsMapper qwSopLogsMapper;
+
+    @Autowired
+    private FsCourseLinkMapper fsCourseLinkMapper;
+
+    @Autowired
+    private FsCourseSopAppLinkMapper fsCourseSopAppLinkMapper;
+
+    @Autowired
+    private ISysConfigService configService;
+
+    @Autowired
+    private FsCourseDomainNameMapper fsCourseDomainNameMapper;
+
+    @Autowired
+    private SopUserLogsInfoMapper sopUserLogsInfoMapper;
+    @Autowired
+    private QwUserMapper qwUserMapper;
+    @Autowired
+    private IQwSopTempRulesService qwSopTempRulesService;
+    @Autowired
+    private IQwSopTempContentService qwSopTempContentService;
+    @Autowired
+    private IQwSopTempVoiceService qwSopTempVoiceService;
+    @Autowired
+    private CloudHostProper cloudHostProper;
+
+    // Blocking queues with bounded capacity to implement backpressure
+    private final BlockingQueue<QwSopLogs> qwSopLogsQueue = new LinkedBlockingQueue<>(20000);
+    private final BlockingQueue<FsCourseWatchLog> watchLogsQueue = new LinkedBlockingQueue<>(20000);
+    private final BlockingQueue<FsCourseLink> linkQueue = new LinkedBlockingQueue<>(20000);
+    private final BlockingQueue<FsCourseSopAppLink> sopAppLinks = new LinkedBlockingQueue<>(20000);
+
+    // Executors for consumer threads
+    private ExecutorService qwSopLogsExecutor;
+    private ExecutorService watchLogsExecutor;
+    private ExecutorService courseLinkExecutor;
+    private ExecutorService courseSopAppLinkExecutor;
+    @Autowired
+    private IQwGroupChatService qwGroupChatService;
+    @Autowired
+    private IQwGroupChatUserService qwGroupChatUserService;
+    @Autowired
+    private ICompanyMiniappService companyMiniappService;
+    // Shutdown flags
+    private volatile boolean running = true;
+    @Autowired
+    private QwSopTempMapper qwSopTempMapper;
+
+    @Autowired
+    private ICompanyUserService companyUserService;
+
+    @Autowired
+    private IQwCompanyService iQwCompanyService;
+
+    @Autowired
+    private CompanyMapper companyMapper;
+
+    @Autowired
+    private AsyncCourseWatchFinishService asyncCourseWatchFinishService;
+
+    @Autowired
+    private IFsUserCompanyBindService fsUserCompanyBindService;
+
+
+    @Autowired
+    private IQwSopTempVoiceService sopTempVoiceService;
+
+    @PostConstruct
+    public void init() {
+        loadCourseConfig();
+        startConsumers();
+    }
+
+    private void loadCourseConfig() {
+        try {
+            String json = configService.selectConfigByKey("course.config");
+            CourseConfig config = JSON.parseObject(json, CourseConfig.class);
+            if (config != null) {
+                cachedCourseConfig = config;
+                log.info("Loaded course.config successfully.");
+            } else {
+                log.error("Failed to load course.config from configService.");
+            }
+        } catch (Exception e) {
+            log.error("Exception while loading course.config: {}", e.getMessage(), e);
+        }
+    }
+
+
+
+    private void startConsumers() {
+        qwSopLogsExecutor = Executors.newSingleThreadExecutor(r -> {
+            Thread t = new Thread(r, "QwSopLogsConsumer");
+            t.setDaemon(true);
+            return t;
+        });
+        watchLogsExecutor = Executors.newSingleThreadExecutor(r -> {
+            Thread t = new Thread(r, "WatchLogsConsumer");
+            t.setDaemon(true);
+            return t;
+        });
+        courseLinkExecutor = Executors.newSingleThreadExecutor(r -> {
+            Thread t = new Thread(r, "courseLinkConsumer");
+            t.setDaemon(true);
+            return t;
+        });
+
+        courseSopAppLinkExecutor = Executors.newSingleThreadExecutor(r -> {
+            Thread t = new Thread(r, "courseSopAppLinkConsumer");
+            t.setDaemon(true);
+            return t;
+        });
+
+
+        qwSopLogsExecutor.submit(this::consumeQwSopLogs);
+        watchLogsExecutor.submit(this::consumeWatchLogs);
+        courseLinkExecutor.submit(this::consumeCourseLink);
+        courseSopAppLinkExecutor.submit(this::consumeCourseSopAppLink);
+    }
+
+    // Scheduled tasks to refresh configurations and domain names periodically
+    @Scheduled(fixedDelay = 60000) // 每60秒刷新一次
+    public void refreshCourseConfig() {
+        synchronized(configLock) {
+            try {
+                String json = configService.selectConfigByKey("course.config");
+                CourseConfig newConfig = JSON.parseObject(json, CourseConfig.class);
+                if (newConfig != null) {
+                    cachedCourseConfig = newConfig;
+                    log.info("Refreshed course.config.");
+                } else {
+                    log.error("Failed to refresh course.config.");
+                }
+            } catch (Exception e) {
+                log.error("Exception while refreshing course.config: {}", e.getMessage(), e);
+            }
+        }
+    }
+
+
+
+    @PreDestroy
+    public void shutdownConsumers() {
+        running = false;
+        qwSopLogsExecutor.shutdown();
+        watchLogsExecutor.shutdown();
+        courseLinkExecutor.shutdown();
+        courseSopAppLinkExecutor.shutdown();
+        try {
+            if (!qwSopLogsExecutor.awaitTermination(60, TimeUnit.SECONDS)) {
+                qwSopLogsExecutor.shutdownNow();
+            }
+            if (!watchLogsExecutor.awaitTermination(60, TimeUnit.SECONDS)) {
+                watchLogsExecutor.shutdownNow();
+            }
+            if (!courseLinkExecutor.awaitTermination(60, TimeUnit.SECONDS)) {
+                courseLinkExecutor.shutdownNow();
+            }
+            if (!courseSopAppLinkExecutor.awaitTermination(60, TimeUnit.SECONDS)) {
+                courseSopAppLinkExecutor.shutdownNow();
+            }
+        } catch (InterruptedException e) {
+            qwSopLogsExecutor.shutdownNow();
+            watchLogsExecutor.shutdownNow();
+            courseLinkExecutor.shutdownNow();
+            courseSopAppLinkExecutor.shutdownNow();
+            Thread.currentThread().interrupt();
+        }
+    }
+
+    @Override
+    public void selectSopUserLogsListByTime(LocalDateTime currentTime, List<String> sopidList) throws Exception {
+        long startTimeMillis = System.currentTimeMillis();
+        log.info("====== 开始选择和处理 SOP 用户日志 ======");
+
+        // 获取缓存的配置
+        CourseConfig config;
+        synchronized(configLock) {
+            config = cachedCourseConfig;
+        }
+
+        List<SopUserLogsVo> sopUserLogsVos = sopUserLogsMapper.selectSopUserLogsListByTime(sopidList);
+        if (sopUserLogsVos.isEmpty()) {
+            log.info("没有需要处理的 SOP 用户日志。");
+            return;
+        }
+        sopUserLogsVos = sopUserLogsVos.stream().filter(e -> e.getFilterMode() == 1 || (e.getFilterMode() == 2 && StringUtils.isNotEmpty(e.getChatId()))).collect(Collectors.toList());
+
+        String[] array = sopUserLogsVos.stream().map(SopUserLogsVo::getChatId).filter(StringUtils::isNotEmpty).toArray(String[]::new);
+        Map<String, QwGroupChat> groupChatMap = new HashMap<>();
+        if (array.length > 0) {
+            List<QwGroupChat> qwGroupChatList = qwGroupChatService.selectQwGroupChatByChatIds(array);
+            List<QwGroupChatUser> qwGroupChatUserList = qwGroupChatUserService.selectQwGroupChatUserByChatIds(array);
+            List<String> groupChatUserIds = PubFun.listToNewList(qwGroupChatUserList, QwGroupChatUser::getUserId);
+            if(!groupChatUserIds.isEmpty()){
+                List<GroupUserExternalVo> userList = qwExternalContactMapper.selectByGroupUser(groupChatUserIds);
+                Map<String, List<GroupUserExternalVo>> userMap = PubFun.listToMapByGroupList(userList, GroupUserExternalVo::getExternalUserId);
+                qwGroupChatUserList.forEach(e -> {
+                    e.setUserList(userMap.getOrDefault(e.getUserId(), Collections.emptyList()));
+                });
+            }
+            Map<String, List<QwGroupChatUser>> chatUserMap = PubFun.listToMapByGroupList(qwGroupChatUserList, QwGroupChatUser::getChatId);
+            qwGroupChatList.stream().filter(e -> chatUserMap.containsKey(e.getChatId())).forEach(e -> e.setChatUserList(chatUserMap.get(e.getChatId())));
+            groupChatMap = PubFun.listToMapByGroupObject(qwGroupChatList, QwGroupChat::getChatId);
+        }
+
+        Map<String, List<SopUserLogsVo>> sopLogsGroupedById = sopUserLogsVos.stream()
+                .collect(Collectors.groupingBy(SopUserLogsVo::getSopId));
+
+        // 查询公司关联小程序数据
+        List<CompanyMiniapp> miniList = companyMiniappService.list(new QueryWrapper<CompanyMiniapp>().orderByAsc("sort_num"));
+
+        Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap = miniList.stream().collect(Collectors.groupingBy(CompanyMiniapp::getCompanyId, Collectors.groupingBy(CompanyMiniapp::getType)));
+
+
+        List<Company> companies = companyMapper.selectCompanyAllList();
+
+        log.info("共分组 {} 个 SOP ID 进行处理。", sopLogsGroupedById.size());
+
+        CountDownLatch sopGroupLatch = new CountDownLatch(sopLogsGroupedById.size());
+
+        for (Map.Entry<String, List<SopUserLogsVo>> entry : sopLogsGroupedById.entrySet()) {
+            String sopId = entry.getKey();
+            List<SopUserLogsVo> userLogsVos = entry.getValue();
+            processSopGroupAsync(sopId, userLogsVos, sopGroupLatch,currentTime, groupChatMap,config,miniMap,companies);
+        }
+
+        // 等待所有 SOP 分组处理完成
+        sopGroupLatch.await();
+
+        // 触发批量插入(可选,如果需要立即插入队列中的数据)
+        // batchInsertQwSopLogs();
+        // batchInsertFsCourseWatchLogs();
+
+        long endTimeMillis = System.currentTimeMillis();
+        log.info("====== SOP 用户日志处理完成,耗时 {} 毫秒 ======", (endTimeMillis - startTimeMillis));
+    }
+
+    @Async("sopTaskExecutor")
+    @Retryable(
+            value = { Exception.class },
+            maxAttempts = 3,
+            backoff = @Backoff(delay = 2000)
+    )
+    public void processSopGroupAsync(String sopId, List<SopUserLogsVo> userLogsVos, CountDownLatch latch ,LocalDateTime currentTime,
+                                     Map<String, QwGroupChat> groupChatMap,CourseConfig config,Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap,
+                                     List<Company> companies) {
+        try {
+            processSopGroup(sopId, userLogsVos,currentTime, groupChatMap, config,miniMap,companies);
+        } catch (Exception e) {
+            log.error("处理 SOP ID {} 时发生异常: {}", sopId, e.getMessage(), e);
+        } finally {
+            latch.countDown();
+        }
+    }
+
+
+    private void processSopGroup(String sopId, List<SopUserLogsVo> userLogsVos,LocalDateTime currentTime, Map<String,
+                                         QwGroupChat> groupChatMap,CourseConfig config,Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap,
+                                 List<Company> companies) throws Exception {
+        QwSopRuleTimeVO ruleTimeVO = sopMapper.selectQwSopByClickHouseId(sopId);
+
+        if (ruleTimeVO == null) {
+//            sopUserLogsMapper.deleteSopUserLogsBySopId(sopId);
+            log.error("SOP ID {} 已删除或不存在,相关日志已清除。", sopId);
+            return;
+        }
+        QwSopTemp qwSopTemp = qwSopTempMapper.selectQwSopTempById(ruleTimeVO.getTempId());
+        if (qwSopTemp == null) {
+//            sopUserLogsMapper.deleteSopUserLogsBySopId(sopId);
+            log.error("SOP ID {} 模板不存在,相关日志已清除。", sopId);
+            return;
+        }
+
+        ruleTimeVO.setTempStatus(qwSopTemp.getStatus());
+        ruleTimeVO.setTempGap(qwSopTemp.getGap());
+
+        if (ruleTimeVO.getStatus() == 0 || "0".equals(ruleTimeVO.getTempStatus())) {
+//            SopUserLogs sopUserLogs = new SopUserLogs();
+//            sopUserLogs.setSopId(sopId);
+//            sopUserLogs.setStatus(2);
+//            sopUserLogsMapper.updateSopUserLogsByStatus(sopUserLogs);
+            log.error("SOP ID {} 的状态为停用,相关日志状态已更新。", sopId);
+            return;
+        }
+
+        List<QwSopTempRules> rulesList = qwSopTempRulesService.listByTempId(ruleTimeVO.getTempId());
+        if (rulesList.isEmpty()) {
+            log.error("SOP ID {} 的 TempSetting 为空,跳过处理。", sopId);
+            return;
+        }
+
+        QwCompany qwCompany = iQwCompanyService.getQwCompanyByRedis(ruleTimeVO.getCorpId());
+
+        if (qwCompany == null ) {
+            log.error("SOP ID {} 的 公司信息为空 为空,跳过处理。", sopId);
+            return ;
+        }
+
+        CountDownLatch userLogsLatch = new CountDownLatch(userLogsVos.size());
+        for (SopUserLogsVo logVo : userLogsVos) {
+            processUserLogAsync(logVo, ruleTimeVO, rulesList, userLogsLatch, currentTime, groupChatMap,qwCompany.getMiniAppId(),
+                    config,miniMap,companies);
+        }
+
+        // 等待所有用户日志处理完成
+        try {
+            userLogsLatch.await();
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            log.error("等待用户日志处理完成时被中断: {}", e.getMessage(), e);
+        }
+        log.info("SOP ID {} 的所有用户日志已处理完毕。", sopId);
+    }
+
+    @Async("sopTaskExecutor")
+    @Retryable(
+            value = { Exception.class },
+            maxAttempts = 3,
+            backoff = @Backoff(delay = 2000)
+    )
+    public void processUserLogAsync(SopUserLogsVo logVo, QwSopRuleTimeVO ruleTimeVO, List<QwSopTempRules> tempSettings,
+                                    CountDownLatch latch, LocalDateTime currentTime, Map<String, QwGroupChat> groupChatMap,
+                                    String miniAppId,CourseConfig config,Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap,
+                                    List<Company> companies) {
+        try {
+            processUserLog(logVo, ruleTimeVO, tempSettings,currentTime, groupChatMap, miniAppId, config,miniMap,companies);
+        } catch (Exception e) {
+            log.error("处理用户日志 {} 时发生异常: {}", logVo.getId(), e.getMessage(), e);
+        } finally {
+            latch.countDown();
+        }
+    }
+
+
+    private void processUserLog(SopUserLogsVo logVo, QwSopRuleTimeVO ruleTimeVO, List<QwSopTempRules> tempSettings,
+                                LocalDateTime currentTime, Map<String, QwGroupChat> groupChatMap,String miniAppId,
+                                CourseConfig config,Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap,
+                                List<Company> companies) {
+        try {
+
+            LocalDate startDate = LocalDate.parse(logVo.getStartTime(), DATE_FORMATTER);
+            LocalDate currentDate = currentTime.toLocalDate();
+
+            long daysBetween = ChronoUnit.DAYS.between(startDate, currentDate);
+            int tempGap = ruleTimeVO.getTempGap();
+
+            if (tempGap <= 0) {
+                log.error("SOP ID {} 的 TempGap {} 无效,跳过处理。", logVo.getSopId(), tempGap);
+                return;
+            }
+
+            int intervalDay = (int) (daysBetween / tempGap);
+            if (intervalDay < 0 || intervalDay >= tempSettings.size()) {
+                log.info("用户日志 {} 的 intervalDay {} 超出 TempSettings 范围,跳过处理。", logVo.getId(), intervalDay);
+                return;
+            }
+            long day = daysBetween;
+            if(day == 0 && ruleTimeVO.getIsAutoSop() == 1){
+                day = 1;
+            }else{
+                day++;
+            }
+            List<QwSopTempSetting.Content> contents = getDay(tempSettings, day);
+            if (contents == null || contents.isEmpty()) {
+                log.error("SOP ID {} 的 TempSetting 内容为空,跳过处理。天数 {}", logVo.getSopId(),day);
+                return;
+            }
+
+
+            //获取企业微信员工的称呼//从redis里或者从库里取
+            QwUser qwUserByRedis = qwExternalContactService.getQwUserByRedis(logVo.getCorpId(),logVo.getQwUserId());
+            if (qwUserByRedis==null){
+                log.error("无企微员工信息 {} 跳过处理。:{}", logVo.getUserId(),logVo.getCorpId());
+                return;
+            }
+
+            String qwUserId = String.valueOf(qwUserByRedis.getId()).trim();
+            String companyUserId = String.valueOf(qwUserByRedis.getCompanyUserId()).trim();
+            String companyId = String.valueOf(qwUserByRedis.getCompanyId()).trim();
+            Integer sendMsgType = qwUserByRedis.getSendMsgType();
+
+            if (StringUtil.strIsNullOrEmpty(companyUserId) || StringUtil.strIsNullOrEmpty(companyId) || "null".equals(companyUserId)) {
+                log.error("员工未绑定销售账号或公司,跳过处理:"+qwUserId);
+                return;
+            }
+
+            CompanyUser companyUser = companyUserService.selectCompanyUserByIdForRedis(Long.valueOf(companyUserId));
+            if (Objects.nonNull(companyUser)) {
+                if (!StringUtil.strIsNullOrEmpty(companyUser.getDomain())) {
+                    logVo.setDomain(companyUser.getDomain().trim());
+                } else {
+                    logVo.setDomain(config.getRealLinkDomainName().trim());
+                }
+            } else {
+                logVo.setDomain(config.getRealLinkDomainName().trim());
+            }
+
+            //寻找时间
+//            LocalDateTime currentTime = LocalDateTime.of(2024, 12, 25,23 , 40);
+
+            // 先算好 60分钟后 ~ 再60分钟后的时间段
+            LocalDateTime startRangeFirst = currentTime.plusMinutes(60);
+
+            // 如果发现已经跨天
+            if (!startRangeFirst.toLocalDate().equals(currentDate)) {
+                // 更新 currentDate
+                currentDate = startRangeFirst.toLocalDate();
+
+                // 重新计算 daysBetween
+                daysBetween = ChronoUnit.DAYS.between(startDate, currentDate);
+                intervalDay = (int) (daysBetween / tempGap);
+                day = daysBetween;
+                if(day == 0 && ruleTimeVO.getIsAutoSop() == 1){
+                    day = 1;
+                }else{
+                    day++;
+                }
+//
+//                // 再次验证 intervalDay 是否在范围内
+//                if (intervalDay < 0 || intervalDay >= tempSettings.size()) {
+//                    log.info("跨天后,intervalDay={} 超出 TempSettings 范围,跳过。", intervalDay);
+//                    return;
+//                }
+//
+//                if (daysBetween % tempGap != 0) {
+//                    log.error("天数差 {} 不是 tempGap {} 的整数倍,跳过操作,SopId {} ", daysBetween, tempGap,logVo.getSopId());
+//                    return;
+//                }
+
+                // 重新拿新的 “天” 的 Setting
+                contents = getDay(tempSettings, day);
+                if (contents == null || contents.isEmpty()) {
+                    log.error("跨天-SOP ID {} 的 TempSetting 内容为空,跳过处理。", logVo.getSopId());
+                    return;
+                }
+            }
+
+
+            // 只有整倍数才做事
+            if (daysBetween % tempGap != 0) {
+                log.error("天数差 {} 不是 tempGap {} 的整数倍,跳过操作,SopId {} ", daysBetween, tempGap,logVo.getSopId());
+                return;
+            }
+
+
+            for (QwSopTempSetting.Content content : contents) {
+                try {
+
+                    LocalTime elementLocalTime = LocalTime.parse(content.getTime());
+                    LocalDateTime elementDateTime = LocalDateTime.of(currentTime.toLocalDate(), elementLocalTime);
+
+                    // 动态调整 elementDateTime 的日期
+                    if (elementLocalTime.isBefore(currentTime.toLocalTime())) {
+                        elementDateTime = elementDateTime.plusDays(1);
+                    }
+
+                    LocalDateTime startRange = currentTime.plusMinutes(60);
+                    LocalDateTime endRange = startRange.plusMinutes(60);
+
+                    // 跨天逻辑修正:仅当 startRange 的时间晚于 endRange 的时间时调整
+                    if (startRange.toLocalTime().isAfter(endRange.toLocalTime())
+                            && startRange.toLocalDate().equals(endRange.toLocalDate())) {
+                        endRange = endRange.plusDays(1); // 将 endRange 调整为第二天
+                    }
+                    if (!elementDateTime.isBefore(startRange) && !elementDateTime.isAfter(endRange.minusMinutes(1))) {
+
+                        // 如果时间差在目标范围内,更新记录
+                        // 组合年月日和element的时间
+                        LocalDate targetDate = startDate.plusDays(intervalDay * tempGap);
+
+                        // 将 targetDate 和 elementTime 组合成 LocalDateTime
+                        LocalDateTime dateTime = LocalDateTime.of(targetDate, elementLocalTime);
+
+                        // 将 LocalDateTime 转换为 Date
+                        Date sendTime = Date.from(dateTime.atZone(ZoneId.systemDefault()).toInstant());
+
+                        SopUserLogsInfo userLogsInfo=new SopUserLogsInfo();
+                        userLogsInfo.setSopId(logVo.getSopId());
+                        userLogsInfo.setUserLogsId(logVo.getId());
+
+                        List<SopUserLogsInfo> sopUserLogsInfos = sopUserLogsInfoMapper.selectSopUserLogsInfoList(userLogsInfo);
+                        if (logVo.getIsRegister() == 1) {
+                            List<Long> externalContactIdList = PubFun.listToNewList(sopUserLogsInfos, SopUserLogsInfo::getExternalId);
+                            if (!externalContactIdList.isEmpty()) {
+                                List<QwExternalContact> list = qwExternalContactService.list(new QueryWrapper<QwExternalContact>().isNotNull("fs_user_id").in("id", externalContactIdList));
+                                Map<Long, QwExternalContact> map = PubFun.listToMapByGroupObject(list, QwExternalContact::getId);
+                                sopUserLogsInfos = sopUserLogsInfos.stream().filter(e -> map.containsKey(e.getExternalId())).collect(Collectors.toList());
+                            }
+                        }
+
+                        // 获取fsUserId TODO
+//                        Set<Long> externalIds = sopUserLogsInfos.stream().map(SopUserLogsInfo::getExternalId).collect(Collectors.toSet());
+//                        if (!externalIds.isEmpty()) {
+//                            List<QwExternalContact> externalContactList = qwExternalContactService.list(Wrappers.<QwExternalContact>lambdaQuery().in(QwExternalContact::getId, externalIds));
+//                            sopUserLogsInfos.forEach(s -> {
+//                                QwExternalContact qwExternalContact = externalContactList.stream().filter(e -> Objects.equals(s.getExternalId(), e.getId())).findFirst().orElse(null);
+//                                if (Objects.nonNull(qwExternalContact)) {
+//                                    s.setFsUserId(qwExternalContact.getFsUserId());
+//                                }
+//                            });
+//                        }
+
+
+                        insertSopUserLogs(sopUserLogsInfos, logVo, sendTime, ruleTimeVO, content, qwUserId,
+                                companyUserId, companyId, qwUserByRedis.getWelcomeText(),qwUserByRedis.getQwUserName(),
+                                groupChatMap, miniAppId,config,miniMap, sendMsgType,companies);
+
+                    }
+                } catch (Exception e) {
+                    log.error("解析模板内容 {} 失败: {}", content.getTime(), e.getMessage(), e);
+                }
+            }
+
+        } catch (Exception e) {
+            log.error("解析解析模板 {} 失败: {}", logVo.getStartTime(), e.getMessage(), e);
+        }
+    }
+
+
+    private List<QwSopTempSetting.Content> getDay(List<QwSopTempRules> tempSettings, long days){
+        List<QwSopTempRules> collect = tempSettings.stream().filter(e -> e.getDayNum() == days && e.getTime() != null).collect(Collectors.toList());
+        AtomicInteger i = new AtomicInteger();
+        return collect.stream().map(e -> {
+            QwSopTempSetting.Content content = new QwSopTempSetting.Content();
+            content.setId(e.getId());
+            content.setType(e.getType());
+            content.setContentType(e.getContentType() != null ? e.getContentType().toString() : null);
+            content.setSetting(e.getSettingList().stream().map(s -> {
+                QwSopTempSetting.Content.Setting setting = JSON.parseObject(s.getContent(), QwSopTempSetting.Content.Setting.class);
+                setting.setId(s.getId());
+                return setting;
+            }).collect(Collectors.toList()));
+            content.setAddTag(e.getAddTag());
+            content.setDelTag(e.getDelTag());
+            content.setTime(e.getTime());
+            content.setIsOfficial(e.getIsOfficial());
+            content.setCourseId(e.getCourseId());
+            content.setVideoId(e.getVideoId());
+            content.setCourseType(e.getCourseType());
+            content.setAiTouch(e.getAiTouch());
+            return content;
+        }).sorted(Comparator.comparing(e -> LocalTime.parse(e.getTime() + ":00"))).peek(e -> e.setIndex(i.getAndIncrement())).collect(Collectors.toList());
+    }
+
+    //消息处理
+    private void insertSopUserLogs(List<SopUserLogsInfo> sopUserLogsInfos, SopUserLogsVo logVo, Date sendTime,
+                                   QwSopRuleTimeVO ruleTimeVO, QwSopTempSetting.Content content,
+                                   String qwUserId,String companyUserId,String companyId,String welcomeText,String qwUserName,
+                                   Map<String, QwGroupChat> groupChatMap,String miniAppId,CourseConfig config,
+                                   Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap, Integer sendMsgType,
+                                   List<Company> companies) {
+        String formattedSendTime = sendTime.toInstant()
+                .atZone(ZoneId.systemDefault())
+                .format(DATE_TIME_FORMATTER);
+        int type = content.getType();
+        Long courseId = content.getCourseId();
+        Long videoId = content.getVideoId();
+        Integer isOfficial = content.getIsOfficial() != null ? Integer.valueOf(content.getIsOfficial()) : 0;
+
+
+        // 发送语音 start
+        if(content.getSetting() == null){
+            return;
+        }
+        List<QwSopTempSetting.Content.Setting> setting = content.getSetting().stream().filter(e -> "7".equals(e.getContentType())).collect(Collectors.toList());
+        if (!setting.isEmpty()) {
+            List<String> valuesList = PubFun.listToNewList(setting, QwSopTempSetting.Content.Setting::getValue);
+            if (valuesList != null && !valuesList.isEmpty()) {
+                try {
+                    List<QwSopTempVoice> voiceList = qwSopTempVoiceService.getVoiceByText(Long.parseLong(companyUserId), valuesList);
+                    if (voiceList != null && !voiceList.isEmpty()) {
+                        Map<String, QwSopTempVoice> collect = voiceList.stream().collect(Collectors.toMap(QwSopTempVoice::getVoiceTxt, e -> e));
+                        setting.parallelStream().filter(e -> "7".equals(e.getContentType())).forEach(st -> {
+                            QwSopTempVoice voice = collect.get(st.getValue());
+                            if (voice.getVoiceUrl() == null) {
+                                return;
+                            }
+                            st.setVoiceUrl(voice.getVoiceUrl());
+                            st.setVoiceDuration(voice.getDuration() + "");
+                        });
+                    }
+                } catch (NumberFormatException e) {
+                    throw new RuntimeException(e);
+                }
+            }
+        }
+//        // 发送语音 end
+        if (content.getType()==5){
+            sopAddTag(logVo,content,sendTime);
+        }
+
+        //当语音模板的qw_sop_temp_voice中无对应语音,就不生成qw_sop_logs记录
+        if (content.getType() == 7 && content.getSetting() != null && !content.getSetting().isEmpty()) {
+            if (content.getSetting().get(0).getVoiceUrl() == null) {
+                return;
+            }
+        }
+
+        if (StringUtils.isNotEmpty(logVo.getChatId())) {
+            QwGroupChat groupChat = groupChatMap.get(logVo.getChatId());
+            ruleTimeVO.setSendType(6);
+            ruleTimeVO.setType(2);
+            if (groupChat.getChatUserList() != null && !groupChat.getChatUserList().isEmpty()) {
+                QwSopLogs sopLogs = createBaseLog(formattedSendTime, logVo, ruleTimeVO, groupChat.getChatId(), groupChat.getName(), null, isOfficial, null,null);
+                handleLogBasedOnType(sopLogs, content, logVo, sendTime, courseId, videoId,
+                        type, qwUserId, companyUserId, companyId, groupChat.getChatId(), welcomeText, qwUserName,
+                        null, true, miniAppId, groupChat,config, miniMap, null, sendMsgType,companies);
+            }
+//            if (content.getIndex() == 0) {
+//                QwSopLogs sopLogs = createBaseLog(formattedSendTime, logVo, ruleTimeVO, groupChat.getChatId(), groupChat.getName(), null, isOfficial, null);
+//                handleLogBasedOnType(sopLogs, content, logVo, sendTime, courseId, videoId,
+//                        type, qwUserId, companyUserId, companyId, groupChat.getChatId(), welcomeText, qwUserName,
+//                        null, true, miniAppId, groupChat,config, miniMap, null, sendMsgType,companies);
+//            } else {
+//                if(groupChat.getChatUserList() != null && !groupChat.getChatUserList().isEmpty()){
+//                    groupChat.getChatUserList().forEach(user -> {
+//                        ruleTimeVO.setSendType(2);
+//                        ruleTimeVO.setRemark("客户群催课");
+//                        QwSopLogs sopLogs = createBaseLog(formattedSendTime, logVo, ruleTimeVO, user.getUserId(), user.getName(), null, isOfficial, null);
+//                        handleLogBasedOnType(sopLogs, content, logVo, sendTime, courseId, videoId,
+//                                type, qwUserId, companyUserId, companyId, user.getId().toString(), welcomeText, qwUserName,
+//                                null, false, miniAppId, groupChat,config, miniMap, null, sendMsgType,companies);
+//                    });
+//                }
+//            }
+        } else {
+            // 处理每个 externalContactId
+            sopUserLogsInfos.forEach(contactId -> {
+                try {
+                    String externalId = contactId.getExternalId().toString();
+                    String externalUserName = contactId.getExternalUserName();
+                    Long fsUserId = contactId.getFsUserId();
+                    Integer grade = contactId.getGrade();
+                    QwSopLogs sopLogs = createBaseLog(formattedSendTime, logVo, ruleTimeVO, contactId.getExternalContactId(), externalUserName, fsUserId, isOfficial, contactId.getExternalId(),contactId.getIsDaysNotStudy());
+                    handleLogBasedOnType(sopLogs, content, logVo, sendTime, courseId, videoId,
+                            type, qwUserId, companyUserId, companyId, externalId, welcomeText, qwUserName, fsUserId, false, miniAppId,
+                            null,config, miniMap, grade, sendMsgType,companies);
+                } catch (Exception e) {
+                    log.error("处理 externalContactId {} 时发生异常: {}", contactId, e.getMessage(), e);
+                }
+            });
+        }
+//        // 处理每个 externalContactId
+//        sopUserLogsInfos.forEach(contactId -> {
+//            try {
+//                String externalId = contactId.getExternalId().toString();
+//                String externalUserName = contactId.getExternalUserName();
+//                Long fsUserId = contactId.getFsUserId();
+//                QwSopLogs sopLogs = createBaseLog(formattedSendTime, logVo, ruleTimeVO, contactId.getExternalContactId(), externalUserName, fsUserId,isOfficial,contactId.getExternalId());
+//                handleLogBasedOnType(sopLogs, content, logVo, sendTime, courseId, videoId,
+//                        type, qwUserId, companyUserId, companyId, externalId, welcomeText,qwUserName);
+//            } catch (Exception e) {
+//                log.error("处理 externalContactId {} 时发生异常: {}", contactId, e.getMessage(), e);
+//            }
+//        });
+    }
+
+    private void sopAddTag(SopUserLogsVo logVo, QwSopTempSetting.Content content, Date sendTime) {
+        String id = logVo.getId();
+        String addTag = content.getAddTag();
+        String delTag = content.getDelTag();
+        String corpId = logVo.getCorpId();
+        if (addTag!=null || delTag!=null) {
+            QwSopTag qwSopTag = new QwSopTag();
+            qwSopTag.setAddTags(addTag);
+            qwSopTag.setDelTags(delTag);
+            qwSopTag.setCorpId(corpId);
+            qwSopTag.setSopUserLogsId(id);
+            qwSopTag.setType(1);
+            qwSopTag.setStatus(1);
+            qwSopTag.setSendTime(sendTime);
+            qwSopTag.setCreateTime(new Date());
+            qwSopTagMapper.insertQwSopTag(qwSopTag);
+        }
+    }
+
+    private QwSopLogs createBaseLog(String formattedSendTime, SopUserLogsVo logVo,
+                                    QwSopRuleTimeVO ruleTimeVO, String externalContactId,
+                                    String externalUserName, Long fsUserId,Integer isOfficial,
+                                    Long externalId,Integer isDaysNotStudy) {
+        QwSopLogs sopLogs = new QwSopLogs();
+        sopLogs.setSendTime(formattedSendTime);
+        sopLogs.setQwUserid(logVo.getQwUserId());
+        sopLogs.setCorpId(logVo.getCorpId());
+        sopLogs.setLogType(ruleTimeVO.getType());
+        sopLogs.setTakeRecords(0);
+
+        if (isOfficial != 1 && Integer.valueOf(1).equals(isDaysNotStudy)) {
+            sopLogs.setSendStatus(5L);
+            sopLogs.setRemark("E级客户不发送");
+        }else {
+            sopLogs.setSendStatus(3L);
+        }
+
+        sopLogs.setReceivingStatus(0L);
+
+        if (isOfficial == 1) {
+
+            if (logVo.getIsSampSend()== 1) {
+                if (fsUserId == null || Long.valueOf(0L).equals(fsUserId)) {
+                    sopLogs.setSendType(2);
+                    sopLogs.setRemark("未绑定小程序用户,单链补发");
+                    //时间设置成固定8点
+                    LocalDateTime dateTime = LocalDateTime.parse(formattedSendTime, DATE_TIME_FORMATTER);
+                    sopLogs.setSendTime(OUTPUT_FORMATTER.format(dateTime));
+                } else {
+                    sopLogs.setSendType(1);
+                }
+
+            }else {
+                if (fsUserId == null || Long.valueOf(0L).equals(fsUserId)) {
+                    sopLogs.setTakeRecords(1);
+                    sopLogs.setSendType(1);
+                }else {
+                    sopLogs.setSendType(1);
+                }
+            }
+
+        } else if (isOfficial == 0) {
+            sopLogs.setSendType(ruleTimeVO.getSendType() == 1 ? 2 : ruleTimeVO.getSendType());
+        } else {
+            sopLogs.setSendType(ruleTimeVO.getSendType());
+        }
+
+
+
+
+        String[] userKey = logVo.getUserId().split("\\|");
+        sopLogs.setCompanyId(Long.valueOf(userKey[2].trim()));
+        if (StringUtils.isNotEmpty(userKey[0].trim())){
+            sopLogs.setQwUserKey(Long.valueOf(userKey[0].trim()));
+        }
+        sopLogs.setSopId(logVo.getSopId());
+        sopLogs.setSort(Integer.valueOf(logVo.getStartTime().replaceAll("-","")));
+        sopLogs.setExternalUserId(externalContactId);
+        sopLogs.setExternalId(externalId);
+        sopLogs.setExternalUserName(externalUserName);
+        sopLogs.setFsUserId(fsUserId);
+        sopLogs.setUserLogsId(logVo.getId());
+
+        if (ObjectUtil.isNotEmpty(logVo.getActualQwId())){
+            sopLogs.setQwUserKey(logVo.getActualQwId());
+        }
+        return sopLogs;
+    }
+
+    private void handleLogBasedOnType(QwSopLogs sopLogs, QwSopTempSetting.Content content,
+                                      SopUserLogsVo logVo, Date sendTime, Long courseId, Long videoId, int type, String qwUserId,
+                                      String companyUserId, String companyId, String externalId, String welcomeText,
+                                      String qwUserName, Long fsUserId, boolean isGroupChat, String miniAppId,
+                                      QwGroupChat groupChat,CourseConfig config,
+                                      Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap,
+                                      Integer grade, Integer sendMsgType ,List<Company> companies ) {
+        switch (type) {
+            case 1:
+                handleNormalMessage(sopLogs, content,companyUserId);
+                break;
+            case 2:
+                handleCourseMessage(sopLogs, content, logVo, sendTime, courseId, videoId,
+                        qwUserId, companyUserId, companyId, externalId, welcomeText,qwUserName, fsUserId,
+                        isGroupChat, miniAppId, groupChat,config,miniMap, grade, sendMsgType,companies);
+                break;
+            case 3:
+                handleOrderMessage(sopLogs, content);
+                break;
+            case 4:
+//                handleAIMessage(sopLogs, content);
+                break;
+            case 5:
+//                handleTagMessage(sopLogs, content);
+                break;
+            case 7:
+                handleVoiceMessage(sopLogs, content, companyUserId);
+                break;
+            default:
+                log.error("未知的消息类型 {},跳过处理。", type);
+                break;
+        }
+    }
+    private void handleVoiceMessage(QwSopLogs sopLogs, QwSopTempSetting.Content content, String companyUserId) {
+        sopLogs.setContentJson(JSON.toJSONString(content));
+        enqueueQwSopLogs(sopLogs);
+    }
+
+    private void handleNormalMessage(QwSopLogs sopLogs, QwSopTempSetting.Content content,String companyUserId) {
+
+        sopLogs.setContentJson(JSON.toJSONString(content));
+        enqueueQwSopLogs(sopLogs);
+    }
+
+    private void handleAIMessage(QwSopLogs sopLogs, QwSopTempSetting.Content content) {
+        sopLogs.setContentJson(JSON.toJSONString(content));
+        sopLogs.setSort(3);
+        enqueueQwSopLogs(sopLogs);
+    }
+
+    private void handleCourseMessage(QwSopLogs sopLogs, QwSopTempSetting.Content content,
+                                     SopUserLogsVo logVo, Date sendTime, Long courseId, Long videoId, String qwUserId, String companyUserId,
+                                     String companyId, String externalId, String welcomeText, String qwUserName,
+                                     Long fsUserId, boolean isGroupChat, String miniAppId, QwGroupChat groupChat,CourseConfig config,Map<Long,
+                    Map<Integer, List<CompanyMiniapp>>> miniMap,Integer grade, Integer sendMsgType,
+                                     List<Company> companies) {
+        QwExternalContact contact = null;
+        if(logVo.getExternalId() != null){
+            contact = qwExternalContactMapper.selectById(logVo.getExternalId());
+        }
+        // 深拷贝 Content 对象,避免使用 JSON
+        QwSopTempSetting.Content clonedContent = deepCopyContent(content);
+        if (clonedContent == null) {
+            log.error("Failed to clone content, skipping handleCourseMessage.");
+            return;
+        }
+
+//
+//        Integer courseType = clonedContent.getCourseType();
+
+        String isOfficial = clonedContent.getIsOfficial();
+
+        List<QwSopTempSetting.Content.Setting> settings = clonedContent.getSetting();
+        if (settings == null || settings.isEmpty()) {
+            log.error("Cloned content settings are empty, skipping.");
+            return;
+        }
+
+        // 顺序处理每个 Setting,避免过多的并行导致线程开销
+        for (QwSopTempSetting.Content.Setting setting : settings) {
+            switch (setting.getContentType()) {
+                //文字和短链一起
+                case "1":
+                case "3":
+//                    if ("1".equals(setting.getIsBindUrl())) {
+//                        String link;
+//                        if (isGroupChat) {
+//                            FsCourseLinkCreateParam createParam = new FsCourseLinkCreateParam();
+//                            createParam.setCourseId(courseId);
+//                            createParam.setVideoId(videoId);
+//                            createParam.setCorpId(logVo.getCorpId());
+//                            createParam.setCompanyUserId(Long.parseLong(companyUserId));
+//                            createParam.setCompanyId(Long.parseLong(companyId));
+//                            createParam.setChatId(logVo.getChatId());
+//                            createParam.setQwUserId(Long.valueOf(qwUserId));
+//                            createParam.setDays(setting.getExpiresDays());
+//                            R createLink = courseLinkService.createRoomLinkUrl(createParam);
+//                            if (createLink.get("code").equals(500)) {
+//                                throw new BaseException("链接生成失败!");
+//                            }
+//                            try {
+//                                groupChat.getChatUserList().stream().filter(e -> e.getUserList() != null && !e.getUserList().isEmpty()).forEach(e -> {
+//                                    Map<String, GroupUserExternalVo> userMap = PubFun.listToMapByGroupObject(e.getUserList(), GroupUserExternalVo::getUserId);
+//                                    GroupUserExternalVo vo = userMap.get(groupChat.getOwner());
+//                                    if (vo != null && vo.getId() != null) {
+//                                        sopLogs.setFsUserId(vo.getFsUserId());
+//                                        addWatchLogIfNeeded(sopLogs, videoId, courseId, sendTime, qwUserId, companyUserId, companyId, vo.getId().toString(), logVo);
+//                                    }
+//                                });
+//                            } catch (Exception e) {
+//                                log.error("群聊创建看课记录失败!", e);
+//                            }
+//                            link = (String) createLink.get("url");
+//                        } else {
+//                            addWatchLogIfNeeded(sopLogs, videoId, courseId, sendTime, qwUserId, companyUserId, companyId, externalId, logVo);
+//                            link = generateShortLink(setting, logVo, sendTime, courseId, videoId,
+//                                    qwUserId, companyUserId, companyId, externalId,isOfficial,sopLogs.getFsUserId());
+//                        }
+
+//                        if (StringUtils.isNotEmpty(link)) {
+//                            if ("3".equals(setting.getContentType())) {
+//                                setting.setLinkUrl(link);
+//                            } else {
+//                                String currentValue = setting.getValue();
+//                                if (currentValue == null) {
+//                                    setting.setValue(link);
+//                                } else {
+//                                    setting.setValue(currentValue
+//                                            .replaceAll("#销售称呼#", StringUtil.strIsNullOrEmpty(welcomeText) ? "" : welcomeText)
+//                                            .replaceAll("#客户称呼#", contact == null || StringUtil.strIsNullOrEmpty(contact.getStageStatus())|| "0".equals(contact.getStageStatus())?"同学":contact.getStageStatus())
+//                                            + "\n" + link);
+//                                }
+//                            }
+//                        } else {
+//                            log.error("生成短链失败,跳过设置 URL。");
+//                        }
+
+//                    } else {
+                        if ("1".equals(setting.getContentType())) {
+                            String defaultName = "同学";
+                            if(contact != null && StringUtils.isNotEmpty(contact.getName()) && !"待同步客户".equals(contact.getName())){
+                                defaultName = contact.getName();
+                            }
+                            setting.setValue(setting.getValue()
+                                    .replaceAll("#销售称呼#", StringUtil.strIsNullOrEmpty(welcomeText) ? "" : welcomeText)
+                                    .replaceAll("#客户称呼#", contact == null || StringUtil.strIsNullOrEmpty(contact.getStageStatus())|| "0".equals(contact.getStageStatus())?defaultName:contact.getStageStatus()));
+                        }
+//                    }
+                    break;
+                //小程序单独
+                case "4":
+                    if (isGroupChat) {
+                        try {
+                            groupChat.getChatUserList().stream().filter(e -> e.getUserList() != null && !e.getUserList().isEmpty()).forEach(e -> {
+                                Map<String, GroupUserExternalVo> userMap = PubFun.listToMapByGroupObject(e.getUserList(), GroupUserExternalVo::getUserId);
+                                GroupUserExternalVo vo = userMap.get(groupChat.getOwner());
+                                if (vo != null && vo.getId() != null) {
+                                    sopLogs.setFsUserId(vo.getFsUserId());
+                                    addWatchLogIfNeeded(sopLogs, videoId, courseId, sendTime, qwUserId, companyUserId, companyId, vo.getId().toString(), logVo);
+                                }
+                            });
+                        } catch (Exception e) {
+                            log.error("群聊创建看课记录失败!", e);
+                        }
+                    } else {
+                        addWatchLogIfNeeded(sopLogs, videoId, courseId, sendTime, qwUserId, companyUserId, companyId, externalId,logVo);
+                    }
+
+                    String sortLink = createLinkByMiniApp(setting, logVo, sendTime, courseId, videoId,
+                            qwUserId, companyUserId, companyId, externalId,isOfficial,sopLogs.getFsUserId(), isGroupChat ? groupChat.getChatId() : null);
+
+                    if(sopLogs.getSendType()==1){
+                        setting.setMiniprogramAppid(miniAppId);
+                    }else {
+                        int miniType = getLevel(grade);
+                        //算主备小程序
+                        String finalAppId = getAppIdFromMiniMap(miniMap, companyId, sendMsgType, grade);
+
+                        if (StringUtil.strIsNullOrEmpty(finalAppId)) {
+                            finalAppId = miniAppId;
+                        }
+
+                        setting.setMiniType(miniType);
+                        if (!StringUtil.strIsNullOrEmpty(finalAppId)) {
+                            setting.setMiniprogramAppid(finalAppId);
+                        } else {
+                            log.error("公司的小程序id为空:采用了前端传的固定值" + sopLogs.getSopId());
+                        }
+
+                    }
+
+                    setting.setMiniprogramPage(sortLink.replaceAll("^[\\s\\u2005]+", ""));
+
+                    try {
+                        setting.setMiniprogramPicUrl(StringUtil.strIsNullOrEmpty(setting.getMiniprogramPicUrl())? config.getSidebarImageUrl():setting.getMiniprogramPicUrl());
+                    } catch (Exception e) {
+                        log.error("赋值-小程序封面地址失败-" + e);
+                    }
+
+                    break;
+                //app
+                case "9":
+                    addWatchLogIfNeeded(sopLogs, videoId, courseId, sendTime, qwUserId, companyUserId, companyId, externalId,logVo);
+
+                    QwCreateLinkByAppVO linkByApp = createLinkByApp(setting, logVo, sendTime, courseId, videoId,
+                            qwUserId, companyUserId, companyId, externalId,sopLogs.getCorpId(),qwUserName);
+
+                    setting.setLinkUrl(linkByApp.getSortLink().replaceAll("^[\\s\\u2005]+", ""));
+                    setting.setAppLinkUrl(linkByApp.getAppMsgLink().replaceAll("^[\\s\\u2005]+", ""));
+                    setting.setCourseUrl(setting.getLinkImageUrl());
+                    setting.setTitle(setting.getLinkDescribe()); //小节名称
+
+                    break;
+                //自定义小程序
+                case "10":
+                    addWatchLogIfNeeded(sopLogs, videoId, courseId, sendTime, qwUserId, companyUserId, companyId, externalId,logVo);
+
+                    Optional<Company> matchedCompany = companies.stream()
+                            .filter(company -> String.valueOf(company.getCompanyId()).equals(companyId))
+                            .findFirst();
+                    if (matchedCompany.isPresent()) {
+                        Company company = matchedCompany.get();
+
+                        String customMiniAppId = company.getCustomMiniAppId();
+
+                        if (customMiniAppId != null && !customMiniAppId.trim().isEmpty()) {
+                            setting.setMiniprogramAppid(customMiniAppId);
+                        } else {
+                            setting.setMiniprogramAppid("该公司未配置自定义小程序:"+companyId);
+                        }
+                    } else {
+                        setting.setMiniprogramAppid("未找到匹配的公司的自定义小程序:"+companyId);
+                    }
+
+                    break;
+                //直播小程序单独
+                case "12":
+                    String sortLiveLink;
+                    sortLiveLink = "/pages_course/living?companyId=" + companyId + "&companyUserId=" + companyUserId + "&liveId=" + setting.getLiveId();
+
+
+                    String miniprogramLiveTitle = setting.getMiniprogramTitle();
+                    int maxLiveLength = 17;
+                    setting.setMiniprogramTitle(miniprogramLiveTitle.length() > maxLiveLength ? miniprogramLiveTitle.substring(0, maxLiveLength) + "..." : miniprogramLiveTitle);
+                    String json = configService.selectConfigByKey("his.config");
+                    FSSysConfig sysConfig= JSON.parseObject(json,FSSysConfig.class);
+                    setting.setMiniprogramAppid(sysConfig.getAppId());
+                    setting.setMiniprogramPage(sortLiveLink);
+                    setting.setContentType("4");
+                    try {
+                        setting.setMiniprogramPicUrl(StringUtil.strIsNullOrEmpty(setting.getMiniprogramPicUrl()) ? "https://cos.his.cdwjyyh.com/fs/20250331/ec2b4e73be8048afbd526124a655ad56.png" : setting.getMiniprogramPicUrl());
+                    } catch (Exception e) {
+                        log.error("赋值-小程序封面地址失败-" + e);
+                    }
+
+                    break;
+                default:
+                    break;
+            }
+
+        }
+        clonedContent.getSetting().stream().filter(e -> "1".equals(e.getIsBindUrl())).forEach(e -> {
+            e.setIsBindUrl("0");
+//            e.setLinkDescribe(null);
+            e.setLinkUrl(null);
+//            e.setLinkImageUrl(null);
+        });
+        sopLogs.setContentJson(JSON.toJSONString(clonedContent));
+        enqueueQwSopLogs(sopLogs);
+    }
+
+    private String getAppIdFromMiniMap(Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap,
+                                       String companyId,
+                                       int sendMsgType,
+                                       Integer grade) {
+        if (miniMap.isEmpty() || sendMsgType != 1) {
+            return null;
+        }
+
+        Map<Integer, List<CompanyMiniapp>> gradeMap = miniMap.get(Long.valueOf(companyId));
+        if (gradeMap == null) {
+            return null;
+        }
+
+        int listIndex = getLevel(grade);
+        List<CompanyMiniapp> miniapps = gradeMap.get(listIndex);
+
+        if (miniapps == null || miniapps.isEmpty()) {
+            return null;
+        }
+
+        CompanyMiniapp companyMiniapp = miniapps.get(0);
+        return (companyMiniapp != null && !StringUtil.strIsNullOrEmpty(companyMiniapp.getAppId()))
+                ? companyMiniapp.getAppId()
+                : null;
+    }
+
+    private static int getLevel(Integer grade) {
+        int effectiveGrade = (grade == null) ? 5 : grade;
+        int listIndex = (effectiveGrade == 1 || effectiveGrade == 2) ? 0 : 1;
+        return listIndex;
+    }
+
+    /**
+     * 深拷贝 Content 对象,避免使用 JSON 进行序列化和反序列化
+     */
+    private QwSopTempSetting.Content deepCopyContent(QwSopTempSetting.Content content) {
+        if (content == null) {
+            return null;
+        }
+        return content.clone();
+    }
+
+    private void handleOrderMessage(QwSopLogs sopLogs, QwSopTempSetting.Content content) {
+        sopLogs.setContentJson(JSON.toJSONString(content));
+        enqueueQwSopLogs(sopLogs);
+    }
+
+
+
+
+    private String generateShortLink(QwSopTempSetting.Content.Setting setting, SopUserLogsVo logVo, Date sendTime,
+                                     Long courseId, Long videoId, String qwUserId,
+                                     String companyUserId, String companyId, String externalId,String isOfficial, Long fsUserId) {
+        // 获取缓存的配置
+        CourseConfig config;
+        synchronized(configLock) {
+            config = cachedCourseConfig;
+        }
+
+        if (config == null) {
+            log.error("CourseConfig is not loaded.");
+            return "";
+        }
+
+        // 手动创建 FsCourseLink 对象,避免使用 BeanUtils.copyProperties
+        FsCourseLink link = new FsCourseLink();
+        link.setCompanyId(Long.parseLong(companyId));
+        link.setQwUserId(Long.parseLong(qwUserId));
+        link.setCompanyUserId(Long.parseLong(companyUserId));
+        link.setVideoId(videoId.longValue());
+        link.setCorpId(logVo.getCorpId());
+        link.setCourseId(courseId.longValue());
+        link.setQwExternalId(Long.parseLong(externalId));
+
+        if (StringUtil.strIsNullOrEmpty(isOfficial)){
+            link.setLinkType(0);
+        }else {
+            if (isOfficial.equals("1")) {
+                if (fsUserId== null || Long.valueOf(0L).equals(fsUserId)){
+                    link.setLinkType(0);
+                }else {
+                    link.setLinkType(5);
+                }
+            }else if (isOfficial.equals("0")){
+                link.setLinkType(0);
+            }else{
+                link.setLinkType(0);
+            }
+        }
+
+        FsCourseRealLink courseMap = new FsCourseRealLink();
+        courseMap.setCompanyId(link.getCompanyId());
+        courseMap.setQwUserId(link.getQwUserId());
+        courseMap.setCompanyUserId(link.getCompanyUserId());
+        courseMap.setVideoId(link.getVideoId());
+        courseMap.setCorpId(link.getCorpId());
+        courseMap.setCourseId(link.getCourseId());
+        courseMap.setQwExternalId(link.getQwExternalId());
+        courseMap.setFsUserId(fsUserId);
+
+        if (StringUtil.strIsNullOrEmpty(isOfficial)){
+            courseMap.setLinkType(0);
+        }else {
+            if (isOfficial.equals("1")) {
+                if (fsUserId== null || Long.valueOf(0L).equals(fsUserId)){
+                    courseMap.setLinkType(0);
+                }else {
+                    courseMap.setLinkType(5);
+                }
+            }else if (isOfficial.equals("0")){
+                courseMap.setLinkType(0);
+            }else{
+                courseMap.setLinkType(0);
+            }
+        }
+
+
+        String courseJson = JSON.toJSONString(courseMap);
+        String realLinkFull = REAL_LINK_PREFIX + courseJson;
+        link.setRealLink(realLinkFull);
+
+        String randomString = generateRandomStringWithLock();
+        if (StringUtil.strIsNullOrEmpty(randomString)){
+            link.setLink(UUID.randomUUID().toString().replace("-", ""));
+        }else {
+            link.setLink(randomString);
+        }
+
+        link.setCreateTime(sendTime);
+
+        Integer expireDays = (setting.getExpiresDays() == null || setting.getExpiresDays() == 0)
+                ? config.getVideoLinkExpireDate()
+                : setting.getExpiresDays();
+
+        // 使用 Java 8 时间 API 计算过期时间
+        LocalDateTime sendDateTime = sendTime.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
+        LocalDateTime expireDateTime = sendDateTime.plusDays(expireDays-1);
+        expireDateTime = expireDateTime.toLocalDate().atTime(23, 59, 59);
+        Date updateTime = Date.from(expireDateTime.atZone(ZoneId.systemDefault()).toInstant());
+        link.setUpdateTime(updateTime);
+
+        //取销售绑定的二级域名
+        String sortLink = logVo.getDomain() + SHORT_LINK_PREFIX + link.getLink();
+        enqueueCourseLink(link);
+        return sortLink.replaceAll("^[\\s\\u2005]+", "");
+    }
+
+    private QwCreateLinkByAppVO createLinkByApp(QwSopTempSetting.Content.Setting setting, SopUserLogsVo logVo, Date sendTime,
+                                                Long courseId, Long videoId, String qwUserId,
+                                                String companyUserId, String companyId, String externalId,String corpId,String qwUserName){
+        // 获取缓存的配置
+        CourseConfig config;
+        synchronized(configLock) {
+            config = cachedCourseConfig;
+        }
+
+        if (config == null) {
+            log.error("CourseConfig is not loaded.");
+            return null;
+        }
+        FsCourseLink link = createFsCourseLink(corpId, sendTime, courseId, videoId, qwUserId,
+                companyUserId, companyId, externalId, 4);
+
+        FsCourseRealLink courseMap = new FsCourseRealLink();
+        BeanUtils.copyProperties(link,courseMap);
+
+        String courseJson = JSON.toJSONString(courseMap);
+        String realLinkFull = REAL_LINK_PREFIX + courseJson;
+        link.setRealLink(realLinkFull);
+
+        Date updateTime = createUpdateTime(setting, sendTime, config);
+
+        link.setUpdateTime(updateTime);
+
+        String sortLink = appLink+link.getLink()+"&videoId="+videoId;
+
+        String appMsgLink=appRealLink+link.getLink();
+
+        QwCreateLinkByAppVO byAppVO=new QwCreateLinkByAppVO();
+        byAppVO.setSortLink(sortLink);
+        byAppVO.setAppMsgLink(appMsgLink);
+
+        FsCourseSopAppLink fsCourseSopAppLink = createFsCourseSopAppLink(link.getLink(), sendTime, updateTime, companyId, companyUserId, qwUserId,
+                qwUserName, corpId, courseId, setting.getLinkTitle(), setting.getLinkImageUrl(), videoId,
+                setting.getLinkDescribe(), appMsgLink, externalId);
+
+        enqueueCourseSopAppLink(fsCourseSopAppLink);
+
+        enqueueCourseLink(link);
+
+        return byAppVO;
+    }
+
+
+    public FsCourseSopAppLink createFsCourseSopAppLink(String link, Date sendTime, Date updateTime, String companyId,
+                                                       String companyUserId,String qwUserId,String qwUserName,String corpId,
+                                                       Long courseId,String linkTile,String linkImageUrl,Long videoId,
+                                                       String linkDescribe,String appMsgLink,String externalId){
+
+        FsCourseSopAppLink sopAppLink=new FsCourseSopAppLink();
+        sopAppLink.setLink(link);
+        sopAppLink.setCreateTime(sendTime);
+        sopAppLink.setUpdateTime(updateTime);
+        sopAppLink.setCompanyId(Long.parseLong(companyId));
+        sopAppLink.setCompanyUserId(Long.parseLong(companyUserId));
+        sopAppLink.setQwUserId(Long.parseLong(qwUserId));
+        sopAppLink.setQwUserName(qwUserName);
+        sopAppLink.setCorpId(corpId);
+        sopAppLink.setCourseId(courseId);
+        sopAppLink.setCourseTitle(linkTile);
+        sopAppLink.setCourseUrl(linkImageUrl);
+        sopAppLink.setVideoId(videoId);
+        sopAppLink.setVideoTitle(linkDescribe);
+        sopAppLink.setAppRealLink(appMsgLink);
+        sopAppLink.setQwExternalId(Long.parseLong(externalId));
+
+
+        return sopAppLink;
+    }
+
+    public FsCourseLink createFsCourseLink(String corpId, Date sendTime,Long courseId,Long videoId, String qwUserId,
+                                           String companyUserId, String companyId,String externalId,Integer type){
+        // 手动创建 FsCourseLink 对象,避免使用 BeanUtils.copyProperties
+        FsCourseLink link = new FsCourseLink();
+        link.setCompanyId(Long.parseLong(companyId));
+        link.setQwUserId(Long.parseLong(qwUserId));
+        link.setCompanyUserId(Long.parseLong(companyUserId));
+        link.setVideoId(videoId.longValue());
+        link.setCorpId(corpId);
+        link.setCourseId(courseId.longValue());
+        link.setQwExternalId(Long.parseLong(externalId));
+        link.setLinkType(type); //小程序
+
+        String randomString = generateRandomStringWithLock();
+        if (StringUtil.strIsNullOrEmpty(randomString)){
+            link.setLink(UUID.randomUUID().toString().replace("-", ""));
+        }else {
+            link.setLink(randomString);
+        }
+
+        link.setCreateTime(sendTime);
+
+        return link;
+    }
+
+    private Date createUpdateTime(QwSopTempSetting.Content.Setting setting,Date sendTime,CourseConfig config){
+
+        Integer expireDays = (setting.getExpiresDays() == null || setting.getExpiresDays() == 0)
+                ? config.getVideoLinkExpireDate()
+                : setting.getExpiresDays();
+
+//         使用 Java 8 时间 API 计算过期时间
+        LocalDateTime sendDateTime = sendTime.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
+        LocalDateTime expireDateTime = sendDateTime.plusDays(expireDays-1);
+        expireDateTime = expireDateTime.toLocalDate().atTime(23, 59, 59);
+        Date updateTime = Date.from(expireDateTime.atZone(ZoneId.systemDefault()).toInstant());
+
+        return updateTime;
+    }
+
+    private String createLinkByMiniApp(QwSopTempSetting.Content.Setting setting, SopUserLogsVo logVo, Date sendTime,
+                                       Long courseId, Long videoId, String qwUserId,
+                                       String companyUserId, String companyId, String externalId,String isOfficial,Long fsUserId, String chatId) {
+        // 获取缓存的配置
+        CourseConfig config;
+        synchronized(configLock) {
+            config = cachedCourseConfig;
+        }
+
+        if (config == null) {
+            log.error("CourseConfig is not loaded.");
+            return "";
+        }
+
+
+//        if (StringUtils.isEmpty(config.getMiniprogramPage())){
+//            log.error("miniprogramPage is not loaded.");
+//            return "";
+//        }
+
+        // 手动创建 FsCourseLink 对象,避免使用 BeanUtils.copyProperties
+        FsCourseLink link = new FsCourseLink();
+        link.setCompanyId(Long.parseLong(companyId));
+        link.setQwUserId(Long.parseLong(qwUserId));
+        link.setCompanyUserId(Long.parseLong(companyUserId));
+        link.setVideoId(videoId);
+        link.setCorpId(logVo.getCorpId());
+        link.setCourseId(courseId);
+        if(StringUtils.isEmpty(chatId)){
+            link.setQwExternalId(Long.parseLong(externalId));
+        }
+        link.setProjectCode(cloudHostProper.getProjectCode());
+        link.setChatId(chatId);
+
+        if (StringUtil.strIsNullOrEmpty(isOfficial)){
+            link.setLinkType(3);
+        }else {
+            if (isOfficial.equals("1")) {
+                if (fsUserId== null || Long.valueOf(0L).equals(fsUserId)){
+                    link.setLinkType(3);
+                }else {
+                    link.setLinkType(5);
+                }
+            }else if (isOfficial.equals("0")){
+                link.setLinkType(3);
+            }else{
+                link.setLinkType(3);
+            }
+        }
+
+        String randomString = generateRandomStringWithLock();
+        if (StringUtil.strIsNullOrEmpty(randomString)){
+            link.setLink(UUID.randomUUID().toString().replace("-", ""));
+        }else {
+            link.setLink(randomString);
+        }
+
+        link.setCreateTime(sendTime);
+
+        FsCourseRealLink courseMap = new FsCourseRealLink();
+        BeanUtils.copyProperties(link,courseMap);
+
+        String courseJson = JSON.toJSONString(courseMap);
+        String realLinkFull = miniappRealLink + courseJson;
+        link.setRealLink(realLinkFull);
+
+
+        Integer expireDays = (setting.getExpiresDays() == null || setting.getExpiresDays() == 0)
+                ? config.getVideoLinkExpireDate()
+                : setting.getExpiresDays();
+
+        // 使用 Java 8 时间 API 计算过期时间
+        LocalDateTime sendDateTime = sendTime.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
+        LocalDateTime expireDateTime = sendDateTime.plusDays(expireDays-1);
+        expireDateTime = expireDateTime.toLocalDate().atTime(23, 59, 59);
+        Date updateTime = Date.from(expireDateTime.atZone(ZoneId.systemDefault()).toInstant());
+        link.setUpdateTime(updateTime);
+
+        //存短链-
+        enqueueCourseLink(link);
+        return link.getRealLink().replaceAll("^[\\s\\u2005]+", "");
+    }
+
+    private void addWatchLogIfNeeded(QwSopLogs sopLogs, Long videoId, Long courseId,
+                                     Date sendTime, String qwUserId, String companyUserId,
+                                     String companyId, String externalId,SopUserLogsVo logsVo) {
+        FsCourseWatchLog watchLog = new FsCourseWatchLog();
+        watchLog.setVideoId(videoId != null ? videoId.longValue() : null);
+        watchLog.setQwExternalContactId(externalId != null ? Long.valueOf(externalId) : null);
+        watchLog.setSendType(2);
+        watchLog.setQwUserId(Long.parseLong(qwUserId));
+        watchLog.setSopId(sopLogs.getSopId());
+        watchLog.setDuration(0L);
+        watchLog.setCourseId(courseId != null ? courseId.longValue() : null);
+        watchLog.setCompanyUserId(companyUserId != null ? Long.valueOf(companyUserId) : null);
+        watchLog.setCompanyId(companyId != null ? Long.valueOf(companyId) : null);
+        watchLog.setCreateTime(convertStringToDate(sopLogs.getSendTime(),"yyyy-MM-dd HH:mm:ss"));
+        watchLog.setUpdateTime(new Date());
+        watchLog.setLogType(3);
+        watchLog.setUserId(sopLogs.getFsUserId());
+        watchLog.setCampPeriodTime(convertStringToDate(logsVo.getStartTime(),"yyyy-MM-dd"));
+        enqueueWatchLog(watchLog);
+    }
+
+    /**
+     * 时间字符串转Date时间
+     * @param dateString
+     * @return
+     */
+    public static Date convertStringToDate(String dateString,String pattern) {
+        if (dateString == null || dateString.isEmpty()) {
+            return null;
+        }
+        DateTimeFormatter formatter = DateTimeFormatter.ofPattern(pattern);
+        LocalDateTime localDateTime;
+        LocalDate localDate;
+        // 先解析成 LocalDate(只含年月日)
+        if (pattern.equals("yyyy-MM-dd")){
+            // 先解析成 LocalDate(只含年月日)
+            localDate = LocalDate.parse(dateString, formatter);
+            // 将 LocalDate 转为当天 00:00:00 的 LocalDateTime
+            localDateTime = localDate.atStartOfDay();
+        }else {
+            localDateTime = LocalDateTime.parse(dateString, formatter);
+        }
+        return Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant());
+    }
+
+
+    /**
+     * 将 QwSopLogs 放入队列
+     */
+    private void enqueueQwSopLogs(QwSopLogs sopLogs) {
+        try {
+            boolean offered = qwSopLogsQueue.offer(sopLogs, 5, TimeUnit.SECONDS);
+            if (!offered) {
+                log.error("QwSopLogs 队列已满,无法添加日志: {}", JSON.toJSONString(sopLogs));
+                // 处理队列已满的情况,例如记录到失败队列或持久化存储
+            }
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            log.error("插入 QwSopLogs 队列时被中断: {}", e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 将 FsCourseWatchLog 放入队列
+     */
+    private void enqueueWatchLog(FsCourseWatchLog watchLog) {
+        try {
+            boolean offered = watchLogsQueue.offer(watchLog, 5, TimeUnit.SECONDS);
+            if (!offered) {
+                log.error("FsCourseWatchLog 队列已满,无法添加日志: {}", JSON.toJSONString(watchLog));
+                // 处理队列已满的情况,例如记录到失败队列或持久化存储
+            }
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            log.error("插入 FsCourseWatchLog 队列时被中断: {}", e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 将 FsCourseWatchLog 放入队列
+     */
+    private void enqueueCourseLink(FsCourseLink courseLink) {
+        try {
+            boolean offered = linkQueue.offer(courseLink, 5, TimeUnit.SECONDS);
+            if (!offered) {
+                log.error("FsCourseLink 队列已满,无法添加日志: {}", JSON.toJSONString(courseLink));
+                // 处理队列已满的情况,例如记录到失败队列或持久化存储
+            }
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            log.error("插入 FsCourseLink 队列时被中断: {}", e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 将 FsCourseSopAppLing 放入队列
+     */
+    private void enqueueCourseSopAppLink(FsCourseSopAppLink sopAppLink) {
+        try {
+            boolean offered = sopAppLinks.offer(sopAppLink, 5, TimeUnit.SECONDS);
+            if (!offered) {
+                log.error("FsCourseSopAppLink 队列已满,无法添加日志: {}", JSON.toJSONString(sopAppLink));
+                // 处理队列已满的情况,例如记录到失败队列或持久化存储
+            }
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            log.error("插入 FsCourseLink 队列时被中断: {}", e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 消费 QwSopLogs 队列并进行批量插入
+     */
+    private void consumeQwSopLogs() {
+        List<QwSopLogs> batch = new ArrayList<>(BATCH_SIZE);
+        while (running || !qwSopLogsQueue.isEmpty()) {
+            try {
+                QwSopLogs log = qwSopLogsQueue.poll(1, TimeUnit.SECONDS);
+                if (log != null) {
+                    batch.add(log);
+                }
+                if (batch.size() >= BATCH_SIZE || (!batch.isEmpty() && log == null)) {
+                    if (!batch.isEmpty()) {
+                        batchInsertQwSopLogs(new ArrayList<>(batch));
+                        batch.clear();
+                    }
+                }
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                log.error("QwSopLogs 消费线程被中断: {}", e.getMessage(), e);
+            }
+        }
+
+        // 处理剩余的数据
+        if (!batch.isEmpty()) {
+            batchInsertQwSopLogs(batch);
+        }
+    }
+
+    /**
+     * 消费 FsCourseWatchLog 队列并进行批量插入
+     */
+    private void consumeCourseLink() {
+        List<FsCourseLink> batch = new ArrayList<>(BATCH_SIZE);
+        while (running || !linkQueue.isEmpty()) {
+            try {
+                FsCourseLink courseLink = linkQueue.poll(1, TimeUnit.SECONDS);
+                if (courseLink != null) {
+                    batch.add(courseLink);
+                }
+                if (batch.size() >= BATCH_SIZE || (!batch.isEmpty() && courseLink == null)) {
+                    if (!batch.isEmpty()) {
+                        batchInsertFsCourseLink(new ArrayList<>(batch));
+                        batch.clear();
+                    }
+                }
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                log.error("FsCourseLink 消费线程被中断: {}", e.getMessage(), e);
+            }
+        }
+
+        // 处理剩余的数据
+        if (!batch.isEmpty()) {
+            batchInsertFsCourseLink(batch);
+        }
+    }
+
+    /**
+     * 消费 FsCourseSopAppLink 队列并进行批量插入
+     */
+    private void consumeCourseSopAppLink() {
+        List<FsCourseSopAppLink> batch = new ArrayList<>(BATCH_SIZE);
+        while (running || !sopAppLinks.isEmpty()) {
+            try {
+                FsCourseSopAppLink courseSopAppLink = sopAppLinks.poll(1, TimeUnit.SECONDS);
+                if (courseSopAppLink != null) {
+                    batch.add(courseSopAppLink);
+                }
+                if (batch.size() >= BATCH_SIZE || (!batch.isEmpty() && courseSopAppLink == null)) {
+                    if (!batch.isEmpty()) {
+                        batchInsertFsCourseSopAppLink(new ArrayList<>(batch));
+                        batch.clear();
+                    }
+                }
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                log.error("FsCourseSopAppLink 消费线程被中断: {}", e.getMessage(), e);
+            }
+        }
+
+        // 处理剩余的数据
+        if (!batch.isEmpty()) {
+            batchInsertFsCourseSopAppLink(batch);
+        }
+    }
+
+    /**
+     * 消费 FsCourseWatchLog 队列并进行批量插入
+     */
+    private void consumeWatchLogs() {
+        List<FsCourseWatchLog> batch = new ArrayList<>(BATCH_SIZE);
+        while (running || !watchLogsQueue.isEmpty()) {
+            try {
+                FsCourseWatchLog watchLog = watchLogsQueue.poll(1, TimeUnit.SECONDS);
+                if (watchLog != null) {
+                    batch.add(watchLog);
+                }
+                if (batch.size() >= BATCH_SIZE || (!batch.isEmpty() && watchLog == null)) {
+                    if (!batch.isEmpty()) {
+                        batchInsertFsCourseWatchLogs(new ArrayList<>(batch));
+                        batch.clear();
+                    }
+                }
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                log.error("FsCourseWatchLog 消费线程被中断: {}", e.getMessage(), e);
+            }
+        }
+
+        // 处理剩余的数据
+        if (!batch.isEmpty()) {
+            batchInsertFsCourseWatchLogs(batch);
+        }
+    }
+
+    /**
+     * 批量插入 QwSopLogs
+     */
+    @Transactional
+    @Retryable(
+            value = { Exception.class },
+            maxAttempts = 3,
+            backoff = @Backoff(delay = 2000)
+    )
+    public void batchInsertQwSopLogs(List<QwSopLogs> logsToInsert) {
+        try {
+            qwSopLogsService.batchInsertQwSopLogs(logsToInsert);
+            log.info("批量插入 QwSopLogs 完成,共插入 {} 条记录。", logsToInsert.size());
+        } catch (Exception e) {
+            log.error("批量插入 QwSopLogs 失败: {}", e.getMessage(), e);
+            // 可选:将失败的数据记录到失败队列或持久化存储以便后续重试
+        }
+    }
+
+    /**
+     * 批量插入 FsCourseWatchLog
+     */
+    @Transactional
+    @Retryable(
+            value = { Exception.class },
+            maxAttempts = 3,
+            backoff = @Backoff(delay = 2000)
+    )
+    public void batchInsertFsCourseWatchLogs(List<FsCourseWatchLog> watchLogsToInsert) {
+        try {
+            fsCourseWatchLogMapper.insertFsCourseWatchLogBatch(watchLogsToInsert);
+            log.info("批量插入 FsCourseWatchLog 完成,共插入 {} 条记录。", watchLogsToInsert.size());
+        } catch (Exception e) {
+            log.error("批量插入 FsCourseWatchLog 失败: {}", e.getMessage(), e);
+            // 可选:将失败的数据记录到失败队列或持久化存储以便后续重试
+        }
+    }
+
+
+    /**
+     * 批量插入 FsCourseLink
+     */
+    @Transactional
+    @Retryable(
+            value = { Exception.class },
+            maxAttempts = 3,
+            backoff = @Backoff(delay = 2000)
+    )
+    public void batchInsertFsCourseLink(List<FsCourseLink> courseLinkToInsert) {
+        try {
+            fsCourseLinkMapper.insertFsCourseLinkBatch(courseLinkToInsert);
+            log.info("批量插入 FsCourseLink 完成,共插入 {} 条记录。", courseLinkToInsert.size());
+        } catch (Exception e) {
+            log.error("批量插入 FsCourseLink 失败: {}", e.getMessage(), e);
+            // 可选:将失败的数据记录到失败队列或持久化存储以便后续重试
+        }
+    }
+
+
+    /**
+     * 批量插入 FsCourseSopAppLink
+     */
+    @Transactional
+    @Retryable(
+            value = { Exception.class },
+            maxAttempts = 3,
+            backoff = @Backoff(delay = 2000)
+    )
+    public void batchInsertFsCourseSopAppLink(List<FsCourseSopAppLink> courseSopAppLinkToInsert) {
+        try {
+            fsCourseSopAppLinkMapper.insertFsCourseSopAppLinkBatch(courseSopAppLinkToInsert);
+            log.info("批量插入 FsCourseSopAppLink 完成,共插入 {} 条记录。", courseSopAppLinkToInsert.size());
+        } catch (Exception e) {
+            log.error("批量插入 FsCourseSopAppLink 失败: {}", e.getMessage(), e);
+            // 可选:将失败的数据记录到失败队列或持久化存储以便后续重试
+        }
+    }
+
+
+    @Override
+    public void updateSopLogsByCancel() {
+        List<QwSopLogs> sopLogs = qwSopLogsMapper.selectQwSopLogsByCancel();
+        log.info("补发过期完课消息总条数:{}",sopLogs.size());
+        processUpdateQwSopLogs(sopLogs);
+    }
+
+
+    // 定义一个方法来批量处理插入逻辑,支持每 500 条数据一次的批量插入
+    private void processUpdateQwSopLogs(List<QwSopLogs> sopLogs) {
+        // 定义批量插入的大小
+        int batchSize = 500;
+
+        // 循环处理外部用户 ID,每次处理批量大小的子集
+        for (int i = 0; i < sopLogs.size(); i += batchSize) {
+
+            int endIndex = Math.min(i + batchSize, sopLogs.size());
+            List<QwSopLogs> batchList = sopLogs.subList(i, endIndex);  // 获取当前批次的子集
+
+            // 直接使用批次数据进行批量更新,不需要额外的 List
+            try {
+                qwSopLogsMapper.batchUpdateQwSopLogsByCancel(batchList);
+                log.info("正在补发条数:{}",batchSize);
+            } catch (Exception e) {
+                // 记录异常日志,方便后续排查问题
+                log.error("批量更新数据时发生异常,处理的批次起始索引为: " + i, e);
+            }
+        }
+    }
+
+    @Autowired
+    private FsCourseFinishTempMapper fsCourseFinishTempMapper;
+    @Autowired
+    private QwExternalContactMapper qwExternalContactMapper;
+
+
+//    @Override
+//    @Transactional
+//    public void creatMessMessage(QwSopLogs logs) {
+//       // qwSopLogsMapper.insertQwSopLogs(logs);
+//        QwSopTempSetting.Content content = JSON.parseObject(logs.getContentJson(), QwSopTempSetting.Content.class);
+//        handleNormalMessage(logs, content,null);
+//    }
+
+    @Override
+    public void createCourseFinishMsg() {
+        long startTime = System.currentTimeMillis();
+        log.info("创建完课消息 - 定时任务开始 {}", startTime);
+
+        // 线程池配置
+        int threadPoolSize = 4;
+        ExecutorService executorService = Executors.newFixedThreadPool(threadPoolSize);
+
+        // 用于收集所有处理结果的队列
+        BlockingQueue<List<FsCourseWatchLog>> batchQueue = new LinkedBlockingQueue<>();
+
+        try {
+            // 查询当天日期范围
+            LocalDate today = LocalDate.now();
+            Date startDate = Date.from(today.atStartOfDay(ZoneId.systemDefault()).toInstant());
+            Date endDate = Date.from(today.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant());
+
+            // 启动生产者线程 - 流式分批查询数据
+            executorService.submit(() -> {
+                try {
+                    int batchSize = 1000;
+                    long  maxId = 0;
+                    boolean hasMore = true;
+
+                    while (hasMore) {
+                        // 查询当前批次数据
+                        List<FsCourseWatchLog> batch = fsCourseWatchLogMapper.selectFsCourseWatchLogFinishBatchByDate(
+                                startDate, endDate, maxId, batchSize);
+
+                        if (!batch.isEmpty()) {
+                            // 将批次放入队列
+                            batchQueue.put(batch);
+                            // 更新maxId为当前批次的最后一个ID
+                            maxId = batch.get(batch.size() - 1).getLogId();
+                            log.debug("已生产批次数据,最后logId: {}, 数量: {}", maxId, batch.size());
+                        }
+
+                        if (batch.size() < batchSize) {
+                            hasMore = false;
+                            batchQueue.put(Collections.emptyList());// 结束标志
+                            log.info("数据生产完成,最后logId: {}", maxId);
+                        }
+                    }
+                } catch (Exception e) {
+                    log.error("生产数据时出错", e);
+                    try {
+                        batchQueue.put(Collections.emptyList()); // 确保消费者能退出
+                    } catch (InterruptedException ie) {
+                        Thread.currentThread().interrupt();
+                    }
+                }
+            });
+
+            // 消费者线程处理数据
+            List<Future<?>> futures = new ArrayList<>();
+            for (int i = 0; i < threadPoolSize; i++) {
+                futures.add(executorService.submit(() -> {
+                    try {
+                        while (true) {
+                            List<FsCourseWatchLog> batch = batchQueue.take();
+
+                            // 空列表表示处理结束
+                            if (batch.isEmpty()) {
+                                batchQueue.put(Collections.emptyList()); // 传递给其他消费者
+                                break;
+                            }
+                            log.info("开始处理批次数据");
+                            processBatch(batch); // 处理批次数据
+                        }
+                    } catch (InterruptedException e) {
+                        Thread.currentThread().interrupt();
+                        log.error("处理数据时被中断", e);
+                    } catch (Exception e) {
+                        log.error("处理数据时出错", e);
+                    }
+                }));
+            }
+
+            // 等待所有任务完成
+            for (Future<?> future : futures) {
+                try {
+                    future.get();
+                } catch (InterruptedException | ExecutionException e) {
+                    log.error("等待任务完成时出错", e);
+                    Thread.currentThread().interrupt();
+                }
+            }
+
+            log.info("所有批次处理完成,总耗时: {}ms", System.currentTimeMillis() - startTime);
+
+        } finally {
+            executorService.shutdown();
+            try {
+                if (!executorService.awaitTermination(60, TimeUnit.SECONDS)) {
+                    executorService.shutdownNow();
+                }
+            } catch (InterruptedException e) {
+                executorService.shutdownNow();
+                Thread.currentThread().interrupt();
+            }
+        }
+    }
+
+    // 处理单个批次的方法
+    private void processBatch(List<FsCourseWatchLog> batch) {
+        List<FsCourseWatchLog> finishLogsToUpdate = new ArrayList<>();
+        List<QwSopLogs> sopLogsToInsert = new ArrayList<>();
+        log.info("开始执行处理批次方法-数量:{}",batch.size());
+        for (FsCourseWatchLog finishLog : batch) {
+            try {
+
+                try {
+
+                    asyncCourseWatchFinishService.executeCourseWatchFinish(finishLog);
+
+                }catch (Exception e){
+                    log.error("添加完课打备注失败",e);
+                }
+
+                // 查询外部联系人信息
+                QwExternalContact externalContact = qwExternalContactMapper.selectQwExternalContactById(finishLog.getQwExternalContactId());
+                if (externalContact == null) {
+                    log.error("外部联系人不存在: {}", finishLog.getQwExternalContactId());
+                    continue;
+                }
+
+                // 查询完课模板信息
+                FsCourseFinishTemp finishTemp = fsCourseFinishTempMapper.selectFsCourseFinishTempByCompanyId(finishLog.getCompanyUserId(),finishLog.getCompanyId(), finishLog.getVideoId());
+
+                // 设置 finishLog 为已发送状态,并加入批量更新列表
+                finishLog.setSendFinishMsg(1);
+                finishLogsToUpdate.add(finishLog);
+
+                if (finishTemp == null) {
+//                    log.error("完课模板不存在: " + finishLog.getQwUserId() + ", " + finishLog.getVideoId());
+                    continue;
+                }
+
+                // 构建 sopLogs 对象
+                QwSopLogs sopLogs = buildSopLogs(finishLog, externalContact, finishTemp);
+                if (sopLogs == null) {
+                    log.error("生成完课发送记录为空-:{}", finishLog.getQwExternalContactId());
+                    continue;
+                }
+
+                // 如果客户状态有效,则加入批量插入列表
+                if (isValidExternalContact(externalContact)) {
+                    sopLogsToInsert.add(sopLogs);
+                } else {
+                    log.info("完课消息-客户信息有误,不生成完课消息: {}", finishLog.getQwExternalContactId());
+                }
+                try {
+                    fsUserCompanyBindService.finish(externalContact.getFsUserId(), externalContact.getQwUserId(), externalContact.getCompanyUserId(), finishLog);
+                }catch (Exception e){
+                    log.error("更新重粉看课状态失败",e);
+                }
+            } catch (Exception e) {
+                log.error("处理完课记录失败: {}", finishLog.getLogId(), e);
+            }
+        }
+
+        // 批量更新和插入
+        if (!finishLogsToUpdate.isEmpty()) {
+            try {
+                fsCourseWatchLogMapper.batchUpdateWatchLogSendMsg(finishLogsToUpdate);
+                log.info("批量更新 finishLog 成功,数量: {}", finishLogsToUpdate.size());
+            } catch (Exception e) {
+                log.error("批量更新 finishLog 失败", e);
+            }
+        }
+
+        if (!sopLogsToInsert.isEmpty()) {
+            try {
+                qwSopLogsService.batchInsertQwSopLogs(sopLogsToInsert);
+                log.info("批量插入 sopLogs 成功,数量: {}", sopLogsToInsert.size());
+            } catch (Exception e) {
+                log.error("批量插入 sopLogs 失败", e);
+            }
+        }
+        log.info("结束处理批次方法-数量:{}",batch.size());
+    }
+
+    /**
+     * 构建 QwSopLogs 对象
+     */
+    private QwSopLogs buildSopLogs(FsCourseWatchLog finishLog, QwExternalContact externalContact, FsCourseFinishTemp finishTemp) {
+        QwSopCourseFinishTempSetting setting = new QwSopCourseFinishTempSetting();
+        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+        LocalDateTime currentTime = LocalDateTime.now();
+        LocalDateTime newTime = currentTime.plusMinutes(3);
+        String newTimeString = newTime.format(formatter);
+
+        QwSopLogs sopLogs = new QwSopLogs();
+        sopLogs.setSendTime(newTimeString);
+        sopLogs.setQwUserid(externalContact.getUserId());
+        sopLogs.setCorpId(externalContact.getCorpId());
+        sopLogs.setLogType(2);
+        sopLogs.setSendType(3);
+        sopLogs.setSendStatus(3L);
+        sopLogs.setReceivingStatus(0L);
+        sopLogs.setSort(40000000);
+        sopLogs.setCompanyId(finishLog.getCompanyId());
+        sopLogs.setSopId(finishLog.getSopId());
+        sopLogs.setExternalUserId(externalContact.getExternalUserId());
+        sopLogs.setExternalUserName(externalContact.getName());
+        sopLogs.setFsUserId(finishLog.getUserId() != null ? finishLog.getUserId() : null );
+        sopLogs.setExternalId(finishLog.getQwExternalContactId());
+        sopLogs.setUserLogsId("-");
+
+        sopLogs.setQwUserKey(finishLog.getQwUserId() != null ? finishLog.getQwUserId() : null);
+
+        // 解析模板设置
+        List<QwSopCourseFinishTempSetting.Setting> settings = parseSettings(finishTemp.getSetting());
+        if (settings == null) {
+            return null;
+        }
+        //完课后若是小程序发送另外一堂课
+        saveWacthLogOfCourseLink(settings,sopLogs,newTimeString,finishLog,finishTemp);
+        // 处理音频内容
+        for (QwSopCourseFinishTempSetting.Setting st : settings) {
+            if (st.getContentType().equals("7")) {
+                Long companyUserId = finishLog.getCompanyUserId();
+                QwSopTempVoice qwSopTempVoice = sopTempVoiceService.selectQwSopTempVoiceByCompanyUserIdAndVoiceTxt(companyUserId, st.getValue());
+                if (qwSopTempVoice != null && qwSopTempVoice.getVoiceUrl() != null && qwSopTempVoice.getRecordType() == 1) {
+                    st.setVoiceUrl(qwSopTempVoice.getVoiceUrl());
+                    st.setVoiceDuration(String.valueOf(qwSopTempVoice.getDuration()));
+                } else if (qwSopTempVoice == null) {
+                    if(companyUserId != null && st.getValue() != null){
+                        qwSopTempVoice = new QwSopTempVoice();
+                        qwSopTempVoice.setCompanyUserId(companyUserId);
+                        qwSopTempVoice.setVoiceTxt(st.getValue());
+                        qwSopTempVoice.setRecordType(0);
+                        sopTempVoiceService.insertQwSopTempVoice(qwSopTempVoice);
+                    }
+                }
+            }
+        }
+//        for (QwSopCourseFinishTempSetting.Setting st : settings) {
+//            if (st.getContentType().equals("7")) {
+//                try {
+//                    AudioVO audioVO = AudioUtils.transferAudioSilkFromText(st.getValue(), finishLog.getCompanyUserId(), false);
+//                    st.setVoiceUrl(audioVO.getUrl());
+//                    st.setVoiceDuration(audioVO.getDuration() + "");
+//                } catch (Exception e) {
+//                    log.error("音频生成失败: " + finishLog.getCompanyUserId(), e);
+//                }
+//            }
+//        }
+
+        setting.setSetting(settings);
+        sopLogs.setContentJson(JSON.toJSONString(setting));
+        return sopLogs;
+    }
+
+    /**
+     * 判定小程序的话新增创建看课记录,以及fsCourseLink
+     *
+     * @param settings
+     */
+    public void saveWacthLogOfCourseLink(List<QwSopCourseFinishTempSetting.Setting> settings, QwSopLogs sopLogs,  String newTimeString, FsCourseWatchLog finishLog, FsCourseFinishTemp finishTemp){
+        String json = configService.selectConfigByKey("course.config");
+        CourseConfig config = JSON.parseObject(json, CourseConfig.class);
+        Date dataTime = new Date();
+        List<CompanyMiniapp> miniList = companyMiniappService.list(new QueryWrapper<CompanyMiniapp>().orderByAsc("sort_num"));
+        Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap = miniList.stream().collect(Collectors.groupingBy(CompanyMiniapp::getCompanyId, Collectors.groupingBy(CompanyMiniapp::getType)));
+
+        QwCompany qwCompany = iQwCompanyService.getQwCompanyByRedis(sopLogs.getCorpId());
+        QwUser qwUser = qwExternalContactService.getQwUserByRedis(sopLogs.getCorpId(), sopLogs.getQwUserid());
+        if (qwUser == null){
+            return;
+        }
+        for (QwSopCourseFinishTempSetting.Setting st : settings) {
+            switch (st.getContentType()) {
+                //小程序单独
+                case "4":
+                    addWatchLogIfNeeded(sopLogs.getSopId(), st.getVideoId().intValue(), st.getCourseId().intValue(), sopLogs.getFsUserId(),  String.valueOf(qwUser.getId()),qwUser.getCompanyUserId().toString(), qwUser.getCompanyId().toString(),
+                            sopLogs.getExternalId(), newTimeString.substring(0, 10), dataTime);
+
+                    String linkByMiniApp = createLinkByMiniApp(st, sopLogs.getCorpId(), dataTime, finishTemp.getCourseId().intValue(), Integer.valueOf(st.getVideoId().toString()),
+                            String.valueOf(qwUser.getId()), qwUser.getCompanyUserId().toString(), qwUser.getCompanyId().toString(), sopLogs.getExternalId(), config);
+
+
+                    String miniAppId = null;
+                    if (!miniMap.isEmpty() && qwUser.getSendMsgType() == 1) {
+                        Map<Integer, List<CompanyMiniapp>> integerListMap = miniMap.get(Long.valueOf(qwUser.getCompanyId()));
+                        if (integerListMap != null) {
+                            int listIndex = 0;
+                            List<CompanyMiniapp> miniapps = integerListMap.get(listIndex);
+
+                            if (miniapps != null && !miniapps.isEmpty()) {
+                                CompanyMiniapp companyMiniapp = miniapps.get(0);
+                                if (companyMiniapp != null && !StringUtil.strIsNullOrEmpty(companyMiniapp.getAppId())) {
+                                    miniAppId = companyMiniapp.getAppId();
+                                }
+                            }
+                        }
+                    }
+
+                    if (StringUtil.strIsNullOrEmpty(miniAppId) && !StringUtil.strIsNullOrEmpty(qwCompany.getMiniAppId())) {
+                        miniAppId = qwCompany.getMiniAppId();
+                    }
+
+                    if (!StringUtil.strIsNullOrEmpty(miniAppId)) {
+                        st.setMiniprogramAppid(miniAppId);
+                    } else {
+                        log.error("企业未配置小程序-" + sopLogs.getCorpId());
+                    }
+
+                    String miniprogramTitle = st.getMiniprogramTitle();
+                    int maxLength = 17;
+                    st.setMiniprogramTitle(miniprogramTitle.length() > maxLength ? miniprogramTitle.substring(0, maxLength)+"..." : miniprogramTitle);
+                    st.setMiniprogramPage(linkByMiniApp);
+                    break;
+                default:
+                    break;
+
+            }
+        }
+    }
+    private Date processDate(String sendTimeParam) {
+        // 1. 获取当前日期(年月日)
+        LocalDate currentDate = LocalDate.now();
+
+        // 2. 解析传入的时分(支持 "HH:mm" 或 "H:mm")
+        LocalTime sendTime = LocalTime.parse(sendTimeParam);
+
+        // 3. 合并为 LocalDateTime
+        LocalDateTime dateTime = LocalDateTime.of(currentDate, sendTime);
+
+        // 4. 转换为 Date(需通过 Instant 和系统默认时区)
+        Date date = Date.from(dateTime.atZone(ZoneId.systemDefault()).toInstant());
+
+        return date;
+    }
+
+    /**
+     * 新增courseLink
+     *
+     * @param setting
+     * @param corpId
+     * @param sendTime
+     * @param courseId
+     * @param videoId
+     * @param qwUserId
+     * @param companyUserId
+     * @param companyId
+     * @param externalId
+     * @param config
+     * @return
+     */
+    private String createLinkByMiniApp(QwSopCourseFinishTempSetting.Setting setting, String corpId, Date sendTime,
+                                       Integer courseId, Integer videoId, String qwUserId,
+                                       String companyUserId, String companyId, Long externalId, CourseConfig config) {
+
+        FsCourseLink link = createFsCourseLink(corpId, sendTime, courseId, videoId, qwUserId,
+                companyUserId, companyId, externalId, 3, null);
+
+        FsCourseRealLink courseMap = new FsCourseRealLink();
+        BeanUtils.copyProperties(link, courseMap);
+
+        String courseJson = JSON.toJSONString(courseMap);
+        String realLinkFull = miniappRealLink + courseJson;
+        link.setRealLink(realLinkFull);
+
+        Date updateTime = createUpdateTime(setting, sendTime, config);
+
+        link.setUpdateTime(updateTime);
+        //存短链-
+        fsCourseLinkMapper.insertFsCourseLink(link);
+        return link.getRealLink();
+    }
+
+    /**
+     * 创建courselink
+     * @param corpId
+     * @param sendTime
+     * @param courseId
+     * @param videoId
+     * @param qwUserId
+     * @param companyUserId
+     * @param companyId
+     * @param externalId
+     * @param type
+     * @param chatId
+     * @return
+     */
+    public FsCourseLink createFsCourseLink(String corpId, Date sendTime, Integer courseId, Integer videoId, String qwUserId,
+                                           String companyUserId, String companyId, Long externalId, Integer type, String chatId) {
+        // 手动创建 FsCourseLink 对象,避免使用 BeanUtils.copyProperties
+        FsCourseLink link = new FsCourseLink();
+        link.setCompanyId(Long.parseLong(companyId));
+        link.setQwUserId(Long.valueOf(qwUserId));
+        link.setCompanyUserId(Long.parseLong(companyUserId));
+        link.setVideoId(videoId.longValue());
+        link.setCorpId(corpId);
+        link.setCourseId(courseId.longValue());
+        link.setChatId(chatId);
+        link.setQwExternalId(externalId);
+        link.setLinkType(type); //小程序
+        link.setUNo(UUID.randomUUID().toString());
+        link.setProjectCode(cloudHostProper.getProjectCode());
+        String randomString = generateRandomStringWithLock();
+        if (StringUtil.strIsNullOrEmpty(randomString)) {
+            link.setLink(UUID.randomUUID().toString().replace("-", ""));
+        } else {
+            link.setLink(randomString);
+        }
+
+        link.setCreateTime(sendTime);
+
+        return link;
+    }
+
+
+    /**
+     * 计算过期时间
+     * @param setting
+     * @param sendTime
+     * @param config
+     * @return
+     */
+    private Date createUpdateTime(QwSopCourseFinishTempSetting.Setting setting, Date sendTime, CourseConfig config) {
+
+        Integer expireDays = (setting.getExpiresDays() == null || setting.getExpiresDays() == 0)
+                ? config.getVideoLinkExpireDate()
+                : setting.getExpiresDays();
+
+//         使用 Java 8 时间 API 计算过期时间
+        LocalDateTime sendDateTime = sendTime.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
+        LocalDateTime expireDateTime = sendDateTime.plusDays(expireDays - 1);
+        expireDateTime = expireDateTime.toLocalDate().atTime(23, 59, 59);
+        Date updateTime = Date.from(expireDateTime.atZone(ZoneId.systemDefault()).toInstant());
+
+        return updateTime;
+    }
+
+    /**
+     * 增加看课记录
+     *
+     * @param sopId
+     * @param videoId
+     * @param courseId
+     * @param fsUserId
+     * @param qwUserId
+     * @param companyUserId
+     * @param companyId
+     * @param externalId
+     * @param startTime
+     * @param createTime
+     * @return
+     */
+    private Long addWatchLogIfNeeded(String sopId, Integer videoId, Integer courseId,
+                                     Long fsUserId, String qwUserId, String companyUserId,
+                                     String companyId, Long externalId, String startTime, Date createTime) {
+
+        try {
+            FsCourseWatchLog watchLog = new FsCourseWatchLog();
+            watchLog.setVideoId(videoId != null ? videoId.longValue() : null);
+            watchLog.setQwExternalContactId(externalId);
+            watchLog.setSendType(2);
+            watchLog.setQwUserId(Long.valueOf(qwUserId));
+            watchLog.setSopId(sopId);
+            watchLog.setDuration(0L);
+            watchLog.setCourseId(courseId != null ? courseId.longValue() : null);
+            watchLog.setCompanyUserId(companyUserId != null ? Long.valueOf(companyUserId) : null);
+            watchLog.setCompanyId(companyId != null ? Long.valueOf(companyId) : null);
+            watchLog.setCreateTime(createTime);
+            watchLog.setUpdateTime(createTime);
+            watchLog.setLogType(3);
+            watchLog.setUserId(fsUserId);
+            watchLog.setCampPeriodTime(convertStringToDate(startTime, "yyyy-MM-dd"));
+
+            //存看课记录
+            int i = fsCourseWatchLogMapper.insertOrUpdateFsCourseWatchLog(watchLog);
+            return watchLog.getLogId();
+        } catch (Exception e) {
+            log.error("插入观看记录失败:" + e.getMessage());
+            return null;
+        }
+    }
+
+
+    /**
+     * 解析模板设置
+     */
+    private List<QwSopCourseFinishTempSetting.Setting> parseSettings(String jsonData) {
+        try {
+            if (jsonData.startsWith("[") && jsonData.endsWith("]")) {
+                return JSONArray.parseArray(jsonData, QwSopCourseFinishTempSetting.Setting.class);
+            } else {
+                String fixedJson = JSON.parseObject(jsonData, String.class);
+                return JSONArray.parseArray(fixedJson, QwSopCourseFinishTempSetting.Setting.class);
+            }
+        } catch (Exception e) {
+            log.error("解析模板设置失败", e);
+            return null;
+        }
+    }
+
+    /**
+     * 检查外部联系人状态是否有效
+     */
+    private boolean isValidExternalContact(QwExternalContact externalContact) {
+        return externalContact.getStatus() == 0 || externalContact.getStatus() == 2 || externalContact.getStatus() == 3;
+    }
+}

+ 965 - 0
fs-wx-task/src/main/java/com/fs/app/taskService/impl/SopLogsTestServiceImpl.java

@@ -0,0 +1,965 @@
+package com.fs.app.taskService.impl;//package com.fs.app.taskService.impl;
+//
+//import com.alibaba.fastjson.JSON;
+//import com.alibaba.fastjson.JSONArray;
+//import com.fs.app.taskService.SopLogsTaskService;
+//import com.fs.app.taskService.SopLogsTestService;
+//import com.fs.common.utils.StringUtils;
+//import com.fs.course.config.CourseConfig;
+//import com.fs.course.domain.*;
+//import com.fs.course.mapper.FsCourseDomainNameMapper;
+//import com.fs.course.mapper.FsCourseFinishTempMapper;
+//import com.fs.course.mapper.FsCourseLinkMapper;
+//import com.fs.course.mapper.FsCourseWatchLogMapper;
+//import com.fs.fastgptApi.util.AudioUtils;
+//import com.fs.fastgptApi.vo.AudioVO;
+//import com.fs.qw.domain.QwExternalContact;
+//import com.fs.qw.mapper.QwExternalContactMapper;
+//import com.fs.qw.mapper.QwUserMapper;
+//import com.fs.qw.vo.QwSopCourseFinishTempSetting;
+//import com.fs.qw.vo.QwSopRuleTimeVO;
+//import com.fs.qw.vo.QwSopTempSetting;
+//import com.fs.sop.domain.QwSopLogs;
+//import com.fs.sop.domain.SopUserLogs;
+//import com.fs.sop.domain.SopUserLogsInfo;
+//import com.fs.sop.mapper.QwSopLogsMapper;
+//import com.fs.sop.mapper.QwSopMapper;
+//import com.fs.sop.mapper.SopUserLogsInfoMapper;
+//import com.fs.sop.mapper.SopUserLogsMapper;
+//import com.fs.sop.service.IQwSopLogsService;
+//import com.fs.sop.vo.SopUserLogsVo;
+//import com.fs.system.service.ISysConfigService;
+//import lombok.extern.slf4j.Slf4j;
+//import org.springframework.beans.factory.annotation.Autowired;
+//import org.springframework.retry.annotation.Backoff;
+//import org.springframework.retry.annotation.Retryable;
+//import org.springframework.scheduling.annotation.Async;
+//import org.springframework.scheduling.annotation.Scheduled;
+//import org.springframework.stereotype.Service;
+//import org.springframework.transaction.annotation.Transactional;
+//
+//import javax.annotation.PostConstruct;
+//import javax.annotation.PreDestroy;
+//import java.time.LocalDate;
+//import java.time.LocalDateTime;
+//import java.time.LocalTime;
+//import java.time.ZoneId;
+//import java.time.format.DateTimeFormatter;
+//import java.time.temporal.ChronoUnit;
+//import java.util.ArrayList;
+//import java.util.Date;
+//import java.util.List;
+//import java.util.Map;
+//import java.util.concurrent.*;
+//import java.util.stream.Collectors;
+//
+//import static com.fs.course.utils.LinkUtil.generateRandomStringWithLock;
+//
+//@Service
+//@Slf4j
+//public class SopLogsTestServiceImpl implements SopLogsTestService {
+//
+//
+//    private static final String REAL_LINK_PREFIX = "https://h5api.ylrzcloud.com/courseh5/pages/course/learning?course=";
+//    private static final String QWSOP_KEY_PREFIX = "qwsop:";
+//    private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+//    private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+//
+//    // Cached configurations and domain names
+//    private CourseConfig cachedCourseConfig;
+//    private final Object configLock = new Object();
+//
+//    private List<FsCourseDomainName> cachedDomainNames;
+//    private final Object domainLock = new Object();
+//
+//
+//    // Batch size for database inserts, configurable via application.properties
+//    private final int BATCH_SIZE = 1000;
+//
+//    @Autowired
+//    private SopUserLogsMapper sopUserLogsMapper;
+//
+//    @Autowired
+//    private QwSopMapper sopMapper;
+//
+//    @Autowired
+//    private FsCourseWatchLogMapper fsCourseWatchLogMapper;
+//
+//    @Autowired
+//    private IQwSopLogsService qwSopLogsService;
+//
+//    @Autowired
+//    private QwSopLogsMapper qwSopLogsMapper;
+//
+//    @Autowired
+//    private FsCourseLinkMapper fsCourseLinkMapper;
+//    @Autowired
+//    private ISysConfigService configService;
+//
+//    @Autowired
+//    private FsCourseDomainNameMapper fsCourseDomainNameMapper;
+//
+//    @Autowired
+//    private SopUserLogsInfoMapper sopUserLogsInfoMapper;
+//    @Autowired
+//    private QwUserMapper qwUserMapper;
+//
+//    // Blocking queues with bounded capacity to implement backpressure
+//    private final BlockingQueue<QwSopLogs> qwSopLogsQueue = new LinkedBlockingQueue<>(10000);
+//    private final BlockingQueue<FsCourseWatchLog> watchLogsQueue = new LinkedBlockingQueue<>(10000);
+//    private final BlockingQueue<FsCourseLink> linkQueue = new LinkedBlockingQueue<>(10000);
+//
+//    // Executors for consumer threads
+//    private ExecutorService qwSopLogsExecutor;
+//    private ExecutorService watchLogsExecutor;
+//    private ExecutorService courseLinkExecutor;
+//
+//    // Shutdown flags
+//    private volatile boolean running = true;
+//
+//
+//    @PostConstruct
+//    public void init() {
+//        loadCourseConfig();
+//        loadDomainNames();
+//        startConsumers();
+//    }
+//
+//    private void loadCourseConfig() {
+//        try {
+//            String json = configService.selectConfigByKey("course.config");
+//            CourseConfig config = JSON.parseObject(json, CourseConfig.class);
+//            if (config != null) {
+//                cachedCourseConfig = config;
+//                log.info("Loaded course.config successfully.");
+//            } else {
+//                log.error("Failed to load course.config from configService.");
+//            }
+//        } catch (Exception e) {
+//            log.error("Exception while loading course.config: {}", e.getMessage(), e);
+//        }
+//    }
+//
+//    private void loadDomainNames() {
+//        try {
+//            cachedDomainNames = fsCourseDomainNameMapper.selectAllDomainNames();
+//            log.info("Loaded {} domain names for short links.", cachedDomainNames.size());
+//        } catch (Exception e) {
+//            log.error("Failed to load domain names: {}", e.getMessage(), e);
+//        }
+//    }
+//
+//
+//    private void startConsumers() {
+//        qwSopLogsExecutor = Executors.newSingleThreadExecutor(r -> {
+//            Thread t = new Thread(r, "QwSopLogsConsumer");
+//            t.setDaemon(true);
+//            return t;
+//        });
+//        watchLogsExecutor = Executors.newSingleThreadExecutor(r -> {
+//            Thread t = new Thread(r, "WatchLogsConsumer");
+//            t.setDaemon(true);
+//            return t;
+//        });
+//        courseLinkExecutor = Executors.newSingleThreadExecutor(r -> {
+//            Thread t = new Thread(r, "courseLinkConsumer");
+//            t.setDaemon(true);
+//            return t;
+//        });
+//
+//        qwSopLogsExecutor.submit(this::consumeQwSopLogs);
+//        watchLogsExecutor.submit(this::consumeWatchLogs);
+//        courseLinkExecutor.submit(this::consumeCourseLink);
+//    }
+//
+//    // Scheduled tasks to refresh configurations and domain names periodically
+//    @Scheduled(fixedDelay = 60000) // 每60秒刷新一次
+//    public void refreshCourseConfig() {
+//        synchronized(configLock) {
+//            try {
+//                String json = configService.selectConfigByKey("course.config");
+//                CourseConfig newConfig = JSON.parseObject(json, CourseConfig.class);
+//                if (newConfig != null) {
+//                    cachedCourseConfig = newConfig;
+//                    log.info("Refreshed course.config.");
+//                } else {
+//                    log.error("Failed to refresh course.config.");
+//                }
+//            } catch (Exception e) {
+//                log.error("Exception while refreshing course.config: {}", e.getMessage(), e);
+//            }
+//        }
+//    }
+//
+//    @Scheduled(fixedDelay = 60000) // 每60秒刷新一次
+//    public void refreshDomainNames() {
+//        synchronized(domainLock) {
+//            try {
+//                cachedDomainNames = fsCourseDomainNameMapper.selectAllDomainNames();
+//                log.info("Refreshed {} domain names for short links.", cachedDomainNames.size());
+//            } catch (Exception e) {
+//                log.error("Failed to refresh domain names: {}", e.getMessage(), e);
+//            }
+//        }
+//    }
+//
+//
+//    @PreDestroy
+//    public void shutdownConsumers() {
+//        running = false;
+//        qwSopLogsExecutor.shutdown();
+//        watchLogsExecutor.shutdown();
+//        courseLinkExecutor.shutdown();
+//        try {
+//            if (!qwSopLogsExecutor.awaitTermination(60, TimeUnit.SECONDS)) {
+//                qwSopLogsExecutor.shutdownNow();
+//            }
+//            if (!watchLogsExecutor.awaitTermination(60, TimeUnit.SECONDS)) {
+//                watchLogsExecutor.shutdownNow();
+//            }
+//            if (!courseLinkExecutor.awaitTermination(60, TimeUnit.SECONDS)) {
+//                courseLinkExecutor.shutdownNow();
+//            }
+//        } catch (InterruptedException e) {
+//            qwSopLogsExecutor.shutdownNow();
+//            watchLogsExecutor.shutdownNow();
+//            courseLinkExecutor.shutdownNow();
+//            Thread.currentThread().interrupt();
+//        }
+//    }
+//
+//    @Override
+//    public void selectSopUserLogsListByTest() throws Exception {
+//        long startTimeMillis = System.currentTimeMillis();
+//        log.info("====== 开始选择和处理 SOP 用户日志 ======");
+//
+//        List<SopUserLogsVo> sopUserLogsVos = sopUserLogsMapper.selectSopUserLogsListByTest();
+//        if (sopUserLogsVos.isEmpty()) {
+//            log.info("没有需要处理的 SOP 用户日志。");
+//            return;
+//        }
+//
+//        Map<String, List<SopUserLogsVo>> sopLogsGroupedById = sopUserLogsVos.stream()
+//                .collect(Collectors.groupingBy(SopUserLogsVo::getSopId));
+//
+//        log.info("共分组 {} 个 SOP ID 进行处理。", sopLogsGroupedById.size());
+//
+//        CountDownLatch sopGroupLatch = new CountDownLatch(sopLogsGroupedById.size());
+//
+//        for (Map.Entry<String, List<SopUserLogsVo>> entry : sopLogsGroupedById.entrySet()) {
+//            String sopId = entry.getKey();
+//            List<SopUserLogsVo> userLogsVos = entry.getValue();
+//            processSopGroupAsync(sopId, userLogsVos, sopGroupLatch);
+//        }
+//
+//        // 等待所有 SOP 分组处理完成
+//        sopGroupLatch.await();
+//
+//        // 触发批量插入(可选,如果需要立即插入队列中的数据)
+//        // batchInsertQwSopLogs();
+//        // batchInsertFsCourseWatchLogs();
+//
+//        long endTimeMillis = System.currentTimeMillis();
+//        log.info("====== SOP 用户日志处理完成,耗时 {} 毫秒 ======", (endTimeMillis - startTimeMillis));
+//    }
+//
+//    @Async("sopTaskExecutor")
+//    @Retryable(
+//            value = { Exception.class },
+//            maxAttempts = 3,
+//            backoff = @Backoff(delay = 2000)
+//    )
+//    public void processSopGroupAsync(String sopId, List<SopUserLogsVo> userLogsVos, CountDownLatch latch) {
+//        try {
+//            processSopGroup(sopId, userLogsVos);
+//        } catch (Exception e) {
+//            log.error("处理 SOP ID {} 时发生异常: {}", sopId, e.getMessage(), e);
+//        } finally {
+//            latch.countDown();
+//        }
+//    }
+//
+//
+//    private void processSopGroup(String sopId, List<SopUserLogsVo> userLogsVos) throws Exception {
+//        QwSopRuleTimeVO ruleTimeVO = sopMapper.selectQwSopByClickHouseId(sopId);
+//
+//        if (ruleTimeVO == null) {
+////            sopUserLogsMapper.deleteSopUserLogsBySopId(sopId);
+//            log.info("SOP ID {} 模板已删除或不存在,相关日志已清除。", sopId);
+//            return;
+//        }
+//
+//        if (ruleTimeVO.getStatus() == 0 || "0".equals(ruleTimeVO.getTempStatus())) {
+//            SopUserLogs sopUserLogs = new SopUserLogs();
+//            sopUserLogs.setSopId(sopId);
+//            sopUserLogs.setStatus(2);
+//            sopUserLogsMapper.updateSopUserLogsByStatus(sopUserLogs);
+//            log.info("SOP ID {} 的状态为停用,相关日志状态已更新。", sopId);
+//            return;
+//        }
+//
+//        if (ruleTimeVO.getTempSetting()==null) {
+//            log.error("SOP ID {} 的 TempSetting 为空,跳过处理。", sopId);
+//            return;
+//        }
+//
+//        //解析模板
+//        String jsonData = ruleTimeVO.getTempSetting();
+//        List<QwSopTempSetting> tempSettings = new ArrayList<>();
+//        if (jsonData.startsWith("[") && jsonData.endsWith("]")) {
+//            // 直接解析 JSON 数组
+//            tempSettings = JSONArray.parseArray(jsonData, QwSopTempSetting.class);
+//        } else {
+//            // 先解包,再解析
+//            String fixedJson = JSON.parseObject(jsonData, String.class);
+//            tempSettings = JSONArray.parseArray(fixedJson, QwSopTempSetting.class);
+//        }
+//
+////        String jsonData = finishTemp.getSetting();
+////
+////        List<QwSopTempSetting> tempSettings = JSON.parseArray(ruleTimeVO.getTempSetting(), QwSopTempSetting.class);
+//        if (tempSettings.isEmpty()) {
+//            log.error("SOP ID {} 的 TempSetting 为空,跳过处理。", sopId);
+//            return;
+//        }
+//
+//        CountDownLatch userLogsLatch = new CountDownLatch(userLogsVos.size());
+//        for (SopUserLogsVo logVo : userLogsVos) {
+//            processUserLogAsync(logVo, ruleTimeVO, tempSettings, userLogsLatch);
+//        }
+//
+//        // 等待所有用户日志处理完成
+//        try {
+//            userLogsLatch.await();
+//        } catch (InterruptedException e) {
+//            Thread.currentThread().interrupt();
+//            log.error("等待用户日志处理完成时被中断: {}", e.getMessage(), e);
+//        }
+//        log.info("SOP ID {} 的所有用户日志已处理完毕。", sopId);
+//    }
+//
+//    @Async("sopTaskExecutor")
+//    @Retryable(
+//            value = { Exception.class },
+//            maxAttempts = 3,
+//            backoff = @Backoff(delay = 2000)
+//    )
+//    public void processUserLogAsync(SopUserLogsVo logVo, QwSopRuleTimeVO ruleTimeVO, List<QwSopTempSetting> tempSettings, CountDownLatch latch) {
+//        try {
+//            processUserLog(logVo, ruleTimeVO, tempSettings);
+//        } catch (Exception e) {
+//            log.error("处理用户日志 {} 时发生异常: {}", logVo.getId(), e.getMessage(), e);
+//        } finally {
+//            latch.countDown();
+//        }
+//    }
+//
+//
+//    private void processUserLog(SopUserLogsVo logVo, QwSopRuleTimeVO ruleTimeVO, List<QwSopTempSetting> tempSettings) {
+//        try {
+//            LocalDate startDate = LocalDate.parse(logVo.getStartTime(), DATE_FORMATTER);
+//            LocalDate currentDate = LocalDate.now();
+////            LocalDate currentDate = LocalDate.parse("2024-12-25", DateTimeFormatter.ofPattern("yyyy-MM-dd"));
+//
+//            long daysBetween = ChronoUnit.DAYS.between(startDate, currentDate);
+//            int tempGap = ruleTimeVO.getTempGap();
+//
+//            if (tempGap <= 0) {
+//                log.error("SOP ID {} 的 TempGap {} 无效,跳过处理。", logVo.getSopId(), tempGap);
+//                return;
+//            }
+//
+//            int intervalDay = (int) (daysBetween / tempGap);
+//            if (intervalDay < 0 || intervalDay >= tempSettings.size()) {
+//                log.info("用户日志 {} 的 intervalDay {} 超出 TempSettings 范围,跳过处理。", logVo.getId(), intervalDay);
+//                return;
+//            }
+//
+//            QwSopTempSetting selectedSetting = tempSettings.get(intervalDay);
+//            List<QwSopTempSetting.Content> contents = selectedSetting.getContent();
+//            if (contents == null || contents.isEmpty()) {
+//                log.error("SOP ID {} 的 TempSetting 内容为空,跳过处理。", logVo.getSopId());
+//                return;
+//            }
+//
+//
+//            //寻找时间
+////            LocalDateTime currentTime = LocalDateTime.now();
+//            LocalDateTime currentTime = LocalDateTime.of(2025, 2, 11,8 , 0);
+//
+//            // 先算好 60分钟后 ~ 再60分钟后的时间段
+//            LocalDateTime startRangeFirst = currentTime.plusMinutes(60);
+//
+//            // 如果发现已经跨天
+//            if (!startRangeFirst.toLocalDate().equals(currentDate)) {
+//                // 更新 currentDate
+//                currentDate = startRangeFirst.toLocalDate();
+//
+//                // 重新计算 daysBetween
+//                daysBetween = ChronoUnit.DAYS.between(startDate, currentDate);
+//                intervalDay = (int) (daysBetween / tempGap);
+//
+//                // 再次验证 intervalDay 是否在范围内
+//                if (intervalDay < 0 || intervalDay >= tempSettings.size()) {
+//                    log.info("跨天后,intervalDay={} 超出 TempSettings 范围,跳过。", intervalDay);
+//                    return;
+//                }
+//
+//                if (daysBetween % tempGap != 0) {
+//                    log.error("天数差 {} 不是 tempGap {} 的整数倍,跳过操作,SopId {} ", daysBetween, tempGap,logVo.getSopId());
+//                    return;
+//                }
+//
+//                // 重新拿新的 “天” 的 Setting
+//                selectedSetting = tempSettings.get(intervalDay);
+//                contents = selectedSetting.getContent();
+//                if (contents == null || contents.isEmpty()) {
+//                    log.error("跨天-SOP ID {} 的 TempSetting 内容为空,跳过处理。", logVo.getSopId());
+//                    return;
+//                }
+//            }
+//
+//
+//            // 只有整倍数才做事
+//            if (daysBetween % tempGap != 0) {
+//                log.error("天数差 {} 不是 tempGap {} 的整数倍,跳过操作,SopId {} ", daysBetween, tempGap,logVo.getSopId());
+//                return;
+//            }
+//
+//
+//            for (QwSopTempSetting.Content content : contents) {
+//                try {
+//
+//                    LocalTime elementLocalTime = LocalTime.parse(content.getTime());
+//                    LocalDateTime elementDateTime = LocalDateTime.of(currentTime.toLocalDate(), elementLocalTime);
+//
+//                    // 动态调整 elementDateTime 的日期
+//                    if (elementLocalTime.isBefore(currentTime.toLocalTime())) {
+//                        elementDateTime = elementDateTime.plusDays(1);
+//                    }
+//
+//                    LocalDateTime startRange = currentTime.plusMinutes(60);
+//                    LocalDateTime endRange = startRange.plusMinutes(60);
+//
+//                    // 跨天逻辑修正:仅当 startRange 的时间晚于 endRange 的时间时调整
+//                    if (startRange.toLocalTime().isAfter(endRange.toLocalTime())
+//                            && startRange.toLocalDate().equals(endRange.toLocalDate())) {
+//                        endRange = endRange.plusDays(1); // 将 endRange 调整为第二天
+//                    }
+//                    if (!elementDateTime.isBefore(startRange) && elementDateTime.isBefore(endRange)) {
+//
+//                        // 如果时间差在目标范围内,更新记录
+//                        // 组合年月日和element的时间
+//                        LocalDate targetDate = startDate.plusDays(intervalDay * tempGap);
+//
+//                        // 将 targetDate 和 elementTime 组合成 LocalDateTime
+//                        LocalDateTime dateTime = LocalDateTime.of(targetDate, elementLocalTime);
+//
+//                        // 将 LocalDateTime 转换为 Date
+//                        Date sendTime = Date.from(dateTime.atZone(ZoneId.systemDefault()).toInstant());
+//
+//                        SopUserLogsInfo userLogsInfo=new SopUserLogsInfo();
+//                        userLogsInfo.setSopId(logVo.getSopId());
+//                        userLogsInfo.setUserLogsId(logVo.getId());
+//
+//                        List<SopUserLogsInfo> sopUserLogsInfos = sopUserLogsInfoMapper.selectSopUserLogsInfoList(userLogsInfo);
+//
+//                        insertSopUserLogs(sopUserLogsInfos, logVo, sendTime, ruleTimeVO, content);
+//
+//                    }
+//                } catch (Exception e) {
+//                    log.error("解析模板内容 {} 失败: {}", content.getTime(), e.getMessage(), e);
+//                }
+//            }
+//
+//        } catch (Exception e) {
+//            log.error("解析解析模板 {} 失败: {}", logVo.getStartTime(), e.getMessage(), e);
+//        }
+//    }
+//
+//
+//
+//
+//    private void insertSopUserLogs(List<SopUserLogsInfo> sopUserLogsInfos, SopUserLogsVo logVo, Date sendTime,
+//                                   QwSopRuleTimeVO ruleTimeVO, QwSopTempSetting.Content content) {
+//        String formattedSendTime = sendTime.toInstant()
+//                .atZone(ZoneId.systemDefault())
+//                .format(DATE_TIME_FORMATTER);
+//        int type = content.getType();
+//        Integer courseId = content.getCourseId();
+//        Integer videoId = content.getVideoId();
+//
+//        String[] userKey = logVo.getUserId().split("\\|");
+//        if (userKey.length < 3) {
+//            log.error("用户 ID {} 格式不正确,跳过处理。", logVo.getUserId());
+//            return;
+//        }
+//        String qwUserId = userKey[0].trim();
+//        String companyUserId = userKey[1].trim();
+//        String companyId = userKey[2].trim();
+//
+//        //生成语音
+//        List<QwSopTempSetting.Content.Setting> setting = content.getSetting();
+//        for (QwSopTempSetting.Content.Setting st : setting) {
+//            if (st.getContentType().equals("7")){
+//                try {
+//                    AudioVO audioVO = AudioUtils.transferAudioSilkFromText(st.getValue(), Long.valueOf(companyUserId), false);
+//                    st.setVoiceUrl(audioVO.getUrl());
+//                    st.setVoiceDuration(audioVO.getDuration()+"");
+//                }catch (Exception e){
+//                    log.info("音频生成失败-: "+companyUserId);
+//                }
+//            }
+//        }
+//
+//        // 处理每个 externalContactId
+//        sopUserLogsInfos.forEach(contactId -> {
+//            try {
+//                String externalId = contactId.getExternalId().toString();
+//                String externalUserName = contactId.getExternalUserName();
+//                Long fsUserId = contactId.getFsUserId();
+//                QwSopLogs sopLogs = createBaseLog(formattedSendTime, logVo, ruleTimeVO, contactId.getExternalContactId(), externalUserName, fsUserId);
+//                handleLogBasedOnType(sopLogs, content, logVo, sendTime, courseId, videoId,
+//                        type, qwUserId, companyUserId, companyId, externalId);
+//            } catch (Exception e) {
+//                log.error("处理 externalContactId {} 时发生异常: {}", contactId, e.getMessage(), e);
+//            }
+//        });
+//    }
+//
+//
+//
+//    private QwSopLogs createBaseLog(String formattedSendTime, SopUserLogsVo logVo,
+//                                    QwSopRuleTimeVO ruleTimeVO, String externalId,
+//                                    String externalUserName, Long fsUserId) {
+//        QwSopLogs sopLogs = new QwSopLogs();
+//        sopLogs.setSendTime(formattedSendTime);
+//        sopLogs.setQwUserid(logVo.getQwUserId());
+//        sopLogs.setCorpId(logVo.getCorpId());
+//        sopLogs.setLogType(ruleTimeVO.getType());
+//        sopLogs.setSendType(ruleTimeVO.getSendType());
+//        sopLogs.setSendStatus(3L);
+//        sopLogs.setReceivingStatus(0L);
+//
+//        String[] userKey = logVo.getUserId().split("\\|");
+//        sopLogs.setCompanyId(Long.valueOf(userKey[2].trim()));
+//        sopLogs.setSopId(logVo.getSopId());
+//
+//        sopLogs.setExternalUserId(externalId);
+//        sopLogs.setExternalUserName(externalUserName);
+//        sopLogs.setFsUserId(fsUserId);
+//
+//        return sopLogs;
+//    }
+//
+//    private void handleLogBasedOnType(QwSopLogs sopLogs, QwSopTempSetting.Content content,
+//                                      SopUserLogsVo logVo, Date sendTime, Integer courseId,
+//                                      Integer videoId, int type, String qwUserId,
+//                                      String companyUserId, String companyId, String externalId) {
+//        switch (type) {
+//            case 1:
+//                handleNormalMessage(sopLogs, content,companyUserId);
+//                break;
+//            case 2:
+//                handleCourseMessage(sopLogs, content, logVo, sendTime, courseId, videoId,
+//                        qwUserId, companyUserId, companyId, externalId);
+//                break;
+//            case 3:
+//                handleOrderMessage(sopLogs, content);
+//                break;
+//            case 4:
+//                handleAIMessage(sopLogs, content);
+//                break;
+//            default:
+//                log.error("未知的消息类型 {},跳过处理。", type);
+//                break;
+//        }
+//    }
+//
+//    private void handleNormalMessage(QwSopLogs sopLogs, QwSopTempSetting.Content content,String companyUserId) {
+//
+//        sopLogs.setContentJson(JSON.toJSONString(content));
+//        enqueueQwSopLogs(sopLogs);
+//    }
+//
+//    private void handleAIMessage(QwSopLogs sopLogs, QwSopTempSetting.Content content) {
+//        sopLogs.setContentJson(JSON.toJSONString(content));
+//        sopLogs.setSort(3);
+//        enqueueQwSopLogs(sopLogs);
+//    }
+//
+//    private void handleCourseMessage(QwSopLogs sopLogs, QwSopTempSetting.Content content,
+//                                     SopUserLogsVo logVo, Date sendTime, Integer courseId,
+//                                     Integer videoId, String qwUserId, String companyUserId,
+//                                     String companyId, String externalId) {
+//        // 深拷贝 Content 对象,避免使用 JSON
+//        QwSopTempSetting.Content clonedContent = deepCopyContent(content);
+//        if (clonedContent == null) {
+//            log.error("Failed to clone content, skipping handleCourseMessage.");
+//            return;
+//        }
+//
+////
+////        Integer courseType = clonedContent.getCourseType();
+//
+//        List<QwSopTempSetting.Content.Setting> settings = clonedContent.getSetting();
+//        if (settings == null || settings.isEmpty()) {
+//            log.error("Cloned content settings are empty, skipping.");
+//            return;
+//        }
+//
+//
+//        // 顺序处理每个 Setting,避免过多的并行导致线程开销
+//        for (QwSopTempSetting.Content.Setting setting : settings) {
+//            if ("1".equals(setting.getIsBindUrl())&&("3".equals(setting.getContentType())||"1".equals(setting.getContentType()))) {
+//                addWatchLogIfNeeded(sopLogs, videoId, courseId, sendTime, qwUserId, companyUserId, companyId, externalId,logVo);
+//                String sortLink = generateShortLink(setting, logVo, sendTime, courseId, videoId,
+//                        qwUserId, companyUserId, companyId, externalId);
+//                if (StringUtils.isNotEmpty(sortLink)) {
+//                    if ("3".equals(setting.getContentType())) {
+//                        setting.setLinkUrl(sortLink);
+//                    } else {
+//                        String currentValue = setting.getValue();
+//                        if (currentValue == null) {
+//                            setting.setValue(sortLink);
+//                        } else {
+//                            setting.setValue(currentValue + "\n" + sortLink);
+//                        }
+//                    }
+//                } else {
+//                    log.error("生成短链失败,跳过设置 URL。");
+//                }
+//            }
+//        }
+//        sopLogs.setContentJson(JSON.toJSONString(clonedContent));
+//        enqueueQwSopLogs(sopLogs);
+//    }
+//
+//    /**
+//     * 深拷贝 Content 对象,避免使用 JSON 进行序列化和反序列化
+//     */
+//    private QwSopTempSetting.Content deepCopyContent(QwSopTempSetting.Content content) {
+//        if (content == null) {
+//            return null;
+//        }
+//        return content.clone();
+//    }
+//
+//    private void handleOrderMessage(QwSopLogs sopLogs, QwSopTempSetting.Content content) {
+//        sopLogs.setContentJson(JSON.toJSONString(content));
+//        enqueueQwSopLogs(sopLogs);
+//    }
+//
+//
+//
+//
+//    private String generateShortLink(QwSopTempSetting.Content.Setting setting, SopUserLogsVo logVo, Date sendTime,
+//                                Integer courseId, Integer videoId, String qwUserId,
+//                                String companyUserId, String companyId, String externalId) {
+//        // 获取缓存的配置
+//        CourseConfig config;
+//        synchronized(configLock) {
+//            config = cachedCourseConfig;
+//        }
+//
+//        if (config == null) {
+//            log.error("CourseConfig is not loaded.");
+//            return "";
+//        }
+//
+//        // 手动创建 FsCourseLink 对象,避免使用 BeanUtils.copyProperties
+//        FsCourseLink link = new FsCourseLink();
+//        link.setCompanyId(Long.parseLong(companyId));
+//        link.setQwUserId(qwUserId);
+//        link.setCompanyUserId(Long.parseLong(companyUserId));
+//        link.setVideoId(videoId.longValue());
+//        link.setCorpId(logVo.getCorpId());
+//        link.setCourseId(courseId.longValue());
+//        link.setQwExternalId(Long.parseLong(externalId));
+//        link.setLinkType(0); //正常链接
+//
+//        FsCourseRealLink courseMap = new FsCourseRealLink();
+//        courseMap.setCompanyId(link.getCompanyId());
+//        courseMap.setQwUserId(link.getQwUserId());
+//        courseMap.setCompanyUserId(link.getCompanyUserId());
+//        courseMap.setVideoId(link.getVideoId());
+//        courseMap.setCorpId(link.getCorpId());
+//        courseMap.setCourseId(link.getCourseId());
+//        courseMap.setQwExternalId(link.getQwExternalId());
+//        courseMap.setLinkType(0);
+//
+//        String courseJson = JSON.toJSONString(courseMap);
+//        String realLinkFull = REAL_LINK_PREFIX + courseJson;
+//        link.setRealLink(realLinkFull);
+//
+//        String randomString = generateRandomStringWithLock();
+//        link.setLink(randomString);
+//        link.setCreateTime(sendTime);
+//
+//        Integer expireDays = (setting.getExpiresDays() == null || setting.getExpiresDays() == 0)
+//                ? config.getVideoLinkExpireDate()
+//                : setting.getExpiresDays();
+//
+//        // 使用 Java 8 时间 API 计算过期时间
+//        LocalDateTime sendDateTime = sendTime.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
+//        LocalDateTime expireDateTime = sendDateTime.plusDays(expireDays-1);
+//        expireDateTime = expireDateTime.toLocalDate().atTime(23, 59, 59);
+//        Date updateTime = Date.from(expireDateTime.atZone(ZoneId.systemDefault()).toInstant());
+//        link.setUpdateTime(updateTime);
+//
+//        // 从缓存中随机选择一个域名
+//        FsCourseDomainName fsCourseDomainName;
+//        if (cachedDomainNames == null || cachedDomainNames.isEmpty()) {
+//            log.error("No domain names available for short link generation.");
+//            return "";
+//        }
+//
+//        int randomIndex = ThreadLocalRandom.current().nextInt(cachedDomainNames.size());
+//        fsCourseDomainName = cachedDomainNames.get(randomIndex);
+//
+//        String sortLink = "https://" + fsCourseDomainName.getDomainName() + "/s/" + link.getLink();
+//        enqueueCourseLink(link);
+//        return sortLink;
+//    }
+//
+//
+//    private void addWatchLogIfNeeded(QwSopLogs sopLogs, Integer videoId, Integer courseId,
+//                                     Date sendTime, String qwUserId, String companyUserId,
+//                                     String companyId, String externalId,SopUserLogsVo logsVo) {
+//        FsCourseWatchLog watchLog = new FsCourseWatchLog();
+//        watchLog.setVideoId(videoId != null ? videoId.longValue() : null);
+//        watchLog.setQwExternalContactId(externalId != null ? Long.valueOf(externalId) : null);
+//        watchLog.setSendType(2);
+//        watchLog.setQwUserId(qwUserId);
+//        watchLog.setSopId(sopLogs.getSopId());
+//        watchLog.setDuration(0L);
+//        watchLog.setCourseId(courseId != null ? courseId.longValue() : null);
+//        watchLog.setCompanyUserId(companyUserId != null ? Long.valueOf(companyUserId) : null);
+//        watchLog.setCompanyId(companyId != null ? Long.valueOf(companyId) : null);
+//        watchLog.setCreateTime(convertStringToDate(sopLogs.getSendTime(),"yyyy-MM-dd HH:mm:ss"));
+//        watchLog.setUpdateTime(new Date());
+//        watchLog.setLogType(3);
+//        watchLog.setUserId(sopLogs.getFsUserId());
+//        watchLog.setCampPeriodTime(convertStringToDate(logsVo.getStartTime(),"yyyy-MM-dd"));
+//        enqueueWatchLog(watchLog);
+//    }
+//
+//    /**
+//     * 时间字符串转Date时间
+//     * @param dateString
+//     * @return
+//     */
+//    public static Date convertStringToDate(String dateString,String pattern) {
+//        if (dateString == null || dateString.isEmpty()) {
+//            return null;
+//        }
+//        DateTimeFormatter formatter = DateTimeFormatter.ofPattern(pattern);
+//        LocalDateTime localDateTime;
+//        LocalDate localDate;
+//        // 先解析成 LocalDate(只含年月日)
+//        if (pattern.equals("yyyy-MM-dd")){
+//            // 先解析成 LocalDate(只含年月日)
+//            localDate = LocalDate.parse(dateString, formatter);
+//            // 将 LocalDate 转为当天 00:00:00 的 LocalDateTime
+//            localDateTime = localDate.atStartOfDay();
+//        }else {
+//            localDateTime = LocalDateTime.parse(dateString, formatter);
+//        }
+//        return Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant());
+//    }
+//
+//
+//    /**
+//     * 将 QwSopLogs 放入队列
+//     */
+//    private void enqueueQwSopLogs(QwSopLogs sopLogs) {
+//        try {
+//            boolean offered = qwSopLogsQueue.offer(sopLogs, 5, TimeUnit.SECONDS);
+//            if (!offered) {
+//                log.error("QwSopLogs 队列已满,无法添加日志: {}", JSON.toJSONString(sopLogs));
+//                // 处理队列已满的情况,例如记录到失败队列或持久化存储
+//            }
+//        } catch (InterruptedException e) {
+//            Thread.currentThread().interrupt();
+//            log.error("插入 QwSopLogs 队列时被中断: {}", e.getMessage(), e);
+//        }
+//    }
+//
+//    /**
+//     * 将 FsCourseWatchLog 放入队列
+//     */
+//    private void enqueueWatchLog(FsCourseWatchLog watchLog) {
+//        try {
+//            boolean offered = watchLogsQueue.offer(watchLog, 5, TimeUnit.SECONDS);
+//            if (!offered) {
+//                log.error("FsCourseWatchLog 队列已满,无法添加日志: {}", JSON.toJSONString(watchLog));
+//                // 处理队列已满的情况,例如记录到失败队列或持久化存储
+//            }
+//        } catch (InterruptedException e) {
+//            Thread.currentThread().interrupt();
+//            log.error("插入 FsCourseWatchLog 队列时被中断: {}", e.getMessage(), e);
+//        }
+//    }
+//
+//    /**
+//     * 将 FsCourseWatchLog 放入队列
+//     */
+//    private void enqueueCourseLink(FsCourseLink courseLink) {
+//        try {
+//            boolean offered = linkQueue.offer(courseLink, 5, TimeUnit.SECONDS);
+//            if (!offered) {
+//                log.error("FsCourseLink 队列已满,无法添加日志: {}", JSON.toJSONString(courseLink));
+//                // 处理队列已满的情况,例如记录到失败队列或持久化存储
+//            }
+//        } catch (InterruptedException e) {
+//            Thread.currentThread().interrupt();
+//            log.error("插入 FsCourseLink 队列时被中断: {}", e.getMessage(), e);
+//        }
+//    }
+//
+//    /**
+//     * 消费 QwSopLogs 队列并进行批量插入
+//     */
+//    private void consumeQwSopLogs() {
+//        List<QwSopLogs> batch = new ArrayList<>(BATCH_SIZE);
+//        while (running || !qwSopLogsQueue.isEmpty()) {
+//            try {
+//                QwSopLogs log = qwSopLogsQueue.poll(1, TimeUnit.SECONDS);
+//                if (log != null) {
+//                    batch.add(log);
+//                }
+//                if (batch.size() >= BATCH_SIZE || (!batch.isEmpty() && log == null)) {
+//                    if (!batch.isEmpty()) {
+//                        batchInsertQwSopLogs(new ArrayList<>(batch));
+//                        batch.clear();
+//                    }
+//                }
+//            } catch (InterruptedException e) {
+//                Thread.currentThread().interrupt();
+//                log.error("QwSopLogs 消费线程被中断: {}", e.getMessage(), e);
+//            }
+//        }
+//
+//        // 处理剩余的数据
+//        if (!batch.isEmpty()) {
+//            batchInsertQwSopLogs(batch);
+//        }
+//    }
+//
+//    /**
+//     * 消费 FsCourseWatchLog 队列并进行批量插入
+//     */
+//    private void consumeCourseLink() {
+//        List<FsCourseLink> batch = new ArrayList<>(BATCH_SIZE);
+//        while (running || !linkQueue.isEmpty()) {
+//            try {
+//                FsCourseLink courseLink = linkQueue.poll(1, TimeUnit.SECONDS);
+//                if (courseLink != null) {
+//                    batch.add(courseLink);
+//                }
+//                if (batch.size() >= BATCH_SIZE || (!batch.isEmpty() && courseLink == null)) {
+//                    if (!batch.isEmpty()) {
+//                        batchInsertFsCourseLink(new ArrayList<>(batch));
+//                        batch.clear();
+//                    }
+//                }
+//            } catch (InterruptedException e) {
+//                Thread.currentThread().interrupt();
+//                log.error("FsCourseLink 消费线程被中断: {}", e.getMessage(), e);
+//            }
+//        }
+//
+//        // 处理剩余的数据
+//        if (!batch.isEmpty()) {
+//            batchInsertFsCourseLink(batch);
+//        }
+//    }
+//
+//    /**
+//     * 消费 FsCourseWatchLog 队列并进行批量插入
+//     */
+//    private void consumeWatchLogs() {
+//        List<FsCourseWatchLog> batch = new ArrayList<>(BATCH_SIZE);
+//        while (running || !watchLogsQueue.isEmpty()) {
+//            try {
+//                FsCourseWatchLog watchLog = watchLogsQueue.poll(1, TimeUnit.SECONDS);
+//                if (watchLog != null) {
+//                    batch.add(watchLog);
+//                }
+//                if (batch.size() >= BATCH_SIZE || (!batch.isEmpty() && watchLog == null)) {
+//                    if (!batch.isEmpty()) {
+//                        batchInsertFsCourseWatchLogs(new ArrayList<>(batch));
+//                        batch.clear();
+//                    }
+//                }
+//            } catch (InterruptedException e) {
+//                Thread.currentThread().interrupt();
+//                log.error("FsCourseWatchLog 消费线程被中断: {}", e.getMessage(), e);
+//            }
+//        }
+//
+//        // 处理剩余的数据
+//        if (!batch.isEmpty()) {
+//            batchInsertFsCourseWatchLogs(batch);
+//        }
+//    }
+//
+//    /**
+//     * 批量插入 QwSopLogs
+//     */
+//    @Transactional
+//    @Retryable(
+//            value = { Exception.class },
+//            maxAttempts = 3,
+//            backoff = @Backoff(delay = 2000)
+//    )
+//    public void batchInsertQwSopLogs(List<QwSopLogs> logsToInsert) {
+//        try {
+//            qwSopLogsService.batchInsertQwSopLogs(logsToInsert);
+//            log.info("批量插入 QwSopLogs 完成,共插入 {} 条记录。", logsToInsert.size());
+//        } catch (Exception e) {
+//            log.error("批量插入 QwSopLogs 失败: {}", e.getMessage(), e);
+//            // 可选:将失败的数据记录到失败队列或持久化存储以便后续重试
+//        }
+//    }
+//
+//    /**
+//     * 批量插入 FsCourseWatchLog
+//     */
+//    @Transactional
+//    @Retryable(
+//            value = { Exception.class },
+//            maxAttempts = 3,
+//            backoff = @Backoff(delay = 2000)
+//    )
+//    public void batchInsertFsCourseWatchLogs(List<FsCourseWatchLog> watchLogsToInsert) {
+//        try {
+//            fsCourseWatchLogMapper.insertFsCourseWatchLogBatch(watchLogsToInsert);
+//            log.info("批量插入 FsCourseWatchLog 完成,共插入 {} 条记录。", watchLogsToInsert.size());
+//        } catch (Exception e) {
+//            log.error("批量插入 FsCourseWatchLog 失败: {}", e.getMessage(), e);
+//            // 可选:将失败的数据记录到失败队列或持久化存储以便后续重试
+//        }
+//    }
+//
+//
+//    /**
+//     * 批量插入 FsCourseLink
+//     */
+//    @Transactional
+//    @Retryable(
+//            value = { Exception.class },
+//            maxAttempts = 3,
+//            backoff = @Backoff(delay = 2000)
+//    )
+//    public void batchInsertFsCourseLink(List<FsCourseLink> courseLinkToInsert) {
+//        try {
+//            fsCourseLinkMapper.insertFsCourseLinkBatch(courseLinkToInsert);
+//            log.info("批量插入 FsCourseLink 完成,共插入 {} 条记录。", courseLinkToInsert.size());
+//        } catch (Exception e) {
+//            log.error("批量插入 FsCourseLink 失败: {}", e.getMessage(), e);
+//            // 可选:将失败的数据记录到失败队列或持久化存储以便后续重试
+//        }
+//    }
+//
+//}

+ 262 - 0
fs-wx-task/src/main/java/com/fs/app/taskService/impl/SopUserLogsInfoByIsDaysNotStudyImpl.java

@@ -0,0 +1,262 @@
+package com.fs.app.taskService.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.app.taskService.SopUserLogsInfoByIsDaysNotStudy;
+import com.fs.course.mapper.FsCourseWatchLogMapper;
+import com.fs.qw.domain.QwExternalContact;
+import com.fs.qw.mapper.QwExternalContactMapper;
+import com.fs.sop.domain.SopUserLogs;
+import com.fs.sop.domain.SopUserLogsInfo;
+import com.fs.sop.params.QwRatingConfig;
+import com.fs.sop.service.ISopUserLogsInfoService;
+import com.fs.sop.service.ISopUserLogsService;
+import com.fs.system.service.ISysConfigService;
+import com.fs.voice.utils.StringUtil;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.PostConstruct;
+import javax.annotation.PreDestroy;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.*;
+import java.util.stream.Collectors;
+
+@Service
+@Slf4j
+public class SopUserLogsInfoByIsDaysNotStudyImpl implements SopUserLogsInfoByIsDaysNotStudy {
+
+
+    @Autowired
+    private FsCourseWatchLogMapper fsCourseWatchLogMapper;
+
+    @Autowired
+    private ISysConfigService configService;
+
+    @Autowired
+    private QwExternalContactMapper qwExternalContactMapper;
+
+    @Autowired
+    private ISopUserLogsInfoService iSopUserLogsInfoService;
+
+    @Autowired
+    private ISopUserLogsService iSopUserLogsService;
+
+    @Autowired
+    private ExecutorService sopRatingExecutor;  // 自定义线程池
+
+    // 任务队列
+    private final BlockingQueue<SopUserLogs> taskQueue = new LinkedBlockingQueue<>(10000);
+
+    private volatile boolean running = true;
+    //批量更新队列
+    private final List<CompletableFuture<Void>> updateFutures = Collections.synchronizedList(new ArrayList<>());
+
+    private final Object configLock = new Object();
+
+
+    private  volatile QwRatingConfig qwRatingConfig;
+
+    // 启动时初始化消费者线程
+    @PostConstruct
+    public void init() {
+
+        loadCourseConfig();
+
+        int consumerCount = Runtime.getRuntime().availableProcessors(); // 消费者线程数,默认 CPU 核心数
+        for (int i = 0; i < consumerCount; i++) {
+            sopRatingExecutor.submit(this::consumeTasks); // 提交消费者任务
+        }
+
+    }
+
+    private void loadCourseConfig() {
+        try {
+            String json = configService.selectConfigByKey("qwRating:config");
+            QwRatingConfig config = JSON.parseObject(json, QwRatingConfig.class);
+            if (!StringUtil.strIsNullOrEmpty(json) && config != null) {
+                qwRatingConfig = config;
+                log.info("Loaded qwRating.config successfully.");
+            } else {
+                log.error("Failed to load course.config from configService.");
+            }
+        } catch (Exception e) {
+            log.error("Exception while loading qwRating.config: {}", e.getMessage(), e);
+        }
+    }
+
+
+
+    @Override
+    public void restoreByIsDaysNotStudy() {
+
+        // 分页加载并放入队列
+        int pageSize = 1000;
+        int offset = 0;
+        List<SopUserLogs> sopUserLogs;
+
+        // 获取缓存的配置
+        QwRatingConfig config;
+        synchronized(configLock) {
+            config = qwRatingConfig;
+        }
+
+        do {
+            sopUserLogs = iSopUserLogsService.meetsTherestoreByIsDaysNotStudy(offset, pageSize,config.getNotStudyDays());
+            if (!sopUserLogs.isEmpty()) {
+                sopUserLogs.forEach(item -> {
+                    try {
+                        taskQueue.put(item); // 将任务放入队列
+                    } catch (InterruptedException e) {
+                        log.error("任务放入队列失败,sopId: {}", item.getSopId(), e);
+                        Thread.currentThread().interrupt();
+                    }
+                });
+                offset += pageSize;
+            }
+        } while (!sopUserLogs.isEmpty());
+
+
+        // 等待队列处理完成
+        CompletableFuture.runAsync(() -> {
+            while (!taskQueue.isEmpty()) {
+                try {
+                    Thread.sleep(1000);
+                } catch (InterruptedException e) {
+                    log.error("等待队列处理时中断", e);
+                    Thread.currentThread().interrupt();
+                }
+            }
+        }).join(); // 等待任务完成
+
+    }
+
+    private void consumeTasks() {
+        if (!running && taskQueue.isEmpty()) {
+            log.info("没有评级任务需要处理");
+            return; // 如果队列为空且没有正在运行的线程,则直接返回
+        }
+
+        while (running) {
+            try {
+                SopUserLogs item = taskQueue.poll(1, TimeUnit.SECONDS); // 等待 1 秒
+                if (item != null) {
+                    processRestoreByIsDaysNotStudy(item);
+                }
+            } catch (Exception e) {
+                log.error("消费者线程异常", e);
+            }
+        }
+    }
+
+    private void processRestoreByIsDaysNotStudy(SopUserLogs item) {
+
+        // 获取缓存的配置
+        QwRatingConfig config;
+        synchronized(configLock) {
+            config = qwRatingConfig;
+        }
+
+        List<SopUserLogsInfo> infos = iSopUserLogsInfoService.selectRestoreByIsDaysNotStudy(
+                item.getSopId(), item.getId());
+
+        if (infos == null || infos.isEmpty()) {
+            log.error("当前营期没有E级客户-sopId:{},营期id:{}", item.getSopId(), item.getId());
+            return;
+        }
+
+        List<QwExternalContact> contacts = infos.stream()
+                .map(info -> processUserLog(info, config))
+                .filter(Objects::nonNull)
+                .collect(Collectors.toList());
+
+        if (!contacts.isEmpty()) {
+            batchUpdateQwExternalContact(contacts);
+        }
+    }
+
+    private void batchUpdateQwExternalContact(List<QwExternalContact> contacts) {
+        // 9. 优化分批逻辑
+        int total = contacts.size();
+        for (int i = 0; i < total; i += 300) {
+            List<QwExternalContact> batch = contacts.subList(i, Math.min(i + 300, total));
+
+            CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
+                try {
+                    qwExternalContactMapper.batchUpdateQwExternalByIsDaysNotStudy(batch);
+                    iSopUserLogsInfoService.batchUpdateSopUserLogsInfoByIsDaysNotStudy(batch);
+                } catch (Exception e) {
+                    log.error("批量更新异常, 批次大小: {}", batch.size(), e);
+                }
+            }, sopRatingExecutor);
+
+            updateFutures.add(future);
+        }
+    }
+
+    @PreDestroy
+    public void shutdown() {
+        running = false;  // 标记消费者停止
+        log.info("正在关闭线程池...");
+
+        // **等待任务队列处理完毕**
+        while (!taskQueue.isEmpty()) {
+            try {
+                Thread.sleep(500);
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                log.warn("等待任务队列处理完成时被中断", e);
+            }
+        }
+
+        // **确保所有  的任务完成**
+        log.info("等待所有批量更新任务完成...");
+        CompletableFuture.allOf(updateFutures.toArray(new CompletableFuture[0])).join();
+
+        // 关闭线程池
+        sopRatingExecutor.shutdown();
+        try {
+            if (!sopRatingExecutor.awaitTermination(60, TimeUnit.SECONDS)) {
+                List<Runnable> pendingTasks = sopRatingExecutor.shutdownNow();
+                log.warn("强制关闭线程池,未完成任务数: {}", pendingTasks.size());
+            }
+        } catch (InterruptedException e) {
+            sopRatingExecutor.shutdownNow();
+            Thread.currentThread().interrupt();
+        }
+        log.info("线程池和消费者已完全关闭");
+    }
+
+    /**
+    * 只计算时长
+    */
+    private QwExternalContact processUserLog(SopUserLogsInfo logsInfo, QwRatingConfig config) {
+        try {
+
+            Long externalId = logsInfo.getExternalId();
+            if (externalId == null) {
+                return null;
+            }
+
+            Integer sumDuration = fsCourseWatchLogMapper.selectFsCourseWatchLogByByIsDaysNotStudy(externalId, config.getNotStudyDays());
+
+            if (sumDuration!=null && sumDuration>0) {
+                QwExternalContact externalContact = new QwExternalContact();
+                externalContact.setId(externalId);
+                externalContact.setIsDaysNotStudy(0);
+                return externalContact;
+            }
+
+            return null;
+
+        } catch (Exception e) {
+            log.error("计算用户积分异常,用户:{}", logsInfo, e);
+            return null;
+        }
+    }
+
+
+}

+ 127 - 0
fs-wx-task/src/main/java/com/fs/app/taskService/impl/SopWxLogsServiceImpl.java

@@ -0,0 +1,127 @@
+package com.fs.app.taskService.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.fs.app.taskService.SopWxLogsService;
+import com.fs.common.utils.PubFun;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.date.DateUtil;
+import com.fs.sop.domain.*;
+import com.fs.sop.mapper.QwSopTempMapper;
+import com.fs.sop.service.*;
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.stream.Collectors;
+
+@Service
+@Slf4j
+@AllArgsConstructor
+public class SopWxLogsServiceImpl implements SopWxLogsService {
+
+    private final IQwSopService qwSopService;
+    private final QwSopTempMapper qwSopTempMapper;
+    private final IQwSopTempDayService qwSopTempDayService;
+    private final ISopUserLogsWxService sopUserLogsWxService;
+    private final IQwSopTempRulesService qwSopTempRulesService;
+    private final IQwSopLogsService  qwSopLogsService;
+
+    @Override
+    public void wxSopLogsByTime(LocalDateTime now) throws Exception {
+        long startTimeMillis = System.currentTimeMillis();
+        log.info("====== 开始选择和处理 个微SOP 用户日志 ======");
+
+        List<QwSop> sopList = qwSopService.selectWxSop();
+        if(sopList.isEmpty()){
+            return;
+        }
+        CountDownLatch sopGroupLatch = new CountDownLatch(sopList.size());
+        sopList.forEach(sop -> {
+            processSopGroupAsync(sop, sopGroupLatch, now);
+        });
+        sopGroupLatch.await();
+
+        long endTimeMillis = System.currentTimeMillis();
+        log.info("====== 个微SOP 用户日志处理完成,耗时 {} 毫秒 ======", (endTimeMillis - startTimeMillis));
+    }
+
+    private void processSopGroupAsync(QwSop sop, CountDownLatch latch, LocalDateTime now) {
+        try {
+            processSopGroup(sop, now);
+        } catch (Exception e) {
+            log.error("处理个微 SOP ID {} 时发生异常: {}", sop.getId(), e.getMessage(), e);
+        } finally {
+            latch.countDown();
+        }
+    }
+
+    private void processSopGroup(QwSop sop, LocalDateTime now) {
+        // 提前一个小时生成
+        LocalDateTime start = now.plusHours(1);
+        // 当前小时
+        int hour = start.getHour();
+        // 获取模板
+        QwSopTemp qwSopTemp = qwSopTempMapper.selectQwSopTempById(sop.getTempId());
+        // 如果模板为空
+        if(qwSopTemp == null){
+            log.error("SOP ID:{}没找到模板 {} 跳过生成", sop.getId(), sop.getTempId());
+            return;
+        }
+        // 获取模板下面每一天的数据
+        List<QwSopTempDay> dayList = qwSopTempRulesService.listByTempIdAll(sop.getTempId());
+        // 更具天数分组
+        Map<Long, QwSopTempDay> dayMap = PubFun.listToMapByGroupObject(dayList, e -> e.getDayNum().longValue());
+        // 获取sop下面所有的个微营期数据
+        List<SopUserLogsWx> wxUserList = sopUserLogsWxService.listBySopId(sop.getId());
+        // 循环每一个人的数据,并对数据进行筛选,营期开始时间不能为空,并且要大于当前生成的时间
+        wxUserList.stream().filter(e -> e.getStartTime() != null && !e.getStartTime().isAfter(now.toLocalDate())).forEach(sopUserLogsWx -> {
+            // 计算营期到现在是第几天
+            long until = sopUserLogsWx.getStartTime().until(now.toLocalDate(), ChronoUnit.DAYS) + 1;
+            // 是否存在跨天的现象
+            if(!now.toLocalDate().equals(start.toLocalDate())){
+                until++;
+            }
+            // 根据第几天获取数据
+            QwSopTempDay day = dayMap.get(until);
+            // 筛选规则,获得当前一个小时的所有数据
+            List<QwSopTempRules> rulesList = day.getList().stream().filter(e -> StringUtils.isNotEmpty(e.getTime()) && LocalTime.parse(e.getTime()).getHour() == hour).collect(Collectors.toList());
+            // 创建发送记录
+            List<QwSopLogs> losList = rulesList.stream().map(rules -> {
+                LocalTime time = LocalTime.parse(rules.getTime());
+                return createBaseLog(LocalDateTime.of(start.toLocalDate(), time), sop, sopUserLogsWx, rules);
+            }).collect(Collectors.toList());
+            // 保存记录
+            qwSopLogsService.batchInsertQwSopLogs(losList);
+        });
+
+    }
+
+
+    private QwSopLogs createBaseLog(LocalDateTime sendTime, QwSop sop, SopUserLogsWx wx, QwSopTempRules rules) {
+        QwSopLogs sopLogs = new QwSopLogs();
+        sopLogs.setSendTime(DateUtil.formatLocalDateTime(sendTime));
+        sopLogs.setExpirationTime(DateUtil.formatLocalDateTime(sendTime.plusHours(sop.getExpiryTime())));
+        sopLogs.setQwUserid(wx.getAccountId().toString());
+        sopLogs.setLogType(1);
+        JSONObject jsonObject = JSON.parseObject(JSON.toJSONString(rules));
+        jsonObject.remove("settingList");
+        jsonObject.remove("list");
+        jsonObject.put("setting", rules.getSettingList());
+        sopLogs.setContentJson(jsonObject.toJSONString());
+        sopLogs.setSendType(sop.getSendType());
+        sopLogs.setSendStatus(3L);
+        sopLogs.setReceivingStatus(0L);
+        sopLogs.setSopId(sop.getId());
+        sopLogs.setExternalUserName(wx.getUserWxName());
+        sopLogs.setCorpId(sop.getCorpId());
+        sopLogs.setCompanyId(sop.getCompanyId());
+        return sopLogs;
+    }
+}

+ 78 - 0
fs-wx-task/src/main/java/com/fs/app/taskService/impl/SyncQwExternalContactServiceImpl.java

@@ -0,0 +1,78 @@
+package com.fs.app.taskService.impl;
+
+import com.fs.app.taskService.SyncQwExternalContactService;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.utils.StringUtils;
+import com.fs.qw.domain.QwExternalContact;
+import com.fs.qw.mapper.QwExternalContactMapper;
+import com.fs.qwApi.domain.QwExternalContactResult;
+import com.fs.qwApi.service.QwApiService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Service
+@Slf4j
+public class SyncQwExternalContactServiceImpl implements SyncQwExternalContactService {
+    @Autowired
+    private RedisCache redisCache;
+    @Autowired
+    private QwExternalContactMapper qwExternalContactMapper;
+    @Autowired
+    private QwApiService qwApiService;
+    @Override
+    public R syncQwExternalContactUnionid() {
+        // 测试环境需要在sql加上:and corp_id='ww51717e2b71d5e2d3'
+        // 查询这次同步的最大id
+        Long maxId = qwExternalContactMapper.selectSyncMaxId();
+        log.info("同步最大id值:"+maxId);
+        if (maxId == null) {
+            return R.ok("无需同步");
+        }
+        Long recordId = 0L;
+        String recordIdStr = redisCache.getCacheObject("syncQwExternalContactUnionId");
+        if (StringUtils.isNotEmpty(recordIdStr)) {
+            try {
+                recordId = Long.parseLong(recordIdStr);
+            } catch (NumberFormatException e) {
+                log.info("Failed to parse recordId from redis: {}", recordIdStr);
+                recordId = 0L;
+            }
+        }
+        log.info("开始同步的recordId值:"+recordId);
+        // 循环同步直到recordId等于maxId
+        while (recordId < maxId) {
+            // 每次查询500条数据
+            List<QwExternalContact> qwExternalContacts = qwExternalContactMapper.selectSyncData(recordId, maxId);
+            if (qwExternalContacts.isEmpty()) {
+                break;
+            }
+            List<QwExternalContact> batchList = new ArrayList<>();
+            // 调用接口
+            for (QwExternalContact info : qwExternalContacts) {
+                QwExternalContactResult externalcontact = qwApiService.getExternalcontact(info.getExternalUserId(), info.getCorpId());
+                if (null!=externalcontact && null!=externalcontact.getExternal_contact() && null!=externalcontact.getExternal_contact().getUnionid() ) {
+                    info.setUnionid(externalcontact.getExternal_contact().getUnionid());
+                    batchList.add(info);
+                }
+            }
+            if (!batchList.isEmpty()) {
+                for (QwExternalContact qwExternalContact : batchList) {
+                    qwExternalContactMapper.batchUpdateUnionId(qwExternalContact);
+                }
+            }else{
+                log.info("集合为空:{recordId->"+recordId+";syncId->"+qwExternalContacts.get(qwExternalContacts.size() - 1).getId()+"}");
+            }
+            // 更新recordId为本次处理的最后一条记录的id
+            recordId = qwExternalContacts.get(qwExternalContacts.size() - 1).getId();
+            // 更新redis中的记录值
+            redisCache.setCacheObject("syncQwExternalContactUnionId", recordId.toString());
+        }
+        log.info("同步成功,同步完之后的recordId:"+recordId);
+        return R.ok();
+    }
+}

+ 171 - 0
fs-wx-task/src/main/java/com/fs/framework/aspectj/DataScopeAspect.java

@@ -0,0 +1,171 @@
+package com.fs.framework.aspectj;
+
+import com.fs.common.annotation.DataScope;
+import com.fs.common.core.domain.BaseEntity;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.spring.SpringUtils;
+import com.fs.company.domain.CompanyRole;
+import com.fs.company.domain.CompanyUser;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
+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
+    {
+        handleDataScope(point);
+    }
+
+    protected void handleDataScope(final JoinPoint joinPoint)
+    {
+        // 获得注解
+        DataScope controllerDataScope = getAnnotationLog(joinPoint);
+        if (controllerDataScope == null)
+        {
+            return;
+        }
+        // 获取当前的用户
+        LoginUser loginUser = SpringUtils.getBean(TokenService.class).getLoginUser(ServletUtils.getRequest());
+        if (StringUtils.isNotNull(loginUser))
+        {
+            CompanyUser 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, CompanyUser user, String deptAlias, String userAlias)
+    {
+        StringBuilder sqlString = new StringBuilder();
+
+        for (CompanyRole 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 company_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 company_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 ");
+                        sqlString.append(StringUtils.format(" OR {}.dept_id = {} ", deptAlias, user.getDeptId()));
+                }
+            }
+        }
+
+        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;
+    }
+}

+ 73 - 0
fs-wx-task/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);
+    }
+}

+ 219 - 0
fs-wx-task/src/main/java/com/fs/framework/aspectj/LogAspect.java

@@ -0,0 +1,219 @@
+package com.fs.framework.aspectj;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.common.annotation.Log;
+import com.fs.common.enums.BusinessStatus;
+import com.fs.common.enums.HttpMethod;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.ip.IpUtils;
+import com.fs.common.utils.spring.SpringUtils;
+import com.fs.company.domain.CompanyOperLog;
+import com.fs.framework.manager.AsyncManager;
+import com.fs.framework.manager.factory.AsyncFactory;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
+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.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.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 = SpringUtils.getBean(TokenService.class).getLoginUser(ServletUtils.getRequest());
+
+            // *========数据库日志=========*//
+            CompanyOperLog operLog = new CompanyOperLog();
+            operLog.setCompanyId(loginUser.getCompany().getCompanyId());
+            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, CompanyOperLog 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, CompanyOperLog 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 (!isFilterObject(paramsArray[i]))
+                {
+                    Object jsonObj = JSON.toJSON(paramsArray[i]);
+                    params += jsonObj.toString() + " ";
+                }
+            }
+        }
+        return params.trim();
+    }
+
+    /**
+     * 判断是否需要过滤的对象。
+     * 
+     * @param o 对象信息。
+     * @return 如果是需要过滤的对象,则返回true;否则返回false。
+     */
+    public boolean isFilterObject(final Object o)
+    {
+        return o instanceof MultipartFile || o instanceof HttpServletRequest || o instanceof HttpServletResponse;
+    }
+}

+ 117 - 0
fs-wx-task/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("访问过于频繁,请稍后再试");
+            }
+            log.info("限制请求'{}',当前请求'{}',缓存key'{}'", count, number.intValue(), key);
+        }
+        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();
+    }
+}

+ 31 - 0
fs-wx-task/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());
+    }
+}

+ 58 - 0
fs-wx-task/src/main/java/com/fs/framework/config/ArrayStringTypeHandler.java

@@ -0,0 +1,58 @@
+package com.fs.framework.config;
+
+import org.apache.ibatis.type.BaseTypeHandler;
+import org.apache.ibatis.type.JdbcType;
+import org.springframework.context.annotation.Configuration;
+
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.List;
+
+@Configuration
+public class ArrayStringTypeHandler extends BaseTypeHandler<List<String>> {
+
+    @Override
+    public void setNonNullParameter(PreparedStatement ps, int i, List<String> parameter, JdbcType jdbcType) throws SQLException {
+        // 将 List<String> 转换为字符串,ClickHouse 支持的格式为 "['item1', 'item2']"
+        StringBuilder sb = new StringBuilder();
+        sb.append("[");
+        for (int j = 0; j < parameter.size(); j++) {
+            sb.append("'").append(parameter.get(j)).append("'");
+            if (j < parameter.size() - 1) {
+                sb.append(",");
+            }
+        }
+        sb.append("]");
+        ps.setString(i, sb.toString());
+    }
+
+    @Override
+    public List<String> getNullableResult(ResultSet rs, String columnName) throws SQLException {
+        // 处理查询结果,将其转换为 List<String>
+        String result = rs.getString(columnName);
+        return parseArray(result);
+    }
+
+    @Override
+    public List<String> getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
+        String result = rs.getString(columnIndex);
+        return parseArray(result);
+    }
+
+    @Override
+    public List<String> getNullableResult(java.sql.CallableStatement cs, int columnIndex) throws SQLException {
+        String result = cs.getString(columnIndex);
+        return parseArray(result);
+    }
+
+    private List<String> parseArray(String arrayStr) {
+        // 将 ClickHouse 的 Array 字符串转换为 List<String>
+        if (arrayStr == null || arrayStr.isEmpty()) {
+            return null;
+        }
+        arrayStr = arrayStr.substring(1, arrayStr.length() - 1);  // 去掉 "[" 和 "]"
+        String[] elements = arrayStr.split(",");
+        return java.util.Arrays.asList(elements);
+    }
+}

+ 85 - 0
fs-wx-task/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;
+    }
+}

+ 92 - 0
fs-wx-task/src/main/java/com/fs/framework/config/DataSourceConfig.java

@@ -0,0 +1,92 @@
+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
+    @Primary
+    public DynamicDataSource dataSource(@Qualifier("masterDataSource") DataSource masterDataSource, @Qualifier("sopDataSource") DataSource sopDataSource) {
+        Map<Object, Object> targetDataSources = new HashMap<>();
+        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-wx-task/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.framework.datasource.DynamicDataSource;
+//import com.fs.common.enums.DataSourceType;
+//import com.fs.common.utils.spring.SpringUtils;
+//import com.fs.framework.config.properties.DruidProperties;
+//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-wx-task/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-wx-task/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-wx-task/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-wx-task/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();
+    }
+}

+ 161 - 0
fs-wx-task/src/main/java/com/fs/framework/config/RedisConfig.java

@@ -0,0 +1,161 @@
+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;";
+    }
+}

+ 76 - 0
fs-wx-task/src/main/java/com/fs/framework/config/ResourcesConfig.java

@@ -0,0 +1,76 @@
+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.format.FormatterRegistry;
+import org.springframework.format.datetime.standard.DateTimeFormatterRegistrar;
+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;
+
+import java.time.format.DateTimeFormatter;
+
+/**
+ * 通用配置
+ *
+
+ */
+@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);
+    }
+
+    @Override
+    public void addFormatters(FormatterRegistry registry) {
+        DateTimeFormatterRegistrar registrar = new DateTimeFormatterRegistrar();
+        registrar.setDateFormatter(DateTimeFormatter.ofPattern("yyyy-MM-dd")); // 统一日期格式
+        registrar.registerFormatters(registry);
+    }
+}

+ 11 - 0
fs-wx-task/src/main/java/com/fs/framework/config/RetryConfig.java

@@ -0,0 +1,11 @@
+package com.fs.framework.config;
+
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.retry.annotation.EnableRetry;
+
+@Configuration
+@EnableRetry
+public class RetryConfig {
+
+}

+ 157 - 0
fs-wx-task/src/main/java/com/fs/framework/config/SecurityConfig.java

@@ -0,0 +1,157 @@
+package com.fs.framework.config;
+
+
+import com.fs.framework.security.filter.JwtAuthenticationTokenFilter;
+import com.fs.framework.security.handle.AuthenticationEntryPointImpl;
+import com.fs.framework.security.handle.LogoutSuccessHandlerImpl;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.http.HttpMethod;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
+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;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+import org.springframework.security.web.authentication.logout.LogoutFilter;
+import org.springframework.web.filter.CorsFilter;
+
+/**
+ * spring security配置
+ *
+
+ */
+@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
+public class SecurityConfig extends WebSecurityConfigurerAdapter
+{
+    /**
+     * 自定义用户认证逻辑
+     */
+    @Autowired
+    private UserDetailsService userDetailsService;
+
+    /**
+     * 认证失败处理类
+     */
+    @Autowired
+    private AuthenticationEntryPointImpl unauthorizedHandler;
+
+    /**
+     * 退出处理类
+     */
+    @Autowired
+    private LogoutSuccessHandlerImpl logoutSuccessHandler;
+
+    /**
+     * token认证过滤器
+     */
+    @Autowired
+    private JwtAuthenticationTokenFilter authenticationTokenFilter;
+
+    /**
+     * 跨域过滤器
+     */
+    @Autowired
+    private CorsFilter corsFilter;
+
+    /**
+     * 解决 无法直接注入 AuthenticationManager
+     *
+     * @return
+     * @throws Exception
+     */
+    @Bean
+    @Override
+    public AuthenticationManager authenticationManagerBean() throws Exception
+    {
+        return super.authenticationManagerBean();
+    }
+
+    /**
+     * 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 httpSecurity) throws Exception
+    {
+        httpSecurity
+                // CSRF禁用,因为不使用session
+                .csrf().disable()
+                // 认证失败处理类
+                .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
+                // 基于token,所以不需要session
+                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
+                // 过滤请求
+                .authorizeRequests()
+                // 对于登录login 注册register 验证码captchaImage 允许匿名访问
+                .antMatchers("/chat/upload/**","/login", "/register", "/captchaImage").anonymous()
+                .antMatchers(
+                        HttpMethod.GET,
+                        "/",
+                        "/*.html",
+                        "/**/*.html",
+                        "/**/*.css",
+                        "/**/*.js",
+                        "/profile/**"
+                ).permitAll()
+                .antMatchers("**").anonymous()
+                .antMatchers("/qw/getJsapiTicket/**").anonymous()
+                .antMatchers("/msg/**").anonymous()
+                .antMatchers("/msg/**/**").anonymous()
+                .antMatchers("/msg").anonymous()
+                .antMatchers("/app/common/**").anonymous()
+                .antMatchers("/common/getId**").anonymous()
+                .antMatchers("/common/uploadOSS**").anonymous()
+                .antMatchers("/common/uploadWang**").anonymous()
+                .antMatchers("/common/download**").anonymous()
+                .antMatchers("/common/download/resource**").anonymous()
+                .antMatchers("/swagger-ui.html").anonymous()
+                .antMatchers("/swagger-resources/**").anonymous()
+                .antMatchers("/webjars/**").anonymous()
+                .antMatchers("/*/api-docs").anonymous()
+                .antMatchers("/druid/**").anonymous()
+                .antMatchers("/qw/data/**").anonymous()
+                // 除上面外的所有请求全部需要鉴权认证
+                .anyRequest().authenticated()
+                .and()
+                .headers().frameOptions().disable();
+        httpSecurity.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);
+        // 添加JWT filter
+        httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
+        // 添加CORS filter
+        httpSecurity.addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class);
+        httpSecurity.addFilterBefore(corsFilter, LogoutFilter.class);
+    }
+
+    /**
+     * 强散列哈希加密实现
+     */
+    @Bean
+    public BCryptPasswordEncoder bCryptPasswordEncoder()
+    {
+        return new BCryptPasswordEncoder();
+    }
+
+    /**
+     * 身份认证接口
+     */
+    @Override
+    protected void configure(AuthenticationManagerBuilder auth) throws Exception
+    {
+        auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
+    }
+}

+ 33 - 0
fs-wx-task/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-wx-task/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();
+    }
+}

+ 115 - 0
fs-wx-task/src/main/java/com/fs/framework/config/ThreadPoolConfig.java

@@ -0,0 +1,115 @@
+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.TaskScheduler;
+import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
+
+import java.util.concurrent.Executor;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.ThreadPoolExecutor;
+
+/**
+ * 线程池配置
+ *
+
+ **/
+@Configuration
+@EnableAsync
+public class ThreadPoolConfig
+{
+    // 核心线程池大小
+    private int corePoolSize = 50;
+
+    // 最大可创建的线程数
+    private int maxPoolSize = 300;
+
+    // 队列最大长度
+    private int queueCapacity = 1000;
+
+    // 线程池维护线程所允许的空闲时间
+    private int keepAliveSeconds = 300;
+
+
+    @Bean
+    public TaskScheduler taskScheduler(){
+        ThreadPoolTaskScheduler scheduler=new ThreadPoolTaskScheduler();
+        scheduler.setPoolSize(18);
+        scheduler.setThreadNamePrefix("scheduled-task-");
+        scheduler.setAwaitTerminationSeconds(60);
+        scheduler.setWaitForTasksToCompleteOnShutdown(true);
+        return scheduler;
+    }
+
+    @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);
+            }
+        };
+    }
+
+    @Bean(name = "sopTaskExecutor")
+    public Executor sopTaskExecutor() {
+        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+        executor.setCorePoolSize(16); // 根据需求调整
+        executor.setMaxPoolSize(32);  // 根据需求调整
+        executor.setQueueCapacity(800); // 根据需求调整
+        executor.setThreadNamePrefix("SopTask-");
+        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
+        executor.initialize();
+        return executor;
+    }
+
+    @Bean(name = "sopChatTaskExecutor")
+    public Executor batchInsertExecutor() {
+        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+        executor.setCorePoolSize(16);
+        executor.setMaxPoolSize(32);
+        executor.setQueueCapacity(800);
+        executor.setThreadNamePrefix("BatchInsert-");
+        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
+        executor.initialize();
+        return executor;
+    }
+
+    @Bean(name = "sopRatingExecutor")
+    public Executor sopRatingExecutor() {
+        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+        executor.setCorePoolSize(16); // 根据需求调整
+        executor.setMaxPoolSize(32);  // 根据需求调整
+        executor.setQueueCapacity(800); // 根据需求调整
+        executor.setThreadNamePrefix("SopRating-");
+        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
+        executor.initialize();
+        return executor;
+    }
+}

+ 77 - 0
fs-wx-task/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-wx-task/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-wx-task/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();
+    }
+}

+ 115 - 0
fs-wx-task/src/main/java/com/fs/framework/exception/GlobalExceptionHandler.java

@@ -0,0 +1,115 @@
+package com.fs.framework.exception;
+
+import com.fs.common.constant.HttpStatus;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.exception.DemoModeException;
+import com.fs.common.exception.ServiceException;
+import com.fs.common.utils.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.validation.BindException;
+import org.springframework.web.HttpRequestMethodNotSupportedException;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * 全局异常处理器
+ *
+
+ */
+@RestControllerAdvice
+public class GlobalExceptionHandler
+{
+    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
+
+    /**
+     * 权限校验异常
+     */
+    @ExceptionHandler(AccessDeniedException.class)
+    public AjaxResult handleAccessDeniedException(AccessDeniedException e, HttpServletRequest request)
+    {
+        String requestURI = request.getRequestURI();
+        log.error("请求地址'{}',权限校验失败'{}'", requestURI, e.getMessage());
+        return AjaxResult.error(HttpStatus.FORBIDDEN, "没有权限,请联系管理员授权");
+    }
+
+    /**
+     * 请求方式不支持
+     */
+    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
+    public AjaxResult handleHttpRequestMethodNotSupported(HttpRequestMethodNotSupportedException e,
+            HttpServletRequest request)
+    {
+        String requestURI = request.getRequestURI();
+        log.error("请求地址'{}',不支持'{}'请求", requestURI, e.getMethod());
+        return AjaxResult.error(e.getMessage());
+    }
+
+    /**
+     * 业务异常
+     */
+    @ExceptionHandler(ServiceException.class)
+    public AjaxResult handleServiceException(ServiceException e, HttpServletRequest request)
+    {
+        log.error(e.getMessage(), e);
+        Integer code = e.getCode();
+        return StringUtils.isNotNull(code) ? AjaxResult.error(code, e.getMessage()) : AjaxResult.error(e.getMessage());
+    }
+
+    /**
+     * 拦截未知的运行时异常
+     */
+    @ExceptionHandler(RuntimeException.class)
+    public AjaxResult handleRuntimeException(RuntimeException e, HttpServletRequest request)
+    {
+        String requestURI = request.getRequestURI();
+        log.error("请求地址'{}',发生未知异常.", requestURI, e);
+        return AjaxResult.error(e.getMessage());
+    }
+
+    /**
+     * 系统异常
+     */
+    @ExceptionHandler(Exception.class)
+    public AjaxResult handleException(Exception e, HttpServletRequest request)
+    {
+        String requestURI = request.getRequestURI();
+        log.error("请求地址'{}',发生系统异常.", requestURI, e);
+        return AjaxResult.error(e.getMessage());
+    }
+
+    /**
+     * 自定义验证异常
+     */
+    @ExceptionHandler(BindException.class)
+    public AjaxResult handleBindException(BindException e)
+    {
+        log.error(e.getMessage(), e);
+        String message = e.getAllErrors().get(0).getDefaultMessage();
+        return AjaxResult.error(message);
+    }
+
+    /**
+     * 自定义验证异常
+     */
+    @ExceptionHandler(MethodArgumentNotValidException.class)
+    public Object handleMethodArgumentNotValidException(MethodArgumentNotValidException e)
+    {
+        log.error(e.getMessage(), e);
+        String message = e.getBindingResult().getFieldError().getDefaultMessage();
+        return AjaxResult.error(message);
+    }
+
+    /**
+     * 演示模式异常
+     */
+    @ExceptionHandler(DemoModeException.class)
+    public AjaxResult handleDemoModeException(DemoModeException e)
+    {
+        return AjaxResult.error("演示模式,不允许操作");
+    }
+}

+ 56 - 0
fs-wx-task/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-wx-task/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-wx-task/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-wx-task/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);
+        }
+    }
+}

+ 106 - 0
fs-wx-task/src/main/java/com/fs/framework/manager/factory/AsyncFactory.java

@@ -0,0 +1,106 @@
+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.ip.AddressUtils;
+import com.fs.common.utils.ip.IpUtils;
+import com.fs.common.utils.spring.SpringUtils;
+import com.fs.company.domain.CompanyLogininfor;
+import com.fs.company.domain.CompanyOperLog;
+import com.fs.company.service.ICompanyLogininforService;
+import com.fs.company.service.ICompanyOperLogService;
+import eu.bitwalker.useragentutils.UserAgent;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Date;
+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 Long companyId,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();
+                // 封装对象
+                CompanyLogininfor logininfor = new CompanyLogininfor();
+                logininfor.setCompanyId(companyId);
+                logininfor.setUserName(username);
+                logininfor.setIpaddr(ip);
+                logininfor.setLoginLocation(address);
+                logininfor.setBrowser(browser);
+                logininfor.setOs(os);
+                logininfor.setMsg(message);
+                // 日志状态
+                if (Constants.LOGIN_SUCCESS.equals(status) || Constants.LOGOUT.equals(status))
+                {
+                    logininfor.setStatus(Constants.SUCCESS);
+                }
+                else if (Constants.LOGIN_FAIL.equals(status))
+                {
+                    logininfor.setStatus(Constants.FAIL);
+                }
+                logininfor.setLoginTime(new Date());
+                // 插入数据
+                SpringUtils.getBean(ICompanyLogininforService.class).insertCompanyLogininfor(logininfor);
+            }
+        };
+    }
+
+    /**
+     * 操作日志记录
+     * 
+     * @param operLog 操作日志信息
+     * @return 任务task
+     */
+    public static TimerTask recordOper(final CompanyOperLog operLog)
+    {
+        return new TimerTask()
+        {
+            @Override
+            public void run()
+            {
+                // 远程查询操作地点
+                operLog.setOperTime(new Date());
+                operLog.setOperLocation(AddressUtils.getRealAddressByIP(operLog.getOperIp()));
+                SpringUtils.getBean(ICompanyOperLogService.class).insertCompanyOperLog(operLog);
+            }
+        };
+    }
+}

+ 69 - 0
fs-wx-task/src/main/java/com/fs/framework/security/LoginBody.java

@@ -0,0 +1,69 @@
+package com.fs.framework.security;
+
+/**
+ * 用户登录对象
+ * 
+
+ */
+public class LoginBody
+{
+    /**
+     * 用户名
+     */
+    private String username;
+
+    /**
+     * 用户密码
+     */
+    private String password;
+
+    /**
+     * 验证码
+     */
+    private String code;
+
+    /**
+     * 唯一标识
+     */
+    private String uuid = "";
+
+    public String getUsername()
+    {
+        return username;
+    }
+
+    public void setUsername(String username)
+    {
+        this.username = username;
+    }
+
+    public String getPassword()
+    {
+        return password;
+    }
+
+    public void setPassword(String password)
+    {
+        this.password = password;
+    }
+
+    public String getCode()
+    {
+        return code;
+    }
+
+    public void setCode(String code)
+    {
+        this.code = code;
+    }
+
+    public String getUuid()
+    {
+        return uuid;
+    }
+
+    public void setUuid(String uuid)
+    {
+        this.uuid = uuid;
+    }
+}

+ 255 - 0
fs-wx-task/src/main/java/com/fs/framework/security/LoginUser.java

@@ -0,0 +1,255 @@
+package com.fs.framework.security;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fs.company.domain.Company;
+import com.fs.company.domain.CompanyUser;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.userdetails.UserDetails;
+
+import java.util.Collection;
+import java.util.Set;
+
+/**
+ * 登录用户身份权限
+ * 
+
+ */
+public class LoginUser implements UserDetails
+{
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 用户唯一标识
+     */
+    private String token;
+
+    /**
+     * 登录时间
+     */
+    private Long loginTime;
+
+    /**
+     * 过期时间
+     */
+    private Long expireTime;
+
+    /**
+     * 登录IP地址
+     */
+    private String ipaddr;
+
+    /**
+     * 登录地点
+     */
+    private String loginLocation;
+
+    /**
+     * 浏览器类型
+     */
+    private String browser;
+
+    /**
+     * 操作系统
+     */
+    private String os;
+
+
+
+    /**
+     * 权限列表
+     */
+    private Set<String> permissions;
+
+    /**
+     * 用户信息
+     */
+    private CompanyUser user;
+
+    private Company company;
+
+
+
+    public static long getSerialVersionUID() {
+        return serialVersionUID;
+    }
+
+
+
+    public Company getCompany() {
+        return company;
+    }
+
+    public void setCompany(Company company) {
+        this.company = company;
+    }
+
+    public String getToken()
+    {
+        return token;
+    }
+
+    public void setToken(String token)
+    {
+        this.token = token;
+    }
+
+    public LoginUser()
+    {
+    }
+    public LoginUser(CompanyUser user, Set<String> permissions, Company company)
+    {
+        this.user = user;
+        this.permissions = permissions;
+        this.company=company;
+    }
+    public LoginUser(CompanyUser user, Set<String> permissions)
+    {
+        this.user = user;
+        this.permissions = permissions;
+    }
+
+    @JsonIgnore
+    @Override
+    public String getPassword()
+    {
+        return user.getPassword();
+    }
+
+    @Override
+    public String getUsername()
+    {
+        return user.getUserName();
+    }
+
+    /**
+     * 账户是否未过期,过期无法验证
+     */
+    @JsonIgnore
+    @Override
+    public boolean isAccountNonExpired()
+    {
+        return true;
+    }
+
+    /**
+     * 指定用户是否解锁,锁定的用户无法进行身份验证
+     * 
+     * @return
+     */
+    @JsonIgnore
+    @Override
+    public boolean isAccountNonLocked()
+    {
+        return true;
+    }
+
+    /**
+     * 指示是否已过期的用户的凭据(密码),过期的凭据防止认证
+     * 
+     * @return
+     */
+    @JsonIgnore
+    @Override
+    public boolean isCredentialsNonExpired()
+    {
+        return true;
+    }
+
+    /**
+     * 是否可用 ,禁用的用户不能身份验证
+     * 
+     * @return
+     */
+    @JsonIgnore
+    @Override
+    public boolean isEnabled()
+    {
+        return true;
+    }
+
+    public Long getLoginTime()
+    {
+        return loginTime;
+    }
+
+    public void setLoginTime(Long loginTime)
+    {
+        this.loginTime = loginTime;
+    }
+
+    public String getIpaddr()
+    {
+        return ipaddr;
+    }
+
+    public void setIpaddr(String ipaddr)
+    {
+        this.ipaddr = ipaddr;
+    }
+
+    public String getLoginLocation()
+    {
+        return loginLocation;
+    }
+
+    public void setLoginLocation(String loginLocation)
+    {
+        this.loginLocation = loginLocation;
+    }
+
+    public String getBrowser()
+    {
+        return browser;
+    }
+
+    public void setBrowser(String browser)
+    {
+        this.browser = browser;
+    }
+
+    public String getOs()
+    {
+        return os;
+    }
+
+    public void setOs(String os)
+    {
+        this.os = os;
+    }
+
+    public Long getExpireTime()
+    {
+        return expireTime;
+    }
+
+    public void setExpireTime(Long expireTime)
+    {
+        this.expireTime = expireTime;
+    }
+
+    public Set<String> getPermissions()
+    {
+        return permissions;
+    }
+
+    public void setPermissions(Set<String> permissions)
+    {
+        this.permissions = permissions;
+    }
+
+    public CompanyUser getUser()
+    {
+        return user;
+    }
+
+    public void setUser(CompanyUser user)
+    {
+        this.user = user;
+    }
+
+    @Override
+    public Collection<? extends GrantedAuthority> getAuthorities()
+    {
+        return null;
+    }
+}

+ 89 - 0
fs-wx-task/src/main/java/com/fs/framework/security/SecurityUtils.java

@@ -0,0 +1,89 @@
+package com.fs.framework.security;
+
+import com.fs.common.constant.HttpStatus;
+import com.fs.common.exception.CustomException;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+
+/**
+ * 安全服务工具类
+ * 
+
+ */
+public class SecurityUtils
+{
+    /**
+     * 获取用户账户
+     **/
+    public static String getUsername()
+    {
+        try
+        {
+            return getLoginUser().getUsername();
+        }
+        catch (Exception e)
+        {
+            throw new CustomException("获取用户账户异常", HttpStatus.UNAUTHORIZED);
+        }
+    }
+
+    /**
+     * 获取用户
+     **/
+    public static LoginUser getLoginUser()
+    {
+        try
+        {
+            return (LoginUser) getAuthentication().getPrincipal();
+        }
+        catch (Exception e)
+        {
+            throw new CustomException("获取用户信息异常", HttpStatus.UNAUTHORIZED);
+        }
+    }
+
+    /**
+     * 获取Authentication
+     */
+    public static Authentication getAuthentication()
+    {
+        return SecurityContextHolder.getContext().getAuthentication();
+    }
+
+    /**
+     * 生成BCryptPasswordEncoder密码
+     *
+     * @param password 密码
+     * @return 加密字符串
+     */
+    public static String encryptPassword(String password)
+    {
+        BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
+        return passwordEncoder.encode(password);
+    }
+
+    /**
+     * 判断密码是否相同
+     *
+     * @param rawPassword 真实密码
+     * @param encodedPassword 加密后字符
+     * @return 结果
+     */
+    public static boolean matchesPassword(String rawPassword, String encodedPassword)
+    {
+        BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
+        return passwordEncoder.matches(rawPassword, encodedPassword);
+    }
+
+    /**
+     * 是否为管理员
+     * 
+     * @param userId 用户ID
+     * @return 结果
+     */
+    public static boolean isAdmin(Long userId)
+    {
+        return userId != null && 1L == userId;
+    }
+}

+ 47 - 0
fs-wx-task/src/main/java/com/fs/framework/security/filter/JwtAuthenticationTokenFilter.java

@@ -0,0 +1,47 @@
+package com.fs.framework.security.filter;
+
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.utils.StringUtils;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.security.SecurityUtils;
+import com.fs.framework.service.TokenService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+/**
+ * token过滤器 验证token有效性
+ *
+
+ */
+@Component
+public class JwtAuthenticationTokenFilter extends OncePerRequestFilter
+{
+    @Autowired
+    private TokenService tokenService;
+    @Autowired
+    private RedisCache redisCache;
+    @Override
+    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
+            throws ServletException, IOException
+    {
+        LoginUser loginUser = tokenService.getLoginUser(request);
+        if (StringUtils.isNotNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication()))
+        {
+            tokenService.verifyToken(loginUser);
+            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
+            authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
+            SecurityContextHolder.getContext().setAuthentication(authenticationToken);
+        }
+        chain.doFilter(request, response);
+    }
+}

+ 35 - 0
fs-wx-task/src/main/java/com/fs/framework/security/handle/AuthenticationEntryPointImpl.java

@@ -0,0 +1,35 @@
+package com.fs.framework.security.handle;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.common.constant.HttpStatus;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.stereotype.Component;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.io.Serializable;
+
+/**
+ * 认证失败处理类 返回未授权
+ * 
+ 
+ */
+@Component
+public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint, Serializable
+{
+    private static final long serialVersionUID = -8970718410437077606L;
+
+    @Override
+    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e)
+            throws IOException
+    {
+        int code = HttpStatus.UNAUTHORIZED;
+        String msg = StringUtils.format("请求访问:{},认证失败,无法访问系统资源", request.getRequestURI());
+        ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(code, msg)));
+    }
+}

+ 54 - 0
fs-wx-task/src/main/java/com/fs/framework/security/handle/LogoutSuccessHandlerImpl.java

@@ -0,0 +1,54 @@
+package com.fs.framework.security.handle;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.common.constant.Constants;
+import com.fs.common.constant.HttpStatus;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.framework.manager.AsyncManager;
+import com.fs.framework.manager.factory.AsyncFactory;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+/**
+ * 自定义退出处理类 返回成功
+ *
+
+ */
+@Configuration
+public class LogoutSuccessHandlerImpl implements LogoutSuccessHandler
+{
+    @Autowired
+    private TokenService tokenService;
+
+    /**
+     * 退出处理
+     *
+     * @return
+     */
+    @Override
+    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
+            throws IOException, ServletException
+    {
+        LoginUser loginUser = tokenService.getLoginUser(request);
+        if (StringUtils.isNotNull(loginUser))
+        {
+            String userName = loginUser.getUsername();
+            // 删除用户缓存记录
+            tokenService.delLoginUser(loginUser.getToken());
+            // 记录用户退出日志
+            AsyncManager.me().execute(AsyncFactory.recordLogininfor(loginUser.getCompany().getCompanyId(),userName, Constants.LOGOUT, "退出成功"));
+        }
+        ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(HttpStatus.SUCCESS, "退出成功")));
+    }
+}

+ 92 - 0
fs-wx-task/src/main/java/com/fs/framework/service/CompanyLoginService.java

@@ -0,0 +1,92 @@
+package com.fs.framework.service;
+
+import com.fs.common.constant.Constants;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.exception.ServiceException;
+import com.fs.common.exception.user.CaptchaException;
+import com.fs.common.exception.user.CaptchaExpireException;
+import com.fs.common.exception.user.UserPasswordNotMatchException;
+import com.fs.common.utils.MessageUtils;
+import com.fs.framework.manager.AsyncManager;
+import com.fs.framework.manager.factory.AsyncFactory;
+import com.fs.framework.security.LoginUser;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.BadCredentialsException;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 登录校验方法
+ *
+
+ */
+@Component
+public class CompanyLoginService
+{
+    @Autowired
+    private TokenService tokenService;
+
+    @Resource
+    private AuthenticationManager authenticationManager;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    /**
+     * 登录验证
+     *
+     * @param username 用户名
+     * @param password 密码
+     * @param code 验证码
+     * @param uuid 唯一标识
+     * @return 结果
+     */
+    public String login(String username, String password, String code, String uuid)
+    {
+        String verifyKey = Constants.CAPTCHA_CODE_KEY + uuid;
+        String captcha = redisCache.getCacheObject(verifyKey);
+        redisCache.deleteObject(verifyKey);
+        if (captcha == null)
+        {
+            AsyncManager.me().execute(AsyncFactory.recordLogininfor(0l,username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire")));
+            throw new CaptchaExpireException();
+        }
+        if (!code.equalsIgnoreCase(captcha))
+        {
+            AsyncManager.me().execute(AsyncFactory.recordLogininfor(0l,username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.error")));
+            throw new CaptchaException();
+        }
+        // 用户验证
+        Authentication authentication = null;
+        try
+        {
+            // 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
+            authentication = authenticationManager
+                    .authenticate(new UsernamePasswordAuthenticationToken(username, password));
+        }
+        catch (Exception e)
+        {
+            if (e instanceof BadCredentialsException)
+            {
+                AsyncManager.me().execute(AsyncFactory.recordLogininfor(0l,username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
+                throw new UserPasswordNotMatchException();
+            }
+            else
+            {
+                AsyncManager.me().execute(AsyncFactory.recordLogininfor(0l,username, Constants.LOGIN_FAIL, e.getMessage()));
+                throw new ServiceException(e.getMessage());
+            }
+        }
+        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
+        AsyncManager.me().execute(AsyncFactory.recordLogininfor(loginUser.getUser().getCompanyId(),username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
+        redisCache.setCacheObject("companyId:"+loginUser.getUser().getUserId(),loginUser.getUser().getCompanyId(),604800, TimeUnit.SECONDS);
+        // 生成token
+        return tokenService.createToken(loginUser);
+    }
+
+}

+ 66 - 0
fs-wx-task/src/main/java/com/fs/framework/service/CompanyPermissionService.java

@@ -0,0 +1,66 @@
+package com.fs.framework.service;
+
+import com.fs.company.domain.CompanyUser;
+import com.fs.company.service.ICompanyMenuService;
+import com.fs.company.service.ICompanyRoleService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * 用户权限处理
+ * 
+ 
+ */
+@Component
+public class CompanyPermissionService
+{
+    @Autowired
+    private ICompanyRoleService roleService;
+    @Autowired
+    private ICompanyMenuService menuService;
+
+    /**
+     * 获取角色数据权限
+     * 
+     * @param user 用户信息
+     * @return 角色权限信息
+     */
+    public Set<String> getRolePermission(CompanyUser user)
+    {
+        Set<String> roles = new HashSet<String>();
+        // 管理员拥有所有权限
+        if (user.isAdmin())
+        {
+            roles.add("admin");
+        }
+        else
+        {
+            roles.addAll(roleService.selectRolePermissionByUserId(user.getUserId()));
+        }
+        return roles;
+    }
+
+    /**
+     * 获取菜单数据权限
+     * 
+     * @param user 用户信息
+     * @return 菜单权限信息
+     */
+    public Set<String> getMenuPermission(CompanyUser user)
+    {
+        Set<String> perms = new HashSet<String>();
+        // 管理员拥有所有权限
+        if (user.isAdmin())
+        {
+            perms.add("*:*:*");
+        }
+        else
+        {
+            perms.addAll(menuService.selectMenuPermsByUserId(user.getUserId()));
+        }
+        return perms;
+    }
+}

+ 170 - 0
fs-wx-task/src/main/java/com/fs/framework/service/PermissionService.java

@@ -0,0 +1,170 @@
+package com.fs.framework.service;
+
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.company.domain.CompanyRole;
+import com.fs.framework.security.LoginUser;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
+
+import java.util.Set;
+
+/**
+ * 自定义权限实现,ss取自SpringSecurity首字母
+ */
+@Service("ss")
+public class PermissionService
+{
+    /** 所有权限标识 */
+    private static final String ALL_PERMISSION = "*:*:*";
+
+    /** 管理员角色权限标识 */
+    private static final String SUPER_ADMIN = "admin";
+
+    private static final String ROLE_DELIMETER = ",";
+
+    private static final String PERMISSION_DELIMETER = ",";
+
+    @Autowired
+    private TokenService tokenService;
+
+    /**
+     * 验证用户是否具备某权限
+     * 
+     * @param permission 权限字符串
+     * @return 用户是否具备某权限
+     */
+    public boolean hasPermi(String permission)
+    {
+        if (StringUtils.isEmpty(permission))
+        {
+            return false;
+        }
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions()))
+        {
+            return false;
+        }
+        return hasPermissions(loginUser.getPermissions(), permission);
+    }
+
+    /**
+     * 验证用户是否不具备某权限,与 hasPermi逻辑相反
+     *
+     * @param permission 权限字符串
+     * @return 用户是否不具备某权限
+     */
+    public boolean lacksPermi(String permission)
+    {
+        return hasPermi(permission) != true;
+    }
+
+    /**
+     * 验证用户是否具有以下任意一个权限
+     *
+     * @param permissions 以 PERMISSION_NAMES_DELIMETER 为分隔符的权限列表
+     * @return 用户是否具有以下任意一个权限
+     */
+    public boolean hasAnyPermi(String permissions)
+    {
+        if (StringUtils.isEmpty(permissions))
+        {
+            return false;
+        }
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions()))
+        {
+            return false;
+        }
+        Set<String> authorities = loginUser.getPermissions();
+        for (String permission : permissions.split(PERMISSION_DELIMETER))
+        {
+            if (permission != null && hasPermissions(authorities, permission))
+            {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 判断用户是否拥有某个角色
+     * 
+     * @param role 角色字符串
+     * @return 用户是否具备某角色
+     */
+    public boolean hasRole(String role)
+    {
+        if (StringUtils.isEmpty(role))
+        {
+            return false;
+        }
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getUser().getRoles()))
+        {
+            return false;
+        }
+        for (CompanyRole sysRole : loginUser.getUser().getRoles())
+        {
+            String roleKey = sysRole.getRoleKey();
+            if (SUPER_ADMIN.contains(roleKey) || roleKey.contains(StringUtils.trim(role)))
+            {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 验证用户是否不具备某角色,与 isRole逻辑相反。
+     *
+     * @param role 角色名称
+     * @return 用户是否不具备某角色
+     */
+    public boolean lacksRole(String role)
+    {
+        return hasRole(role) != true;
+    }
+
+    /**
+     * 验证用户是否具有以下任意一个角色
+     *
+     * @param roles 以 ROLE_NAMES_DELIMETER 为分隔符的角色列表
+     * @return 用户是否具有以下任意一个角色
+     */
+    public boolean hasAnyRoles(String roles)
+    {
+        if (StringUtils.isEmpty(roles))
+        {
+            return false;
+        }
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getUser().getRoles()))
+        {
+            return false;
+        }
+        for (String role : roles.split(ROLE_DELIMETER))
+        {
+            if (hasRole(role))
+            {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 判断是否包含权限
+     * 
+     * @param permissions 权限列表
+     * @param permission 权限字符串
+     * @return 用户是否具备某权限
+     */
+    private boolean hasPermissions(Set<String> permissions, String permission)
+    {
+        return permissions.contains(ALL_PERMISSION) || permissions.contains(StringUtils.trim(permission));
+    }
+
+
+}

+ 236 - 0
fs-wx-task/src/main/java/com/fs/framework/service/TokenService.java

@@ -0,0 +1,236 @@
+package com.fs.framework.service;
+
+import com.fs.common.constant.Constants;
+import com.fs.common.core.redis.RedisCache;
+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.uuid.IdUtils;
+import com.fs.framework.security.LoginUser;
+import eu.bitwalker.useragentutils.UserAgent;
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.SignatureAlgorithm;
+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;
+
+/**
+ * token验证处理
+ *
+ 
+ */
+@Component
+public class TokenService
+{
+    // 令牌自定义标识
+    @Value("${token.header}")
+    private String header;
+
+    // 令牌秘钥
+    @Value("${token.secret}")
+    private String secret;
+
+    // 令牌有效期(默认30分钟)
+    @Value("${token.expireTime}")
+    private int expireTime;
+
+    protected static final long MILLIS_SECOND = 1000;
+
+    protected static final long MILLIS_MINUTE = 60 * MILLIS_SECOND;
+
+    private static final Long MILLIS_MINUTE_TEN = 20 * 60 * 100000000000L;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    /**
+     * 获取用户身份信息
+     *
+     * @return 用户信息
+     */
+    public LoginUser getLoginUser(HttpServletRequest request)
+    {
+        // 获取请求携带的令牌
+        String token = getToken(request);
+        if (StringUtils.isNotEmpty(token))
+        {
+            Claims claims = parseToken(token);
+            // 解析对应的权限以及用户信息
+            String uuid = (String) claims.get(Constants.COMPANY_LOGIN_USER_KEY);
+            String userKey = getTokenKey(uuid);
+            LoginUser user = redisCache.getCacheObject(userKey);
+            return user;
+        }
+        token=getUrlToken(request);
+        if (StringUtils.isNotEmpty(token))
+        {
+            Claims claims = parseToken(token);
+            // 解析对应的权限以及用户信息
+            String uuid = (String) claims.get(Constants.COMPANY_LOGIN_USER_KEY);
+            String userKey = getTokenKey(uuid);
+            LoginUser user = redisCache.getCacheObject(userKey);
+            return user;
+        }
+
+        return null;
+    }
+
+    /**
+     * 设置用户身份信息
+     */
+    public void setLoginUser(LoginUser loginUser)
+    {
+        if (StringUtils.isNotNull(loginUser) && StringUtils.isNotEmpty(loginUser.getToken()))
+        {
+            refreshToken(loginUser);
+        }
+    }
+
+    /**
+     * 删除用户身份信息
+     */
+    public void delLoginUser(String token)
+    {
+        if (StringUtils.isNotEmpty(token))
+        {
+            String userKey = getTokenKey(token);
+            redisCache.deleteObject(userKey);
+        }
+    }
+
+    /**
+     * 创建令牌
+     *
+     * @param loginUser 用户信息
+     * @return 令牌
+     */
+    public String createToken(LoginUser loginUser)
+    {
+        String token = IdUtils.fastUUID();
+        loginUser.setToken(token);
+        setUserAgent(loginUser);
+        refreshToken(loginUser);
+
+        Map<String, Object> claims = new HashMap<>();
+        claims.put(Constants.COMPANY_LOGIN_USER_KEY, token);
+        return createToken(claims);
+    }
+
+    /**
+     * 验证令牌有效期,相差不足20分钟,自动刷新缓存
+     *
+     * @param loginUser
+     * @return 令牌
+     */
+    public void verifyToken(LoginUser loginUser)
+    {
+        long expireTime = loginUser.getExpireTime();
+        long currentTime = System.currentTimeMillis();
+        if (expireTime - currentTime <= MILLIS_MINUTE_TEN)
+        {
+            refreshToken(loginUser);
+        }
+    }
+
+    /**
+     * 刷新令牌有效期
+     *
+     * @param loginUser 登录信息
+     */
+    public void refreshToken(LoginUser loginUser)
+    {
+        loginUser.setLoginTime(System.currentTimeMillis());
+        loginUser.setExpireTime(loginUser.getLoginTime() + expireTime * MILLIS_MINUTE);
+        // 根据uuid将loginUser缓存
+        String userKey = getTokenKey(loginUser.getToken());
+        redisCache.setCacheObject(userKey, loginUser, expireTime, TimeUnit.MINUTES);
+    }
+
+    /**
+     * 设置用户代理信息
+     *
+     * @param loginUser 登录信息
+     */
+    public void setUserAgent(LoginUser loginUser)
+    {
+        UserAgent userAgent = UserAgent.parseUserAgentString(ServletUtils.getRequest().getHeader("User-Agent"));
+        String ip = IpUtils.getIpAddr(ServletUtils.getRequest());
+        loginUser.setIpaddr(ip);
+        loginUser.setLoginLocation(AddressUtils.getRealAddressByIP(ip));
+        loginUser.setBrowser(userAgent.getBrowser().getName());
+        loginUser.setOs(userAgent.getOperatingSystem().getName());
+    }
+
+    /**
+     * 从数据声明生成令牌
+     *
+     * @param claims 数据声明
+     * @return 令牌
+     */
+    private String createToken(Map<String, Object> claims)
+    {
+        String token = Jwts.builder()
+                .setClaims(claims)
+                .signWith(SignatureAlgorithm.HS512, secret).compact();
+        return token;
+    }
+
+    /**
+     * 从令牌中获取数据声明
+     *
+     * @param token 令牌
+     * @return 数据声明
+     */
+    private Claims parseToken(String token)
+    {
+        return Jwts.parser()
+                .setSigningKey(secret)
+                .parseClaimsJws(token)
+                .getBody();
+    }
+
+    /**
+     * 从令牌中获取用户名
+     *
+     * @param token 令牌
+     * @return 用户名
+     */
+    public String getUsernameFromToken(String token)
+    {
+        Claims claims = parseToken(token);
+        return claims.getSubject();
+    }
+
+    /**
+     * 获取请求token
+     *
+     * @param request
+     * @return token
+     */
+    private String getToken(HttpServletRequest request)
+    {
+        String token = request.getHeader(header);
+        if (StringUtils.isNotEmpty(token) && token.startsWith(Constants.TOKEN_PREFIX))
+        {
+            token = token.replace(Constants.TOKEN_PREFIX, "");
+        }
+        return token;
+    }
+    private String getUrlToken(HttpServletRequest request)
+    {
+        String token = request.getParameter("token");
+        return token;
+    }
+
+    private String getTokenKey(String uuid)
+    {
+        return Constants.COMPANY_LOGIN_TOKEN_KEY + uuid;
+    }
+}

+ 75 - 0
fs-wx-task/src/main/java/com/fs/framework/service/UserDetailsServiceImpl.java

@@ -0,0 +1,75 @@
+package com.fs.framework.service;
+
+
+import com.fs.common.enums.UserStatus;
+import com.fs.common.exception.CustomException;
+import com.fs.common.utils.StringUtils;
+import com.fs.company.domain.Company;
+import com.fs.company.domain.CompanyUser;
+import com.fs.company.service.ICompanyService;
+import com.fs.company.service.ICompanyUserService;
+import com.fs.framework.security.LoginUser;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.core.userdetails.UsernameNotFoundException;
+import org.springframework.stereotype.Service;
+
+/**
+ * 用户验证处理
+ *
+ 
+ */
+@Service
+public class UserDetailsServiceImpl implements UserDetailsService
+{
+    private static final Logger log = LoggerFactory.getLogger(UserDetailsServiceImpl.class);
+
+    @Autowired
+    private ICompanyUserService userService;
+
+    @Autowired
+    private CompanyPermissionService permissionService;
+
+    @Autowired
+    private ICompanyService companyService;
+
+
+
+
+    @Override
+    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException
+    {
+
+
+        CompanyUser user = userService.selectUserByUserName(username);
+        if (StringUtils.isNull(user))
+        {
+            log.info("登录用户:{} 不存在.", username);
+            throw new UsernameNotFoundException("登录用户:" + username + " 不存在");
+        }
+        Company company=companyService.selectCompanyById(user.getCompanyId()) ;
+        if(company==null||company.getStatus()==0||company.getIsDel()==1){
+            throw new CustomException("此用户所属公司不存在或已停用");
+        }
+        if (UserStatus.DELETED.getCode().equals(user.getDelFlag()))
+        {
+            log.info("登录用户:{} 已被删除.", username);
+            throw new CustomException("对不起,您的账号:" + username + " 已被删除");
+        }
+        else if (UserStatus.DISABLE.getCode().equals(user.getStatus()))
+        {
+            log.info("登录用户:{} 已被停用.", username);
+            throw new CustomException("对不起,您的账号:" + username + " 已停用");
+        }
+
+        return createLoginUser(user);
+    }
+
+    public UserDetails createLoginUser(CompanyUser user)
+    {
+        return new LoginUser(user, permissionService.getMenuPermission(user),companyService.selectCompanyById(user.getCompanyId()));
+    }
+}

+ 1 - 0
fs-wx-task/src/main/resources/META-INF/spring-devtools.properties

@@ -0,0 +1 @@
+restart.include.json=/com.alibaba.fastjson.*.jar

+ 2 - 0
fs-wx-task/src/main/resources/banner.txt

@@ -0,0 +1,2 @@
+Application Version: ${fs.version}
+Spring Boot Version: ${spring-boot.version}

+ 37 - 0
fs-wx-task/src/main/resources/i18n/messages.properties

@@ -0,0 +1,37 @@
+#错误消息
+not.null=* 必须填写
+user.jcaptcha.error=验证码错误
+user.jcaptcha.expire=验证码已失效
+user.not.exists=用户不存在/密码错误
+user.password.not.match=用户不存在/密码错误
+user.password.retry.limit.count=密码输入错误{0}次
+user.password.retry.limit.exceed=密码输入错误{0}次,帐户锁定10分钟
+user.password.delete=对不起,您的账号已被删除
+user.blocked=用户已封禁,请联系管理员
+role.blocked=角色已封禁,请联系管理员
+user.logout.success=退出成功
+
+length.not.valid=长度必须在{min}到{max}个字符之间
+
+user.username.not.valid=* 2到20个汉字、字母、数字或下划线组成,且必须以非数字开头
+user.password.not.valid=* 5-50个字符
+ 
+user.email.not.valid=邮箱格式错误
+user.mobile.phone.number.not.valid=手机号格式错误
+user.login.success=登录成功
+user.register.success=注册成功
+user.notfound=请重新登录
+user.forcelogout=管理员强制退出,请重新登录
+user.unknown.error=未知错误,请重新登录
+
+##文件上传消息
+upload.exceed.maxSize=上传的文件大小超出限制的文件大小!<br/>允许的文件最大大小是:{0}MB!
+upload.filename.exceed.length=上传的文件名最长{0}个字符
+
+##权限
+no.permission=您没有数据的权限,请联系管理员添加权限 [{0}]
+no.create.permission=您没有创建数据的权限,请联系管理员添加权限 [{0}]
+no.update.permission=您没有修改数据的权限,请联系管理员添加权限 [{0}]
+no.delete.permission=您没有删除数据的权限,请联系管理员添加权限 [{0}]
+no.export.permission=您没有导出数据的权限,请联系管理员添加权限 [{0}]
+no.view.permission=您没有查看数据的权限,请联系管理员添加权限 [{0}]

+ 93 - 0
fs-wx-task/src/main/resources/logback.xml

@@ -0,0 +1,93 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<configuration>
+    <!-- 日志存放路径 -->
+	<property name="log.path" value="/home/fs-qw-task/logs" />
+    <!-- 日志输出格式 -->
+	<property name="log.pattern" value="%d{HH:mm:ss.SSS} [%thread] %-5level %logger{20} - [%method,%line] - %msg%n" />
+
+	<!-- 控制台输出 -->
+	<appender name="console" class="ch.qos.logback.core.ConsoleAppender">
+		<encoder>
+			<pattern>${log.pattern}</pattern>
+		</encoder>
+	</appender>
+
+	<!-- 系统日志输出 -->
+	<appender name="file_info" class="ch.qos.logback.core.rolling.RollingFileAppender">
+	    <file>${log.path}/sys-info.log</file>
+        <!-- 循环政策:基于时间创建日志文件 -->
+		<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <!-- 日志文件名格式 -->
+			<fileNamePattern>${log.path}/sys-info.%d{yyyy-MM-dd}.log</fileNamePattern>
+			<!-- 日志最大的历史 30 -->
+			<maxHistory>30</maxHistory>
+		</rollingPolicy>
+		<encoder>
+			<pattern>${log.pattern}</pattern>
+		</encoder>
+		<filter class="ch.qos.logback.classic.filter.LevelFilter">
+            <!-- 过滤的级别 -->
+            <level>INFO</level>
+            <!-- 匹配时的操作:接收(记录) -->
+            <onMatch>ACCEPT</onMatch>
+            <!-- 不匹配时的操作:拒绝(不记录) -->
+            <onMismatch>DENY</onMismatch>
+        </filter>
+	</appender>
+
+	<appender name="file_error" class="ch.qos.logback.core.rolling.RollingFileAppender">
+	    <file>${log.path}/sys-error.log</file>
+        <!-- 循环政策:基于时间创建日志文件 -->
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <!-- 日志文件名格式 -->
+            <fileNamePattern>${log.path}/sys-error.%d{yyyy-MM-dd}.log</fileNamePattern>
+			<!-- 日志最大的历史 30 -->
+			<maxHistory>30</maxHistory>
+        </rollingPolicy>
+        <encoder>
+            <pattern>${log.pattern}</pattern>
+        </encoder>
+        <filter class="ch.qos.logback.classic.filter.LevelFilter">
+            <!-- 过滤的级别 -->
+            <level>ERROR</level>
+			<!-- 匹配时的操作:接收(记录) -->
+            <onMatch>ACCEPT</onMatch>
+			<!-- 不匹配时的操作:拒绝(不记录) -->
+            <onMismatch>DENY</onMismatch>
+        </filter>
+    </appender>
+
+	<!-- 用户访问日志输出  -->
+    <appender name="sys-user" class="ch.qos.logback.core.rolling.RollingFileAppender">
+		<file>${log.path}/sys-user.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <!-- 按天回滚 daily -->
+            <fileNamePattern>${log.path}/sys-user.%d{yyyy-MM-dd}.log</fileNamePattern>
+            <!-- 日志最大的历史 30 -->
+            <maxHistory>30</maxHistory>
+        </rollingPolicy>
+        <encoder>
+            <pattern>${log.pattern}</pattern>
+        </encoder>
+    </appender>
+
+	<!-- 系统模块日志级别控制  -->
+	<logger name="com.fs" level="info" />
+	<!-- Spring日志级别控制  -->
+	<logger name="org.springframework" level="warn" />
+
+	<root level="info">
+		<appender-ref ref="console" />
+	</root>
+
+	<!--系统操作日志-->
+    <root level="info">
+        <appender-ref ref="file_info" />
+        <appender-ref ref="file_error" />
+    </root>
+
+	<!--系统用户操作日志-->
+    <logger name="sys-user" level="info">
+        <appender-ref ref="sys-user"/>
+    </logger>
+</configuration>

+ 19 - 0
fs-wx-task/src/main/resources/mybatis/mybatis-config.xml

@@ -0,0 +1,19 @@
+<?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>
+
+	<typeHandlers>
+		<typeHandler handler="com.fs.framework.config.ArrayStringTypeHandler"/>
+	</typeHandlers>
+
+</configuration>

+ 15 - 0
pom.xml

@@ -243,6 +243,20 @@
                 <version>${fs.version}</version>
             </dependency>
 
+            <!-- WebSocket服务-->
+            <dependency>
+                <groupId>com.fs</groupId>
+                <artifactId>fs-wx-task</artifactId>
+                <version>${fs.version}</version>
+            </dependency>
+
+            <!-- WebSocket服务-->
+            <dependency>
+                <groupId>com.fs</groupId>
+                <artifactId>fs-wx-api</artifactId>
+                <version>${fs.version}</version>
+            </dependency>
+
             <dependency>
                 <groupId>com.github.javen205</groupId>
                 <artifactId>IJPay-All</artifactId>
@@ -265,6 +279,7 @@
         <module>fs-doctor-app</module>
         <module>fs-ai-chat</module>
         <module>fs-wx-api</module>
+        <module>fs-wx-task</module>
         <module>fs-user-app-ai-chat</module>
         <module>fs-qwhook</module>
         <module>fs-qwhook-sop</module>