Sfoglia il codice sorgente

迁移wx三个板块代码

吴树波 1 settimana fa
parent
commit
e120f26ece
87 ha cambiato i file con 7352 aggiunte e 236 eliminazioni
  1. 1 1
      fs-qw-mq/src/main/java/com/fs/app/controller/CommonController.java
  2. 1 1
      fs-qw-voice/src/main/java/com/fs/app/controller/CommonController.java
  3. 1 1
      fs-qw-voice/src/main/java/com/fs/app/controller/VoiceController.java
  4. 6 0
      fs-service/src/main/java/com/fs/company/service/CompanyWorkflowEngine.java
  5. 2 0
      fs-service/src/main/java/com/fs/company/service/ICompanyVoiceRoboticService.java
  6. 18 0
      fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticServiceImpl.java
  7. 14 0
      fs-service/src/main/java/com/fs/company/service/impl/CompanyWorkflowEngineImpl.java
  8. 1 0
      fs-service/src/main/java/com/fs/crm/domain/CrmCustomer.java
  9. 24 0
      fs-service/src/main/java/com/fs/ipad/WxIpadSendUtils.java
  10. 6 0
      fs-service/src/main/java/com/fs/sop/mapper/QwSopTempRulesMapper.java
  11. 33 0
      fs-service/src/main/java/com/fs/sop/params/WxSopTagsParam.java
  12. 33 0
      fs-service/src/main/java/com/fs/sop/vo/QwSopTempRulesWithDayVO.java
  13. 63 0
      fs-service/src/main/java/com/fs/sop/vo/WxFilterSopCustomersResult.java
  14. 76 0
      fs-service/src/main/java/com/fs/wx/sop/domain/WxSop.java
  15. 92 0
      fs-service/src/main/java/com/fs/wx/sop/domain/WxSopLogs.java
  16. 53 0
      fs-service/src/main/java/com/fs/wx/sop/domain/WxSopUser.java
  17. 72 0
      fs-service/src/main/java/com/fs/wx/sop/domain/WxSopUserInfo.java
  18. 88 0
      fs-service/src/main/java/com/fs/wx/sop/mapper/WxSopLogsMapper.java
  19. 93 0
      fs-service/src/main/java/com/fs/wx/sop/mapper/WxSopMapper.java
  20. 79 0
      fs-service/src/main/java/com/fs/wx/sop/mapper/WxSopUserInfoMapper.java
  21. 94 0
      fs-service/src/main/java/com/fs/wx/sop/mapper/WxSopUserMapper.java
  22. 13 0
      fs-service/src/main/java/com/fs/wx/sop/params/SendWxSopMsgParam.java
  23. 12 0
      fs-service/src/main/java/com/fs/wx/sop/params/UpdateWxSopUserLogDateVo.java
  24. 57 0
      fs-service/src/main/java/com/fs/wx/sop/params/WxSopLogsParam.java
  25. 42 0
      fs-service/src/main/java/com/fs/wx/sop/service/IWxSopExecuteService.java
  26. 86 0
      fs-service/src/main/java/com/fs/wx/sop/service/IWxSopLogsService.java
  27. 79 0
      fs-service/src/main/java/com/fs/wx/sop/service/IWxSopService.java
  28. 62 0
      fs-service/src/main/java/com/fs/wx/sop/service/IWxSopUserInfoService.java
  29. 66 0
      fs-service/src/main/java/com/fs/wx/sop/service/IWxSopUserService.java
  30. 560 0
      fs-service/src/main/java/com/fs/wx/sop/service/impl/WxSopExecuteServiceImpl.java
  31. 320 0
      fs-service/src/main/java/com/fs/wx/sop/service/impl/WxSopLogsServiceImpl.java
  32. 240 0
      fs-service/src/main/java/com/fs/wx/sop/service/impl/WxSopServiceImpl.java
  33. 102 0
      fs-service/src/main/java/com/fs/wx/sop/service/impl/WxSopUserInfoServiceImpl.java
  34. 133 0
      fs-service/src/main/java/com/fs/wx/sop/service/impl/WxSopUserServiceImpl.java
  35. 110 0
      fs-service/src/main/java/com/fs/wx/sop/vo/WxSopLogsListVO.java
  36. 11 0
      fs-service/src/main/java/com/fs/wx/sop/vo/WxSopMsgVo.java
  37. 27 0
      fs-service/src/main/java/com/fs/wx/sop/vo/WxSopUserMsgGenVO.java
  38. 0 4
      fs-service/src/main/resources/application-dev.yml
  39. 33 0
      fs-service/src/main/resources/mapper/sop/QwSopTempRulesMapper.xml
  40. 237 0
      fs-service/src/main/resources/mapper/wx/WxSopLogsMapper.xml
  41. 180 0
      fs-service/src/main/resources/mapper/wx/WxSopMapper.xml
  42. 144 0
      fs-service/src/main/resources/mapper/wx/WxSopUserInfoMapper.xml
  43. 150 0
      fs-service/src/main/resources/mapper/wx/WxSopUserMapper.xml
  44. 2 3
      fs-wx-api/src/main/java/com/fs/app/controller/AppBaseController.java
  45. 3 2
      fs-wx-api/src/main/java/com/fs/app/controller/CommonController.java
  46. 1 1
      fs-wx-api/src/main/java/com/fs/app/interceptor/AuthorizationInterceptor.java
  47. 0 87
      fs-wx-api/src/main/java/com/fs/app/utils/JwtUtils.java
  48. 267 113
      fs-wx-api/src/main/java/com/fs/app/websocket/service/WebSocketServer.java
  49. 113 0
      fs-wx-api/src/main/java/com/fs/framework/datasource/TenantDataSourceManager.java
  50. 155 0
      fs-wx-api/src/main/resources/application-common.yml
  51. 137 0
      fs-wx-api/src/main/resources/application-config-dev.yml
  52. 132 0
      fs-wx-api/src/main/resources/application-dev.yml
  53. 4 1
      fs-wx-api/src/main/resources/application.yml
  54. 146 0
      fs-wx-ipad-task/pom.xml
  55. 14 0
      fs-wx-ipad-task/src/main/java/com/fs/FSServletInitializer.java
  56. 25 0
      fs-wx-ipad-task/src/main/java/com/fs/FsWxIpadTaskApplication.java
  57. 51 0
      fs-wx-ipad-task/src/main/java/com/fs/app/exception/FSException.java
  58. 82 0
      fs-wx-ipad-task/src/main/java/com/fs/app/exception/FSExceptionHandler.java
  59. 34 0
      fs-wx-ipad-task/src/main/java/com/fs/app/service/CustomThreadPoolConfig.java
  60. 65 0
      fs-wx-ipad-task/src/main/java/com/fs/app/service/WxIpadSendServer.java
  61. 261 0
      fs-wx-ipad-task/src/main/java/com/fs/app/task/SendMsg.java
  62. 175 0
      fs-wx-ipad-task/src/main/java/com/fs/framework/aspectj/SopTenantDataSourceAspect.java
  63. 31 0
      fs-wx-ipad-task/src/main/java/com/fs/framework/config/ApplicationConfig.java
  64. 115 0
      fs-wx-ipad-task/src/main/java/com/fs/framework/config/DataSourceConfig.java
  65. 123 0
      fs-wx-ipad-task/src/main/java/com/fs/framework/config/DruidConfig.java
  66. 150 0
      fs-wx-ipad-task/src/main/java/com/fs/framework/config/MyBatisConfig.java
  67. 76 0
      fs-wx-ipad-task/src/main/java/com/fs/framework/config/ResourcesConfig.java
  68. 77 0
      fs-wx-ipad-task/src/main/java/com/fs/framework/config/properties/DruidProperties.java
  69. 27 0
      fs-wx-ipad-task/src/main/java/com/fs/framework/datasource/DynamicDataSource.java
  70. 45 0
      fs-wx-ipad-task/src/main/java/com/fs/framework/datasource/DynamicDataSourceContextHolder.java
  71. 102 0
      fs-wx-ipad-task/src/main/java/com/fs/framework/datasource/TenantDataSourceManager.java
  72. 56 0
      fs-wx-ipad-task/src/main/java/com/fs/framework/interceptor/RepeatSubmitInterceptor.java
  73. 126 0
      fs-wx-ipad-task/src/main/java/com/fs/framework/interceptor/impl/SameUrlDataInterceptor.java
  74. 1 0
      fs-wx-ipad-task/src/main/resources/META-INF/spring-devtools.properties
  75. 155 0
      fs-wx-ipad-task/src/main/resources/application-common.yml
  76. 137 0
      fs-wx-ipad-task/src/main/resources/application-config-dev.yml
  77. 132 0
      fs-wx-ipad-task/src/main/resources/application-dev.yml
  78. 37 0
      fs-wx-ipad-task/src/main/resources/i18n/messages.properties
  79. 94 0
      fs-wx-ipad-task/src/main/resources/logback.xml
  80. 15 0
      fs-wx-ipad-task/src/main/resources/mybatis/mybatis-config.xml
  81. 206 12
      fs-wx-task/src/main/java/com/fs/app/service/WxTaskService.java
  82. 7 2
      fs-wx-task/src/main/java/com/fs/app/task/TenantTaskRunner.java
  83. 28 8
      fs-wx-task/src/main/java/com/fs/app/task/WxTask.java
  84. 155 0
      fs-wx-task/src/main/resources/application-common.yml
  85. 137 0
      fs-wx-task/src/main/resources/application-config-dev.yml
  86. 132 0
      fs-wx-task/src/main/resources/application-dev.yml
  87. 7 0
      pom.xml

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

@@ -13,10 +13,10 @@ import com.fs.qwApi.param.QwExternalContactRemarkParam;
 import com.fs.qwApi.service.QwApiService;
 import com.fs.voice.utils.StringUtil;
 import io.swagger.annotations.Api;
-import jdk.nashorn.internal.ir.annotations.Ignore;
 import lombok.AllArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.rocketmq.spring.core.RocketMQTemplate;
+import org.junit.Ignore;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.*;
 

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

@@ -9,7 +9,7 @@ import com.fs.fastgptApi.vo.AudioVO;
 import com.fs.sop.domain.QwSopTempVoice;
 import com.fs.sop.service.IQwSopTempVoiceService;
 import io.swagger.annotations.Api;
-import jdk.nashorn.internal.ir.annotations.Ignore;
+import org.junit.Ignore;
 import lombok.AllArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.ibatis.annotations.Param;

+ 1 - 1
fs-qw-voice/src/main/java/com/fs/app/controller/VoiceController.java

@@ -21,9 +21,9 @@ import com.fs.sop.service.IQwSopTempDayService;
 import com.fs.sop.service.IQwSopTempVoiceService;
 import com.fs.voice.utils.StringUtil;
 import io.swagger.annotations.Api;
-import jdk.nashorn.internal.ir.annotations.Ignore;
 import lombok.AllArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.junit.Ignore;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;

+ 6 - 0
fs-service/src/main/java/com/fs/company/service/CompanyWorkflowEngine.java

@@ -62,6 +62,12 @@ public interface CompanyWorkflowEngine {
      * @param inputData
      */
     void timeDoExecute(String workflowInstanceId, String nodeKey, Map<String, Object> inputData);
+    /**
+     * 更新exec记录的variables字段(用于暂停时同步延时上下文到DB)
+     * @param workflowInstanceId
+     * @param variables 最新的variables map
+     */
+    void updateExecVariables(String workflowInstanceId, Map<String, Object> variables);
     /**
      * 创建sip任务
      * @param roboticId

+ 2 - 0
fs-service/src/main/java/com/fs/company/service/ICompanyVoiceRoboticService.java

@@ -109,4 +109,6 @@ public interface ICompanyVoiceRoboticService extends IService<CompanyVoiceRoboti
     R pauseRoboticActive(PauseRoboticActiveParam param);
 
     void updateDelFlag(Long id, Integer delFlag);
+
+    boolean isTaskPaused(Long taskId);
 }

+ 18 - 0
fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticServiceImpl.java

@@ -1966,4 +1966,22 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
         robotic.setDelFlag(delFlag);
         companyVoiceRoboticMapper.updateById(robotic);
     }
+
+    @Override
+    public boolean isTaskPaused(Long taskId) {
+        if (taskId == null) return false;
+        // 优先从Redis读取
+        Integer status = redisCache2.getCacheObject("task:status:" + taskId);
+        if (status != null) {
+            return status == 2;
+        }
+        // Redis无数据则查DB
+        CompanyVoiceRobotic robotic = companyVoiceRoboticMapper.selectCompanyVoiceRoboticById(taskId);
+        if (robotic != null) {
+            // 回填Redis
+            redisCache2.setCacheObject("task:status:" + taskId, robotic.getTaskStatus());
+            return robotic.getTaskStatus() != null && robotic.getTaskStatus() == 2;
+        }
+        return false;
+    }
 }

+ 14 - 0
fs-service/src/main/java/com/fs/company/service/impl/CompanyWorkflowEngineImpl.java

@@ -623,4 +623,18 @@ public class CompanyWorkflowEngineImpl implements CompanyWorkflowEngine {
         return null;
     }
 
+    @Override
+    public void updateExecVariables(String workflowInstanceId, Map<String, Object> variables) {
+        try {
+            CompanyAiWorkflowExec update = new CompanyAiWorkflowExec();
+            update.setWorkflowInstanceId(workflowInstanceId);
+            update.setVariables(objectMapper.writeValueAsString(variables));
+            update.setLastUpdateTime(LocalDateTime.now());
+            currentExecutionMapper.updateByWorkflowInstanceId(update);
+            log.info("更新exec variables成功 - workflowInstanceId: {}", workflowInstanceId);
+        } catch (Exception e) {
+            log.error("更新exec variables失败 - workflowInstanceId: {}", workflowInstanceId, e);
+        }
+    }
+
 }

+ 1 - 0
fs-service/src/main/java/com/fs/crm/domain/CrmCustomer.java

@@ -177,6 +177,7 @@ public class CrmCustomer extends BaseEntity
     private String sourceCode;
 
     private String pushTime;
+    private Long wxContactId;
 
     private String pushCode;
     private String intention;

+ 24 - 0
fs-service/src/main/java/com/fs/ipad/WxIpadSendUtils.java

@@ -0,0 +1,24 @@
+package com.fs.ipad;
+
+
+import com.fs.ipad.vo.WxBaseVo;
+import com.fs.ipad.vo.WxTxtVo;
+import com.fs.wxwork.service.WxIpadService;
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+@Slf4j
+@Component
+@AllArgsConstructor
+public class WxIpadSendUtils {
+
+    private final WxIpadService wxIpadService;
+
+    public void sendTxt(WxBaseVo baseVo, String txt){
+        WxTxtVo vo = new WxTxtVo();
+        vo.setBase(baseVo);
+        vo.setContent(txt);
+        wxIpadService.sendTxt(vo);
+    }
+}

+ 6 - 0
fs-service/src/main/java/com/fs/sop/mapper/QwSopTempRulesMapper.java

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.fs.common.annotation.DataSource;
 import com.fs.common.enums.DataSourceType;
 import com.fs.sop.domain.QwSopTempRules;
+import com.fs.sop.vo.QwSopTempRulesWithDayVO;
 import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Select;
 import org.springframework.stereotype.Repository;
@@ -104,4 +105,9 @@ public interface QwSopTempRulesMapper extends BaseMapper<QwSopTempRules> {
     List<Long> getTempOfficialIdsForClose(@Param("tempId") String tempId);
 
     int updateTempRulesOfficialBatch(@Param("ids") List<Long> ids,@Param("official") Integer official);
+
+    /**
+     * 查询模板规则并关联 day_num(通过 qw_sop_temp_day)
+     */
+    List<QwSopTempRulesWithDayVO> listByTempIdWithDayNum(@Param("id") String id);
 }

+ 33 - 0
fs-service/src/main/java/com/fs/sop/params/WxSopTagsParam.java

@@ -0,0 +1,33 @@
+package com.fs.sop.params;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 个微SOP标签筛选参数类
+ *
+ * @author fs
+ * @date 2025-03-12
+ */
+@Data
+public class WxSopTagsParam {
+
+    /**
+     * 执行账号ID集合
+     */
+    private List<String> accountIdsSelectList;
+
+    /**
+     * 标签过滤类型(排除标签默认 只要有其一的就排除)
+     */
+    private Integer filterType;
+
+    /** 按标签筛选客户 */
+    private List<String> tagsIdsSelectList;
+
+    /** 按排除标签筛选客户 */
+    private List<String> outTagsIdsSelectList;
+
+    private String corpId;
+}

+ 33 - 0
fs-service/src/main/java/com/fs/sop/vo/QwSopTempRulesWithDayVO.java

@@ -0,0 +1,33 @@
+package com.fs.sop.vo;
+
+import lombok.Data;
+
+/**
+ * qw_sop_temp_rules 关联 day_num(content) 
+ * 用于个微消息生成流程
+ */
+@Data
+public class QwSopTempRulesWithDayVO {
+
+    private Long id;
+    private String tempId;
+    private Long dayId;
+    private String name;
+    private String time;
+    private String isOfficial;
+    private Integer contentType;
+    private Integer type;
+    private Integer courseType;
+    private Long courseId;
+    private Long videoId;
+    private String aiTouch;
+    private String addTag;
+    private String delTag;
+    private Integer sorts;
+    private Integer isAtAll;
+    private Long liveId;
+
+    private Integer dayNum;
+
+    private String textContent;
+}

+ 63 - 0
fs-service/src/main/java/com/fs/sop/vo/WxFilterSopCustomersResult.java

@@ -0,0 +1,63 @@
+package com.fs.sop.vo;
+
+import lombok.Data;
+
+/**
+ * 个微SOP筛选客户结果对象
+ *
+ * @author fs
+ * @date 2025-03-12
+ */
+@Data
+public class WxFilterSopCustomersResult {
+
+    /**
+     * 微信号ID
+     */
+    private String weixinId;
+
+    /**
+     * 客户ID
+     */
+    private String id;
+
+    /**
+     * CRM客户ID
+     */
+    private String customerId;
+
+    /**
+     * 客户名称
+     */
+    private String name;
+
+    /**
+     * 小程序用户ID
+     */
+    private String fsUserId;
+
+    /**
+     * 公司用户ID
+     */
+    private Long cuCompanyUserId;
+
+    /**
+     * 公司ID
+     */
+    private Long cuCompanyId;
+
+    /**
+     * 执行账号ID
+     */
+    private String accountId;
+
+    /**
+     * 企微用户ID
+     */
+    private String qwUserId;
+
+    /**
+     * 企业ID
+     */
+    private String corpId;
+}

+ 76 - 0
fs-service/src/main/java/com/fs/wx/sop/domain/WxSop.java

@@ -0,0 +1,76 @@
+package com.fs.wx.sop.domain;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fs.common.annotation.Excel;
+import com.fs.common.core.domain.BaseEntityTow;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 个微SOP对象 wx_sop
+ *
+ * @author 吴树波
+ * @date 2026-02-24
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class WxSop extends BaseEntityTow {
+
+    /** 名称 */
+    @Excel(name = "名称")
+    private String name;
+
+    /** 筛选方式(0标签1群聊) */
+    @Excel(name = "筛选方式(0标签1群聊)")
+    private Integer filterType;
+
+    /** 选择的标签 */
+    @Excel(name = "选择的标签")
+    private String selectTags;
+
+    /** 排查的标签 */
+    @Excel(name = "排查的标签")
+    private String excludeTags;
+
+    /** 模板ID */
+    @Excel(name = "模板ID")
+    private String tempId;
+
+    /** 公司ID */
+    @Excel(name = "公司ID")
+    private Long companyId;
+
+    /** 是否固定营期(0否1是) */
+    @Excel(name = "是否固定营期(0否1是)")
+    private Integer isFixed;
+
+    /** 过期时间(小时) */
+    @Excel(name = "过期时间(小时)")
+    private Integer expiryTime;
+
+    /** 营期开始时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    @Excel(name = "营期开始时间", width = 30, dateFormat = "yyyy-MM-dd")
+    private LocalDate startTime;
+
+    /** 执行账号ID,逗号分隔(入库) */
+    @Excel(name = "执行账号ID")
+    private String accountIds;
+
+    /** 执行账号列表(不入库,用于编辑回显) */
+    @TableField(exist = false)
+    private List<Map<String, Object>> selectedQwUsers;
+
+    /** 状态(0停止 1启用 2执行中) */
+    @Excel(name = "状态(0停止 1启用 2执行中)")
+    private Long status;
+
+    /** 备注 */
+    @Excel(name = "备注")
+    private String remark;
+}

+ 92 - 0
fs-service/src/main/java/com/fs/wx/sop/domain/WxSopLogs.java

@@ -0,0 +1,92 @@
+package com.fs.wx.sop.domain;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fs.common.annotation.Excel;
+import com.fs.common.core.domain.BaseEntityTow;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.time.LocalDateTime;
+
+/**
+ * 个微发送记录对象 wx_sop_logs
+ *
+ * @author 吴树波
+ * @date 2026-02-24
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class WxSopLogs extends BaseEntityTow {
+
+    /** 消息类型0个人1群 */
+    @Excel(name = "消息类型0个人1群")
+    private Integer type;
+
+    /** 任务ID */
+    @Excel(name = "任务ID")
+    private Long sopId;
+
+    /** 营期ID */
+    @Excel(name = "营期ID")
+    private Long sopUserId;
+
+    /** 发送类型(字典-wx_send_type) */
+    @Excel(name = "发送类型", readConverterExp = "字=典-wx_send_type")
+    private Integer sendType;
+
+    private LocalDateTime sendTime;
+
+    private String contentJson;
+
+    /** 生成类型(0自动1手动) */
+    @Excel(name = "生成类型(0自动1手动)")
+    private Integer generateType;
+
+    /** 发送账号ID */
+    @Excel(name = "发送账号ID")
+    private Long accountId;
+
+    /** 发送对象ID */
+    @Excel(name = "发送对象ID")
+    private Long wxContactId;
+
+    /** 发送对象名称 */
+    @Excel(name = "发送对象名称")
+    private String wxContactName;
+
+    /** 发送群聊ID */
+    @Excel(name = "发送群聊ID")
+    private Long wxRoomId;
+
+    /** 发送群聊名称 */
+    @Excel(name = "发送群聊名称")
+    private String wxRoomName;
+
+    /** 小程序ID */
+    @Excel(name = "小程序ID")
+    private Long fsUserId;
+
+    /** 发送状态0待发送1发送成功2发送失败3消息作废 */
+    @Excel(name = "发送状态0待发送1发送成功2发送失败3消息作废")
+    private Integer sendStatus;
+
+    /** 发送备注 */
+    @Excel(name = "发送备注")
+    private String sendRemark;
+
+    /** 发送排序 */
+    @Excel(name = "发送排序")
+    private Integer sendSort;
+
+    /** 消息过期时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    @Excel(name = "消息过期时间", width = 30, dateFormat = "yyyy-MM-dd")
+    private LocalDateTime expirationTime;
+    @TableField(exist = false)
+    private boolean send;
+    @TableField(exist = false)
+    private String wxRemark;
+
+
+}

+ 53 - 0
fs-service/src/main/java/com/fs/wx/sop/domain/WxSopUser.java

@@ -0,0 +1,53 @@
+package com.fs.wx.sop.domain;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fs.common.annotation.Excel;
+import com.fs.common.core.domain.BaseEntityTow;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.time.LocalDate;
+
+/**
+ * 个微营期对象 wx_sop_user
+ *
+ * @author 吴树波
+ * @date 2026-02-24
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("wx_sop_user")
+public class WxSopUser extends BaseEntityTow {
+
+    /** 类型(0个人1群聊) */
+    @Excel(name = "类型(0个人1群聊)")
+    private Integer type;
+
+    /** 任务ID */
+    @Excel(name = "任务ID")
+    private Long sopId;
+
+    /** 个微账号ID */
+    @Excel(name = "个微账号ID")
+    private Long accountId;
+
+    /** 个微账号名称 */
+    @Excel(name = "个微账号名称")
+    private String accountName;
+
+    /** 营期时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    @Excel(name = "营期时间", width = 30, dateFormat = "yyyy-MM-dd")
+    private LocalDate startTime;
+
+    /** 群聊ID */
+    @Excel(name = "群聊ID")
+    private String chatId;
+
+    /** 状态(0正常1暂停) */
+    @Excel(name = "状态", readConverterExp = "0=正常1暂停")
+    private Integer status;
+
+
+}

+ 72 - 0
fs-service/src/main/java/com/fs/wx/sop/domain/WxSopUserInfo.java

@@ -0,0 +1,72 @@
+package com.fs.wx.sop.domain;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fs.common.annotation.Excel;
+import com.fs.common.core.domain.BaseEntityTow;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.time.LocalDateTime;
+
+/**
+ * 个微营期详情对象 wx_sop_user_info
+ *
+ * @author 吴树波
+ * @date 2026-02-24
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("wx_sop_user_info")
+public class WxSopUserInfo extends BaseEntityTow {
+
+    /** 任务ID */
+    @Excel(name = "任务ID")
+    private Long sopId;
+
+    /** 营期ID */
+    @Excel(name = "营期ID")
+    private Long sopUserId;
+
+    /** 联系人ID */
+    @Excel(name = "联系人ID")
+    private Long wxContactId;
+
+    /** 客户ID */
+    @Excel(name = "客户ID")
+    private Long customerId;
+
+    /** 小程序ID */
+    @Excel(name = "小程序ID")
+    private Long fsUserId;
+
+    /** 是否7天都没有看课 0否 1是 */
+    @Excel(name = "是否7天都没有看课 0否 1是")
+    private Integer isDaysNotStudy;
+
+    /** 总完课天数 */
+    @Excel(name = "总完课天数")
+    private Integer finishCout;
+
+    /** 最近完课时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    @Excel(name = "最近完课时间", width = 30, dateFormat = "yyyy-MM-dd")
+    private LocalDateTime finishTime;
+
+    /** 连续完课天数 */
+    @Excel(name = "连续完课天数")
+    private Integer finishCourseDays;
+
+    /** 客户评级的等级 */
+    @Excel(name = "客户评级的等级")
+    private Integer grade;
+
+    /** 禁用状态 0 正常 1禁用 */
+    @Excel(name = "禁用状态 0 正常 1禁用")
+    private Integer status;
+
+    /** 客户标签名称 */
+    private String tagNames;
+
+
+}

+ 88 - 0
fs-service/src/main/java/com/fs/wx/sop/mapper/WxSopLogsMapper.java

@@ -0,0 +1,88 @@
+package com.fs.wx.sop.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.common.annotation.DataSource;
+import com.fs.common.enums.DataSourceType;
+import com.fs.wx.sop.domain.WxSopLogs;
+import com.fs.wx.sop.params.WxSopLogsParam;
+import com.fs.wx.sop.vo.WxSopLogsListVO;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 个微发送记录Mapper接口
+ *
+ * @author 吴树波
+ * @date 2026-02-24
+ */
+public interface WxSopLogsMapper extends BaseMapper<WxSopLogs>{
+    /**
+     * 查询个微发送记录
+     *
+     * @param id 个微发送记录主键
+     * @return 个微发送记录
+     */
+    
+    WxSopLogs selectWxSopLogsById(Long id);
+
+    /**
+     * 查询个微发送记录列表
+     *
+     * @param wxSopLogs 个微发送记录
+     * @return 个微发送记录集合
+     */
+    
+    List<WxSopLogs> selectWxSopLogsList(WxSopLogs wxSopLogs);
+
+    /**
+     * 查询个微SOP执行记录列表(带关联信息)
+     *
+     * @param param 查询参数
+     * @return 执行记录集合
+     */
+    
+    List<WxSopLogsListVO> selectWxSopLogsListBySopId(WxSopLogsParam param);
+
+    /**
+     * 新增个微发送记录
+     *
+     * @param wxSopLogs 个微发送记录
+     * @return 结果
+     */
+    
+    int insertWxSopLogs(WxSopLogs wxSopLogs);
+
+    /**
+     * 修改个微发送记录
+     *
+     * @param wxSopLogs 个微发送记录
+     * @return 结果
+     */
+    
+    int updateWxSopLogs(WxSopLogs wxSopLogs);
+
+    /**
+     * 删除个微发送记录
+     *
+     * @param id 个微发送记录主键
+     * @return 结果
+     */
+    
+    int deleteWxSopLogsById(Long id);
+
+    /**
+     * 批量删除个微发送记录
+     *
+     * @param ids 需要删除的数据主键集合
+     * @return 结果
+     */
+    
+    int deleteWxSopLogsByIds(Long[] ids);
+
+    
+    void batchInsertWxSopLogs(List<WxSopLogs> logsToInsert);
+
+    
+    List<WxSopLogs> selectByWxId(@Param("id") Long id);
+}

+ 93 - 0
fs-service/src/main/java/com/fs/wx/sop/mapper/WxSopMapper.java

@@ -0,0 +1,93 @@
+package com.fs.wx.sop.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.common.annotation.DataSource;
+import com.fs.common.enums.DataSourceType;
+import com.fs.sop.params.WxSopTagsParam;
+import com.fs.sop.vo.WxFilterSopCustomersResult;
+import com.fs.wx.sop.domain.WxSop;
+
+import java.util.List;
+
+/**
+ * 个微SOPMapper接口
+ *
+ * @author 吴树波
+ * @date 2026-02-24
+ */
+public interface WxSopMapper extends BaseMapper<WxSop>{
+    /**
+     * 查询个微SOP
+     *
+     * @param id 个微SOP主键
+     * @return 个微SOP
+     */
+    
+    WxSop selectWxSopById(Long id);
+
+    /**
+     * 查询个微SOP列表
+     *
+     * @param wxSop 个微SOP
+     * @return 个微SOP集合
+     */
+    
+    List<WxSop> selectWxSopList(WxSop wxSop);
+
+    /**
+     * 新增个微SOP
+     *
+     * @param wxSop 个微SOP
+     * @return 结果
+     */
+    int insertWxSop(WxSop wxSop);
+
+    /**
+     * 修改个微SOP
+     *
+     * @param wxSop 个微SOP
+     * @return 结果
+     */
+    int updateWxSop(WxSop wxSop);
+
+    /**
+     * 删除个微SOP
+     *
+     * @param id 个微SOP主键
+     * @return 结果
+     */
+    int deleteWxSopById(Long id);
+
+    /**
+     * 批量删除个微SOP
+     *
+     * @param ids 需要删除的数据主键集合
+     * @return 结果
+     */
+    int deleteWxSopByIds(Long[] ids);
+
+    /**
+     * 根据ID数组查询个微SOP列表
+     *
+     * @param ids 个微SOP主键数组
+     * @return 个微SOP集合
+     */
+    List<WxSop> selectWxSopByIds(Long[] ids);
+
+    /**
+     * 批量更新个微SOP状态
+     *
+     * @param ids 个微SOP主键数组
+     * @param status 状态
+     * @return 结果
+     */
+    int updateStatusWxSopByIds(Long[] ids, Long status);
+
+    /**
+     * 根据筛选条件查询符合条件的客户
+     *
+     * @param param 筛选参数
+     * @return 客户结果列表
+     */
+    List<WxFilterSopCustomersResult> selectFilterWxSopCustomers(WxSopTagsParam param);
+}

+ 79 - 0
fs-service/src/main/java/com/fs/wx/sop/mapper/WxSopUserInfoMapper.java

@@ -0,0 +1,79 @@
+package com.fs.wx.sop.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.common.annotation.DataSource;
+import com.fs.common.enums.DataSourceType;
+import com.fs.wx.sop.domain.WxSopUserInfo;
+
+import java.util.List;
+
+/**
+ * 个微营期详情Mapper接口
+ *
+ * @author 吴树波
+ * @date 2026-02-24
+ */
+public interface WxSopUserInfoMapper extends BaseMapper<WxSopUserInfo>{
+    /**
+     * 查询个微营期详情
+     *
+     * @param id 个微营期详情主键
+     * @return 个微营期详情
+     */
+    
+    WxSopUserInfo selectWxSopUserInfoById(Long id);
+
+    /**
+     * 查询个微营期详情列表
+     *
+     * @param wxSopUserInfo 个微营期详情
+     * @return 个微营期详情集合
+     */
+    
+    List<WxSopUserInfo> selectWxSopUserInfoList(WxSopUserInfo wxSopUserInfo);
+
+    /**
+     * 新增个微营期详情
+     *
+     * @param wxSopUserInfo 个微营期详情
+     * @return 结果
+     */
+    
+    int insertWxSopUserInfo(WxSopUserInfo wxSopUserInfo);
+
+    /**
+     * 修改个微营期详情
+     *
+     * @param wxSopUserInfo 个微营期详情
+     * @return 结果
+     */
+    
+    int updateWxSopUserInfo(WxSopUserInfo wxSopUserInfo);
+
+    /**
+     * 删除个微营期详情
+     *
+     * @param id 个微营期详情主键
+     * @return 结果
+     */
+    
+    int deleteWxSopUserInfoById(Long id);
+
+    /**
+     * 批量删除个微营期详情
+     *
+     * @param ids 需要删除的数据主键集合
+     * @return 结果
+     */
+    
+    int deleteWxSopUserInfoByIds(Long[] ids);
+
+    /**
+     * 根据条件查询单个营期成员记录
+     *
+     * @param wxSopUserInfo 个微营期详情
+     * @return 个微营期详情
+     */
+    
+    WxSopUserInfo selectWxSopUserInfoByCondition(WxSopUserInfo wxSopUserInfo);
+}

+ 94 - 0
fs-service/src/main/java/com/fs/wx/sop/mapper/WxSopUserMapper.java

