吴树波 пре 1 месец
родитељ
комит
9d17468256

+ 114 - 0
fs-cid-workflow/src/main/java/com/fs/app/service/OutboundRetryTaskService.java

@@ -0,0 +1,114 @@
+package com.fs.app.service;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.company.service.easycall.IEasyCallService;
+import com.fs.company.vo.easycall.EasyCallCommonAddCallListParam;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.Set;
+
+/**
+ * 外呼限制重试任务服务(ZSET 方案)
+ * <p>
+ * 使用 Redis Sorted Set 存储待重试任务,score 为 nextAvailableTime 毫秒时间戳。
+ * 定时任务通过 ZRANGEBYSCORE 按批次查询已到期任务,支撑百万级数据量。
+ */
+@Service
+@Slf4j
+public class OutboundRetryTaskService {
+
+    private static final String ZSET_KEY = "outbound:limit:retry:zset";
+    private static final int BATCH_SIZE = 1000;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    @Autowired
+    private IEasyCallService easyCallService;
+
+    /**
+     * 处理外呼重试任务(ZSET 方案)
+     * <p>
+     * 1. 用 ZRANGEBYSCORE 查询 score <= 当前时间 的任务(即已到期的)
+     * 2. 每次取一批(100 条),循环处理直到没有到期任务为止
+     * 3. 处理完后 ZREM 移除
+     */
+    public void processRetryTasks() {
+        int totalProcessed = 0;
+
+        try {
+            while (true) {
+                long now = System.currentTimeMillis();
+                // 使用 ZRANGEBYSCORE 查询已到期的任务(score <= now),每批最多 BATCH_SIZE 条
+                Set<String> readyTasks = redisCache.zRangeByScore(ZSET_KEY, 0, now, 0, BATCH_SIZE);
+                if (readyTasks == null || readyTasks.isEmpty()) {
+                    break;
+                }
+
+                for (String taskJson : readyTasks) {
+                    try {
+                        processOneRetryTask(taskJson);
+                    } catch (Exception e) {
+                        log.error("processRetryTasks: 处理重试任务异常", e);
+                        // 处理失败也要移除,避免死循环
+                        redisCache.zSetRemove(ZSET_KEY, taskJson);
+                    }
+                    totalProcessed++;
+                }
+            }
+        } catch (Exception e) {
+            log.error("processRetryTasks: 扫描重试任务异常", e);
+        }
+
+        if (totalProcessed > 0) {
+            log.info("processRetryTasks: 本次共处理 {} 个重试任务", totalProcessed);
+        }
+    }
+
+    /**
+     * 处理单个重试任务
+     */
+    private void processOneRetryTask(String taskJson) {
+        JSONObject retryData = JSON.parseObject(taskJson);
+        if (retryData == null) {
+            redisCache.zSetRemove(ZSET_KEY, taskJson);
+            return;
+        }
+
+        Long companyId = retryData.getLong("companyId");
+        Long gatewayId = retryData.getLong("gatewayId");
+        EasyCallCommonAddCallListParam param = retryData.getObject("param", EasyCallCommonAddCallListParam.class);
+
+        if (companyId == null || gatewayId == null || param == null) {
+            log.warn("processOneRetryTask: 参数不完整,移除无效任务");
+            redisCache.zSetRemove(ZSET_KEY, taskJson);
+            return;
+        }
+
+        // 从 param 中获取 batchId(正常流程中 addCommonCallList 前已通过 setBatchId 设置)
+        Long batchId = param.getBatchId();
+
+        log.info("processOneRetryTask: 开始重试外呼 - companyId: {}, gatewayId: {}", companyId, gatewayId);
+
+        // 重新调用 addCommonCallList(内部会再次检查限制,如果仍被限制会再次存入 ZSET)
+        boolean success = easyCallService.addCommonCallList(param, companyId, gatewayId);
+        if (success) {
+            log.info("processOneRetryTask: 重试成功 - companyId: {}, gatewayId: {}", companyId, gatewayId);
+            // 启动外呼任务
+            if (batchId != null) {
+                easyCallService.startTask(batchId, null);
+            } else {
+                log.warn("processOneRetryTask: batchId 为空,跳过启动任务 - companyId: {}, gatewayId: {}", companyId, gatewayId);
+            }
+        } else {
+            log.warn("processOneRetryTask: 重试未成功(可能仍被限制,已重新入队)- companyId: {}, gatewayId: {}", companyId, gatewayId);
+        }
+
+        // 无论成功失败都移除当前记录(如果仍被限制,addCommonCallList 内部会重新 saveRetryTask)
+        redisCache.zSetRemove(ZSET_KEY, taskJson);
+    }
+}

+ 17 - 7
fs-cid-workflow/src/main/java/com/fs/app/task/CidTask.java

@@ -1,6 +1,7 @@
 package com.fs.app.task;
 
 import com.fs.app.service.CidWorkflowTaskService;
+import com.fs.app.service.OutboundRetryTaskService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
@@ -23,6 +24,9 @@ public class CidTask {
     @Autowired
     private CidWorkflowTaskService cidWorkflowTaskService;
 
+    @Autowired
+    private OutboundRetryTaskService outboundRetryTaskService;
+
     /** SaaS 模式:为 true 时各定时任务按租户遍历执行;为 false 时保持原单库执行(私有化部署) */
     @Value("${saas.task.enabled:true}")
     private boolean saasTaskEnabled;
@@ -56,14 +60,20 @@ public class CidTask {
     }
 
     /**
-     * 扫描服务定时任务执行
+     * 外呼重试任务 - 每30分钟执行一次
+     * 扫描 Redis 中被外呼限制拦截的待重试呼叫,到达 nextAvailableTime 后重新执行
      */
-//    @Scheduled(cron = "0 0/1 * * * ?")
-//    public void runContinueTask() {
-//        cidWorkflowTaskService.runContinueTask();
-//    }
-
-
+    @Scheduled(cron = "0 0/30 * * * ?")
+    public void retryOutboundCallTask() {
+        log.info("执行外呼限制重试机制");
+        if (saasTaskEnabled) {
+            tenantTaskRunner.runForEachTenant("外呼限制重试", tenantInfo -> {
+                outboundRetryTaskService.processRetryTasks();
+            });
+        } else {
+            outboundRetryTaskService.processRetryTasks();
+        }
+    }
 
 
 }

+ 1 - 1
fs-cid-workflow/src/main/resources/application.yml

@@ -10,7 +10,7 @@ spring:
 #    active: druid-sxjz
 #    active: druid-hdt
 #    active: druid-myhk-test
