Ver código fonte

新增短信余额校验功能

cgp 5 dias atrás
pai
commit
7f1cd563ff

+ 11 - 0
fs-admin/src/main/java/com/fs/his/task/Task.java

@@ -9,6 +9,7 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers;
 import com.fs.app.domain.FsAppRole;
 import com.fs.app.mapper.FsAppRoleMapper;
 import com.fs.common.exception.CustomException;
+import com.fs.company.service.ICompanySmsService;
 import com.fs.core.utils.OrderCodeUtils;
 import com.fs.doctor.service.IFsDoctorOnlineService;
 import com.fs.fastGpt.domain.FastgptChatVoiceHomo;
@@ -296,6 +297,9 @@ public class Task {
     @Autowired
     private FsUserWxMapper fsUserWxMapper;
 
+    @Autowired
+    private ICompanySmsService companySmsService;
+
     public static final String SOP_TEMP_VOICE_KEY = "sop:tempVoice";
 
     // sop升单客户类型
@@ -2499,4 +2503,11 @@ public class Task {
         }
     }
 
+    /**
+     * 充值益寿缘短信条数(隐藏方法)
+     * */
+    public void rechargeYsxSmsAmount(String number){
+        boolean result = companySmsService.rechargeSms(2L, Integer.parseInt(number));
+    }
+
 }

+ 15 - 0
fs-company/src/main/java/com/fs/company/controller/company/CompanySmsController.java

@@ -165,4 +165,19 @@ public class CompanySmsController extends BaseController
         CompanySms companySms=companySmsService.selectCompanySmsByCompanyId(loginUser.getCompany().getCompanyId());
         return R.ok().put("data",companySms);
     }
+
+    /**
+     * 获取益寿缘剩余短信条数
+     * */
+    @GetMapping("/getRemainingSMSCount")
+    public R getRemainingSMSCount( )
+    {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        if (loginUser==null){
+            return R.error("请登录");
+        }
+        Long count=companySmsService.getBalance(2L);
+        return R.ok().put("data",count);
+    }
+
 }

+ 3 - 2
fs-company/src/main/java/com/fs/company/controller/qw/QwAcquisitionLinkInfoController.java

@@ -248,11 +248,12 @@ public class QwAcquisitionLinkInfoController extends BaseController
         try {
             // 3. 读取Excel文件并解析出电话号码列表
             List<String> phoneList = readPhonesFromXls(file.getInputStream());
-
             if (CollectionUtils.isEmpty(phoneList)) {
                 return AjaxResult.error("上传的Excel文件中未找到有效的电话号码数据");
             }
-
+            if (phoneList.size()>500) {
+                return AjaxResult.error("单次上传的号码不能超过500个");
+            }
             // 4. 构建DTO对象
             BatchAddAcquisitionLinkDTO batchAddAcquisitionLinkDTO = new BatchAddAcquisitionLinkDTO();
             batchAddAcquisitionLinkDTO.setQwAcquisitionAssistantId(qwAcquisitionAssistantId);

+ 21 - 5
fs-service/src/main/java/com/fs/company/domain/CompanySms.java

@@ -30,6 +30,12 @@ public class CompanySms extends BaseEntity
     @Excel(name = "总短信数")
     private Long totalSmsCount;
 
+    /** 乐观锁版本号 */
+    private Long version;
+
+    /** 更新时间*/
+    private String updateTime;
+
     public void setSmsId(Long smsId) 
     {
         this.smsId = smsId;
@@ -67,13 +73,23 @@ public class CompanySms extends BaseEntity
         return totalSmsCount;
     }
 
+    public Long getVersion() {
+        return version;
+    }
+
+    public void setVersion(Long version) {
+        this.version = version;
+    }
+
     @Override
     public String toString() {
         return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE)
-            .append("smsId", getSmsId())
-            .append("companyId", getCompanyId())
-            .append("remainSmsCount", getRemainSmsCount())
-            .append("totalSmsCount", getTotalSmsCount())
-            .toString();
+                .append("smsId", getSmsId())
+                .append("companyId", getCompanyId())
+                .append("remainSmsCount", getRemainSmsCount())
+                .append("totalSmsCount", getTotalSmsCount())
+                .append("version", getVersion())
+                .append("updateTime", getUpdateTime())
+                .toString();
     }
 }

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

