lmx 6 часов назад
Родитель
Сommit
e7c968f0f6
24 измененных файлов с 1635 добавлено и 176 удалено
  1. 83 0
      fs-admin-saas/src/main/java/com/fs/company/controller/AdminSaasVoiceSeatController.java
  2. 188 0
      fs-admin/src/main/java/com/fs/admin/controller/VoiceSeatController.java
  3. 30 15
      fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSipCallUserController.java
  4. 2 4
      fs-service/src/main/java/com/fs/aiSipCall/service/IAiSipCallUserService.java
  5. 71 64
      fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipCallUserServiceImpl.java
  6. 6 0
      fs-service/src/main/java/com/fs/company/domain/CompanyExtensionBind.java
  7. 12 0
      fs-service/src/main/java/com/fs/company/mapper/CcExtNumMapper.java
  8. 27 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyExtensionBindMapper.java
  9. 16 0
      fs-service/src/main/java/com/fs/company/param/AssignExtensionToCompanyParam.java
  10. 25 0
      fs-service/src/main/java/com/fs/company/param/SaasVoiceSeatQueryParam.java
  11. 27 0
      fs-service/src/main/java/com/fs/company/service/ISaasVoiceSeatService.java
  12. 250 0
      fs-service/src/main/java/com/fs/company/service/easycall/CcExtNumAllocator.java
  13. 4 85
      fs-service/src/main/java/com/fs/company/service/impl/CompanyExtensionBindServiceImpl.java
  14. 88 0
      fs-service/src/main/java/com/fs/company/service/impl/SaasVoiceSeatServiceImpl.java
  15. 43 0
      fs-service/src/main/java/com/fs/company/vo/SaasVoiceSeatVO.java
  16. 57 0
      fs-service/src/main/java/com/fs/proxy/domain/TenantExtensionBind.java
  17. 33 0
      fs-service/src/main/java/com/fs/proxy/mapper/TenantExtensionBindMapper.java
  18. 22 0
      fs-service/src/main/java/com/fs/proxy/param/BatchCreateTenantExtensionParam.java
  19. 45 0
      fs-service/src/main/java/com/fs/proxy/service/ITenantExtensionBindService.java
  20. 261 0
      fs-service/src/main/java/com/fs/proxy/service/impl/TenantExtensionBindServiceImpl.java
  21. 32 4
      fs-service/src/main/resources/db/tenant-initTable.sql
  22. 18 0
      fs-service/src/main/resources/mapper/company/CcExtNumMapper.xml
  23. 161 4
      fs-service/src/main/resources/mapper/company/CompanyExtensionBindMapper.xml
  24. 134 0
      fs-service/src/main/resources/mapper/proxy/TenantExtensionBindMapper.xml

+ 83 - 0
fs-admin-saas/src/main/java/com/fs/company/controller/AdminSaasVoiceSeatController.java

@@ -0,0 +1,83 @@
+package com.fs.company.controller;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.company.param.AssignExtensionToCompanyParam;
+import com.fs.company.param.SaasVoiceSeatQueryParam;
+import com.fs.company.service.ISaasVoiceSeatService;
+import com.fs.company.vo.SaasVoiceSeatVO;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 租户管理端分机管理
+ *
+ * @author MixLiu
+ * @date 2026/6/8
+ */
+@RestController
+@RequestMapping("/adminSaas/voiceSeat")
+public class AdminSaasVoiceSeatController extends BaseController {
+
+    @Autowired
+    private ISaasVoiceSeatService saasVoiceSeatService;
+
+    /**
+     * 分页查询分机列表
+     */
+    @PreAuthorize("@ss.hasPermi('company:companyVoiceCaller:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(SaasVoiceSeatQueryParam param) {
+        startPage();
+        List<SaasVoiceSeatVO> list = saasVoiceSeatService.selectList(param);
+        return getDataTable(list);
+    }
+
+    /**
+     * 分机池未分配列表
+     */
+    @PreAuthorize("@ss.hasPermi('company:companyVoiceCaller:list')")
+    @GetMapping("/poolList")
+    public TableDataInfo poolList(SaasVoiceSeatQueryParam param) {
+        startPage();
+        List<SaasVoiceSeatVO> list = saasVoiceSeatService.selectPoolList(param);
+        return getDataTable(list);
+    }
+
+    /**
+     * 分机详情
+     */
+    @PreAuthorize("@ss.hasPermi('company:companyVoiceCaller:list')")
+    @GetMapping("/{id}")
+    public AjaxResult getInfo(@PathVariable Long id) {
+        SaasVoiceSeatVO data = saasVoiceSeatService.selectById(id);
+        if (data == null) {
+            return AjaxResult.error("分机记录不存在");
+        }
+        return AjaxResult.success(data);
+    }
+
+    /**
+     * 分配分机到公司
+     */
+    @PreAuthorize("@ss.hasPermi('company:companyVoiceCaller:bind')")
+    @Log(title = "分配分机到公司", businessType = BusinessType.UPDATE)
+    @PostMapping("/assign")
+    public AjaxResult assign(@RequestBody AssignExtensionToCompanyParam param) {
+        try {
+            int rows = saasVoiceSeatService.assignToCompany(param);
+            if (rows <= 0) {
+                return AjaxResult.error("分配失败,请确认所选分机均在分机池且状态可用");
+            }
+            return AjaxResult.success("成功分配 " + rows + " 个分机");
+        } catch (IllegalArgumentException e) {
+            return AjaxResult.error(e.getMessage());
+        }
+    }
+}

+ 188 - 0
fs-admin/src/main/java/com/fs/admin/controller/VoiceSeatController.java

@@ -0,0 +1,188 @@
+package com.fs.admin.controller;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.enums.DataSourceType;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import com.fs.proxy.domain.TenantExtensionBind;
+import com.fs.proxy.param.BatchCreateTenantExtensionParam;
+import com.fs.proxy.service.ITenantExtensionBindService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 租户坐席(分机)管理
+ *
+ * @author MixLiu
+ * @date 2026/6/8
+ */
+@RestController
+@RequestMapping("/admin/voiceSeat")
+public class VoiceSeatController extends BaseController {
+
+    @Autowired(required = false)
+    private ITenantExtensionBindService tenantExtensionBindService;
+
+    /**
+     * 分页查询租户分机列表
+     */
+    @GetMapping("/list")
+    public TableDataInfo list(TenantExtensionBind param) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        startPage();
+        List<TenantExtensionBind> list = tenantExtensionBindService != null
+                ? tenantExtensionBindService.selectList(param)
+                : new ArrayList<>();
+        return getDataTable(list);
+    }
+
+    /**
+     * 获取分机详情
+     */
+    @GetMapping("/{id}")
+    public AjaxResult getInfo(@PathVariable Long id) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        if (tenantExtensionBindService == null) {
+            return AjaxResult.error("服务未就绪");
+        }
+        TenantExtensionBind data = tenantExtensionBindService.selectById(id);
+        if (data == null) {
+            return AjaxResult.error("分机记录不存在");
+        }
+        return AjaxResult.success(data);
+    }
+
+    /**
+     * 一键创建租户分机
+     */
+    @Log(title = "创建租户分机", businessType = BusinessType.INSERT)
+    @PostMapping("/batchCreate")
+    public AjaxResult batchCreate(@RequestBody BatchCreateTenantExtensionParam param) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        if (tenantExtensionBindService == null) {
+            return AjaxResult.error("服务未就绪");
+        }
+        if (param.getTenantId() == null) {
+            return AjaxResult.error("请选择租户");
+        }
+        if (param.getCreateNum() == null || param.getCreateNum() <= 0) {
+            return AjaxResult.error("生成数量必须大于0");
+        }
+        if (StringUtils.isEmpty(param.getPassword())) {
+            param.setPassword("123456");
+        }
+        try {
+            int count = tenantExtensionBindService.batchCreateExtension(param, getUsername());
+            if (count <= 0) {
+                return AjaxResult.error("分机创建失败,请重试");
+            }
+            return AjaxResult.success("成功创建" + count + "个分机");
+        } catch (IllegalArgumentException e) {
+            return AjaxResult.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 更新单条分机状态
+     */
+    @Log(title = "更新租户分机状态", businessType = BusinessType.UPDATE)
+    @PutMapping("/status")
+    public AjaxResult updateStatus(@RequestBody Map<String, Object> body) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        if (tenantExtensionBindService == null) {
+            return AjaxResult.error("服务未就绪");
+        }
+        if (body.get("id") == null) {
+            return AjaxResult.error("请选择分机记录");
+        }
+        if (body.get("status") == null) {
+            return AjaxResult.error("请指定状态");
+        }
+        Integer status = Integer.valueOf(body.get("status").toString());
+        if (status != 0 && status != 1) {
+            return AjaxResult.error("状态值无效");
+        }
+        Long id = Long.valueOf(body.get("id").toString());
+        return toAjax(tenantExtensionBindService.updateStatus(id, status));
+    }
+
+    /**
+     * 批量更新分机状态
+     */
+    @Log(title = "批量更新租户分机状态", businessType = BusinessType.UPDATE)
+    @PutMapping("/batchStatus")
+    public AjaxResult batchStatus(@RequestBody Map<String, Object> body) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        if (tenantExtensionBindService == null) {
+            return AjaxResult.error("服务未就绪");
+        }
+        List<Long> ids = parseIdList(body.get("ids"));
+        if (ids.isEmpty()) {
+            return AjaxResult.error("请选择要更新的记录");
+        }
+        if (body.get("status") == null) {
+            return AjaxResult.error("请指定状态");
+        }
+        Integer status = Integer.valueOf(body.get("status").toString());
+        if (status != 0 && status != 1) {
+            return AjaxResult.error("状态值无效");
+        }
+        return toAjax(tenantExtensionBindService.batchUpdateStatus(ids, status));
+    }
+
+    /**
+     * 批量逻辑删除分机
+     */
+    @Log(title = "删除租户分机", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        if (tenantExtensionBindService == null) {
+            return AjaxResult.error("服务未就绪");
+        }
+        if (ids == null || ids.length == 0) {
+            return AjaxResult.error("请选择要删除的记录");
+        }
+        return toAjax(tenantExtensionBindService.batchLogicDeleteByIds(java.util.Arrays.asList(ids)));
+    }
+
+    /**
+     * 导出租户分机列表
+     */
+    @Log(title = "导出租户分机", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(TenantExtensionBind param) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        if (tenantExtensionBindService == null) {
+            return AjaxResult.error("服务未就绪");
+        }
+        List<TenantExtensionBind> list = tenantExtensionBindService.selectListForExport(param);
+        ExcelUtil<TenantExtensionBind> util = new ExcelUtil<>(TenantExtensionBind.class);
+        return util.exportExcel(list, "租户分机数据");
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<Long> parseIdList(Object raw) {
+        List<Long> ids = new ArrayList<>();
+        if (raw == null) {
+            return ids;
+        }
+        if (raw instanceof List) {
+            for (Object item : (List<?>) raw) {
+                if (item != null) {
+                    ids.add(Long.valueOf(item.toString()));
+                }
+            }
+        }
+        return ids;
+    }
+}