@@ -0,0 +1,94 @@
+package com.fs.wx.sop.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.common.annotation.DataSource;
+import com.fs.common.enums.DataSourceType;
+import com.fs.wx.sop.domain.WxSopUser;
+import com.fs.wx.sop.params.UpdateWxSopUserLogDateVo;
+import com.fs.wx.sop.vo.WxSopUserMsgGenVO;
+
+import java.util.List;
+
+/**
+ * 个微营期Mapper接口
+ *
+ * @author 吴树波
+ * @date 2026-02-24
+ */
+
+public interface WxSopUserMapper extends BaseMapper<WxSopUser>{
+    /**
+     * 查询个微营期
+     *
+     * @param id 个微营期主键
+     * @return 个微营期
+     */
+    WxSopUser selectWxSopUserById(Long id);
+
+    /**
+     * 查询个微营期列表
+     *
+     * @param wxSopUser 个微营期
+     * @return 个微营期集合
+     */
+    
+    List<WxSopUser> selectWxSopUserList(WxSopUser wxSopUser);
+
+    /**
+     * 新增个微营期
+     *
+     * @param wxSopUser 个微营期
+     * @return 结果
+     */
+    int insertWxSopUser(WxSopUser wxSopUser);
+
+    /**
+     * 修改个微营期
+     *
+     * @param wxSopUser 个微营期
+     * @return 结果
+     */
+    int updateWxSopUser(WxSopUser wxSopUser);
+
+    /**
+     * 删除个微营期
+     *
+     * @param id 个微营期主键
+     * @return 结果
+     */
+    int deleteWxSopUserById(Long id);
+
+    /**
+     * 批量删除个微营期
+     *
+     * @param ids 需要删除的数据主键集合
+     * @return 结果
+     */
+    int deleteWxSopUserByIds(Long[] ids);
+
+    /**
+     * 根据SOP ID删除执行账号
+     *
+     * @param sopId SOP主键
+     * @return 结果
+     */
+    int deleteBySopId(Long sopId);
+
+    /**
+     * 查询营期记录(根据条件)
+     *
+     * @param wxSopUser 查询条件
+     * @return 营期记录
+     */
+    WxSopUser selectwxSopUser(WxSopUser wxSopUser);
+
+    /**
+     * 查询活跃的个微SOP营期及客户信息(用于消息生成)
+     *
+     * @return 营期客户信息列表
+     */
+    
+    List<WxSopUserMsgGenVO> selectActiveWxSopUserForMsgGen();
+
+    int updateWxSopUserDateById(UpdateWxSopUserLogDateVo vo);
+}

+ 13 - 0
fs-service/src/main/java/com/fs/wx/sop/params/SendWxSopMsgParam.java

@@ -0,0 +1,13 @@
+package com.fs.wx.sop.params;
+
+import lombok.Data;
+
+@Data
+public class SendWxSopMsgParam {
+    /** 选中的SOP ID数组 */
+    private Long[] sopIds;
+    /** 消息内容JSON,格式: [{"contentType":"1","value":"文本内容"}] */
+    private String setting;
+    /** 发送时间 HH:mm 格式,不填默认立即发送 */
+    private String sendTime;
+}

+ 12 - 0
fs-service/src/main/java/com/fs/wx/sop/params/UpdateWxSopUserLogDateVo.java

@@ -0,0 +1,12 @@
+package com.fs.wx.sop.params;
+
+import lombok.Data;
+
+import java.time.LocalDate;
+import java.util.List;
+
+@Data
+public class UpdateWxSopUserLogDateVo {
+    private List<String> ids;
+    private LocalDate newStartTime;
+}

+ 57 - 0
fs-service/src/main/java/com/fs/wx/sop/params/WxSopLogsParam.java