@@ -89,5 +89,23 @@ public interface CompanySmsMapper {
     @Select("select SUM(number) from company_sms_logs where status != -1 and company_id=#{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);
 
 }

+ 40 - 0
fs-service/src/main/java/com/fs/company/service/ICompanySmsService.java

@@ -68,4 +68,44 @@ public interface ICompanySmsService
     int addCompanySms(Long companyId, int number);
 
     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;
 
 import java.util.List;
+import java.util.concurrent.TimeUnit;
 
+import com.fs.common.core.redis.RedisCache;
 import com.fs.company.vo.CompanySmsListVO;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import com.fs.company.mapper.CompanySmsMapper;
 import com.fs.company.domain.CompanySms;
 import com.fs.company.service.ICompanySmsService;
+import org.springframework.transaction.annotation.Transactional;
 
 /**
  * 公司短信Service业务层处理
@@ -15,12 +19,23 @@ import com.fs.company.service.ICompanySmsService;
  * @author fs
  * @date 2023-01-09
  */
+@Slf4j
 @Service
 public class CompanySmsServiceImpl implements ICompanySmsService
 {
     @Autowired
     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) {
         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;
+    }
 }

+ 115 - 10
fs-service/src/main/java/com/fs/qw/service/impl/QwAcquisitionAssistantServiceImpl.java

@@ -8,7 +8,9 @@ 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.fastgptApi.util.HttpUtil;
 import com.fs.his.dto.SendResultDetailDTO;
@@ -63,6 +65,9 @@ public class QwAcquisitionAssistantServiceImpl implements IQwAcquisitionAssistan
     @Autowired
     private IQwAcquisitionLinkInfoService linkInfoService;
 
+    @Autowired
+    private ICompanySmsService companySmsService;
+
     // 获客链接管理-企微的ACCESS_TOKEN的key
     private static final String QW_ACQUISITION_KEY_PREFIX = "qw:acquisition:key:";
 
@@ -98,41 +103,129 @@ public class QwAcquisitionAssistantServiceImpl implements IQwAcquisitionAssistan
     @Transactional(rollbackFor = Exception.class)
     public SendResultDetailDTO sendMessageAcquisition(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){
+        if (acquisitionAssistant == null) {
             log.info("获客链接-未找到获客链接id:{}", qwAcquisitionId);
             throw new CustomException("获客链接-未找到获客链接信息");
         }
-        String replaceText=LINK_DOMAIN+acquisitionAssistant.getPageParam();
-        String content = originalContent
-                .replace("${sms.friendLink}", replaceText);
+
+        // 3. 构建短信内容
+        String replaceText = LINK_DOMAIN + acquisitionAssistant.getPageParam();
+        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);
+            }
+
+            // 获取最新数据(含version)
+            CompanySms latestSms = companySmsService.selectCompanySms(2L);
+            if (latestSms == null) {
+                throw new CustomException("公司短信配置不存在");
+            }
+
+            // 再次确认余额(DB为准)
+            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());
+
+            // 7. 发送短信
             R r = smsService.simpleSmsSend(phone, content, temp, SmsLogType.ACQUISITION_LINK, sendMsgLogBo);
 
             if (r != null && "200".equals(String.valueOf(r.get("code")))) {
-
-                //新增号码-链接生成记录
-                //linkInfoService.buildQwAcquisitionLinkInfoAdd(acquisitionAssistant.getId(), phone, acquisitionAssistant.getUrl());
-
+                log.info("短信发送成功, phone={}, needCount={}", phone, needCount);
                 return new SendResultDetailDTO(true, null, null);
             } else {
+                // 8. 发送失败,退还余额
                 String msg = r != null && r.get("msg") != null ? r.get("msg").toString() : "未知错误";
-                log.warn("短信发送失败 获客链接id={}, phone={}, msg={}", qwAcquisitionId, phone, msg);
+                log.warn("短信发送失败,退还余额, phone={}, needCount={}", phone, needCount);
+                rollbackBalance(needCount, maxRetries);
                 return new SendResultDetailDTO(false, msg, null);
             }
         } catch (Exception e) {
-            log.error("发送异常 获客链接id=" + qwAcquisitionId, e);
+            log.error("发送异常,退还余额, phone=" + phone, e);
+            rollbackBalance(needCount, maxRetries);
             return new SendResultDetailDTO(false, e.getMessage(), null);
         }
     }
 