+ 30 - 15
fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSipCallUserController.java

@@ -109,14 +109,20 @@ public class AiSipCallUserController extends BaseController
         if(aiSipCallUser.getCompanyUserId() == null){
             aiSipCallUser.setCompanyId(loginUser.getCompany().getCompanyId());
             aiSipCallUser.setCompanyUserId(loginUser.getUser().getUserId());
+        } else if (aiSipCallUser.getCompanyId() == null) {
+            aiSipCallUser.setCompanyId(loginUser.getCompany().getCompanyId());
         }
         aiSipCallUser.setCreateBy(loginUser.getUser().getUserName());
-        int i = aiSipCallUserService.insertAiSipCallUserNew(aiSipCallUser);
-        aiSipCallUser.setCreateTime(new Date());
-        ccExtNumMapper.updateUserCodeByExtNum(aiSipCallUser.getExtNum(), aiSipCallUser.getLoginName());
-//        Long tenantId = SecurityUtils.getTenantId();
-//        tenantDataSourceManager.ensureSwitchByTenantId(tenantId);
-        return toAjax(i);
+        try {
+            int i = aiSipCallUserService.insertAiSipCallUserNew(aiSipCallUser);
+            aiSipCallUser.setCreateTime(new Date());
+            if (StringUtils.isNotBlank(aiSipCallUser.getExtNum()) && StringUtils.isNotBlank(aiSipCallUser.getLoginName())) {
+                ccExtNumMapper.updateUserCodeByExtNum(aiSipCallUser.getExtNum(), aiSipCallUser.getLoginName());
+            }
+            return toAjax(i);
+        } catch (RuntimeException e) {
+            return AjaxResult.error(e.getMessage());
+        }
     }
 
     /**
@@ -201,21 +207,30 @@ public class AiSipCallUserController extends BaseController
     {
         Long tenantId = SecurityUtils.getTenantId();
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
-//        aiSipCallUser.setCompanyId(loginUser.getCompany().getCompanyId());
-//        aiSipCallUser.setCompanyUserId(loginUser.getUser().getUserId());
+        if (aiSipCallUser.getCompanyId() == null) {
+            aiSipCallUser.setCompanyId(loginUser.getCompany().getCompanyId());
+        }
         aiSipCallUser.setUpdateBy(loginUser.getUser().getUserName());
         aiSipCallUser.setUpdateTime(new Date());
 
-        AiSipCallUserNewVO aiSipCallUserNewVO = aiSipCallUserService.updateAiSipCallUserNew(aiSipCallUser);
-        if(StringUtils.isNotBlank(aiSipCallUserNewVO.getOldExtNum())){
-            ccExtNumMapper.updateUserCodeByExtNum(aiSipCallUserNewVO.getOldExtNum(), tenantId + "_" + loginUser.getCompany().getCompanyId() + "_" + aiSipCallUserNewVO.getOldExtNum());
-        }
-        if(StringUtils.isNotBlank(aiSipCallUserNewVO.getNewExtNum()) && StringUtils.isNotBlank(aiSipCallUserNewVO.getNewUserCode())){
-            ccExtNumMapper.updateUserCodeByExtNum(aiSipCallUserNewVO.getNewExtNum(), aiSipCallUserNewVO.getNewUserCode());
+        try {
+            AiSipCallUserNewVO aiSipCallUserNewVO = aiSipCallUserService.updateAiSipCallUserNew(aiSipCallUser);
+            if(StringUtils.isNotBlank(aiSipCallUserNewVO.getOldExtNum())){
+                ccExtNumMapper.updateUserCodeByExtNum(aiSipCallUserNewVO.getOldExtNum(),
+                        tenantId + "_" + loginUser.getCompany().getCompanyId() + "_" + aiSipCallUserNewVO.getOldExtNum());
+            }
+            if(StringUtils.isNotBlank(aiSipCallUserNewVO.getNewExtNum()) && StringUtils.isNotBlank(aiSipCallUserNewVO.getNewUserCode())){
+                ccExtNumMapper.updateUserCodeByExtNum(aiSipCallUserNewVO.getNewExtNum(), aiSipCallUserNewVO.getNewUserCode());
+            }
+            return toAjax(1);
+        } catch (RuntimeException e) {
+            return AjaxResult.error(e.getMessage());
         }
-        return toAjax(1);
     }
 
+    /**
+     * 查询本公司可绑定分机(sipUserId 传 0 表示新建绑定)
+     */
     @GetMapping("/getUnBindExtnumNew/{sipUserId}")
     public AjaxResult getUnBindExtnumNew(@PathVariable Long sipUserId)
     {

+ 2 - 4
fs-service/src/main/java/com/fs/aiSipCall/service/IAiSipCallUserService.java

@@ -87,11 +87,9 @@ public interface IAiSipCallUserService extends IService<AiSipCallUser>{
     AjaxResult agentLogin(java.util.Map<String, Object> param);
 
     /**
-     * 查询分机号码改
-     * @param companyId
-     * @return
+     * 查询本公司可绑定的分机(已分配到本公司且未绑员工,修改时含当前员工已绑分机)
      */
-    AjaxResult getUnBindExtnumNew(Long companyId,Long sipUserId);
+    AjaxResult getUnBindExtnumNew(Long companyId, Long sipUserId);
 
     /**
      * 修改sip用户信息New

+ 71 - 64
fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipCallUserServiceImpl.java

@@ -241,25 +241,50 @@ public class AiSipCallUserServiceImpl extends ServiceImpl<AiSipCallUserMapper, A
     }
 
     /**
-     * 查询分机号码改
-     * @param companyId
-     * @param sipUserId
-     * @return
+     * 查询本公司可绑定的分机列表(仅 company_id=当前公司,不含租户分机池)
      */
     @Override
-    public AjaxResult getUnBindExtnumNew(Long companyId,Long sipUserId){
-        AiSipCallUser aiSipCallUser = aiSipCallUserMapper.selectAiSipCallUserByUserId(sipUserId);
-        List<CompanyExtensionBind> unBindList = companyExtensionBindService.selectUnBindAndSelfByCompanyId(companyId, aiSipCallUser!=null ? aiSipCallUser.getCompanyUserId() : -1L);
+    public AjaxResult getUnBindExtnumNew(Long companyId, Long sipUserId) {
+        if (companyId == null || companyId <= 0) {
+            return AjaxResult.error("公司信息无效");
+        }
+        Long queryCompanyUserId = -1L;
+        if (sipUserId != null && sipUserId > 0) {
+            AiSipCallUser sipUser = aiSipCallUserMapper.selectAiSipCallUserByUserId(sipUserId);
+            if (sipUser != null && sipUser.getCompanyUserId() != null) {
+                queryCompanyUserId = sipUser.getCompanyUserId();
+            }
+        }
+        List<CompanyExtensionBind> unBindList = companyExtensionBindService
+                .selectUnBindAndSelfByCompanyId(companyId, queryCompanyUserId);
         List<Map<String, Object>> resultList = new ArrayList<>();
         for (CompanyExtensionBind bind : unBindList) {
             Map<String, Object> map = new HashMap<>();
             map.put("extId", bind.getExtId());
             map.put("extNum", bind.getExtensionNum());
+            map.put("extPass", bind.getExtensionPass());
             resultList.add(map);
         }
         return AjaxResult.success(resultList);
     }
 
+    /**
+     * 校验分机是否可绑定到指定员工
+     */
+    private CompanyExtensionBind resolveBindableExtension(String extNum, Long companyId, Long companyUserId, boolean allowSelf) {
+        CompanyExtensionBind bind = companyExtensionBindService.selectUnBindByExtNum(extNum, companyId);
+        if (bind == null) {
+            throw new RuntimeException("分机号不存在或未分配到本公司,请联系租户管理员分配");
+        }
+        Long boundUserId = bind.getCompanyUserId();
+        if (boundUserId != null && boundUserId > 0) {
+            if (!allowSelf || !boundUserId.equals(companyUserId)) {
+                throw new RuntimeException("分机号已被其他员工绑定,请刷新后重试");
+            }
+        }
+        return bind;
+    }
+
     /**
      * 修改sip用户信息New
      * @param aiSipCallUser
@@ -267,48 +292,34 @@ public class AiSipCallUserServiceImpl extends ServiceImpl<AiSipCallUserMapper, A
      */
     @Override
     @Transactional
-    public AiSipCallUserNewVO updateAiSipCallUserNew(AiSipCallUser aiSipCallUser){
-        AiSipCallUserNewVO result= new AiSipCallUserNewVO();
-        CompanyExtensionBind bind = companyExtensionBindService.selectUnBindByExtNum(String.valueOf(aiSipCallUser.getExtNum()), aiSipCallUser.getCompanyId());
-        if (bind != null && (bind.getCompanyUserId() == null || bind.getCompanyUserId().equals(aiSipCallUser.getCompanyUserId()))) {
-            AiSipCallUser oldUser = aiSipCallUserMapper.selectAiSipCallUserByUserId(aiSipCallUser.getUserId());
-            String oldExtNum = (oldUser != null) ? oldUser.getExtNum() : null;
-            String newExtNum = aiSipCallUser.getExtNum();
-            result.setNewExtNum(newExtNum);
-            result.setOldExtNum(oldExtNum);
-            result.setNewUserCode(aiSipCallUser.getLoginName());
-            if(StringUtils.isEmpty(oldUser.getExtPass()) && StringUtils.isNotBlank(bind.getExtensionPass())){
-                aiSipCallUser.setExtPass(bind.getExtensionPass());
-            }
-            int rows = aiSipCallUserMapper.updateAiSipCallUser(aiSipCallUser);
-            //解除绑定
+    public AiSipCallUserNewVO updateAiSipCallUserNew(AiSipCallUser aiSipCallUser) {
+        AiSipCallUserNewVO result = new AiSipCallUserNewVO();
+        if (aiSipCallUser.getCompanyId() == null || aiSipCallUser.getCompanyId() <= 0) {
+            throw new RuntimeException("公司信息无效");
+        }
+        CompanyExtensionBind bind = resolveBindableExtension(
+                String.valueOf(aiSipCallUser.getExtNum()),
+                aiSipCallUser.getCompanyId(),
+                aiSipCallUser.getCompanyUserId(),
+                true);
+
+        AiSipCallUser oldUser = aiSipCallUserMapper.selectAiSipCallUserByUserId(aiSipCallUser.getUserId());
+        String oldExtNum = (oldUser != null) ? oldUser.getExtNum() : null;
+        String newExtNum = aiSipCallUser.getExtNum();
+        result.setNewExtNum(newExtNum);
+        result.setOldExtNum(oldExtNum);
+        result.setNewUserCode(aiSipCallUser.getLoginName());
+
+        if (oldUser != null && StringUtils.isEmpty(oldUser.getExtPass()) && StringUtils.isNotBlank(bind.getExtensionPass())) {
+            aiSipCallUser.setExtPass(bind.getExtensionPass());
+        }
+        aiSipCallUserMapper.updateAiSipCallUser(aiSipCallUser);
+
+        if (StringUtils.isNotBlank(oldExtNum) && !oldExtNum.equals(newExtNum)) {
             companyExtensionBindService.clearBindByExtNum(oldExtNum, aiSipCallUser.getCompanyId(), aiSipCallUser.getCompanyUserId());
-            //绑定新分机号
-            companyExtensionBindService.updateBindByExtId(bind.getExtId(), aiSipCallUser.getCompanyUserId(), aiSipCallUser.getLoginName());
-        } else {
-            throw new RuntimeException("分机号已被绑定,请刷新后重试");
         }
+        companyExtensionBindService.updateBindByExtId(bind.getExtId(), aiSipCallUser.getCompanyUserId(), aiSipCallUser.getLoginName());
         return result;
-
-//        AiSipCallUser oldUser = baseMapper.selectAiSipCallUserByUserId(aiSipCallUser.getUserId());
-//        String oldExtNum = (oldUser != null) ? oldUser.getExtNum() : null;
-//        String newExtNum = aiSipCallUser.getExtNum();
-//
-//        int rows = baseMapper.updateAiSipCallUser(aiSipCallUser);
-//        if (rows > 0) {
-//            if (oldExtNum != null && !oldExtNum.equals(newExtNum)) {
-//                ccExtNumMapper.updateUserCodeByExtNum(oldExtNum, null);
-//                companyExtensionBindService.clearBindByExtNum(String.valueOf(oldExtNum), aiSipCallUser.getCompanyId());
-//            }
-//            if (newExtNum != null) {
-//                ccExtNumMapper.updateUserCodeByExtNum(newExtNum, aiSipCallUser.getLoginName());
-//                CompanyExtensionBind bind = companyExtensionBindService.selectUnBindByExtNum(String.valueOf(newExtNum), aiSipCallUser.getCompanyId());
-//                if (bind != null) {
-//                    companyExtensionBindService.updateBindByExtId(bind.getExtId(), aiSipCallUser.getCompanyUserId(), aiSipCallUser.getLoginName());
-//                }
-//            }
-//        }
-//        return rows;
     }
 
     /**
@@ -318,30 +329,26 @@ public class AiSipCallUserServiceImpl extends ServiceImpl<AiSipCallUserMapper, A
      */
     @Override
     @Transactional
-    public int insertAiSipCallUserNew(AiSipCallUser aiSipCallUser){
+    public int insertAiSipCallUserNew(AiSipCallUser aiSipCallUser) {
         CompanyUser companyUser = companyUserMapper.selectCompanyUserByCompanyUserId(aiSipCallUser.getCompanyUserId());
+        if (companyUser == null) {
+            throw new RuntimeException("员工信息不存在");
+        }
         if (aiSipCallUser.getCompanyId() == null) {
             aiSipCallUser.setCompanyId(companyUser.getCompanyId());
         }
-//        if(aiSipCallUser.getUserId() == null){
-//            aiSipCallUser.setUserId(companyUser.getUserId());
-//        }
 
         int rows = baseMapper.insertAiSipCallUser(aiSipCallUser);
-        if (rows > 0) {
-            if (aiSipCallUser.getExtNum() != null) {
-//                ccExtNumMapper.updateUserCodeByExtNum(aiSipCallUser.getExtNum(), aiSipCallUser.getLoginName());
-                CompanyExtensionBind bind = companyExtensionBindService.selectUnBindByExtNum(String.valueOf(aiSipCallUser.getExtNum()), aiSipCallUser.getCompanyId());
-                if (bind != null && bind.getCompanyUserId() == null) {
-                    companyExtensionBindService.updateBindByExtId(bind.getExtId(), aiSipCallUser.getCompanyUserId(), aiSipCallUser.getLoginName());
-                }else{
-                    throw new RuntimeException("分机号已被绑定,请刷新后重试");
-                }
-                //获取分机密码
-                if(bind.getExtensionPass() != null){
-                    aiSipCallUser.setExtPass(bind.getExtensionPass());
-                    baseMapper.updateAiSipCallUser(aiSipCallUser);
-                }
+        if (rows > 0 && aiSipCallUser.getExtNum() != null) {
+            CompanyExtensionBind bind = resolveBindableExtension(
+                    String.valueOf(aiSipCallUser.getExtNum()),
+                    aiSipCallUser.getCompanyId(),
+                    aiSipCallUser.getCompanyUserId(),
+                    false);
+            companyExtensionBindService.updateBindByExtId(bind.getExtId(), aiSipCallUser.getCompanyUserId(), aiSipCallUser.getLoginName());
+            if (bind.getExtensionPass() != null) {
+                aiSipCallUser.setExtPass(bind.getExtensionPass());
+                baseMapper.updateAiSipCallUser(aiSipCallUser);
             }
             if (aiSipCallUser.getCompanyUserId() != null && aiSipCallUser.getUserId() != null) {
                 companyUserMapper.updateCompanyUserByAiSipCall(aiSipCallUser.getCompanyUserId(), aiSipCallUser.getUserId());

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

@@ -42,5 +42,11 @@ public class CompanyExtensionBind extends BaseEntity{
     @Excel(name = "easycall使用字段-所属员工/绑定关系")
     private String userCode;
 
+    /** 状态:0停用,1可用 */
+    private Integer status;
+
+    /** 是否删除:0否,1是 */
+    private Integer isDel;
+
 
 }

+ 12 - 0
fs-service/src/main/java/com/fs/company/mapper/CcExtNumMapper.java

@@ -14,6 +14,18 @@ public interface CcExtNumMapper {
     @DataSource(DataSourceType.EASYCALL)
     CcExtNumVo selectLastExtNum();
 
+    /**
+     * 查询 SaaS 专用 6~7 位号池(100000~9999999)内的最大分机号
+     */
+    @DataSource(DataSourceType.EASYCALL)
+    Long selectMaxSaasPoolExtNum();
+
+    /**
+     * 查询指定分层号段内的最大分机号
+     */
+    @DataSource(DataSourceType.EASYCALL)
+    Long selectMaxSaasPoolExtNumInRange(@Param("rangeMin") long rangeMin, @Param("rangeMax") long rangeMax);
+
     @DataSource(DataSourceType.EASYCALL)
     List<CcExtNumVo> selectExtNumByExtNums(@Param("extNums") List<Long> extNums);
 

+ 27 - 0
fs-service/src/main/java/com/fs/company/mapper/CompanyExtensionBindMapper.java

@@ -2,9 +2,12 @@ package com.fs.company.mapper;
 
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.fs.company.domain.CompanyExtensionBind;
+import com.fs.company.param.SaasVoiceSeatQueryParam;
+import com.fs.company.vo.SaasVoiceSeatVO;
 import com.fs.company.vo.easycall.ExtensionVO;
 import org.apache.ibatis.annotations.Param;
 
+import java.util.Date;
 import java.util.List;
 
 /**
@@ -77,4 +80,28 @@ public interface CompanyExtensionBindMapper extends BaseMapper<CompanyExtensionB
     int updateBindByExtNum(@Param("num") String num, @Param("companyId") Long companyId, @Param("companyUserId") Long companyUserId, @Param("userCode") String userCode);
 
     List<ExtensionVO> getExtensionList(@Param("companyId") Long companyId);
+
+    int updateStatusByExtId(@Param("extId") Long extId, @Param("status") Integer status);
+
+    int batchUpdateStatusByExtIds(@Param("extIds") List<Long> extIds, @Param("status") Integer status);
+
+    int logicDeleteByExtId(@Param("extId") Long extId);
+
+    int batchLogicDeleteByExtIds(@Param("extIds") List<Long> extIds);
+
+    List<SaasVoiceSeatVO> selectSaasVoiceSeatList(SaasVoiceSeatQueryParam param);
+
+    SaasVoiceSeatVO selectSaasVoiceSeatById(@Param("id") Long id);
+
+    int assignExtensionToCompany(@Param("ids") List<Long> ids,
+                                 @Param("companyId") Long companyId,
+                                 @Param("updateTime") Date updateTime);
+
+    int recycleExtensionToPool(@Param("ids") List<Long> ids,
+                               @Param("poolCompanyId") Long poolCompanyId,
+                               @Param("updateTime") Date updateTime);
+
+    int updateStatusById(@Param("id") Long id, @Param("status") Integer status);
+
+    int batchUpdateStatusByIds(@Param("ids") List<Long> ids, @Param("status") Integer status);
 }

+ 16 - 0
fs-service/src/main/java/com/fs/company/param/AssignExtensionToCompanyParam.java

@@ -0,0 +1,16 @@
+package com.fs.company.param;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 分机分配到公司参数
+ */
+@Data
+public class AssignExtensionToCompanyParam {
+
+    private Long companyId;
+
+    private List<Long> ids;
+}

+ 25 - 0
fs-service/src/main/java/com/fs/company/param/SaasVoiceSeatQueryParam.java

@@ -0,0 +1,25 @@
+package com.fs.company.param;
+
+import lombok.Data;
+
+/**
+ * 租户管理端分机查询参数
+ */
+@Data
+public class SaasVoiceSeatQueryParam {
+
+    /** 公司名称(模糊) */
+    private String companyName;
+
+    /** 分机号码 */
+    private String extensionNum;
+
+    /** 状态:0停用,1可用 */
+    private Integer status;
+
+    /** 分配状态:0分机池,1已分配公司 */
+    private Integer assignStatus;
+
+    /** 指定公司ID */
+    private Long companyId;
+}

+ 27 - 0
fs-service/src/main/java/com/fs/company/service/ISaasVoiceSeatService.java

@@ -0,0 +1,27 @@
+package com.fs.company.service;
+
+import com.fs.company.param.AssignExtensionToCompanyParam;
+import com.fs.company.param.SaasVoiceSeatQueryParam;
+import com.fs.company.vo.SaasVoiceSeatVO;
+
+import java.util.List;
+
+/**
+ * 租户管理端分机管理 Service
+ */
+public interface ISaasVoiceSeatService {
+
+    List<SaasVoiceSeatVO> selectList(SaasVoiceSeatQueryParam param);
+
+    SaasVoiceSeatVO selectById(Long id);
+
+    List<SaasVoiceSeatVO> selectPoolList(SaasVoiceSeatQueryParam param);
+
+    int assignToCompany(AssignExtensionToCompanyParam param);
+
+    int recycleToPool(List<Long> ids);
+
+    int updateStatus(Long id, Integer status);
+
+    int batchUpdateStatus(List<Long> ids, Integer status);
+}

+ 250 - 0
fs-service/src/main/java/com/fs/company/service/easycall/CcExtNumAllocator.java

@@ -0,0 +1,250 @@
+package com.fs.company.service.easycall;
+
+import com.fs.aiSipCall.vo.CcExtNumVo;
+import com.fs.company.mapper.CcExtNumMapper;
+import com.fs.common.core.redis.RedisCache;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+import org.springframework.util.CollectionUtils;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.function.IntFunction;
+import java.util.stream.Collectors;
+
+/**
+ * cc_ext_num 分机号分配器(SaaS 专用 6~7 位分层号池:100000~9999999)
+ * <p>
+ * 分层规则:先 6 位段 100000~999999,再按百万分段 1000000~1999999 … 9000000~9999999。
+ * 每层独立 MAX+1,隔离号如 3331001 不会拉高全局起点。
+ * Redis 缓存当前层索引,下次优先从该层继续查询。
+ */
+@Component
+public class CcExtNumAllocator {
+
+    private static final Logger log = LoggerFactory.getLogger(CcExtNumAllocator.class);
+
+    /** SaaS 平台专用号段下限(6 位起) */
+    public static final long SAAS_EXT_NUM_MIN = 100_000L;
+
+    /** SaaS 平台专用号段上限(7 位满) */
+    public static final long SAAS_EXT_NUM_MAX = 9_999_999L;
+
+    /** Redis 键:当前分层号池层索引(0~9) */
+    private static final String REDIS_LAYER_KEY = "saas:cc_ext_num:alloc_layer";
+
+    /**
+     * 分层号段定义:第 0 层为 6 位段,后续每层 100 万
+     */
+    private static final long[][] LAYER_RANGES = {
+            {100_000L, 999_999L},
+            {1_000_000L, 1_999_999L},
+            {2_000_000L, 2_999_999L},
+            {3_000_000L, 3_999_999L},
+            {4_000_000L, 4_999_999L},
+            {5_000_000L, 5_999_999L},
+            {6_000_000L, 6_999_999L},
+            {7_000_000L, 7_999_999L},
+            {8_000_000L, 8_999_999L},
+            {9_000_000L, 9_999_999L},
+    };
+
+    private static final int CONFLICT_RETRY = 10;
+    private static final int COMPENSATE_RETRY = 5;
+
+    @Autowired
+    private CcExtNumMapper ccExtNumMapper;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    public List<CcExtNumVo> batchAllocate(int createNum, String password, IntFunction<String> userCodeFn) {
+        if (createNum <= 0) {
+            throw new IllegalArgumentException("生成数量必须大于0");
+        }
+
+        int startLayer = getCachedLayerIndex();
+        List<Long> extNums = allocateNumbersInLayers(startLayer, createNum, null);
+
+        List<CcExtNumVo> allNewExtNums = new ArrayList<>(createNum);
+        for (int i = 0; i < extNums.size(); i++) {
+            CcExtNumVo vo = new CcExtNumVo();
+            vo.setExtNum(extNums.get(i));
+            vo.setExtPass(password);
+            vo.setUserCode(userCodeFn.apply(i + 1));
+            allNewExtNums.add(vo);
+        }
+
+        resolveConflicts(allNewExtNums);
+
+        try {
+            ccExtNumMapper.batchInsertCcExtNum(allNewExtNums);
+        } catch (Exception e) {
+            log.warn("批量插入cc_ext_num失败,尝试逐条插入补偿", e);
+            allNewExtNums = insertOneByOne(allNewExtNums);
+        }
+
+        return allNewExtNums;
+    }
+
+    public static boolean isInSaasPool(long extNum) {
+        if (extNum < SAAS_EXT_NUM_MIN || extNum > SAAS_EXT_NUM_MAX) {
+            return false;
+        }
+        int digits = String.valueOf(extNum).length();
+        return digits == 6 || digits == 7;
+    }
+
+    /**
+     * 按分层顺序分配指定数量的分机号
+     *
+     * @param reserved 本批次已占用号码(未入库),避免同批重复
+     */
+    private List<Long> allocateNumbersInLayers(int startLayer, int count, Set<Long> reserved) {
+        Set<Long> occupied = reserved == null ? new HashSet<>() : new HashSet<>(reserved);
+        List<Long> result = new ArrayList<>(count);
+        int layerIndex = Math.max(0, Math.min(startLayer, LAYER_RANGES.length - 1));
+        int remaining = count;
+        int lastUsedLayer = layerIndex;
+
+        while (remaining > 0) {
+            if (layerIndex >= LAYER_RANGES.length) {
+                throw new IllegalStateException(
+                        "SaaS分机号池已满(" + SAAS_EXT_NUM_MIN + "~" + SAAS_EXT_NUM_MAX + "),请联系管理员");
+            }
+
+            long[] range = LAYER_RANGES[layerIndex];
+            long layerMin = range[0];
+            long layerMax = range[1];
+
+            Long layerDbMax = ccExtNumMapper.selectMaxSaasPoolExtNumInRange(layerMin, layerMax);
+            long nextInLayer = layerDbMax == null ? layerMin : layerDbMax + 1;
+
+            while (remaining > 0 && nextInLayer <= layerMax) {
+                if (occupied.contains(nextInLayer)) {
+                    nextInLayer++;
+                    continue;
+                }
+                if (!isInSaasPool(nextInLayer)) {
+                    throw new IllegalStateException("SaaS分机号超出号池范围: " + nextInLayer);
+                }
+                result.add(nextInLayer);
+                occupied.add(nextInLayer);
+                remaining--;
+                lastUsedLayer = layerIndex;
+                nextInLayer++;
+            }
+
+            if (remaining > 0) {
+                layerIndex++;
+            }
+        }
+
+        saveLayerIndex(lastUsedLayer);
+        return result;
+    }
+
+    private int getCachedLayerIndex() {
+        Integer layer = redisCache.getCacheObject(REDIS_LAYER_KEY);
+        if (layer == null || layer < 0 || layer >= LAYER_RANGES.length) {
+            return 0;
+        }
+        return layer;
+    }
+
+    private void saveLayerIndex(int layerIndex) {
+        if (layerIndex >= 0 && layerIndex < LAYER_RANGES.length) {
+            redisCache.setCacheObject(REDIS_LAYER_KEY, layerIndex);
+        }
+    }
+
+    private static int resolveLayerIndex(long extNum) {
+        for (int i = 0; i < LAYER_RANGES.length; i++) {
+            if (extNum >= LAYER_RANGES[i][0] && extNum <= LAYER_RANGES[i][1]) {
+                return i;
+            }
+        }
+        return 0;
+    }
+
+    private void resolveConflicts(List<CcExtNumVo> allNewExtNums) {
+        for (int retry = 0; retry < CONFLICT_RETRY; retry++) {
+            List<Long> extNumsToCheck = allNewExtNums.stream()
+                    .map(CcExtNumVo::getExtNum)
+                    .collect(Collectors.toList());
+
+            List<CcExtNumVo> existingExtNums = ccExtNumMapper.selectExtNumByExtNums(extNumsToCheck);
+            if (CollectionUtils.isEmpty(existingExtNums)) {
+                return;
+            }
+
+            Set<Long> existingValues = existingExtNums.stream()
+                    .map(CcExtNumVo::getExtNum)
+                    .collect(Collectors.toSet());
+
+            Set<Long> reserved = allNewExtNums.stream()
+                    .map(CcExtNumVo::getExtNum)
+                    .collect(Collectors.toSet());
+
+            boolean replaced = false;
+            for (CcExtNumVo vo : allNewExtNums) {
+                if (existingValues.contains(vo.getExtNum())) {
+                    reserved.remove(vo.getExtNum());
+                    int layer = resolveLayerIndex(vo.getExtNum());
+                    List<Long> replacements = allocateNumbersInLayers(layer, 1, reserved);
+                    if (replacements.isEmpty()) {
+                        throw new IllegalStateException("SaaS分机号池已无可用号码");
+                    }
+                    long newExtNum = replacements.get(0);
+                    vo.setExtNum(newExtNum);
+                    reserved.add(newExtNum);
+                    replaced = true;
+                }
+            }
+
+            if (!replaced) {
+                return;
+            }
+        }
+    }
+
+    private List<CcExtNumVo> insertOneByOne(List<CcExtNumVo> candidates) {
+        Set<Long> reserved = new HashSet<>();
+        List<CcExtNumVo> successList = new ArrayList<>();
+        for (CcExtNumVo vo : candidates) {
+            try {
+                ccExtNumMapper.insertCcExtNum(vo);
+                successList.add(vo);
+                reserved.add(vo.getExtNum());
+            } catch (Exception ex) {
+                log.warn("插入分机号{} 失败,尝试从号池重新取号", vo.getExtNum());
+                if (compensateInsert(vo, reserved)) {
+                    successList.add(vo);
+                }
+            }
+        }
+        return successList;
+    }
+
+    private boolean compensateInsert(CcExtNumVo vo, Set<Long> reserved) {
+        for (int retry = 0; retry < COMPENSATE_RETRY; retry++) {
+            int startLayer = getCachedLayerIndex();
+            List<Long> nums = allocateNumbersInLayers(startLayer, 1, reserved);
+            if (nums.isEmpty()) {
+                return false;
+            }
+            vo.setExtNum(nums.get(0));
+            try {
+                ccExtNumMapper.insertCcExtNum(vo);
+                reserved.add(nums.get(0));
+                return true;
+            } catch (Exception ignored) {
+            }
+        }
+        return false;
+    }
+}

+ 4 - 85
fs-service/src/main/java/com/fs/company/service/impl/CompanyExtensionBindServiceImpl.java

@@ -4,8 +4,8 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.fs.aiSipCall.vo.CcExtNumVo;
 import com.fs.common.utils.DateUtils;
 import com.fs.company.domain.CompanyExtensionBind;
-import com.fs.company.mapper.CcExtNumMapper;
 import com.fs.company.mapper.CompanyExtensionBindMapper;
+import com.fs.company.service.easycall.CcExtNumAllocator;
 import com.fs.company.param.BatchCreateExtensionParam;
 import com.fs.company.service.ICompanyExtensionBindService;
 import org.slf4j.Logger;
@@ -16,7 +16,6 @@ import org.springframework.util.CollectionUtils;
 
 import java.util.ArrayList;
 import java.util.List;
-import java.util.stream.Collectors;
 
 /**
  * 公司分机绑定Service业务层处理
@@ -30,7 +29,7 @@ public class CompanyExtensionBindServiceImpl extends ServiceImpl<CompanyExtensio
     private static final Logger log = LoggerFactory.getLogger(CompanyExtensionBindServiceImpl.class);
 
     @Autowired
-    private CcExtNumMapper ccExtNumMapper;
+    private CcExtNumAllocator ccExtNumAllocator;
 
     /**
      * 查询公司分机绑定
@@ -110,88 +109,8 @@ public class CompanyExtensionBindServiceImpl extends ServiceImpl<CompanyExtensio
         int createNum = param.getCreateNum();
         String password = param.getPassword();
         Long companyId = param.getCompanyId();
-
-        CcExtNumVo lastExtNum = ccExtNumMapper.selectLastExtNum();
-        long currentMaxExtNum = (lastExtNum != null && lastExtNum.getExtNum() != null) ? lastExtNum.getExtNum() : 0;
-
-        List<CcExtNumVo> allNewExtNums = new ArrayList<>();
-        long nextExtNum = currentMaxExtNum + 1;
-        int userCodeSeq = 1;
-
-        for (int i = 0; i < createNum; i++) {
-            CcExtNumVo vo = new CcExtNumVo();
-            vo.setExtNum(nextExtNum + i);
-            vo.setExtPass(password);
-            vo.setUserCode(tenantId + "_" + companyId + "_" + userCodeSeq);
-            userCodeSeq++;
-            allNewExtNums.add(vo);
-        }
-
-        int maxRetry = 10;
-        for (int retry = 0; retry < maxRetry; retry++) {
-            List<Long> extNumsToCheck = allNewExtNums.stream()
-                    .map(CcExtNumVo::getExtNum)
-                    .collect(Collectors.toList());
-
-            List<CcExtNumVo> existingExtNums = ccExtNumMapper.selectExtNumByExtNums(extNumsToCheck);
-            if (CollectionUtils.isEmpty(existingExtNums)) {
-                break;
-            }
-
-            List<Long> existingExtNumValues = existingExtNums.stream()
-                    .map(CcExtNumVo::getExtNum)
-                    .collect(Collectors.toList());
-
-            long maxExisting = existingExtNumValues.stream().max(Long::compareTo).orElse(currentMaxExtNum);
-            nextExtNum = maxExisting + 1;
-
-            List<CcExtNumVo> replaceList = new ArrayList<>();
-            for (CcExtNumVo vo : allNewExtNums) {
-                if (existingExtNumValues.contains(vo.getExtNum())) {
-                    vo.setExtNum(nextExtNum);
-                    nextExtNum++;
-                    replaceList.add(vo);
-                }
-            }
-
-            if (CollectionUtils.isEmpty(replaceList)) {
-                break;
-            }
-        }
-
-        try {
-            ccExtNumMapper.batchInsertCcExtNum(allNewExtNums);
-        } catch (Exception e) {
-            log.warn("批量插入cc_ext_num失败,尝试逐条插入补偿", e);
-            List<CcExtNumVo> successList = new ArrayList<>();
-            for (CcExtNumVo vo : allNewExtNums) {
-                try {
-                    ccExtNumMapper.insertCcExtNum(vo);
-                    successList.add(vo);
-                } catch (Exception ex) {
-                    log.warn("插入分机号{}失败,可能已被其他线程占用,尝试补偿", vo.getExtNum());
-                    int compensateRetry = 0;
-                    boolean compensated = false;
-                    while (compensateRetry < 5 && !compensated) {
-                        CcExtNumVo currentLast = ccExtNumMapper.selectLastExtNum();
-                        long newExtNum = (currentLast != null && currentLast.getExtNum() != null) ? currentLast.getExtNum() + 1 : 1;
-                        vo.setExtNum(newExtNum);
-                        try {
-                            ccExtNumMapper.insertCcExtNum(vo);
-                            compensated = true;
-                        } catch (Exception ex2) {
-                            compensateRetry++;
-                        }
-                    }
-                    if (compensated) {
-                        successList.add(vo);
-                    }
-                }
-            }
-            allNewExtNums = successList;
-        }
-
-        return allNewExtNums;
+        return ccExtNumAllocator.batchAllocate(createNum, password,
+                seq -> tenantId + "_" + companyId + "_" + seq);
     }
 
     @Override

+ 88 - 0
fs-service/src/main/java/com/fs/company/service/impl/SaasVoiceSeatServiceImpl.java

@@ -0,0 +1,88 @@
+package com.fs.company.service.impl;
+
+import com.fs.common.utils.DateUtils;
+import com.fs.company.mapper.CompanyExtensionBindMapper;
+import com.fs.company.param.AssignExtensionToCompanyParam;
+import com.fs.company.param.SaasVoiceSeatQueryParam;
+import com.fs.company.service.ICompanyService;
+import com.fs.company.service.ISaasVoiceSeatService;
+import com.fs.company.vo.SaasVoiceSeatVO;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
+
+import java.util.List;
+
+/**
+ * 租户管理端分机管理 Service 实现
+ */
+@Service
+public class SaasVoiceSeatServiceImpl implements ISaasVoiceSeatService {
+
+    private static final Long POOL_COMPANY_ID = 0L;
+
+    @Autowired
+    private CompanyExtensionBindMapper companyExtensionBindMapper;
+
+    @Autowired
+    private ICompanyService companyService;
+
+    @Override
+    public List<SaasVoiceSeatVO> selectList(SaasVoiceSeatQueryParam param) {
+        return companyExtensionBindMapper.selectSaasVoiceSeatList(param);
+    }
+
+    @Override
+    public SaasVoiceSeatVO selectById(Long id) {
+        return companyExtensionBindMapper.selectSaasVoiceSeatById(id);
+    }
+
+    @Override
+    public List<SaasVoiceSeatVO> selectPoolList(SaasVoiceSeatQueryParam param) {
+        if (param == null) {
+            param = new SaasVoiceSeatQueryParam();
+        }
+        param.setAssignStatus(0);
+        return companyExtensionBindMapper.selectSaasVoiceSeatList(param);
+    }
+
+    @Override
+    public int assignToCompany(AssignExtensionToCompanyParam param) {
+        if (param == null || param.getCompanyId() == null || param.getCompanyId() <= 0) {
+            throw new IllegalArgumentException("请选择目标公司");
+        }
+        if (CollectionUtils.isEmpty(param.getIds())) {
+            throw new IllegalArgumentException("请选择要分配的分机");
+        }
+        if (companyService.selectCompanyById(param.getCompanyId()) == null) {
+            throw new IllegalArgumentException("目标公司不存在");
+        }
+        return companyExtensionBindMapper.assignExtensionToCompany(
+                param.getIds(), param.getCompanyId(), DateUtils.getNowDate());
+    }
+
+    @Override
+    public int recycleToPool(List<Long> ids) {
+        if (CollectionUtils.isEmpty(ids)) {
+            throw new IllegalArgumentException("请选择要回收的分机");
+        }
+        return companyExtensionBindMapper.recycleExtensionToPool(
+                ids, POOL_COMPANY_ID, DateUtils.getNowDate());
+    }
+
+    @Override
+    public int updateStatus(Long id, Integer status) {
+        if (id == null || status == null) {
+            return 0;
+        }
+        return companyExtensionBindMapper.updateStatusById(id, status);
+    }
+
+    @Override
+    public int batchUpdateStatus(List<Long> ids, Integer status) {
+        if (CollectionUtils.isEmpty(ids) || status == null) {
+            return 0;
+        }
+        return companyExtensionBindMapper.batchUpdateStatusByIds(ids, status);
+    }
+}

+ 43 - 0
fs-service/src/main/java/com/fs/company/vo/SaasVoiceSeatVO.java

@@ -0,0 +1,43 @@
+package com.fs.company.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 租户管理端分机列表 VO
+ */
+@Data
+public class SaasVoiceSeatVO {
+
+    private Long id;
+
+    private Long companyId;
+
+    /** 所属公司名称,分机池显示「租户分机池」 */
+    private String companyName;
+
+    private Long companyUserId;
+
+    private String companyUserName;
+
+    private String extensionNum;
+
+    private String extensionPass;
+
+    private Long extId;
+
+    private String userCode;
+
+    /** 状态:0停用,1可用 */
+    private Integer status;
+
+    private String remark;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date updateTime;
+}

+ 57 - 0
fs-service/src/main/java/com/fs/proxy/domain/TenantExtensionBind.java

@@ -0,0 +1,57 @@
+package com.fs.proxy.domain;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.fs.common.annotation.Excel;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 租户分机号码管理 tenant_extension_bind
+ *
+ * @author MixLiu
+ * @date 2026/6/8
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class TenantExtensionBind extends BaseEntity {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long id;
+
+    @Excel(name = "租户ID")
+    private Long tenantId;
+
+    @Excel(name = "分机号码")
+    private String extensionNum;
+
+    @Excel(name = "分机密码")
+    private String extensionPass;
+
+    /** easycall使用字段-流水编号 */
+    private Long extId;
+
+    /** easycall使用字段-所属员工/绑定关系 */
+    private String userCode;
+
+    /** 状态:0停用,1可用 */
+    @Excel(name = "状态", readConverterExp = "0=停用,1=可用")
+    private Integer status;
+
+    /** 是否删除:0否,1是 */
+    private Integer isDel;
+
+    /** 备注 */
+    @Excel(name = "备注")
+    private String remark;
+
+    /** 联查:租户名称 */
+    @TableField(exist = false)
+    @Excel(name = "租户名称")
+    private String tenantName;
+
+    /** 查询条件:租户名称模糊搜索 */
+    @TableField(exist = false)
+    private String companyName;
+}

+ 33 - 0
fs-service/src/main/java/com/fs/proxy/mapper/TenantExtensionBindMapper.java

@@ -0,0 +1,33 @@
+package com.fs.proxy.mapper;
+
+import com.fs.proxy.domain.TenantExtensionBind;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 租户分机绑定 Mapper
+ *
+ * @author MixLiu
+ * @date 2026/6/8
+ */
+public interface TenantExtensionBindMapper {
+
+    TenantExtensionBind selectById(Long id);
+
+    List<TenantExtensionBind> selectList(TenantExtensionBind param);
+
+    int insert(TenantExtensionBind record);
+
+    int batchInsert(@Param("list") List<TenantExtensionBind> list);
+
+    int updateStatus(@Param("id") Long id, @Param("status") Integer status);
+
+    int batchUpdateStatus(@Param("ids") List<Long> ids, @Param("status") Integer status);
+
+    List<TenantExtensionBind> selectByIds(@Param("ids") List<Long> ids);
+
+    int logicDeleteById(@Param("id") Long id);
+
+    int batchLogicDeleteByIds(@Param("ids") List<Long> ids);
+}

+ 22 - 0
fs-service/src/main/java/com/fs/proxy/param/BatchCreateTenantExtensionParam.java

@@ -0,0 +1,22 @@
+package com.fs.proxy.param;
+
+import lombok.Data;
+
+/**
+ * 批量创建租户分机参数
+ *
+ * @author MixLiu
+ * @date 2026/6/8
+ */
+@Data
+public class BatchCreateTenantExtensionParam {
+
+    /** 租户id */
+    private Long tenantId;
+
+    /** 生成数量 */
+    private Integer createNum;
+
+    /** 分机密码 */
+    private String password;
+}

+ 45 - 0
fs-service/src/main/java/com/fs/proxy/service/ITenantExtensionBindService.java

@@ -0,0 +1,45 @@
+package com.fs.proxy.service;
+
+import com.fs.aiSipCall.vo.CcExtNumVo;
+import com.fs.proxy.domain.TenantExtensionBind;
+import com.fs.proxy.param.BatchCreateTenantExtensionParam;
+
+import java.util.List;
+
+/**
+ * 租户分机绑定 Service
+ *
+ * @author MixLiu
+ * @date 2026/6/8
+ */
+public interface ITenantExtensionBindService {
+
+    TenantExtensionBind selectById(Long id);
+
+    List<TenantExtensionBind> selectList(TenantExtensionBind param);
+
+    /**
+     * 在 EASYCALL 数据源中生成分机号
+     */
+    List<CcExtNumVo> createExtensionInEasycall(BatchCreateTenantExtensionParam param);
+
+    /**
+     * 写入主库 tenant_extension_bind
+     */
+    void bindExtensionToTenant(List<CcExtNumVo> extNums, Long tenantId, String createBy);
+
+    /**
+     * 一键创建租户分机
+     */
+    int batchCreateExtension(BatchCreateTenantExtensionParam param, String createBy);
+
+    int updateStatus(Long id, Integer status);
+
+    int batchUpdateStatus(List<Long> ids, Integer status);
+
+    int logicDeleteById(Long id);
+
+    int batchLogicDeleteByIds(List<Long> ids);
+
+    List<TenantExtensionBind> selectListForExport(TenantExtensionBind param);
+}

+ 261 - 0
fs-service/src/main/java/com/fs/proxy/service/impl/TenantExtensionBindServiceImpl.java

@@ -0,0 +1,261 @@
+package com.fs.proxy.service.impl;
+
+import com.fs.aiSipCall.vo.CcExtNumVo;
+import com.fs.common.enums.DataSourceType;
+import com.fs.common.utils.DateUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.company.domain.CompanyExtensionBind;
+import com.fs.company.mapper.CompanyExtensionBindMapper;
+import com.fs.company.service.easycall.CcExtNumAllocator;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import com.fs.framework.datasource.TenantDataSourceManager;
+import com.fs.proxy.domain.TenantExtensionBind;
+import com.fs.proxy.mapper.TenantExtensionBindMapper;
+import com.fs.proxy.param.BatchCreateTenantExtensionParam;
+import com.fs.proxy.service.ITenantExtensionBindService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 租户分机绑定 Service 实现
+ *
+ * @author MixLiu
+ * @date 2026/6/8
+ */
+@Service
+public class TenantExtensionBindServiceImpl implements ITenantExtensionBindService {
+
+    private static final Logger log = LoggerFactory.getLogger(TenantExtensionBindServiceImpl.class);
+
+    /** 租户级分机池,company_id 使用 0 表示未分配到具体公司 */
+    private static final Long TENANT_POOL_COMPANY_ID = 0L;
+
+    @Autowired
+    private TenantExtensionBindMapper tenantExtensionBindMapper;
+
+    @Autowired
+    private CcExtNumAllocator ccExtNumAllocator;
+
+    @Autowired
+    private CompanyExtensionBindMapper companyExtensionBindMapper;
+
+    @Autowired
+    private TenantDataSourceManager tenantDataSourceManager;
+
+    @Override
+    public TenantExtensionBind selectById(Long id) {
+        return tenantExtensionBindMapper.selectById(id);
+    }
+
+    @Override
+    public List<TenantExtensionBind> selectList(TenantExtensionBind param) {
+        return tenantExtensionBindMapper.selectList(param);
+    }
+
+    @Override
+    public List<CcExtNumVo> createExtensionInEasycall(BatchCreateTenantExtensionParam param) {
+        int createNum = param.getCreateNum();
+        String password = param.getPassword();
+        Long tenantId = param.getTenantId();
+        return ccExtNumAllocator.batchAllocate(createNum, password,
+                seq -> tenantId + "_" + seq);
+    }
+
+    @Override
+    public void bindExtensionToTenant(List<CcExtNumVo> extNums, Long tenantId, String createBy) {
+        restoreMasterDataSource();
+        bindExtensionToMaster(extNums, tenantId, createBy);
+    }
+
+    @Override
+    public int batchCreateExtension(BatchCreateTenantExtensionParam param, String createBy) {
+        if (param.getTenantId() == null) {
+            throw new IllegalArgumentException("租户ID不能为空");
+        }
+        if (param.getCreateNum() == null || param.getCreateNum() <= 0) {
+            throw new IllegalArgumentException("生成数量必须大于0");
+        }
+        if (StringUtils.isEmpty(param.getPassword())) {
+            param.setPassword("123456");
+        }
+
+        List<CcExtNumVo> extNums = createExtensionInEasycall(param);
+        if (CollectionUtils.isEmpty(extNums)) {
+            return 0;
+        }
+        restoreMasterDataSource();
+        bindExtensionToMaster(extNums, param.getTenantId(), createBy);
+        syncCompanyExtensionBind(param.getTenantId(), extNums, createBy);
+        return extNums.size();
+    }
+
+    @Override
+    public int updateStatus(Long id, Integer status) {
+        restoreMasterDataSource();
+        TenantExtensionBind record = tenantExtensionBindMapper.selectById(id);
+        if (record == null) {
+            return 0;
+        }
+        int rows = tenantExtensionBindMapper.updateStatus(id, status);
+        if (rows > 0 && record.getExtId() != null) {
+            syncCompanyExtensionStatus(record.getTenantId(), java.util.Collections.singletonList(record.getExtId()), status);
+        }
+        return rows;
+    }
+
+    @Override
+    public int batchUpdateStatus(List<Long> ids, Integer status) {
+        if (CollectionUtils.isEmpty(ids)) {
+            return 0;
+        }
+        restoreMasterDataSource();
+        Map<Long, List<Long>> grouped = groupExtIdsByTenantId(ids);
+        int rows = tenantExtensionBindMapper.batchUpdateStatus(ids, status);
+        if (rows > 0) {
+            for (Map.Entry<Long, List<Long>> entry : grouped.entrySet()) {
+                syncCompanyExtensionStatus(entry.getKey(), entry.getValue(), status);
+            }
+        }
+        return rows;
+    }
+
+    @Override
+    public int logicDeleteById(Long id) {
+        restoreMasterDataSource();
+        TenantExtensionBind record = tenantExtensionBindMapper.selectById(id);
+        if (record == null) {
+            return 0;
+        }
+        int rows = tenantExtensionBindMapper.logicDeleteById(id);
+        if (rows > 0 && record.getExtId() != null) {
+            syncCompanyExtensionDelete(record.getTenantId(), java.util.Collections.singletonList(record.getExtId()));
+        }
+        return rows;
+    }
+
+    @Override
+    public int batchLogicDeleteByIds(List<Long> ids) {
+        if (CollectionUtils.isEmpty(ids)) {
+            return 0;
+        }
+        restoreMasterDataSource();
+        Map<Long, List<Long>> grouped = groupExtIdsByTenantId(ids);
+        int rows = tenantExtensionBindMapper.batchLogicDeleteByIds(ids);
+        if (rows > 0) {
+            for (Map.Entry<Long, List<Long>> entry : grouped.entrySet()) {
+                syncCompanyExtensionDelete(entry.getKey(), entry.getValue());
+            }
+        }
+        return rows;
+    }
+
+    @Override
+    public List<TenantExtensionBind> selectListForExport(TenantExtensionBind param) {
+        restoreMasterDataSource();
+        return tenantExtensionBindMapper.selectList(param);
+    }
+
+    private void bindExtensionToMaster(List<CcExtNumVo> extNums, Long tenantId, String createBy) {
+        List<TenantExtensionBind> bindList = new ArrayList<>();
+        for (CcExtNumVo extNumVo : extNums) {
+            TenantExtensionBind bind = new TenantExtensionBind();
+            bind.setTenantId(tenantId);
+            bind.setExtensionNum(String.valueOf(extNumVo.getExtNum()));
+            bind.setExtensionPass(extNumVo.getExtPass());
+            bind.setExtId(extNumVo.getExtId());
+            bind.setUserCode(extNumVo.getUserCode());
+            bind.setStatus(1);
+            bind.setIsDel(0);
+            bind.setCreateBy(createBy);
+            bind.setCreateTime(DateUtils.getNowDate());
+            bindList.add(bind);
+        }
+
+        int batchSize = 100;
+        for (int i = 0; i < bindList.size(); i += batchSize) {
+            int end = Math.min(i + batchSize, bindList.size());
+            tenantExtensionBindMapper.batchInsert(bindList.subList(i, end));
+        }
+    }
+
+    private void syncCompanyExtensionBind(Long tenantId, List<CcExtNumVo> extNums, String createBy) {
+        switchToTenantDb(tenantId);
+        try {
+            List<CompanyExtensionBind> bindList = new ArrayList<>();
+            for (CcExtNumVo extNumVo : extNums) {
+                CompanyExtensionBind bind = new CompanyExtensionBind();
+                bind.setCompanyId(TENANT_POOL_COMPANY_ID);
+                bind.setExtensionNum(String.valueOf(extNumVo.getExtNum()));
+                bind.setExtensionPass(extNumVo.getExtPass());
+                bind.setExtId(extNumVo.getExtId());
+                bind.setUserCode(extNumVo.getUserCode());
+                bind.setStatus(1);
+                bind.setIsDel(0);
+                bind.setCreateBy(createBy);
+                bind.setCreateTime(DateUtils.getNowDate());
+                bindList.add(bind);
+            }
+            int batchSize = 100;
+            for (int i = 0; i < bindList.size(); i += batchSize) {
+                int end = Math.min(i + batchSize, bindList.size());
+                companyExtensionBindMapper.batchInsertCompanyExtensionBind(bindList.subList(i, end));
+            }
+        } finally {
+            restoreMasterDataSource();
+        }
+    }
+
+    private void syncCompanyExtensionStatus(Long tenantId, List<Long> extIds, Integer status) {
+        if (CollectionUtils.isEmpty(extIds)) {
+            return;
+        }
+        switchToTenantDb(tenantId);
+        try {
+            companyExtensionBindMapper.batchUpdateStatusByExtIds(extIds, status);
+        } finally {
+            restoreMasterDataSource();
+        }
+    }
+
+    private void syncCompanyExtensionDelete(Long tenantId, List<Long> extIds) {
+        if (CollectionUtils.isEmpty(extIds)) {
+            return;
+        }
+        switchToTenantDb(tenantId);
+        try {
+            companyExtensionBindMapper.batchLogicDeleteByExtIds(extIds);
+        } finally {
+            restoreMasterDataSource();
+        }
+    }
+
+    private Map<Long, List<Long>> groupExtIdsByTenantId(List<Long> ids) {
+        List<TenantExtensionBind> records = tenantExtensionBindMapper.selectByIds(ids);
+        Map<Long, List<Long>> grouped = new HashMap<>();
+        for (TenantExtensionBind record : records) {
+            if (record.getExtId() == null) {
+                continue;
+            }
+            grouped.computeIfAbsent(record.getTenantId(), k -> new ArrayList<>()).add(record.getExtId());
+        }
+        return grouped;
+    }
+
+    private void switchToTenantDb(Long tenantId) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        tenantDataSourceManager.ensureSwitchByTenantId(tenantId);
+    }
+
+    private void restoreMasterDataSource() {
+        tenantDataSourceManager.clear();
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+    }
+}

+ 32 - 4
fs-service/src/main/resources/db/tenant-initTable.sql

@@ -18607,19 +18607,24 @@ CREATE TABLE `crm_customer_property`
 DROP TABLE IF EXISTS `company_extension_bind`;
 CREATE TABLE `company_extension_bind`
 (
-    `id`              bigint NOT NULL AUTO_INCREMENT COMMENT '主键id',
-    `company_id`      bigint NOT NULL COMMENT '公司id',
+    `id`              bigint  NOT NULL AUTO_INCREMENT COMMENT '主键id',
+    `company_id`      bigint  NOT NULL COMMENT '公司id',
     `company_user_id` bigint NULL DEFAULT NULL COMMENT '销售id(为空时没有绑定)',
     `extension_num`   varchar(50) NULL DEFAULT NULL COMMENT '分机号码',
     `extension_pass`  varchar(50) NULL DEFAULT NULL COMMENT '分机密码',
     `ext_id`          int NULL DEFAULT NULL COMMENT 'easycall使用字段-流水编号',
     `user_code`       varchar(32) NULL DEFAULT NULL COMMENT 'easycall使用字段-所属员工/绑定关系',
+    `status`          tinyint NOT NULL DEFAULT 1 COMMENT '状态:0停用,1可用',
+    `is_del`          tinyint NOT NULL DEFAULT 0 COMMENT '是否删除:0否,1是',
     `create_time`     datetime NULL DEFAULT NULL,
+    `update_time`     datetime NULL DEFAULT NULL COMMENT '更新时间',
     `create_by`       varchar(255) NULL DEFAULT NULL,
     PRIMARY KEY (`id`) USING BTREE,
     INDEX             `company_company_user_idx`(`company_id`, `company_user_id`) USING BTREE,
-    INDEX             `company_extension_idx`(`company_id`, `extension_num`) USING BTREE
-) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
+    INDEX             `company_extension_idx`(`company_id`, `extension_num`) USING BTREE,
+    INDEX             `idx_ext_id`(`ext_id`) USING BTREE,
+    INDEX             `idx_status_del`(`status`, `is_del`) USING BTREE
+) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = DYNAMIC;
 
 
 -- ----------------------------
@@ -20624,3 +20629,26 @@ CREATE TABLE `wx_sop_logs` (
     KEY `idx_sop_id` (`sop_id`),
     KEY `idx_sop_user_id` (`sop_user_id`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='微信SOP发送日志';
+
+-- ----------------------------
+-- Table structure for tenant_extension_bind
+-- ----------------------------
+DROP TABLE IF EXISTS `tenant_extension_bind`;
+CREATE TABLE `tenant_extension_bind`
+(
+    `id`             bigint NOT NULL AUTO_INCREMENT COMMENT '主键id',
+    `tenant_id`      bigint NOT NULL COMMENT '租户id',
+    `extension_num`  varchar(50) NULL DEFAULT NULL COMMENT '分机号码',
+    `extension_pass` varchar(50) NULL DEFAULT NULL COMMENT '分机密码',
+    `ext_id`         int NULL DEFAULT NULL COMMENT 'easycall使用字段-流水编号',
+    `user_code`      varchar(32) NULL DEFAULT NULL COMMENT 'easycall使用字段-所属员工/绑定关系',
+    `status`         tinyint NULL DEFAULT NULL COMMENT '状态:0、停用,1、可用',
+    `is_del`         tinyint NULL DEFAULT NULL COMMENT '是否删除:0否,1是',
+    `remark`         varchar(255) NULL DEFAULT NULL COMMENT '备注',
+    `create_time`    datetime NULL DEFAULT NULL,
+    `create_by`      varchar(255) NULL DEFAULT NULL,
+    `update_time`    datetime NULL DEFAULT NULL,
+    PRIMARY KEY (`id`) USING BTREE,
+    INDEX            `company_company_user_idx`(`tenant_id`) USING BTREE,
+    INDEX            `company_extension_idx`(`tenant_id`, `extension_num`) USING BTREE
+) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '租户分机号码管理' ROW_FORMAT = DYNAMIC;

+ 18 - 0
fs-service/src/main/resources/mapper/company/CcExtNumMapper.xml

@@ -15,6 +15,24 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         select ext_id, ext_num, ext_pass, user_code from cc_ext_num order by ext_id desc limit 1
     </select>
 
+    <!-- SaaS 专用 6~7 位号池最大值(100000~9999999),与其他系统长号段/手机号隔离 -->
+    <select id="selectMaxSaasPoolExtNum" resultType="java.lang.Long">
+        select MAX(CAST(ext_num AS UNSIGNED))
+        from cc_ext_num
+        where ext_num REGEXP '^[0-9]{6,7}$'
+          and CAST(ext_num AS UNSIGNED) &gt;= 100000
+          and CAST(ext_num AS UNSIGNED) &lt;= 9999999
+    </select>
+
+    <!-- 分层号段内最大值(每层独立 MAX+1,避免跨层孤立号拉高起点) -->
+    <select id="selectMaxSaasPoolExtNumInRange" resultType="java.lang.Long">
+        select MAX(CAST(ext_num AS UNSIGNED))
+        from cc_ext_num
+        where ext_num REGEXP '^[0-9]{6,7}$'
+          and CAST(ext_num AS UNSIGNED) &gt;= #{rangeMin}
+          and CAST(ext_num AS UNSIGNED) &lt;= #{rangeMax}
+    </select>
+
     <select id="selectExtNumByExtNums" resultMap="CcExtNumResult">
         select ext_id, ext_num, ext_pass, user_code from cc_ext_num
         where ext_num in

+ 161 - 4
fs-service/src/main/resources/mapper/company/CompanyExtensionBindMapper.xml

@@ -12,12 +12,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="extensionPass"    column="extension_pass"    />
         <result property="extId"    column="ext_id"    />
         <result property="userCode"    column="user_code"    />
+        <result property="status"      column="status"       />
+        <result property="isDel"       column="is_del"       />
         <result property="createTime"    column="create_time"    />
         <result property="createBy"    column="create_by"    />
+        <result property="updateTime"    column="update_time"    />
     </resultMap>
 
     <sql id="selectCompanyExtensionBindVo">
-        select id, company_id, company_user_id, extension_num, extension_pass, ext_id, user_code, create_time, create_by from company_extension_bind
+        select id, company_id, company_user_id, extension_num, extension_pass, ext_id, user_code, status, is_del, create_time, create_by, update_time from company_extension_bind
     </sql>
 
     <select id="selectCompanyExtensionBindList" parameterType="CompanyExtensionBind" resultMap="CompanyExtensionBindResult">
@@ -29,6 +32,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="extensionPass != null  and extensionPass != ''"> and extension_pass = #{extensionPass}</if>
             <if test="extId != null "> and ext_id = #{extId}</if>
             <if test="userCode != null  and userCode != ''"> and user_code = #{userCode}</if>
+            and (is_del = 0 or is_del is null)
         </where>
     </select>
     
@@ -46,6 +50,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="extensionPass != null">extension_pass,</if>
             <if test="extId != null">ext_id,</if>
             <if test="userCode != null">user_code,</if>
+            <if test="status != null">status,</if>
+            <if test="isDel != null">is_del,</if>
             <if test="createTime != null">create_time,</if>
             <if test="createBy != null">create_by,</if>
          </trim>
@@ -56,6 +62,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="extensionPass != null">#{extensionPass},</if>
             <if test="extId != null">#{extId},</if>
             <if test="userCode != null">#{userCode},</if>
+            <if test="status != null">#{status},</if>
+            <if test="isDel != null">#{isDel},</if>
             <if test="createTime != null">#{createTime},</if>
             <if test="createBy != null">#{createBy},</if>
          </trim>
@@ -88,21 +96,23 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     </delete>
 
     <insert id="batchInsertCompanyExtensionBind" parameterType="java.util.List">
-        insert into company_extension_bind (company_id, company_user_id, extension_num, extension_pass, ext_id, user_code, create_time)
+        insert into company_extension_bind (company_id, company_user_id, extension_num, extension_pass, ext_id, user_code, status, is_del, create_time, create_by)
         values
         <foreach item="item" collection="list" separator=",">
-            (#{item.companyId}, #{item.companyUserId}, #{item.extensionNum}, #{item.extensionPass}, #{item.extId}, #{item.userCode}, #{item.createTime})
+            (#{item.companyId}, #{item.companyUserId}, #{item.extensionNum}, #{item.extensionPass}, #{item.extId}, #{item.userCode}, #{item.status}, #{item.isDel}, #{item.createTime}, #{item.createBy})
         </foreach>
     </insert>
 
     <select id="selectUnBindByCompanyId" resultMap="CompanyExtensionBindResult">
         <include refid="selectCompanyExtensionBindVo"/>
         where company_id = #{companyId} and (company_user_id is null or company_user_id = 0)
+          and status = 1 and (is_del = 0 or is_del is null)
     </select>
 
     <select id="selectUnBindAndSelfByCompanyId" resultMap="CompanyExtensionBindResult">
         <include refid="selectCompanyExtensionBindVo"/>
         where company_id = #{companyId} and (company_user_id is null or company_user_id =#{companyUserId})
+          and status = 1 and (is_del = 0 or is_del is null)
     </select>
 
     <update id="updateBindByExtId">
@@ -113,7 +123,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
 
     <select id="selectByExtNumAndCompanyId" resultMap="CompanyExtensionBindResult">
         <include refid="selectCompanyExtensionBindVo"/>
-        where extension_num = #{extensionNum} and company_id = #{companyId}
+        where extension_num = #{extensionNum}
+          and company_id = #{companyId}
+          and company_id &gt; 0
+          and status = 1
+          and (is_del = 0 or is_del is null)
     </select>
 
     <update id="clearBindByExtNum">
@@ -140,5 +154,148 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
                 left join company_user t2 on t1.company_user_id = t2.user_id
         WHERE
             t1.company_id = #{companyId}
+          and t1.status = 1 and (t1.is_del = 0 or t1.is_del is null)
     </select>
+
+    <update id="updateStatusByExtId">
+        update company_extension_bind
+        set status = #{status}, update_time = now()
+        where ext_id = #{extId} and (is_del = 0 or is_del is null)
+    </update>
+
+    <update id="batchUpdateStatusByExtIds">
+        update company_extension_bind
+        set status = #{status}, update_time = now()
+        where (is_del = 0 or is_del is null) and ext_id in
+        <foreach collection="extIds" item="extId" open="(" separator="," close=")">
+            #{extId}
+        </foreach>
+    </update>
+
+    <update id="logicDeleteByExtId">
+        update company_extension_bind
+        set is_del = 1, update_time = now()
+        where ext_id = #{extId} and (is_del = 0 or is_del is null)
+    </update>
+
+    <update id="batchLogicDeleteByExtIds">
+        update company_extension_bind
+        set is_del = 1, update_time = now()
+        where (is_del = 0 or is_del is null) and ext_id in
+        <foreach collection="extIds" item="extId" open="(" separator="," close=")">
+            #{extId}
+        </foreach>
+    </update>
+
+    <sql id="saasVoiceSeatWhere">
+        and (t.is_del = 0 or t.is_del is null)
+        <if test="extensionNum != null and extensionNum != ''">
+            and t.extension_num like concat('%', #{extensionNum}, '%')
+        </if>
+        <if test="status != null">
+            and t.status = #{status}
+        </if>
+        <if test="companyId != null">
+            and t.company_id = #{companyId}
+        </if>
+        <if test="assignStatus != null and assignStatus == 0">
+            and t.company_id = 0
+        </if>
+        <if test="assignStatus != null and assignStatus == 1">
+            and t.company_id &gt; 0
+        </if>
+        <if test="companyName != null and companyName != ''">
+            and (
+                (t.company_id = 0 and '租户分机池' like concat('%', #{companyName}, '%'))
+                or c.company_name like concat('%', #{companyName}, '%')
+            )
+        </if>
+    </sql>
+
+    <select id="selectSaasVoiceSeatList" resultType="com.fs.company.vo.SaasVoiceSeatVO">
+        select
+            t.id,
+            t.company_id as companyId,
+            case when t.company_id = 0 then '租户分机池' else c.company_name end as companyName,
+            t.company_user_id as companyUserId,
+            cu.nick_name as companyUserName,
+            t.extension_num as extensionNum,
+            t.extension_pass as extensionPass,
+            t.ext_id as extId,
+            t.user_code as userCode,
+            t.status,
+            t.create_time as createTime,
+            t.update_time as updateTime
+        from company_extension_bind t
+        left join company c on t.company_id = c.company_id and t.company_id &gt; 0
+        left join company_user cu on t.company_user_id = cu.user_id
+        <where>
+            1 = 1
+            <include refid="saasVoiceSeatWhere"/>
+        </where>
+        order by t.id desc
+    </select>
+
+    <select id="selectSaasVoiceSeatById" resultType="com.fs.company.vo.SaasVoiceSeatVO">
+        select
+            t.id,
+            t.company_id as companyId,
+            case when t.company_id = 0 then '租户分机池' else c.company_name end as companyName,
+            t.company_user_id as companyUserId,
+            cu.nick_name as companyUserName,
+            t.extension_num as extensionNum,
+            t.extension_pass as extensionPass,
+            t.ext_id as extId,
+            t.user_code as userCode,
+            t.status,
+            t.create_time as createTime,
+            t.update_time as updateTime
+        from company_extension_bind t
+        left join company c on t.company_id = c.company_id and t.company_id &gt; 0
+        left join company_user cu on t.company_user_id = cu.user_id
+        where t.id = #{id} and (t.is_del = 0 or t.is_del is null)
+    </select>
+
+    <update id="assignExtensionToCompany">
+        update company_extension_bind
+        set company_id = #{companyId},
+            update_time = #{updateTime}
+        where company_id = 0
+          and status = 1
+          and (is_del = 0 or is_del is null)
+          and (company_user_id is null or company_user_id = 0)
+          and id in
+        <foreach collection="ids" item="id" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </update>
+
+    <update id="recycleExtensionToPool">
+        update company_extension_bind
+        set company_id = #{poolCompanyId},
+            company_user_id = null,
+            update_time = #{updateTime}
+        where company_id &gt; 0
+          and (is_del = 0 or is_del is null)
+          and (company_user_id is null or company_user_id = 0)
+          and id in
+        <foreach collection="ids" item="id" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </update>
+
+    <update id="updateStatusById">
+        update company_extension_bind
+        set status = #{status}, update_time = now()
+        where id = #{id} and (is_del = 0 or is_del is null)
+    </update>
+
+    <update id="batchUpdateStatusByIds">
+        update company_extension_bind
+        set status = #{status}, update_time = now()
+        where (is_del = 0 or is_del is null) and id in
+        <foreach collection="ids" item="id" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </update>
 </mapper>

+ 134 - 0
fs-service/src/main/resources/mapper/proxy/TenantExtensionBindMapper.xml

@@ -0,0 +1,134 @@
+<?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.proxy.mapper.TenantExtensionBindMapper">
+
+    <resultMap type="TenantExtensionBind" id="TenantExtensionBindResult">
+        <result property="id"            column="id"             />
+        <result property="tenantId"      column="tenant_id"      />
+        <result property="extensionNum"  column="extension_num"  />
+        <result property="extensionPass" column="extension_pass" />
+        <result property="extId"         column="ext_id"         />
+        <result property="userCode"      column="user_code"      />
+        <result property="status"        column="status"         />
+        <result property="isDel"         column="is_del"         />
+        <result property="remark"        column="remark"         />
+        <result property="createTime"    column="create_time"    />
+        <result property="updateTime"    column="update_time"    />
+        <result property="createBy"      column="create_by"      />
+        <result property="tenantName"    column="tenant_name"    />
+    </resultMap>
+
+    <sql id="selectVo">
+        select t.id, t.tenant_id, t.extension_num, t.extension_pass, t.ext_id, t.user_code,
+               t.status, t.is_del, t.remark, t.create_time, t.update_time, t.create_by
+        from tenant_extension_bind t
+    </sql>
+
+    <select id="selectById" resultMap="TenantExtensionBindResult">
+        select t.id, t.tenant_id, t.extension_num, t.extension_pass, t.ext_id, t.user_code,
+               t.status, t.is_del, t.remark, t.create_time, t.update_time, t.create_by,
+               ti.tenant_name
+        from tenant_extension_bind t
+        left join tenant_info ti on ti.id = t.tenant_id
+        where t.id = #{id} and t.is_del = 0
+    </select>
+
+    <select id="selectList" resultMap="TenantExtensionBindResult">
+        select t.id, t.tenant_id, t.extension_num, t.extension_pass, t.ext_id, t.user_code,
+               t.status, t.is_del, t.remark, t.create_time, t.update_time, t.create_by,
+               ti.tenant_name
+        from tenant_extension_bind t
+        left join tenant_info ti on ti.id = t.tenant_id
+        <where>
+            t.is_del = 0
+            <if test="tenantId != null"> and t.tenant_id = #{tenantId}</if>
+            <if test="extensionNum != null and extensionNum != ''"> and t.extension_num = #{extensionNum}</if>
+            <if test="status != null"> and t.status = #{status}</if>
+            <if test="companyName != null and companyName != ''"> and ti.tenant_name like concat('%', #{companyName}, '%')</if>
+        </where>
+        order by t.id desc
+    </select>
+
+    <insert id="insert" useGeneratedKeys="true" keyProperty="id">
+        insert into tenant_extension_bind
+        <trim prefix="(" suffix=")" suffixOverrides=",">
+            <if test="tenantId != null">tenant_id,</if>
+            <if test="extensionNum != null">extension_num,</if>
+            <if test="extensionPass != null">extension_pass,</if>
+            <if test="extId != null">ext_id,</if>
+            <if test="userCode != null">user_code,</if>
+            <if test="status != null">status,</if>
+            <if test="isDel != null">is_del,</if>
+            <if test="remark != null">remark,</if>
+            <if test="createBy != null">create_by,</if>
+            create_time,
+        </trim>
+        <trim prefix="values (" suffix=")" suffixOverrides=",">
+            <if test="tenantId != null">#{tenantId},</if>
+            <if test="extensionNum != null">#{extensionNum},</if>
+            <if test="extensionPass != null">#{extensionPass},</if>
+            <if test="extId != null">#{extId},</if>
+            <if test="userCode != null">#{userCode},</if>
+            <if test="status != null">#{status},</if>
+            <if test="isDel != null">#{isDel},</if>
+            <if test="remark != null">#{remark},</if>
+            <if test="createBy != null">#{createBy},</if>
+            now(),
+        </trim>
+    </insert>
+
+    <insert id="batchInsert" parameterType="java.util.List">
+        insert into tenant_extension_bind
+            (tenant_id, extension_num, extension_pass, ext_id, user_code, status, is_del, create_time)
+        values
+        <foreach collection="list" item="item" separator=",">
+            (#{item.tenantId}, #{item.extensionNum}, #{item.extensionPass}, #{item.extId},
+             #{item.userCode}, #{item.status}, #{item.isDel}, #{item.createTime})
+        </foreach>
+    </insert>
+
+    <update id="updateStatus">
+        update tenant_extension_bind
+        set status = #{status}, update_time = now()
+        where id = #{id} and is_del = 0
+    </update>
+
+    <update id="batchUpdateStatus">
+        update tenant_extension_bind
+        set status = #{status}, update_time = now()
+        where is_del = 0 and id in
+        <foreach collection="ids" item="id" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </update>
+
+    <select id="selectByIds" resultMap="TenantExtensionBindResult">
+        select t.id, t.tenant_id, t.extension_num, t.extension_pass, t.ext_id, t.user_code,
+               t.status, t.is_del, t.remark, t.create_time, t.update_time, t.create_by,
+               ti.tenant_name
+        from tenant_extension_bind t
+        left join tenant_info ti on ti.id = t.tenant_id
+        where t.is_del = 0 and t.id in
+        <foreach collection="ids" item="id" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </select>
+
+    <update id="logicDeleteById">
+        update tenant_extension_bind
+        set is_del = 1, update_time = now()
+        where id = #{id} and is_del = 0
+    </update>
+
+    <update id="batchLogicDeleteByIds">
+        update tenant_extension_bind
+        set is_del = 1, update_time = now()
+        where is_del = 0 and id in
+        <foreach collection="ids" item="id" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </update>
+
+</mapper>