-cid-group-no: 3
+cid-group-no: 5
 tenant-id: 11
 # 配置了服务标记后,请在tenant_service_config 中配置服务应用租户ids信息
 tenant-service-marker: cidWorkflow00

+ 14 - 0
fs-common/src/main/java/com/fs/common/core/redis/RedisCache.java

@@ -314,6 +314,20 @@ public class RedisCache
         return redisTemplate.opsForZSet().reverseRange(key, start, end);
     }
 
+    /**
+     * 按 score 范围查询有序集合元素(带分页)
+     *
+     * @param key    有序集合键
+     * @param min    最小 score(包含)
+     * @param max    最大 score(包含)
+     * @param offset 偏移量
+     * @param count  数量限制
+     * @return 元素集合
+     */
+    public Set<String> zRangeByScore(String key, double min, double max, long offset, long count) {
+        return redisTemplate.opsForZSet().rangeByScore(key, min, max, offset, count);
+    }
+
     /**
      * 获取有序集合元素数量
      *

+ 1 - 1
fs-company/src/main/java/com/fs/company/controller/company/EasyCallController.java

@@ -229,7 +229,7 @@ public class EasyCallController extends BaseController {
     public R addCommonCallList(@RequestBody EasyCallCommonAddCallListParam param) {
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
         Long companyId = loginUser.getUser().getCompanyId();
-        easyCallService.addCommonCallList(param, companyId);
+        easyCallService.addCommonCallList(param, companyId, null);
         return R.ok("操作成功");
     }
 

+ 47 - 0
fs-service/src/main/java/com/fs/company/domain/OutboundLineLimitLog.java

@@ -0,0 +1,47 @@
+package com.fs.company.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+@TableName("outbound_line_limit_log")
+public class OutboundLineLimitLog {
+
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    private Long companyId;
+
+    private Long gatewayId;
+
+    /** 时间粒度(分钟): 30/60/120/360/1440 */
+    private Integer timeWindow;
+
+    /** 时间窗口起始时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime windowStart;
+
+    /** 该窗口最大允许呼出次数 */
+    private Integer maxCalls;
+
+    /** 当前窗口已呼叫次数 */
+    private Integer currentCount;
+
+    /** 是否触发限制: 0否 1是 */
+    private Integer isLimited;
+
+    /** 触发时的调用参数JSON */
+    private String callParam;
+
+    /** 下一个可用时间点 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime nextAvailableTime;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime createTime;
+}

+ 26 - 0
fs-service/src/main/java/com/fs/company/dto/OutboundLimitResultVO.java

@@ -0,0 +1,26 @@
+package com.fs.company.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.util.Date;
+
+@Data
+public class OutboundLimitResultVO {
+    private boolean passed;
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date nextAvailableTime;
+
+    public static OutboundLimitResultVO pass() {
+        OutboundLimitResultVO r = new OutboundLimitResultVO();
+        r.passed = true;
+        return r;
+    }
+
+    public static OutboundLimitResultVO limited(Date nextAvailableTime) {
+        OutboundLimitResultVO r = new OutboundLimitResultVO();
+        r.passed = false;
+        r.nextAvailableTime = nextAvailableTime;
+        return r;
+    }
+}

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

@@ -3,6 +3,7 @@ package com.fs.company.mapper;
 import com.fs.company.domain.CompanyConfig;
 import com.fs.company.vo.CompanyMiniAppVO;
 import com.fs.system.domain.SysConfig;
+import org.apache.ibatis.annotations.Mapper;
 import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Select;
 
@@ -14,6 +15,7 @@ import java.util.List;
  * @author fs
  * @date 2021-05-25
  */
