Преглед изворни кода

cid-主动加微-获客链接-益寿源迁移版

三七 пре 2 недеља
родитељ
комит
095ad68f87
28 измењених фајлова са 2539 додато и 28 уклоњено
  1. 11 0
      fs-service/src/main/java/com/fs/common/service/ISmsService.java
  2. 149 5
      fs-service/src/main/java/com/fs/common/service/impl/SmsServiceImpl.java
  3. 15 9
      fs-service/src/main/java/com/fs/company/domain/CompanySms.java
  4. 19 0
      fs-service/src/main/java/com/fs/company/mapper/CompanySmsMapper.java
  5. 49 8
      fs-service/src/main/java/com/fs/company/service/ICompanySmsService.java
  6. 164 0
      fs-service/src/main/java/com/fs/company/service/impl/CompanySmsServiceImpl.java
  7. 1 0
      fs-service/src/main/java/com/fs/course/config/CourseConfig.java
  8. 33 0
      fs-service/src/main/java/com/fs/qw/bo/SendMsgLogBo.java
  9. 77 0
      fs-service/src/main/java/com/fs/qw/domain/QwAcquisitionLinkInfo.java
  10. 55 0
      fs-service/src/main/java/com/fs/qw/domain/QwAcquisitionSendMsgLog.java
  11. 64 0
      fs-service/src/main/java/com/fs/qw/domain/QwCourseLinkSendMsgLog.java
  12. 32 0
      fs-service/src/main/java/com/fs/qw/dto/BatchAddAcquisitionLinkDTO.java
  13. 45 0
      fs-service/src/main/java/com/fs/qw/dto/IpadBlindAddDto.java
  14. 33 0
      fs-service/src/main/java/com/fs/qw/enums/SmsLogType.java
  15. 91 0
      fs-service/src/main/java/com/fs/qw/mapper/QwAcquisitionLinkInfoMapper.java
  16. 62 0
      fs-service/src/main/java/com/fs/qw/mapper/QwAcquisitionSendMsgLogMapper.java
  17. 62 0
      fs-service/src/main/java/com/fs/qw/mapper/QwCourseLinkSendMsgLogMapper.java
  18. 106 0
      fs-service/src/main/java/com/fs/qw/service/IQwAcquisitionLinkInfoService.java
  19. 823 0
      fs-service/src/main/java/com/fs/qw/service/impl/QwAcquisitionLinkInfoServiceImpl.java
  20. 19 0
      fs-service/src/main/java/com/fs/qw/strategy/SmsLogStrategy.java
  21. 54 0
      fs-service/src/main/java/com/fs/qw/strategy/SmsLogStrategyManager.java
  22. 35 0
      fs-service/src/main/java/com/fs/qw/strategy/impl/AcquisitionLinkLogStrategyImpl.java
  23. 38 0
      fs-service/src/main/java/com/fs/qw/strategy/impl/CourseLinkLogStrategyImpl.java
  24. 18 0
      fs-service/src/main/java/com/fs/qw/strategy/impl/NoOpSmsLogStrategy.java
  25. 51 6
      fs-service/src/main/resources/mapper/company/CompanySmsMapper.xml
  26. 213 0
      fs-service/src/main/resources/mapper/qw/QwAcquisitionLinkInfoMapper.xml
  27. 105 0
      fs-service/src/main/resources/mapper/qw/QwAcquisitionSendMsgLogMapper.xml
  28. 115 0
      fs-service/src/main/resources/mapper/qw/QwCourseLinkSendMsgLogMapper.xml

+ 11 - 0
fs-service/src/main/java/com/fs/common/service/ISmsService.java

@@ -1,9 +1,12 @@
 package com.fs.common.service;
 package com.fs.common.service;
 
 
 import com.fs.common.core.domain.R;
 import com.fs.common.core.domain.R;
+import com.fs.company.domain.CompanySmsTemp;
 import com.fs.crm.param.SmsSendBatchParam;
 import com.fs.crm.param.SmsSendBatchParam;
 import com.fs.crm.param.SmsSendParam;
 import com.fs.crm.param.SmsSendParam;
 import com.fs.crm.param.SmsSendUserParam;
 import com.fs.crm.param.SmsSendUserParam;
+import com.fs.qw.bo.SendMsgLogBo;
+import com.fs.qw.enums.SmsLogType;
 
 
 
 
 public interface ISmsService
 public interface ISmsService
@@ -26,4 +29,12 @@ public interface ISmsService
 
 
     R sendUrl(String phone, String content, String code,Long uuid,Integer smsIndex,String deleteKey,Long companyId,Long companyUserId);
     R sendUrl(String phone, String content, String code,Long uuid,Integer smsIndex,String deleteKey,Long companyId,Long companyUserId);
 
 
+    /**
+     *  根据号码、内容、模板发送短信(简洁版)
+     *  @param phone 号码
+     *  @param content 内容
+     *  @param temp 模板
+     * */
+    R simpleSmsSend(String phone, String content, CompanySmsTemp temp, SmsLogType logType, SendMsgLogBo sendMsgLogBo);
+
 }
 }

+ 149 - 5
fs-service/src/main/java/com/fs/common/service/impl/SmsServiceImpl.java

@@ -9,6 +9,7 @@ import com.fs.common.core.domain.R;
 import com.fs.common.core.redis.RedisCache;
 import com.fs.common.core.redis.RedisCache;
 
 
 import com.fs.common.service.ISmsService;
 import com.fs.common.service.ISmsService;
+import com.fs.common.utils.DateUtils;
 import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.StringUtils;
 import com.fs.common.vo.SmsNotifyVO;
 import com.fs.common.vo.SmsNotifyVO;
 import com.fs.common.vo.SmsSendItemVO;
 import com.fs.common.vo.SmsSendItemVO;
@@ -23,6 +24,7 @@ import com.fs.company.service.ICompanySmsTempService;
 import com.fs.company.service.ICompanyUserService;
 import com.fs.company.service.ICompanyUserService;
 import com.fs.company.service.impl.CompanyVoiceRoboticCallBlacklistServiceImpl;
 import com.fs.company.service.impl.CompanyVoiceRoboticCallBlacklistServiceImpl;
 import com.fs.company.vo.CompanyVoiceRoboticCallBlacklistCheckVO;
 import com.fs.company.vo.CompanyVoiceRoboticCallBlacklistCheckVO;
+import com.fs.course.config.CourseConfig;
 import com.fs.crm.domain.CrmCustomer;
 import com.fs.crm.domain.CrmCustomer;
 import com.fs.crm.param.SmsSendBatchParam;
 import com.fs.crm.param.SmsSendBatchParam;
 import com.fs.crm.param.SmsSendParam;
 import com.fs.crm.param.SmsSendParam;
@@ -34,15 +36,22 @@ import com.fs.his.domain.FsStoreOrder;
 import com.fs.his.mapper.FsPackageOrderMapper;
 import com.fs.his.mapper.FsPackageOrderMapper;
 import com.fs.his.mapper.FsStoreOrderMapper;
 import com.fs.his.mapper.FsStoreOrderMapper;
 import com.fs.his.vo.FsPackageOrderVO;
 import com.fs.his.vo.FsPackageOrderVO;
+import com.fs.qw.bo.SendMsgLogBo;
+import com.fs.qw.domain.QwAcquisitionLinkInfo;
 import com.fs.qw.domain.QwSopSmsLogs;
 import com.fs.qw.domain.QwSopSmsLogs;
+import com.fs.qw.enums.SmsLogType;
+import com.fs.qw.mapper.QwAcquisitionLinkInfoMapper;
 import com.fs.qw.mapper.QwSopSmsLogsMapper;
 import com.fs.qw.mapper.QwSopSmsLogsMapper;
 import com.fs.qw.service.IQwSopSmsLogsService;
 import com.fs.qw.service.IQwSopSmsLogsService;
+import com.fs.qw.strategy.SmsLogStrategyManager;
 import com.fs.sms.domain.SendSmsReturn;
 import com.fs.sms.domain.SendSmsReturn;
 import com.fs.sms.service.impl.SmsTServiceImpl;
 import com.fs.sms.service.impl.SmsTServiceImpl;
 import com.fs.sop.domain.QwSopLogs;
 import com.fs.sop.domain.QwSopLogs;
 import com.fs.sop.mapper.QwSopLogsMapper;
 import com.fs.sop.mapper.QwSopLogsMapper;
 import com.fs.system.domain.SysConfig;
 import com.fs.system.domain.SysConfig;
 import com.fs.system.mapper.SysConfigMapper;
 import com.fs.system.mapper.SysConfigMapper;
+import com.fs.system.service.ISysConfigService;
+import com.fs.utils.ShortCodeGeneratorUtils;
 import com.google.gson.Gson;
 import com.google.gson.Gson;
 import lombok.Synchronized;
 import lombok.Synchronized;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
@@ -61,6 +70,8 @@ import com.fs.company.service.ICompanySmsCommonLogsService;
 import org.redisson.api.RLock;
 import org.redisson.api.RLock;
 import org.redisson.api.RedissonClient;
 import org.redisson.api.RedissonClient;
 
 
+import static com.fs.his.utils.PhoneUtil.encryptPhone;
+
 @Service
 @Service
 @Slf4j
 @Slf4j
 public class SmsServiceImpl implements ISmsService
 public class SmsServiceImpl implements ISmsService
@@ -109,6 +120,19 @@ public class SmsServiceImpl implements ISmsService
     @Autowired
     @Autowired
     private RedissonClient redissonClient;
     private RedissonClient redissonClient;
 
 
+    //获客链接短信模板code
+    private static final String  SMS_LINK_TEMPLATE_CODE = "获客链接短信模板";
+
+    @Autowired
+    private QwAcquisitionLinkInfoMapper qwAcquisitionLinkInfoMapper;
+
+    @Autowired
+    private SmsLogStrategyManager smsLogStrategyManager;
+
+    @Autowired
+    private ISysConfigService configService;
+
+
     @Override
     @Override
     public R sendTSms(String mobile, String code) {
     public R sendTSms(String mobile, String code) {
 //        try{
 //        try{
@@ -785,7 +809,88 @@ public class SmsServiceImpl implements ISmsService
         return R.ok();
         return R.ok();
     }
     }
 
 
+    /**
+     * 发送简单短信
+     * @param phone 接收方手机号
+     * @param content 短信内容
+     * @param temp 短信模板
+     * @param logType 日志记录类型,用于区分调用方
+     * @param sendMsgLogBo 特定业务的上下文对象,如qwAcquisitionId或externalContactId
+     * @return R 响应结果
+     */
+    @Override
+    public R simpleSmsSend(String phone, String content, CompanySmsTemp temp, SmsLogType logType, SendMsgLogBo sendMsgLogBo) {
+        String urls = null;
+        R response; // 存储最终响应
+        Integer number = calculateSmsCount(content);
+        SysConfig sysConfig = sysConfigMapper.selectConfigByConfigKey("his.sms");
+        FsSmsConfig sms = JSON.parseObject(sysConfig.getConfigValue(), FsSmsConfig.class);
+
+        try {
+            urls = sms.getRfUrl2() + "sms?action=send&account=" + sms.getRfAccount2() + "&password=" + sms.getRfPassword2() + "&mobile=" + phone + "&content=" + URLEncoder.encode(content, "UTF-8") + "&extno=" + sms.getRfCode2() + "&rt=json";
+        } catch (UnsupportedEncodingException e) {
+            log.error("{}发送失败", phone, e);
+            response = R.error("短信发送失败:" + e.getMessage());
+            // 发送失败也要记录特定业务日志
+            smsLogStrategyManager.executeLogStrategy(logType, response, content, phone, temp.getTempId(), sms.getType(), number, sendMsgLogBo);
+            return response;
+        }
+
+        String post = HttpRequest.get(urls).execute().body();
+        SmsSendVO vo = JSONUtil.toBean(post, SmsSendVO.class);
+
+        if (vo.getStatus().equals(0)) {
+            boolean anySuccess = false;
+            for (SmsSendItemVO itemVO : vo.getList()) {
+                if (itemVO.getResult().equals("0")) {
+                    anySuccess = true;
+                    // 记录通用日志
+                    CompanySmsLogs logs = new CompanySmsLogs();
+                    logs.setContent(content);
+                    logs.setTempCode(temp.getTempCode());
+                    logs.setTempId(temp.getTempId());
+                    logs.setPhone(phone);
+                    logs.setSendTime(new Date());
+                    logs.setStatus(0);
+                    logs.setType(sms.getType());
+                    logs.setMid(itemVO.getMid());
+                    logs.setNumber(number);
+                    logs.setCompanyId(sendMsgLogBo.getCompanyId());
+                    logs.setCompanyUserId(sendMsgLogBo.getCompanyUserId());
+                    logs.setCustomerId(sendMsgLogBo.getCustomerId());
+                    smsLogsService.insertCompanySmsLogs(logs);
+                    //子记录表关联主表的id
+                    sendMsgLogBo.setCompanySmsLogsId(logs.getLogsId());
+                }
+            }
+            if(anySuccess) {
+                response = R.ok();
+                // 记录特定业务日志
+                smsLogStrategyManager.executeLogStrategy(logType, response, content, phone, temp.getTempId(), sms.getType(), number, sendMsgLogBo);
+            } else {
+                response = R.error("发送短信失败,服务商返回无成功项!");
+                smsLogStrategyManager.executeLogStrategy(logType, response, content, phone, temp.getTempId(), sms.getType(), number, sendMsgLogBo);
+            }
+        } else {
+            response = R.error("发送短信失败!状态码: " + vo.getStatus());
+            // 发送失败也要记录特定业务日志
+            smsLogStrategyManager.executeLogStrategy(logType, response, content, phone, temp.getTempId(), sms.getType(), number, sendMsgLogBo);
+        }
+        return response;
+    }
 
 
+    // 将计算短信条数的逻辑提取出来,方便复用
+    private int calculateSmsCount(String content) {
+        if (content == null) return 1;
+        int counts = content.length() / 67;
+        if (content.length() % 67 > 0) {
+            counts = counts + 1;
+        }
+        if (counts == 0) {
+            counts = 1;
+        }
+        return counts;
+    }
 
 
     @Override
     @Override
     @Synchronized
     @Synchronized
@@ -965,6 +1070,19 @@ public class SmsServiceImpl implements ISmsService
                 content=content.replace("${sms.senderName}",param.getSenderName());
                 content=content.replace("${sms.senderName}",param.getSenderName());
             }
             }
 
 
+            if (param.getTempCode()!=null &&SMS_LINK_TEMPLATE_CODE.equals(temp.getTempCode())){
+
+                String json = configService.selectConfigByKey("course.config");
+                CourseConfig config = JSON.parseObject(json, CourseConfig.class);
+
+                String randomStr = ShortCodeGeneratorUtils.generate8();
+                String replaceText=config.getSmsAcquisitionName()+randomStr;
+                content = content.replace("${sms.friendLink}",replaceText);
+                //添加获客链接记录
+                addAcquisitionLinkInfo(null,crmCustomer.getMobile(),param.getCardUrl(),randomStr, param.getCompanyUserId(),config.getSmsAcquisitionName());
+            }
+
+
             String urls= null;
             String urls= null;
             // 通知类的不加 退订回T 只有营销类的加
             // 通知类的不加 退订回T 只有营销类的加
             //最多500个手机号
             //最多500个手机号