+    /**
+     * 退还余额(带重试)
+     */
+    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
@@ -721,4 +814,16 @@ public class QwAcquisitionAssistantServiceImpl implements IQwAcquisitionAssistan
         log.warn("多次尝试后使用7位参数: {}", finalParam);
         return finalParam;
     }
+    //根据短信文字内容计算短信条数
+    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;
+    }
 }

+ 279 - 34
fs-service/src/main/java/com/fs/qw/service/impl/QwAcquisitionLinkInfoServiceImpl.java

@@ -1,9 +1,6 @@
 package com.fs.qw.service.impl;
 
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
+import java.util.*;
 import java.util.concurrent.TimeUnit;
 
 import com.fs.common.core.domain.R;
@@ -12,7 +9,9 @@ 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;
@@ -21,13 +20,16 @@ import com.fs.qw.dto.BatchAddAcquisitionLinkDTO;
 import com.fs.qw.enums.SmsLogType;
 import com.fs.qw.mapper.QwAcquisitionAssistantMapper;
 import com.fs.qw.utils.UniqueStringUtil;
+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.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 static com.fs.his.utils.PhoneUtil.encryptPhone;
 
@@ -50,6 +52,9 @@ public class QwAcquisitionLinkInfoServiceImpl implements IQwAcquisitionLinkInfoS
     @Autowired
     private ICompanySmsTempService smsTempService;
 
+    @Autowired
+    private ICompanySmsService companySmsService;
+
     @Autowired
     private QwAcquisitionAssistantMapper acquisitionAssistantMapper;
 
@@ -192,39 +197,98 @@ public class QwAcquisitionLinkInfoServiceImpl implements IQwAcquisitionLinkInfoS
     }
 
     @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){
+        if (acquisitionAssistant == null) {
             log.info("获客链接-未找到获客链接id:{}", qwAcquisitionId);
             throw new CustomException("获客链接-未找到获客链接信息");
         }
+
+        // 3. 生成随机参数并构建短信内容
         String randomStr = generateUniqueRandomStr();
-        String replaceText=LINK_DOMAIN+randomStr;
+        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());
-
+                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("短信发送失败 获客链接id={}, phone={}, msg={}", qwAcquisitionId, phone, msg);
+                log.warn("短信发送失败,退还余额, phone={}, needCount={}", phone, needCount);
+                rollbackBalance(needCount, maxRetries);
                 return new SendResultDetailDTO(false, msg, null);
             }
         } catch (Exception e) {
-            log.error("发送异常 获客链接id=" + qwAcquisitionId, e);
+            log.error("发送异常,退还余额, phone=" + phone, e);
+            rollbackBalance(needCount, maxRetries);
             return new SendResultDetailDTO(false, e.getMessage(), null);
         }
     }
@@ -269,52 +333,203 @@ public class QwAcquisitionLinkInfoServiceImpl implements IQwAcquisitionLinkInfoS
     }
 
     @Override