+@Mapper
 public interface CompanyConfigMapper
 {
     /**

+ 18 - 0
fs-service/src/main/java/com/fs/company/mapper/OutboundLineLimitLogMapper.java

@@ -0,0 +1,18 @@
+package com.fs.company.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.company.domain.OutboundLineLimitLog;
+import org.apache.ibatis.annotations.Param;
+
+import java.time.LocalDateTime;
+
+public interface OutboundLineLimitLogMapper extends BaseMapper<OutboundLineLimitLog> {
+
+    int asyncInsert(OutboundLineLimitLog log);
+
+    int countByWindow(@Param("companyId") Long companyId,
+                      @Param("gatewayId") Long gatewayId,
+                      @Param("timeWindow") Integer timeWindow,
+                      @Param("windowStart") LocalDateTime windowStart,
+                      @Param("windowEnd") LocalDateTime windowEnd);
+}

+ 316 - 5
fs-service/src/main/java/com/fs/company/service/easycall/EasyCallServiceImpl.java

@@ -1,12 +1,20 @@
 package com.fs.company.service.easycall;
 
+import cn.hutool.core.date.DateUtil;
 import cn.hutool.http.HttpRequest;
 import cn.hutool.http.HttpUtil;
 import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.core.redis.RedisCacheT;
 import com.fs.common.utils.StringUtils;
+import com.fs.company.domain.CompanyConfig;
+import com.fs.company.domain.OutboundLineLimitLog;
+import com.fs.company.dto.OutboundLimitResultVO;
+import com.fs.company.mapper.CompanyConfigMapper;
 import com.fs.company.mapper.CompanyMapper;
+import com.fs.company.mapper.OutboundLineLimitLogMapper;
 import com.fs.company.vo.easycall.*;
 import com.fs.system.service.ISysConfigService;
 import lombok.extern.slf4j.Slf4j;
@@ -14,10 +22,16 @@ import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
 
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Date;
 import java.util.List;
+import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
+import java.util.stream.IntStream;
 
 /**
  * EasyCallCenter365 外呼服务实现类
@@ -35,6 +49,18 @@ public class EasyCallServiceImpl implements IEasyCallService {
     @Autowired
     private ISysConfigService configService;
 
+    @Autowired
+    private CompanyConfigMapper companyConfigMapper;
+
+    @Autowired
+    private OutboundLineLimitLogMapper outboundLineLimitLogMapper;
+
+    @Autowired
+    private RedisCacheT<String> redisCacheT;
+
+    @Autowired
+    private RedisCache redisCache;
+
     /**
      * EasyCallCenter365 服务器基础地址,从配置文件 easycall.base-url 读取
      */
@@ -87,8 +113,11 @@ public class EasyCallServiceImpl implements IEasyCallService {
      */
     private static final String API_COMMON_ADD_LIST = "/aicall/api/common/addCallList";
 
+    /** 外呼线路执行限制 Redis key 前缀 */
+    private static final String OUTBOUND_LIMIT_REDIS_PREFIX = "outbound:limit:";
+
     @Autowired
-    CompanyMapper companyMapper;
+    private CompanyMapper companyMapper;
     // =================== 基础数据查询接口 ===================
 
     /**
@@ -245,10 +274,30 @@ public class EasyCallServiceImpl implements IEasyCallService {
      * bizJson 字段可传入需要传递给机器人的业务数据(如客户姓名、订单号等)
      */
     @Override
-    public void addCommonCallList(EasyCallCommonAddCallListParam param, Long companyId) {
+    public boolean addCommonCallList(EasyCallCommonAddCallListParam param, Long companyId, Long gatewayId) {
+        // 1. 检查外呼线路限制
+        OutboundLimitResultVO limitResult = checkOutboundLineLimit(companyId, gatewayId);
+        if (!limitResult.isPassed()) {
+            log.warn("addCommonCallList: 外呼线路执行限制触发 - companyId: {}, gatewayId: {}, 下次可用时间: {}",
+                    companyId, gatewayId, DateUtil.format(limitResult.getNextAvailableTime(), "yyyy-MM-dd HH:mm:ss"));
+            // 将未执行的呼叫参数存入 Redis,供定时任务重试
+            saveRetryTask(companyId, gatewayId, param, limitResult.getNextAvailableTime());
+            return false;
+        }
+        // 2. 调用外呼接口追加名单
         String url = buildUrl(API_COMMON_ADD_LIST);
-        JSONObject result = doPost(url, JSON.toJSONString(param));
-        checkSuccess(result);
+        try {
+            JSONObject result = doPost(url, JSON.toJSONString(param));
+            boolean success = checkSuccess(result);
+            if (success) {
+                // 3. 执行成功后记录OutboundLineLimitLog日志
+                saveExecutionLog(companyId, gatewayId, param);
+            }
+            return success;
+        } catch (Exception e) {
+            log.error("addCommonCallList: 外呼接口调用异常 - companyId: {}, gatewayId: {}", companyId, gatewayId, e);
+            return false;
+        }
     }
 
     // =================== 录音相关接口 ===================
@@ -268,6 +317,266 @@ public class EasyCallServiceImpl implements IEasyCallService {
         return base + wavFileUrl;
     }
 
+    // =================== 外呼线路执行限制 ===================
+
+    /**
+     * 检查外呼线路是否达到限制
+     * <p>
+     * 流程:
+     * 1. 根据 gatewayId 线路ID找到公司配置
+     * 2. 如果配置了外呼限制(outboundLineLimitEnabled=true),筛选匹配当前线路的规则
+     * 3. 直接从 OutboundLineLimitLog 数据库查询时间窗口内的实际外呼次数(不依赖Redis缓存计数)
+     * 4. 如果达到限制则返回 limited 状态,上一级方法中断执行
+     * 5. 如果未达到限制则返回 pass 状态,上一级方法继续执行
+     *
+     * @param companyId 公司ID
+     * @param gatewayId 外呼线路(网关)ID
+     * @return 限制检查结果
+     */
+    private OutboundLimitResultVO checkOutboundLineLimit(Long companyId, Long gatewayId) {
+        try {
+            // gatewayId为空时不做限制
+            if (gatewayId == null) {
+                return OutboundLimitResultVO.pass();
+            }
+
+            // 1. 查询公司配置
+            CompanyConfig companyConfig = companyConfigMapper.selectCompanyConfigByKey(companyId, "cId.config");
+            if (companyConfig == null || StringUtils.isEmpty(companyConfig.getConfigValue())) {
+                return OutboundLimitResultVO.pass();
+            }
+
+            // 2. 检查是否启用外呼限制
+            JSONObject configJson = JSONObject.parseObject(companyConfig.getConfigValue());
+            Boolean enabled = configJson.getBoolean("outboundLineLimitEnabled");
+            if (enabled == null || !enabled) {
+                return OutboundLimitResultVO.pass();
+            }
+
+            JSONArray rules = configJson.getJSONArray("outboundLineRules");
+            if (rules == null || rules.isEmpty()) {
+                return OutboundLimitResultVO.pass();
+            }
+
+            // 3. 预筛选:找出匹配当前 gatewayId 的规则(纯内存操作,无I/O)
+            List<JSONObject> matchedRules = IntStream.range(0, rules.size())
+                    .mapToObj(rules::getJSONObject)
+                    .filter(rule -> {
+                        Integer tw = rule.getInteger("timeWindow");
+                        Integer mc = rule.getInteger("maxCalls");
+                        return tw != null && tw > 0 && mc != null && mc > 0;
+                    })
+                    .filter(rule -> {
+                        JSONArray lineIdsArr = rule.getJSONArray("lineIds");
+                        if (lineIdsArr == null || lineIdsArr.isEmpty()) {
+                            return true; // 全局规则,匹配所有线路
+                        }
+                        return IntStream.range(0, lineIdsArr.size())
+                                .anyMatch(j -> gatewayId.equals(lineIdsArr.getLong(j)));
+                    })
+                    .collect(Collectors.toList());
+
+            if (matchedRules.isEmpty()) {
+                return OutboundLimitResultVO.pass();
+            }
+
+            // 4. 对匹配的规则逐一检查限制(数量通常很少,1-3条)
+            LocalDateTime now = LocalDateTime.now();
+            LocalDateTime todayMidnight = now.toLocalDate().atStartOfDay();
+            Date earliestNextTime = null;
+
+            for (JSONObject rule : matchedRules) {
+                Integer timeWindow = rule.getInteger("timeWindow");
+                Integer maxCalls = rule.getInteger("maxCalls");
+
+                // 计算当前固定窗口(以零点对齐)
+                // 例:timeWindow=30 → 00:00~00:29, 00:30~00:59, ...
+                // 例:timeWindow=60 → 00:00~00:59, 01:00~01:59, ...
+                // 例:timeWindow=1440 → 00:00~23:59(整天)
+                long minutesSinceMidnight = Duration.between(todayMidnight, now).toMinutes();
+                long windowIndex = minutesSinceMidnight / timeWindow;
+                LocalDateTime windowStart = todayMidnight.plusMinutes(windowIndex * timeWindow);
+                LocalDateTime windowEnd = windowStart.plusMinutes(timeWindow);
+
+                // 快速拒绝:检查 Redis blocked 标记
+                String blockedRedisKey = OUTBOUND_LIMIT_REDIS_PREFIX + companyId + ":" + gatewayId + ":blocked:" + timeWindow + ":" + windowIndex;
+                String cachedBlock = redisCacheT.getCacheObject(blockedRedisKey);
+                if (cachedBlock != null) {
+                    Date nextAvailable = JSON.parseObject(cachedBlock, Date.class);
+                    if (nextAvailable != null) {
+                        if (earliestNextTime == null || nextAvailable.before(earliestNextTime)) {
+                            earliestNextTime = nextAvailable;
+                        }
+                        continue;
+                    }
+                }
+
+                // 查数据库:当前窗口内的实际外呼数量
+                int currentCount = outboundLineLimitLogMapper.countByWindow(companyId, gatewayId, timeWindow, windowStart, windowEnd);
+
+                // 判断是否达到限制
+                if (currentCount >= maxCalls) {
+                    // nextAvailableTime = 下一个窗口的起始时间(定时任务扫描用)
+                    Date nextAvailable = Date.from(windowEnd.atZone(ZoneId.systemDefault()).toInstant());
+
+                    // 设置 Redis blocked 标记,过期时间到当前窗口结束
+                    long remainingMinutes = Duration.between(now, windowEnd).toMinutes();
+                    remainingMinutes = Math.max(1, remainingMinutes);
+                    redisCacheT.setCacheObject(blockedRedisKey, JSON.toJSONString(nextAvailable),
+                            remainingMinutes, TimeUnit.MINUTES);
+
+                    if (earliestNextTime == null || nextAvailable.before(earliestNextTime)) {
+                        earliestNextTime = nextAvailable;
+                    }
+
+                    // 记录限制触发日志
+                    LocalDateTime nextAvailableTime = windowEnd;
+                    saveLimitLog(companyId, gatewayId, timeWindow, windowStart, maxCalls,
+                            currentCount, null, nextAvailableTime, 1);
+                }
+            }
+
+            // 5. 返回结果
+            if (earliestNextTime != null) {
+                return OutboundLimitResultVO.limited(earliestNextTime);
+            }
+            return OutboundLimitResultVO.pass();
+
+        } catch (Exception e) {
+            log.error("checkOutboundLineLimit 异常,默认放行 - companyId: {}, gatewayId: {}", companyId, gatewayId, e);
+            return OutboundLimitResultVO.pass();
+        }
+    }
+
+    /**
+     * 保存外呼重试任务到 Redis(Sorted Set 结构)
+     * <p>
+     * ZSET key: outbound:limit:retry:zset
+     * score: nextAvailableTime 的毫秒时间戳(定时任务用 ZRANGEBYSCORE 查询已到期的任务)
+     * member: JSON 字符串,包含 companyId, gatewayId, param, nextAvailableTime, createTime
+     *
+     * @param companyId        公司ID
+     * @param gatewayId        网关ID
+     * @param param            外呼调用参数(定时任务重试时需要)
+     * @param nextAvailableTime 下一个可用时间点
+     */
+    private void saveRetryTask(Long companyId, Long gatewayId, EasyCallCommonAddCallListParam param, Date nextAvailableTime) {
+        try {
+            JSONObject retryData = new JSONObject();
+            retryData.put("companyId", companyId);
+            retryData.put("gatewayId", gatewayId);
+            retryData.put("param", param);
+            retryData.put("nextAvailableTime", nextAvailableTime.getTime());
+            retryData.put("createTime", System.currentTimeMillis());
+
+            // score = nextAvailableTime 时间戳,定时任务通过 ZRANGEBYSCORE 0 当前时间 查询到期任务
+            String zsetKey = OUTBOUND_LIMIT_REDIS_PREFIX + "retry:zset";
+            double score = nextAvailableTime.getTime();
+            redisCache.zSetAdd(zsetKey, retryData.toJSONString(), score);
+
+            log.info("saveRetryTask: 已保存重试任务到ZSET - companyId: {}, gatewayId: {}, nextAvailableTime: {}",
+                    companyId, gatewayId, DateUtil.format(nextAvailableTime, "yyyy-MM-dd HH:mm:ss"));
+        } catch (Exception e) {
+            log.error("saveRetryTask 异常 - companyId: {}, gatewayId: {}", companyId, gatewayId, e);
+        }
+    }
+
+    /**
+     * 外呼执行成功后,记录 OutboundLineLimitLog 日志
+     * <p>
+     * 仅做数据库写入,不维护Redis计数缓存(计数以数据库为准)
+     *
+     * @param companyId 公司ID
+     * @param gatewayId 外呼线路(网关)ID
+     * @param callParam 调用参数(记录到日志便于追溯)
+     */
+    private void saveExecutionLog(Long companyId, Long gatewayId, EasyCallCommonAddCallListParam callParam) {
+        try {
+            if (gatewayId == null) {
+                return;
+            }
+
+            // 查询配置,获取匹配的规则
+            CompanyConfig companyConfig = companyConfigMapper.selectCompanyConfigByKey(companyId, "cId.config");
+            if (companyConfig == null || StringUtils.isEmpty(companyConfig.getConfigValue())) {
+                return;
+            }
+
+            JSONObject configJson = JSONObject.parseObject(companyConfig.getConfigValue());
+            Boolean enabled = configJson.getBoolean("outboundLineLimitEnabled");
+            if (enabled == null || !enabled) {
+                return;
+            }
+
+            JSONArray rules = configJson.getJSONArray("outboundLineRules");
+            if (rules == null || rules.isEmpty()) {
+                return;
+            }
+
+            LocalDateTime now = LocalDateTime.now();
+            LocalDateTime todayMidnight = now.toLocalDate().atStartOfDay();
+
+            IntStream.range(0, rules.size())
+                    .mapToObj(rules::getJSONObject)
+                    .filter(rule -> {
+                        Integer tw = rule.getInteger("timeWindow");
+                        Integer mc = rule.getInteger("maxCalls");
+                        return tw != null && tw > 0 && mc != null && mc > 0;
+                    })
+                    .filter(rule -> {
+                        JSONArray lineIdsArr = rule.getJSONArray("lineIds");
+                        if (lineIdsArr == null || lineIdsArr.isEmpty()) {
+                            return true;
+                        }
+                        return IntStream.range(0, lineIdsArr.size())
+                                .anyMatch(j -> gatewayId.equals(lineIdsArr.getLong(j)));
+                    })
+                    .forEach(rule -> {
+                        Integer timeWindow = rule.getInteger("timeWindow");
+                        Integer maxCalls = rule.getInteger("maxCalls");
+
+                        // 计算当前固定窗口(以零点对齐)
+                        long minutesSinceMidnight = Duration.between(todayMidnight, now).toMinutes();
+                        long windowIndex = minutesSinceMidnight / timeWindow;
+                        LocalDateTime windowStart = todayMidnight.plusMinutes(windowIndex * timeWindow);
+                        LocalDateTime windowEnd = windowStart.plusMinutes(timeWindow);
+
+                        // 查询当前窗口内的实际数量
+                        int currentCount = outboundLineLimitLogMapper.countByWindow(companyId, gatewayId, timeWindow, windowStart, windowEnd);
+
+                        // 记录成功执行日志(is_limited=0)
+                        saveLimitLog(companyId, gatewayId, timeWindow, windowStart, maxCalls,
+                                currentCount + 1, callParam, null, 0);
+                    });
+        } catch (Exception e) {
+            log.error("saveExecutionLog 异常 - companyId: {}, gatewayId: {}", companyId, gatewayId, e);
+        }
+    }
+
+    /**
+     * 保存外呼限制日志到数据库
+     */
+    private void saveLimitLog(Long companyId, Long gatewayId, Integer timeWindow, LocalDateTime windowStart,
+                               Integer maxCalls, int currentCount, Object callParam,
+                               LocalDateTime nextAvailableTime, int isLimited) {
+        try {
+            OutboundLineLimitLog logEntity = new OutboundLineLimitLog();
+            logEntity.setCompanyId(companyId);
+            logEntity.setGatewayId(gatewayId);
+            logEntity.setTimeWindow(timeWindow);
+            logEntity.setWindowStart(windowStart);
+            logEntity.setMaxCalls(maxCalls);
+            logEntity.setCurrentCount(currentCount);
+            logEntity.setIsLimited(isLimited);
+            logEntity.setCallParam(callParam != null ? JSON.toJSONString(callParam) : null);
+            logEntity.setNextAvailableTime(nextAvailableTime);
+            logEntity.setCreateTime(LocalDateTime.now());
+            outboundLineLimitLogMapper.asyncInsert(logEntity);
+        } catch (Exception e) {
+            log.error("saveLimitLog 异常 - companyId: {}, gatewayId: {}, isLimited: {}", companyId, gatewayId, isLimited, e);
+        }
+    }
+
     // =================== 私有工具方法 ===================
 
     /**
@@ -355,8 +664,10 @@ public class EasyCallServiceImpl implements IEasyCallService {
      * 启动、停止、追加名单等操作类接口无 data 返回,
      * 成功与否已由 parseAndCheck 中的 code 判断保证,此处无需额外处理
      */
-    private void checkSuccess(JSONObject result) {
+    private boolean checkSuccess(JSONObject result) {
         // parseAndCheck 中 code != 0 时已抛异常,走到这里即代表操作成功
+        Integer code = result.getInteger("code");
+        return code != null && code == 0;
     }
 
     /**

+ 2 - 1
fs-service/src/main/java/com/fs/company/service/easycall/IEasyCallService.java

@@ -97,8 +97,9 @@ public interface IEasyCallService {
      *
      * @param param     追加参数
      * @param companyId 公司id
+     * @param gatewayId
      */
-    void addCommonCallList(EasyCallCommonAddCallListParam param, Long companyId);
+    boolean addCommonCallList(EasyCallCommonAddCallListParam param, Long companyId, Long gatewayId);
 
     /**
      * 获取录音文件访问URL

+ 11 - 7
fs-service/src/main/java/com/fs/company/service/impl/call/node/AiCallTaskNode.java

@@ -6,6 +6,7 @@ import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.spring.SpringUtils;
 import com.fs.company.domain.*;
 import com.fs.company.enums.BusinessTypeEnum;
+import com.fs.company.vo.easycall.EasyCallCommonAddCallListParam;
 import com.fs.company.mapper.*;
 import com.fs.company.param.CompanyVoiceRoboticCallBlacklistCheckParam;
 import com.fs.company.param.ExecutionContext;
@@ -18,15 +19,11 @@ import com.fs.company.vo.AiCallConfigVO;
 import com.fs.company.vo.AiCallWorkflowConditionVo;
 import com.fs.company.vo.CompanyVoiceRoboticCallBlacklistCheckVO;
 import com.fs.company.vo.ExecutionResult;
-import com.fs.company.vo.easycall.EasyCallCommonAddCallListParam;
-import com.fs.company.vo.easycall.EasyCallCreateTaskParam;
 import com.fs.company.vo.easycall.EasyCallPhoneItemVO;
-import com.fs.company.vo.easycall.EasyCallTaskVO;
 import com.fs.crm.domain.CrmCustomer;
 import com.fs.crm.service.ICrmCustomerService;
 import com.fs.enums.ExecutionStatusEnum;
 import com.fs.enums.NodeTypeEnum;
-import com.fs.enums.TaskTypeEnum;
 import com.fs.his.config.CidPhoneConfig;
 import com.fs.his.utils.PhoneUtil;
 import com.fs.system.service.ISysConfigService;
@@ -368,9 +365,16 @@ public class AiCallTaskNode extends AbstractWorkflowNode {
         // bizJson 默认传入客户姓名占位,运行时可根据实际业务填充
         phoneItem.setBizJson(new JSONObject().fluentPut("custName", callees.getUserName()).fluentPut("tenantId", TenantHelper.getTenantId()).fluentPut("callBackUuid",callBackUuid).fluentPut("callBackUrl",callBackUrl));
         addListParam.setPhoneList(Collections.singletonList(phoneItem));
-        easyCallService.addCommonCallList(addListParam, null);
-        log.info("workflowCallPhoneOne4EasyCall: 名单追加成功 - batchId: {}, phone: {}",
-                batchId, callees.getPhone());
+        Long gatewayId = callConfigVo.getGatewayId();
+
+        boolean b = easyCallService.addCommonCallList(addListParam, robotic.getCompanyId(), gatewayId);
+        if(b){
+            log.info("workflowCallPhoneOne4EasyCall: 名单追加成功 - batchId: {}, phone: {}",
+                    batchId, callees.getPhone());
+        }else{
+            log.error("workflowCallPhoneOne4EasyCall: 名单追失败 - batchId: {}, phone: {}",
+                    batchId, callees.getPhone());
+        }
 
         // 6. 启动外呼任务
         easyCallService.startTask(batchId, null);

+ 19 - 0
fs-service/src/main/resources/db/outbound_line_limit_log.sql

@@ -0,0 +1,19 @@
+-- 外呼线路执行限制日志表
+DROP TABLE IF EXISTS `outbound_line_limit_log`;
+CREATE TABLE `outbound_line_limit_log`
+(
+    `id`              bigint       NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `company_id`      bigint       NOT NULL COMMENT '公司ID',
+    `gateway_id`      bigint       NOT NULL COMMENT '外呼线路(网关)ID',
+    `time_window`     int          NOT NULL COMMENT '时间粒度(分钟): 30/60/120/360/1440',
+    `window_start`    datetime     NOT NULL COMMENT '时间窗口起始时间',
+    `max_calls`       int          NOT NULL COMMENT '该窗口最大允许呼出次数',
+    `current_count`   int          NOT NULL DEFAULT 0 COMMENT '当前窗口已呼叫次数',
+    `is_limited`      tinyint(1)   NOT NULL DEFAULT 0 COMMENT '是否触发限制: 0否 1是',
+    `call_param`      text         NULL COMMENT '触发时的调用参数JSON',
+    `next_available_time` datetime  NULL COMMENT '下一个可用时间点',
+    `create_time`     datetime     DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    PRIMARY KEY (`id`) USING BTREE,
+    KEY `idx_company_gateway_window` (`company_id`, `gateway_id`, `window_start`),
+    KEY `idx_company_time` (`company_id`, `create_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='外呼线路执行限制日志';

+ 250 - 0
fs-service/src/main/resources/db/tenant-initTable.sql

@@ -3053,6 +3053,7 @@ CREATE TABLE `crm_customer`
     `shop_name`             varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '店铺名称',
     `platform_name`         varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '平台名称',
     `create_by`             bigint NULL DEFAULT NULL COMMENT '创建人',
+    `wx_contact_id`             bigint NULL DEFAULT NULL COMMENT '外部联系人',
     `update_by`             varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
     `trace_id`              varchar(36) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '投流来源id',
     `customer_company_name` varchar(80) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '客户公司名称',
@@ -18091,6 +18092,255 @@ CREATE TABLE `company_ai_sensitive_word`
     UNIQUE INDEX `uk_word`(`word` ASC, `del_flag` ASC) USING BTREE,
     INDEX         `idx_enabled`(`enabled` ASC) USING BTREE
 ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '敏感词库' ROW_FORMAT = DYNAMIC;
+DROP TABLE IF EXISTS `qw_sop_temp`;
+CREATE TABLE `qw_sop_temp`  (
+                                `id` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT (uuid()),
+                                `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '模板名称',
+                                `setting` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '内容',
+                                `status` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '状态 0 未启用 1 启用',
+                                `gap` int NULL DEFAULT NULL COMMENT '排序',
+                                `sort` int NULL DEFAULT NULL COMMENT '排序',
+                                `create_time` datetime NULL DEFAULT NULL,
+                                `create_by` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
+                                `company_id` int NULL DEFAULT NULL COMMENT '销售公司id',
+                                `corp_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '企业微信公司id',
+                                `send_type` int NULL DEFAULT NULL COMMENT '模板类型',
+                                `update_time` datetime NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
+                                `project` int NULL DEFAULT NULL COMMENT '所属项目',
+                                `course_id` bigint NULL DEFAULT NULL COMMENT '课程ID',
+                                `create_by_dept` int NULL DEFAULT NULL COMMENT '所属部门',
+                                PRIMARY KEY (`id`) USING BTREE,
+                                INDEX `index_id`(`id` ASC) USING BTREE,
+                                INDEX `index2`(`id` ASC, `status` ASC) USING BTREE
+) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '模板' ROW_FORMAT = DYNAMIC;
+
+-- ----------------------------
+-- Table structure for qw_sop_temp_content
+-- ----------------------------
+DROP TABLE IF EXISTS `qw_sop_temp_content`;
+CREATE TABLE `qw_sop_temp_content`  (
+                                        `id` bigint NOT NULL AUTO_INCREMENT COMMENT 'id',
+                                        `rules_id` bigint NULL DEFAULT NULL COMMENT '规则ID',
+                                        `temp_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
+                                        `content_type` int NULL DEFAULT NULL COMMENT '发送类型',
+                                        `content` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '内容(JSON)',
+                                        `is_bind_url` int NULL DEFAULT NULL COMMENT '是否绑定url',
+                                        `expires_days` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '链接过期时间',
+                                        `day_id` bigint NULL DEFAULT NULL,
+                                        `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+                                        PRIMARY KEY (`id`) USING BTREE,
+                                        INDEX `temp_id`(`temp_id` ASC) USING BTREE,
+                                        INDEX `day_id`(`day_id` ASC) USING BTREE,
+                                        INDEX `rule_id`(`rules_id` ASC) USING BTREE
+) ENGINE = InnoDB AUTO_INCREMENT = 18237 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = 'sop任务模板内容' ROW_FORMAT = DYNAMIC;
+
+-- ----------------------------
+-- Table structure for qw_sop_temp_day
+-- ----------------------------
+DROP TABLE IF EXISTS `qw_sop_temp_day`;
+CREATE TABLE `qw_sop_temp_day`  (
+                                    `id` bigint NOT NULL AUTO_INCREMENT,
+                                    `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
+                                    `temp_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
+                                    `day_num` int NULL DEFAULT NULL,
+                                    `sorts` int NULL DEFAULT NULL,
+                                    `voice` int NULL DEFAULT 0 COMMENT '语言生成状态0正常1生成中',
+                                    `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+                                    `is_rating` tinyint NULL DEFAULT 0 COMMENT '是否评级 0 否 1是 (只有群发助手才有)',
+                                    PRIMARY KEY (`id`) USING BTREE,
+                                    INDEX `temp_id`(`temp_id` ASC) USING BTREE
+) ENGINE = InnoDB AUTO_INCREMENT = 2768 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = DYNAMIC;
+
+-- ----------------------------
+-- Table structure for qw_sop_temp_media
+-- ----------------------------
+DROP TABLE IF EXISTS `qw_sop_temp_media`;
+CREATE TABLE `qw_sop_temp_media`  (
+                                      `id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID',
+                                      `media_id` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '素材ID',
+                                      `corp_id` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '企业微信ID',
+                                      `content_id` bigint NULL DEFAULT NULL COMMENT '模板ID',
+                                      `create_time` datetime NULL DEFAULT NULL COMMENT '创建时间',
+                                      `update_time` datetime NULL DEFAULT NULL COMMENT '更新时间',
+                                      PRIMARY KEY (`id`) USING BTREE,
+                                      INDEX `idx_content_id`(`content_id` ASC) USING BTREE
+) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '素材库' ROW_FORMAT = DYNAMIC;
+
+-- ----------------------------
+-- Table structure for qw_sop_temp_qw_sop_temp
+-- ----------------------------
+DROP TABLE IF EXISTS `qw_sop_temp_qw_sop_temp`;
+CREATE TABLE `qw_sop_temp_qw_sop_temp`  (
+                                            `id` int NOT NULL AUTO_INCREMENT COMMENT 'id',
+                                            `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '模板标题',
+                                            `setting` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '规则',
+                                            `status` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '状态 正常 或 停用',
+                                            `gap` int NULL DEFAULT NULL COMMENT '间隔天数',
+                                            `sort` int NULL DEFAULT NULL COMMENT '排序',
+                                            `create_time` datetime NULL DEFAULT NULL COMMENT '创建时间',
+                                            `create_by` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '创建人',
+                                            `company_id` int NULL DEFAULT NULL,
+                                            `corp_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
+                                            `send_type` tinyint NULL DEFAULT NULL COMMENT '模板类型 1企微接口 2 Ai插件  3客户触发模板',
+                                            `update_time` datetime NULL DEFAULT NULL COMMENT '更新时间',
+                                            PRIMARY KEY (`id`) USING BTREE
+) ENGINE = InnoDB AUTO_INCREMENT = 21 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = 'sop模板' ROW_FORMAT = DYNAMIC;
+
+-- ----------------------------
+-- Table structure for qw_sop_temp_rules
+-- ----------------------------
+DROP TABLE IF EXISTS `qw_sop_temp_rules`;
+CREATE TABLE `qw_sop_temp_rules`  (
+                                      `id` bigint NOT NULL AUTO_INCREMENT COMMENT 'id',
+                                      `day_id` bigint NULL DEFAULT NULL,
+                                      `temp_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '模板ID',
+                                      `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '内容名称',
+                                      `day_num` int NULL DEFAULT NULL COMMENT '天数',
+                                      `sorts` int NULL DEFAULT NULL COMMENT '排序',
+                                      `time` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '发送时间',
+                                      `content_type` int NULL DEFAULT NULL COMMENT '消息类别;(1普通,2课程,3订单,4AI触达)',
+                                      `course_type` int NULL DEFAULT NULL COMMENT '消息类型',
+                                      `course_id` bigint NULL DEFAULT NULL COMMENT '课程ID',
+                                      `video_id` bigint NULL DEFAULT NULL COMMENT '视屏ID',
+                                      `ai_touch` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT 'Ai触达类型',
+                                      `type` int NULL DEFAULT NULL,
+                                      `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+                                      `add_tag` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
+                                      `del_tag` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
+                                      `is_official` int NULL DEFAULT 0 COMMENT '是否官方',
+                                      `open_app_course` int NULL DEFAULT 0 COMMENT '是否App发课',
+                                      `is_at_all` int NULL DEFAULT 0 COMMENT '是否@所有人,1是0否',
+                                      `live_id` bigint NULL DEFAULT NULL COMMENT '直播间id',
+                                      `ai_customer_role_id` bigint NULL DEFAULT NULL COMMENT 'Ai呼叫客服',
+                                      PRIMARY KEY (`id`) USING BTREE,
+                                      INDEX `temp_id`(`temp_id` ASC) USING BTREE,
+                                      INDEX `day_id`(`day_id` ASC) USING BTREE
+) ENGINE = InnoDB AUTO_INCREMENT = 28773 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = 'sop任务模板规则' ROW_FORMAT = DYNAMIC;
+
+-- ----------------------------
+-- Table structure for qw_sop_temp_voice
+-- ----------------------------
+DROP TABLE IF EXISTS `qw_sop_temp_voice`;
+CREATE TABLE `qw_sop_temp_voice`  (
+                                      `id` bigint NOT NULL AUTO_INCREMENT COMMENT 'id',
+                                      `company_user_id` bigint NULL DEFAULT NULL COMMENT '销售用户ID',
+                                      `company_id` bigint NULL DEFAULT NULL COMMENT '销售公司ID',
+                                      `qw_user_id` bigint NULL DEFAULT NULL COMMENT '企微用户id',
+                                      `temp_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '模板ID',
+                                      `rule_id` bigint NULL DEFAULT NULL COMMENT '模板规则ID',
+                                      `day_id` bigint NULL DEFAULT NULL,
+                                      `content_id` bigint NULL DEFAULT NULL COMMENT '模板内容ID',
+                                      `voice_txt` varchar(900) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '语音文本',
+                                      `voice_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '语音文件路径',
+                                      `user_voice_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '员工录制文件路径',
+                                      `record_type` int NULL DEFAULT 0 COMMENT '是否录制完成 0未录制 1已录制',
+                                      `duration` int NULL DEFAULT NULL COMMENT '秒',
+                                      `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+                                      `update_time` datetime NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
+                                      PRIMARY KEY (`id`) USING BTREE,
+                                      INDEX `a`(`company_user_id` ASC, `temp_id` ASC) USING BTREE
+) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '模板对应的销售语音文件' ROW_FORMAT = DYNAMIC;
+DROP TABLE IF EXISTS `wx_sop`;
+CREATE TABLE `wx_sop`  (
+                           `id` bigint NOT NULL AUTO_INCREMENT COMMENT 'id',
+                           `name` varchar(300) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '名称',
+                           `filter_type` int NULL DEFAULT NULL COMMENT '筛选方式(0标签1群聊)',
+                           `select_tags` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '选择的标签',
+                           `exclude_tags` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '排查的标签',
+                           `temp_id` varchar(300) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '模板ID',
+                           `company_id` bigint NULL DEFAULT NULL COMMENT '公司ID',
+                           `is_fixed` int NULL DEFAULT 0 COMMENT '是否固定营期(0否1是)',
+                           `expiry_time` int NULL DEFAULT NULL COMMENT '过期时间(小时)',
+                           `start_time` date NULL DEFAULT NULL COMMENT '营期开始时间',
+                           `account_ids` varchar(2048) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '账号ID',
+                           `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+                           `create_by` varchar(300) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '创建人',
+                           `update_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
+                           `update_by` varchar(300) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '修改人',
+                           `status` int NULL DEFAULT NULL COMMENT '状态(0停止 1启用 2执行中)',
+                           `remark` varchar(300) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '备注',
+                           PRIMARY KEY (`id`) USING BTREE
+) ENGINE = InnoDB AUTO_INCREMENT = 26 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '个微SOP表' ROW_FORMAT = Dynamic;
+
+-- ----------------------------
+-- Table structure for wx_sop_logs
+-- ----------------------------
+DROP TABLE IF EXISTS `wx_sop_logs`;
+CREATE TABLE `wx_sop_logs`  (
+                                `id` bigint NOT NULL AUTO_INCREMENT COMMENT 'id',
+                                `type` int NULL DEFAULT NULL COMMENT '消息类型0个人1群',
+                                `sop_id` bigint NULL DEFAULT NULL COMMENT '任务ID',
+                                `sop_user_id` bigint NULL DEFAULT NULL COMMENT '营期ID',
+                                `send_type` int NULL DEFAULT NULL COMMENT '发送类型(字典-wx_send_type)',
+                                `generate_type` bigint NULL DEFAULT NULL COMMENT '生成类型(0自动1手动)',
+                                `account_id` bigint NULL DEFAULT NULL COMMENT '发送账号ID',
+                                `wx_contact_id` bigint NULL DEFAULT NULL COMMENT '发送对象ID',
+                                `wx_contact_name` varchar(300) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '发送对象名称',
+                                `wx_room_id` bigint NULL DEFAULT NULL COMMENT '发送群聊ID',
+                                `wx_room_name` varchar(300) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '发送群聊名称',
+                                `fs_user_id` bigint NULL DEFAULT NULL COMMENT '小程序ID',
+                                `send_status` int NULL DEFAULT NULL COMMENT '发送状态0待发送1发送成功2发送失败3消息作废',
+                                `send_remark` varchar(300) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '发送备注',
+                                `send_sort` int NULL DEFAULT NULL COMMENT '发送排序',
+                                `expiration_time` datetime NULL DEFAULT NULL COMMENT '消息过期时间',
+                                `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+                                `create_by` varchar(300) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '创建人',
+                                `update_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
+                                `update_by` varchar(300) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '修改人',
+                                `remark` varchar(300) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '备注',
+                                `content_json` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL,
+                                `send_time` datetime NULL DEFAULT NULL,
+                                PRIMARY KEY (`id`) USING BTREE
+) ENGINE = InnoDB AUTO_INCREMENT = 43 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '个微发送记录表' ROW_FORMAT = Dynamic;
+
+-- ----------------------------
+-- Table structure for wx_sop_user
+-- ----------------------------
+DROP TABLE IF EXISTS `wx_sop_user`;
+CREATE TABLE `wx_sop_user`  (
+                                `id` bigint NOT NULL AUTO_INCREMENT COMMENT 'id',
+                                `type` int NULL DEFAULT 0 COMMENT '类型(0个人1群聊)',
+                                `sop_id` bigint NULL DEFAULT NULL COMMENT '任务ID',
+                                `account_id` bigint NULL DEFAULT NULL COMMENT '个微账号ID',
+                                `start_time` date NULL DEFAULT NULL COMMENT '营期时间',
+                                `chat_id` varchar(300) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '群聊ID',
+                                `status` int NULL DEFAULT 0 COMMENT '状态(0正常1暂停)',
+                                `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+                                `create_by` varchar(300) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '创建人',
+                                `update_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
+                                `update_by` varchar(300) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '修改人',
+                                `remark` varchar(300) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '备注',
+                                PRIMARY KEY (`id`) USING BTREE,
+                                UNIQUE INDEX `index_1`(`sop_id` ASC, `type` ASC, `account_id` ASC, `start_time` ASC) USING BTREE COMMENT '营期唯一索引'
+) ENGINE = InnoDB AUTO_INCREMENT = 14 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '个微营期' ROW_FORMAT = Dynamic;
+
+-- ----------------------------
+-- Table structure for wx_sop_user_info
+-- ----------------------------
+DROP TABLE IF EXISTS `wx_sop_user_info`;
+CREATE TABLE `wx_sop_user_info`  (
+                                     `id` bigint NOT NULL AUTO_INCREMENT COMMENT 'id',
+                                     `sop_id` bigint NULL DEFAULT NULL COMMENT '任务ID',
+                                     `sop_user_id` bigint NULL DEFAULT NULL COMMENT '营期ID',
+                                     `wx_contact_id` bigint NULL DEFAULT NULL COMMENT '联系人ID',
+                                     `customer_id` bigint NULL DEFAULT NULL COMMENT '客户ID',
+                                     `fs_user_id` bigint NULL DEFAULT NULL COMMENT '小程序ID',
+                                     `is_days_not_study` int NULL DEFAULT 0 COMMENT '是否7天都没有看课 0否 1是',
+                                     `finish_cout` int NULL DEFAULT NULL COMMENT '总完课天数',
+                                     `finish_time` datetime NULL DEFAULT NULL COMMENT '最近完课时间',
+                                     `finish_course_days` int NULL DEFAULT NULL COMMENT '连续完课天数',
+                                     `grade` int NULL DEFAULT NULL COMMENT '客户评级的等级',
+                                     `status` int NULL DEFAULT 0 COMMENT '禁用状态 0 正常 1禁用',
+                                     `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+                                     `create_by` varchar(300) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '创建人',
+                                     `update_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
+                                     `update_by` varchar(300) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '修改人',
+                                     `remark` varchar(300) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '备注',
+                                     `tag_names` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '客户标签名称',
+                                     PRIMARY KEY (`id`) USING BTREE,
+                                     INDEX `idx_customer_id`(`customer_id` ASC) USING BTREE
+) ENGINE = InnoDB AUTO_INCREMENT = 42 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '个微营期详情' ROW_FORMAT = Dynamic;
+
 
 -- ----------------------------
 -- Table structure for company_siptask_info

+ 22 - 0
fs-service/src/main/resources/mapper/company/OutboundLineLimitLogMapper.xml

@@ -0,0 +1,22 @@
+<?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.company.mapper.OutboundLineLimitLogMapper">
+
+    <insert id="asyncInsert" parameterType="com.fs.company.domain.OutboundLineLimitLog" useGeneratedKeys="true" keyProperty="id">
+        insert into outbound_line_limit_log (company_id, gateway_id, time_window, window_start, max_calls,
+            current_count, is_limited, call_param, next_available_time, create_time)
+        values (#{companyId}, #{gatewayId}, #{timeWindow}, #{windowStart}, #{maxCalls},
+            #{currentCount}, #{isLimited}, #{callParam}, #{nextAvailableTime}, #{createTime})
+    </insert>
+
+    <select id="countByWindow" resultType="int">
+        SELECT COUNT(*) FROM outbound_line_limit_log
+        WHERE company_id = #{companyId}
+          AND gateway_id = #{gatewayId}
+          AND time_window = #{timeWindow}
+          AND create_time >= #{windowStart}
+          AND create_time &lt; #{windowEnd}
+          AND is_limited = 0
+    </select>
+
+</mapper>