@@ -972,12 +1090,18 @@ public class SmsServiceImpl implements ISmsService
             FsSmsConfig sms = JSON.parseObject(sysConfig.getConfigValue(), FsSmsConfig.class);
             FsSmsConfig sms = JSON.parseObject(sysConfig.getConfigValue(), FsSmsConfig.class);
             if (sms.getType().equals("rf")){
             if (sms.getType().equals("rf")){
                 try {
                 try {
-                    if(temp.getTempType().equals(1)){
-                        urls = sms.getRfUrl1()+"sms?action=send&account="+sms.getRfAccount1()+"&password="+sms.getRfPassword1()+"&mobile="+crmCustomer.getMobile()+"&content="+ URLEncoder.encode(sms.getRfSign()+content, "UTF-8")+"&extno="+sms.getRfCode1()+"&rt=json";
-                    }
-                    else if(temp.getTempType().equals(2)){
-                        urls = sms.getRfUrl2()+"sms?action=send&account="+sms.getRfAccount2()+"&password="+sms.getRfPassword2()+"&mobile="+crmCustomer.getMobile()+"&content="+ URLEncoder.encode(sms.getRfSign()+content+"拒收请回复R", "UTF-8")+"&extno="+sms.getRfCode2()+"&rt=json";
+
+                    if (temp.getTempType().equals(2) && SMS_LINK_TEMPLATE_CODE.equals(temp.getTempCode())){
+                        urls = sms.getRfUrl2() + "sms?action=send&account=" + sms.getRfAccount2() + "&password=" + sms.getRfPassword2() + "&mobile=" + crmCustomer.getMobile() + "&content=" + URLEncoder.encode(content, "UTF-8") + "&extno=" + sms.getRfCode2() + "&rt=json";
+                    } else {
+                        if(temp.getTempType().equals(1)){
+                            urls = sms.getRfUrl1()+"sms?action=send&account="+sms.getRfAccount1()+"&password="+sms.getRfPassword1()+"&mobile="+crmCustomer.getMobile()+"&content="+ URLEncoder.encode(sms.getRfSign()+content, "UTF-8")+"&extno="+sms.getRfCode1()+"&rt=json";
+                        }
+                        else if(temp.getTempType().equals(2)){
+                            urls = sms.getRfUrl2()+"sms?action=send&account="+sms.getRfAccount2()+"&password="+sms.getRfPassword2()+"&mobile="+crmCustomer.getMobile()+"&content="+ URLEncoder.encode(sms.getRfSign()+content+"拒收请回复R", "UTF-8")+"&extno="+sms.getRfCode2()+"&rt=json";
+                        }
                     }
                     }
+
                 } catch (UnsupportedEncodingException e) {
                 } catch (UnsupportedEncodingException e) {
                     e.printStackTrace();
                     e.printStackTrace();
                 }
                 }
@@ -1074,6 +1198,26 @@ public class SmsServiceImpl implements ISmsService
         }
         }
     }
     }
 
 
+
+
+    /**
+     * 添加链接生成记录
+     * */
+    public int addAcquisitionLinkInfo(Long qwAcquisitionAssistantId,String originalPhone,String originalLink,String randomStr,Long createBy,String smsAcquisitionName){
+        QwAcquisitionLinkInfo qwAcquisitionLinkInfo=new QwAcquisitionLinkInfo();
+        qwAcquisitionLinkInfo.setQwAcquisitionAssistantId(qwAcquisitionAssistantId);
+        qwAcquisitionLinkInfo.setCreateBy(createBy);
+        qwAcquisitionLinkInfo.setCreateTime(DateUtils.getTime());
+        qwAcquisitionLinkInfo.setPhone(originalPhone);//这里存储原始手机号
+        //加密手机号
+        String phonePlus = encryptPhone(originalPhone);
+        String linkPlus=originalLink+smsAcquisitionName+ phonePlus;
+        qwAcquisitionLinkInfo.setLink(linkPlus);
+        qwAcquisitionLinkInfo.setRandomStr(randomStr);
+        int addResult=qwAcquisitionLinkInfoMapper.insertQwAcquisitionLinkInfo(qwAcquisitionLinkInfo);
+        return addResult;
+    }
+
     /**
     /**
      * 根据 uuid(QwSopSmsLogs.id)和索引更新子记录,并检查主记录完成状态
      * 根据 uuid(QwSopSmsLogs.id)和索引更新子记录,并检查主记录完成状态
      */
      */

+ 15 - 9
fs-service/src/main/java/com/fs/company/domain/CompanySms.java

@@ -7,7 +7,7 @@ import org.apache.commons.lang3.builder.ToStringStyle;
 
 
 /**
 /**
  * 公司短信对象 company_sms
  * 公司短信对象 company_sms
- * 
+ *
  * @author fs
  * @author fs
  * @date 2023-01-09
  * @date 2023-01-09
  */
  */
@@ -30,43 +30,49 @@ public class CompanySms extends BaseEntity
     @Excel(name = "总短信数")
     @Excel(name = "总短信数")
     private Long totalSmsCount;
     private Long totalSmsCount;
 
 
-    public void setSmsId(Long smsId) 
+    /** 乐观锁版本号 */
+    private Long version;
+
+    public void setSmsId(Long smsId)
     {
     {
         this.smsId = smsId;
         this.smsId = smsId;
     }
     }
 
 
-    public Long getSmsId() 
+    public Long getSmsId()
     {
     {
         return smsId;
         return smsId;
     }
     }
-    public void setCompanyId(Long companyId) 
+    public void setCompanyId(Long companyId)
     {
     {
         this.companyId = companyId;
         this.companyId = companyId;
     }
     }
 
 
-    public Long getCompanyId() 
+    public Long getCompanyId()
     {
     {
         return companyId;
         return companyId;
     }
     }
-    public void setRemainSmsCount(Long remainSmsCount) 
+    public void setRemainSmsCount(Long remainSmsCount)
     {
     {
         this.remainSmsCount = remainSmsCount;
         this.remainSmsCount = remainSmsCount;
     }
     }
 
 
-    public Long getRemainSmsCount() 
+    public Long getRemainSmsCount()
     {
     {
         return remainSmsCount;
         return remainSmsCount;
     }
     }
-    public void setTotalSmsCount(Long totalSmsCount) 
+    public void setTotalSmsCount(Long totalSmsCount)
     {
     {
         this.totalSmsCount = totalSmsCount;
         this.totalSmsCount = totalSmsCount;
     }
     }
 
 
-    public Long getTotalSmsCount() 
+    public Long getTotalSmsCount()
     {
     {
         return totalSmsCount;
         return totalSmsCount;
     }
     }
 
 
+    public Long getVersion() {
+        return version;
+    }
     @Override
     @Override
     public String toString() {
     public String toString() {
         return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE)
         return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE)

+ 19 - 0
fs-service/src/main/java/com/fs/company/mapper/CompanySmsMapper.java

@@ -90,4 +90,23 @@ public interface CompanySmsMapper {
     Long debitSmsCount(Long companyId);
     Long debitSmsCount(Long companyId);
 
 
 
 
+    // ========== 扣减/增加方法 ==========
+    /** 直接扣减(配合悲观锁) */
+    int decrementRemainSmsCount(@Param("companyId") Long companyId,
+                                @Param("number") int number);
+
+    /** 直接增加(配合悲观锁) */
+    int incrementRemainSmsCount(@Param("companyId") Long companyId,
+                                @Param("number") int number);
+
+    /** 乐观锁扣减 */
+    int decrementRemainSmsCountWithVersion(@Param("companyId") Long companyId,
+                                           @Param("number") int number,
+                                           @Param("currentVersion") Long currentVersion);
+
+    /** 乐观锁增加 */
+    int incrementRemainSmsCountWithVersion(@Param("companyId") Long companyId,
+                                           @Param("number") int number,
+                                           @Param("currentVersion") Long currentVersion);
+
 }
 }

+ 49 - 8
fs-service/src/main/java/com/fs/company/service/ICompanySmsService.java

@@ -6,15 +6,15 @@ import com.fs.company.vo.CompanySmsListVO;
 
 
 /**
 /**
  * 公司短信Service接口
  * 公司短信Service接口
- * 
+ *
  * @author fs
  * @author fs
  * @date 2023-01-09
  * @date 2023-01-09
  */
  */
-public interface ICompanySmsService 
+public interface ICompanySmsService
 {
 {
     /**
     /**
      * 查询公司短信
      * 查询公司短信
-     * 
+     *
      * @param smsId 公司短信ID
      * @param smsId 公司短信ID
      * @return 公司短信
      * @return 公司短信
      */
      */
@@ -22,7 +22,7 @@ public interface ICompanySmsService
 
 
     /**
     /**
      * 查询公司短信列表
      * 查询公司短信列表
-     * 
+     *
      * @param companySms 公司短信
      * @param companySms 公司短信
      * @return 公司短信集合
      * @return 公司短信集合
      */
      */
@@ -30,7 +30,7 @@ public interface ICompanySmsService
 
 
     /**
     /**
      * 新增公司短信
      * 新增公司短信
-     * 
+     *
      * @param companySms 公司短信
      * @param companySms 公司短信
      * @return 结果
      * @return 结果
      */
      */
@@ -38,7 +38,7 @@ public interface ICompanySmsService
 
 
     /**
     /**
      * 修改公司短信
      * 修改公司短信
-     * 
+     *
      * @param companySms 公司短信
      * @param companySms 公司短信
      * @return 结果
      * @return 结果
      */
      */
@@ -46,7 +46,7 @@ public interface ICompanySmsService
 
 
     /**
     /**
      * 批量删除公司短信
      * 批量删除公司短信
-     * 
+     *
      * @param smsIds 需要删除的公司短信ID
      * @param smsIds 需要删除的公司短信ID
      * @return 结果
      * @return 结果
      */
      */
@@ -54,7 +54,7 @@ public interface ICompanySmsService
 
 
     /**
     /**
      * 删除公司短信信息
      * 删除公司短信信息
-     * 
+     *
      * @param smsId 公司短信ID
      * @param smsId 公司短信ID
      * @return 结果
      * @return 结果
      */
      */
@@ -68,4 +68,45 @@ public interface ICompanySmsService
     int addCompanySms(Long companyId, int number);
     int addCompanySms(Long companyId, int number);
 
 
     CompanySms selectCompanySmsByCompanyIdForUpdate(Long companyId);
     CompanySms selectCompanySmsByCompanyIdForUpdate(Long companyId);
+
+
+    // ========== 查询方法 ==========
+    /** 普通查询(不带锁) */
+    CompanySms selectCompanySms(Long companyId);
+
+    /** 悲观锁查询(for update) */
+    CompanySms selectCompanySmsForUpdate(Long companyId);
+
+    // ========== 扣减/增加方法 ==========
+    /** 直接扣减(配合悲观锁使用) */
+    int decrementRemainSmsCount(Long companyId, int number);
+
+    /** 直接增加(配合悲观锁使用) */
+    int incrementRemainSmsCount(Long companyId, int number);
+
+    /** 乐观锁扣减(配合重试机制使用) */
+    int decrementRemainSmsCountWithVersion(Long companyId, int number, Long currentVersion);
+
+    /** 乐观锁增加(配合重试机制使用) */
+    int incrementRemainSmsCountWithVersion(Long companyId, int number, Long currentVersion);
+
+    // ========== 缓存方法 ==========
+    /** 获取余额(优先从缓存获取) */
+    Long getBalance(Long companyId);
+
+    /** 删除缓存(数据库变更后调用) */
+    void evictBalance(Long companyId);
+
+    /**
+     * 更新缓存中的余额(数据库更新后调用)
+     */
+    void updateCacheBalance(Long companyId, int delta);
+
+    /**
+     * 充值短信条数(带缓存更新)
+     * @param companyId 公司ID
+     * @param number 充值条数
+     * @return 是否成功
+     */
+    boolean rechargeSms(Long companyId, int number);
 }
 }

+ 164 - 0
fs-service/src/main/java/com/fs/company/service/impl/CompanySmsServiceImpl.java

@@ -1,13 +1,17 @@
 package com.fs.company.service.impl;
 package com.fs.company.service.impl;
 
 
 import java.util.List;
 import java.util.List;
+import java.util.concurrent.TimeUnit;
 
 
+import com.fs.common.core.redis.RedisCache;
 import com.fs.company.vo.CompanySmsListVO;
 import com.fs.company.vo.CompanySmsListVO;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 import com.fs.company.mapper.CompanySmsMapper;
 import com.fs.company.mapper.CompanySmsMapper;
 import com.fs.company.domain.CompanySms;
 import com.fs.company.domain.CompanySms;
 import com.fs.company.service.ICompanySmsService;
 import com.fs.company.service.ICompanySmsService;
+import org.springframework.transaction.annotation.Transactional;
 
 
 /**
 /**
  * 公司短信Service业务层处理
  * 公司短信Service业务层处理
@@ -15,12 +19,23 @@ import com.fs.company.service.ICompanySmsService;
  * @author fs
  * @author fs
  * @date 2023-01-09
  * @date 2023-01-09
  */
  */