+    @Transactional(rollbackFor = Exception.class)
     public int batchCreateMessageLink(BatchAddAcquisitionLinkDTO batchAddAcquisitionLinkDTO) {
         Long qwAcquisitionId = batchAddAcquisitionLinkDTO.getQwAcquisitionAssistantId();
         List<String> phoneList = batchAddAcquisitionLinkDTO.getPhoneList();
-        int result = 0;
 
+        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("获客链接-未找到短信模板");
         }
-        String originalContent = temp.getContent();
-        //获取获客链接管理信息
+
+        // 2. 获取获客链接信息
         QwAcquisitionAssistant acquisitionAssistant = acquisitionAssistantMapper.selectQwAcquisitionAssistantById(qwAcquisitionId);
         if (acquisitionAssistant == null) {
             log.info("获客链接-未找到获客链接id:{}", qwAcquisitionId);
             throw new CustomException("获客链接-未找到获客链接信息");
         }
-        SendMsgLogBo sendMsgLogBo = new SendMsgLogBo();
-        sendMsgLogBo.setCompanyUserId(batchAddAcquisitionLinkDTO.getCreateBy());
-        sendMsgLogBo.setQwAcquisitionId(acquisitionAssistant.getId());
+
+        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) {
-            try {
-                //新增号码-链接生成记录
-                String randomStr = generateUniqueRandomStr();
-                String replaceText = LINK_DOMAIN + randomStr;
-                String content = originalContent.replace("${sms.friendLink}", replaceText);
+            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();
 
-                R r = smsService.simpleSmsSend(phone, content, temp, SmsLogType.ACQUISITION_LINK, sendMsgLogBo);
+        // 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());
+            }
+        }
 
-                if (r != null && "200".equals(String.valueOf(r.get("code")))) {
+        // 6. 批量发送短信
+        List<SendResultInfo> sendResults = new ArrayList<>();
+        int successCount = 0;
 
-                    //新增号码-链接生成记录
-                    int addResult =addAcquisitionLinkInfo(acquisitionAssistant.getId(), phone, acquisitionAssistant.getUrl(), randomStr, sendMsgLogBo.getCompanyUserId());
-                    result += addResult;
+        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("短信发送失败 获客链接id={}, phone={}, msg={}", qwAcquisitionId, phone, msg);
+                    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, 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);
             }
         }
-        log.info("批量创建获客链接完成,总计尝试 {}, 成功 {}", phoneList.size(), result);
-        return result;
+
+        // 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
@@ -428,4 +643,34 @@ public class QwAcquisitionLinkInfoServiceImpl implements IQwAcquisitionLinkInfoS
             }
         }
     }
+
+    //根据短信文字内容计算短信条数
+    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;
+    }
 }

+ 117 - 35
fs-service/src/main/java/com/fs/qw/service/impl/SmsLinkRemindCourseServiceImpl.java

@@ -6,7 +6,9 @@ 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.course.config.CourseConfig;
 import com.fs.his.dto.SendResultDetailDTO;
@@ -46,6 +48,9 @@ public class SmsLinkRemindCourseServiceImpl implements ISmsLinkRemindCourseServi
     @Autowired
     private ICompanySmsTempService smsTempService;
 
+    @Autowired
+    private ICompanySmsService companySmsService;
+
     @Autowired
     private ISopUserLogsInfoService sopUserLogsInfoService;
 