@@ -0,0 +1,57 @@
+package com.fs.wx.sop.params;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 个微SOP执行记录查询参数
+ *
+ * @author fs
+ * @date 2026-03-06
+ */
+@Data
+public class WxSopLogsParam {
+    
+    /** SOP ID */
+    private Long sopId;
+
+    /** 个微账号昵称(用于模糊搜索) */
+    private String accountName;
+
+    /** 个微账号ID */
+    private Long accountId;
+
+    /** 个微账号ID列表 */
+    private List<Long> accountIdList;
+
+    /** 客户昵称(用于模糊搜索) */
+    private String wxContactName;
+
+    /** 客户ID */
+    private Long wxContactId;
+
+    /** 公司ID */
+    private Long companyId;
+
+    /** 发送状态 0待发送1发送成功2发送失败3消息作废 */
+    private Integer sendStatus;
+
+    /** 发送类型 */
+    private Integer sendType;
+
+    /** 消息类型 0个人1群 */
+    private Integer type;
+
+    /** 营期ID */
+    private Long sopUserId;
+
+    /** 预计发送开始时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private String scheduleStartTime;
+
+    /** 预计发送结束时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private String scheduleEndTime;
+}

+ 42 - 0
fs-service/src/main/java/com/fs/wx/sop/service/IWxSopExecuteService.java

@@ -0,0 +1,42 @@
+package com.fs.wx.sop.service;
+
+import com.fs.common.core.domain.R;
+import com.fs.wx.sop.domain.WxSop;
+
+/**
+ * 个微SOP执行服务接口
+ *
+ * @author fs
+ * @date 2025-03-12
+ */
+public interface IWxSopExecuteService {
+    /**
+     * 根据标签筛选客户并创建营期
+     *
+     * @param wxSop 个微SOP
+     * @return 结果
+     */
+    R processTagFilterWxSop(WxSop wxSop);
+
+    /**
+     * 根据群聊筛选客户并创建营期
+     *
+     * @param wxSop 个微SOP
+     * @return 结果
+     */
+    R processGroupFilterWxSop(WxSop wxSop);
+
+    /**
+     * 为客户创建营期记录
+     *
+     * @param wxSop 个微SOP
+     * @return 结果
+     */
+    R createSopUserLogsWx(WxSop wxSop);
+
+    /**
+     * 处理客户标签变更
+     *
+     */
+    void processCustomerTagsChange(Long customerId);
+}

+ 86 - 0
fs-service/src/main/java/com/fs/wx/sop/service/IWxSopLogsService.java

@@ -0,0 +1,86 @@
+package com.fs.wx.sop.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.fs.common.core.domain.R;
+import com.fs.wx.sop.domain.WxSopLogs;
+import com.fs.wx.sop.params.SendWxSopMsgParam;
+import com.fs.wx.sop.params.WxSopLogsParam;
+import com.fs.wx.sop.vo.WxSopLogsListVO;
+
+import java.util.List;
+
+/**
+ * 个微发送记录Service接口
+ *
+ * @author 吴树波
+ * @date 2026-02-24
+ */
+public interface IWxSopLogsService extends IService<WxSopLogs>{
+    /**
+     * 查询个微发送记录
+     *
+     * @param id 个微发送记录主键
+     * @return 个微发送记录
+     */
+    WxSopLogs selectWxSopLogsById(Long id);
+
+    /**
+     * 查询个微发送记录列表
+     *
+     * @param wxSopLogs 个微发送记录
+     * @return 个微发送记录集合
+     */
+    List<WxSopLogs> selectWxSopLogsList(WxSopLogs wxSopLogs);
+
+    /**
+     * 查询个微SOP执行记录列表(带关联信息)
+     *
+     * @param param 查询参数
+     * @return 执行记录集合
+     */
+    List<WxSopLogsListVO> selectWxSopLogsListBySopId(WxSopLogsParam param);
+
+    /**
+     * 新增个微发送记录
+     *
+     * @param wxSopLogs 个微发送记录
+     * @return 结果
+     */
+    int insertWxSopLogs(WxSopLogs wxSopLogs);
+
+    /**
+     * 修改个微发送记录
+     *
+     * @param wxSopLogs 个微发送记录
+     * @return 结果
+     */
+    int updateWxSopLogs(WxSopLogs wxSopLogs);
+
+    /**
+     * 批量删除个微发送记录
+     *
+     * @param ids 需要删除的个微发送记录主键集合
+     * @return 结果
+     */
+    int deleteWxSopLogsByIds(Long[] ids);
+
+    /**
+     * 删除个微发送记录信息
+     *
+     * @param id 个微发送记录主键
+     * @return 结果
+     */
+    int deleteWxSopLogsById(Long id);
+
+    void batchInsertQwSopLogs(List<WxSopLogs> logsToInsert);
+
+    /**
+     * 个微SOP一键群发
+     *
+     * @param param 群发参数
+     * @return 结果
+     */
+    R sendWxSopMsg(SendWxSopMsgParam param);
+
+    boolean updateMapper(WxSopLogs updateQwSop);
+}

+ 79 - 0
fs-service/src/main/java/com/fs/wx/sop/service/IWxSopService.java

@@ -0,0 +1,79 @@
+package com.fs.wx.sop.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.fs.common.core.domain.R;
+import com.fs.wx.sop.domain.WxSop;
+
+import java.util.List;
+
+/**
+ * 个微SOPService接口
+ *
+ * @author 吴树波
+ * @date 2026-02-24
+ */
+public interface IWxSopService extends IService<WxSop>{
+    /**
+     * 查询个微SOP
+     *
+     * @param id 个微SOP主键
+     * @return 个微SOP
+     */
+    WxSop selectWxSopById(Long id);
+
+    /**
+     * 查询个微SOP详情(含执行账号 companyUserIds)
+     *
+     * @param id 个微SOP主键
+     * @return 个微SOP
+     */
+    WxSop selectWxSopDetailById(Long id);
+
+    /**
+     * 查询个微SOP列表
+     *
+     * @param wxSop 个微SOP
+     * @return 个微SOP集合
+     */
+    List<WxSop> selectWxSopList(WxSop wxSop);
+
+    /**
+     * 新增个微SOP
+     *
+     * @param wxSop 个微SOP
+     * @return 结果
+     */
+    int insertWxSop(WxSop wxSop);
+
+    /**
+     * 修改个微SOP
+     *
+     * @param wxSop 个微SOP
+     * @return 结果
+     */
+    int updateWxSop(WxSop wxSop);
+
+    /**
+     * 批量删除个微SOP
+     *
+     * @param ids 需要删除的个微SOP主键集合
+     * @return 结果
+     */
+    int deleteWxSopByIds(Long[] ids);
+
+    /**
+     * 删除个微SOP信息
+     *
+     * @param id 个微SOP主键
+     * @return 结果
+     */
+    int deleteWxSopById(Long id);
+
+    /**
+     * 批量执行个微SOP
+     *
+     * @param ids 个微SOP主键数组
+     * @return 结果
+     */
+    R updateStatusWxSopByIds(Long[] ids);
+}

+ 62 - 0
fs-service/src/main/java/com/fs/wx/sop/service/IWxSopUserInfoService.java

@@ -0,0 +1,62 @@
+package com.fs.wx.sop.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.fs.wx.sop.domain.WxSopUserInfo;
+
+import java.util.List;
+
+/**
+ * 个微营期详情Service接口
+ *
+ * @author 吴树波
+ * @date 2026-02-24
+ */
+public interface IWxSopUserInfoService extends IService<WxSopUserInfo>{
+    /**
+     * 查询个微营期详情
+     *
+     * @param id 个微营期详情主键
+     * @return 个微营期详情
+     */
+    WxSopUserInfo selectWxSopUserInfoById(Long id);
+
+    /**
+     * 查询个微营期详情列表
+     *
+     * @param wxSopUserInfo 个微营期详情
+     * @return 个微营期详情集合
+     */
+    List<WxSopUserInfo> selectWxSopUserInfoList(WxSopUserInfo wxSopUserInfo);
+
+    /**
+     * 新增个微营期详情
+     *
+     * @param wxSopUserInfo 个微营期详情
+     * @return 结果
+     */
+    int insertWxSopUserInfo(WxSopUserInfo wxSopUserInfo);
+
+    /**
+     * 修改个微营期详情
+     *
+     * @param wxSopUserInfo 个微营期详情
+     * @return 结果
+     */
+    int updateWxSopUserInfo(WxSopUserInfo wxSopUserInfo);
+
+    /**
+     * 批量删除个微营期详情
+     *
+     * @param ids 需要删除的个微营期详情主键集合
+     * @return 结果
+     */
+    int deleteWxSopUserInfoByIds(Long[] ids);
+
+    /**
+     * 删除个微营期详情信息
+     *
+     * @param id 个微营期详情主键
+     * @return 结果
+     */
+    int deleteWxSopUserInfoById(Long id);
+}

+ 66 - 0
fs-service/src/main/java/com/fs/wx/sop/service/IWxSopUserService.java

@@ -0,0 +1,66 @@
+package com.fs.wx.sop.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.fs.common.core.domain.R;
+import com.fs.wx.sop.domain.WxSopUser;
+import com.fs.wx.sop.params.UpdateWxSopUserLogDateVo;
+
+import java.util.List;
+
+/**
+ * 个微营期Service接口
+ *
+ * @author 吴树波
+ * @date 2026-02-24
+ */
+public interface IWxSopUserService extends IService<WxSopUser>{
+    /**
+     * 查询个微营期
+     *
+     * @param id 个微营期主键
+     * @return 个微营期
+     */
+    WxSopUser selectWxSopUserById(Long id);
+
+    /**
+     * 查询个微营期列表
+     *
+     * @param wxSopUser 个微营期
+     * @return 个微营期集合
+     */
+    List<WxSopUser> selectWxSopUserList(WxSopUser wxSopUser);
+
+    /**
+     * 新增个微营期
+     *
+     * @param wxSopUser 个微营期
+     * @return 结果
+     */
+    int insertWxSopUser(WxSopUser wxSopUser);
+
+    /**
+     * 修改个微营期
+     *
+     * @param wxSopUser 个微营期
+     * @return 结果
+     */
+    int updateWxSopUser(WxSopUser wxSopUser);
+
+    /**
+     * 批量删除个微营期
+     *
+     * @param ids 需要删除的个微营期主键集合
+     * @return 结果
+     */
+    int deleteWxSopUserByIds(Long[] ids);
+
+    /**
+     * 删除个微营期信息
+     *
+     * @param id 个微营期主键
+     * @return 结果
+     */
+    int deleteWxSopUserById(Long id);
+
+    R updateLogDate(UpdateWxSopUserLogDateVo vo);
+}

+ 560 - 0
fs-service/src/main/java/com/fs/wx/sop/service/impl/WxSopExecuteServiceImpl.java

@@ -0,0 +1,560 @@
+package com.fs.wx.sop.service.impl;
+
+
+import com.fs.common.annotation.DataSource;
+import com.fs.common.core.domain.R;
+import com.fs.common.enums.DataSourceType;
+import com.fs.crm.domain.CrmCustomer;
+import com.fs.crm.mapper.CrmCustomerMapper;
+import com.fs.sop.params.WxSopTagsParam;
+import com.fs.sop.vo.WxFilterSopCustomersResult;
+import com.fs.system.service.ISysDictDataService;
+import com.fs.wx.sop.domain.WxSop;
+import com.fs.wx.sop.domain.WxSopUser;
+import com.fs.wx.sop.domain.WxSopUserInfo;
+import com.fs.wx.sop.mapper.WxSopMapper;
+import com.fs.wx.sop.mapper.WxSopUserInfoMapper;
+import com.fs.wx.sop.mapper.WxSopUserMapper;
+import com.fs.wx.sop.service.IWxSopExecuteService;
+import com.fs.wxcid.mapper.WxContactMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 个微SOP执行服务实现类
+ *
+ * @author fs
+ * @date 2025-03-12
+ */
+@Service
+@Slf4j
+public class WxSopExecuteServiceImpl implements IWxSopExecuteService {
+
+    @Autowired
+    private WxSopMapper wxSopMapper;
+
+    @Autowired
+    private WxSopUserMapper wxSopUserMapper;
+
+    @Autowired
+    private WxSopUserInfoMapper wxSopUserInfoMapper;
+
+    @Autowired
+    private CrmCustomerMapper crmCustomerMapper;
+
+    @Autowired
+    private WxContactMapper wxContactMapper;
+
+    @Autowired
+    private ISysDictDataService sysDictDataService;
+
+    @Override
+    
+    public R processTagFilterWxSop(WxSop wxSop) {
+        try {
+            log.info("====== 开始执行标签筛选SOP ======");
+            log.info("SOP ID: {}, 名称: {}, 筛选标签: {}, 执行账号: {}",
+                    wxSop.getId(), wxSop.getName(), wxSop.getSelectTags(), wxSop.getAccountIds());
+
+            // 构建标签筛选参数
+            WxSopTagsParam wxSopTagsParam = buildWxSopTagsParam(wxSop);
+            log.info("筛选参数: accountIds={}, tags={}, excludeTags={}",
+                    wxSopTagsParam.getAccountIdsSelectList(),
+                    wxSopTagsParam.getTagsIdsSelectList(),
+                    wxSopTagsParam.getOutTagsIdsSelectList());
+
+            // 查询符合条件的客户
+            List<WxFilterSopCustomersResult> customerResults = selectFilterWxSopCustomers(wxSopTagsParam);
+            log.info("查询到的客户数量: {}", customerResults != null ? customerResults.size() : 0);
+
+            if (customerResults != null && !customerResults.isEmpty()) {
+                for (WxFilterSopCustomersResult customer : customerResults) {
+                    log.info("符合条件的客户: ID={}, 名称={}, 账号ID={}",
+                            customer.getId(), customer.getName(), customer.getAccountId());
+                }
+            }
+
+            if (customerResults == null || customerResults.isEmpty()) {
+                log.warn("未找到符合条件的客户,SOP ID: {}", wxSop.getId());
+                return R.error("未找到符合条件的客户");
+            }
+
+            // 为客户创建营期记录
+            createSopUserLogsWxForCustomers(wxSop, customerResults);
+
+            log.info("标签筛选SOP处理完成,SOP ID: {},客户数量: {}", wxSop.getId(), customerResults.size());
+            return R.ok("标签筛选SOP处理完成");
+        } catch (Exception e) {
+            log.error("处理标签筛选SOP时发生异常,SOP ID: {}", wxSop.getId(), e);
+            return R.error("处理过程中发生异常: " + e.getMessage());
+        }
+    }
+
+    @Override
+    
+    public R processGroupFilterWxSop(WxSop wxSop) {
+        try {
+            // 群聊筛选逻辑
+            // 这里需要根据群聊ID或其他群聊相关信息筛选客户
+            // 暂时留空实现,需要根据具体业务需求完善
+
+            log.info("群聊筛选SOP处理完成,SOP ID: {}", wxSop.getId());
+            return R.ok("群聊筛选SOP处理完成");
+        } catch (Exception e) {
+            log.error("处理群聊筛选SOP时发生异常,SOP ID: {}", wxSop.getId(), e);
+            return R.error("处理过程中发生异常: " + e.getMessage());
+        }
+    }
+
+    @Override
+    
+    public R createSopUserLogsWx(WxSop wxSop) {
+        try {
+            // 根据SOP的筛选方式进行不同的客户筛选
+            if (wxSop.getFilterType() != null && wxSop.getFilterType() == 0) { // 0: 标签筛选
+                return processTagFilterWxSop(wxSop);
+            } else if (wxSop.getFilterType() != null && wxSop.getFilterType() == 1) { // 1: 群聊筛选
+                return processGroupFilterWxSop(wxSop);
+            } else {
+                return R.error("未知的筛选方式");
+            }
+        } catch (Exception e) {
+            log.error("创建SOP营期记录时发生异常,SOP ID: {}", wxSop.getId(), e);
+            return R.error("创建营期记录时发生异常: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 构建标签筛选参数
+     *
+     * @param wxSop 个微SOP
+     * @return 标签筛选参数
+     */
+    private WxSopTagsParam buildWxSopTagsParam(WxSop wxSop) {
+        WxSopTagsParam param = new WxSopTagsParam();
+
+        // 设置执行账号ID列表
+        if (wxSop.getAccountIds() != null && !wxSop.getAccountIds().isEmpty()) {
+            String[] accountIds = wxSop.getAccountIds().split(",");
+            List<String> accountIdsList = new ArrayList<>();
+            for (String accountId : accountIds) {
+                if (accountId != null && !accountId.trim().isEmpty()) {
+                    accountIdsList.add(accountId.trim());
+                }
+            }
+            param.setAccountIdsSelectList(accountIdsList);
+        }
+
+        // 设置筛选标签
+        if (wxSop.getSelectTags() != null && !wxSop.getSelectTags().isEmpty()) {
+            String[] tags = wxSop.getSelectTags().split(",");
+            List<String> tagsList = new ArrayList<>();
+            for (String tag : tags) {
+                if (tag != null && !tag.trim().isEmpty()) {
+                    tagsList.add(tag.trim());
+                }
+            }
+            param.setTagsIdsSelectList(tagsList);
+        }
+
+        // 设置排除标签
+        if (wxSop.getExcludeTags() != null && !wxSop.getExcludeTags().isEmpty()) {
+            String[] excludeTags = wxSop.getExcludeTags().split(",");
+            List<String> excludeTagsList = new ArrayList<>();
+            for (String excludeTag : excludeTags) {
+                if (excludeTag != null && !excludeTag.trim().isEmpty()) {
+                    excludeTagsList.add(excludeTag.trim());
+                }
+            }
+            param.setOutTagsIdsSelectList(excludeTagsList);
+        }
+
+        // 设置筛选类型
+        param.setFilterType(wxSop.getFilterType());
+
+        return param;
+    }
+
+    /**
+     * 查询符合条件的客户
+     *
+     * @param param 标签筛选参数
+     * @return 客户结果列表
+     */
+    private List<WxFilterSopCustomersResult> selectFilterWxSopCustomers(WxSopTagsParam param) {
+        log.info("开始查询符合条件的客户...");
+        List<WxFilterSopCustomersResult> results = wxSopMapper.selectFilterWxSopCustomers(param);
+        log.info("客户查询完成,结果数: {}", results != null ? results.size() : 0);
+        return results;
+    }
+
+    /**
+     * 为客户创建营期记录
+     *
+     * @param wxSop 个微SOP
+     * @param customerResults 客户结果列表
+     */
+    @Transactional(rollbackFor = Exception.class)
+    
+    public void createSopUserLogsWxForCustomers(WxSop wxSop, List<WxFilterSopCustomersResult> customerResults) {
+        log.info("====== 开始为 {} 个客户创建营期记录 ======", customerResults.size());
+        // 按执行账号分组创建营期
+        for (WxFilterSopCustomersResult customer : customerResults) {
+            try {
+                log.info("处理客户: ID={}, 名称={}, 账号ID={}",
+                        customer.getId(), customer.getName(), customer.getAccountId());
+                Long sopUserId = getOrCreateWxSopUser(wxSop, customer);
+
+                if (sopUserId == null) {
+                    log.warn("创建营期失败,跳过客户:{}", customer.getId());
+                    continue;
+                }
+
+                log.info("营期ID: {}", sopUserId);
+
+                // 2. 检查客户是否已在该营期中
+                WxSopUserInfo queryParam = new WxSopUserInfo();
+                queryParam.setSopId(wxSop.getId());
+                queryParam.setSopUserId(sopUserId);
+                queryParam.setWxContactId(Long.parseLong(customer.getId()));
+
+                WxSopUserInfo existingInfo = wxSopUserInfoMapper.selectWxSopUserInfoByCondition(queryParam);
+                if (existingInfo != null) {
+                    log.info("客户已在营期中,跳过:客户ID={}, 营期ID={}", customer.getId(), sopUserId);
+                    continue;
+                }
+
+                // 3. 创建营期成员记录(wx_sop_user_info)
+                WxSopUserInfo wxSopUserInfo = new WxSopUserInfo();
+                wxSopUserInfo.setSopId(wxSop.getId());
+                wxSopUserInfo.setSopUserId(sopUserId);
+                wxSopUserInfo.setWxContactId(Long.parseLong(customer.getId()));
+                if (customer.getCustomerId() != null && !customer.getCustomerId().isEmpty()) {
+                    try {
+                        wxSopUserInfo.setCustomerId(Long.parseLong(customer.getCustomerId()));
+                    } catch (NumberFormatException e) {
+                        log.warn("客户ID格式错误:{}", customer.getCustomerId());
+                    }
+                }
+
+                // 设置小程序ID(如果有)
+                if (customer.getFsUserId() != null && !customer.getFsUserId().isEmpty()) {
+                    try {
+                        wxSopUserInfo.setFsUserId(Long.parseLong(customer.getFsUserId()));
+                    } catch (NumberFormatException e) {
+                        log.warn("小程序ID格式错误:{}", customer.getFsUserId());
+                    }
+                }
+
+                wxSopUserInfo.setStatus(0); // 正常状态
+
+                int result = wxSopUserInfoMapper.insertWxSopUserInfo(wxSopUserInfo);
+                log.info("成功添加客户到营期:客户ID={}, 营期ID={}, SOP ID={}, 插入结果={}",
+                        customer.getId(), sopUserId, wxSop.getId(), result);
+
+            } catch (Exception e) {
+                log.error("为客户创建营期记录失败,客户ID: {}", customer.getId(), e);
+                // 继续处理其他客户
+            }
+        }
+        log.info("====== 营期记录创建完成 ======");
+    }
+
+    /**
+     * 获取或创建营期主表记录
+     *
+     * @param wxSop 个微SOP
+     * @param customer 客户信息
+     * @return 营期ID
+     */
+    private Long getOrCreateWxSopUser(WxSop wxSop, WxFilterSopCustomersResult customer) {
+        try {
+            // 解析账号ID
+            Long accountId = null;
+            if (customer.getAccountId() != null && !customer.getAccountId().isEmpty()) {
+                accountId = Long.parseLong(customer.getAccountId());
+            } else {
+                log.warn("客户账号ID为空,客户ID: {}", customer.getId());
+                return null;
+            }
+
+            // 确定营期时间
+            LocalDate startTime;
+            if (wxSop.getIsFixed() != null && wxSop.getIsFixed() == 1) {
+                // 固定营期:使用SOP的开始时间
+                startTime = wxSop.getStartTime();
+            } else {
+                // 非固定营期:使用今天
+                startTime = LocalDate.now();
+            }
+
+            // 查询是否已存在营期
+            WxSopUser queryParam = new WxSopUser();
+            queryParam.setSopId(wxSop.getId());
+            queryParam.setType(0); // 个人类型
+            queryParam.setAccountId(accountId);
+            queryParam.setStartTime(startTime);
+
+            WxSopUser existingUser = wxSopUserMapper.selectwxSopUser(queryParam);
+            if (existingUser != null) {
+                return existingUser.getId();
+            }
+
+            // 创建新的营期记录
+            WxSopUser wxSopUser = new WxSopUser();
+            wxSopUser.setType(0); // 个人类型
+            wxSopUser.setSopId(wxSop.getId());
+            wxSopUser.setAccountId(accountId);
+            wxSopUser.setStartTime(startTime);
+            wxSopUser.setStatus(0); // 正常状态
+
+            int result = wxSopUserMapper.insertWxSopUser(wxSopUser);
+            if (result > 0) {
+                log.info("创建营期成功:SOP ID={}, 账号ID={}, 营期时间={}, 营期ID={}",
+                        wxSop.getId(), accountId, startTime, wxSopUser.getId());
+                return wxSopUser.getId();
+            } else {
+                log.error("创建营期失败:SOP ID={}, 账号ID={}", wxSop.getId(), accountId);
+                return null;
+            }
+        } catch (Exception e) {
+            log.error("获取或创建营期失败", e);
+            return null;
+        }
+    }
+
+    /**
+     * 处理客户标签变更后的SOP营期动态管理
+     * 根据客户最新标签,自动加入或移出符合条件的营期
+     *
+     * @param customerId 客户ID
+     */
+    @Override
+    public void processCustomerTagsChange(Long customerId) {
+        try {
+            log.info("====== 开始处理客户标签变更,客户ID: {} ======", customerId);
+
+            CrmCustomer customer = crmCustomerMapper.selectCrmCustomerById(customerId);
+            if (customer == null) {
+                log.warn("客户不存在,客户ID: {}", customerId);
+                return;
+            }
+            log.info("客户信息: ID={}, 名称={}, 标签={}", customerId, customer.getCustomerName(), customer.getTags());
+
+            WxSop sopQuery = new WxSop();
+            sopQuery.setStatus(2L);
+            sopQuery.setFilterType(0);
+            List<WxSop> enabledSops = wxSopMapper.selectWxSopList(sopQuery);
+            log.info("查询到 {} 个启用的标签筛选SOP", enabledSops != null ? enabledSops.size() : 0);
+
+            if (enabledSops == null || enabledSops.isEmpty()) {
+                log.info("没有启用的标签筛选SOP,无需处理");
+                return;
+            }
+
+            for (WxSop sop : enabledSops) {
+                processCustomerForSop(customer, sop);
+            }
+
+            log.info("====== 客户标签变更处理完成,客户ID: {} ======", customerId);
+        } catch (Exception e) {
+            log.error("处理客户标签变更时发生异常,客户ID: {}", customerId, e);
+            throw e;
+        }
+    }
+
+
+
+
+    /**
+     * 处理单个客户在单个SOP中的营期管理
+     *
+     * @param customer 客户信息
+     * @param sop SOP信息
+     */
+    public void processCustomerForSop(CrmCustomer customer, WxSop sop) {
+        try {
+            log.info("检查客户是否符合SOP条件 - 客户ID: {}, SOP ID: {}, SOP名称: {}",
+                    customer.getCustomerId(), sop.getId(), sop.getName());
+
+            boolean isMatch = checkCustomerMatchSopTags(customer, sop);
+            log.info("客户是否符合SOP条件: {}", isMatch);
+
+
+            List<WxSopUserInfo> existingInfos = querySopUserInfosByCustomer(sop.getId(), customer.getCustomerId());
+
+            if (isMatch) {
+                // 符合条件:如果不在营期中,则加入
+                if (existingInfos == null || existingInfos.isEmpty()) {
+                    log.info("客户符合条件且不在营期中,准备加入营期");
+                    addCustomerToSop(customer, sop);
+                } else {
+                    log.info("客户已在营期中,无需重复加入");
+                }
+            } else {
+                // 不符合条件:如果在营期中,则移出
+                if (existingInfos != null && !existingInfos.isEmpty()) {
+                    log.info("客户不符合条件但在营期中,准备移出营期,记录数: {}", existingInfos.size());
+                    removeCustomerFromSop(customer, sop, existingInfos);
+                } else {
+                    log.info("客户不符合条件且不在营期中,无需处理");
+                }
+            }
+        } catch (Exception e) {
+            log.error("处理客户在SOP中的营期管理时发生异常,客户ID: {}, SOP ID: {}",
+                    customer.getCustomerId(), sop.getId(), e);
+        }
+    }
+
+    /**
+     * 查询客户在指定SOP下的所有营期成员记录
+     *
+     * @param sopId SOP ID
+     * @param customerId 客户ID
+     * @return 营期成员记录列表
+     */
+    public List<WxSopUserInfo> querySopUserInfosByCustomer(Long sopId, Long customerId) {
+        WxSopUserInfo queryParam = new WxSopUserInfo();
+        queryParam.setSopId(sopId);
+        queryParam.setCustomerId(customerId);
+        return wxSopUserInfoMapper.selectWxSopUserInfoList(queryParam);
+    }
+
+    /**
+     * 检查客户是否符合SOP的标签筛选条件
+     * 注意:客户tags字段与SOP的selectTags/excludeTags统一使用标签值(label)进行比对
+     *
+     * @param customer 客户信息
+     * @param sop SOP信息
+     * @return true-符合条件,false-不符合条件
+     */
+    private boolean checkCustomerMatchSopTags(CrmCustomer customer, WxSop sop) {
+        String customerTags = customer.getTags();
+        if (customerTags == null || customerTags.trim().isEmpty()) {
+            log.info("客户标签为空");
+            // 如果客户没有标签,但SOP要求有筛选标签,则不符合
+            return sop.getSelectTags() == null || sop.getSelectTags().trim().isEmpty();
+        }
+
+        // 客户的标签列表(逗号分隔的标签值)- 需要将dict_label转换为dict_value
+        String[] customerTagArray = customerTags.split(",");
+        List<String> customerTagIdList = new ArrayList<>();
+        for (String tagLabel : customerTagArray) {
+            if (tagLabel != null && !tagLabel.trim().isEmpty()) {
+                // 将标签名(dict_label)转换为标签ID(dict_value)
+                String tagId = com.fs.common.utils.DictUtils.getDictValue("crm_customer_tag", tagLabel.trim());
+                if (tagId != null && !tagId.isEmpty()) {
+                    customerTagIdList.add(tagId);
+                }
+            }
+        }
+        log.info("客户标签ID列表: {}", customerTagIdList);
+
+        // 检查必须包含的标签(selectTags)- 直接使用标签ID比对
+        if (sop.getSelectTags() != null && !sop.getSelectTags().trim().isEmpty()) {
+            String[] selectTags = sop.getSelectTags().split(",");
+            for (String tag : selectTags) {
+                if (tag != null && !tag.trim().isEmpty()) {
+                    String trimmedTag = tag.trim();
+                    if (!customerTagIdList.contains(trimmedTag)) {
+                        log.info("客户缺少必需标签ID: {}", trimmedTag);
+                        return false;
+                    }
+                }
+            }
+            log.info("客户包含所有必需标签");
+        }
+
+        // 检查排除的标签(excludeTags)- 直接使用标签ID比对
+        if (sop.getExcludeTags() != null && !sop.getExcludeTags().trim().isEmpty()) {
+            String[] excludeTags = sop.getExcludeTags().split(",");
+            for (String tag : excludeTags) {
+                if (tag != null && !tag.trim().isEmpty()) {
+                    String trimmedTag = tag.trim();
+                    if (customerTagIdList.contains(trimmedTag)) {
+                        log.info("客户包含排除标签ID: {}", trimmedTag);
+                        return false;
+                    }
+                }
+            }
+            log.info("客户不包含排除标签");
+        }
+
+        log.info("客户符合SOP标签筛选条件");
+        return true;
+    }
+
+    /**
+     * 将客户加入SOP营期
+     *
+     * @param customer 客户信息
+     * @param sop SOP信息
+     */
+    
+    public void addCustomerToSop(CrmCustomer customer, WxSop sop) {
+        try {
+            WxFilterSopCustomersResult customerResult = new WxFilterSopCustomersResult();
+            customerResult.setId(customer.getCustomerId().toString());
+            customerResult.setName(customer.getCustomerName());
+            if (sop.getAccountIds() != null && !sop.getAccountIds().isEmpty()) {
+                String[] accountIds = sop.getAccountIds().split(",");
+                if (accountIds.length > 0) {
+                    customerResult.setAccountId(accountIds[0].trim());
+                }
+            }
+
+            if (customerResult.getAccountId() == null) {
+                log.warn("无法获取执行账号,跳过加入营期");
+                return;
+            }
+
+            // 获取或创建营期
+            Long sopUserId = getOrCreateWxSopUser(sop, customerResult);
+            if (sopUserId == null) {
+                log.warn("创建营期失败,跳过");
+                return;
+            }
+
+            // 创建营期成员记录
+            WxSopUserInfo wxSopUserInfo = new WxSopUserInfo();
+            wxSopUserInfo.setSopId(sop.getId());
+            wxSopUserInfo.setSopUserId(sopUserId);
+            wxSopUserInfo.setCustomerId(customer.getCustomerId());
+            wxSopUserInfo.setWxContactId(customer.getWxContactId());
+            wxSopUserInfo.setStatus(0); // 正常状态
+            wxSopUserInfo.setTagNames(customer.getTags());
+
+            int result = wxSopUserInfoMapper.insertWxSopUserInfo(wxSopUserInfo);
+            log.info("成功将客户加入营期:客户ID={}, 营期ID={}, SOP ID={}, 插入结果={}",
+                    customer.getCustomerId(), sopUserId, sop.getId(), result);
+        } catch (Exception e) {
+            log.error("将客户加入SOP营期时发生异常", e);
+        }
+    }
+
+    /**
+     * 将客户从SOP营期中移出
+     *
+     * @param customer 客户信息
+     * @param sop SOP信息
+     * @param existingInfos 已存在的营期成员记录
+     */
+    
+    public void removeCustomerFromSop(CrmCustomer customer, WxSop sop, List<WxSopUserInfo> existingInfos) {
+        try {
+            for (WxSopUserInfo info : existingInfos) {
+                int result = wxSopUserInfoMapper.deleteWxSopUserInfoById(info.getId());
+                log.info("成功将客户从营期中移出:客户ID={}, 营期成员记录ID={}, SOP ID={}, 删除结果={}",
+                        customer.getCustomerId(), info.getId(), sop.getId(), result);
+            }
+        } catch (Exception e) {
+            log.error("将客户从SOP营期中移出时发生异常", e);
+        }
+    }
+}

+ 320 - 0
fs-service/src/main/java/com/fs/wx/sop/service/impl/WxSopLogsServiceImpl.java

@@ -0,0 +1,320 @@
+package com.fs.wx.sop.service.impl;
+
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.fs.common.annotation.DataSource;
+import com.fs.common.core.domain.R;
+import com.fs.common.enums.DataSourceType;
+import com.fs.common.utils.DateUtils;
+import com.fs.common.utils.PubFun;
+import com.fs.common.utils.date.DateUtil;
+import com.fs.company.domain.CompanyWxAccount;
+import com.fs.company.mapper.CompanyWxAccountMapper;
+import com.fs.crm.domain.CrmCustomer;
+import com.fs.crm.mapper.CrmCustomerMapper;
+import com.fs.wx.sop.domain.WxSopLogs;
+import com.fs.wx.sop.domain.WxSopUser;
+import com.fs.wx.sop.domain.WxSopUserInfo;
+import com.fs.wx.sop.mapper.WxSopLogsMapper;
+import com.fs.wx.sop.mapper.WxSopUserInfoMapper;
+import com.fs.wx.sop.mapper.WxSopUserMapper;
+import com.fs.wx.sop.params.SendWxSopMsgParam;
+import com.fs.wx.sop.params.WxSopLogsParam;
+import com.fs.wx.sop.service.IWxSopLogsService;
+import com.fs.wx.sop.vo.WxSopLogsListVO;
+import com.fs.wxcid.domain.WxContact;
+import com.fs.wxcid.mapper.WxContactMapper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.text.SimpleDateFormat;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 个微发送记录Service业务层处理
+ *
+ * @author 吴树波
+ * @date 2026-02-24
+ */
+@Service
+public class WxSopLogsServiceImpl extends ServiceImpl<WxSopLogsMapper, WxSopLogs> implements IWxSopLogsService {
+    @Autowired
+    private WxSopLogsMapper wxSopLogsMapper;
+
+    @Autowired
+    private WxSopUserMapper wxSopUserMapper;
+
+    @Autowired
+    private WxSopUserInfoMapper wxSopUserInfoMapper;
+    @Autowired
+    private CompanyWxAccountMapper companyWxAccountMapper;
+    @Autowired
+    private WxContactMapper wxContactMapper;
+    @Autowired
+    private CrmCustomerMapper crmCustomerMapper;
+
+    /**
+     * 查询个微发送记录
+     *
+     * @param id 个微发送记录主键
+     * @return 个微发送记录
+     */
+    @Override
+    
+    public WxSopLogs selectWxSopLogsById(Long id)
+    {
+        return baseMapper.selectWxSopLogsById(id);
+    }
+
+    /**
+     * 查询个微发送记录列表
+     * 
+     * @param wxSopLogs 个微发送记录
+     * @return 个微发送记录
+     */
+    @Override
+    
+    public List<WxSopLogs> selectWxSopLogsList(WxSopLogs wxSopLogs)
+    {
+        return baseMapper.selectWxSopLogsList(wxSopLogs);
+    }
+
+    /**
+     * 新增个微发送记录
+     * 
+     * @param wxSopLogs 个微发送记录
+     * @return 结果
+     */
+    @Override
+    
+    public int insertWxSopLogs(WxSopLogs wxSopLogs)
+    {
+        wxSopLogs.setCreateTime(DateUtils.getNowDate());
+        return baseMapper.insertWxSopLogs(wxSopLogs);
+    }
+
+    /**
+     * 修改个微发送记录
+     * 
+     * @param wxSopLogs 个微发送记录
+     * @return 结果
+     */
+    @Override
+    
+    public int updateWxSopLogs(WxSopLogs wxSopLogs)
+    {
+        wxSopLogs.setUpdateTime(DateUtils.getNowDate());
+        return baseMapper.updateWxSopLogs(wxSopLogs);
+    }
+
+    /**
+     * 批量删除个微发送记录
+     * 
+     * @param ids 需要删除的个微发送记录主键
+     * @return 结果
+     */
+    @Override
+    
+    public int deleteWxSopLogsByIds(Long[] ids)
+    {
+        return baseMapper.deleteWxSopLogsByIds(ids);
+    }
+
+    /**
+     * 删除个微发送记录信息
+     * 
+     * @param id 个微发送记录主键
+     * @return 结果
+     */
+    @Override
+    
+    public int deleteWxSopLogsById(Long id)
+    {
+        return baseMapper.deleteWxSopLogsById(id);
+    }
+
+    /**
+     * 查询个微SOP执行记录列表(带关联信息)
+     *
+     * @param param 查询参数
+     * @return 执行记录集合
+     */
+    @Override
+    public List<WxSopLogsListVO> selectWxSopLogsListBySopId(WxSopLogsParam param){
+        List<WxSopLogsListVO> list = baseMapper.selectWxSopLogsListBySopId(param);
+        if(list.isEmpty()){
+            return Collections.emptyList();
+        }
+        List<Long> longs = PubFun.listToNewList(list, WxSopLogsListVO::getAccountId);
+        List<CompanyWxAccount> companyWxAccounts = companyWxAccountMapper.selectBatchIds(longs);
+        Map<Long, CompanyWxAccount> accountMap = PubFun.listToMapByGroupObject(companyWxAccounts, CompanyWxAccount::getId);
+        list.parallelStream().filter(e -> accountMap.containsKey(e.getAccountId())).forEach(e -> {
+           e.setAccountName(accountMap.get(e.getAccountId()).getWxNickName());
+        });
+        return list;
+    }
+
+    
+    public void batchInsertQwSopLogs(List<WxSopLogs> logsToInsert) {
+        if(logsToInsert == null || logsToInsert.isEmpty()) return;
+        wxSopLogsMapper.batchInsertWxSopLogs(logsToInsert);
+    }
+
+    /**
+     * 个微SOP一键群发
+     * 注意:不加 ,因为需要跨库查询
+     * SOP相关Mapper方法自带  注解
+     * wx_contact、crm_customer 在主库,使用默认数据源
+     */
+    @Override
+    public R sendWxSopMsg(SendWxSopMsgParam param) {
+        if (param.getSopIds() == null || param.getSopIds().length == 0) {
+            return R.error("请选择要群发的SOP");
+        }
+
+        // 1. 解析发送时间:前端选了就用前端的,没选就用当前时间
+        String sendTimeStr;
+        if (param.getSendTime() != null && !param.getSendTime().isEmpty()) {
+            SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+            sendTimeStr = sdf.format(new Date()) + " " + param.getSendTime() + ":00";
+        } else {
+            SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
+            sendTimeStr = sdf.format(new Date());
+        }
+
+        List<WxSopLogs> logsToInsert = new ArrayList<>();
+        // 收集所有联系人ID和客户ID,用于批量查询
+        List<Long> allContactIds = new ArrayList<>();
+        // 临时存储:sopUser + userInfos 的关系
+        List<Object[]> sopUserAndInfos = new ArrayList<>();
+
+        // 2. 遍历每个SOP,从SOP数据库查询营期和成员
+        for (Long sopId : param.getSopIds()) {
+            WxSopUser query = new WxSopUser();
+            query.setSopId(sopId);
+            // wxSopUserMapper.selectWxSopUserList 自带 
+            List<WxSopUser> sopUsers = wxSopUserMapper.selectWxSopUserList(query);
+
+            if (sopUsers == null || sopUsers.isEmpty()) {
+                continue;
+            }
+
+            for (WxSopUser sopUser : sopUsers) {
+                WxSopUserInfo userInfoQuery = new WxSopUserInfo();
+                userInfoQuery.setSopUserId(sopUser.getId());
+                // wxSopUserInfoMapper.selectWxSopUserInfoList 自带 
+                List<WxSopUserInfo> userInfos = wxSopUserInfoMapper.selectWxSopUserInfoList(userInfoQuery);
+
+                if (userInfos == null || userInfos.isEmpty()) {
+                    continue;
+                }
+
+                sopUserAndInfos.add(new Object[]{sopId, sopUser, userInfos});
+
+                for (WxSopUserInfo info : userInfos) {
+                    if (info.getWxContactId() != null) {
+                        allContactIds.add(info.getWxContactId());
+                    }
+                }
+            }
+        }
+
+        if (sopUserAndInfos.isEmpty()) {
+            return R.error("未找到可群发的营期成员");
+        }
+
+        // 3. 从主库批量查询 wx_contact 获取联系人昵称
+        Map<Long, WxContact> contactMap = new HashMap<>();
+        Map<Long, CrmCustomer> customerMap = new HashMap<>();
+        if (!allContactIds.isEmpty()) {
+            List<Long> uniqueContactIds = allContactIds.stream().distinct().collect(Collectors.toList());
+            List<WxContact> contacts = wxContactMapper.selectBatchIds(uniqueContactIds);
+            if (contacts != null) {
+                for (WxContact c : contacts) {
+                    contactMap.put(c.getId(), c);
+                }
+                // 4. 收集customerIds,从主库查询 crm_customer 获取客户标签
+                List<Long> customerIds = contacts.stream()
+                        .filter(c -> c.getCustomerId() != null)
+                        .map(WxContact::getCustomerId)
+                        .distinct()
+                        .collect(Collectors.toList());
+                if (!customerIds.isEmpty()) {
+                    List<CrmCustomer> customers = crmCustomerMapper.selectBatchIds(customerIds);
+                    if (customers != null) {
+                        for (CrmCustomer cust : customers) {
+                            customerMap.put(cust.getCustomerId(), cust);
+                        }
+                    }
+                }
+            }
+        }
+
+        // 5. 遍历构建发送记录,设置联系人昵称
+        // 同时收集需要更新标签的 userInfo
+        List<WxSopUserInfo> userInfosToUpdateTag = new ArrayList<>();
+        for (Object[] arr : sopUserAndInfos) {
+            Long sopId = (Long) arr[0];
+            WxSopUser sopUser = (WxSopUser) arr[1];
+            @SuppressWarnings("unchecked")
+            List<WxSopUserInfo> userInfos = (List<WxSopUserInfo>) arr[2];
+
+            for (WxSopUserInfo userInfo : userInfos) {
+                WxSopLogs log = new WxSopLogs();
+                log.setType(0);
+                log.setSopId(sopId);
+                log.setSopUserId(sopUser.getId());
+                log.setGenerateType(1);
+                log.setAccountId(sopUser.getAccountId());
+                log.setWxContactId(userInfo.getWxContactId());
+                log.setFsUserId(userInfo.getFsUserId());
+                log.setSendStatus(0);
+                log.setSendSort(30000000);
+                log.setContentJson(param.getSetting());
+                log.setSendTime(DateUtil.stringToLocalDateTime(sendTimeStr));
+                log.setCreateTime(new Date());
+
+                // 设置联系人昵称
+                WxContact contact = contactMap.get(userInfo.getWxContactId());
+                if (contact != null) {
+                    log.setWxContactName(contact.getNickName());
+
+                    // 更新 wx_sop_user_info 的标签(如果为空)
+                    if ((userInfo.getTagNames() == null || userInfo.getTagNames().isEmpty())
+                            && contact.getCustomerId() != null) {
+                        CrmCustomer customer = customerMap.get(contact.getCustomerId());
+                        if (customer != null && customer.getTags() != null && !customer.getTags().isEmpty()) {
+                            userInfo.setTagNames(customer.getTags());
+                            userInfosToUpdateTag.add(userInfo);
+                        }
+                    }
+                }
+
+                logsToInsert.add(log);
+            }
+        }
+
+        if (logsToInsert.isEmpty()) {
+            return R.error("未找到可群发的营期成员");
+        }
+
+        // 6. 更新 wx_sop_user_info 的标签信息(SOP数据库)
+        for (WxSopUserInfo info : userInfosToUpdateTag) {
+            WxSopUserInfo updateInfo = new WxSopUserInfo();
+            updateInfo.setId(info.getId());
+            updateInfo.setTagNames(info.getTagNames());
+            wxSopUserInfoMapper.updateWxSopUserInfo(updateInfo);
+        }
+
+        // 7. 批量插入发送记录到SOP数据库
+        batchInsertQwSopLogs(logsToInsert);
+
+        return R.ok("一键群发成功,共发送 " + logsToInsert.size() + " 条消息");
+    }
+
+    @Override
+    
+    public boolean updateMapper(WxSopLogs updateQwSop) {
+        return updateById(updateQwSop);
+    }
+}

+ 240 - 0
fs-service/src/main/java/com/fs/wx/sop/service/impl/WxSopServiceImpl.java

@@ -0,0 +1,240 @@
+package com.fs.wx.sop.service.impl;
+
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.fs.common.annotation.DataSource;
+import com.fs.common.core.domain.R;
+import com.fs.common.enums.DataSourceType;
+import com.fs.common.utils.DateUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.company.domain.CompanyWxAccount;
+import com.fs.company.service.ICompanyWxAccountService;
+import com.fs.wx.sop.domain.WxSop;
+import com.fs.wx.sop.mapper.WxSopMapper;
+import com.fs.wx.sop.service.IWxSopExecuteService;
+import com.fs.wx.sop.service.IWxSopService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 个微SOPService业务层处理
+ *
+ * @author 吴树波
+ * @date 2026-02-24
+ */
+@Service
+public class WxSopServiceImpl extends ServiceImpl<WxSopMapper, WxSop> implements IWxSopService {
+
+    @Autowired
+    private ICompanyWxAccountService companyWxAccountService;
+
+    @Autowired
+    private WxSopMapper wxSopMapper;
+
+    @Autowired
+    private IWxSopExecuteService wxSopExecuteService;
+
+    /**
+     * 查询个微SOP
+     *
+     * @param id 个微SOP主键
+     * @return 个微SOP
+     */
+    @Override
+    
+    public WxSop selectWxSopById(Long id)
+    {
+        return baseMapper.selectWxSopById(id);
+    }
+
+    @Override
+    public WxSop selectWxSopDetailById(Long id)
+    {
+        WxSop wxSop = baseMapper.selectWxSopById(id);
+        if (wxSop != null) {
+            fillSelectedQwUsers(wxSop);
+        }
+        return wxSop;
+    }
+
+    /**
+     * 填充执行账号 selectedQwUsers(从 accountIds 解析并查询并查询账号详情)
+     */
+    private void fillSelectedQwUsers(WxSop wxSop) {
+        String accountIdsStr = wxSop.getAccountIds();
+        if (StringUtils.isEmpty(accountIdsStr)) {
+            return;
+        }
+        List<Long> accountIds = Arrays.stream(accountIdsStr.split(","))
+            .map(String::trim)
+            .filter(s -> !s.isEmpty())
+            .map(Long::parseLong)
+            .collect(Collectors.toList());
+        if (accountIds.isEmpty()) {
+            return;
+        }
+        List<CompanyWxAccount> accounts = (List<CompanyWxAccount>) companyWxAccountService.listByIds(accountIds);
+        if (accounts != null) {
+            List<Map<String, Object>> selectedQwUsers = new ArrayList<>();
+            for (CompanyWxAccount acc : accounts) {
+                Map<String, Object> m = new HashMap<>();
+                m.put("id", acc.getId());
+                m.put("wxNickName", acc.getWxNickName());
+                m.put("wxNo", acc.getWxNo());
+                m.put("companyUserName", acc.getCompanyUserId() != null ? String.valueOf(acc.getCompanyUserId()) : null);
+                selectedQwUsers.add(m);
+            }
+            wxSop.setSelectedQwUsers(selectedQwUsers);
+        }
+    }
+
+    /**
+     * 查询个微SOP列表
+     *
+     * @param wxSop 个微SOP
+     * @return 个微SOP
+     */
+    @Override
+    
+    public List<WxSop> selectWxSopList(WxSop wxSop)
+    {
+        return baseMapper.selectWxSopList(wxSop);
+    }
+
+    /**
+     * 新增个微SOP(含执行账号保存到 wx_sop_user)
+     *
+     * @param wxSop 个微SOP
+     * @return 结果
+     */
+    /**
+     * 新增个微SOP(accountIds 直接入库到 wx_sop.account_ids)
+     *
+     * @param wxSop 个微SOP
+     * @return 结果
+     */
+    @Override
+    
+    public int insertWxSop(WxSop wxSop)
+    {
+        if (wxSop.getStatus() == null) {
+            wxSop.setStatus(1L);
+        }
+        wxSop.setCreateTime(DateUtils.getNowDate());
+        return baseMapper.insertWxSop(wxSop);
+    }
+
+    /**
+     * 修改个微SOP(accountIds 直接入库到 wx_sop.account_ids)
+     *
+     * @param wxSop 个微SOP
+     * @return 结果
+     */
+    @Override
+    
+    public int updateWxSop(WxSop wxSop)
+    {
+        wxSop.setUpdateTime(DateUtils.getNowDate());
+        return baseMapper.updateWxSop(wxSop);
+    }
+
+    /**
+     * 批量删除个微SOP
+     *
+     * @param ids 需要删除的个微SOP主键
+     * @return 结果
+     */
+    @Override
+    
+    public int deleteWxSopByIds(Long[] ids)
+    {
+        return baseMapper.deleteWxSopByIds(ids);
+    }
+
+    /**
+     * 删除个微SOP信息
+     *
+     * @param id 个微SOP主键
+     * @return 结果
+     */
+    @Override
+    
+    public int deleteWxSopById(Long id)
+    {
+        return baseMapper.deleteWxSopById(id);
+    }
+
+    /**
+     * 批量执行个微SOP
+     *
+     * @param ids 个微SOP主键数组
+     * @return 结果
+     */
+    @Override
+    
+    public R updateStatusWxSopByIds(Long[] ids) {
+        if (ids == null || ids.length == 0) {
+            return R.error("参数不能为空");
+        }
+
+        // 获取要操作的个微SOP列表
+        List<WxSop> wxSops = baseMapper.selectWxSopByIds(ids);
+        if (wxSops.isEmpty()) {
+            return R.ok().put("suc", new ArrayList<>()).put("err", ids);
+        }
+
+        // 筛选出 status == 1 的 IDs (可执行的)
+        List<Long> toBeSent = wxSops.stream()
+                .filter(wxSop -> wxSop.getStatus() != null && wxSop.getStatus() == 1)
+                .map(WxSop::getId)
+                .collect(Collectors.toList());
+
+        // 筛选出 status != 1 的 IDs (不可执行的)
+        List<Long> areadyList = wxSops.stream()
+                .filter(wxSop -> wxSop.getStatus() == null || wxSop.getStatus() != 1)
+                .map(WxSop::getId)
+                .collect(Collectors.toList());
+
+        // 如果有待执行的SOP,则更新其状态
+        if (!toBeSent.isEmpty()) {
+            int updateCount = baseMapper.updateStatusWxSopByIds(toBeSent.toArray(new Long[0]), 2L); // 设置为执行中状态
+            if (updateCount > 0) {
+                // 对于每个待执行的SOP,根据其筛选方式进行客户筛选和营期创建
+                for (Long sopId : toBeSent) {
+                    WxSop wxSop = baseMapper.selectWxSopById(sopId);
+                    if (wxSop != null) {
+                        // 根据筛选方式进行客户筛选和营期创建
+                        processSopCustomerSelection(wxSop);
+                    }
+                }
+
+                return R.ok().put("suc", toBeSent.toArray(new Long[0])).put("err", areadyList.toArray(new Long[0]));
+            } else {
+                // 即使更新失败,也要返回哪些成功哪些失败
+                return R.ok().put("suc", new ArrayList<>()).put("err", ids);
+            }
+        } else {
+            return R.ok().put("suc", new ArrayList<>()).put("err", areadyList.toArray(new Long[0]));
+        }
+    }
+
+
+
+    /**
+     * 处理SOP的客户筛选和营期创建
+     *
+     * @param wxSop 个微SOP
+     */
+    private void processSopCustomerSelection(WxSop wxSop) {
+        // 根据SOP的筛选方式筛选客户
+        if (wxSop.getFilterType() != null && wxSop.getFilterType() == 0) { // 0: 标签筛选
+            // 执行标签筛选逻辑
+            wxSopExecuteService.processTagFilterWxSop(wxSop);
+        } else if (wxSop.getFilterType() != null && wxSop.getFilterType() == 1) { // 1: 群聊筛选
+            // 执行群聊筛选逻辑
+            wxSopExecuteService.processGroupFilterWxSop(wxSop);
+        }
+    }
+}

+ 102 - 0
fs-service/src/main/java/com/fs/wx/sop/service/impl/WxSopUserInfoServiceImpl.java

@@ -0,0 +1,102 @@
+package com.fs.wx.sop.service.impl;
+
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.fs.common.annotation.DataSource;
+import com.fs.common.enums.DataSourceType;
+import com.fs.common.utils.DateUtils;
+import com.fs.wx.sop.domain.WxSopUserInfo;
+import com.fs.wx.sop.mapper.WxSopUserInfoMapper;
+import com.fs.wx.sop.service.IWxSopUserInfoService;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+/**
+ * 个微营期详情Service业务层处理
+ *
+ * @author 吴树波
+ * @date 2026-02-24
+ */
+@Service
+public class WxSopUserInfoServiceImpl extends ServiceImpl<WxSopUserInfoMapper, WxSopUserInfo> implements IWxSopUserInfoService {
+
+    /**
+     * 查询个微营期详情
+     *
+     * @param id 个微营期详情主键
+     * @return 个微营期详情
+     */
+    @Override
+    
+    public WxSopUserInfo selectWxSopUserInfoById(Long id)
+    {
+        return baseMapper.selectWxSopUserInfoById(id);
+    }
+
+    /**
+     * 查询个微营期详情列表
+     *
+     * @param wxSopUserInfo 个微营期详情
+     * @return 个微营期详情
+     */
+    @Override
+    
+    public List<WxSopUserInfo> selectWxSopUserInfoList(WxSopUserInfo wxSopUserInfo)
+    {
+        return baseMapper.selectWxSopUserInfoList(wxSopUserInfo);
+    }
+
+    /**
+     * 新增个微营期详情
+     *
+     * @param wxSopUserInfo 个微营期详情
+     * @return 结果
+     */
+    @Override
+    
+    public int insertWxSopUserInfo(WxSopUserInfo wxSopUserInfo)
+    {
+        wxSopUserInfo.setCreateTime(DateUtils.getNowDate());
+        return baseMapper.insertWxSopUserInfo(wxSopUserInfo);
+    }
+
+    /**
+     * 修改个微营期详情
+     *
+     * @param wxSopUserInfo 个微营期详情
+     * @return 结果
+     */
+    @Override
+    
+    public int updateWxSopUserInfo(WxSopUserInfo wxSopUserInfo)
+    {
+        wxSopUserInfo.setUpdateTime(DateUtils.getNowDate());
+        return baseMapper.updateWxSopUserInfo(wxSopUserInfo);
+    }
+
+    /**
+     * 批量删除个微营期详情
+     *
+     * @param ids 需要删除的个微营期详情主键
+     * @return 结果
+     */
+    @Override
+    
+    public int deleteWxSopUserInfoByIds(Long[] ids)
+    {
+        return baseMapper.deleteWxSopUserInfoByIds(ids);
+    }
+
+    /**
+     * 删除个微营期详情信息
+     *
+     * @param id 个微营期详情主键
+     * @return 结果
+     */
+    @Override
+    
+    public int deleteWxSopUserInfoById(Long id)
+    {
+        return baseMapper.deleteWxSopUserInfoById(id);
+    }
+}

+ 133 - 0
fs-service/src/main/java/com/fs/wx/sop/service/impl/WxSopUserServiceImpl.java

@@ -0,0 +1,133 @@
+package com.fs.wx.sop.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.fs.common.annotation.DataSource;
+import com.fs.common.enums.DataSourceType;
+import com.fs.common.utils.DateUtils;
+import com.fs.common.utils.PubFun;
+import com.fs.common.core.domain.R;
+import com.fs.company.domain.CompanyWxAccount;
+import com.fs.company.mapper.CompanyWxAccountMapper;
+import com.fs.wx.sop.domain.WxSopUser;
+import com.fs.wx.sop.mapper.WxSopUserMapper;
+import com.fs.wx.sop.params.UpdateWxSopUserLogDateVo;
+import com.fs.wx.sop.service.IWxSopUserService;
+import lombok.AllArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 个微营期Service业务层处理
+ *
+ * @author 吴树波
+ * @date 2026-02-24
+ */
+@Service
+@AllArgsConstructor
+public class WxSopUserServiceImpl extends ServiceImpl<WxSopUserMapper, WxSopUser> implements IWxSopUserService {
+
+    private final CompanyWxAccountMapper companyWxAccountMapper;
+    /**
+     * 查询个微营期
+     *
+     * @param id 个微营期主键
+     * @return 个微营期
+     */
+    @Override
+    
+    public WxSopUser selectWxSopUserById(Long id)
+    {
+        return baseMapper.selectWxSopUserById(id);
+    }
+
+    /**
+     * 查询个微营期列表
+     *
+     * @param wxSopUser 个微营期
+     * @return 个微营期
+     */
+    @Override
+    public List<WxSopUser> selectWxSopUserList(WxSopUser wxSopUser){
+        List<WxSopUser> wxSopUsers = baseMapper.selectWxSopUserList(wxSopUser);
+        if(wxSopUsers.isEmpty()){
+            return Collections.emptyList();
+        }
+        List<CompanyWxAccount> companyWxAccounts = companyWxAccountMapper.selectList(new QueryWrapper<CompanyWxAccount>().in("id", PubFun.listToNewList(wxSopUsers, WxSopUser::getAccountId)));
+        Map<Long, CompanyWxAccount> accountMap = PubFun.listToMapByGroupObject(companyWxAccounts, CompanyWxAccount::getId);
+        wxSopUsers.parallelStream().filter(e -> accountMap.containsKey(e.getAccountId())).forEach(e -> {
+            CompanyWxAccount companyWxAccount = accountMap.get(e.getAccountId());
+            e.setAccountName(companyWxAccount.getWxNickName());
+        });
+        return wxSopUsers;
+    }
+
+    /**
+     * 新增个微营期
+     *
+     * @param wxSopUser 个微营期
+     * @return 结果
+     */
+    @Override
+    
+    public int insertWxSopUser(WxSopUser wxSopUser)
+    {
+        wxSopUser.setCreateTime(DateUtils.getNowDate());
+        return baseMapper.insertWxSopUser(wxSopUser);
+    }
+
+    /**
+     * 修改个微营期
+     *
+     * @param wxSopUser 个微营期
+     * @return 结果
+     */
+    @Override
+    
+    public int updateWxSopUser(WxSopUser wxSopUser)
+    {
+        wxSopUser.setUpdateTime(DateUtils.getNowDate());
+        return baseMapper.updateWxSopUser(wxSopUser);
+    }
+
+    /**
+     * 批量删除个微营期
+     *
+     * @param ids 需要删除的个微营期主键
+     * @return 结果
+     */
+    @Override
+    
+    public int deleteWxSopUserByIds(Long[] ids)
+    {
+        return baseMapper.deleteWxSopUserByIds(ids);
+    }
+
+    /**
+     * 删除个微营期信息
+     *
+     * @param id 个微营期主键
+     * @return 结果
+     */
+    @Override
+    
+    public int deleteWxSopUserById(Long id)
+    {
+        return baseMapper.deleteWxSopUserById(id);
+    }
+
+    @Override
+    public R updateLogDate(UpdateWxSopUserLogDateVo vo) {
+        if (vo.getNewStartTime() == null) {
+            return R.error("修改时间不能为空");
+        }
+        if (vo.getIds() == null || vo.getIds().isEmpty()) {
+            return R.error("营期ID不能为空");
+        }
+        int rows = baseMapper.updateWxSopUserDateById(vo);
+        return R.ok(String.valueOf(rows));
+    }
+}

+ 110 - 0
fs-service/src/main/java/com/fs/wx/sop/vo/WxSopLogsListVO.java

@@ -0,0 +1,110 @@
+package com.fs.wx.sop.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fs.common.annotation.Excel;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+import java.util.Date;
+
+/**
+ * 个微SOP执行记录列表VO
+ *
+ * @author fs
+ * @date 2026-03-06
+ */
+@Data
+public class WxSopLogsListVO implements Serializable {
+    
+    private static final long serialVersionUID = 1L;
+
+    /** 主键ID */
+    private Long id;
+
+    /** SOP ID */
+    private Long sopId;
+
+    /** 营期ID */
+    private Long sopUserId;
+
+    /** 个微账号ID */
+    @Excel(name = "个微账号ID")
+    private Long accountId;
+
+    /** 个微账号昵称 */
+    @Excel(name = "个微账号昵称")
+    private String accountName;
+
+    /** 客户ID */
+    private Long wxContactId;
+
+    /** 客户昵称 */
+    @Excel(name = "客户昵称")
+    private String wxContactName;
+
+    /** 客户标签 */
+    @Excel(name = "客户标签")
+    private String tagNames;
+
+    /** 群聊ID */
+    private Long wxRoomId;
+
+    /** 群聊名称 */
+    @Excel(name = "群聊名称")
+    private String wxRoomName;
+
+    /** 消息类型 0个人1群 */
+    @Excel(name = "消息类型")
+    private Integer type;
+
+    /** 发送类型 */
+    @Excel(name = "发送类型")
+    private Integer sendType;
+
+    /** 生成类型 0自动1手动 */
+    @Excel(name = "生成类型")
+    private Integer generateType;
+
+    /** 发送状态 0待发送1发送成功2发送失败3消息作废 */
+    @Excel(name = "发送状态")
+    private Integer sendStatus;
+
+    /** 发送备注 */
+    @Excel(name = "发送备注")
+    private String sendRemark;
+
+    /** 发送排序 */
+    private Integer sendSort;
+
+    /** 消息过期时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @Excel(name = "消息过期时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
+    private Date expirationTime;
+
+    /** 生成时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @Excel(name = "生成时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+
+    /** 实际发送时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @Excel(name = "实际发送时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
+    private Date realSendTime;
+
+    /** 备注 */
+    private String remark;
+
+    /** 小程序ID */
+    private Long fsUserId;
+
+    /** 公司ID */
+    private Long companyId;
+
+    /** 预计发送时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime sendTime;
+
+    /** 消息内容JSON */
+    private String contentJson;
+}

+ 11 - 0
fs-service/src/main/java/com/fs/wx/sop/vo/WxSopMsgVo.java

@@ -0,0 +1,11 @@
+package com.fs.wx.sop.vo;
+
+import lombok.Data;
+
+@Data
+public class WxSopMsgVo {
+    private Integer contentType;
+    private String value;
+    private Integer sendStatus;
+    private String sendRemarks;
+}

+ 27 - 0
fs-service/src/main/java/com/fs/wx/sop/vo/WxSopUserMsgGenVO.java

@@ -0,0 +1,27 @@
+package com.fs.wx.sop.vo;
+
+import lombok.Data;
+
+import java.time.LocalDate;
+
+/**
+ * 个微SOP消息生成——营期客户视图对象
+ * 用于查询需要生成消息的活跃营期及客户信息
+ */
+@Data
+public class WxSopUserMsgGenVO {
+
+    private Long sopUserId;
+    private Integer type;
+    private Long sopId;
+    private Long accountId;
+    private LocalDate startTime;
+
+    private String tempId;
+    private Long companyId;
+
+    private Long infoId;
+    private Long wxContactId;
+    private Long customerId;
+    private Long fsUserId;
+}

+ 0 - 4
fs-service/src/main/resources/application-dev.yml

@@ -1,9 +1,5 @@
 # 数据源配置
 spring:
-    profiles:
-        include: common,config-dev
-    #    profiles:
-    #        include: config-dev,common
     # redis 配置
     redis:
         # 地址

+ 33 - 0
fs-service/src/main/resources/mapper/sop/QwSopTempRulesMapper.xml

@@ -160,4 +160,37 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         </foreach>
     </update>
 
+    <resultMap type="com.fs.sop.vo.QwSopTempRulesWithDayVO" id="QwSopTempRulesWithDayResult">
+        <result property="id"    column="id"    />
+        <result property="tempId"    column="temp_id"    />
+        <result property="dayId"    column="day_id"    />
+        <result property="name"    column="name"    />
+        <result property="time"    column="time"    />
+        <result property="isOfficial"    column="is_official"    />
+        <result property="contentType"    column="content_type"    />
+        <result property="type"    column="type"    />
+        <result property="courseType"    column="course_type"    />
+        <result property="courseId"    column="course_id"    />
+        <result property="videoId"    column="video_id"    />
+        <result property="aiTouch"    column="ai_touch"    />
+        <result property="addTag"    column="add_tag"    />
+        <result property="delTag"    column="del_tag"    />
+        <result property="sorts"    column="sorts"    />
+        <result property="isAtAll"    column="is_at_all"    />
+        <result property="liveId"    column="live_id"    />
+        <result property="dayNum"    column="day_num"    />
+        <result property="textContent" column="ct_content" />
+    </resultMap>
+
+    <select id="listByTempIdWithDayNum" resultMap="QwSopTempRulesWithDayResult">
+        select tr.*, td.day_num,
+               (select tc.content from qw_sop_temp_content tc
+                where tc.rules_id = tr.id and tc.content_type = 1
+                   limit 1) as ct_content
+        from qw_sop_temp_rules tr
+            left join qw_sop_temp_day td on tr.day_id = td.id
+        where tr.temp_id = #{id}
+        order by td.day_num, tr.time
+    </select>
+
 </mapper>

+ 237 - 0
fs-service/src/main/resources/mapper/wx/WxSopLogsMapper.xml

@@ -0,0 +1,237 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.wx.sop.mapper.WxSopLogsMapper">
+
+    <resultMap type="WxSopLogs" id="WxSopLogsResult">
+        <result property="id"    column="id"    />
+        <result property="type"    column="type"    />
+        <result property="sopId"    column="sop_id"    />
+        <result property="sopUserId"    column="sop_user_id"    />
+        <result property="sendType"    column="send_type"    />
+        <result property="generateType"    column="generate_type"    />
+        <result property="accountId"    column="account_id"    />
+        <result property="wxContactId"    column="wx_contact_id"    />
+        <result property="wxContactName"    column="wx_contact_name"    />
+        <result property="wxRoomId"    column="wx_room_id"    />
+        <result property="wxRoomName"    column="wx_room_name"    />
+        <result property="fsUserId"    column="fs_user_id"    />
+        <result property="sendStatus"    column="send_status"    />
+        <result property="sendRemark"    column="send_remark"    />
+        <result property="sendSort"    column="send_sort"    />
+        <result property="expirationTime"    column="expiration_time"    />
+        <result property="createTime"    column="create_time"    />
+        <result property="createBy"    column="create_by"    />
+        <result property="updateTime"    column="update_time"    />
+        <result property="updateBy"    column="update_by"    />
+        <result property="remark"    column="remark"    />
+    </resultMap>
+
+    <sql id="selectWxSopLogsVo">
+        select id, type, sop_id, sop_user_id, send_type, generate_type, account_id, wx_contact_id, wx_contact_name, wx_room_id, wx_room_name, fs_user_id, send_status, send_remark, send_sort, expiration_time, create_time, create_by, update_time, update_by, remark from wx_sop_logs
+    </sql>
+
+    <select id="selectWxSopLogsList" parameterType="WxSopLogs" resultMap="WxSopLogsResult">
+        <include refid="selectWxSopLogsVo"/>
+        <where>
+            <if test="type != null "> and type = #{type}</if>
+            <if test="sopId != null "> and sop_id = #{sopId}</if>
+            <if test="sopUserId != null "> and sop_user_id = #{sopUserId}</if>
+            <if test="sendType != null "> and send_type = #{sendType}</if>
+            <if test="generateType != null "> and generate_type = #{generateType}</if>
+            <if test="accountId != null "> and account_id = #{accountId}</if>
+            <if test="wxContactId != null "> and wx_contact_id = #{wxContactId}</if>
+            <if test="wxContactName != null  and wxContactName != ''"> and wx_contact_name like concat('%', #{wxContactName}, '%')</if>
+            <if test="wxRoomId != null "> and wx_room_id = #{wxRoomId}</if>
+            <if test="wxRoomName != null  and wxRoomName != ''"> and wx_room_name like concat('%', #{wxRoomName}, '%')</if>
+            <if test="fsUserId != null "> and fs_user_id = #{fsUserId}</if>
+            <if test="sendStatus != null "> and send_status = #{sendStatus}</if>
+            <if test="sendRemark != null  and sendRemark != ''"> and send_remark = #{sendRemark}</if>
+            <if test="sendSort != null "> and send_sort = #{sendSort}</if>
+            <if test="expirationTime != null "> and expiration_time = #{expirationTime}</if>
+        </where>
+    </select>
+
+    <select id="selectWxSopLogsById" parameterType="Long" resultMap="WxSopLogsResult">
+        <include refid="selectWxSopLogsVo"/>
+        where id = #{id}
+    </select>
+
+    <insert id="insertWxSopLogs" parameterType="WxSopLogs" useGeneratedKeys="true" keyProperty="id">
+        insert into wx_sop_logs
+        <trim prefix="(" suffix=")" suffixOverrides=",">
+            <if test="type != null">type,</if>
+            <if test="sopId != null">sop_id,</if>
+            <if test="sopUserId != null">sop_user_id,</if>
+            <if test="sendType != null">send_type,</if>
+            <if test="generateType != null">generate_type,</if>
+            <if test="accountId != null">account_id,</if>
+            <if test="wxContactId != null">wx_contact_id,</if>
+            <if test="wxContactName != null">wx_contact_name,</if>
+            <if test="wxRoomId != null">wx_room_id,</if>
+            <if test="wxRoomName != null">wx_room_name,</if>
+            <if test="fsUserId != null">fs_user_id,</if>
+            <if test="sendStatus != null">send_status,</if>
+            <if test="sendRemark != null">send_remark,</if>
+            <if test="sendSort != null">send_sort,</if>
+            <if test="expirationTime != null">expiration_time,</if>
+            <if test="createTime != null">create_time,</if>
+            <if test="createBy != null">create_by,</if>
+            <if test="updateTime != null">update_time,</if>
+            <if test="updateBy != null">update_by,</if>
+            <if test="remark != null">remark,</if>
+         </trim>
+        <trim prefix="values (" suffix=")" suffixOverrides=",">
+            <if test="type != null">#{type},</if>
+            <if test="sopId != null">#{sopId},</if>
+            <if test="sopUserId != null">#{sopUserId},</if>
+            <if test="sendType != null">#{sendType},</if>
+            <if test="generateType != null">#{generateType},</if>
+            <if test="accountId != null">#{accountId},</if>
+            <if test="wxContactId != null">#{wxContactId},</if>
+            <if test="wxContactName != null">#{wxContactName},</if>
+            <if test="wxRoomId != null">#{wxRoomId},</if>
+            <if test="wxRoomName != null">#{wxRoomName},</if>
+            <if test="fsUserId != null">#{fsUserId},</if>
+            <if test="sendStatus != null">#{sendStatus},</if>
+            <if test="sendRemark != null">#{sendRemark},</if>
+            <if test="sendSort != null">#{sendSort},</if>
+            <if test="expirationTime != null">#{expirationTime},</if>
+            <if test="createTime != null">#{createTime},</if>
+            <if test="createBy != null">#{createBy},</if>
+            <if test="updateTime != null">#{updateTime},</if>
+            <if test="updateBy != null">#{updateBy},</if>
+            <if test="remark != null">#{remark},</if>
+         </trim>
+    </insert>
+    <insert id="batchInsertWxSopLogs" parameterType="java.util.List">
+        INSERT INTO wx_sop_logs
+        (
+        type, sop_id, sop_user_id, send_type, generate_type, account_id,
+        wx_contact_id, wx_contact_name, wx_room_id, wx_room_name, fs_user_id,
+        send_status, send_remark, send_sort, expiration_time, create_time,
+        create_by, update_time, update_by, remark, content_json, send_time
+        )
+        VALUES
+        <foreach collection="list" item="log" separator=",">
+            (
+            #{log.type}, #{log.sopId}, #{log.sopUserId}, #{log.sendType}, #{log.generateType},
+            #{log.accountId}, #{log.wxContactId}, #{log.wxContactName}, #{log.wxRoomId},
+            #{log.wxRoomName}, #{log.fsUserId}, #{log.sendStatus}, #{log.sendRemark},
+            #{log.sendSort}, #{log.expirationTime}, #{log.createTime}, #{log.createBy},
+            #{log.updateTime}, #{log.updateBy}, #{log.remark}, #{log.contentJson}, #{log.sendTime}
+            )
+        </foreach>
+    </insert>
+
+    <update id="updateWxSopLogs" parameterType="WxSopLogs">
+        update wx_sop_logs
+        <trim prefix="SET" suffixOverrides=",">
+            <if test="type != null">type = #{type},</if>
+            <if test="sopId != null">sop_id = #{sopId},</if>
+            <if test="sopUserId != null">sop_user_id = #{sopUserId},</if>
+            <if test="sendType != null">send_type = #{sendType},</if>
+            <if test="generateType != null">generate_type = #{generateType},</if>
+            <if test="accountId != null">account_id = #{accountId},</if>
+            <if test="wxContactId != null">wx_contact_id = #{wxContactId},</if>
+            <if test="wxContactName != null">wx_contact_name = #{wxContactName},</if>
+            <if test="wxRoomId != null">wx_room_id = #{wxRoomId},</if>
+            <if test="wxRoomName != null">wx_room_name = #{wxRoomName},</if>
+            <if test="fsUserId != null">fs_user_id = #{fsUserId},</if>
+            <if test="sendStatus != null">send_status = #{sendStatus},</if>
+            <if test="sendRemark != null">send_remark = #{sendRemark},</if>
+            <if test="sendSort != null">send_sort = #{sendSort},</if>
+            <if test="expirationTime != null">expiration_time = #{expirationTime},</if>
+            <if test="createTime != null">create_time = #{createTime},</if>
+            <if test="createBy != null">create_by = #{createBy},</if>
+            <if test="updateTime != null">update_time = #{updateTime},</if>
+            <if test="updateBy != null">update_by = #{updateBy},</if>
+            <if test="remark != null">remark = #{remark},</if>
+        </trim>
+        where id = #{id}
+    </update>
+
+    <delete id="deleteWxSopLogsById" parameterType="Long">
+        delete from wx_sop_logs where id = #{id}
+    </delete>
+
+    <delete id="deleteWxSopLogsByIds" parameterType="String">
+        delete from wx_sop_logs where id in
+        <foreach item="id" collection="array" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </delete>
+
+    <resultMap type="com.fs.wx.sop.vo.WxSopLogsListVO" id="WxSopLogsListVOResult">
+        <result property="id"    column="id"    />
+        <result property="sopId"    column="sop_id"    />
+        <result property="sopUserId"    column="sop_user_id"    />
+        <result property="accountId"    column="account_id"    />
+        <result property="accountName"    column="account_name"    />
+        <result property="wxContactId"    column="wx_contact_id"    />
+        <result property="wxContactName"    column="wx_contact_name"    />
+        <result property="tagNames"    column="tag_names"    />
+        <result property="wxRoomId"    column="wx_room_id"    />
+        <result property="wxRoomName"    column="wx_room_name"    />
+        <result property="type"    column="type"    />
+        <result property="sendType"    column="send_type"    />
+        <result property="generateType"    column="generate_type"    />
+        <result property="sendStatus"    column="send_status"    />
+        <result property="sendRemark"    column="send_remark"    />
+        <result property="sendSort"    column="send_sort"    />
+        <result property="expirationTime"    column="expiration_time"    />
+        <result property="createTime"    column="create_time"    />
+        <result property="realSendTime"    column="real_send_time"    />
+        <result property="remark"    column="remark"    />
+        <result property="fsUserId"    column="fs_user_id"    />
+        <result property="companyId"    column="company_id"    />
+        <result property="sendTime"    column="send_time"    />
+        <result property="contentJson"    column="content_json"    />
+    </resultMap>
+
+    <select id="selectWxSopLogsListBySopId" parameterType="com.fs.wx.sop.params.WxSopLogsParam" resultMap="WxSopLogsListVOResult">
+        SELECT
+            wsl.id, wsl.sop_id, wsl.sop_user_id, wsl.account_id,
+            wsl.wx_contact_id, wsl.wx_contact_name, wsui.tag_names,
+            wsl.wx_room_id, wsl.wx_room_name, wsl.type, wsl.send_type,
+            wsl.generate_type, wsl.send_status, wsl.send_remark, wsl.send_sort,
+            wsl.expiration_time, wsl.create_time, wsl.update_time as real_send_time,
+            wsl.remark, wsl.fs_user_id, wsl.send_time, wsl.content_json
+        FROM wx_sop_logs wsl
+        LEFT JOIN wx_sop_user_info wsui ON wsl.sop_id = wsui.sop_id AND wsl.wx_contact_id = wsui.wx_contact_id
+        <where>
+            <if test="sopId != null">AND wsl.sop_id = #{sopId}</if>
+            <if test="sopUserId != null">AND wsl.sop_user_id = #{sopUserId}</if>
+            <if test="accountId != null">AND wsl.account_id = #{accountId}</if>
+            <if test="accountIdList != null and accountIdList.size() > 0">
+                AND wsl.account_id IN
+                <foreach collection="accountIdList" item="accId" open="(" separator="," close=")">
+                    #{accId}
+                </foreach>
+            </if>
+            <if test="wxContactName != null and wxContactName != ''">AND wsl.wx_contact_name LIKE CONCAT('%', #{wxContactName}, '%')</if>
+            <if test="wxContactId != null">AND wsl.wx_contact_id = #{wxContactId}</if>
+            <if test="sendStatus != null">AND wsl.send_status = #{sendStatus}</if>
+            <if test="sendType != null">AND wsl.send_type = #{sendType}</if>
+            <if test="type != null">AND wsl.type = #{type}</if>
+            <if test="scheduleStartTime != null and scheduleStartTime != ''">AND wsl.create_time >= #{scheduleStartTime}</if>
+            <if test="scheduleEndTime != null and scheduleEndTime != ''">AND wsl.create_time &lt;= #{scheduleEndTime}</if>
+        </where>
+        ORDER BY wsl.create_time DESC
+    </select>
+
+    <select id="selectByWxId" resultType="com.fs.wx.sop.domain.WxSopLogs">
+        select ql.*,
+               qs.name,
+               qs.expiry_time as expiryTime
+        from wx_sop_logs ql
+                 left join wx_sop qs on qs.id = ql.sop_id
+        where ql.account_id = #{id}
+          and ql.send_status = 0
+        <![CDATA[
+          and ql.send_time <= now()
+        ]]>
+        order by ql.send_time limit 30
+    </select>
+</mapper>

+ 180 - 0
fs-service/src/main/resources/mapper/wx/WxSopMapper.xml

@@ -0,0 +1,180 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.wx.sop.mapper.WxSopMapper">
+
+    <resultMap type="WxSop" id="WxSopResult">
+        <result property="id"    column="id"    />
+        <result property="name"    column="name"    />
+        <result property="filterType"    column="filter_type"    />
+        <result property="selectTags"    column="select_tags"    />
+        <result property="excludeTags"    column="exclude_tags"    />
+        <result property="tempId"    column="temp_id"    />
+        <result property="companyId"    column="company_id"    />
+        <result property="isFixed"    column="is_fixed"    />
+        <result property="expiryTime"    column="expiry_time"    />
+        <result property="startTime"    column="start_time"    />
+        <result property="accountIds"    column="account_ids"    />
+        <result property="createTime"    column="create_time"    />
+        <result property="createBy"    column="create_by"    />
+        <result property="updateTime"    column="update_time"    />
+        <result property="updateBy"    column="update_by"    />
+        <result property="status"    column="status"    />
+        <result property="remark"    column="remark"    />
+    </resultMap>
+
+    <sql id="selectWxSopVo">
+        select id, name, filter_type, select_tags, exclude_tags, temp_id, company_id, is_fixed, expiry_time, start_time, account_ids, create_time, create_by, update_time, update_by, status, remark from wx_sop
+    </sql>
+
+    <select id="selectWxSopList" parameterType="WxSop" resultMap="WxSopResult">
+        <include refid="selectWxSopVo"/>
+        <where>
+            <if test="name != null  and name != ''"> and name like concat('%', #{name}, '%')</if>
+            <if test="filterType != null "> and filter_type = #{filterType}</if>
+            <if test="selectTags != null  and selectTags != ''"> and select_tags = #{selectTags}</if>
+            <if test="excludeTags != null  and excludeTags != ''"> and exclude_tags = #{excludeTags}</if>
+            <if test="tempId != null  and tempId != ''"> and temp_id = #{tempId}</if>
+            <if test="companyId != null "> and company_id = #{companyId}</if>
+            <if test="isFixed != null "> and is_fixed = #{isFixed}</if>
+            <if test="expiryTime != null "> and expiry_time = #{expiryTime}</if>
+            <if test="startTime != null "> and start_time = #{startTime}</if>
+            <if test="accountIds != null  and accountIds != ''"> and account_ids = #{accountIds}</if>
+            <if test="status != null "> and status = #{status}</if>
+        </where>
+    </select>
+
+    <select id="selectWxSopById" parameterType="Long" resultMap="WxSopResult">
+        <include refid="selectWxSopVo"/>
+        where id = #{id}
+    </select>
+
+    <select id="selectWxSopByIds" resultMap="WxSopResult">
+        <include refid="selectWxSopVo"/>
+        where id in
+        <foreach collection="array" item="id" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </select>
+
+    <insert id="insertWxSop" parameterType="WxSop" useGeneratedKeys="true" keyProperty="id">
+        insert into wx_sop
+        <trim prefix="(" suffix=")" suffixOverrides=",">
+            <if test="name != null">name,</if>
+            <if test="filterType != null">filter_type,</if>
+            <if test="selectTags != null">select_tags,</if>
+            <if test="excludeTags != null">exclude_tags,</if>
+            <if test="tempId != null">temp_id,</if>
+            <if test="companyId != null">company_id,</if>
+            <if test="isFixed != null">is_fixed,</if>
+            <if test="expiryTime != null">expiry_time,</if>
+            <if test="startTime != null">start_time,</if>
+            <if test="accountIds != null">account_ids,</if>
+            <if test="createTime != null">create_time,</if>
+            <if test="createBy != null">create_by,</if>
+            <if test="updateTime != null">update_time,</if>
+            <if test="updateBy != null">update_by,</if>
+            <if test="status != null">status,</if>
+            <if test="remark != null">remark,</if>
+         </trim>
+        <trim prefix="values (" suffix=")" suffixOverrides=",">
+            <if test="name != null">#{name},</if>
+            <if test="filterType != null">#{filterType},</if>
+            <if test="selectTags != null">#{selectTags},</if>
+            <if test="excludeTags != null">#{excludeTags},</if>
+            <if test="tempId != null">#{tempId},</if>
+            <if test="companyId != null">#{companyId},</if>
+            <if test="isFixed != null">#{isFixed},</if>
+            <if test="expiryTime != null">#{expiryTime},</if>
+            <if test="startTime != null">#{startTime},</if>
+            <if test="accountIds != null">#{accountIds},</if>
+            <if test="createTime != null">#{createTime},</if>
+            <if test="createBy != null">#{createBy},</if>
+            <if test="updateTime != null">#{updateTime},</if>
+            <if test="updateBy != null">#{updateBy},</if>
+            <if test="status != null">#{status},</if>
+            <if test="remark != null">#{remark},</if>
+         </trim>
+    </insert>
+
+    <update id="updateWxSop" parameterType="WxSop">
+        update wx_sop
+        <trim prefix="SET" suffixOverrides=",">
+            <if test="name != null">name = #{name},</if>
+            <if test="filterType != null">filter_type = #{filterType},</if>
+            <if test="selectTags != null">select_tags = #{selectTags},</if>
+            <if test="excludeTags != null">exclude_tags = #{excludeTags},</if>
+            <if test="tempId != null">temp_id = #{tempId},</if>
+            <if test="companyId != null">company_id = #{companyId},</if>
+            <if test="isFixed != null">is_fixed = #{isFixed},</if>
+            <if test="expiryTime != null">expiry_time = #{expiryTime},</if>
+            <if test="startTime != null">start_time = #{startTime},</if>
+            <if test="accountIds != null">account_ids = #{accountIds},</if>
+            <if test="createTime != null">create_time = #{createTime},</if>
+            <if test="createBy != null">create_by = #{createBy},</if>
+            <if test="updateTime != null">update_time = #{updateTime},</if>
+            <if test="updateBy != null">update_by = #{updateBy},</if>
+            <if test="status != null">status = #{status},</if>
+            <if test="remark != null">remark = #{remark},</if>
+        </trim>
+        where id = #{id}
+    </update>
+
+    <update id="updateStatusWxSopByIds" parameterType="map">
+        update wx_sop
+        SET status = #{arg1}
+        where id in
+        <foreach collection="arg0" item="id" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </update>
+
+    <delete id="deleteWxSopById" parameterType="Long">
+        delete from wx_sop where id = #{id}
+    </delete>
+
+    <delete id="deleteWxSopByIds">
+        delete from wx_sop where id in
+        <foreach item="id" collection="array" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </delete>
+
+    <select id="selectFilterWxSopCustomers" parameterType="com.fs.sop.params.WxSopTagsParam" resultType="com.fs.sop.vo.WxFilterSopCustomersResult">
+        SELECT DISTINCT
+            wc.id,
+            wc.nick_name AS name,
+            wc.account_id AS accountId,
+            wc.company_id AS cuCompanyId,
+            wc.company_user_id AS cuCompanyUserId,
+            cc.customer_id AS customerId
+        FROM
+            wx_contact wc
+        INNER JOIN crm_customer cc ON wc.customer_id = cc.customer_id
+        WHERE
+            1 = 1
+            <if test="accountIdsSelectList != null and accountIdsSelectList.size() > 0">
+                AND wc.account_id IN
+                <foreach collection="accountIdsSelectList" item="accountId" open="(" separator="," close=")">
+                    #{accountId}
+                </foreach>
+            </if>
+            <if test="tagsIdsSelectList != null and tagsIdsSelectList.size() > 0">
+                <foreach collection="tagsIdsSelectList" item="tagId">
+                    AND FIND_IN_SET(
+                        (SELECT dict_label FROM sys_dict_data WHERE dict_type = 'crm_customer_tag' AND dict_value = #{tagId} LIMIT 1),
+                        cc.tags
+                    )
+                </foreach>
+            </if>
+            <if test="outTagsIdsSelectList != null and outTagsIdsSelectList.size() > 0">
+                <foreach collection="outTagsIdsSelectList" item="excludeTagId">
+                    AND NOT FIND_IN_SET(
+                        (SELECT dict_label FROM sys_dict_data WHERE dict_type = 'crm_customer_tag' AND dict_value = #{excludeTagId} LIMIT 1),
+                        cc.tags
+                    )
+                </foreach>
+            </if>
+    </select>
+</mapper>

+ 144 - 0
fs-service/src/main/resources/mapper/wx/WxSopUserInfoMapper.xml

@@ -0,0 +1,144 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.wx.sop.mapper.WxSopUserInfoMapper">
+
+    <resultMap type="WxSopUserInfo" id="WxSopUserInfoResult">
+        <result property="id"    column="id"    />
+        <result property="sopId"    column="sop_id"    />
+        <result property="sopUserId"    column="sop_user_id"    />
+        <result property="wxContactId"    column="wx_contact_id"    />
+        <result property="customerId"    column="customer_id"    />
+        <result property="fsUserId"    column="fs_user_id"    />
+        <result property="isDaysNotStudy"    column="is_days_not_study"    />
+        <result property="finishCout"    column="finish_cout"    />
+        <result property="finishTime"    column="finish_time"    />
+        <result property="finishCourseDays"    column="finish_course_days"    />
+        <result property="grade"    column="grade"    />
+        <result property="status"    column="status"    />
+        <result property="createTime"    column="create_time"    />
+        <result property="createBy"    column="create_by"    />
+        <result property="updateTime"    column="update_time"    />
+        <result property="updateBy"    column="update_by"    />
+        <result property="remark"    column="remark"    />
+        <result property="tagNames"    column="tag_names"    />
+    </resultMap>
+
+    <sql id="selectWxSopUserInfoVo">
+        select wsui.id, wsui.sop_id, wsui.sop_user_id, wsui.wx_contact_id, wsui.customer_id, wsui.fs_user_id, wsui.is_days_not_study,
+               wsui.finish_cout, wsui.finish_time, wsui.finish_course_days, wsui.grade, wsui.status,
+               wsui.create_time, wsui.create_by, wsui.update_time, wsui.update_by, wsui.remark
+        from wx_sop_user_info wsui
+    </sql>
+
+    <select id="selectWxSopUserInfoList" parameterType="WxSopUserInfo" resultMap="WxSopUserInfoResult">
+        <include refid="selectWxSopUserInfoVo"/>
+        <where>
+            <if test="sopId != null "> and wsui.sop_id = #{sopId}</if>
+            <if test="sopUserId != null "> and wsui.sop_user_id = #{sopUserId}</if>
+            <if test="wxContactId != null "> and wsui.wx_contact_id = #{wxContactId}</if>
+            <if test="customerId != null "> and wsui.customer_id = #{customerId}</if>
+            <if test="fsUserId != null "> and wsui.fs_user_id = #{fsUserId}</if>
+            <if test="isDaysNotStudy != null "> and wsui.is_days_not_study = #{isDaysNotStudy}</if>
+            <if test="finishCout != null "> and wsui.finish_cout = #{finishCout}</if>
+            <if test="finishTime != null "> and wsui.finish_time = #{finishTime}</if>
+            <if test="finishCourseDays != null "> and wsui.finish_course_days = #{finishCourseDays}</if>
+            <if test="grade != null "> and wsui.grade = #{grade}</if>
+            <if test="status != null "> and wsui.status = #{status}</if>
+        </where>
+    </select>
+
+    <select id="selectWxSopUserInfoById" parameterType="Long" resultMap="WxSopUserInfoResult">
+        <include refid="selectWxSopUserInfoVo"/>
+        where wsui.id = #{id}
+    </select>
+
+    <insert id="insertWxSopUserInfo" parameterType="WxSopUserInfo" useGeneratedKeys="true" keyProperty="id">
+        insert into wx_sop_user_info
+        <trim prefix="(" suffix=")" suffixOverrides=",">
+            <if test="sopId != null">sop_id,</if>
+            <if test="sopUserId != null">sop_user_id,</if>
+            <if test="wxContactId != null">wx_contact_id,</if>
+            <if test="customerId != null">customer_id,</if>
+            <if test="fsUserId != null">fs_user_id,</if>
+            <if test="isDaysNotStudy != null">is_days_not_study,</if>
+            <if test="finishCout != null">finish_cout,</if>
+            <if test="finishTime != null">finish_time,</if>
+            <if test="finishCourseDays != null">finish_course_days,</if>
+            <if test="grade != null">grade,</if>
+            <if test="status != null">status,</if>
+            <if test="createTime != null">create_time,</if>
+            <if test="createBy != null">create_by,</if>
+            <if test="updateTime != null">update_time,</if>
+            <if test="updateBy != null">update_by,</if>
+            <if test="remark != null">remark,</if>
+            <if test="tagNames != null">tag_names,</if>
+         </trim>
+        <trim prefix="values (" suffix=")" suffixOverrides=",">
+            <if test="sopId != null">#{sopId},</if>
+            <if test="sopUserId != null">#{sopUserId},</if>
+            <if test="wxContactId != null">#{wxContactId},</if>
+            <if test="customerId != null">#{customerId},</if>
+            <if test="fsUserId != null">#{fsUserId},</if>
+            <if test="isDaysNotStudy != null">#{isDaysNotStudy},</if>
+            <if test="finishCout != null">#{finishCout},</if>
+            <if test="finishTime != null">#{finishTime},</if>
+            <if test="finishCourseDays != null">#{finishCourseDays},</if>
+            <if test="grade != null">#{grade},</if>
+            <if test="status != null">#{status},</if>
+            <if test="createTime != null">#{createTime},</if>
+            <if test="createBy != null">#{createBy},</if>
+            <if test="updateTime != null">#{updateTime},</if>
+            <if test="updateBy != null">#{updateBy},</if>
+            <if test="remark != null">#{remark},</if>
+            <if test="tagNames != null">#{tagNames},</if>
+         </trim>
+    </insert>
+
+    <update id="updateWxSopUserInfo" parameterType="WxSopUserInfo">
+        update wx_sop_user_info
+        <trim prefix="SET" suffixOverrides=",">
+            <if test="sopId != null">sop_id = #{sopId},</if>
+            <if test="sopUserId != null">sop_user_id = #{sopUserId},</if>
+            <if test="wxContactId != null">wx_contact_id = #{wxContactId},</if>
+            <if test="customerId != null">customer_id = #{customerId},</if>
+            <if test="fsUserId != null">fs_user_id = #{fsUserId},</if>
+            <if test="isDaysNotStudy != null">is_days_not_study = #{isDaysNotStudy},</if>
+            <if test="finishCout != null">finish_cout = #{finishCout},</if>
+            <if test="finishTime != null">finish_time = #{finishTime},</if>
+            <if test="finishCourseDays != null">finish_course_days = #{finishCourseDays},</if>
+            <if test="grade != null">grade = #{grade},</if>
+            <if test="status != null">status = #{status},</if>
+            <if test="createTime != null">create_time = #{createTime},</if>
+            <if test="createBy != null">create_by = #{createBy},</if>
+            <if test="updateTime != null">update_time = #{updateTime},</if>
+            <if test="updateBy != null">update_by = #{updateBy},</if>
+            <if test="remark != null">remark = #{remark},</if>
+            <if test="tagNames != null">tag_names = #{tagNames},</if>
+        </trim>
+        where id = #{id}
+    </update>
+
+    <delete id="deleteWxSopUserInfoById" parameterType="Long">
+        delete from wx_sop_user_info where id = #{id}
+    </delete>
+
+    <delete id="deleteWxSopUserInfoByIds" parameterType="String">
+        delete from wx_sop_user_info where id in
+        <foreach item="id" collection="array" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </delete>
+
+    <select id="selectWxSopUserInfoByCondition" parameterType="WxSopUserInfo" resultMap="WxSopUserInfoResult">
+        <include refid="selectWxSopUserInfoVo"/>
+        <where>
+            <if test="sopId != null">and wsui.sop_id = #{sopId}</if>
+            <if test="sopUserId != null">and wsui.sop_user_id = #{sopUserId}</if>
+            <if test="wxContactId != null">and wsui.wx_contact_id = #{wxContactId}</if>
+            <if test="customerId != null">and wsui.customer_id = #{customerId}</if>
+        </where>
+        limit 1
+    </select>
+</mapper>

+ 150 - 0
fs-service/src/main/resources/mapper/wx/WxSopUserMapper.xml

@@ -0,0 +1,150 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.wx.sop.mapper.WxSopUserMapper">
+
+    <resultMap type="WxSopUser" id="WxSopUserResult">
+        <result property="id"    column="id"    />
+        <result property="type"    column="type"    />
+        <result property="sopId"    column="sop_id"    />
+        <result property="accountId"    column="account_id"    />
+        <result property="startTime"    column="start_time"    />
+        <result property="chatId"    column="chat_id"    />
+        <result property="status"    column="status"    />
+        <result property="createTime"    column="create_time"    />
+        <result property="createBy"    column="create_by"    />
+        <result property="updateTime"    column="update_time"    />
+        <result property="updateBy"    column="update_by"    />
+        <result property="remark"    column="remark"    />
+    </resultMap>
+
+    <sql id="selectWxSopUserVo">
+        select id, type, sop_id, account_id, start_time, chat_id, status, create_time, create_by, update_time, update_by, remark from wx_sop_user
+    </sql>
+
+    <select id="selectWxSopUserList" parameterType="WxSopUser" resultMap="WxSopUserResult">
+        SELECT
+        w.id,w.type, w.sop_id, w.account_id, w.start_time, w.chat_id, w.status,
+        w.create_time, w.create_by, w.update_time, w.update_by, w.remark
+        FROM wx_sop_user w
+        <where>
+            <if test="type != null "> and type = #{type}</if>
+            <if test="sopId != null "> and sop_id = #{sopId}</if>
+            <if test="accountId != null "> and account_id = #{accountId}</if>
+            <if test="accountName != null "> and account_name = #{accountName}</if>
+            <if test="startTime != null "> and start_time = #{startTime}</if>
+            <if test="chatId != null  and chatId != ''"> and chat_id = #{chatId}</if>
+            <if test="status != null "> and status = #{status}</if>
+        </where>
+    </select>
+
+    <select id="selectWxSopUserById" parameterType="Long" resultMap="WxSopUserResult">
+        <include refid="selectWxSopUserVo"/>
+        where id = #{id}
+    </select>
+
+    <insert id="insertWxSopUser" parameterType="WxSopUser" useGeneratedKeys="true" keyProperty="id">
+        insert into wx_sop_user
+        <trim prefix="(" suffix=")" suffixOverrides=",">
+            <if test="type != null">type,</if>
+            <if test="sopId != null">sop_id,</if>
+            <if test="accountId != null">account_id,</if>
+            <if test="startTime != null">start_time,</if>
+            <if test="chatId != null">chat_id,</if>
+            <if test="status != null">status,</if>
+            <if test="createTime != null">create_time,</if>
+            <if test="createBy != null">create_by,</if>
+            <if test="updateTime != null">update_time,</if>
+            <if test="updateBy != null">update_by,</if>
+            <if test="remark != null">remark,</if>
+         </trim>
+        <trim prefix="values (" suffix=")" suffixOverrides=",">
+            <if test="type != null">#{type},</if>
+            <if test="sopId != null">#{sopId},</if>
+            <if test="accountId != null">#{accountId},</if>
+            <if test="startTime != null">#{startTime},</if>
+            <if test="chatId != null">#{chatId},</if>
+            <if test="status != null">#{status},</if>
+            <if test="createTime != null">#{createTime},</if>
+            <if test="createBy != null">#{createBy},</if>
+            <if test="updateTime != null">#{updateTime},</if>
+            <if test="updateBy != null">#{updateBy},</if>
+            <if test="remark != null">#{remark},</if>
+         </trim>
+    </insert>
+
+    <update id="updateWxSopUser" parameterType="WxSopUser">
+        update wx_sop_user
+        <trim prefix="SET" suffixOverrides=",">
+            <if test="type != null">type = #{type},</if>
+            <if test="sopId != null">sop_id = #{sopId},</if>
+            <if test="accountId != null">account_id = #{accountId},</if>
+            <if test="startTime != null">start_time = #{startTime},</if>
+            <if test="chatId != null">chat_id = #{chatId},</if>
+            <if test="status != null">status = #{status},</if>
+            <if test="createTime != null">create_time = #{createTime},</if>
+            <if test="createBy != null">create_by = #{createBy},</if>
+            <if test="updateTime != null">update_time = #{updateTime},</if>
+            <if test="updateBy != null">update_by = #{updateBy},</if>
+            <if test="remark != null">remark = #{remark},</if>
+        </trim>
+        where id = #{id}
+    </update>
+
+    <delete id="deleteWxSopUserById" parameterType="Long">
+        delete from wx_sop_user where id = #{id}
+    </delete>
+
+    <delete id="deleteWxSopUserByIds" parameterType="String">
+        delete from wx_sop_user where id in
+        <foreach item="id" collection="array" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </delete>
+
+    <delete id="deleteBySopId" parameterType="Long">
+        delete from wx_sop_user where sop_id = #{sopId}
+    </delete>
+
+    <select id="selectwxSopUser" parameterType="WxSopUser" resultMap="WxSopUserResult">
+        <include refid="selectWxSopUserVo"/>
+        <where>
+            <if test="sopId != null">and sop_id = #{sopId}</if>
+            <if test="type != null">and type = #{type}</if>
+            <if test="accountId != null">and account_id = #{accountId}</if>
+            <if test="startTime != null">and start_time = #{startTime}</if>
+        </where>
+        LIMIT 1
+    </select>
+
+    <select id="selectActiveWxSopUserForMsgGen" resultType="com.fs.wx.sop.vo.WxSopUserMsgGenVO">
+        SELECT
+            wsu.id AS sopUserId,
+            wsu.type,
+            wsu.sop_id AS sopId,
+            wsu.account_id AS accountId,
+            wsu.start_time AS startTime,
+            wsu.chat_id AS chatId,
+            ws.temp_id AS tempId,
+            ws.company_id AS companyId,
+            wsui.id AS infoId,
+            wsui.wx_contact_id AS wxContactId,
+            wsui.customer_id AS customerId,
+            wsui.fs_user_id AS fsUserId
+        FROM wx_sop_user wsu
+        INNER JOIN wx_sop ws ON wsu.sop_id = ws.id AND ws.status IN (1, 2)
+        INNER JOIN wx_sop_user_info wsui ON wsu.id = wsui.sop_user_id AND wsui.status = 0
+        WHERE wsu.start_time &lt;= CURDATE()
+          AND wsu.status = 0
+        ORDER BY wsu.id ASC, wsui.id ASC
+    </select>
+
+    <update id="updateWxSopUserDateById" parameterType="com.fs.wx.sop.params.UpdateWxSopUserLogDateVo">
+        update wx_sop_user set start_time = #{newStartTime}, update_time = NOW()
+        where id in
+        <foreach item="id" collection="ids" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </update>
+</mapper>

+ 2 - 3
fs-wx-api/src/main/java/com/fs/app/controller/AppBaseController.java

@@ -1,16 +1,15 @@
 package com.fs.app.controller;
 
 
-import com.fs.app.utils.JwtUtils;
-import com.fs.common.core.redis.RedisCache;
 import com.fs.common.utils.ServletUtils;
+import com.fs.company.utils.JwtUtils;
 import io.jsonwebtoken.Claims;
 import org.springframework.beans.factory.annotation.Autowired;
 
 
 public class AppBaseController {
 	@Autowired
-	JwtUtils jwtUtils;
+    JwtUtils jwtUtils;
 
 	public String getUserId()
 	{

+ 3 - 2
fs-wx-api/src/main/java/com/fs/app/controller/CommonController.java

@@ -8,6 +8,7 @@ import com.fs.app.websocket.bean.ResultMsgVo;
 import com.fs.common.core.domain.ResponseResult;
 import com.fs.company.domain.CompanyWxAccount;
 import com.fs.company.mapper.CompanyWxAccountMapper;
+import com.fs.his.utils.PhoneUtil;
 import com.fs.wxcid.domain.WxContact;
 import com.fs.wxcid.mapper.WxContactMapper;
 import com.fs.wxcid.vo.wxvo.AddWxVo;
@@ -61,7 +62,7 @@ public class CommonController {
         WxContact wxContact = new WxContact();
         wxContact.setRemark(remark);
         wxContact.setNickName("测试1");
-        wxContact.setPhone(phone);
+        wxContact.setPhone(PhoneUtil.decryptPhone(phone));
         wxContact.setAccountId(companyWxAccount.getId());
         wxContact.setCompanyId(companyWxAccount.getCompanyId());
         wxContact.setCompanyUserId(companyWxAccount.getCompanyUserId());
@@ -75,7 +76,7 @@ public class CommonController {
     public R addWxAction(@RequestBody AddWxActionParam param){
         String wxId = param.getWxId();
         Session session = WebSocketServer.sessionPools.get(wxId);
-        webSocketServer.sendMessage(session, ResultMsgVo.<AddWxVo>builder().cmd(CmdType.ADD_WX).data(new AddWxVo(param.getRemark(), param.getPhone(), param.getApplyMsg(), param.getBizJson())).build());
+        webSocketServer.sendMessage(session, ResultMsgVo.<AddWxVo>builder().cmd(CmdType.ADD_WX).data(new AddWxVo(param.getRemark(), PhoneUtil.decryptPhone(param.getPhone()), param.getApplyMsg(), param.getBizJson())).build());
         return R.ok();
     }
 

+ 1 - 1
fs-wx-api/src/main/java/com/fs/app/interceptor/AuthorizationInterceptor.java

@@ -3,9 +3,9 @@ package com.fs.app.interceptor;
 
 import com.fs.app.annotation.Login;
 import com.fs.app.exception.FSException;
-import com.fs.app.utils.JwtUtils;
 import com.fs.common.core.redis.RedisCache;
 import com.fs.common.utils.StringUtils;
+import com.fs.company.utils.JwtUtils;
 import io.jsonwebtoken.Claims;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.http.HttpStatus;

+ 0 - 87
fs-wx-api/src/main/java/com/fs/app/utils/JwtUtils.java

@@ -1,87 +0,0 @@
-package com.fs.app.utils;
-
-import io.jsonwebtoken.Claims;
-import io.jsonwebtoken.Jwts;
-import io.jsonwebtoken.SignatureAlgorithm;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.springframework.boot.context.properties.ConfigurationProperties;
-import org.springframework.stereotype.Component;
-
-import java.util.Date;
-
-/**
- * jwt工具类
-
- */
-@ConfigurationProperties(prefix = "fs.jwt")
-@Component
-public class JwtUtils {
-    private Logger logger = LoggerFactory.getLogger(getClass());
-
-
-    private String secret;
-    private long expire;
-    private String header;
-
-    /**
-     * 生成jwt token
-     */
-    public String generateToken(long userId) {
-        Date nowDate = new Date();
-        //过期时间
-        Date expireDate = new Date(nowDate.getTime() + expire * 1000);
-
-        return Jwts.builder()
-                .setHeaderParam("typ", "JWT")
-                .setSubject(userId+"")
-                .setIssuedAt(nowDate)
-                .setExpiration(expireDate)
-                .signWith(SignatureAlgorithm.HS512, secret)
-                .compact();
-    }
-
-    public Claims getClaimByToken(String token) {
-        try {
-            return Jwts.parser()
-                    .setSigningKey(secret)
-                    .parseClaimsJws(token)
-                    .getBody();
-        }catch (Exception e){
-            logger.debug("validate is token error ", e);
-            return null;
-        }
-    }
-
-    /**
-     * token是否过期
-     * @return  true:过期
-     */
-    public boolean isTokenExpired(Date expiration) {
-        return expiration.before(new Date());
-    }
-
-    public String getSecret() {
-        return secret;
-    }
-
-    public void setSecret(String secret) {
-        this.secret = secret;
-    }
-
-    public long getExpire() {
-        return expire;
-    }
-
-    public void setExpire(long expire) {
-        this.expire = expire;
-    }
-
-    public String getHeader() {
-        return header;
-    }
-
-    public void setHeader(String header) {
-        this.header = header;
-    }
-}

+ 267 - 113
fs-wx-api/src/main/java/com/fs/app/websocket/service/WebSocketServer.java

@@ -6,39 +6,53 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.fs.app.enums.CmdType;
 import com.fs.app.websocket.bean.ResultMsgVo;
 import com.fs.app.websocket.bean.SendMsgVo;
-import com.fs.company.domain.CompanyWxClient;
-import com.fs.company.mapper.CompanyWxClientMapper;
-import com.fs.company.service.CompanyWorkflowEngine;
-import com.fs.company.service.impl.CompanyWxServiceImpl;
-import com.fs.wxcid.domain.CidIpadServer;
-import com.fs.wxcid.mapper.CidIpadServerMapper;
-import com.fs.wxcid.vo.wxvo.*;
+import com.fs.common.config.RedisTenantContext;
+import com.fs.common.core.domain.model.TenantPrincipal;
 import com.fs.common.core.redis.RedisCache;
 import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.spring.SpringUtils;
 import com.fs.company.domain.CompanyWxAccount;
+import com.fs.company.domain.CompanyWxClient;
 import com.fs.company.mapper.CompanyWxAccountMapper;
+import com.fs.company.mapper.CompanyWxClientMapper;
+import com.fs.company.service.CompanyWorkflowEngine;
+import com.fs.company.service.impl.CompanyWxServiceImpl;
+import com.fs.company.service.ICompanyVoiceRoboticService;
+import com.fs.core.config.TenantConfigContext;
+import com.fs.tenant.domain.TenantInfo;
+import com.fs.tenant.mapper.TenantInfoMapper;
 import com.fs.wxcid.domain.WxContact;
+import com.fs.wxcid.mapper.CidIpadServerMapper;
 import com.fs.wxcid.mapper.WxContactMapper;
 import com.fs.wxcid.service.IWxMsgLogService;
+import com.fs.wxcid.utils.TenantHelper;
+import com.fs.wxcid.vo.wxvo.ContactInfoVo;
+import com.fs.wxcid.vo.wxvo.SyncInfoVo;
+import com.fs.wxcid.vo.wxvo.WxSendResultMsgVo;
 import com.hc.openapi.tool.fastjson.JSON;
 import lombok.extern.slf4j.Slf4j;
-import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
 import org.springframework.stereotype.Component;
 
 import javax.websocket.*;
 import javax.websocket.server.PathParam;
 import javax.websocket.server.ServerEndpoint;
 import java.io.IOException;
+import java.lang.reflect.Method;
 import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Collections;
 import java.util.Date;
 import java.util.List;
+import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Executor;
 
 
 @Slf4j
 @Component
-@ServerEndpoint("/app/webSocket/{wxId}")
+@ServerEndpoint("/app/webSocket/{wxId}/{tenantCode}")
 public class WebSocketServer {
 
     //concurrent包的线程安全Set,用来存放每个客户端对应的WebSocketServer对象。
@@ -51,7 +65,9 @@ public class WebSocketServer {
     CompanyWxServiceImpl companyWxService = SpringUtils.getBean(CompanyWxServiceImpl.class);
     CidIpadServerMapper cidIpadServerMapper = SpringUtils.getBean(CidIpadServerMapper.class);
     CompanyWorkflowEngine companyWorkflowEngine = SpringUtils.getBean(CompanyWorkflowEngine.class);
-
+    TenantInfoMapper tenantInfoMapper = SpringUtils.getBean(TenantInfoMapper.class);
+    ICompanyVoiceRoboticService companyVoiceRoboticService = SpringUtils.getBean(ICompanyVoiceRoboticService.class);
+    Executor cidWorkFlowExecutor = SpringUtils.getBean("cidWorkFlowExecutor");
     //发送消息
     public <T> void sendMessage(Session session, ResultMsgVo<T> data) {
         if (session != null) {
@@ -65,133 +81,185 @@ public class WebSocketServer {
             }
         }
     }
+
     //建立连接成功调用
     @OnOpen
-    public void onOpen(Session session, @PathParam(value = "wxId") String wxId) {
-        CompanyWxAccount companyWxAccount = accountMapper.selectOne(new QueryWrapper<CompanyWxAccount>().eq("wx_no", wxId));
-        if(companyWxAccount == null){
-            sendMessage(session, ResultMsgVo.error("未找到对应微信数据"));
-            return;
+    public void onOpen(Session session, @PathParam(value = "wxId") String wxId, @PathParam(value = "tenantCode") String tenantCode) {
+        Boolean switchBool = switchDataBaseByTenantCode(tenantCode);
+        try {
+            CompanyWxAccount companyWxAccount = accountMapper.selectOne(new QueryWrapper<CompanyWxAccount>().eq("wx_no", wxId));
+            if (companyWxAccount == null) {
+                sendMessage(session, ResultMsgVo.error("未找到对应微信数据"));
+                return;
+            }
+            sessionPools.put(wxId, session);
+            companyWxAccount.setLoginStatus(1);
+            companyWxAccount.setLoginTime(LocalDateTime.now());
+            accountMapper.updateById(companyWxAccount);
+            JSONObject jsonObject = new JSONObject();
+            jsonObject.put("remark", companyWxAccount.getRemark());
+            sendMessage(session, ResultMsgVo.<JSONObject>builder().cmd(CmdType.INIT_REMARK).data(jsonObject).build());
+            log.info("{}加入webSocket!当前人数为{}", wxId, sessionPools.size());
+        } catch (Exception e) {
+            log.error("onOpenErr:{}", e.getMessage());
+        } finally {
+            if (switchBool) {
+                finalHandle();
+            }
         }
-        sessionPools.put(wxId, session);
-        companyWxAccount.setLoginStatus(1);
-        companyWxAccount.setLoginTime(LocalDateTime.now());
-        accountMapper.updateById(companyWxAccount);
-        JSONObject jsonObject = new JSONObject();
-        jsonObject.put("remark", companyWxAccount.getRemark());
-        sendMessage(session, ResultMsgVo.<JSONObject>builder().cmd(CmdType.INIT_REMARK).data(jsonObject).build());
-        log.info("{}加入webSocket!当前人数为{}", wxId, sessionPools.size());
+
     }
 
     //关闭连接时调用
     @OnClose
-    public void onClose(@PathParam(value = "wxId") String wxId) {
-        sessionPools.remove(wxId);
-        CompanyWxAccount companyWxAccount = accountMapper.selectOne(new QueryWrapper<CompanyWxAccount>().eq("wx_no", wxId));
-        if(companyWxAccount != null){
-            companyWxAccount.setLoginStatus(0);
-            companyWxAccount.setOutTime(LocalDateTime.now());
-            companyWxAccount.setOutRemark("连接断开");
-            accountMapper.updateById(companyWxAccount);
+    public void onClose(@PathParam(value = "wxId") String wxId, @PathParam(value = "tenantCode") String tenantCode) {
+        Boolean switchBool = switchDataBaseByTenantCode(tenantCode);
+        try {
+            sessionPools.remove(wxId);
+            CompanyWxAccount companyWxAccount = accountMapper.selectOne(new QueryWrapper<CompanyWxAccount>().eq("wx_no", wxId));
+            if (companyWxAccount != null) {
+                companyWxAccount.setLoginStatus(0);
+                companyWxAccount.setOutTime(LocalDateTime.now());
+                companyWxAccount.setOutRemark("连接断开");
+                accountMapper.updateById(companyWxAccount);
+            }
+            log.info("{}断开webSocket连接!当前人数为{}", wxId, sessionPools.size());
+        } catch (Exception e) {
+            log.error("onCloseErr:{}", e.getMessage());
+        } finally {
+            if (switchBool) {
+                finalHandle();
+            }
         }
-        log.info("{}断开webSocket连接!当前人数为{}", wxId, sessionPools.size());
     }
 
     //收到客户端信息
     @OnMessage
-    public void onMessage(String message, @PathParam(value = "wxId") String wxId) {
-        SendMsgVo msg = JSONObject.parseObject(message, SendMsgVo.class);
-        if(msg.getType() == 0){
-            return;
-        }
-        Session session = sessionPools.get(wxId);
-        if(session == null){
-            log.error("参数异常:{}", wxId);
-            return;
-        }
-        log.info("收到数据:{}", msg.getCmd());
-        CompanyWxAccount companyWxAccount = accountMapper.selectOne(new QueryWrapper<CompanyWxAccount>().eq("wx_no", wxId));
-        if(companyWxAccount == null){
-            log.error("未找到对应账号:{}", wxId);
+    public void onMessage(String message, @PathParam(value = "wxId") String wxId, @PathParam(value = "tenantCode") String tenantCode) {
+        Boolean switchBool = switchDataBaseByTenantCode(tenantCode);
+        if(!switchBool){
+            log.error("{} 微信连接解决,微检测到租户信息!!", wxId);
             return;
         }
         try {
-            switch (msg.getCmd()) {
-                case HEARTBEAT:
-                    log.info("接收心跳:{}", wxId);
-                    break;
-                case SYNC_CONTACT_PERSON:
-                    ContactInfoVo contactInfoVo = JSON.parseObject(msg.getDataJson(), ContactInfoVo.class);
-                    if(contactInfoVo == null || StringUtils.isEmpty(contactInfoVo.getRemark())){
-                        log.error("{}同步数据失败,数据缺失:{}", wxId, contactInfoVo);
-                        return;
-                    }
-                    WxContact contact = wxContactMapper.selectOne(new QueryWrapper<WxContact>().eq("remark", contactInfoVo.getRemark()));
-                    if(contact != null){
-                        contact.setNickName(contactInfoVo.getNickName());
-                        contact.setCity(contactInfoVo.getAddress());
-                        contact.setUserName(contactInfoVo.getWxNo());
-                        contact.setUpdateTime(new Date());
-                        wxContactMapper.updateById(contact);
-                    }else{
-                        WxContact contact1 = new WxContact();
-                        contact1.setUserName(contactInfoVo.getWxNo());
-                        contact1.setNickName(contactInfoVo.getNickName());
-                        contact1.setCity(contactInfoVo.getAddress());
-                        contact1.setAccountId(companyWxAccount.getId());
-                        contact1.setCompanyId(companyWxAccount.getCompanyId());
-                        contact1.setCompanyUserId(companyWxAccount.getCompanyUserId());
-                        contact1.setRemark(contactInfoVo.getRemark());
-                        contact1.setCreateTime(new Date());
-                        contact1.setUpdateTime(new Date());
-                        wxContactMapper.insert(contact1);
-                    }
-                    break;
-                case SEND_MSG:
-                    log.info("发送返回:{}", msg);
-                    wxMsgLogService.insertLog(JSON.parseObject(msg.getDataJson(), WxSendResultMsgVo.class), companyWxAccount, 0);
-                    break;
-                case SEND_RESULT:
-                    log.info("接收消息:{}", msg);
-                    wxMsgLogService.insertLog(JSON.parseObject(msg.getDataJson(), WxSendResultMsgVo.class), companyWxAccount, 0);
-                    break;
-                case SYNC_INFO:
-                    SyncInfoVo syncInfoVo = JSON.parseObject(msg.getDataJson(), SyncInfoVo.class);
-                    companyWxAccount.setHeadImgUrl(syncInfoVo.getImg());
-                    companyWxAccount.setPhone(syncInfoVo.getPhone());
-                    accountMapper.updateById(companyWxAccount);
-                    break;
-                case ADD_WX_RESULT:
-                    com.fs.wxcid.vo.wxvo.AddResultWxVo addResultWxVo = JSON.parseObject(msg.getDataJson(), com.fs.wxcid.vo.wxvo.AddResultWxVo.class);
-                    log.info("接收到加好友回调:{}", addResultWxVo);
-                    WxContact wxContact = wxContactMapper.selectOne(new QueryWrapper<WxContact>().eq("remark", addResultWxVo.getRemark()).eq("friends", 0));
-                    log.info("更新联系人:{}", wxContact);
-                    wxContact.setFriends(1);
-                    wxContact.setAlias(addResultWxVo.getWxid());
-                    wxContactMapper.updateById(wxContact);
-                    List<CompanyWxClient> clients = companyWxClientMapper.selectWxV2(companyWxAccount.getId(), wxContact.getPhone());
-                    log.info("更新联系人2:{}", clients);
-                    if(clients != null){
-                        clients.parallelStream().forEach(e -> {
-                            e.setIsAdd(1);
-                            e.setRemark(addResultWxVo.getRemark());
-                            e.setWxName(addResultWxVo.getUserName());
-                            e.setSuccessAddTime(LocalDateTime.now());
-                            companyWxClientMapper.updateById(e);
-                            companyWxService.triggerWorkflowOnAddWxSuccess(e.getId());
-                        });
-                    }
+            SendMsgVo msg = JSONObject.parseObject(message, SendMsgVo.class);
+            if (msg.getType() == 0) {
+                return;
+            }
+            Session session = sessionPools.get(wxId);
+            if (session == null) {
+                log.error("参数异常:{}", wxId);
+                return;
+            }
+            log.info("收到数据:{}", msg.getCmd());
+            CompanyWxAccount companyWxAccount = accountMapper.selectOne(new QueryWrapper<CompanyWxAccount>().eq("wx_no", wxId));
+            if (companyWxAccount == null) {
+                log.error("未找到对应账号:{}", wxId);
+                return;
+            }
+            try {
+                switch (msg.getCmd()) {
+                    case HEARTBEAT:
+                        log.info("接收心跳:{}", wxId);
+                        break;
+                    case SYNC_CONTACT_PERSON:
+                        ContactInfoVo contactInfoVo = JSON.parseObject(msg.getDataJson(), ContactInfoVo.class);
+                        if (contactInfoVo == null || StringUtils.isEmpty(contactInfoVo.getWxNo())) {
+                            log.error("{}同步数据失败,数据缺失:{}", wxId, contactInfoVo);
+                            return;
+                        }
+                        WxContact contact = wxContactMapper.selectOne(new QueryWrapper<WxContact>().eq("user_name", contactInfoVo.getWxNo()).eq("user_name", contactInfoVo.getWxNo()));
+                        if (contact != null) {
+                            contact.setNickName(contactInfoVo.getNickName());
+                            contact.setCity(contactInfoVo.getAddress());
+                            contact.setUserName(contactInfoVo.getWxNo());
+                            contact.setUpdateTime(new Date());
+                            wxContactMapper.updateById(contact);
+                        } else {
+                            WxContact contact1 = new WxContact();
+                            contact1.setUserName(contactInfoVo.getWxNo());
+                            contact1.setNickName(contactInfoVo.getNickName());
+                            contact1.setCity(contactInfoVo.getAddress());
+                            contact1.setAccountId(companyWxAccount.getId());
+                            contact1.setCompanyId(companyWxAccount.getCompanyId());
+                            contact1.setCompanyUserId(companyWxAccount.getCompanyUserId());
+                            contact1.setRemark(contactInfoVo.getRemark());
+                            contact1.setCreateTime(new Date());
+                            contact1.setUpdateTime(new Date());
+                            wxContactMapper.insert(contact1);
+                        }
+                        break;
+                    case SEND_MSG:
+                        log.info("发送返回:{}", msg);
+                        wxMsgLogService.insertLog(JSON.parseObject(msg.getDataJson(), WxSendResultMsgVo.class), companyWxAccount, 0);
+                        break;
+                    case SEND_RESULT:
+                        log.info("接收消息:{}", msg);
+                        wxMsgLogService.insertLog(JSON.parseObject(msg.getDataJson(), WxSendResultMsgVo.class), companyWxAccount, 0);
+                        break;
+                    case SYNC_INFO:
+                        SyncInfoVo syncInfoVo = JSON.parseObject(msg.getDataJson(), SyncInfoVo.class);
+                        companyWxAccount.setHeadImgUrl(syncInfoVo.getImg());
+                        companyWxAccount.setPhone(syncInfoVo.getPhone());
+                        accountMapper.updateById(companyWxAccount);
+                        break;
+                    case ADD_WX_RESULT:
+                        com.fs.wxcid.vo.wxvo.AddResultWxVo addResultWxVo = JSON.parseObject(msg.getDataJson(), com.fs.wxcid.vo.wxvo.AddResultWxVo.class);
+                        log.info("接收到加好友回调:{}", addResultWxVo);
+                        JSONObject jsonObject = JSONObject.parseObject(addResultWxVo.getBizJson());
+                        WxContact wxContact = wxContactMapper.selectById(jsonObject.getLong("wxContactId"));
+                        log.info("更新联系人:{}", wxContact);
+                        wxContact.setFriends(1);
+                        wxContact.setUserName(addResultWxVo.getWxid());
+                        wxContactMapper.updateById(wxContact);
+                        List<CompanyWxClient> clients = companyWxClientMapper.selectWxV2(companyWxAccount.getId(), wxContact.getPhone());
+                        log.info("更新联系人2:{}", clients);
+                        if (clients != null) {
+                            List<CompletableFuture<Void>> futures = new ArrayList<>();
+                            clients.forEach(e -> {
+                                CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
+                                    try {
+                                        e.setIsAdd(1);
+                                        e.setWxNo(addResultWxVo.getWxid());
+                                        e.setWxName(addResultWxVo.getUserName());
+                                        e.setSuccessAddTime(LocalDateTime.now());
+                                        companyWxClientMapper.updateById(e);
+                                        // 暂停检查:任务暂停中则跳过工作流触发,加微结果已保存,恢复时resumePausedInstances会自动检测并补触发
+                                        if (e.getRoboticId() != null && companyVoiceRoboticService.isTaskPaused(e.getRoboticId())) {
+                                            log.info("任务暂停中,加微结果已保存但不触发工作流继续执行 - taskId: {}, wxClientId: {}", e.getRoboticId(), e.getId());
+                                            // 标记回调已到达,便于恢复时补触发
+                                            markAddWxCallbackReceived(e.getId());
+                                            return;
+                                        }
+                                        companyWxService.triggerWorkflowOnAddWxSuccess(e.getId());
+                                    } catch (Exception ex) {
+                                        log.error("处理加微回调异常 - wxClientId: {}", e.getId(), ex);
+                                    }
+                                }, cidWorkFlowExecutor);
+                                futures.add(future);
+                            });
+                            CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
+                        }
 //                    if(null != addResultWxVo && StringUtils.isNotBlank(addResultWxVo.getBizJson())){
 //                        JSONObject jsonObject = JSONObject.parseObject(addResultWxVo.getBizJson());
 //                        jsonObject.put("remark",addResultWxVo.getRemark());
 //                        companyWorkflowEngine.addWxSuccess(jsonObject);
 //                    }
-                    break;
+                        break;
 
+                }
+            } catch (Exception e) {
+                log.error("发生错误;{}", e.getMessage());
             }
         } catch (Exception e) {
-            log.error("发生错误;{}", e.getMessage());
+            log.error("onMessageErr:{}", e.getMessage());
         }
+        finally {
+            if (switchBool) {
+                finalHandle();
+            }
+        }
+
 
     }
 
@@ -201,4 +269,90 @@ public class WebSocketServer {
         log.error("发生错误;{}", throwable.getMessage());
         throwable.printStackTrace();
     }
+
+    /**
+     * 根据租户编码切换数据源
+     *
+     * @param tenantCode
+     */
+    public Boolean switchDataBaseByTenantCode(String tenantCode) {
+        if (StringUtils.isBlank(tenantCode)) {
+            log.error("未找到对应租户:{}", tenantCode);
+            return Boolean.FALSE;
+        }
+        try {
+            TenantInfo tenantInfo = tenantInfoMapper.getTenByCode(tenantCode);
+            TenantHelper.setTenantId(tenantInfo.getId());
+            Object manager = SpringUtils.getBean("tenantDataSourceManager");
+            Method method = manager.getClass().getMethod("ensureSwitchByTenantId", Long.class);
+            method.invoke(manager, tenantInfo.getId());
+            // 设置租户到 SecurityContext,供 TenantKeyRedisSerializer 自动为 Redis Key 加 tenantid 前缀
+            SecurityContextHolder.getContext().setAuthentication(
+                    new UsernamePasswordAuthenticationToken(
+                            new TenantPrincipal(TenantHelper.getTenantId()),
+                            null,
+                            Collections.emptyList()
+                    )
+            );
+            // 切换 Redis 租户上下文
+            RedisTenantContext.setTenantId(TenantHelper.getTenantId());
+            return Boolean.TRUE;
+        } catch (Exception e) {
+            log.error("callerResult4EasyCall 切换租户数据源失败: tenantId={}", TenantHelper.getTenantId(), e);
+            return Boolean.FALSE;
+        }
+    }
+
+
+    /**
+     * 标记加微回调已到达(暂停期间),便于恢复时补触发
+     */
+    private void markAddWxCallbackReceived(Long wxClientId) {
+        try {
+            com.fs.company.mapper.CompanyAiWorkflowExecMapper execMapper =
+                    SpringUtils.getBean(com.fs.company.mapper.CompanyAiWorkflowExecMapper.class);
+            // 查找WAITING状态的加微工作流实例(AI_ADD_WX_TASK_NEW=10)
+            com.fs.company.domain.CompanyAiWorkflowExec exec = execMapper.selectWaitingAddWxWorkflowByWxClientId(
+                    wxClientId, 5, 10);
+            // 未找到新类型,尝试老类型(AI_ADD_WX_TASK=8)
+            if (exec == null) {
+                exec = execMapper.selectWaitingAddWxWorkflowByWxClientId(wxClientId, 5, 8);
+            }
+            if (exec == null) {
+                log.debug("markAddWxCallbackReceived: 未找到对应工作流实例 - wxClientId: {}", wxClientId);
+                return;
+            }
+            String variablesJson = exec.getVariables();
+            com.alibaba.fastjson.JSONObject variables;
+            if (com.fs.common.utils.StringUtils.isNotBlank(variablesJson)) {
+                variables = com.alibaba.fastjson.JSONObject.parseObject(variablesJson);
+            } else {
+                variables = new com.alibaba.fastjson.JSONObject();
+            }
+            variables.put("pause_callback_received", true);
+            com.fs.company.domain.CompanyAiWorkflowExec update = new com.fs.company.domain.CompanyAiWorkflowExec();
+            update.setWorkflowInstanceId(exec.getWorkflowInstanceId());
+            update.setVariables(variables.toJSONString());
+            execMapper.updateByWorkflowInstanceId(update);
+            log.info("markAddWxCallbackReceived: 已标记加微回调到达 - wxClientId: {}, workflowInstanceId: {}",
+                    wxClientId, exec.getWorkflowInstanceId());
+        } catch (Exception e) {
+            log.error("markAddWxCallbackReceived: 标记失败 - wxClientId: {}", wxClientId, e);
+        }
+    }
+
+    public void finalHandle() {
+        try {
+            TenantConfigContext.clear();
+            SecurityContextHolder.clearContext();
+            Object manager = SpringUtils.getBean("tenantDataSourceManager");
+            Method method = manager.getClass().getMethod("clear");
+            method.invoke(manager);
+            TenantHelper.clearTenantId();
+            RedisTenantContext.clear();
+        } catch (Exception e) {
+            log.error("SOP异步任务清理租户数据源失败", e);
+        }
+    }
+
 }

+ 113 - 0
fs-wx-api/src/main/java/com/fs/framework/datasource/TenantDataSourceManager.java

@@ -0,0 +1,113 @@
+package com.fs.framework.datasource;
+
+import com.alibaba.druid.pool.DruidDataSource;
+import com.fs.common.enums.DataSourceType;
+import com.fs.tenant.domain.TenantInfo;
+import com.fs.tenant.service.TenantInfoService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import javax.sql.DataSource;
+import java.lang.reflect.Field;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 租户数据源管理,SaaS 模式下定时任务按租户切库时使用。
+ */
+@Component
+@Slf4j
+public class TenantDataSourceManager {
+
+    @Resource
+    private DynamicDataSource dynamicDataSource;
+
+    @Resource
+    private TenantInfoService tenantInfoService;
+
+    private static final Map<String, DataSource> TENANT_DS_CACHE = new ConcurrentHashMap<>();
+
+    public void switchTenant(TenantInfo tenantInfo) {
+        String tenantKey = buildTenantKey(tenantInfo.getId());
+        if (!TENANT_DS_CACHE.containsKey(tenantKey)) {
+            synchronized (this) {
+                if (!TENANT_DS_CACHE.containsKey(tenantKey)) {
+                    DataSource tenantDs = createTenantDataSource(tenantInfo);
+                    TENANT_DS_CACHE.put(tenantKey, tenantDs);
+                    Map<Object, DataSource> resolvedMap = getResolvedDataSources();
+                    resolvedMap.put(tenantKey, tenantDs);
+                }
+            }
+        }
+        DynamicDataSourceContextHolder.setDataSourceType(tenantKey);
+    }
+
+    private String buildTenantKey(Long tenantId) {
+        return "tenant:" + tenantId;
+    }
+
+    public void clear() {
+        DynamicDataSourceContextHolder.clearDataSourceType();
+    }
+
+    private DataSource createTenantDataSource(TenantInfo tenant) {
+        DruidDataSource ds = new DruidDataSource();
+        ds.setUrl(tenant.getDbUrl());
+        ds.setUsername(tenant.getDbAccount());
+        ds.setPassword(tenant.getDbPwd());
+        ds.setDriverClassName("com.mysql.cj.jdbc.Driver");
+        ds.setInitialSize(5);
+        ds.setMinIdle(10);
+        ds.setMaxActive(20);
+        ds.setMaxWait(60000);
+        return ds;
+    }
+
+    @SuppressWarnings("unchecked")
+    private Map<Object, DataSource> getResolvedDataSources() {
+        try {
+            Field field = org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource.class
+                    .getDeclaredField("resolvedDataSources");
+            field.setAccessible(true);
+            return (Map<Object, DataSource>) field.get(dynamicDataSource);
+        } catch (Exception e) {
+            throw new IllegalStateException("获取 resolvedDataSources 失败", e);
+        }
+    }
+    /**
+     * 根据租户ID确保数据源已注册并切换(用于 Filter/拦截器等非登录场景)
+     * 解决 JVM 重启后 TENANT_DS_CACHE 被清空,导致 resolvedDataSources 中找不到租户数据源的问题
+     *
+     * @param tenantId 租户ID
+     */
+    public void ensureSwitchByTenantId(Long tenantId) {
+        String tenantKey = buildTenantKey(tenantId);
+
+        if (TENANT_DS_CACHE.containsKey(tenantKey)) {
+            DynamicDataSourceContextHolder.setDataSourceType(tenantKey);
+            log.debug("[TenantDS] 数据源已缓存,直接切换: {}", tenantKey);
+            return;
+        }
+
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        try {
+            TenantInfo tenantInfo = tenantInfoService.getById(tenantId);
+            if (tenantInfo == null) {
+                log.warn("[TenantDS] 租户ID={} 在主库中不存在,回退到主库", tenantId);
+                DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+                return;
+            }
+            if (!tenantInfo.getStatus().equals(1)) {
+                log.warn("[TenantDS] 租户ID={} 已禁用,回退到主库", tenantId);
+                DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+                return;
+            }
+            switchTenant(tenantInfo);
+            log.info("[TenantDS] 动态注册并切换数据源: key={}, url={}", tenantKey, tenantInfo.getDbUrl());
+        } catch (Exception e) {
+            log.error("[TenantDS] 动态注册租户数据源失败, tenantId={}, 回退到主库", tenantId, e);
+            DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        }
+    }
+}

+ 155 - 0
fs-wx-api/src/main/resources/application-common.yml

@@ -0,0 +1,155 @@
+# 项目相关配置
+fs:
+  # 名称
+  name: fs
+  # 版本
+  version: 1.1.0
+  # 版权年份
+  copyrightYear: 2021
+  # 实例演示开关
+  demoEnabled: true
+  # 文件路径 示例( Windows配置D:/fs/uploadPath,Linux配置 /home/fs/uploadPath)
+  profile: c:/fs/uploadPath
+  # 获取ip地址开关
+  addressEnabled: false
+  # 验证码类型 math 数组计算 char 字符验证
+  captchaType: math
+#  jwt:
+#    # 加密秘钥
+#    secret: f4e2e52034348f86b67cde581c0f9eb5
+#    # token有效时长,7天,单位秒
+#    expire: 31536000
+#    header: AppToken
+# 开发环境配置
+server:
+  servlet:
+    # 应用的访问路径
+    context-path: /
+  tomcat:
+    # tomcat的URI编码
+    uri-encoding: UTF-8
+    # tomcat最大线程数,默认为200
+    max-threads: 800
+    # Tomcat启动初始化的线程数,默认值25
+    min-spare-threads: 30
+
+# 日志配置
+logging:
+  level:
+    com.fs: info
+    org.springframework: warn
+
+express:
+  omsCode: "SF.0235402855"
+# Spring配置
+spring:
+  main:
+    allow-circular-references: true
+  cache:
+    type: redis
+  # 资源信息
+  messages:
+    # 国际化资源文件路径
+    basename: i18n/messages
+  mvc:
+    async:
+      request-timeout: 600000
+
+  # 文件上传
+  servlet:
+     multipart:
+       # 单个文件大小
+       max-file-size:  3GB
+       # 设置总上传的文件大小
+       max-request-size:  3GB
+  # 服务模块
+  devtools:
+    restart:
+      # 热部署开关
+      enabled: true
+
+
+# token配置
+token:
+    # 令牌自定义标识
+    header: Authorization
+    # 令牌密钥
+    secret: YlrzSaas2026SecKey!@#QwErTyUiOpAsDfGhJkLzXcVbNm
+    # 令牌有效期(默认30分钟)
+    expireTime: 720
+mybatis-plus:
+  # 搜索指定包别名
+  typeAliasesPackage: com.fs.**.domain,com.fs.**.bo
+  # 配置mapper的扫描,找到所有的mapper.xml映射文件
+  mapperLocations: classpath*:/mapper/**/*.xml
+  configLocation: classpath:mybatis/mybatis-config.xml
+  # 全局配置
+  global-config:
+    db-config:
+      # 主键类型  0:"数据库ID自增", 1:"用户输入ID",2:"全局唯一ID (数字类型唯一ID)", 3:"全局唯一ID UUID";
+      idType: AUTO
+      # 字段策略 0:"忽略判断",1:"非 NULL 判断"),2:"非空判断"
+      fieldStrategy: NOT_EMPTY
+    banner: false
+    # 配置
+  configuration:
+    # 驼峰式命名
+    mapUnderscoreToCamelCase: true
+    # 全局映射器启用缓存
+    cacheEnabled: true
+    # 配置默认的执行器
+    defaultExecutorType: REUSE
+    # 允许 JDBC 支持自动生成主键
+    useGeneratedKeys: true
+
+# MyBatis配置
+mybatis:
+    # 搜索指定包别名
+    typeAliasesPackage: com.fs.**.domain
+    # 配置mapper的扫描,找到所有的mapper.xml映射文件
+    mapperLocations: classpath*:mapper/**/*Mapper.xml
+    # 加载全局的配置文件
+    configLocation: classpath:mybatis/mybatis-config.xml
+
+# PageHelper分页插件
+pagehelper:
+  helperDialect: mysql
+  reasonable: false #超出后不显示
+  supportMethodsArguments: false
+  params: count=countSql
+
+# Swagger配置
+swagger:
+  # 是否开启swagger
+  enabled: false
+  # 请求前缀
+  pathMapping: /dev-api
+
+# 防止XSS攻击
+xss:
+  # 过滤开关
+  enabled: true
+  # 排除链接(多个用逗号分隔)
+  excludes: /system/notice,/system/config/*
+  # 匹配链接
+  urlPatterns: /system/*,/monitor/*,/tool/*
+zhyf:
+  url: https://zhyf-testController.jingpai.com
+
+image:
+  storage:
+    local-path: C:\logoFile\logo.jpg
+    server-path: C:\logoFile\logo.jpg
+# application.properties
+wechat:
+  api:
+    base-url: https://api.weixin.qq.com
+    upload-shipping-info: /wxa/sec/order/upload_shipping_info
+hsy:
+  access_key: AKLTZTc4YTE4ZjI2OWViNDNjZGI2NjhiYTI5Njc5ZjA1Mzk
+  secret_key: WXpjelpUYzFOakF5TUdObE5EZGtNR0ZsWXpKaU1tTmtZakk1WXpObE4yRQ==
+  region: cn-north-1
+  role_access_key: AKLTNmMwNjJkNDFhYTVjNDIzYzhhNzEyZmZmZTlmYzBhNGM
+  role_secret_key: T0RaaFl6UmhZV1V4WXpKbU5EWTBNMkZpT0RNNU9UY3daak0wTjJFd09XUQ==
+  role_trn: trn:iam::2114522511:role/hylj
+

+ 137 - 0
fs-wx-api/src/main/resources/application-config-dev.yml

@@ -0,0 +1,137 @@
+baidu:
+  token: 12313231232
+  back-domain: https://www.xxxx.com
+#配置
+logging:
+  level:
+    org.springframework.web: debug
+    com.github.binarywang.demo.wx.cp: DEBUG
+    me.chanjar.weixin: DEBUG
+#wx:
+#  miniapp:
+#    configs:
+#      - appid: wx29d26f63f836be7f
+#        secret: 7542db9774355a89b1adce24defb6013
+#        token: Ncbnd7lJvkripVOpyTFAna6NAWCxCrvC
+#        aesKey: HlEiBB55eaWUaeBVAQO3cWKWPYv1vOVQSq7nFNICw4E
+#        msgDataFormat: JSON
+#  cp:
+#    corpId: wwb2a1055fb6c9a7c2
+#    appConfigs:
+#      - agentId: 1000005
+#        secret: ec7okROXJqkNafq66-L6aKNv0asTzQIG0CYrj3vyBbo
+#        token: PPKOdAlCoMO
+#        aesKey: PKvaxtpSv8NGpfTDm7VUHIK8Wok2ESyYX24qpXJAdMP
+#  pay:
+#    appId: wx73f85f8d62769119 #微信公众号或者小程序等的appid
+#    mchId: 1611402045 #微信支付商户号
+#    mchKey: 8cab128997a3547c1363b0898b877f38 #微信支付商户密钥
+#    subAppId:  #服务商模式下的子商户公众账号ID
+#    subMchId:  #服务商模式下的子商户号
+#    keyPath: c:\\cert\\apiclient_cert.p12 # p12证书的位置,可以指定绝对路径,也可以指定类路径(以classpath:开头)
+#    notifyUrl: https://userapp.his.runtzh.com/app/wxpay/wxPayNotify
+#  mp:
+#    useRedis: false
+#    redisConfig:
+#      host: 127.0.0.1
+#      port: 6379
+#      timeout: 2000
+#    configs:
+#      - appId: wx93ce67750e3cfba3 # 第一个公众号的appid  //公众号名称:云联融智
+#        secret: c172884087264160563bfe5775ca0f6f # 公众号的appsecret
+#        token: PPKOdAlCoMO # 接口配置里的Token值
+#        aesKey: Eswa6VjwtVMCcw03qZy6fWllgrv5aytIA1SZPEU0kU2 # 接口配置里的EncodingAESKey值
+#aifabu:  #爱链接
+#  appKey: 7b471be905ab17e00f3b858c6710dd117601d008
+#watch:
+#  watchUrl: watch.ylrzcloud.com/prod-api
+#  #  account: tcloud
+#  #  password: mdf-m2h_6yw2$hq
+#  account1: ccif #866655060138751
+#  password1: cp-t5or_6xw7$mt
+#  account2: tcloud #rt500台
+#  password2: mdf-m2h_6yw2$hq
+#  account3: whr
+#  password3: v9xsKuqn_$d2y
+#
+#fs :
+#  commonApi: http://172.16.0.16:8010
+#  h5CommonApi: http://119.29.195.254:8010
+#  jwt:
+#    # 加密秘钥
+#    secret: e10adc3949ba59abbe56e057f20f883e
+#    # token有效时长,7天,单位秒
+#    expire: 31536000
+#    header: AppToken
+#nuonuo:
+#  key: 10924508
+#  secret: A2EB20764D304D16
+#
+## 存储捅配置
+#tencent_cloud_config:
+#  secret_id: AKIDiMq9lDf2EOM9lIfqqfKo7FNgM5meD0sT
+#  secret_key: u5SuS80342xzx8FRBukza9lVNHKNMSaB
+#  bucket: myhk-1323137866
+#  app_id: 1323137866
+#  region: ap-chongqing
+#  proxy: myhk
+#cloud_host:
+#  company_name: 金康健
+#  projectCode: DEV
+#  spaceName:
+#  volcengineUrl:
+#headerImg:
+#  imgUrl: https://jz-cos-1356808054.cos.ap-chengdu.myqcloud.com/fs/20250515/0877754b59814ea8a428fa3697b20e68.png
+#ipad:
+#  url:
+#  ipadUrl: http://ipad.cdwjyyh.com
+#  aiApi: http://152.136.202.157:3000/api
+#  voiceApi:
+#  commonApi:
+#wx_miniapp_temp:
+#  pay_order_temp_id:
+#  inquiry_temp_id:
+## 聚水潭API配置
+#jst:
+##  app_key: a4b1fab173c84f67b3873857eea11d90 #聚水潭2025-07-25
+#  app_key: 871348458a964548a72bf8124cf917a4 #聚水潭2025-08-14
+#  app_secret: 5b7d9369dbcd414db45089bc047ebe1a #聚水潭2025-08-14
+##  app_secret: dfce1f8dc8a64ddc91212fc3fcdd9349 #聚水潭2025-07-25
+#  authorization_code: 666666
+#  shop_code: "18461733"
+#
+## RocketMQ配置
+#rocketmq:
+#  name-server: 127.0.0.1:9876
+#  producer:
+#    group: event-feedback-producer
+#    send-message-timeout: 3000
+#    retry-times-when-send-failed: 2
+#    retry-times-when-send-async-failed: 2
+#    max-message-size: 4194304
+#    compress-message-body-threshold: 4096
+#    retry-next-server: true
+#custom:
+#  token: "1o62d3YxvdHd4LEUiltnu7sK"
+#  encoding-aes-key: "UJfTQ5qKTKlegjkXtp1YuzJzxeHlUKvq5GyFbERN1iU"
+#  corp-id: "ww51717e2b71d5e2d3"configValue
+#  secret: "6ODAmw-8W4t6h9mdzHh2Z4Apwj8mnsyRnjEDZOHdA7k"
+#  private-key-path: "privatekey.pem"
+#  webhook-url: "https://your-server.com/wecom/archive"
+## token配置
+#token:
+#  # 令牌自定义标识
+#  header: Authorization
+#  # 令牌密钥
+#  secret: abcdefghijklmnopqrstuvwxyz
+#  # 令牌有效期(默认30分钟)
+#  expireTime: 180
+#openIM:
+#  secret: openIM123
+#  userID: imAdmin
+#  url: https://web.jnmyim.ylrzfs.com/api
+##是否为新商户,新商户不走mpOpenId
+#isNewWxMerchant: true
+##是否使用新im
+#im:
+#  type: OPENIM

+ 132 - 0
fs-wx-api/src/main/resources/application-dev.yml

@@ -0,0 +1,132 @@
+# 数据源配置
+spring:
+    # redis 配置
+    redis:
+        # 地址
+        host: localhost
+        # 端口,默认为6379
+        port: 6379
+        # 数据库索引
+        database: 0
+        # 密码
+        password:
+        # 连接超时时间
+        timeout: 20s
+        lettuce:
+            pool:
+                # 连接池中的最小空闲连接
+                min-idle: 0
+                # 连接池中的最大空闲连接
+                max-idle: 8
+                # 连接池的最大数据库连接数
+                max-active: 8
+                # #连接池最大阻塞等待时间(使用负值表示没有限制)
+                max-wait: -1ms
+    datasource:
+        mysql:
+            type: com.alibaba.druid.pool.DruidDataSource
+            driverClassName: com.mysql.cj.jdbc.Driver
+            druid:
+                initialSize: 5
+                minIdle: 10
+                maxActive: 20
+                maxWait: 60000
+                timeBetweenEvictionRunsMillis: 60000
+                minEvictableIdleTimeMillis: 300000
+                maxEvictableIdleTimeMillis: 900000
+                validationQuery: SELECT 1 FROM DUAL
+                testWhileIdle: true
+                testOnBorrow: false
+                testOnReturn: false
+                # 主库数据源
+                master:
+                    url: jdbc:mysql://cq-cdb-8fjmemkb.sql.tencentcdb.com:27220/ylrz_saas?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&useSSL=false
+                    username: root
+                    password: Ylrz_1q2w3e4r5t6y
+                    # 初始连接数
+                    initialSize: 5
+                    # 最小连接池数量
+                    minIdle: 10
+                    # 最大连接池数量
+                    maxActive: 20
+                    # 配置获取连接等待超时的时间
+                    maxWait: 60000
+                    # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
+                    timeBetweenEvictionRunsMillis: 60000
+                    # 配置一个连接在池中最小生存的时间,单位是毫秒
+                    minEvictableIdleTimeMillis: 300000
+                    # 配置一个连接在池中最大生存的时间,单位是毫秒
+                    maxEvictableIdleTimeMillis: 900000
+                    # 配置检测连接是否有效
+                    validationQuery: SELECT 1 FROM DUAL
+                    testWhileIdle: true
+                    testOnBorrow: false
+                    testOnReturn: false
+                    webStatFilter:
+                        enabled: true
+                    statViewServlet:
+                        enabled: true
+                        # 设置白名单,不填则允许所有访问
+                        allow:
+                        url-pattern: /druid/*
+                        # 控制台管理用户名和密码
+                        login-username: fs
+                        login-password: 123456
+                    filter:
+                        stat:
+                            enabled: true
+                            # 慢SQL记录
+                            log-slow-sql: true
+                            slow-sql-millis: 1000
+                            merge-sql: true
+                        wall:
+                            config:
+                                multi-statement-allow: true
+        easycall:
+            type: com.alibaba.druid.pool.DruidDataSource
+            driverClassName: com.mysql.cj.jdbc.Driver
+            druid:
+                # 主库数据源
+                master:
+                    url: jdbc:mysql://129.28.164.235:3306/easycallcenter365?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
+                    username: root
+                    password: easycallcenter365
+                # 初始连接数
+                initialSize: 5
+                # 最小连接池数量
+                minIdle: 10
+                # 最大连接池数量
+                maxActive: 20
+                # 配置获取连接等待超时的时间
+                maxWait: 60000
+                # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
+                timeBetweenEvictionRunsMillis: 60000
+                # 配置一个连接在池中最小生存的时间,单位是毫秒
+                minEvictableIdleTimeMillis: 300000
+                # 配置一个连接在池中最大生存的时间,单位是毫秒
+                maxEvictableIdleTimeMillis: 900000
+                # 配置检测连接是否有效
+                validationQuery: SELECT 1 FROM DUAL
+                testWhileIdle: true
+                testOnBorrow: false
+                testOnReturn: false
+                webStatFilter:
+                    enabled: true
+                statViewServlet:
+                    enabled: true
+                    # 设置白名单,不填则允许所有访问
+                    allow:
+                    url-pattern: /druid/*
+                    # 控制台管理用户名和密码
+                    login-username: fs
+                    login-password: 123456
+                filter:
+                    stat:
+                        enabled: true
+                        # 慢SQL记录
+                        log-slow-sql: true
+                        slow-sql-millis: 1000
+                        merge-sql: true
+                    wall:
+                        config:
+                            multi-statement-allow: true

+ 4 - 1
fs-wx-api/src/main/resources/application.yml

@@ -5,5 +5,8 @@ server:
 
 # Spring配置
 spring:
+  main:
+    allow-bean-definition-overriding: true
   profiles:
-    active: dev
+    active: dev
+    include: common,config-dev

+ 146 - 0
fs-wx-ipad-task/pom.xml

@@ -0,0 +1,146 @@
+<?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-ipad-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>
+
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</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-ipad-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(FsWxIpadTaskApplication.class);
+    }
+}

+ 25 - 0
fs-wx-ipad-task/src/main/java/com/fs/FsWxIpadTaskApplication.java

@@ -0,0 +1,25 @@
+package com.fs;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
+import org.springframework.context.annotation.EnableAspectJAutoProxy;
+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 FsWxIpadTaskApplication
+{
+    public static void main(String[] args){
+        // System.setProperty("spring.devtools.restart.enabled", "false");
+        SpringApplication.run(FsWxIpadTaskApplication.class, args);
+        System.out.println("IpadTask启动成功");
+    }
+}

+ 51 - 0
fs-wx-ipad-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;
+	}
+	
+	
+}

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

@@ -0,0 +1,82 @@
+package com.fs.app.exception;
+
+
+
+
+import com.fs.common.core.domain.R;
+import com.fs.common.exception.CustomException;
+import lombok.extern.slf4j.Slf4j;
+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;
+
+
+/**
+ * 异常处理器
+ */
+@Slf4j
+@RestControllerAdvice
+public class FSExceptionHandler {
+
+	/**
+	 * 处理自定义异常
+	 */
+	@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) {
+		log.error(e.getMessage(), e);
+		return R.error(404, "路径不存在,请检查路径是否正确");
+	}
+
+	@ExceptionHandler(DuplicateKeyException.class)
+	public R handleDuplicateKeyException(DuplicateKeyException e){
+		log.error(e.getMessage(), e);
+		return R.error("数据库中已存在该记录");
+	}
+
+
+	@ExceptionHandler(Exception.class)
+	public R handleException(Exception e){
+		log.error(e.getMessage(), e);
+		return R.error();
+	}
+	@ExceptionHandler(AccessDeniedException.class)
+	public R handleAccessDeniedException(AccessDeniedException e){
+		log.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());
+	}
+}

+ 34 - 0
fs-wx-ipad-task/src/main/java/com/fs/app/service/CustomThreadPoolConfig.java

@@ -0,0 +1,34 @@
+package com.fs.app.service;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+import java.util.concurrent.ThreadPoolExecutor;
+
+/**
+ * @author MixLiu
+ * @date 2025/7/11 上午11:04)
+ */
+@Configuration
+public class CustomThreadPoolConfig {
+    @Bean(name = "customThreadPool", destroyMethod = "shutdown")
+    public ThreadPoolTaskExecutor customThreadPool() {
+        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+        // 核心线程数
+        executor.setCorePoolSize(300);
+        // 最大线程数
+        executor.setMaxPoolSize(300);
+        // 线程名前缀
+        executor.setThreadNamePrefix("custom-pool-");
+        // 拒绝策略:直接丢弃新任务
+        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());
+        // 非核心线程空闲存活时间(秒)
+        executor.setKeepAliveSeconds(60);
+        // 等待所有任务完成后关闭线程池
+        executor.setWaitForTasksToCompleteOnShutdown(true);
+        // 初始化
+        executor.initialize();
+        return executor;
+    }
+}

+ 65 - 0
fs-wx-ipad-task/src/main/java/com/fs/app/service/WxIpadSendServer.java

@@ -0,0 +1,65 @@
+package com.fs.app.service;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.company.domain.CompanyWxAccount;
+import com.fs.company.mapper.CompanyWxAccountMapper;
+import com.fs.ipad.WxIpadSendUtils;
+import com.fs.ipad.vo.*;
+import com.fs.qw.vo.QwSopCourseFinishTempSetting;
+import com.fs.wx.sop.domain.WxSopLogs;
+import com.fs.wx.sop.vo.WxSopMsgVo;
+import com.fs.wxcid.domain.WxContact;
+import com.fs.wxcid.mapper.WxContactMapper;
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.util.*;
+
+@Slf4j
+@Service
+@AllArgsConstructor
+public class WxIpadSendServer {
+
+    private final CompanyWxAccountMapper companyWxAccountMapper;
+    private final WxIpadSendUtils wxIpadSendUtils;
+    private final WxContactMapper wxContactMapper;
+
+
+    public void send(WxSopMsgVo content, CompanyWxAccount account, WxSopLogs logs) {
+        WxBaseVo vo = new WxBaseVo();
+        vo.setId(logs.getId());
+        vo.setServerId(account.getServerId());
+        vo.setRemark(account.getWxNo());
+        WxContact wxContact = wxContactMapper.selectById(logs.getWxContactId());
+        vo.setUserRemark(wxContact.getNickName());
+        try {
+            content.setSendStatus(1);
+            switch (content.getContentType()) {
+                case 1:
+                    sendTxt(vo, content);
+                    break;
+                default:
+                    log.error("SOP_LOG_ID:{}错误的发送类型: {}", logs.getId(), content.getContentType());
+                    break;
+            }
+        } catch (Exception e) {
+            log.error("发送失败QW_SOP_ID:{},content:{},vo:{}", logs.getId(), JSON.toJSONString(content), JSON.toJSONString(vo), e);
+            content.setSendStatus(2);
+            content.setSendRemarks("发送失败:" + e.getMessage());
+        }
+    }
+
+    public void sendTxt(WxBaseVo vo, WxSopMsgVo content){
+        wxIpadSendUtils.sendTxt(vo, content.getValue());
+    }
+
+    public boolean isSend(CompanyWxAccount account) {
+        CompanyWxAccount wxAccount = companyWxAccountMapper.selectById(account.getId());
+        if(wxAccount.getServerId() == null || wxAccount.getLoginStatus() == 0){
+            log.info("微信:{},离线", wxAccount.getWxNickName());
+            return false;
+        }
+        return true;
+    }
+}

+ 261 - 0
fs-wx-ipad-task/src/main/java/com/fs/app/task/SendMsg.java

@@ -0,0 +1,261 @@
+package com.fs.app.task;
+
+
+import com.alibaba.fastjson.JSON;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.fs.app.service.WxIpadSendServer;
+import com.fs.common.annotation.TenantDataScope;
+import com.fs.common.core.redis.RedisCacheTenant;
+import com.fs.common.utils.PubFun;
+import com.fs.company.domain.CompanyWxAccount;
+import com.fs.company.mapper.CompanyWxAccountMapper;
+import com.fs.framework.aspectj.SopTenantDataSourceAspect;
+import com.fs.qw.vo.QwSopCourseFinishTempSetting;
+import com.fs.wxcid.domain.CidIpadServer;
+import com.fs.wxcid.mapper.CidIpadServerMapper;
+import com.fs.wx.sop.domain.WxSopLogs;
+import com.fs.wx.sop.mapper.WxSopLogsMapper;
+import com.fs.wx.sop.service.IWxSopLogsService;
+import com.fs.wx.sop.vo.WxSopMsgVo;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+import java.text.SimpleDateFormat;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+@Component
+@Slf4j
+public class SendMsg {
+
+    private final CompanyWxAccountMapper companyWxAccountMapper;
+    private final CidIpadServerMapper ipadServerMapper;
+    private final WxSopLogsMapper wxSopLogsMapper;
+    private final IWxSopLogsService wxSopLogsService;
+    private final WxIpadSendServer sendServer;
+    private final RedisCacheTenant<Long> redisCache;
+
+    private final SopTenantDataSourceAspect sopTenantDataSourceAspect;
+
+    @Value("${group-no}")
+    private String groupNo;
+    @Value("${tenant-id}")
+    private Long tenantId;
+
+    private final List<CompanyWxAccount> qwUserList = Collections.synchronizedList(new ArrayList<>());
+    private final Map<Long, TaskContext> qwMap = new ConcurrentHashMap<>();
+    private static final long TASK_TIMEOUT_MS = 3 * 60 * 1000L;
+
+    public SendMsg(CompanyWxAccountMapper companyWxAccountMapper, CidIpadServerMapper ipadServerMapper, WxSopLogsMapper wxSopLogsMapper, IWxSopLogsService wxSopLogsService, WxIpadSendServer sendServer, RedisCacheTenant<Long> redisCache, SopTenantDataSourceAspect sopTenantDataSourceAspect) {
+        this.companyWxAccountMapper = companyWxAccountMapper;
+        this.ipadServerMapper = ipadServerMapper;
+        this.wxSopLogsMapper = wxSopLogsMapper;
+        this.wxSopLogsService = wxSopLogsService;
+        this.sendServer = sendServer;
+        this.redisCache = redisCache;
+        this.sopTenantDataSourceAspect = sopTenantDataSourceAspect;
+    }
+
+    private static class TaskContext {
+        final long startTime;
+        final AtomicBoolean cancelled;
+
+        TaskContext() {
+            this.startTime = System.currentTimeMillis();
+            this.cancelled = new AtomicBoolean(false);
+        }
+
+        boolean isTimeout() {
+            return System.currentTimeMillis() - startTime > TASK_TIMEOUT_MS;
+        }
+
+        void cancel() {
+            cancelled.set(true);
+        }
+
+        boolean isCancelled() {
+            return cancelled.get();
+        }
+    }
+
+    @Autowired
+    @Qualifier("customThreadPool")
+    private ThreadPoolTaskExecutor customThreadPool;
+
+    private List<CompanyWxAccount> getUserList() {
+        if (qwUserList.isEmpty()) {
+            List<CidIpadServer> serverList = ipadServerMapper.selectList(new QueryWrapper<CidIpadServer>().eq("group_no", groupNo));
+            if (serverList.isEmpty()) {
+                log.info("没找到可用的服务器 {} ", serverList);
+                return new ArrayList<>();
+            }
+            List<Long> serverIds = PubFun.listToNewList(serverList, CidIpadServer::getId);
+            List<CompanyWxAccount> qwUsers = companyWxAccountMapper.selectList(new QueryWrapper<CompanyWxAccount>().eq("server_status", 1).eq("login_status", 1).in("server_id", serverIds));
+            qwUserList.addAll(qwUsers);
+        }
+        log.info("getQwUserList {}", JSON.toJSONString(qwUserList));
+        return qwUserList;
+    }
+
+    @Scheduled(fixedRate = 50000) // 每50秒执行一次
+    public void refulsQwUserList() {
+        qwUserList.clear();
+    }
+
+    @Scheduled(fixedDelay = 20000) // 每20秒执行一次
+    public void sendMsg2() {
+        sopTenantDataSourceAspect.switchTenant(tenantId);
+        log.info("执行日志:{}", LocalDateTime.now());
+        if (StringUtils.isEmpty(groupNo)) {
+            log.error("corpId为空不执行");
+            return;
+        }
+        int delayStart = 1000;
+        int delayEnd = 2000;
+        getUserList().forEach(e -> {
+            TaskContext ctx = qwMap.get(e.getId());
+            if (ctx != null) {
+                if (ctx.isTimeout()) {
+                    log.warn("任务超时,标记取消:{}, 已运行: {}ms", e.getWxNickName(), System.currentTimeMillis() - ctx.startTime);
+                    ctx.cancel();
+                } else {
+                    log.debug("任务正在执行中,跳过:{}", e.getWxNickName());
+                    return;
+                }
+            }
+            if (customThreadPool.getActiveCount() >= customThreadPool.getMaxPoolSize()) {
+                log.warn("线程池已满,跳过任务:{}, 活跃线程: {}/{}", e.getWxNickName(), customThreadPool.getActiveCount(), customThreadPool.getMaxPoolSize());
+                return;
+            }
+            TaskContext newCtx = new TaskContext();
+            qwMap.put(e.getId(), newCtx);
+            try {
+                CompletableFuture.runAsync(() -> {
+                    try {
+                        log.info("开始任务:{}", e.getWxNickName());
+                        sopTenantDataSourceAspect.switchTenant(tenantId);
+                        processUser(e, delayStart, delayEnd, newCtx);
+                    } catch (Exception exception) {
+                        log.error("发送错误:", exception);
+                    } finally {
+                        log.info("删除任务:{}", e.getWxNickName());
+                        sopTenantDataSourceAspect.clear();
+                        qwMap.remove(e.getId());
+                    }
+                }, customThreadPool).exceptionally(ex -> {
+                    log.error("任务异步执行异常:{}, 错误: {}", e.getWxNickName(), ex.getMessage());
+                    qwMap.remove(e.getId());
+                    return null;
+                });
+            } catch (Exception ex) {
+                log.error("任务提交到线程池失败:{}, 错误: {}", e.getWxNickName(), ex.getMessage());
+                qwMap.remove(e.getId());
+            }
+        });
+    }
+
+    /**
+     * 发送任务执行
+     *
+     * @param account    发送企微
+     * @param delayStart 随机延迟 最小值
+     * @param delayEnd   随机延迟 最大值
+     * @param ctx        任务上下文(用于取消检查)
+     */
+    private void processUser(CompanyWxAccount account, int delayStart, int delayEnd, TaskContext ctx) {
+        long start1 = System.currentTimeMillis();
+        List<WxSopLogs> qwSopLogList = wxSopLogsMapper.selectByWxId(account.getId());
+        if (qwSopLogList.isEmpty()) {
+            log.info("获取当前企微待发送记录为空");
+            return;
+        }
+        long end1 = System.currentTimeMillis();
+        if (ctx.isCancelled()) {
+            log.info("任务被取消,退出:{}", account.getWxNickName());
+            return;
+        }
+        if (!sendServer.isSend(account)) {
+            log.info("当前这个微信不需要发送 数据{}", account);
+            return;
+        }
+        log.info("销售:{}, 消息:{}, 耗时: {}, 时间:{}", account.getWxNickName(), qwSopLogList.size(), end1 - start1, ctx.startTime);
+        long start3 = System.currentTimeMillis();
+        for (WxSopLogs qwSopLogs : qwSopLogList) {
+            if (ctx.isCancelled()) {
+                log.info("任务被取消,中断发送:{}, 已发送部分消息", account.getWxNickName());
+                return;
+            }
+            long start2 = System.currentTimeMillis();
+            log.info("进入发送消息状态:{}", qwSopLogs.getId());
+            String key = "qw:logs:pad:send:id:" + qwSopLogs.getId();
+            Long time = redisCache.getCacheObject(key);
+            redisCache.setCacheObject(key, System.currentTimeMillis(), 24, TimeUnit.HOURS);
+            List<WxSopMsgVo> setting = JSON.parseArray(qwSopLogs.getContentJson(), WxSopMsgVo.class);
+            for (WxSopMsgVo content : setting) {
+                if (ctx.isCancelled()) {
+                    log.info("任务被取消,中断发送:{}", account.getWxNickName());
+                    return;
+                }
+                long start4 = System.currentTimeMillis();
+                sendServer.send(content, account, qwSopLogs);
+                long end4 = System.currentTimeMillis();
+                log.info("请求pad发送完成:{}, {}, 时长4:{}", account.getWxNickName(), qwSopLogs.getId(), end4 - start4);
+                try {
+                    if (ctx.isCancelled()) {
+                        return;
+                    }
+                    int delay = ThreadLocalRandom.current().nextInt(300, 1000);
+                    log.debug("pad发送消息等待:{}ms", delay);
+                    Thread.sleep(delay);
+                } catch (InterruptedException e) {
+                    log.error("线程等待错误!");
+                    Thread.currentThread().interrupt();
+                    return;
+                }
+            }
+            qwSopLogs.setSend(true);
+            WxSopLogs updateQwSop = new WxSopLogs();
+            updateQwSop.setId(qwSopLogs.getId());
+            updateQwSop.setSendTime(LocalDateTime.now());
+            if (setting.stream().allMatch(e -> e.getSendStatus() == 2)) {
+                updateQwSop.setSendStatus(2);
+                updateQwSop.setRemark("全部发送失败");
+            } else if (setting.stream().anyMatch(e -> e.getSendStatus() == 2)) {
+                updateQwSop.setSendStatus(1);
+                updateQwSop.setRemark("部分发送失败");
+            } else {
+                updateQwSop.setSendStatus(1);
+                updateQwSop.setRemark("全部发送成功");
+            }
+            updateQwSop.setContentJson(JSON.toJSONString(setting));
+            long end2 = System.currentTimeMillis();
+            boolean i = wxSopLogsService.updateMapper(updateQwSop);
+            log.info("销售:{}, 修改条数{}, 发送方消息完成:{}, 耗时: {}", account.getWxNickName(), i, qwSopLogs.getId(), end2 - start2);
+            try {
+                if (ctx.isCancelled()) {
+                    return;
+                }
+                int delay = ThreadLocalRandom.current().nextInt(delayStart, delayEnd);
+                log.debug("企微发送消息等待:{}ms", delay);
+                Thread.sleep(delay);
+            } catch (InterruptedException e) {
+                log.error("线程等待错误!");
+                Thread.currentThread().interrupt();
+                return;
+            }
+        }
+        long end3 = System.currentTimeMillis();
+        log.info("销售执行完成:{}, 耗时:{}", account.getWxNickName(), end3 - start3);
+    }
+}

+ 175 - 0
fs-wx-ipad-task/src/main/java/com/fs/framework/aspectj/SopTenantDataSourceAspect.java

@@ -0,0 +1,175 @@
+package com.fs.framework.aspectj;
+
+import com.alibaba.druid.pool.DruidDataSource;
+import com.fs.common.annotation.TenantDataScope;
+import com.fs.common.core.redis.RedisCacheT;
+import com.fs.common.enums.TenantIdType;
+import com.fs.common.exception.CustomException;
+import com.fs.framework.datasource.DynamicDataSource;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import com.fs.tenant.domain.TenantInfo;
+import com.fs.tenant.mapper.TenantInfoMapper;
+import lombok.extern.slf4j.Slf4j;
+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.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.core.annotation.AnnotationUtils;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import javax.sql.DataSource;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 多数据源处理
+ *
+ */
+@Slf4j
+@Aspect
+@Order(1)
+@Component
+public class SopTenantDataSourceAspect {
+    private static final String TENANT_KEY = "tenant:info:";
+    @Resource
+    private DynamicDataSource dynamicDataSource;
+    @Value("${tenant-id}")
+    private Long ymlTenantId;
+    @Autowired
+    private TenantInfoMapper tenantInfoMapper;
+    @Autowired
+    private RedisCacheT<TenantInfo> redis;
+    /**
+     * 租户数据源缓存
+     */
+    private static final Map<String, DataSource> TENANT_DS_CACHE = new ConcurrentHashMap<>();
+
+    @Pointcut("@annotation(com.fs.common.annotation.TenantDataScope)"
+            + "|| @within(com.fs.common.annotation.TenantDataScope)")
+    public void dsPointCut() {
+
+    }
+
+    @Around("dsPointCut()")
+    public Object around(ProceedingJoinPoint point) throws Throwable {
+        MethodSignature signature = (MethodSignature) point.getSignature();
+        Method targetMethod = signature.getMethod(); // 拿到目标方法对象
+        log.info("执行方法:{}", targetMethod.getName());
+        TenantDataScope dataSource = getDataSource(point);
+        TenantIdType type = dataSource.type();
+        Long tenantId = 0L;
+        if(type.equals(TenantIdType.YML)){
+            tenantId = ymlTenantId;
+        }
+        if(type.equals(TenantIdType.REQUEST)){
+
+        }
+        switchTenant(tenantId);
+        try {
+            return point.proceed();
+        } finally {
+            // 销毁数据源 在执行方法之后
+            DynamicDataSourceContextHolder.clearDataSourceType();
+        }
+    }
+
+    public void switchTenant(Long id) {
+        TenantInfo tenantInfo = redis.getCacheObject(TENANT_KEY + id);
+        if(tenantInfo == null){
+            tenantInfo = tenantInfoMapper.selectById(id);
+            if(tenantInfo == null){
+                throw new CustomException("租户不存在请检查");
+            }
+            redis.setCacheObject(TENANT_KEY + id, tenantInfo);
+        }
+        // 用租户主键作为唯一标识
+        String tenantKey = buildTenantKey(tenantInfo.getId());
+
+        if (!TENANT_DS_CACHE.containsKey(tenantKey)) {
+            synchronized (this) {
+                if (!TENANT_DS_CACHE.containsKey(tenantKey)) {
+
+                    javax.sql.DataSource tenantDs = createTenantDataSource(tenantInfo);
+                    TENANT_DS_CACHE.put(tenantKey, tenantDs);
+
+                    // 动态追加到已解析的数据源
+                    Map<Object, DataSource> resolvedMap = getResolvedDataSources();
+                    resolvedMap.put(tenantKey, tenantDs);
+                }
+            }
+        }
+
+        // ThreadLocal 切库
+        DynamicDataSourceContextHolder.setDataSourceType(tenantKey);
+    }
+
+    private String buildTenantKey(Long tenantId) {
+        return "tenant:" + tenantId;
+    }
+
+
+
+    /**
+     * 清理 ThreadLocal
+     */
+    public void clear() {
+        DynamicDataSourceContextHolder.clearDataSourceType();
+    }
+
+    /**
+     * 创建租户数据源(MySQL + Druid)
+     */
+    private DataSource createTenantDataSource(TenantInfo tenant) {
+
+        DruidDataSource ds = new DruidDataSource();
+        ds.setUrl(tenant.getDbUrl());
+        ds.setUsername(tenant.getDbAccount());
+        ds.setPassword(tenant.getDbPwd());
+
+        // 统一 MySQL
+        ds.setDriverClassName("com.mysql.cj.jdbc.Driver");
+
+        ds.setInitialSize(5);
+        ds.setMinIdle(10);
+        ds.setMaxActive(20);
+        ds.setMaxWait(60000);
+
+        return ds;
+    }
+
+    /**
+     * 反射获取 AbstractRoutingDataSource.resolvedDataSources
+     */
+    @SuppressWarnings("unchecked")
+    private Map<Object, DataSource> getResolvedDataSources() {
+        try {
+            Field field = org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource
+                    .class.getDeclaredField("resolvedDataSources");
+            field.setAccessible(true);
+            return (Map<Object, DataSource>) field.get(dynamicDataSource);
+        } catch (Exception e) {
+            throw new IllegalStateException("获取 resolvedDataSources 失败", e);
+        }
+    }
+
+    /**
+     * 获取需要切换的数据源
+     */
+    public TenantDataScope getDataSource(ProceedingJoinPoint point) {
+        MethodSignature signature = (MethodSignature) point.getSignature();
+        TenantDataScope dataSource = AnnotationUtils.findAnnotation(signature.getMethod(), TenantDataScope.class);
+        if (Objects.nonNull(dataSource)) {
+            return dataSource;
+        }
+
+        return AnnotationUtils.findAnnotation(signature.getDeclaringType(), TenantDataScope.class);
+    }
+}

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

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

@@ -0,0 +1,115 @@
+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.read")
+    public DataSource sopReadDataSource() {
+        return new DruidDataSource();
+    }
+
+    @Bean
+    @ConfigurationProperties(prefix = "spring.datasource.clickhouse")
+    public DataSource clickhouseDataSource() {
+        return new DruidDataSource();
+    }
+
+    @Bean
+    @ConfigurationProperties(prefix = "spring.datasource.mysql.druid.master")
+    public DataSource masterDataSource() {
+        return new DruidDataSource();
+    }
+
+    @Bean
+    @ConfigurationProperties(prefix = "spring.datasource.mysql.druid.slave")
+    public DataSource slaveDataSource() {
+        return new DruidDataSource();
+    }
+
+
+
+    @Bean
+    @Primary
+    public DynamicDataSource dataSource(@Qualifier("clickhouseDataSource") DataSource clickhouseDataSource,
+                                        @Qualifier("masterDataSource") DataSource masterDataSource,
+                                        
+                                        @Qualifier("slaveDataSource") DataSource slaveDataSource,
+                                        @Qualifier("sopReadDataSource") DataSource sopReadDataSource
+                                        ) {
+        Map<Object, Object> targetDataSources = new HashMap<>();
+        targetDataSources.put(DataSourceType.MASTER, masterDataSource);
+
+        targetDataSources.put(DataSourceType.SLAVE.name(), slaveDataSource);
+        
+        targetDataSources.put(DataSourceType.SopREAD.name(), sopReadDataSource);
+        targetDataSources.put(DataSourceType.CLICKHOUSE.name(), clickhouseDataSource); // Ensure matching key
+        return new DynamicDataSource(masterDataSource, targetDataSources);
+    }
+
+    /**
+     * 去除监控页面底部的广告
+     */
+    @SuppressWarnings({ "rawtypes", "unchecked" })
+    @Bean
+    @ConditionalOnProperty(name = "spring.datasource.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-ipad-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.druid.master")
+//    public DataSource masterDataSource(DruidProperties druidProperties)
+//    {
+//        DruidDataSource dataSource = DruidDataSourceBuilder.create().build();
+//        return druidProperties.dataSource(dataSource);
+//    }
+//
+//    @Bean
+//    @ConfigurationProperties("spring.datasource.druid.slave")
+//    @ConditionalOnProperty(prefix = "spring.datasource.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.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;
+//    }
+//}

+ 150 - 0
fs-wx-ipad-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();
+    }
+}

+ 76 - 0
fs-wx-ipad-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);
+    }
+}

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

+ 102 - 0
fs-wx-ipad-task/src/main/java/com/fs/framework/datasource/TenantDataSourceManager.java

@@ -0,0 +1,102 @@
+package com.fs.framework.datasource;
+
+import com.alibaba.druid.pool.DruidDataSource;
+import com.fs.tenant.domain.TenantInfo;
+import com.fs.tenant.mapper.TenantInfoMapper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import javax.sql.DataSource;
+import java.lang.reflect.Field;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+@Component
+public class TenantDataSourceManager {
+
+    @Resource
+    private DynamicDataSource dynamicDataSource;
+    @Autowired
+    private TenantInfoMapper tenantInfoMapper;
+
+    /**
+     * 租户数据源缓存
+     */
+    private static final Map<String, DataSource> TENANT_DS_CACHE = new ConcurrentHashMap<>();
+
+    /**
+     * 切换到租户数据源(不存在则创建)
+     */
+    public void switchTenant(Long id) {
+        TenantInfo tenantInfo = tenantInfoMapper.selectById(id);
+        // 用租户主键作为唯一标识
+        String tenantKey = buildTenantKey(tenantInfo.getId());
+
+        if (!TENANT_DS_CACHE.containsKey(tenantKey)) {
+            synchronized (this) {
+                if (!TENANT_DS_CACHE.containsKey(tenantKey)) {
+
+                    DataSource tenantDs = createTenantDataSource(tenantInfo);
+                    TENANT_DS_CACHE.put(tenantKey, tenantDs);
+
+                    // 动态追加到已解析的数据源
+                    Map<Object, DataSource> resolvedMap = getResolvedDataSources();
+                    resolvedMap.put(tenantKey, tenantDs);
+                }
+            }
+        }
+
+        // ThreadLocal 切库
+        DynamicDataSourceContextHolder.setDataSourceType(tenantKey);
+    }
+
+    private String buildTenantKey(Long tenantId) {
+        return "tenant:" + tenantId;
+    }
+
+
+
+    /**
+     * 清理 ThreadLocal
+     */
+    public void clear() {
+        DynamicDataSourceContextHolder.clearDataSourceType();
+    }
+
+    /**
+     * 创建租户数据源(MySQL + Druid)
+     */
+    private DataSource createTenantDataSource(TenantInfo tenant) {
+
+        DruidDataSource ds = new DruidDataSource();
+        ds.setUrl(tenant.getDbUrl());
+        ds.setUsername(tenant.getDbAccount());
+        ds.setPassword(tenant.getDbPwd());
+
+        // 统一 MySQL
+        ds.setDriverClassName("com.mysql.cj.jdbc.Driver");
+
+        ds.setInitialSize(5);
+        ds.setMinIdle(10);
+        ds.setMaxActive(20);
+        ds.setMaxWait(60000);
+
+        return ds;
+    }
+
+    /**
+     * 反射获取 AbstractRoutingDataSource.resolvedDataSources
+     */
+    @SuppressWarnings("unchecked")
+    private Map<Object, DataSource> getResolvedDataSources() {
+        try {
+            Field field = org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource
+                    .class.getDeclaredField("resolvedDataSources");
+            field.setAccessible(true);
+            return (Map<Object, DataSource>) field.get(dynamicDataSource);
+        } catch (Exception e) {
+            throw new IllegalStateException("获取 resolvedDataSources 失败", e);
+        }
+    }
+}

+ 56 - 0
fs-wx-ipad-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-ipad-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;
+    }
+}

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

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

+ 155 - 0
fs-wx-ipad-task/src/main/resources/application-common.yml

@@ -0,0 +1,155 @@
+# 项目相关配置
+fs:
+  # 名称
+  name: fs
+  # 版本
+  version: 1.1.0
+  # 版权年份
+  copyrightYear: 2021
+  # 实例演示开关
+  demoEnabled: true
+  # 文件路径 示例( Windows配置D:/fs/uploadPath,Linux配置 /home/fs/uploadPath)
+  profile: c:/fs/uploadPath
+  # 获取ip地址开关
+  addressEnabled: false
+  # 验证码类型 math 数组计算 char 字符验证
+  captchaType: math
+#  jwt:
+#    # 加密秘钥
+#    secret: f4e2e52034348f86b67cde581c0f9eb5
+#    # token有效时长,7天,单位秒
+#    expire: 31536000
+#    header: AppToken
+# 开发环境配置
+server:
+  servlet:
+    # 应用的访问路径
+    context-path: /
+  tomcat:
+    # tomcat的URI编码
+    uri-encoding: UTF-8
+    # tomcat最大线程数,默认为200
+    max-threads: 800
+    # Tomcat启动初始化的线程数,默认值25
+    min-spare-threads: 30
+
+# 日志配置
+logging:
+  level:
+    com.fs: info
+    org.springframework: warn
+
+express:
+  omsCode: "SF.0235402855"
+# Spring配置
+spring:
+  main:
+    allow-circular-references: true
+  cache:
+    type: redis
+  # 资源信息
+  messages:
+    # 国际化资源文件路径
+    basename: i18n/messages
+  mvc:
+    async:
+      request-timeout: 600000
+
+  # 文件上传
+  servlet:
+     multipart:
+       # 单个文件大小
+       max-file-size:  3GB
+       # 设置总上传的文件大小
+       max-request-size:  3GB
+  # 服务模块
+  devtools:
+    restart:
+      # 热部署开关
+      enabled: true
+
+
+# token配置
+token:
+    # 令牌自定义标识
+    header: Authorization
+    # 令牌密钥
+    secret: YlrzSaas2026SecKey!@#QwErTyUiOpAsDfGhJkLzXcVbNm
+    # 令牌有效期(默认30分钟)
+    expireTime: 720
+mybatis-plus:
+  # 搜索指定包别名
+  typeAliasesPackage: com.fs.**.domain,com.fs.**.bo
+  # 配置mapper的扫描,找到所有的mapper.xml映射文件
+  mapperLocations: classpath*:/mapper/**/*.xml
+  configLocation: classpath:mybatis/mybatis-config.xml
+  # 全局配置
+  global-config:
+    db-config:
+      # 主键类型  0:"数据库ID自增", 1:"用户输入ID",2:"全局唯一ID (数字类型唯一ID)", 3:"全局唯一ID UUID";
+      idType: AUTO
+      # 字段策略 0:"忽略判断",1:"非 NULL 判断"),2:"非空判断"
+      fieldStrategy: NOT_EMPTY
+    banner: false
+    # 配置
+  configuration:
+    # 驼峰式命名
+    mapUnderscoreToCamelCase: true
+    # 全局映射器启用缓存
+    cacheEnabled: true
+    # 配置默认的执行器
+    defaultExecutorType: REUSE
+    # 允许 JDBC 支持自动生成主键
+    useGeneratedKeys: true
+
+# MyBatis配置
+mybatis:
+    # 搜索指定包别名
+    typeAliasesPackage: com.fs.**.domain
+    # 配置mapper的扫描,找到所有的mapper.xml映射文件
+    mapperLocations: classpath*:mapper/**/*Mapper.xml
+    # 加载全局的配置文件
+    configLocation: classpath:mybatis/mybatis-config.xml
+
+# PageHelper分页插件
+pagehelper:
+  helperDialect: mysql
+  reasonable: false #超出后不显示
+  supportMethodsArguments: false
+  params: count=countSql
+
+# Swagger配置
+swagger:
+  # 是否开启swagger
+  enabled: false
+  # 请求前缀
+  pathMapping: /dev-api
+
+# 防止XSS攻击
+xss:
+  # 过滤开关
+  enabled: true
+  # 排除链接(多个用逗号分隔)
+  excludes: /system/notice,/system/config/*
+  # 匹配链接
+  urlPatterns: /system/*,/monitor/*,/tool/*
+zhyf:
+  url: https://zhyf-testController.jingpai.com
+
+image:
+  storage:
+    local-path: C:\logoFile\logo.jpg
+    server-path: C:\logoFile\logo.jpg
+# application.properties
+wechat:
+  api:
+    base-url: https://api.weixin.qq.com
+    upload-shipping-info: /wxa/sec/order/upload_shipping_info
+hsy:
+  access_key: AKLTZTc4YTE4ZjI2OWViNDNjZGI2NjhiYTI5Njc5ZjA1Mzk
+  secret_key: WXpjelpUYzFOakF5TUdObE5EZGtNR0ZsWXpKaU1tTmtZakk1WXpObE4yRQ==
+  region: cn-north-1
+  role_access_key: AKLTNmMwNjJkNDFhYTVjNDIzYzhhNzEyZmZmZTlmYzBhNGM
+  role_secret_key: T0RaaFl6UmhZV1V4WXpKbU5EWTBNMkZpT0RNNU9UY3daak0wTjJFd09XUQ==
+  role_trn: trn:iam::2114522511:role/hylj
+

+ 137 - 0
fs-wx-ipad-task/src/main/resources/application-config-dev.yml

@@ -0,0 +1,137 @@
+baidu:
+  token: 12313231232
+  back-domain: https://www.xxxx.com
+#配置
+logging:
+  level:
+    org.springframework.web: debug
+    com.github.binarywang.demo.wx.cp: DEBUG
+    me.chanjar.weixin: DEBUG
+#wx:
+#  miniapp:
+#    configs:
+#      - appid: wx29d26f63f836be7f
+#        secret: 7542db9774355a89b1adce24defb6013
+#        token: Ncbnd7lJvkripVOpyTFAna6NAWCxCrvC
+#        aesKey: HlEiBB55eaWUaeBVAQO3cWKWPYv1vOVQSq7nFNICw4E
+#        msgDataFormat: JSON
+#  cp:
+#    corpId: wwb2a1055fb6c9a7c2
+#    appConfigs:
+#      - agentId: 1000005
+#        secret: ec7okROXJqkNafq66-L6aKNv0asTzQIG0CYrj3vyBbo
+#        token: PPKOdAlCoMO
+#        aesKey: PKvaxtpSv8NGpfTDm7VUHIK8Wok2ESyYX24qpXJAdMP
+#  pay:
+#    appId: wx73f85f8d62769119 #微信公众号或者小程序等的appid
+#    mchId: 1611402045 #微信支付商户号
+#    mchKey: 8cab128997a3547c1363b0898b877f38 #微信支付商户密钥
+#    subAppId:  #服务商模式下的子商户公众账号ID
+#    subMchId:  #服务商模式下的子商户号
+#    keyPath: c:\\cert\\apiclient_cert.p12 # p12证书的位置,可以指定绝对路径,也可以指定类路径(以classpath:开头)
+#    notifyUrl: https://userapp.his.runtzh.com/app/wxpay/wxPayNotify
+#  mp:
+#    useRedis: false
+#    redisConfig:
+#      host: 127.0.0.1
+#      port: 6379
+#      timeout: 2000
+#    configs:
+#      - appId: wx93ce67750e3cfba3 # 第一个公众号的appid  //公众号名称:云联融智
+#        secret: c172884087264160563bfe5775ca0f6f # 公众号的appsecret
+#        token: PPKOdAlCoMO # 接口配置里的Token值
+#        aesKey: Eswa6VjwtVMCcw03qZy6fWllgrv5aytIA1SZPEU0kU2 # 接口配置里的EncodingAESKey值
+#aifabu:  #爱链接
+#  appKey: 7b471be905ab17e00f3b858c6710dd117601d008
+#watch:
+#  watchUrl: watch.ylrzcloud.com/prod-api
+#  #  account: tcloud
+#  #  password: mdf-m2h_6yw2$hq
+#  account1: ccif #866655060138751
+#  password1: cp-t5or_6xw7$mt
+#  account2: tcloud #rt500台
+#  password2: mdf-m2h_6yw2$hq
+#  account3: whr
+#  password3: v9xsKuqn_$d2y
+#
+#fs :
+#  commonApi: http://172.16.0.16:8010
+#  h5CommonApi: http://119.29.195.254:8010
+#  jwt:
+#    # 加密秘钥
+#    secret: e10adc3949ba59abbe56e057f20f883e
+#    # token有效时长,7天,单位秒
+#    expire: 31536000
+#    header: AppToken
+#nuonuo:
+#  key: 10924508
+#  secret: A2EB20764D304D16
+#
+## 存储捅配置
+#tencent_cloud_config:
+#  secret_id: AKIDiMq9lDf2EOM9lIfqqfKo7FNgM5meD0sT
+#  secret_key: u5SuS80342xzx8FRBukza9lVNHKNMSaB
+#  bucket: myhk-1323137866
+#  app_id: 1323137866
+#  region: ap-chongqing
+#  proxy: myhk
+#cloud_host:
+#  company_name: 金康健
+#  projectCode: DEV
+#  spaceName:
+#  volcengineUrl:
+#headerImg:
+#  imgUrl: https://jz-cos-1356808054.cos.ap-chengdu.myqcloud.com/fs/20250515/0877754b59814ea8a428fa3697b20e68.png
+#ipad:
+#  url:
+#  ipadUrl: http://ipad.cdwjyyh.com
+#  aiApi: http://152.136.202.157:3000/api
+#  voiceApi:
+#  commonApi:
+#wx_miniapp_temp:
+#  pay_order_temp_id:
+#  inquiry_temp_id:
+## 聚水潭API配置
+#jst:
+##  app_key: a4b1fab173c84f67b3873857eea11d90 #聚水潭2025-07-25
+#  app_key: 871348458a964548a72bf8124cf917a4 #聚水潭2025-08-14
+#  app_secret: 5b7d9369dbcd414db45089bc047ebe1a #聚水潭2025-08-14
+##  app_secret: dfce1f8dc8a64ddc91212fc3fcdd9349 #聚水潭2025-07-25
+#  authorization_code: 666666
+#  shop_code: "18461733"
+#
+## RocketMQ配置
+#rocketmq:
+#  name-server: 127.0.0.1:9876
+#  producer:
+#    group: event-feedback-producer
+#    send-message-timeout: 3000
+#    retry-times-when-send-failed: 2
+#    retry-times-when-send-async-failed: 2
+#    max-message-size: 4194304
+#    compress-message-body-threshold: 4096
+#    retry-next-server: true
+#custom:
+#  token: "1o62d3YxvdHd4LEUiltnu7sK"
+#  encoding-aes-key: "UJfTQ5qKTKlegjkXtp1YuzJzxeHlUKvq5GyFbERN1iU"
+#  corp-id: "ww51717e2b71d5e2d3"configValue
+#  secret: "6ODAmw-8W4t6h9mdzHh2Z4Apwj8mnsyRnjEDZOHdA7k"
+#  private-key-path: "privatekey.pem"
+#  webhook-url: "https://your-server.com/wecom/archive"
+## token配置
+#token:
+#  # 令牌自定义标识
+#  header: Authorization
+#  # 令牌密钥
+#  secret: abcdefghijklmnopqrstuvwxyz
+#  # 令牌有效期(默认30分钟)
+#  expireTime: 180
+#openIM:
+#  secret: openIM123
+#  userID: imAdmin
+#  url: https://web.jnmyim.ylrzfs.com/api
+##是否为新商户,新商户不走mpOpenId
+#isNewWxMerchant: true
+##是否使用新im
+#im:
+#  type: OPENIM

+ 132 - 0
fs-wx-ipad-task/src/main/resources/application-dev.yml

@@ -0,0 +1,132 @@
+# 数据源配置
+spring:
+    # redis 配置
+    redis:
+        # 地址
+        host: localhost
+        # 端口,默认为6379
+        port: 6379
+        # 数据库索引
+        database: 0
+        # 密码
+        password:
+        # 连接超时时间
+        timeout: 20s
+        lettuce:
+            pool:
+                # 连接池中的最小空闲连接
+                min-idle: 0
+                # 连接池中的最大空闲连接
+                max-idle: 8
+                # 连接池的最大数据库连接数
+                max-active: 8
+                # #连接池最大阻塞等待时间(使用负值表示没有限制)
+                max-wait: -1ms
+    datasource:
+        mysql:
+            type: com.alibaba.druid.pool.DruidDataSource
+            driverClassName: com.mysql.cj.jdbc.Driver
+            druid:
+                initialSize: 5
+                minIdle: 10
+                maxActive: 20
+                maxWait: 60000
+                timeBetweenEvictionRunsMillis: 60000
+                minEvictableIdleTimeMillis: 300000
+                maxEvictableIdleTimeMillis: 900000
+                validationQuery: SELECT 1 FROM DUAL
+                testWhileIdle: true
+                testOnBorrow: false
+                testOnReturn: false
+                # 主库数据源
+                master:
+                    url: jdbc:mysql://cq-cdb-8fjmemkb.sql.tencentcdb.com:27220/ylrz_saas?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&useSSL=false
+                    username: root
+                    password: Ylrz_1q2w3e4r5t6y
+                    # 初始连接数
+                    initialSize: 5
+                    # 最小连接池数量
+                    minIdle: 10
+                    # 最大连接池数量
+                    maxActive: 20
+                    # 配置获取连接等待超时的时间
+                    maxWait: 60000
+                    # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
+                    timeBetweenEvictionRunsMillis: 60000
+                    # 配置一个连接在池中最小生存的时间,单位是毫秒
+                    minEvictableIdleTimeMillis: 300000
+                    # 配置一个连接在池中最大生存的时间,单位是毫秒
+                    maxEvictableIdleTimeMillis: 900000
+                    # 配置检测连接是否有效
+                    validationQuery: SELECT 1 FROM DUAL
+                    testWhileIdle: true
+                    testOnBorrow: false
+                    testOnReturn: false
+                    webStatFilter:
+                        enabled: true
+                    statViewServlet:
+                        enabled: true
+                        # 设置白名单,不填则允许所有访问
+                        allow:
+                        url-pattern: /druid/*
+                        # 控制台管理用户名和密码
+                        login-username: fs
+                        login-password: 123456
+                    filter:
+                        stat:
+                            enabled: true
+                            # 慢SQL记录
+                            log-slow-sql: true
+                            slow-sql-millis: 1000
+                            merge-sql: true
+                        wall:
+                            config:
+                                multi-statement-allow: true
+        easycall:
+            type: com.alibaba.druid.pool.DruidDataSource
+            driverClassName: com.mysql.cj.jdbc.Driver
+            druid:
+                # 主库数据源
+                master:
+                    url: jdbc:mysql://129.28.164.235:3306/easycallcenter365?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
+                    username: root
+                    password: easycallcenter365
+                # 初始连接数
+                initialSize: 5
+                # 最小连接池数量
+                minIdle: 10
+                # 最大连接池数量
+                maxActive: 20
+                # 配置获取连接等待超时的时间
+                maxWait: 60000
+                # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
+                timeBetweenEvictionRunsMillis: 60000
+                # 配置一个连接在池中最小生存的时间,单位是毫秒
+                minEvictableIdleTimeMillis: 300000
+                # 配置一个连接在池中最大生存的时间,单位是毫秒
+                maxEvictableIdleTimeMillis: 900000
+                # 配置检测连接是否有效
+                validationQuery: SELECT 1 FROM DUAL
+                testWhileIdle: true
+                testOnBorrow: false
+                testOnReturn: false
+                webStatFilter:
+                    enabled: true
+                statViewServlet:
+                    enabled: true
+                    # 设置白名单,不填则允许所有访问
+                    allow:
+                    url-pattern: /druid/*
+                    # 控制台管理用户名和密码
+                    login-username: fs
+                    login-password: 123456
+                filter:
+                    stat:
+                        enabled: true
+                        # 慢SQL记录
+                        log-slow-sql: true
+                        slow-sql-millis: 1000
+                        merge-sql: true
+                    wall:
+                        config:
+                            multi-statement-allow: true

+ 37 - 0
fs-wx-ipad-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}]

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

@@ -0,0 +1,94 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<configuration>
+    <springProperty scope="context" name="groupNo" source="group-no"/>
+    <!-- 日志存放路径 -->
+	<property name="log.path" value="/home/fs-ipad-task/${groupNo}/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>
+			<!-- 日志最大的历史 60天 -->
+			<maxHistory>6</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>
+			<!-- 日志最大的历史 60天 -->
+			<maxHistory>6</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>
+            <!-- 日志最大的历史 60天 -->
+            <maxHistory>6</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>

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

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

+ 206 - 12
fs-wx-task/src/main/java/com/fs/app/service/WxTaskService.java

@@ -16,11 +16,11 @@ import com.fs.company.mapper.*;
 import com.fs.company.param.ExecutionContext;
 import com.fs.company.service.*;
 import com.fs.company.service.impl.*;
+import com.fs.company.service.impl.call.node.AiAddWxTaskNewNode;
 import com.fs.company.service.impl.call.node.AiAddWxTaskNode;
 import com.fs.company.service.impl.call.node.AiQwAddWxTaskNode;
 import com.fs.company.service.impl.call.node.WorkflowNodeFactory;
 import com.fs.company.vo.CompanyWxClient4WorkFlowVO;
-import com.fs.course.config.RedisKeyScanner;
 import com.fs.crm.param.SmsSendBatchParam;
 import com.fs.enums.ExecutionStatusEnum;
 import com.fs.enums.NodeTypeEnum;
@@ -36,8 +36,14 @@ import com.fs.qw.mapper.QwUserMapper;
 import com.fs.qwApi.domain.QwLinkCreateResult;
 import com.fs.qwApi.param.QwLinkCreateParam;
 import com.fs.qwApi.service.QwApiService;
+import com.fs.sop.mapper.QwSopTempRulesMapper;
+import com.fs.sop.vo.QwSopTempRulesWithDayVO;
 import com.fs.system.service.ISysConfigService;
 import com.fs.voice.utils.StringUtil;
+import com.fs.wx.sop.domain.WxSopLogs;
+import com.fs.wx.sop.mapper.WxSopLogsMapper;
+import com.fs.wx.sop.mapper.WxSopUserMapper;
+import com.fs.wx.sop.vo.WxSopUserMsgGenVO;
 import com.fs.wxcid.dto.friend.AddContactParam;
 import com.fs.wxcid.service.FriendService;
 import com.fs.wxcid.vo.AddContactVo;
@@ -48,7 +54,6 @@ import com.fs.wxwork.dto.WxWorkResponseDTO;
 import com.fs.wxwork.service.WxWorkService;
 import com.google.gson.JsonObject;
 import com.google.gson.JsonParser;
-import lombok.AllArgsConstructor;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.redisson.api.RLock;
@@ -57,10 +62,11 @@ import org.springframework.beans.BeanUtils;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Qualifier;
 import org.springframework.beans.factory.annotation.Value;
-import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Service;
 
+import java.time.LocalDate;
 import java.time.LocalDateTime;
+import java.time.LocalTime;
 import java.time.temporal.ChronoUnit;
 import java.util.*;
 import java.util.concurrent.*;
@@ -105,6 +111,9 @@ public class WxTaskService {
     private final CompanyVoiceRoboticCallLogSendmsgServiceImpl companyVoiceRoboticCallLogSendmsgService;
     private final QwApiService qwApiService;
     private final RedisCache redisCache2;
+    private final WxSopUserMapper wxSopUserMapper;
+    private final QwSopTempRulesMapper qwSopTempRulesMapper;
+    private final WxSopLogsMapper wxSopLogsMapper;
 //    private final ExecutorService cidExcutor = new ThreadPoolExecutor(
 //            32,
 //            64,
@@ -843,25 +852,46 @@ public class WxTaskService {
      */
     public void cidWorkflowAddWxRun() {
         log.info("===========工作流延时任务开始扫描===========");
-        String delayAddWxKeyPrefix = AiAddWxTaskNode.getDelayAddWxKeyPrefix(cidGroupNo,null) + "*";
-//        Set<String> keys = redisKeyScanner.scanMatchKey(delayAddWxKeyPrefix);
-        Collection<String> keys = redisCache2.keys(delayAddWxKeyPrefix);
+//        String delayAddWxKeyPrefix = AiAddWxTaskNode.getDelayAddWxKeyPrefix(cidGroupNo,null) + "*";
+////        Set<String> keys = redisKeyScanner.scanMatchKey(delayAddWxKeyPrefix);
+//        Collection<String> keys = redisCache2.keys(delayAddWxKeyPrefix);
+        // 扫描新加微节点的延时Key
+        String delayAddWxNewKeyPrefix = AiAddWxTaskNewNode.getDelayAddWxKeyPrefix(cidGroupNo,null) + "*";
+        Collection<String> keys = redisCache2.keys(delayAddWxNewKeyPrefix);
         log.info("cidWorkflowAddWxRun共扫描到 {} 个待处理键", keys.size());
+        // 本地缓存已查询的任务暂停状态,避免同一批次重复查询
+        Map<Long, Boolean> pausedCache = new ConcurrentHashMap<>();
         keys.parallelStream().forEach(key -> {
             try {
                 //doExec
                 CompletableFuture.runAsync(()->{
                     try {
                         ExecutionContext context = redisCache2.getCacheObject(key);
+                        if (context == null) {
+                            log.warn("工作流延时任务context为空,跳过 - key: {}", key);
+                            redisCache2.deleteObject(key);
+                            return;
+                        }
+                        // 任务暂停守卫检查(roboticId即CompanyVoiceRobotic.id,是实际暂停操作的目标)
+                        Long taskId = context.getVariable("roboticId", Long.class);
+                        if (taskId != null && pausedCache.computeIfAbsent(taskId, id -> companyVoiceRoboticService.isTaskPaused(id))) {
+                            // 延时key是时间分片前缀,下一分钟就不会再扫到,直接删除
+                            // 同步context信息到DB exec,供恢复时resumePausedInstances使用
+                            context.setVariable("callSource", "addWxTimer");
+                            context.setVariable("_delayTargetNodeKey", context.getCurrentNodeKey());
+                            companyWorkflowEngine.updateExecVariables(context.getWorkflowInstanceId(), context.getVariables());
+                            log.info("任务已暂停,删除延时key并同步exec,等待恢复时从DB重建 - taskId: {}, key: {}", taskId, key);
+                            redisCache2.deleteObject(key);
+                            return;
+                        }
                         context.setVariable("callRedisKey",key);
                         context.setVariable("callSource","addWxTimer");
                         companyWorkflowEngine.timeDoExecute(context.getWorkflowInstanceId(),context.getCurrentNodeKey(),context.getVariables());
+                        redisCache2.deleteObject(key);
                     } catch (Exception e) {
                         log.error("处理工作流延时任务异常 - key: {}", key, e);
                     }
-                }, cidWorkFlowExecutor).thenRun(()->{
-                    redisCache2.deleteObject(key);
-                });
+                }, cidWorkFlowExecutor);
 
             } catch (Exception ex) {
                 log.error("处理工作流延时任务异常 - key: {}", key, ex);
@@ -901,6 +931,22 @@ public class WxTaskService {
                 return;
             }
 
+            // 任务暂停守卫检查:过滤掉已暂停任务的数据
+            Map<Long, Boolean> pausedCache = new HashMap<>();
+            list = list.stream().filter(item -> {
+                Long taskId = item.getRoboticId();
+                if (taskId == null) return true;
+                boolean paused = pausedCache.computeIfAbsent(taskId, id -> companyVoiceRoboticService.isTaskPaused(id));
+                if (paused) {
+                    log.debug("任务已暂停,跳过加微 - taskId: {}, clientId: {}", taskId, item.getRoboticWxId());
+                }
+                return !paused;
+            }).collect(Collectors.toList());
+            if (list.isEmpty()) {
+                log.info("过滤暂停任务后无需要加微的数据");
+                return;
+            }
+
             // 构建客户映射
             Map<Long, CompanyWxClient4WorkFlowVO> clientMap = PubFun.listToMapByGroupObject(
                     list, CompanyWxClient4WorkFlowVO::getAccountId);
@@ -1579,6 +1625,23 @@ public class WxTaskService {
             if (clients.isEmpty()) {
                 return;
             }
+
+            // 任务暂停守卫检查:过滤掉已暂停任务的数据
+            Map<Long, Boolean> pausedCache = new HashMap<>();
+            clients = clients.stream().filter(client -> {
+                Long taskId = client.getRoboticId();
+                if (taskId == null) return true;
+                boolean paused = pausedCache.computeIfAbsent(taskId, id -> companyVoiceRoboticService.isTaskPaused(id));
+                if (paused) {
+                    log.debug("任务已暂停,跳过加微结果查询 - taskId: {}, clientId: {}", taskId, client.getId());
+                }
+                return !paused;
+            }).collect(Collectors.toList());
+            if (clients.isEmpty()) {
+                log.info("过滤暂停任务后无需要查询加微结果的数据");
+                return;
+            }
+
             // 处理每个客户的加微结果
             List<CompanyWxClient> upClientList = new CopyOnWriteArrayList<>();
             List<CompletableFuture<Void>> futures = new ArrayList<>();
@@ -1618,21 +1681,39 @@ public class WxTaskService {
         String delayAddWxKeyPrefix = AiQwAddWxTaskNode.getDelayAddWxKeyPrefix(cidGroupNo,null) + "*";
         Collection<String> keys = redisCache2.keys(delayAddWxKeyPrefix);
         log.info("企微加微共扫描到 {} 个待处理键", keys.size());
+        // 本地缓存已查询的任务暂停状态,避免同一批次重复查询
+        Map<Long, Boolean> pausedCache = new ConcurrentHashMap<>();
         keys.parallelStream().forEach(key -> {
             try {
                 //doExec
                 CompletableFuture.runAsync(()->{
                     try {
                         ExecutionContext context = redisCache2.getCacheObject(key);
+                        if (context == null) {
+                            log.warn("企微加微工作流延时任务context为空,跳过 - key: {}", key);
+                            redisCache2.deleteObject(key);
+                            return;
+                        }
+                        // 任务暂停守卫检查(roboticId即CompanyVoiceRobotic.id,是实际暂停操作的目标)
+                        Long taskId = context.getVariable("roboticId", Long.class);
+                        if (taskId != null && pausedCache.computeIfAbsent(taskId, id -> companyVoiceRoboticService.isTaskPaused(id))) {
+                            // 延时key是时间分片前缀,下一分钟就不会再扫到,直接删除
+                            // 同步context信息到DB exec,供恢复时resumePausedInstances使用
+                            context.setVariable("callSource", "qwAddWxTimer");
+                            context.setVariable("_delayTargetNodeKey", context.getCurrentNodeKey());
+                            companyWorkflowEngine.updateExecVariables(context.getWorkflowInstanceId(), context.getVariables());
+                            log.info("任务已暂停,删除延时key并同步exec,等待恢复时从DB重建 - taskId: {}, key: {}", taskId, key);
+                            redisCache2.deleteObject(key);
+                            return;
+                        }
                         context.setVariable("callRedisKey",key);
                         context.setVariable("callSource","qwAddWxTimer");
                         companyWorkflowEngine.timeDoExecute(context.getWorkflowInstanceId(),context.getCurrentNodeKey(),context.getVariables());
+                        redisCache2.deleteObject(key);
                     } catch (Exception e) {
                         log.error("处理工作流延时任务异常 - key: {}", key, e);
                     }
-                }, cidWorkFlowExecutor).thenRun(()->{
-                    redisCache2.deleteObject(key);
-                });
+                }, cidWorkFlowExecutor);
 
             } catch (Exception ex) {
                 log.error("处理工作流延时任务异常 - key: {}", key, ex);
@@ -1867,4 +1948,117 @@ public class WxTaskService {
         }
     }
 
+    /**
+     * 个微SOP消息生成(文本类型)
+     * 每小时触发,查询活跃的WxSOP营期及客户,生成文本消息待发送记录
+     *
+     * @param currentTime 当前整点时间
+     */
+    public void generateWxSopMsgByTime(LocalDateTime currentTime) {
+        long startTimeMillis = System.currentTimeMillis();
+        log.info("====== 个微SOP文本消息生成开始, currentTime: {} ======", currentTime);
+
+        List<WxSopUserMsgGenVO> msgGenList = wxSopUserMapper.selectActiveWxSopUserForMsgGen();
+        if (msgGenList == null || msgGenList.isEmpty()) {
+            log.info("个微SOP消息生成: 没有需要处理的活跃营期。");
+            return;
+        }
+        log.info("个微SOP消息生成: 查询到 {} 条营期客户记录。", msgGenList.size());
+
+        Map<String, List<QwSopTempRulesWithDayVO>> rulesCache = new HashMap<>();
+        List<WxSopLogs> logsToInsert = new ArrayList<>();
+
+        for (WxSopUserMsgGenVO vo : msgGenList) {
+            if (vo.getTempId() == null || vo.getTempId().isEmpty()) {
+                continue;
+            }
+
+            LocalDate startDate = vo.getStartTime();
+            LocalDate currentDate = currentTime.toLocalDate();
+            long daysBetween = ChronoUnit.DAYS.between(startDate, currentDate);
+            long targetDay = daysBetween + 1;
+
+            List<QwSopTempRulesWithDayVO> rulesList = rulesCache.computeIfAbsent(vo.getTempId(),
+                    qwSopTempRulesMapper::listByTempIdWithDayNum);
+            if (rulesList == null || rulesList.isEmpty()) {
+                continue;
+            }
+
+            List<QwSopTempRulesWithDayVO> dayRules = rulesList.stream()
+                    .filter(r -> r.getDayNum() != null && r.getDayNum() == targetDay)
+                    .filter(r -> r.getContentType() != null && r.getContentType() == 1)
+                    .collect(Collectors.toList());
+
+            if (dayRules.isEmpty()) {
+                continue;
+            }
+
+            for (QwSopTempRulesWithDayVO rule : dayRules) {
+                LocalTime ruleTime;
+                try {
+                    ruleTime = LocalTime.parse(rule.getTime());
+                } catch (Exception e) {
+                    log.warn("个微SOP消息生成: 解析时间失败, ruleId: {}, time: {}", rule.getId(), rule.getTime());
+                    continue;
+                }
+                LocalDateTime ruleDateTime = LocalDateTime.of(currentDate, ruleTime);
+                if (ruleDateTime.isBefore(currentTime) && ruleDateTime.plusHours(1).isBefore(currentTime)) {
+                    continue;
+                }
+
+                LocalDateTime startRange = currentTime.plusMinutes(60);
+                LocalDateTime endRange = startRange.plusMinutes(60);
+                if (ruleDateTime.isBefore(startRange) || !ruleDateTime.isBefore(endRange)) {
+                    continue;
+                }
+
+                if (rule.getTextContent() == null || rule.getTextContent().isEmpty()) {
+                    continue;
+                }
+
+                WxSopLogs sopLogs = new WxSopLogs();
+                sopLogs.setType(vo.getType());
+                sopLogs.setSopId(vo.getSopId());
+                sopLogs.setSopUserId(vo.getSopUserId());
+                sopLogs.setSendType(2);
+                sopLogs.setGenerateType(0);
+                sopLogs.setAccountId(vo.getAccountId());
+                sopLogs.setWxContactId(vo.getWxContactId());
+                sopLogs.setFsUserId(vo.getFsUserId());
+                sopLogs.setSendStatus(0);
+                sopLogs.setSendSort(10000000);
+                sopLogs.setSendTime(ruleDateTime);
+
+                String contentJson = buildTextContentJson(rule.getTextContent());
+                sopLogs.setContentJson(contentJson);
+                sopLogs.setCreateTime(new Date());
+                sopLogs.setUpdateTime(new Date());
+
+                logsToInsert.add(sopLogs);
+            }
+        }
+
+        if (!logsToInsert.isEmpty()) {
+            wxSopLogsMapper.batchInsertWxSopLogs(logsToInsert);
+            log.info("个微SOP消息生成: 批量写入 {} 条文本消息。", logsToInsert.size());
+        }
+
+        long endTimeMillis = System.currentTimeMillis();
+        log.info("====== 个微SOP文本消息生成完成, 耗时 {} 毫秒 ======", (endTimeMillis - startTimeMillis));
+    }
+
+    private String buildTextContentJson(String text) {
+        if (text == null || text.isEmpty()) {
+            return null;
+        }
+        com.alibaba.fastjson.JSONObject item = new com.alibaba.fastjson.JSONObject();
+        item.put("contentType", "1");
+        item.put("value", text);
+        com.alibaba.fastjson.JSONArray settingsArray = new com.alibaba.fastjson.JSONArray();
+        settingsArray.add(item);
+        com.alibaba.fastjson.JSONObject wrapper = new com.alibaba.fastjson.JSONObject();
+        wrapper.put("settings", settingsArray);
+        return com.alibaba.fastjson.JSON.toJSONString(wrapper);
+    }
+
 }

+ 7 - 2
fs-wx-task/src/main/java/com/fs/app/task/TenantTaskRunner.java

@@ -190,9 +190,14 @@ public class TenantTaskRunner {
             log.info("[SaaS Task] 定时任务切换数据源和Redis dataSource={}, tenantId={}, tenantCode={}, task={}",
                     dataSourceKey, tenant.getId(), tenant.getTenantCode(), taskName != null ? taskName : "");
 
-            // 加载租户项目配置(安全解析,防止非法JSON导致级联崩溃)
+            // 加载租户项目配置
             SysConfig cfg = sysConfigMapper.selectConfigByConfigKey("projectConfig");
-            ProjectConfig.safeLoadTenantConfigFromValue(cfg != null ? cfg.getConfigValue() : null);
+            if (cfg != null && StringUtils.isNotBlank(cfg.getConfigValue())) {
+                TenantConfigContext.set(JSONObject.parseObject(cfg.getConfigValue()));
+            } else {
+                TenantConfigContext.set(null);
+            }
+            ProjectConfig.loadTenantConfigsFromContext();
 
             // 执行租户任务
             action.accept(tenant);

+ 28 - 8
fs-wx-task/src/main/java/com/fs/app/task/WxTask.java

@@ -4,9 +4,12 @@ import com.fs.app.service.WxTaskService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
+import org.springframework.scheduling.annotation.Async;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
 
+import java.time.LocalDateTime;
+
 /**
  * 企业微信SOP定时任务管理类
  * 负责处理各种定时任务,包括SOP规则检查、消息发送、数据清理等
@@ -32,14 +35,14 @@ public class WxTask {
 //    public void addWx() {
 //        taskService.addWx(null);
 //    }
-    @Scheduled(cron = "0 0/1 * * * ?")
-    public void addWx4Workflow() {
-        if (saasTaskEnabled) {
-            tenantTaskRunner.runForResponsibleTenant("addWx4Workflow", () ->   taskService.addWx4Workflow(null));
-        } else {
-            taskService.addWx4Workflow(null);
-        }
-    }
+//    @Scheduled(cron = "0 0/1 * * * ?")
+//    public void addWx4Workflow() {
+//        if (saasTaskEnabled) {
+//            tenantTaskRunner.runForResponsibleTenant("addWx4Workflow", () ->   taskService.addWx4Workflow(null));
+//        } else {
+//            taskService.addWx4Workflow(null);
+//        }
+//    }
     @Scheduled(cron = "0 0 0 * * ?")
     public void initAccountNum() {
         if (saasTaskEnabled) {
@@ -123,4 +126,21 @@ public class WxTask {
         }
 
     }
+
+    /**
+     * 个微SOP消息生成任务(文本类型)
+     * 每小时的第5分钟执行,仿照企微selectSopUserLogsListByTime模式
+     * 查询活跃的wx_sop_user及客户,生成仅文本类型(contentType=1)的待发送消息
+     */
+    @Async
+    @Scheduled(cron = "0 5 * * * ?")
+    public void generateWxSopMsgByTime() {
+        LocalDateTime currentTime = LocalDateTime.now().withMinute(0).withSecond(0).withNano(0);
+        log.info("个微SOP消息生成任务执行时间: {}", currentTime);
+        try {
+//            taskService.generateWxSopMsgByTime(currentTime);
+        } catch (Exception e) {
+            log.error("个微SOP消息生成任务失败: {}", e.getMessage(), e);
+        }
+    }
 }

+ 155 - 0
fs-wx-task/src/main/resources/application-common.yml

@@ -0,0 +1,155 @@
+# 项目相关配置
+fs:
+  # 名称
+  name: fs
+  # 版本
+  version: 1.1.0
+  # 版权年份
+  copyrightYear: 2021
+  # 实例演示开关
+  demoEnabled: true
+  # 文件路径 示例( Windows配置D:/fs/uploadPath,Linux配置 /home/fs/uploadPath)
+  profile: c:/fs/uploadPath
+  # 获取ip地址开关
+  addressEnabled: false
+  # 验证码类型 math 数组计算 char 字符验证
+  captchaType: math
+#  jwt:
+#    # 加密秘钥
+#    secret: f4e2e52034348f86b67cde581c0f9eb5
+#    # token有效时长,7天,单位秒
+#    expire: 31536000
+#    header: AppToken
+# 开发环境配置
+server:
+  servlet:
+    # 应用的访问路径
+    context-path: /
+  tomcat:
+    # tomcat的URI编码
+    uri-encoding: UTF-8
+    # tomcat最大线程数,默认为200
+    max-threads: 800
+    # Tomcat启动初始化的线程数,默认值25
+    min-spare-threads: 30
+
+# 日志配置
+logging:
+  level:
+    com.fs: info
+    org.springframework: warn
+
+express:
+  omsCode: "SF.0235402855"
+# Spring配置
+spring:
+  main:
+    allow-circular-references: true
+  cache:
+    type: redis
+  # 资源信息
+  messages:
+    # 国际化资源文件路径
+    basename: i18n/messages
+  mvc:
+    async:
+      request-timeout: 600000
+
+  # 文件上传
+  servlet:
+     multipart:
+       # 单个文件大小
+       max-file-size:  3GB
+       # 设置总上传的文件大小
+       max-request-size:  3GB
+  # 服务模块
+  devtools:
+    restart:
+      # 热部署开关
+      enabled: true
+
+
+# token配置
+token:
+    # 令牌自定义标识
+    header: Authorization
+    # 令牌密钥
+    secret: YlrzSaas2026SecKey!@#QwErTyUiOpAsDfGhJkLzXcVbNm
+    # 令牌有效期(默认30分钟)
+    expireTime: 720
+mybatis-plus:
+  # 搜索指定包别名
+  typeAliasesPackage: com.fs.**.domain,com.fs.**.bo
+  # 配置mapper的扫描,找到所有的mapper.xml映射文件
+  mapperLocations: classpath*:/mapper/**/*.xml
+  configLocation: classpath:mybatis/mybatis-config.xml
+  # 全局配置
+  global-config:
+    db-config:
+      # 主键类型  0:"数据库ID自增", 1:"用户输入ID",2:"全局唯一ID (数字类型唯一ID)", 3:"全局唯一ID UUID";
+      idType: AUTO
+      # 字段策略 0:"忽略判断",1:"非 NULL 判断"),2:"非空判断"
+      fieldStrategy: NOT_EMPTY
+    banner: false
+    # 配置
+  configuration:
+    # 驼峰式命名
+    mapUnderscoreToCamelCase: true
+    # 全局映射器启用缓存
+    cacheEnabled: true
+    # 配置默认的执行器
+    defaultExecutorType: REUSE
+    # 允许 JDBC 支持自动生成主键
+    useGeneratedKeys: true
+
+# MyBatis配置
+mybatis:
+    # 搜索指定包别名
+    typeAliasesPackage: com.fs.**.domain
+    # 配置mapper的扫描,找到所有的mapper.xml映射文件
+    mapperLocations: classpath*:mapper/**/*Mapper.xml
+    # 加载全局的配置文件
+    configLocation: classpath:mybatis/mybatis-config.xml
+
+# PageHelper分页插件
+pagehelper:
+  helperDialect: mysql
+  reasonable: false #超出后不显示
+  supportMethodsArguments: false
+  params: count=countSql
+
+# Swagger配置
+swagger:
+  # 是否开启swagger
+  enabled: false
+  # 请求前缀
+  pathMapping: /dev-api
+
+# 防止XSS攻击
+xss:
+  # 过滤开关
+  enabled: true
+  # 排除链接(多个用逗号分隔)
+  excludes: /system/notice,/system/config/*
+  # 匹配链接
+  urlPatterns: /system/*,/monitor/*,/tool/*
+zhyf:
+  url: https://zhyf-testController.jingpai.com
+
+image:
+  storage:
+    local-path: C:\logoFile\logo.jpg
+    server-path: C:\logoFile\logo.jpg
+# application.properties
+wechat:
+  api:
+    base-url: https://api.weixin.qq.com
+    upload-shipping-info: /wxa/sec/order/upload_shipping_info
+hsy:
+  access_key: AKLTZTc4YTE4ZjI2OWViNDNjZGI2NjhiYTI5Njc5ZjA1Mzk
+  secret_key: WXpjelpUYzFOakF5TUdObE5EZGtNR0ZsWXpKaU1tTmtZakk1WXpObE4yRQ==
+  region: cn-north-1
+  role_access_key: AKLTNmMwNjJkNDFhYTVjNDIzYzhhNzEyZmZmZTlmYzBhNGM
+  role_secret_key: T0RaaFl6UmhZV1V4WXpKbU5EWTBNMkZpT0RNNU9UY3daak0wTjJFd09XUQ==
+  role_trn: trn:iam::2114522511:role/hylj
+

+ 137 - 0
fs-wx-task/src/main/resources/application-config-dev.yml

@@ -0,0 +1,137 @@
+baidu:
+  token: 12313231232
+  back-domain: https://www.xxxx.com
+#配置
+logging:
+  level:
+    org.springframework.web: debug
+    com.github.binarywang.demo.wx.cp: DEBUG
+    me.chanjar.weixin: DEBUG
+#wx:
+#  miniapp:
+#    configs:
+#      - appid: wx29d26f63f836be7f
+#        secret: 7542db9774355a89b1adce24defb6013
+#        token: Ncbnd7lJvkripVOpyTFAna6NAWCxCrvC
+#        aesKey: HlEiBB55eaWUaeBVAQO3cWKWPYv1vOVQSq7nFNICw4E
+#        msgDataFormat: JSON
+#  cp:
+#    corpId: wwb2a1055fb6c9a7c2
+#    appConfigs:
+#      - agentId: 1000005
+#        secret: ec7okROXJqkNafq66-L6aKNv0asTzQIG0CYrj3vyBbo
+#        token: PPKOdAlCoMO
+#        aesKey: PKvaxtpSv8NGpfTDm7VUHIK8Wok2ESyYX24qpXJAdMP
+#  pay:
+#    appId: wx73f85f8d62769119 #微信公众号或者小程序等的appid
+#    mchId: 1611402045 #微信支付商户号
+#    mchKey: 8cab128997a3547c1363b0898b877f38 #微信支付商户密钥
+#    subAppId:  #服务商模式下的子商户公众账号ID
+#    subMchId:  #服务商模式下的子商户号
+#    keyPath: c:\\cert\\apiclient_cert.p12 # p12证书的位置,可以指定绝对路径,也可以指定类路径(以classpath:开头)
+#    notifyUrl: https://userapp.his.runtzh.com/app/wxpay/wxPayNotify
+#  mp:
+#    useRedis: false
+#    redisConfig:
+#      host: 127.0.0.1
+#      port: 6379
+#      timeout: 2000
+#    configs:
+#      - appId: wx93ce67750e3cfba3 # 第一个公众号的appid  //公众号名称:云联融智
+#        secret: c172884087264160563bfe5775ca0f6f # 公众号的appsecret
+#        token: PPKOdAlCoMO # 接口配置里的Token值
+#        aesKey: Eswa6VjwtVMCcw03qZy6fWllgrv5aytIA1SZPEU0kU2 # 接口配置里的EncodingAESKey值
+#aifabu:  #爱链接
+#  appKey: 7b471be905ab17e00f3b858c6710dd117601d008
+#watch:
+#  watchUrl: watch.ylrzcloud.com/prod-api
+#  #  account: tcloud
+#  #  password: mdf-m2h_6yw2$hq
+#  account1: ccif #866655060138751
+#  password1: cp-t5or_6xw7$mt
+#  account2: tcloud #rt500台
+#  password2: mdf-m2h_6yw2$hq
+#  account3: whr
+#  password3: v9xsKuqn_$d2y
+#
+#fs :
+#  commonApi: http://172.16.0.16:8010
+#  h5CommonApi: http://119.29.195.254:8010
+#  jwt:
+#    # 加密秘钥
+#    secret: e10adc3949ba59abbe56e057f20f883e
+#    # token有效时长,7天,单位秒
+#    expire: 31536000
+#    header: AppToken
+#nuonuo:
+#  key: 10924508
+#  secret: A2EB20764D304D16
+#
+## 存储捅配置
+#tencent_cloud_config:
+#  secret_id: AKIDiMq9lDf2EOM9lIfqqfKo7FNgM5meD0sT
+#  secret_key: u5SuS80342xzx8FRBukza9lVNHKNMSaB
+#  bucket: myhk-1323137866
+#  app_id: 1323137866
+#  region: ap-chongqing
+#  proxy: myhk
+#cloud_host:
+#  company_name: 金康健
+#  projectCode: DEV
+#  spaceName:
+#  volcengineUrl:
+#headerImg:
+#  imgUrl: https://jz-cos-1356808054.cos.ap-chengdu.myqcloud.com/fs/20250515/0877754b59814ea8a428fa3697b20e68.png
+#ipad:
+#  url:
+#  ipadUrl: http://ipad.cdwjyyh.com
+#  aiApi: http://152.136.202.157:3000/api
+#  voiceApi:
+#  commonApi:
+#wx_miniapp_temp:
+#  pay_order_temp_id:
+#  inquiry_temp_id:
+## 聚水潭API配置
+#jst:
+##  app_key: a4b1fab173c84f67b3873857eea11d90 #聚水潭2025-07-25
+#  app_key: 871348458a964548a72bf8124cf917a4 #聚水潭2025-08-14
+#  app_secret: 5b7d9369dbcd414db45089bc047ebe1a #聚水潭2025-08-14
+##  app_secret: dfce1f8dc8a64ddc91212fc3fcdd9349 #聚水潭2025-07-25
+#  authorization_code: 666666
+#  shop_code: "18461733"
+#
+## RocketMQ配置
+#rocketmq:
+#  name-server: 127.0.0.1:9876
+#  producer:
+#    group: event-feedback-producer
+#    send-message-timeout: 3000
+#    retry-times-when-send-failed: 2
+#    retry-times-when-send-async-failed: 2
+#    max-message-size: 4194304
+#    compress-message-body-threshold: 4096
+#    retry-next-server: true
+#custom:
+#  token: "1o62d3YxvdHd4LEUiltnu7sK"
+#  encoding-aes-key: "UJfTQ5qKTKlegjkXtp1YuzJzxeHlUKvq5GyFbERN1iU"
+#  corp-id: "ww51717e2b71d5e2d3"configValue
+#  secret: "6ODAmw-8W4t6h9mdzHh2Z4Apwj8mnsyRnjEDZOHdA7k"
+#  private-key-path: "privatekey.pem"
+#  webhook-url: "https://your-server.com/wecom/archive"
+## token配置
+#token:
+#  # 令牌自定义标识
+#  header: Authorization
+#  # 令牌密钥
+#  secret: abcdefghijklmnopqrstuvwxyz
+#  # 令牌有效期(默认30分钟)
+#  expireTime: 180
+#openIM:
+#  secret: openIM123
+#  userID: imAdmin
+#  url: https://web.jnmyim.ylrzfs.com/api
+##是否为新商户,新商户不走mpOpenId
+#isNewWxMerchant: true
+##是否使用新im
+#im:
+#  type: OPENIM

+ 132 - 0
fs-wx-task/src/main/resources/application-dev.yml

@@ -0,0 +1,132 @@
+# 数据源配置
+spring:
+    # redis 配置
+    redis:
+        # 地址
+        host: localhost
+        # 端口,默认为6379
+        port: 6379
+        # 数据库索引
+        database: 0
+        # 密码
+        password:
+        # 连接超时时间
+        timeout: 20s
+        lettuce:
+            pool:
+                # 连接池中的最小空闲连接
+                min-idle: 0
+                # 连接池中的最大空闲连接
+                max-idle: 8
+                # 连接池的最大数据库连接数
+                max-active: 8
+                # #连接池最大阻塞等待时间(使用负值表示没有限制)
+                max-wait: -1ms
+    datasource:
+        mysql:
+            type: com.alibaba.druid.pool.DruidDataSource
+            driverClassName: com.mysql.cj.jdbc.Driver
+            druid:
+                initialSize: 5
+                minIdle: 10
+                maxActive: 20
+                maxWait: 60000
+                timeBetweenEvictionRunsMillis: 60000
+                minEvictableIdleTimeMillis: 300000
+                maxEvictableIdleTimeMillis: 900000
+                validationQuery: SELECT 1 FROM DUAL
+                testWhileIdle: true
+                testOnBorrow: false
+                testOnReturn: false
+                # 主库数据源
+                master:
+                    url: jdbc:mysql://cq-cdb-8fjmemkb.sql.tencentcdb.com:27220/ylrz_saas?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&useSSL=false
+                    username: root
+                    password: Ylrz_1q2w3e4r5t6y
+                    # 初始连接数
+                    initialSize: 5
+                    # 最小连接池数量
+                    minIdle: 10
+                    # 最大连接池数量
+                    maxActive: 20
+                    # 配置获取连接等待超时的时间
+                    maxWait: 60000
+                    # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
+                    timeBetweenEvictionRunsMillis: 60000
+                    # 配置一个连接在池中最小生存的时间,单位是毫秒
+                    minEvictableIdleTimeMillis: 300000
+                    # 配置一个连接在池中最大生存的时间,单位是毫秒
+                    maxEvictableIdleTimeMillis: 900000
+                    # 配置检测连接是否有效
+                    validationQuery: SELECT 1 FROM DUAL
+                    testWhileIdle: true
+                    testOnBorrow: false
+                    testOnReturn: false
+                    webStatFilter:
+                        enabled: true
+                    statViewServlet:
+                        enabled: true
+                        # 设置白名单,不填则允许所有访问
+                        allow:
+                        url-pattern: /druid/*
+                        # 控制台管理用户名和密码
+                        login-username: fs
+                        login-password: 123456
+                    filter:
+                        stat:
+                            enabled: true
+                            # 慢SQL记录
+                            log-slow-sql: true
+                            slow-sql-millis: 1000
+                            merge-sql: true
+                        wall:
+                            config:
+                                multi-statement-allow: true
+        easycall:
+            type: com.alibaba.druid.pool.DruidDataSource
+            driverClassName: com.mysql.cj.jdbc.Driver
+            druid:
+                # 主库数据源
+                master:
+                    url: jdbc:mysql://129.28.164.235:3306/easycallcenter365?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
+                    username: root
+                    password: easycallcenter365
+                # 初始连接数
+                initialSize: 5
+                # 最小连接池数量
+                minIdle: 10
+                # 最大连接池数量
+                maxActive: 20
+                # 配置获取连接等待超时的时间
+                maxWait: 60000
+                # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
+                timeBetweenEvictionRunsMillis: 60000
+                # 配置一个连接在池中最小生存的时间,单位是毫秒
+                minEvictableIdleTimeMillis: 300000
+                # 配置一个连接在池中最大生存的时间,单位是毫秒
+                maxEvictableIdleTimeMillis: 900000
+                # 配置检测连接是否有效
+                validationQuery: SELECT 1 FROM DUAL
+                testWhileIdle: true
+                testOnBorrow: false
+                testOnReturn: false
+                webStatFilter:
+                    enabled: true
+                statViewServlet:
+                    enabled: true
+                    # 设置白名单,不填则允许所有访问
+                    allow:
+                    url-pattern: /druid/*
+                    # 控制台管理用户名和密码
+                    login-username: fs
+                    login-password: 123456
+                filter:
+                    stat:
+                        enabled: true
+                        # 慢SQL记录
+                        log-slow-sql: true
+                        slow-sql-millis: 1000
+                        merge-sql: true
+                    wall:
+                        config:
+                            multi-statement-allow: true

+ 7 - 0
pom.xml

@@ -226,6 +226,12 @@
                 <version>${fs.version}</version>
             </dependency>
 
+            <dependency>
+                <groupId>com.fs</groupId>
+                <artifactId>fs-wx-ipad-task</artifactId>
+                <version>${fs.version}</version>
+            </dependency>
+
             <!-- 通用工具-->
             <dependency>
                 <groupId>com.fs</groupId>
@@ -288,6 +294,7 @@
         <module>fs-qw-task</module>
         <module>fs-redis</module>
         <module>fs-watch</module>
+        <module>fs-wx-ipad-task</module>
         <module>fs-common-api</module>
         <module>fs-company-app</module>
         <module>fs-live-app</module>