+@Slf4j
 @Service
 @Service
 public class CompanySmsServiceImpl implements ICompanySmsService
 public class CompanySmsServiceImpl implements ICompanySmsService
 {
 {
     @Autowired
     @Autowired
     private CompanySmsMapper companySmsMapper;
     private CompanySmsMapper companySmsMapper;
 
 
+    @Autowired
+    private RedisCache redisCache;
+
+    // 公司短信余额缓存key前缀
+    private static final String BALANCE_KEY_PREFIX = "sms:balance:";
+    // 缓存有效期
+    private static final int CACHE_EXPIRE_SECONDS = 18000; // 300分钟
+    // 最大重试次数
+    private static final int MAX_RETRY_TIMES = 3;
+
     /**
     /**
      * 查询公司短信
      * 查询公司短信
      *
      *
@@ -119,4 +134,153 @@ public class CompanySmsServiceImpl implements ICompanySmsService
     public CompanySms selectCompanySmsByCompanyIdForUpdate(Long companyId) {
     public CompanySms selectCompanySmsByCompanyIdForUpdate(Long companyId) {
         return companySmsMapper.selectCompanySmsByCompanyIdForUpdate(companyId);
         return companySmsMapper.selectCompanySmsByCompanyIdForUpdate(companyId);
     }
     }
+
+    // ========== 查询方法 ==========
+    @Override
+    public CompanySms selectCompanySms(Long companyId) {
+        return companySmsMapper.selectCompanySmsByCompanyId(companyId);
+    }
+
+    @Override
+    public CompanySms selectCompanySmsForUpdate(Long companyId) {
+        return companySmsMapper.selectCompanySmsByCompanyIdForUpdate(companyId);
+    }
+
+    // ========== 扣减/增加方法 ==========
+    @Override
+    public int decrementRemainSmsCount(Long companyId, int number) {
+        return companySmsMapper.decrementRemainSmsCount(companyId, number);
+    }
+
+    @Override
+    public int incrementRemainSmsCount(Long companyId, int number) {
+        return companySmsMapper.incrementRemainSmsCount(companyId, number);
+    }
+
+    @Override
+    public int decrementRemainSmsCountWithVersion(Long companyId, int number, Long currentVersion) {
+        return companySmsMapper.decrementRemainSmsCountWithVersion(companyId, number, currentVersion);
+    }
+
+    @Override
+    public int incrementRemainSmsCountWithVersion(Long companyId, int number, Long currentVersion) {
+        return companySmsMapper.incrementRemainSmsCountWithVersion(companyId, number, currentVersion);
+    }
+
+    // ========== 缓存方法 ==========
+    @Override
+    public Long getBalance(Long companyId) {
+        String cacheKey = BALANCE_KEY_PREFIX + companyId;
+        try {
+            Long balance = redisCache.getCacheObject(cacheKey);
+            if (balance != null) {
+                log.debug("从缓存获取余额成功, companyId={}, balance={}", companyId, balance);
+                return balance;
+            }
+            CompanySms companySms = companySmsMapper.selectCompanySmsByCompanyId(companyId);
+            if (companySms == null) {
+                return null;
+            }
+            Long realBalance = companySms.getRemainSmsCount();
+            redisCache.setCacheObject(cacheKey, realBalance, CACHE_EXPIRE_SECONDS, TimeUnit.SECONDS);
+            log.debug("从数据库加载余额并写入缓存, companyId={}, balance={}", companyId, realBalance);
+            return realBalance;
+        } catch (Exception e) {
+            log.error("获取余额缓存失败, companyId={}", companyId, e);
+            CompanySms companySms = companySmsMapper.selectCompanySmsByCompanyId(companyId);
+            return companySms != null ? companySms.getRemainSmsCount() : null;
+        }
+    }
+
+    @Override
+    public void updateCacheBalance(Long companyId, int delta) {
+        String cacheKey = BALANCE_KEY_PREFIX + companyId;
+        String lockKey = "sms:balance:lock:" + companyId;
+
+        try {
+            Boolean locked = redisCache.setIfAbsent(lockKey, "1", 3, TimeUnit.SECONDS);
+
+            if (Boolean.TRUE.equals(locked)) {
+                try {
+                    Long currentBalance = redisCache.getCacheObject(cacheKey);
+                    if (currentBalance != null) {
+                        Long newBalance = currentBalance + delta;
+                        redisCache.setCacheObject(cacheKey, newBalance, CACHE_EXPIRE_SECONDS, TimeUnit.SECONDS);
+                        log.debug("更新缓存余额成功, companyId={}, old={}, new={}, delta={}",
+                                companyId, currentBalance, newBalance, delta);
+                    } else {
+                        log.debug("缓存不存在,跳过更新, companyId={}", companyId);
+                    }
+                } finally {
+                    redisCache.deleteObject(lockKey);
+                }
+            } else {
+                log.warn("获取缓存锁失败,删除缓存, companyId={}", companyId);
+                redisCache.deleteObject(cacheKey);
+            }
+        } catch (Exception e) {
+            log.error("更新缓存余额失败, companyId={}, delta={}", companyId, delta, e);
+            redisCache.deleteObject(cacheKey);
+        }
+    }
+
+    @Override
+    public void evictBalance(Long companyId) {
+        String cacheKey = BALANCE_KEY_PREFIX + companyId;
+        try {
+            redisCache.deleteObject(cacheKey);
+            log.debug("删除余额缓存成功, companyId={}", companyId);
+        } catch (Exception e) {
+            log.error("删除余额缓存失败, companyId={}", companyId, e);
+        }
+    }
+
+    // ========== 充值方法 ==========
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public boolean rechargeSms(Long companyId, int number) {
+        if (number <= 0) {
+            log.warn("充值条数无效, companyId={}, number={}", companyId, number);
+            return false;
+        }
+
+        int maxRetries = 3;
+
+        for (int retryCount = 0; retryCount < maxRetries; retryCount++) {
+            // 获取最新数据(含version)
+            CompanySms latestSms = selectCompanySms(companyId);
+            if (latestSms == null) {
+                log.error("充值失败,公司短信配置不存在, companyId={}", companyId);
+                return false;
+            }
+
+            Long version = latestSms.getVersion() != null ? latestSms.getVersion() : 0L;
+            int updateCount = companySmsMapper.incrementRemainSmsCountWithVersion(companyId, number, version);
+
+            if (updateCount > 0) {
+                // 充值成功,更新缓存
+                updateCacheBalance(companyId, number);
+                log.info("充值成功, companyId={}, number={}, newBalance={}",
+                        companyId, number, latestSms.getRemainSmsCount() + number);
+                return true;
+            }
+
+            log.warn("乐观锁充值失败,第{}次重试, companyId={}", retryCount + 1, companyId);
+
+            if (retryCount == maxRetries - 1) {
+                log.error("充值失败,重试次数用尽, companyId={}, number={}", companyId, number);
+                return false;
+            }
+
+            try {
+                Thread.sleep(50L * (retryCount + 1));
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                log.error("充值被中断, companyId={}", companyId);
+                return false;
+            }
+        }
+
+        return false;
+    }
 }
 }

+ 1 - 0
fs-service/src/main/java/com/fs/course/config/CourseConfig.java

@@ -28,6 +28,7 @@ public class CourseConfig implements Serializable {
     private String realLinkH5LiveName;//H5通用直播域名
     private String realLinkH5LiveName;//H5通用直播域名
     private String authDomainName;//网页授权域名
     private String authDomainName;//网页授权域名
     private String smsDomainName;//短信推送域名
     private String smsDomainName;//短信推送域名
+    private String smsAcquisitionName;//短信推送域名
     private String smsDomain;//短信推送域名
     private String smsDomain;//短信推送域名
     private String mpAppId;//看课公众号APPID
     private String mpAppId;//看课公众号APPID
     private String registerDomainName;//注册域名
     private String registerDomainName;//注册域名

+ 33 - 0
fs-service/src/main/java/com/fs/qw/bo/SendMsgLogBo.java

@@ -0,0 +1,33 @@
+package com.fs.qw.bo;
+
+import lombok.Data;
+
+
+@Data
+public class SendMsgLogBo {
+
+    //短信发送记录id
+    private Long companySmsLogsId;
+
+    //获客链接主键
+    private Long qwAcquisitionId;
+
+    //公司id
+    private Long companyId;
+
+    //客户id
+    private Long customerId;
+
+    //公司用户id
+    private Long companyUserId;
+
+    //外部联系人id
+    private Long externalId;
+
+    // 课程id
+    private Long courseId;
+
+    // 视频id
+    private Long videoId;
+
+}

+ 77 - 0
fs-service/src/main/java/com/fs/qw/domain/QwAcquisitionLinkInfo.java

@@ -0,0 +1,77 @@
+package com.fs.qw.domain;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import lombok.Data;
+
+/**
+ * 获客链接-号码链接生成记录对象 qw_acquisition_link_info
+ *
+ * @author fs
+ * @date 2026-03-27
+ */
+@Data
+public class QwAcquisitionLinkInfo {
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 主键ID
+     */
+    private Long id;
+
+    /**
+     * 获客链接管理主键ID
+     */
+    private Long qwAcquisitionAssistantId;
+
+    /**
+     * 完整链接
+     */
+    private String link;
+
+    /**
+     * 客户电话
+     */
+    private String phone;
+
+    /**
+     * 随机字符串
+     */
+    private String randomStr;
+
+    /**
+     * 创建人
+     */
+    private Long createBy;
+
+    /**
+     * 创建时间
+     * */
+    private String createTime;
+
+    /**
+     * 更新人
+     */
+    private Long updateBy;
+
+    /**
+     * 更新时间
+     * */
+    private String updateTime;
+
+    /**
+     * 备注
+     * */
+    private String remark;
+
+    /**
+     * 链接名称
+     * */
+    @TableField(exist = false)
+    private String linkName;
+
+    /**
+     * 创建人名称
+     * */
+    @TableField(exist = false)
+    private String createName;
+}

+ 55 - 0
fs-service/src/main/java/com/fs/qw/domain/QwAcquisitionSendMsgLog.java

@@ -0,0 +1,55 @@
+package com.fs.qw.domain;
+
+import com.fs.common.annotation.Excel;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+
+/**
+ * 企微-获客链接短信发送记录日志对象 qw_acquisition_send_msg_log
+ *
+ * @author fs
+ * @date 2026-03-24
+ */
+@Data
+public class QwAcquisitionSendMsgLog extends BaseEntity {
+    private static final long serialVersionUID = 1L;
+
+    /** 主键ID */
+    private Long id;
+
+    /** 短信发送记录主键id */
+    @Excel(name = "短信发送记录主键id")
+    private Long companySmsLogsId;
+
+    /** 获客链接管理ID */
+    @Excel(name = "获客链接管理ID")
+    private Long qwAcquisitionId;
+
+    /** 客户电话 */
+    @Excel(name = "客户电话")
+    private String phone;
+
+    /** 短信数量,超过67个字符为2条短信 */
+    @Excel(name = "短信数量")
+    private Integer number;
+
+    /** 短信模板id */
+    @Excel(name = "短信模板id")
+    private Long tempId;
+
+    /** 短信服务商类型 */
+    @Excel(name = "短信服务商类型")
+    private String type;
+
+    /** 短信内容 */
+    @Excel(name = "短信内容")
+    private String content;
+
+    /** 发送结果 */
+    @Excel(name = "发送结果")
+    private String result;
+
+    /** 备注 */
+    @Excel(name = "备注")
+    private String remark;
+}

+ 64 - 0
fs-service/src/main/java/com/fs/qw/domain/QwCourseLinkSendMsgLog.java

@@ -0,0 +1,64 @@
+package com.fs.qw.domain;
+
+import com.fs.common.annotation.Excel;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+
+/**
+ * 企微-发送看课链接短信记录日志对象 qw_course_link_send_msg_log
+ *
+ * @author fs
+ * @date 2026-03-24
+ */
+@Data
+public class QwCourseLinkSendMsgLog extends BaseEntity
+{
+    private static final long serialVersionUID = 1L;
+
+    /** 主键ID */
+    private Long id;
+
+    /** 短信发送记录主键id */
+    @Excel(name = "短信发送记录主键id")
+    private Long companySmsLogsId;
+
+    /** 课程id */
+    @Excel(name = "课程id")
+    private Long courseId;
+
+    /** 视频id */
+    @Excel(name = "视频id")
+    private Long videoId;
+
+    /** 短信数量,超过67个字符为2条短信 */
+    @Excel(name = "短信数量")
+    private Integer number;
+
+    /** 短信模板id */
+    @Excel(name = "短信模板id")
+    private Long tempId;
+
+    /** 外部联系人ID */
+    @Excel(name = "外部联系人ID")
+    private Long externalContactId;
+
+    /** 短信服务商类型 */
+    @Excel(name = "短信服务商类型")
+    private String type;
+
+    /** 客户电话 */
+    @Excel(name = "客户电话")
+    private String phone;
+
+    /** 短信内容 */
+    @Excel(name = "短信内容")
+    private String content;
+
+    /** 发送结果 */
+    @Excel(name = "发送结果")
+    private String result;
+
+    /** 备注 */
+    @Excel(name = "备注")
+    private String remark;
+}

+ 32 - 0
fs-service/src/main/java/com/fs/qw/dto/BatchAddAcquisitionLinkDTO.java

@@ -0,0 +1,32 @@
+package com.fs.qw.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class BatchAddAcquisitionLinkDTO {
+
+    /** 公司id */
+    private Long companyId;
+
+    /**
+     * 创建人
+     */
+    private Long createBy;
+
+    /**
+     * 获客链接管理主键ID
+     */
+    private Long qwAcquisitionAssistantId;
+
+    /**
+     * 获客链接管理url
+     */
+    private String qwAcquisitionAssistantUrl;
+
+    /**
+     * 客户电话列表
+     */
+    private List<String> phoneList;
+}

+ 45 - 0
fs-service/src/main/java/com/fs/qw/dto/IpadBlindAddDto.java

@@ -0,0 +1,45 @@
+package com.fs.qw.dto;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+import javax.validation.constraints.Pattern;
+
+/**
+ * iPad盲加请求参数DTO
+ */
+@Data
+public class IpadBlindAddDto {
+
+    /**
+     * 链接ID(企微返回的linkId)
+     */
+    @NotBlank(message = "链接ID不能为空")
+    private String linkId;
+
+    /**
+     * 所在主体id
+     */
+    @NotBlank(message = "企业主体ID不能为空")
+    private String corpId;
+
+    /**
+     * 获客链接主键ID
+     */
+    @NotNull(message = "获客链接ID不能为空")
+    private Long qwAcquisitionAssistantId;
+
+    /**
+     * 选中的成员ID(本地数据库ID)
+     */
+    @NotNull(message = "成员ID不能为空")
+    private Long userId;
+
+    /**
+     * 手机号码
+     */
+    @NotBlank(message = "手机号码不能为空")
+    @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号码格式不正确")
+    private String phone;
+}

+ 33 - 0
fs-service/src/main/java/com/fs/qw/enums/SmsLogType.java

@@ -0,0 +1,33 @@
+package com.fs.qw.enums;
+
+/**
+ * 短信发送日志类型枚举
+ * 用于在通用发送接口中区分不同的日志记录行为
+ */
+public enum SmsLogType {
+    /**
+     * 获客链接短信
+     */
+    ACQUISITION_LINK("ACQUISITION_LINK", "获客链接短信"),
+
+    /**
+     * 看课链接短信
+     */
+    COURSE_LINK("COURSE_LINK", "看课链接短信");
+
+    private final String code;
+    private final String info;
+
+    SmsLogType(String code, String info) {
+        this.code = code;
+        this.info = info;
+    }
+
+    public String getCode() {
+        return code;
+    }
+
+    public String getInfo() {
+        return info;
+    }
+}

+ 91 - 0
fs-service/src/main/java/com/fs/qw/mapper/QwAcquisitionLinkInfoMapper.java

@@ -0,0 +1,91 @@
+package com.fs.qw.mapper;
+
+import com.fs.qw.domain.QwAcquisitionLinkInfo;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 获客链接-号码链接生成记录Mapper接口
+ *
+ * @author fs
+ * @date 2026-03-27
+ */
+public interface QwAcquisitionLinkInfoMapper
+{
+    /**
+     * 查询获客链接-号码链接生成记录
+     *
+     * @param id 获客链接-号码链接生成记录主键
+     * @return 获客链接-号码链接生成记录
+     */
+    public QwAcquisitionLinkInfo selectQwAcquisitionLinkInfoById(Long id);
+
+    /**
+     * 查询获客链接-号码链接生成记录列表
+     *
+     * @param qwAcquisitionLinkInfo 获客链接-号码链接生成记录
+     * @return 获客链接-号码链接生成记录集合
+     */
+    public List<QwAcquisitionLinkInfo> selectQwAcquisitionLinkInfoList(QwAcquisitionLinkInfo qwAcquisitionLinkInfo);
+
+    /**
+     * 新增获客链接-号码链接生成记录
+     *
+     * @param qwAcquisitionLinkInfo 获客链接-号码链接生成记录
+     * @return 结果
+     */
+    public int insertQwAcquisitionLinkInfo(QwAcquisitionLinkInfo qwAcquisitionLinkInfo);
+
+    /**
+     * 修改获客链接-号码链接生成记录
+     *
+     * @param qwAcquisitionLinkInfo 获客链接-号码链接生成记录
+     * @return 结果
+     */
+    public int updateQwAcquisitionLinkInfo(QwAcquisitionLinkInfo qwAcquisitionLinkInfo);
+
+    /**
+     * 删除获客链接-号码链接生成记录
+     *
+     * @param id 获客链接-号码链接生成记录主键
+     * @return 结果
+     */
+    public int deleteQwAcquisitionLinkInfoById(Long id);
+
+    /**
+     * 批量删除获客链接-号码链接生成记录
+     *
+     * @param ids 需要删除的数据主键集合
+     * @return 结果
+     */
+    public int deleteQwAcquisitionLinkInfoByIds(Long[] ids);
+
+    /**
+     * 批量删除获客链接-号码链接生成记录
+     *
+     * @param qwAcquisitionAssistantIds 需要删除的获客链接管理主键ID集合
+     * @return 结果
+     */
+    public int deleteQwAcquisitionLinkInfoByQwAcquisitionAssistantIds(@Param("qwAcquisitionAssistantIds") Long[] qwAcquisitionAssistantIds);
+
+    /**
+     * 查询已存在的随机字符串
+     * */
+    List<String> selectAllRandomStr();
+
+    /**
+     * 根据随机字符串查询获客链接
+     * */
+    String selectQwAcquisitionUrlByRandomStr(String randomStr);
+
+    /**
+     *  根据主键id列表批量查询获客链接记录列表
+     * */
+    List<QwAcquisitionLinkInfo> selectAcquisitionLinkInfoListByIds(@Param("ids")Long[] ids);
+
+    /**
+     *  根据获客链接管理主键id列表批量查询获客链接记录列表
+     * */
+    List<QwAcquisitionLinkInfo> selectAcquisitionLinkInfoListByAcquisitionAssistantIds(@Param("qwAcquisitionAssistantIds")Long[] qwAcquisitionAssistantIds);
+}

+ 62 - 0
fs-service/src/main/java/com/fs/qw/mapper/QwAcquisitionSendMsgLogMapper.java

@@ -0,0 +1,62 @@
+package com.fs.qw.mapper;
+
+import com.fs.qw.domain.QwAcquisitionSendMsgLog;
+
+import java.util.List;
+
+/**
+ * 企微-获客链接短信发送记录日志Mapper接口
+ *
+ * @author fs
+ * @date 2026-03-24
+ */
+public interface QwAcquisitionSendMsgLogMapper
+{
+    /**
+     * 查询企微-获客链接短信发送记录日志
+     *
+     * @param id 企微-获客链接短信发送记录日志主键
+     * @return 企微-获客链接短信发送记录日志
+     */
+    public QwAcquisitionSendMsgLog selectQwAcquisitionSendMsgLogById(Long id);
+
+    /**
+     * 查询企微-获客链接短信发送记录日志列表
+     *
+     * @param qwAcquisitionSendMsgLog 企微-获客链接短信发送记录日志
+     * @return 企微-获客链接短信发送记录日志集合
+     */
+    public List<QwAcquisitionSendMsgLog> selectQwAcquisitionSendMsgLogList(QwAcquisitionSendMsgLog qwAcquisitionSendMsgLog);
+
+    /**
+     * 新增企微-获客链接短信发送记录日志
+     *
+     * @param qwAcquisitionSendMsgLog 企微-获客链接短信发送记录日志
+     * @return 结果
+     */
+    public int insertQwAcquisitionSendMsgLog(QwAcquisitionSendMsgLog qwAcquisitionSendMsgLog);
+
+    /**
+     * 修改企微-获客链接短信发送记录日志
+     *
+     * @param qwAcquisitionSendMsgLog 企微-获客链接短信发送记录日志
+     * @return 结果
+     */
+    public int updateQwAcquisitionSendMsgLog(QwAcquisitionSendMsgLog qwAcquisitionSendMsgLog);
+
+    /**
+     * 删除企微-获客链接短信发送记录日志
+     *
+     * @param id 企微-获客链接短信发送记录日志主键
+     * @return 结果
+     */
+    public int deleteQwAcquisitionSendMsgLogById(Long id);
+
+    /**
+     * 批量删除企微-获客链接短信发送记录日志
+     *
+     * @param ids 需要删除的数据主键集合
+     * @return 结果
+     */
+    public int deleteQwAcquisitionSendMsgLogByIds(Long[] ids);
+}

+ 62 - 0
fs-service/src/main/java/com/fs/qw/mapper/QwCourseLinkSendMsgLogMapper.java

@@ -0,0 +1,62 @@
+package com.fs.qw.mapper;
+
+import com.fs.qw.domain.QwCourseLinkSendMsgLog;
+
+import java.util.List;
+
+/**
+ * 企微-发送看课链接短信记录日志Mapper接口
+ *
+ * @author fs
+ * @date 2026-03-24
+ */
+public interface QwCourseLinkSendMsgLogMapper
+{
+    /**
+     * 查询企微-发送看课链接短信记录日志
+     *
+     * @param id 企微-发送看课链接短信记录日志主键
+     * @return 企微-发送看课链接短信记录日志
+     */
+    public QwCourseLinkSendMsgLog selectQwCourseLinkSendMsgLogById(Long id);
+
+    /**
+     * 查询企微-发送看课链接短信记录日志列表
+     *
+     * @param qwCourseLinkSendMsgLog 企微-发送看课链接短信记录日志
+     * @return 企微-发送看课链接短信记录日志集合
+     */
+    public List<QwCourseLinkSendMsgLog> selectQwCourseLinkSendMsgLogList(QwCourseLinkSendMsgLog qwCourseLinkSendMsgLog);
+
+    /**
+     * 新增企微-发送看课链接短信记录日志
+     *
+     * @param qwCourseLinkSendMsgLog 企微-发送看课链接短信记录日志
+     * @return 结果
+     */
+    public int insertQwCourseLinkSendMsgLog(QwCourseLinkSendMsgLog qwCourseLinkSendMsgLog);
+
+    /**
+     * 修改企微-发送看课链接短信记录日志
+     *
+     * @param qwCourseLinkSendMsgLog 企微-发送看课链接短信记录日志
+     * @return 结果
+     */
+    public int updateQwCourseLinkSendMsgLog(QwCourseLinkSendMsgLog qwCourseLinkSendMsgLog);
+
+    /**
+     * 删除企微-发送看课链接短信记录日志
+     *
+     * @param id 企微-发送看课链接短信记录日志主键
+     * @return 结果
+     */
+    public int deleteQwCourseLinkSendMsgLogById(Long id);
+
+    /**
+     * 批量删除企微-发送看课链接短信记录日志
+     *
+     * @param ids 需要删除的数据主键集合
+     * @return 结果
+     */
+    public int deleteQwCourseLinkSendMsgLogByIds(Long[] ids);
+}

+ 106 - 0
fs-service/src/main/java/com/fs/qw/service/IQwAcquisitionLinkInfoService.java

@@ -0,0 +1,106 @@
+package com.fs.qw.service;
+
+import java.util.List;
+
+import com.fs.common.core.domain.R;
+import com.fs.his.dto.SendResultDetailDTO;
+import com.fs.qw.bo.SendMsgLogBo;
+import com.fs.qw.domain.QwAcquisitionLinkInfo;
+import com.fs.qw.dto.BatchAddAcquisitionLinkDTO;
+import com.fs.qw.dto.IpadBlindAddDto;
+
+/**
+ * 获客链接-号码链接生成记录Service接口
+ *
+ * @author fs
+ * @date 2026-03-27
+ */
+public interface IQwAcquisitionLinkInfoService
+{
+    /**
+     * 查询获客链接-号码链接生成记录
+     *
+     * @param id 获客链接-号码链接生成记录主键
+     * @return 获客链接-号码链接生成记录
+     */
+    public QwAcquisitionLinkInfo selectQwAcquisitionLinkInfoById(Long id);
+
+    /**
+     * 查询获客链接-号码链接生成记录列表
+     *
+     * @param qwAcquisitionLinkInfo 获客链接-号码链接生成记录
+     * @return 获客链接-号码链接生成记录集合
+     */
+    public List<QwAcquisitionLinkInfo> selectQwAcquisitionLinkInfoList(QwAcquisitionLinkInfo qwAcquisitionLinkInfo);
+
+    /**
+     * 新增获客链接-号码链接生成记录
+     *
+     * @param qwAcquisitionLinkInfo 获客链接-号码链接生成记录
+     * @return 结果
+     */
+    public int insertQwAcquisitionLinkInfo(QwAcquisitionLinkInfo qwAcquisitionLinkInfo);
+
+    /**
+     * 修改获客链接-号码链接生成记录
+     *
+     * @param qwAcquisitionLinkInfo 获客链接-号码链接生成记录
+     * @return 结果
+     */
+    public int updateQwAcquisitionLinkInfo(QwAcquisitionLinkInfo qwAcquisitionLinkInfo);
+
+    /**
+     * 批量删除获客链接-号码链接生成记录
+     *
+     * @param ids 需要删除的获客链接-号码链接生成记录主键集合
+     * @return 结果
+     */
+    public int deleteQwAcquisitionLinkInfoByIds(Long[] ids);
+
+    /**
+     * 批量删除获客链接-号码链接生成记录
+     *
+     * @param qwAcquisitionAssistantIds 需要删除的获客链接管理主键ID集合
+     * @return 结果
+     */
+    public int deleteQwAcquisitionLinkInfoByQwAcquisitionAssistantIds(Long[] qwAcquisitionAssistantIds);
+
+    /**
+     * 删除获客链接-号码链接生成记录信息
+     *
+     * @param id 获客链接-号码链接生成记录主键
+     * @return 结果
+     */
+    public int deleteQwAcquisitionLinkInfoById(Long id);
+
+    /**
+     * 发送获客链接短信
+     *
+     * @param phone 待发送短信的手机号
+     * @param qwAcquisitionId 获客链接Id
+     * @return 结果
+     */
+    public SendResultDetailDTO sendMessageLink(String phone, Long qwAcquisitionId, SendMsgLogBo sendMsgLogBo);
+
+    /**
+     * 根据页面路径参数查询完整获客链接url
+     * */
+    public String selectQwAcquisitionUrlByRandomStr(String randomStr);
+
+
+    /**
+     * 批量生成获客链接短链
+     * */
+    public int batchCreateMessageLink(BatchAddAcquisitionLinkDTO batchAddAcquisitionLinkDTO);
+
+    /**
+     * 根据手机号生成单个获客链接
+     * */
+    String extractLink(Long qwAcquisitionAssistantId, String originalPhone, String originalLink,Long createBy);
+    R extractLinkNol(Long qwAcquisitionAssistantId, String originalLink, Long createBy);
+
+    /**
+     * iPad盲加好友
+     * */
+    void ipadBlindAdd(IpadBlindAddDto dto,SendMsgLogBo sendMsgLogBo);
+}

+ 823 - 0
fs-service/src/main/java/com/fs/qw/service/impl/QwAcquisitionLinkInfoServiceImpl.java

@@ -0,0 +1,823 @@
+package com.fs.qw.service.impl;
+
+
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+
+import com.fs.common.core.domain.R;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.exception.CustomException;
+import com.fs.common.service.ISmsService;
+import com.fs.common.utils.DateUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.company.domain.CompanySms;
+import com.fs.company.domain.CompanySmsTemp;
+import com.fs.company.service.ICompanySmsService;
+import com.fs.company.service.ICompanySmsTempService;
+import com.fs.his.dto.SendResultDetailDTO;
+import com.fs.qw.bo.SendMsgLogBo;
+import com.fs.qw.domain.QwAcquisitionAssistant;
+import com.fs.qw.domain.QwUser;
+import com.fs.qw.dto.BatchAddAcquisitionLinkDTO;
+import com.fs.qw.dto.IpadBlindAddDto;
+import com.fs.qw.enums.SmsLogType;
+import com.fs.qw.mapper.QwAcquisitionAssistantMapper;
+import com.fs.qw.mapper.QwUserMapper;
+import com.fs.qw.param.QwUserListParam;
+import com.fs.qw.utils.UniqueStringUtil;
+import com.fs.qw.vo.QwUserVO;
+import com.fs.wxwork.dto.WxAddSearchDTO;
+import com.fs.wxwork.dto.WxSearchContactDTO;
+import com.fs.wxwork.dto.WxSearchContactResp;
+import com.fs.wxwork.dto.WxWorkResponseDTO;
+import com.fs.wxwork.service.WxWorkService;
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.collections4.CollectionUtils;
+import org.springframework.beans.BeanUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.ApplicationContext;
+import org.springframework.stereotype.Service;
+import com.fs.qw.mapper.QwAcquisitionLinkInfoMapper;
+import com.fs.qw.domain.QwAcquisitionLinkInfo;
+import com.fs.qw.service.IQwAcquisitionLinkInfoService;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.PostConstruct;
+
+import static com.fs.his.utils.PhoneUtil.encryptPhone;
+
+/**
+ * 获客链接-号码链接生成记录Service业务层处理
+ *
+ * @author fs
+ * @date 2026-03-27
+ */
+@Slf4j
+@Service
+public class QwAcquisitionLinkInfoServiceImpl implements IQwAcquisitionLinkInfoService
+{
+    @Autowired
+    private ApplicationContext applicationContext;
+
+    @Autowired
+    private ISmsService smsService;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    @Autowired
+    private ICompanySmsTempService smsTempService;
+
+    @Autowired
+    private ICompanySmsService companySmsService;
+
+    @Autowired
+    private QwAcquisitionAssistantMapper acquisitionAssistantMapper;
+
+    @Autowired
+    private QwAcquisitionLinkInfoMapper qwAcquisitionLinkInfoMapper;
+
+    @Autowired
+    private QwUserMapper qwUserMapper;
+
+    @Autowired
+    private WxWorkService wxWorkService;
+
+    @PostConstruct
+    public void init() {
+        // 如果自动注入失败,手动获取
+        if (wxWorkService == null) {
+            wxWorkService = applicationContext.getBean(WxWorkService.class);
+        }
+    }
+    //拼接电话号码的链接后缀(这个后面拼接加密后的手机字符串)
+    private static final String  LINK_SUFFIX = "?customer_channel=up:";
+    private static final String  LINK_SUFFIX_NOL = "?customer_channel=link:";
+
+    // 企微加好友链接-url的key
+    private static final String QW_FRIEND_LINK_URL_KEY = "qw_friend_link_url:";
+
+    //获客链接短信模板code
+    private static final String  SMS_LINK_TEMPLATE_CODE = "获客链接短信模板";
+
+    //访问链接域名
+    private static final String  LINK_DOMAIN = "https://c.ysyd.top/";
+    /**
+     * 查询获客链接-号码链接生成记录
+     *
+     * @param id 获客链接-号码链接生成记录主键
+     * @return 获客链接-号码链接生成记录
+     */
+    @Override
+    public QwAcquisitionLinkInfo selectQwAcquisitionLinkInfoById(Long id)
+    {
+        return qwAcquisitionLinkInfoMapper.selectQwAcquisitionLinkInfoById(id);
+    }
+
+    /**
+     * 查询获客链接-号码链接生成记录列表
+     *
+     * @param qwAcquisitionLinkInfo 获客链接-号码链接生成记录
+     * @return 获客链接-号码链接生成记录
+     */
+    @Override
+    public List<QwAcquisitionLinkInfo> selectQwAcquisitionLinkInfoList(QwAcquisitionLinkInfo qwAcquisitionLinkInfo)
+    {
+        List<QwAcquisitionLinkInfo> resultList = qwAcquisitionLinkInfoMapper.selectQwAcquisitionLinkInfoList(qwAcquisitionLinkInfo);
+        if (CollectionUtils.isEmpty(resultList)){
+            return Collections.emptyList();
+        }
+        for (QwAcquisitionLinkInfo item : resultList) {
+            if (item.getPhone() != null) {
+                item.setPhone(item.getPhone().replaceAll("(\\d{3})\\d*(\\d{4})", "$1****$2"));
+            }
+        }
+        return resultList;
+    }
+
+    /**
+     * 新增获客链接-号码链接生成记录
+     *
+     * @param qwAcquisitionLinkInfo 获客链接-号码链接生成记录
+     * @return 结果
+     */
+    @Override
+    public int insertQwAcquisitionLinkInfo(QwAcquisitionLinkInfo qwAcquisitionLinkInfo)
+    {
+        return qwAcquisitionLinkInfoMapper.insertQwAcquisitionLinkInfo(qwAcquisitionLinkInfo);
+    }
+
+    /**
+     * 修改获客链接-号码链接生成记录
+     *
+     * @param qwAcquisitionLinkInfo 获客链接-号码链接生成记录
+     * @return 结果
+     */
+    @Override
+    public int updateQwAcquisitionLinkInfo(QwAcquisitionLinkInfo qwAcquisitionLinkInfo)
+    {
+        //TODO 如果放开编辑就需要更新缓存
+        return qwAcquisitionLinkInfoMapper.updateQwAcquisitionLinkInfo(qwAcquisitionLinkInfo);
+    }
+
+    /**
+     * 批量删除获客链接-号码链接生成记录
+     *
+     * @param ids 需要删除的获客链接-号码链接生成记录主键集合
+     * @return 结果
+     */
+    @Override
+    public int deleteQwAcquisitionLinkInfoByIds(Long[] ids)
+    {
+        if (ids.length == 0){
+            return 0;
+        }
+        List<QwAcquisitionLinkInfo> toBeDeletedList = qwAcquisitionLinkInfoMapper.selectAcquisitionLinkInfoListByIds(ids);
+        //循环删除 Redis 缓存
+        batchDeleteLinkCatch(toBeDeletedList);
+        return qwAcquisitionLinkInfoMapper.deleteQwAcquisitionLinkInfoByIds(ids);
+    }
+
+    /**
+     * 删除获客链接-号码链接生成记录信息
+     *
+     * @param qwAcquisitionAssistantIds 需要删除的获客链接管理主键ID集合
+     * @return 结果
+     */
+    @Override
+    public int deleteQwAcquisitionLinkInfoByQwAcquisitionAssistantIds(Long[] qwAcquisitionAssistantIds){
+        if (qwAcquisitionAssistantIds.length == 0){
+            return 0;
+        }
+        List<QwAcquisitionLinkInfo> toBeDeletedList = qwAcquisitionLinkInfoMapper.selectAcquisitionLinkInfoListByAcquisitionAssistantIds(qwAcquisitionAssistantIds);
+        //循环删除 Redis 缓存
+        batchDeleteLinkCatch(toBeDeletedList);
+        return qwAcquisitionLinkInfoMapper.deleteQwAcquisitionLinkInfoByQwAcquisitionAssistantIds(qwAcquisitionAssistantIds);
+    }
+
+    /**
+     * 删除获客链接-号码链接生成记录信息
+     *
+     * @param id 获客链接-号码链接生成记录主键
+     * @return 结果
+     */
+    @Override
+    public int deleteQwAcquisitionLinkInfoById(Long id)
+    {
+        QwAcquisitionLinkInfo existAcquisitionLinkInfo = qwAcquisitionLinkInfoMapper.selectQwAcquisitionLinkInfoById(id);
+        if (existAcquisitionLinkInfo==null){
+            throw new CustomException("数据不存在");
+        }
+        // ========== 删除Redis缓存 ==========
+        try {
+            // 1. 删除pageParam对应的URL缓存
+            if (StringUtils.isNotEmpty(existAcquisitionLinkInfo.getRandomStr())) {
+                String urlCacheKey = QW_FRIEND_LINK_URL_KEY + existAcquisitionLinkInfo.getRandomStr();
+                redisCache.deleteObject(urlCacheKey);
+                log.info("删除获客链接URL缓存成功, pageParam: {}, key: {}",
+                        existAcquisitionLinkInfo.getRandomStr(), urlCacheKey);
+            }
+        } catch (Exception e) {
+            // 缓存删除失败不应该影响主流程,但需要记录日志
+            log.error("删除获客链接缓存失败, id: {}, qwAcquisitionAssistantId: {}, pageParam: {}",
+                    existAcquisitionLinkInfo.getId(), existAcquisitionLinkInfo.getQwAcquisitionAssistantId(),
+                    existAcquisitionLinkInfo.getRandomStr(), e);
+        }
+        return qwAcquisitionLinkInfoMapper.deleteQwAcquisitionLinkInfoById(id);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public SendResultDetailDTO sendMessageLink(String phone, Long qwAcquisitionId, SendMsgLogBo sendMsgLogBo) {
+        log.info("发送获客链接短信(带随机参数),号码:{}", phone);
+
+        // 1. 获取短信模板
+        CompanySmsTemp temp = smsTempService.selectCompanySmsTempByCode(SMS_LINK_TEMPLATE_CODE);
+        if (temp == null) {
+            log.info("获客链接-未找到短信模板:{}", SMS_LINK_TEMPLATE_CODE);
+            throw new CustomException("获客链接-未找到短信模板");
+        }
+
+        // 2. 获取获客链接信息
+        String originalContent = temp.getContent();
+        QwAcquisitionAssistant acquisitionAssistant = acquisitionAssistantMapper.selectQwAcquisitionAssistantById(qwAcquisitionId);
+        if (acquisitionAssistant == null) {
+            log.info("获客链接-未找到获客链接id:{}", qwAcquisitionId);
+            throw new CustomException("获客链接-未找到获客链接信息");
+        }
+
+        // 3. 生成随机参数并构建短信内容
+        String randomStr = generateUniqueRandomStr();
+        String replaceText = LINK_DOMAIN + randomStr;
+        String content = originalContent.replace("${sms.friendLink}", replaceText);
+
+        // 4. 计算需要的短信条数
+        Integer needCount = calculateSmsCount(content);
+
+        // 5. 【乐观锁扣减,支持重试】
+        int maxRetries = 3;
+
+        for (int retryCount = 0; retryCount < maxRetries; retryCount++) {
+            Long balance = companySmsService.getBalance(2L);
+            if (balance == null || balance < needCount) {
+                log.warn("短信余额不足, companyId=2, balance={}, needCount={}", balance, needCount);
+                return new SendResultDetailDTO(false, "短信余额不足", null);
+            }
+
+            CompanySms latestSms = companySmsService.selectCompanySms(2L);
+            if (latestSms == null) {
+                throw new CustomException("公司短信配置不存在");
+            }
+
+            if (latestSms.getRemainSmsCount() < needCount) {
+                log.warn("短信余额不足, companyId=2, remainCount={}, needCount={}",
+                        latestSms.getRemainSmsCount(), needCount);
+                return new SendResultDetailDTO(false, "短信余额不足", null);
+            }
+
+            Long version = latestSms.getVersion() != null ? latestSms.getVersion() : 0L;
+            int updateCount = companySmsService.decrementRemainSmsCountWithVersion(2L, needCount, version);
+
+            if (updateCount > 0) {
+                log.info("乐观锁扣减成功, needCount={}, version={}", needCount, version);
+                break;
+            }
+
+            log.warn("乐观锁扣减失败,第{}次重试", retryCount + 1);
+
+            if (retryCount == maxRetries - 1) {
+                return new SendResultDetailDTO(false, "系统繁忙,请稍后重试", null);
+            }
+
+            try {
+                Thread.sleep(50L * (retryCount + 1));
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                return new SendResultDetailDTO(false, "操作被中断", null);
+            }
+        }
+
+        // 6. 更新缓存
+        companySmsService.updateCacheBalance(2L, -needCount);
+
+        try {
+            sendMsgLogBo.setQwAcquisitionId(acquisitionAssistant.getId());
+
+            R r = smsService.simpleSmsSend(phone, content, temp, SmsLogType.ACQUISITION_LINK, sendMsgLogBo);
+
+            if (r != null && "200".equals(String.valueOf(r.get("code")))) {
+                addAcquisitionLinkInfo(acquisitionAssistant.getId(), phone, acquisitionAssistant.getUrl(),
+                        randomStr, sendMsgLogBo.getCompanyUserId());
+                log.info("短信发送成功, phone={}, needCount={}", phone, needCount);
+                return new SendResultDetailDTO(true, null, null);
+            } else {
+                String msg = r != null && r.get("msg") != null ? r.get("msg").toString() : "未知错误";
+                log.warn("短信发送失败,退还余额, phone={}, needCount={}", phone, needCount);
+                rollbackBalance(needCount, maxRetries);
+                return new SendResultDetailDTO(false, msg, null);
+            }
+        } catch (Exception e) {
+            log.error("发送异常,退还余额, phone=" + phone, e);
+            rollbackBalance(needCount, maxRetries);
+            return new SendResultDetailDTO(false, e.getMessage(), null);
+        }
+    }
+
+    @Override
+    public String selectQwAcquisitionUrlByRandomStr(String randomStr) {
+        String key = QW_FRIEND_LINK_URL_KEY + randomStr;
+        String fullLink = null;
+
+        try {
+            Object cacheObj = redisCache.getCacheObject(key);
+            if (cacheObj instanceof String) {
+                fullLink = (String) cacheObj;
+                // 处理缓存空值的情况
+                if ("NULL".equals(fullLink)) {
+                    return null;
+                }
+                log.debug("从缓存获取完整获客链接url成功,randomStr:{}", randomStr);
+                return fullLink;
+            }
+        } catch (Exception e) {
+            log.warn("从缓存获取完整获客链接url异常, 将重新获取, randomStr:{}", randomStr);
+        }
+
+        // 缓存中没有,查询数据库
+        fullLink = qwAcquisitionLinkInfoMapper.selectQwAcquisitionUrlByRandomStr(randomStr);
+
+        // 缓存处理(包括空值缓存)
+        if (fullLink == null) {
+            int nullCacheExpire = 10; // 10秒
+            redisCache.setCacheObject(key, "NULL", nullCacheExpire, TimeUnit.SECONDS);
+            log.info("完整获客链接URL不存在,缓存空值10秒, randomStr:{}", randomStr);
+            return null;
+        } else {
+            // 正常值仍缓存2天
+            Integer cacheExpire = 2;
+            redisCache.setCacheObject(key, fullLink, cacheExpire, TimeUnit.DAYS);
+            log.info("完整获客链接URL缓存成功, randomStr:{}", randomStr);
+        }
+
+        return fullLink;
+    }
+
+    @Override
+    //TODO 多数据批量操作时,当前事务还未提交,在短时间内可能会导致第三方回调的时候查不到需要更新的数据,后续优化
+    //@Transactional(rollbackFor = Exception.class)
+    public int batchCreateMessageLink(BatchAddAcquisitionLinkDTO batchAddAcquisitionLinkDTO) {
+        Long qwAcquisitionId = batchAddAcquisitionLinkDTO.getQwAcquisitionAssistantId();
+        List<String> phoneList = batchAddAcquisitionLinkDTO.getPhoneList();
+
+        if (CollectionUtils.isEmpty(phoneList)) {
+            log.warn("批量发送短信,手机号列表为空");
+            return 0;
+        }
+
+        // 1. 获取短信模板
+        CompanySmsTemp temp = smsTempService.selectCompanySmsTempByCode(SMS_LINK_TEMPLATE_CODE);
+        if (temp == null) {
+            log.info("获客链接-未找到短信模板:{}", SMS_LINK_TEMPLATE_CODE);
+            throw new CustomException("获客链接-未找到短信模板");
+        }
+
+        // 2. 获取获客链接信息
+        QwAcquisitionAssistant acquisitionAssistant = acquisitionAssistantMapper.selectQwAcquisitionAssistantById(qwAcquisitionId);
+        if (acquisitionAssistant == null) {
+            log.info("获客链接-未找到获客链接id:{}", qwAcquisitionId);
+            throw new CustomException("获客链接-未找到获客链接信息");
+        }
+
+        String originalContent = temp.getContent();
+        SendMsgLogBo baseSendMsgLogBo = new SendMsgLogBo();
+        baseSendMsgLogBo.setCompanyUserId(batchAddAcquisitionLinkDTO.getCreateBy());
+        baseSendMsgLogBo.setQwAcquisitionId(acquisitionAssistant.getId());
+
+        // 3. 为每个手机号预生成链接和短信内容
+        List<PhoneMessageInfo> phoneMessageList = new ArrayList<>();
+        for (String phone : phoneList) {
+            String randomStr = generateUniqueRandomStr();
+            String replaceText = LINK_DOMAIN + randomStr;
+            String content = originalContent.replace("${sms.friendLink}", replaceText);
+            Integer needCount = calculateSmsCount(content);
+
+            PhoneMessageInfo info = new PhoneMessageInfo();
+            info.setPhone(phone);
+            info.setContent(content);
+            info.setRandomStr(randomStr);
+            info.setNeedCount(needCount);
+            phoneMessageList.add(info);
+        }
+
+        // 4. 计算总需要条数
+        int totalNeedCount = phoneMessageList.stream().mapToInt(PhoneMessageInfo::getNeedCount).sum();
+
+        // 5. 快速校验总余额是否充足(使用缓存)
+        Long balance = companySmsService.getBalance(2L);
+        if (balance == null || balance < totalNeedCount) {
+            // 缓存可能不准,再查一次DB确认
+            CompanySms companySms = companySmsService.selectCompanySms(2L);
+            if (companySms == null) {
+                throw new CustomException("公司短信配置不存在");
+            }
+            if (companySms.getRemainSmsCount() < totalNeedCount) {
+                throw new CustomException("短信余额不足,总需要:" + totalNeedCount +
+                        ",当前余额:" + companySms.getRemainSmsCount());
+            }
+        }
+
+        // 6. 批量发送短信
+        List<SendResultInfo> sendResults = new ArrayList<>();
+        int successCount = 0;
+
+        for (PhoneMessageInfo info : phoneMessageList) {
+            try {
+                SendMsgLogBo sendMsgLogBo = new SendMsgLogBo();
+                BeanUtils.copyProperties(baseSendMsgLogBo, sendMsgLogBo);
+
+                R r = smsService.simpleSmsSend(info.getPhone(), info.getContent(), temp,
+                        SmsLogType.ACQUISITION_LINK, sendMsgLogBo);
+
+                if (r != null && "200".equals(String.valueOf(r.get("code")))) {
+                    addAcquisitionLinkInfo(acquisitionAssistant.getId(), info.getPhone(),
+                            acquisitionAssistant.getUrl(), info.getRandomStr(),
+                            baseSendMsgLogBo.getCompanyUserId());
+
+                    SendResultInfo successResult = new SendResultInfo();
+                    successResult.setSuccess(true);
+                    successResult.setNeedCount(info.getNeedCount());
+                    successResult.setPhone(info.getPhone());
+                    sendResults.add(successResult);
+                    successCount++;
+
+                    log.info("短信发送成功 phone={}, needCount={}", info.getPhone(), info.getNeedCount());
+                } else {
+                    String msg = r != null && r.get("msg") != null ? r.get("msg").toString() : "未知错误";
+                    log.warn("短信发送失败 phone={}, msg={}", info.getPhone(), msg);
+
+                    SendResultInfo failResult = new SendResultInfo();
+                    failResult.setSuccess(false);
+                    failResult.setNeedCount(0);
+                    failResult.setPhone(info.getPhone());
+                    failResult.setFailReason(msg);
+                    sendResults.add(failResult);
+                }
+            } catch (Exception e) {
+                log.error("短信发送异常 phone=" + info.getPhone(), e);
+
+                SendResultInfo failResult = new SendResultInfo();
+                failResult.setSuccess(false);
+                failResult.setNeedCount(0);
+                failResult.setPhone(info.getPhone());
+                failResult.setFailReason(e.getMessage());
+                sendResults.add(failResult);
+            }
+        }
+
+        // 7. 计算实际需要扣减的总条数
+        int actualNeedCount = sendResults.stream()
+                .filter(SendResultInfo::isSuccess)
+                .mapToInt(SendResultInfo::getNeedCount)
+                .sum();
+
+        if (actualNeedCount == 0) {
+            log.info("批量发送全部失败,不扣减余额");
+            return 0;
+        }
+
+        // 8. 乐观锁扣减,支持重试
+        int maxRetries = 3;
+
+        for (int retryCount = 0; retryCount < maxRetries; retryCount++) {
+            // 快速检查(用缓存)
+            Long reBalance = companySmsService.getBalance(2L);
+            if (reBalance == null || reBalance < actualNeedCount) {
+                // 缓存可能不准,再查一次DB确认
+                CompanySms latestSms = companySmsService.selectCompanySms(2L);
+                if (latestSms == null) {
+                    throw new CustomException("公司短信配置不存在");
+                }
+                if (latestSms.getRemainSmsCount() < actualNeedCount) {
+                    throw new CustomException("短信余额不足");
+                }
+            }
+
+            // 获取最新数据(含version)
+            CompanySms latestSms = companySmsService.selectCompanySms(2L);
+            if (latestSms == null) {
+                throw new CustomException("公司短信配置不存在");
+            }
+
+            Long version = latestSms.getVersion() != null ? latestSms.getVersion() : 0L;
+            int updateCount = companySmsService.decrementRemainSmsCountWithVersion(2L, actualNeedCount, version);
+
+            if (updateCount > 0) {
+                log.info("乐观锁扣减余额成功, companyId=2, needCount={}, version={}", actualNeedCount, version);
+                // 扣减成功,更新缓存
+                companySmsService.updateCacheBalance(2L, -actualNeedCount);
+                log.info("批量创建获客链接完成,总计尝试 {}, 成功 {}, 实际扣减条数 {}",
+                        phoneList.size(), successCount, actualNeedCount);
+                return successCount;
+            }
+
+            log.warn("乐观锁扣减余额失败,第{}次重试, version={}", retryCount + 1, version);
+
+            if (retryCount == maxRetries - 1) {
+                throw new CustomException("扣减余额失败,系统繁忙,请稍后重试");
+            }
+
+            try {
+                Thread.sleep(100L * (retryCount + 1));
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                throw new CustomException("扣减余额被中断");
+            }
+        }
+
+        return successCount;
+    }
+
+    /**
+     * 退还余额(带重试)
+     */
+    private void rollbackBalance(int needCount, int maxRetries) {
+        for (int i = 0; i < maxRetries; i++) {
+            try {
+                CompanySms current = companySmsService.selectCompanySms(2L);
+                if (current == null) {
+                    log.error("退还短信余额失败,公司短信配置不存在");
+                    return;
+                }
+                Long currentVersion = current.getVersion() != null ? current.getVersion() : 0L;
+                int rollbackCount = companySmsService.incrementRemainSmsCountWithVersion(2L, needCount, currentVersion);
+                if (rollbackCount > 0) {
+                    companySmsService.updateCacheBalance(2L, needCount);
+                    log.info("退还余额成功, needCount={}", needCount);
+                    return;
+                }
+                Thread.sleep(50L);
+            } catch (Exception e) {
+                log.error("退还短信余额异常,第{}次重试", i + 1, e);
+            }
+        }
+        log.error("退还短信余额失败,需要人工处理, companyId=2, needCount={}", needCount);
+    }
+
+    @Override
+    public String extractLink(Long qwAcquisitionAssistantId, String originalPhone, String originalLink,Long createBy) {
+        String randomStr = generateUniqueRandomStr();
+        QwAcquisitionLinkInfo qwAcquisitionLinkInfo=new QwAcquisitionLinkInfo();
+        qwAcquisitionLinkInfo.setQwAcquisitionAssistantId(qwAcquisitionAssistantId);
+        qwAcquisitionLinkInfo.setCreateBy(createBy);
+        qwAcquisitionLinkInfo.setCreateTime(DateUtils.getTime());
+        qwAcquisitionLinkInfo.setPhone(originalPhone);//这里存储原始手机号
+        //加密手机号
+        String phonePlus = encryptPhone(originalPhone);
+        String linkPlus=originalLink+LINK_SUFFIX+ phonePlus;
+        qwAcquisitionLinkInfo.setLink(linkPlus);
+        qwAcquisitionLinkInfo.setRandomStr(randomStr);
+        int addResult=qwAcquisitionLinkInfoMapper.insertQwAcquisitionLinkInfo(qwAcquisitionLinkInfo);
+        // ========== 缓存URL,便于后续通过randomStr访问 ==========
+        try {
+            String cacheKey = QW_FRIEND_LINK_URL_KEY + randomStr;
+            Integer cacheExpire = 2; // 默认缓存2天
+            redisCache.setCacheObject(cacheKey, linkPlus, cacheExpire, TimeUnit.DAYS);
+            log.info("获客链接URL缓存成功, pageParam: {}, url: {}", randomStr, linkPlus);
+        } catch (Exception e) {
+            // 缓存失败不影响主流程,但需要记录日志
+            log.error("获客链接URL缓存失败, pageParam: {}", randomStr, e);
+        }
+        // 返回域名+随机字符串
+        return LINK_DOMAIN+randomStr;
+    }
+
+    @Override
+    public R extractLinkNol(Long qwAcquisitionAssistantId, String originalLink, Long createBy) {
+        String randomStr = generateUniqueRandomStr();
+        QwAcquisitionLinkInfo qwAcquisitionLinkInfo=new QwAcquisitionLinkInfo();
+        qwAcquisitionLinkInfo.setQwAcquisitionAssistantId(qwAcquisitionAssistantId);
+        qwAcquisitionLinkInfo.setCreateBy(createBy);
+        qwAcquisitionLinkInfo.setCreateTime(DateUtils.getTime());
+        String linkPlus=originalLink+LINK_SUFFIX_NOL+ qwAcquisitionAssistantId;
+        qwAcquisitionLinkInfo.setLink(linkPlus);
+        qwAcquisitionLinkInfo.setRandomStr(randomStr);
+        int addResult=qwAcquisitionLinkInfoMapper.insertQwAcquisitionLinkInfo(qwAcquisitionLinkInfo);
+        // ========== 缓存URL,便于后续通过randomStr访问 ==========
+        try {
+            String cacheKey = QW_FRIEND_LINK_URL_KEY + randomStr;
+            Integer cacheExpire = 2; // 默认缓存2天
+            redisCache.setCacheObject(cacheKey, linkPlus, cacheExpire, TimeUnit.DAYS);
+            log.info("获客链接URL缓存成功, pageParam: {}, url: {}", randomStr, linkPlus);
+        } catch (Exception e) {
+            // 缓存失败不影响主流程,但需要记录日志
+            log.error("获客链接URL缓存失败, pageParam: {}", randomStr, e);
+        }
+
+        String shortLink = LINK_DOMAIN+randomStr;
+        return R.ok().put("shortLink",shortLink).put("linkPlus",linkPlus);
+        // 返回域名+随机字符串
+
+    }
+
+    @Override
+    public void ipadBlindAdd(IpadBlindAddDto dto,SendMsgLogBo sendMsgLogBo) {
+        //获取QwUser信息
+        QwUser qwUser=qwUserMapper.selectQwUserById(dto.getUserId());
+        if (qwUser==null){
+            log.error( "销售用户不存在,qwUserId:{}",dto.getUserId());
+            throw new CustomException("销售用户不存在");
+        }
+        //判断这个销售的ipad是否在线
+        QwUserListParam queryCondition=new QwUserListParam();
+        queryCondition.setCorpId(dto.getCorpId());
+        queryCondition.setQwUserId(qwUser.getQwUserId());
+        List<QwUserVO> qwUserVOS = qwUserMapper.selectQwUserListStaffVO(queryCondition);
+        if (qwUserVOS.isEmpty()){
+            throw new CustomException("此主体不存在该销售用户");
+        }
+        if (qwUserVOS.get(0).getIpadStatus()==0){
+            throw new CustomException("此销售用户ipad已离线,请登录");
+        }
+        // 每次使用时获取代理对象
+        QwAcquisitionLinkInfoServiceImpl self = applicationContext.getBean(QwAcquisitionLinkInfoServiceImpl.class);
+        try {
+            //调用ipad加好友接口
+            WxWorkResponseDTO<String> response = self.qwAddWxInvokeIpad(dto.getPhone(), qwUser.getUid(),
+                    qwUser.getServerId(),
+                    qwUser.getVid(),
+                    qwUser.getQwUserName());
+            if (response != null && response.getErrcode() == 0) {
+                log.info("ipad获客链接加微成功");
+            }else {
+                log.error("ipad获客链接加微失败,错误码:{},错误信息:{}",response.getErrcode(),response.getErrmsg());
+            }
+        }catch (Exception e){
+            //TODO 如果有异常,暂时只做记录
+            log.error("ipad加微异常",e);
+        }
+
+        //调用发送短信接口
+        self.sendMessageLink(dto.getPhone(),dto.getQwAcquisitionAssistantId(),sendMsgLogBo);
+    }
+
+    /**
+     * 企微加个微调用ipad端
+     * @param mobile  手机号
+     * @param qwUid   企微uid
+     * @param serverId   服务器id
+     * @return String 结果
+     */
+    public WxWorkResponseDTO<String> qwAddWxInvokeIpad(String mobile, String qwUid, Long serverId, String vid, String qwUserName) {
+        if (StringUtils.isBlank(mobile) || StringUtils.isBlank(qwUid) || serverId == null) {
+            log.warn("企微申请加好友任务参数校验失败: mobile={}, qwUid={}, serverId={}", mobile, qwUid, serverId);
+            return null;
+        }
+
+        try {
+            WxAddSearchDTO wxAddSearchDTO = new WxAddSearchDTO();
+            wxAddSearchDTO.setUuid(qwUid);
+            wxAddSearchDTO.setVid(Long.valueOf(vid));
+            wxAddSearchDTO.setPhone(mobile);
+
+            WxSearchContactDTO contactDTO=new WxSearchContactDTO();
+            contactDTO.setUuid(qwUid);
+            contactDTO.setPhoneNumber(mobile);
+
+
+            WxWorkResponseDTO<WxSearchContactResp> respWxWorkResponseDTO = wxWorkService.searchContact(contactDTO, serverId);
+            WxSearchContactResp.UserList user = respWxWorkResponseDTO.getData().getUserList().stream()
+                    .filter(u -> u.getState().equals("2"))
+                    .findFirst()
+                    .orElse(null); // 或者 .orElseThrow(() -> new RuntimeException("未找到指定用户"))
+
+            wxAddSearchDTO.setOptionid(user.getOpenid());
+            wxAddSearchDTO.setTicket(user.getTicket());
+            wxAddSearchDTO.setContent("你好,我是你的专属助手:"+qwUserName+",有什么问题都可以问我哦~");
+
+            WxWorkResponseDTO<String> response = wxWorkService.addSearch(wxAddSearchDTO, serverId);
+            log.debug("企微加微接口调用结果: errcode={}, errmsg={}",
+                    response != null ? response.getErrcode() : "null",
+                    response != null ? response.getErrmsg() : "null");
+
+            return response;
+        } catch (Exception e) {
+            log.error("企微申请加好友任务请求接口异常: mobile={}, qwUid={}, serverId={}", mobile, qwUid, serverId, e);
+            return null;
+        }
+    }
+
+    /**
+     * 添加链接生成记录
+     * */
+    public int addAcquisitionLinkInfo(Long qwAcquisitionAssistantId,String originalPhone,String originalLink,String randomStr,Long createBy){
+        QwAcquisitionLinkInfo qwAcquisitionLinkInfo=new QwAcquisitionLinkInfo();
+        qwAcquisitionLinkInfo.setQwAcquisitionAssistantId(qwAcquisitionAssistantId);
+        qwAcquisitionLinkInfo.setCreateBy(createBy);
+        qwAcquisitionLinkInfo.setCreateTime(DateUtils.getTime());
+        qwAcquisitionLinkInfo.setPhone(originalPhone);//这里存储原始手机号
+        //加密手机号
+        String phonePlus = encryptPhone(originalPhone);
+        String linkPlus=originalLink+LINK_SUFFIX+ phonePlus;
+        qwAcquisitionLinkInfo.setLink(linkPlus);
+        qwAcquisitionLinkInfo.setRandomStr(randomStr);
+        int addResult=qwAcquisitionLinkInfoMapper.insertQwAcquisitionLinkInfo(qwAcquisitionLinkInfo);
+        // ========== 缓存URL,便于后续通过randomStr访问 ==========
+        try {
+            String cacheKey = QW_FRIEND_LINK_URL_KEY + randomStr;
+            Integer cacheExpire = 2; // 默认缓存2天
+            redisCache.setCacheObject(cacheKey, linkPlus, cacheExpire, TimeUnit.DAYS);
+            log.info("获客链接URL缓存成功, pageParam: {}, url: {}", randomStr, linkPlus);
+        } catch (Exception e) {
+            // 缓存失败不影响主流程,但需要记录日志
+            log.error("获客链接URL缓存失败, pageParam: {}", randomStr, e);
+        }
+        return addResult;
+    }
+
+
+    /**
+     * 生成唯一的页面参数
+     */
+    private String generateUniqueRandomStr() {
+        // 获取所有已存在的pageParam(只取需要的字段)
+        List<String> existingParams = qwAcquisitionLinkInfoMapper.selectAllRandomStr();
+        //使用Set,提高查找效率 O(1)
+        Set<String> paramSet = new HashSet<>(existingParams);
+
+        int maxAttempts = 10; // 设置最大尝试次数
+        int attempt = 0;
+
+        while (attempt < maxAttempts) {
+            // 生成7位随机码
+            String candidate = UniqueStringUtil.generateTimeBasedUnique(7);
+
+            // 使用Set的contains方法,O(1)复杂度
+            if (!paramSet.contains(candidate)) {
+                log.debug("生成页面参数成功: {}, 尝试次数: {}", candidate, attempt + 1);
+                return candidate;
+            }
+
+            attempt++;
+            log.debug("页面参数 {} 已存在,重新生成,第{}次尝试", candidate, attempt);
+        }
+
+        // 如果多次尝试都失败,使用+1随机数方案
+        String finalParam = UniqueStringUtil.generateTimeBasedUnique(8);
+        log.warn("多次尝试后使用7位参数: {}", finalParam);
+        return finalParam;
+    }
+
+    /**
+     *  批量删除缓存
+     * */
+    private void batchDeleteLinkCatch(List<QwAcquisitionLinkInfo> toBeDeletedList) {
+        if (!toBeDeletedList.isEmpty()) {
+            for (QwAcquisitionLinkInfo record : toBeDeletedList) {
+                try {
+                    // 检查 randomStr 是否为空,避免处理无效的缓存 key
+                    if (StringUtils.isNotEmpty(record.getRandomStr())) {
+                        String urlCacheKey = QW_FRIEND_LINK_URL_KEY + record.getRandomStr();
+                        redisCache.deleteObject(urlCacheKey);
+                        log.info("批量删除获客链接URL缓存成功, randomStr: {}, key: {}",
+                                record.getRandomStr(), urlCacheKey);
+                    }
+                } catch (Exception e) {
+                    // 缓存删除失败不应影响主数据库删除流程,但需要记录日志
+                    log.error("批量删除获客链接缓存失败, id: {}, qwAcquisitionAssistantId: {}, randomStr: {}",
+                            record.getId(), record.getQwAcquisitionAssistantId(), record.getRandomStr(), e);
+                }
+            }
+        }
+    }
+
+    //根据短信文字内容计算短信条数
+    private int calculateSmsCount(String content) {
+        if (content == null) return 1;
+        int counts = content.length() / 67;
+        if (content.length() % 67 > 0) {
+            counts = counts + 1;
+        }
+        if (counts == 0) {
+            counts = 1;
+        }
+        return counts;
+    }
+
+    // 辅助类
+    @Data
+    private static class PhoneMessageInfo {
+        private String phone;
+        private String content;
+        private String randomStr;
+        private Integer needCount;
+    }
+
+    @Data
+    private static class SendResultInfo {
+        private boolean success;
+        private int needCount;
+        private String phone;
+        private String failReason;
+    }
+}

+ 19 - 0
fs-service/src/main/java/com/fs/qw/strategy/SmsLogStrategy.java

@@ -0,0 +1,19 @@
+package com.fs.qw.strategy;
+
+import com.fs.common.core.domain.R;
+/**
+ * 短信发送后记录特定业务日志的策略接口
+ */
+public interface SmsLogStrategy {
+    /**
+     * 执行日志记录操作
+     * @param result 发送结果
+     * @param content 短信内容
+     * @param phone 手机号
+     * @param tempId 模板ID
+     * @param type 服务商类型
+     * @param number 短信条数
+     * @param contextObject 特定业务的上下文对象(如qwAcquisitionId, externalContactId等)
+     */
+    void recordLog(R result, String content, String phone, Long tempId, String type, Integer number, Object contextObject);
+}

+ 54 - 0
fs-service/src/main/java/com/fs/qw/strategy/SmsLogStrategyManager.java

@@ -0,0 +1,54 @@
+package com.fs.qw.strategy;
+
+import com.fs.common.core.domain.R;
+import com.fs.qw.enums.SmsLogType;
+import com.fs.qw.strategy.impl.AcquisitionLinkLogStrategyImpl;
+import com.fs.qw.strategy.impl.CourseLinkLogStrategyImpl;
+import com.fs.qw.strategy.impl.NoOpSmsLogStrategy;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.PostConstruct;
+import java.util.EnumMap;
+import java.util.Map;
+@Slf4j
+@Component
+public class SmsLogStrategyManager {
+
+    private final Map<SmsLogType, SmsLogStrategy> strategies = new EnumMap<>(SmsLogType.class);
+
+    @Autowired
+    private AcquisitionLinkLogStrategyImpl acquisitionLinkLogStrategy;
+
+    @Autowired
+    private CourseLinkLogStrategyImpl courseLinkLogStrategy;
+
+    //注入空操作策略
+    @Autowired
+    private NoOpSmsLogStrategy noOpSmsLogStrategy;
+
+    @PostConstruct
+    public void init() {
+        strategies.put(SmsLogType.ACQUISITION_LINK, acquisitionLinkLogStrategy);
+        strategies.put(SmsLogType.COURSE_LINK, courseLinkLogStrategy);
+    }
+
+    public void executeLogStrategy(SmsLogType logType, R result, String content, String phone, Long tempId, String type, Integer number, Object contextObject) {
+        // 如果 logType 为 null,则使用空操作策略
+        if (logType == null) {
+            noOpSmsLogStrategy.recordLog(result, content, phone, tempId, type, number, contextObject);
+            return;
+        }
+
+        // 尝试从map中获取策略
+        SmsLogStrategy strategy = strategies.get(logType);
+        if (strategy == null) {
+            // 使用空操作策略,以保证业务流程不中断
+            noOpSmsLogStrategy.recordLog(result, content, phone, tempId, type, number, contextObject);
+             log.warn("未找到处理类型 [{}] 的日志策略,将执行空操作。", logType.getInfo());
+            return;
+        }
+        strategy.recordLog(result, content, phone, tempId, type, number, contextObject);
+    }
+}

+ 35 - 0
fs-service/src/main/java/com/fs/qw/strategy/impl/AcquisitionLinkLogStrategyImpl.java

@@ -0,0 +1,35 @@
+package com.fs.qw.strategy.impl;
+
+import com.fs.common.core.domain.R;
+import com.fs.common.utils.DateUtils;
+import com.fs.qw.bo.SendMsgLogBo;
+import com.fs.qw.domain.QwAcquisitionSendMsgLog;
+import com.fs.qw.mapper.QwAcquisitionSendMsgLogMapper;
+import com.fs.qw.strategy.SmsLogStrategy;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+@Component
+public class AcquisitionLinkLogStrategyImpl implements SmsLogStrategy {
+
+    @Autowired
+    private QwAcquisitionSendMsgLogMapper acquisitionSendMsgLogMapper;
+
+    @Override
+    public void recordLog(R result, String content, String phone, Long tempId, String type, Integer number, Object contextObject) {
+        SendMsgLogBo sendMsgLogBo = (SendMsgLogBo) contextObject;
+
+        QwAcquisitionSendMsgLog log = new QwAcquisitionSendMsgLog();
+        log.setCompanySmsLogsId(sendMsgLogBo.getCompanySmsLogsId());
+        log.setQwAcquisitionId(sendMsgLogBo.getQwAcquisitionId());
+        log.setNumber(number);
+        log.setType(type);
+        log.setPhone(phone);
+        log.setTempId(tempId);
+        log.setContent(content);
+        log.setCreateTime(DateUtils.getNowDate());
+        log.setResult(result.get("msg").toString());
+
+        acquisitionSendMsgLogMapper.insertQwAcquisitionSendMsgLog(log);
+    }
+}

+ 38 - 0
fs-service/src/main/java/com/fs/qw/strategy/impl/CourseLinkLogStrategyImpl.java

@@ -0,0 +1,38 @@
+package com.fs.qw.strategy.impl;
+
+import com.fs.common.core.domain.R;
+import com.fs.common.utils.DateUtils;
+import com.fs.qw.bo.SendMsgLogBo;
+import com.fs.qw.domain.QwCourseLinkSendMsgLog;
+import com.fs.qw.mapper.QwCourseLinkSendMsgLogMapper;
+import com.fs.qw.strategy.SmsLogStrategy;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+@Component
+public class CourseLinkLogStrategyImpl implements SmsLogStrategy {
+
+    @Autowired
+    private QwCourseLinkSendMsgLogMapper courseLinkSendMsgLogMapper;
+
+    @Override
+    public void recordLog(R result, String content, String phone, Long tempId, String type, Integer number, Object contextObject) {
+
+        SendMsgLogBo sendMsgLogBo = (SendMsgLogBo) contextObject;
+
+        QwCourseLinkSendMsgLog log = new QwCourseLinkSendMsgLog();
+        log.setCompanySmsLogsId(sendMsgLogBo.getCompanySmsLogsId());
+        log.setExternalContactId(sendMsgLogBo.getExternalId());
+        log.setCourseId(sendMsgLogBo.getCourseId());
+        log.setVideoId(sendMsgLogBo.getVideoId());
+        log.setNumber(number);
+        log.setType(type);
+        log.setPhone(phone);
+        log.setTempId(tempId);
+        log.setContent(content);
+        log.setCreateTime(DateUtils.getNowDate());
+        log.setResult(result.get("msg").toString());
+
+        courseLinkSendMsgLogMapper.insertQwCourseLinkSendMsgLog(log);
+    }
+}

+ 18 - 0
fs-service/src/main/java/com/fs/qw/strategy/impl/NoOpSmsLogStrategy.java

@@ -0,0 +1,18 @@
+package com.fs.qw.strategy.impl;
+
+import com.fs.common.core.domain.R;
+import com.fs.qw.strategy.SmsLogStrategy;
+import org.springframework.stereotype.Component;
+
+/**
+ * 空操作日志策略,用于处理不需要记录特定业务日志的场景
+ */
+@Component
+public class NoOpSmsLogStrategy implements SmsLogStrategy {
+
+    @Override
+    public void recordLog(R result, String content, String phone, Long tempId, String type, Integer number, Object contextObject) {
+        // 什么都不做
+        // 这样可以保证当logType为null时,simpleSmsSend也能正常运行
+    }
+}

+ 51 - 6
fs-service/src/main/resources/mapper/company/CompanySmsMapper.xml

@@ -3,7 +3,7 @@
 PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
 PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
 "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 <mapper namespace="com.fs.company.mapper.CompanySmsMapper">
 <mapper namespace="com.fs.company.mapper.CompanySmsMapper">
-    
+
     <resultMap type="CompanySms" id="CompanySmsResult">
     <resultMap type="CompanySms" id="CompanySmsResult">
         <result property="smsId"    column="sms_id"    />
         <result property="smsId"    column="sms_id"    />
         <result property="companyId"    column="company_id"    />
         <result property="companyId"    column="company_id"    />
@@ -16,12 +16,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     </sql>
     </sql>
 
 
 
 
-    
+
     <select id="selectCompanySmsById" parameterType="Long" resultMap="CompanySmsResult">
     <select id="selectCompanySmsById" parameterType="Long" resultMap="CompanySmsResult">
         <include refid="selectCompanySmsVo"/>
         <include refid="selectCompanySmsVo"/>
         where sms_id = #{smsId}
         where sms_id = #{smsId}
     </select>
     </select>
-        
+
     <insert id="insertCompanySms" parameterType="CompanySms" useGeneratedKeys="true" keyProperty="smsId">
     <insert id="insertCompanySms" parameterType="CompanySms" useGeneratedKeys="true" keyProperty="smsId">
         insert into company_sms
         insert into company_sms
         <trim prefix="(" suffix=")" suffixOverrides=",">
         <trim prefix="(" suffix=")" suffixOverrides=",">
@@ -51,10 +51,55 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     </delete>
     </delete>
 
 
     <delete id="deleteCompanySmsByIds" parameterType="String">
     <delete id="deleteCompanySmsByIds" parameterType="String">
-        delete from company_sms where sms_id in 
+        delete from company_sms where sms_id in
         <foreach item="smsId" collection="array" open="(" separator="," close=")">
         <foreach item="smsId" collection="array" open="(" separator="," close=")">
             #{smsId}
             #{smsId}
         </foreach>
         </foreach>
     </delete>
     </delete>
-    
-</mapper>
+
+    <!-- 直接扣减 -->
+    <update id="decrementRemainSmsCount">
+        update company_sms
+        set remain_sms_count = remain_sms_count - #{number},
+            total_sms_count = total_sms_count - #{number},
+            version = version + 1,
+            update_time = NOW()
+        where company_id = #{companyId}
+          and remain_sms_count >= #{number}
+    </update>
+
+    <!-- 直接增加 -->
+    <update id="incrementRemainSmsCount">
+        update company_sms
+        set remain_sms_count = remain_sms_count + #{number},
+            total_sms_count = total_sms_count + #{number},
+            version = version + 1,
+            update_time = NOW()
+        where company_id = #{companyId}
+    </update>
+
+    <!-- 乐观锁扣减 -->
+    <update id="decrementRemainSmsCountWithVersion">
+        update company_sms
+        set remain_sms_count = remain_sms_count - #{number},
+            total_sms_count = total_sms_count - #{number},
+            version = version + 1,
+            update_time = NOW()
+        where company_id = #{companyId}
+          and remain_sms_count >= #{number}
+          and version = #{currentVersion}
+    </update>
+
+    <!-- 乐观锁增加 -->
+    <update id="incrementRemainSmsCountWithVersion">
+        update company_sms
+        set remain_sms_count = remain_sms_count + #{number},
+            total_sms_count = total_sms_count + #{number},
+            version = version + 1,
+            update_time = NOW()
+        where company_id = #{companyId}
+          and version = #{currentVersion}
+    </update>
+
+
+</mapper>

+ 213 - 0
fs-service/src/main/resources/mapper/qw/QwAcquisitionLinkInfoMapper.xml

@@ -0,0 +1,213 @@
+<?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.qw.mapper.QwAcquisitionLinkInfoMapper">
+
+    <resultMap type="com.fs.qw.domain.QwAcquisitionLinkInfo" id="QwAcquisitionLinkInfoResult">
+        <result property="id" column="id"/>
+        <result property="qwAcquisitionAssistantId" column="qw_acquisition_assistant_id"/>
+        <result property="link" column="link"/>
+        <result property="phone" column="phone"/>
+        <result property="createBy" column="create_by"/>
+        <result property="createTime" column="create_time"/>
+        <result property="updateBy" column="update_by"/>
+        <result property="updateTime" column="update_time"/>
+        <result property="remark" column="remark"/>
+        <result property="randomStr" column="random_str"/>
+        <result property="linkName" column="link_name"/>
+        <result property="createName" column="nick_name"/>
+    </resultMap>
+
+    <sql id="selectQwAcquisitionLinkInfoVo">
+        select id,
+               qw_acquisition_assistant_id,
+               link,
+               phone,
+               create_by,
+               create_time,
+               update_by,
+               update_time,
+               remark,
+               random_str
+        from qw_acquisition_link_info
+    </sql>
+
+    <select id="selectQwAcquisitionLinkInfoList" parameterType="com.fs.qw.domain.QwAcquisitionLinkInfo"
+            resultMap="QwAcquisitionLinkInfoResult">
+        select acinfo.id, acinfo.qw_acquisition_assistant_id, acinfo.link, acinfo.phone, acinfo.create_by,
+        acinfo.create_time, acinfo.update_by,
+        acinfo.update_time,acinfo.remark,acinfo.random_str, acas.link_name,cu.nick_name
+        from qw_acquisition_link_info acinfo
+        left join qw_acquisition_assistant acas on acinfo.qw_acquisition_assistant_id = acas.id
+        left join company_user cu on cu.user_id = acinfo.create_by
+        <where>
+            <if test="qwAcquisitionAssistantId != null and qwAcquisitionAssistantId != ''">
+                and acinfo.qw_acquisition_assistant_id = #{qwAcquisitionAssistantId}
+            </if>
+            <if test="link != null and link != ''">
+                and acinfo.link like concat('%', #{link}, '%')
+            </if>
+            <if test="phone != null and phone != ''">
+                and acinfo.phone like concat('%', #{phone}, '%')
+            </if>
+            <if test="linkName != null and linkName != ''">
+                and acas.link_name like concat('%', #{linkName}, '%')
+            </if>
+            <if test="createName != null and createName != ''">
+                and cu.nick_name like concat('%', #{createName}, '%')
+            </if>
+            <if test="remark != null and remark != ''">
+                and acinfo.remark like concat('%', #{remark}, '%')
+            </if>
+            <if test="randomStr != null and randomStr != ''">
+                and acinfo.random_str like concat('%', #{randomStr}, '%')
+            </if>
+        </where>
+        order by acinfo.create_time desc
+    </select>
+
+    <select id="selectQwAcquisitionLinkInfoById" parameterType="Long" resultMap="QwAcquisitionLinkInfoResult">
+        <include refid="selectQwAcquisitionLinkInfoVo"/>
+        where id = #{id}
+    </select>
+
+    <select id="selectAllRandomStr" resultType="java.lang.String">
+        select random_str
+        from qw_acquisition_link_info
+    </select>
+    <select id="selectQwAcquisitionUrlByRandomStr" resultType="java.lang.String">
+        select link
+        from qw_acquisition_link_info
+        where random_str = #{randomStr}
+    </select>
+
+    <select id="selectAcquisitionLinkInfoListByIds" resultMap="QwAcquisitionLinkInfoResult">
+        select id, qw_acquisition_assistant_id, link, phone, create_by, create_time, update_by, update_time,
+        remark,random_str from qw_acquisition_link_info where id in
+        <foreach item="id" collection="ids" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </select>
+
+    <select id="selectAcquisitionLinkInfoListByAcquisitionAssistantIds" resultMap="QwAcquisitionLinkInfoResult">
+        select id, qw_acquisition_assistant_id, link, phone, create_by, create_time, update_by, update_time,
+        remark,random_str
+        from qw_acquisition_link_info
+        where qw_acquisition_assistant_id in
+        <foreach item="id" collection="qwAcquisitionAssistantIds" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </select>
+
+    <insert id="insertQwAcquisitionLinkInfo" parameterType="com.fs.qw.domain.QwAcquisitionLinkInfo"
+            useGeneratedKeys="true" keyProperty="id">
+        insert into qw_acquisition_link_info
+        <trim prefix="(" suffix=")" suffixOverrides=",">
+            <if test="qwAcquisitionAssistantId != null">
+                qw_acquisition_assistant_id,
+            </if>
+            <if test="link != null and link != ''">
+                link,
+            </if>
+            <if test="phone != null and phone != ''">
+                phone,
+            </if>
+            <if test="createBy != null and createBy != ''">
+                create_by,
+            </if>
+            <if test="createTime != null">
+                create_time,
+            </if>
+            <if test="updateBy != null and updateBy != ''">
+                update_by,
+            </if>
+            <if test="updateTime != null">
+                update_time,
+            </if>
+            <if test="remark != null and remark != ''">
+                remark,
+            </if>
+            <if test="randomStr != null and randomStr != ''">
+                random_str,
+            </if>
+        </trim>
+        <trim prefix="values (" suffix=")" suffixOverrides=",">
+            <if test="qwAcquisitionAssistantId != null">
+                #{qwAcquisitionAssistantId},
+            </if>
+            <if test="link != null and link != ''">
+                #{link},
+            </if>
+            <if test="phone != null and phone != ''">
+                #{phone},
+            </if>
+            <if test="createBy != null and createBy != ''">
+                #{createBy},
+            </if>
+            <if test="createTime != null">
+                #{createTime},
+            </if>
+            <if test="updateBy != null and updateBy != ''">
+                #{updateBy},
+            </if>
+            <if test="updateTime != null">
+                #{updateTime},
+            </if>
+            <if test="remark != null and remark != ''">
+                #{remark},
+            </if>
+            <if test="randomStr != null and randomStr != ''">
+                #{randomStr},
+            </if>
+        </trim>
+    </insert>
+
+    <update id="updateQwAcquisitionLinkInfo" parameterType="com.fs.qw.domain.QwAcquisitionLinkInfo">
+        update qw_acquisition_link_info
+        <trim prefix="SET" suffixOverrides=",">
+            <if test="qwAcquisitionAssistantId != null">
+                qw_acquisition_assistant_id = #{qwAcquisitionAssistantId},
+            </if>
+            <if test="link != null and link != ''">
+                link = #{link},
+            </if>
+            <if test="phone != null and phone != ''">
+                phone = #{phone},
+            </if>
+            <if test="updateBy != null and updateBy != ''">
+                update_by = #{updateBy},
+            </if>
+            <if test="updateTime != null">
+                update_time = #{updateTime},
+            </if>
+            <if test="remark != null and remark != ''">
+                remark = #{remark},
+            </if>
+            <if test="randomStr != null and randomStr != ''">
+                random_str = #{randomStr},
+            </if>
+        </trim>
+        where id = #{id}
+    </update>
+
+    <delete id="deleteQwAcquisitionLinkInfoById" parameterType="Long">
+        delete
+        from qw_acquisition_link_info
+        where id = #{id}
+    </delete>
+
+    <delete id="deleteQwAcquisitionLinkInfoByIds" parameterType="String">
+        delete from qw_acquisition_link_info where id in
+        <foreach item="id" collection="array" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </delete>
+
+    <delete id="deleteQwAcquisitionLinkInfoByQwAcquisitionAssistantIds" parameterType="String">
+        delete from qw_acquisition_link_info where qw_acquisition_assistant_id in
+        <foreach item="id" collection="qwAcquisitionAssistantIds" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </delete>
+</mapper>

+ 105 - 0
fs-service/src/main/resources/mapper/qw/QwAcquisitionSendMsgLogMapper.xml

@@ -0,0 +1,105 @@
+<?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.qw.mapper.QwAcquisitionSendMsgLogMapper">
+
+    <resultMap type="com.fs.qw.domain.QwAcquisitionSendMsgLog" id="QwAcquisitionSendMsgLogResult">
+        <result property="id"    column="id"    />
+        <result property="companySmsLogsId"    column="company_sms_logs_id"    />
+        <result property="qwAcquisitionId"    column="qw_acquisition_id"    />
+        <result property="phone"    column="phone"    />
+        <result property="number"    column="number"    />
+        <result property="tempId"    column="temp_id"    />
+        <result property="type"    column="type"    />
+        <result property="content"    column="content"    />
+        <result property="result"    column="result"    />
+        <result property="remark"    column="remark"    />
+        <result property="createBy"    column="create_by"    />
+        <result property="createTime"    column="create_time"    />
+    </resultMap>
+
+    <sql id="selectQwAcquisitionSendMsgLogVo">
+        select id, company_sms_logs_id,qw_acquisition_id, phone, number, temp_id, type, content, result, remark, create_by, create_time from qw_acquisition_send_msg_log
+    </sql>
+
+    <select id="selectQwAcquisitionSendMsgLogList" parameterType="com.fs.qw.domain.QwAcquisitionSendMsgLog" resultMap="QwAcquisitionSendMsgLogResult">
+        <include refid="selectQwAcquisitionSendMsgLogVo"/>
+        <where>
+            <if test="companySmsLogsId != null  and companySmsLogsId != ''"> and company_sms_logs_id = #{companySmsLogsId}</if>
+            <if test="qwAcquisitionId != null  and qwAcquisitionId != ''"> and qw_acquisition_id = #{qwAcquisitionId}</if>
+            <if test="phone != null  and phone != ''"> and phone like concat('%', #{phone}, '%')</if>
+            <if test="number != null "> and number = #{number}</if>
+            <if test="tempId != null "> and temp_id = #{tempId}</if>
+            <if test="type != null  and type != ''"> and type like concat('%', #{type}, '%')</if>
+            <if test="content != null  and content != ''"> and content like concat('%', #{content}, '%')</if>
+            <if test="result != null  and result != ''"> and result like concat('%', #{result}, '%')</if>
+            <if test="remark != null  and remark != ''"> and remark like concat('%', #{remark}, '%')</if>
+        </where>
+        order by create_time desc
+    </select>
+
+    <select id="selectQwAcquisitionSendMsgLogById" parameterType="Long" resultMap="QwAcquisitionSendMsgLogResult">
+        <include refid="selectQwAcquisitionSendMsgLogVo"/>
+        where id = #{id}
+    </select>
+
+    <insert id="insertQwAcquisitionSendMsgLog" parameterType="com.fs.qw.domain.QwAcquisitionSendMsgLog" useGeneratedKeys="true" keyProperty="id">
+        insert into qw_acquisition_send_msg_log
+        <trim prefix="(" suffix=")" suffixOverrides=",">
+            <if test="companySmsLogsId != null and companySmsLogsId != ''">company_sms_logs_id,</if>
+            <if test="qwAcquisitionId != null and qwAcquisitionId != ''">qw_acquisition_id,</if>
+            <if test="phone != null and phone != ''">phone,</if>
+            <if test="number != null">number,</if>
+            <if test="tempId != null">temp_id,</if>
+            <if test="type != null and type != ''">type,</if>
+            <if test="content != null and content != ''">content,</if>
+            <if test="result != null and result != ''">result,</if>
+            <if test="remark != null and remark != ''">remark,</if>
+            <if test="createBy != null and createBy != ''">create_by,</if>
+            create_time,
+        </trim>
+        <trim prefix="values (" suffix=")" suffixOverrides=",">
+            <if test="companySmsLogsId != null and companySmsLogsId != ''">#{companySmsLogsId},</if>
+            <if test="qwAcquisitionId != null and qwAcquisitionId != ''">#{qwAcquisitionId},</if>
+            <if test="phone != null and phone != ''">#{phone},</if>
+            <if test="number != null">#{number},</if>
+            <if test="tempId != null">#{tempId},</if>
+            <if test="type != null and type != ''">#{type},</if>
+            <if test="content != null and content != ''">#{content},</if>
+            <if test="result != null and result != ''">#{result},</if>
+            <if test="remark != null and remark != ''">#{remark},</if>
+            <if test="createBy != null and createBy != ''">#{createBy},</if>
+            sysdate(),
+        </trim>
+    </insert>
+
+    <update id="updateQwAcquisitionSendMsgLog" parameterType="com.fs.qw.domain.QwAcquisitionSendMsgLog">
+        update qw_acquisition_send_msg_log
+        <trim prefix="SET" suffixOverrides=",">
+            <if test="companySmsLogsId != null and companySmsLogsId != ''">company_sms_logs_id = #{companySmsLogsId},</if>
+            <if test="qwAcquisitionId != null and qwAcquisitionId != ''">qw_acquisition_id = #{qwAcquisitionId},</if>
+            <if test="phone != null and phone != ''">phone = #{phone},</if>
+            <if test="number != null">number = #{number},</if>
+            <if test="tempId != null">temp_id = #{tempId},</if>
+            <if test="type != null and type != ''">type = #{type},</if>
+            <if test="content != null and content != ''">content = #{content},</if>
+            <if test="result != null and result != ''">result = #{result},</if>
+            <if test="remark != null and remark != ''">remark = #{remark},</if>
+            <if test="updateBy != null and updateBy != ''">update_by = #{updateBy},</if>
+            update_time = sysdate(),
+        </trim>
+        where id = #{id}
+    </update>
+
+    <delete id="deleteQwAcquisitionSendMsgLogById" parameterType="Long">
+        delete from qw_acquisition_send_msg_log where id = #{id}
+    </delete>
+
+    <delete id="deleteQwAcquisitionSendMsgLogByIds" parameterType="String">
+        delete from qw_acquisition_send_msg_log where id in
+        <foreach item="id" collection="array" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </delete>
+</mapper>

+ 115 - 0
fs-service/src/main/resources/mapper/qw/QwCourseLinkSendMsgLogMapper.xml

@@ -0,0 +1,115 @@
+<?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.qw.mapper.QwCourseLinkSendMsgLogMapper">
+
+    <resultMap type="com.fs.qw.domain.QwCourseLinkSendMsgLog" id="QwCourseLinkSendMsgLogResult">
+        <result property="id"    column="id"    />
+        <result property="companySmsLogsId"    column="company_sms_logs_id"    />
+        <result property="courseId"    column="course_id"    />
+        <result property="videoId"    column="video_id"    />
+        <result property="number"    column="number"    />
+        <result property="tempId"    column="temp_id"    />
+        <result property="externalContactId"    column="external_contact_id"    />
+        <result property="type"    column="type"    />
+        <result property="phone"    column="phone"    />
+        <result property="content"    column="content"    />
+        <result property="result"    column="result"    />
+        <result property="remark"    column="remark"    />
+        <result property="createBy"    column="create_by"    />
+        <result property="createTime"    column="create_time"    />
+    </resultMap>
+
+    <sql id="selectQwCourseLinkSendMsgLogVo">
+        select id,company_sms_logs_id,course_id,video_id, number, temp_id, external_contact_id, type, phone, content, result, remark, create_by, create_time from qw_course_link_send_msg_log
+    </sql>
+
+    <select id="selectQwCourseLinkSendMsgLogList" parameterType="com.fs.qw.domain.QwCourseLinkSendMsgLog" resultMap="QwCourseLinkSendMsgLogResult">
+        <include refid="selectQwCourseLinkSendMsgLogVo"/>
+        <where>
+            <if test="companySmsLogsId != null  and companySmsLogsId != ''"> and company_sms_logs_id = #{companySmsLogsId}</if>
+            <if test="courseId != null  and courseId != ''"> and course_id = #{courseId}</if>
+            <if test="videoId != null  and videoId != ''"> and video_id = #{videoId}</if>
+            <if test="number != null "> and number = #{number}</if>
+            <if test="tempId != null "> and temp_id = #{tempId}</if>
+            <if test="externalContactId != null "> and external_contact_id = #{externalContactId}</if>
+            <if test="type != null  and type != ''"> and type like concat('%', #{type}, '%')</if>
+            <if test="phone != null  and phone != ''"> and phone like concat('%', #{phone}, '%')</if>
+            <if test="content != null  and content != ''"> and content like concat('%', #{content}, '%')</if>
+            <if test="result != null  and result != ''"> and result like concat('%', #{result}, '%')</if>
+            <if test="remark != null  and remark != ''"> and remark like concat('%', #{remark}, '%')</if>
+        </where>
+        order by create_time desc
+    </select>
+
+    <select id="selectQwCourseLinkSendMsgLogById" parameterType="Long" resultMap="QwCourseLinkSendMsgLogResult">
+        <include refid="selectQwCourseLinkSendMsgLogVo"/>
+        where id = #{id}
+    </select>
+
+    <insert id="insertQwCourseLinkSendMsgLog" parameterType="com.fs.qw.domain.QwCourseLinkSendMsgLog" useGeneratedKeys="true" keyProperty="id">
+        insert into qw_course_link_send_msg_log
+        <trim prefix="(" suffix=")" suffixOverrides=",">
+            <if test="companySmsLogsId != null and companySmsLogsId != ''">company_sms_logs_id,</if>
+            <if test="courseId != null and courseId != ''">course_id,</if>
+            <if test="videoId != null and videoId != ''">video_id,</if>
+            <if test="number != null">number,</if>
+            <if test="tempId != null">temp_id,</if>
+            <if test="externalContactId != null">external_contact_id,</if>
+            <if test="type != null and type != ''">type,</if>
+            <if test="phone != null and phone != ''">phone,</if>
+            <if test="content != null and content != ''">content,</if>
+            <if test="result != null and result != ''">result,</if>
+            <if test="remark != null and remark != ''">remark,</if>
+            <if test="createBy != null and createBy != ''">create_by,</if>
+            create_time,
+        </trim>
+        <trim prefix="values (" suffix=")" suffixOverrides=",">
+            <if test="companySmsLogsId != null and companySmsLogsId != ''">#{companySmsLogsId},</if>
+            <if test="courseId != null and courseId != ''">#{courseId},</if>
+            <if test="videoId != null and videoId != ''">#{videoId},</if>
+            <if test="number != null">#{number},</if>
+            <if test="tempId != null">#{tempId},</if>
+            <if test="externalContactId != null">#{externalContactId},</if>
+            <if test="type != null and type != ''">#{type},</if>
+            <if test="phone != null and phone != ''">#{phone},</if>
+            <if test="content != null and content != ''">#{content},</if>
+            <if test="result != null and result != ''">#{result},</if>
+            <if test="remark != null and remark != ''">#{remark},</if>
+            <if test="createBy != null and createBy != ''">#{createBy},</if>
+            sysdate(),
+        </trim>
+    </insert>
+
+    <update id="updateQwCourseLinkSendMsgLog" parameterType="com.fs.qw.domain.QwCourseLinkSendMsgLog">
+        update qw_course_link_send_msg_log
+        <trim prefix="SET" suffixOverrides=",">
+            <if test="companySmsLogsId != null and companySmsLogsId != ''">company_sms_logs_id = #{companySmsLogsId},</if>
+            <if test="courseId != null and courseId != ''">course_id = #{courseId},</if>
+            <if test="videoId != null and videoId != ''">video_id = #{videoId},</if>
+            <if test="number != null">number = #{number},</if>
+            <if test="tempId != null">temp_id = #{tempId},</if>
+            <if test="externalContactId != null">external_contact_id = #{externalContactId},</if>
+            <if test="type != null and type != ''">type = #{type},</if>
+            <if test="phone != null and phone != ''">phone = #{phone},</if>
+            <if test="content != null and content != ''">content = #{content},</if>
+            <if test="result != null and result != ''">result = #{result},</if>
+            <if test="remark != null and remark != ''">remark = #{remark},</if>
+            <if test="updateBy != null and updateBy != ''">update_by = #{updateBy},</if>
+            update_time = sysdate(),
+        </trim>
+        where id = #{id}
+    </update>
+
+    <delete id="deleteQwCourseLinkSendMsgLogById" parameterType="Long">
+        delete from qw_course_link_send_msg_log where id = #{id}
+    </delete>
+
+    <delete id="deleteQwCourseLinkSendMsgLogByIds" parameterType="String">
+        delete from qw_course_link_send_msg_log where id in
+        <foreach item="id" collection="array" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </delete>
+</mapper>