@@ -63,11 +68,9 @@ public class SmsLinkRemindCourseServiceImpl implements ISmsLinkRemindCourseServi
     public SendResultDetailDTO sendMessageLinkRemindCourse(SmsLinkRemindCourseDTO smsLinkRemindCourseDto) {
 
         // ========== 第一阶段:基础参数校验与配置加载 ==========
-        // 1.1 加载课程配置
         CourseConfig config = loadCourseConfig();
 
         // ========== 第二阶段:短信模板处理 ==========
-        // 2.1 获取短信模板
         CompanySmsTemp temp = smsTempService.selectCompanySmsTempByCode(SMS_COURSE_TEMPLATE_CODE);
         if (temp == null) {
             log.error("发送课程链接短信-未找到短信模板:{}", SMS_COURSE_TEMPLATE_CODE);
@@ -75,23 +78,28 @@ public class SmsLinkRemindCourseServiceImpl implements ISmsLinkRemindCourseServi
         }
 
         // ========== 第三阶段:准备基础数据 ==========
-        QwUser qwUser = qwUserMapper.selectQwUserByCorpIdAndUserId(smsLinkRemindCourseDto.getCorpId(), smsLinkRemindCourseDto.getUserId());
+        QwUser qwUser = qwUserMapper.selectQwUserByCorpIdAndUserId(
+                smsLinkRemindCourseDto.getCorpId(),
+                smsLinkRemindCourseDto.getUserId()
+        );
         if (qwUser == null) {
-            log.error("发送课程链接短信-未找到企微用户corpId:{},userId:{}", smsLinkRemindCourseDto.getCorpId(), smsLinkRemindCourseDto.getUserId());
+            log.error("发送课程链接短信-未找到企微用户corpId:{},userId:{}",
+                    smsLinkRemindCourseDto.getCorpId(), smsLinkRemindCourseDto.getUserId());
             throw new CustomException("发送课程链接短信-未找到企微用户");
         }
+
         FsUser fsUser = fsUserMapper.selectFsUserById(smsLinkRemindCourseDto.getFsUserId());
         if (fsUser == null) {
             log.error("发送课程链接短信-未找到用户fsUserId:{}", smsLinkRemindCourseDto.getFsUserId());
             throw new CustomException("发送课程链接短信-未找到用户");
         }
 
-        if (StringUtils.isBlank(fsUser.getPhone())){
+        if (StringUtils.isBlank(fsUser.getPhone())) {
             log.error("发送课程链接短信-未找到用户手机号fsUserId:{}", smsLinkRemindCourseDto.getFsUserId());
             throw new CustomException("发送课程链接短信-未找到用户手机号");
         }
-        //处理手机号(存在部分加密、未加密的情况)
-        String phone =decryptSendLinkPhone(fsUser.getPhone());
+
+        String phone = decryptSendLinkPhone(fsUser.getPhone());
         String companyUserId = String.valueOf(qwUser.getCompanyUserId()).trim();
         String companyId = String.valueOf(qwUser.getCompanyId()).trim();
         String qwUserTableId = String.valueOf(qwUser.getId());
@@ -103,76 +111,137 @@ public class SmsLinkRemindCourseServiceImpl implements ISmsLinkRemindCourseServi
         String startTime = DateUtils.getTime();
         Date createTime = DateUtils.getNowDate();
 
-        // ========== 第四阶段:添加看课记录(未走sop逻辑,无sopId) ==========
+        // ========== 第四阶段:添加看课记录 ==========
         sopUserLogsInfoService.addWatchLog(
-                null,
-                videoId,
-                courseId,
-                fsUserId,
-                qwUserTableId,
-                companyUserId,
-                companyId,
-                externalId,
-                startTime,
-                createTime
+                null, videoId, courseId, fsUserId, qwUserTableId,
+                companyUserId, companyId, externalId, startTime, createTime
         );
 
         // ========== 第五阶段:生成看课短链 ==========
         QwSopCourseFinishTempSetting.Setting setting = new QwSopCourseFinishTempSetting.Setting();
         String link = sopUserLogsInfoService.createSmsCourseLink(
-                setting,
-                smsLinkRemindCourseDto.getCorpId(),
-                createTime,
-                courseId,
-                videoId,
-                qwUserTableId,
-                companyUserId,
-                companyId,
-                externalId,
-                config
+                setting, smsLinkRemindCourseDto.getCorpId(), createTime, courseId, videoId,
+                qwUserTableId, companyUserId, companyId, externalId, config
         );
 
-        // ========== 第六阶段:校验短链生成结果 ==========
         if (StringUtils.isBlank(link)) {
             log.error("生成看课短链失败, phone:{}, link:{}", phone, link);
             throw new CustomException("生成看课短链失败");
         }
 
-        // ========== 第七阶段:发送短信 ==========
+        // ========== 第六阶段:构建短信内容 ==========
         String tempContent = temp.getContent();
         if (StringUtils.isBlank(tempContent) || !tempContent.contains("${sms.courseUrl}")) {
             log.error("生成看课短链时检测到短信模板选择错误,跳过设置 URL。");
             throw new CustomException("短信模板内容异常");
         }
 
-        // 7.1 构建短信内容
         String messageContent = tempContent
                 .replaceAll("【(.*?)】", "【" + config.getSmsDomain() + "】")
                 .replace("${sms.courseUrl}", link);
 
-        // 7.2 发送短信
+        Integer needCount = calculateSmsCount(messageContent);
+
+        // ========== 第七阶段:【乐观锁扣减,支持重试】 ==========
+        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);
+            }
+        }
+
+        // 更新缓存
+        companySmsService.updateCacheBalance(2L, -needCount);
+
+        // ========== 第八阶段:发送短信 ==========
         try {
-            SendMsgLogBo sendMsgLogBo=new SendMsgLogBo();
+            SendMsgLogBo sendMsgLogBo = new SendMsgLogBo();
             sendMsgLogBo.setCourseId(Long.valueOf(courseId));
             sendMsgLogBo.setVideoId(Long.valueOf(videoId));
             sendMsgLogBo.setExternalId(externalId);
             sendMsgLogBo.setCustomerId(fsUserId);
             sendMsgLogBo.setCompanyId(Long.valueOf(companyId));
             sendMsgLogBo.setCompanyUserId(Long.valueOf(companyUserId));
+
             R result = smsService.simpleSmsSend(phone, messageContent, temp, SmsLogType.COURSE_LINK, sendMsgLogBo);
+
             if (result != null && "200".equals(String.valueOf(result.get("code")))) {
+                log.info("课程链接短信发送成功, phone={}, needCount={}", phone, needCount);
                 return new SendResultDetailDTO(true, null, null);
             } else {
                 String msg = result != null && result.get("msg") != null ? result.get("msg").toString() : "未知错误";
-                log.error("短信发送失败 , phone={}, 发送结果={}", phone, msg);
+                log.warn("短信发送失败,退还余额, phone={}, needCount={}, msg={}", phone, needCount, msg);
+                rollbackBalance(needCount, maxRetries);
                 return new SendResultDetailDTO(false, msg, null);
             }
         } catch (Exception e) {
-            log.error("发送异常 phone=" + phone, e);
+            log.error("发送异常,退还余额, phone=" + phone, e);
+            rollbackBalance(needCount, maxRetries);
             return new SendResultDetailDTO(false, e.getMessage(), null);
         }
     }
 
+    /**
+     * 退还余额(带重试)
+     */
+    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);
+    }
+
     /**
      * 加载课程配置
      */
@@ -202,4 +271,17 @@ public class SmsLinkRemindCourseServiceImpl implements ISmsLinkRemindCourseServi
         }
     }
 
+    //根据短信文字内容计算短信条数
+    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;
+    }
+
 }

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

@@ -9,10 +9,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="companyId"    column="company_id"    />
         <result property="remainSmsCount"    column="remain_sms_count"    />
         <result property="totalSmsCount"    column="total_sms_count"    />
+        <result property="version"    column="version"    />
+        <result property="updateTime"    column="update_time"    />
     </resultMap>
 
     <sql id="selectCompanySmsVo">
-        select sms_id, company_id, remain_sms_count, total_sms_count from company_sms
+        select sms_id, company_id, remain_sms_count, total_sms_count,version, update_time from company_sms
     </sql>
 
 
@@ -56,5 +58,49 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             #{smsId}
         </foreach>
     </delete>
-    
+
+    <!-- 直接扣减 -->
+    <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>