boss hai 2 semanas
pai
achega
9f30ac4ebf
Modificáronse 39 ficheiros con 2594 adicións e 566 borrados
  1. 45 0
      fs-admin-saas/src/main/java/com/fs/admin/controller/AdminLobsterBridgeController.java
  2. 1 0
      fs-admin-saas/src/main/java/com/fs/web/controller/system/SysKeywordController.java
  3. 143 5
      fs-admin/src/main/java/com/fs/admin/controller/AdminLobsterBridgeController.java
  4. 60 0
      fs-admin/src/main/java/com/fs/web/controller/system/CompanySmsApiController.java
  5. 65 0
      fs-admin/src/main/java/com/fs/web/controller/system/CompanySmsApiTenantController.java
  6. 180 0
      fs-admin/src/main/java/com/fs/web/controller/system/CompanySmsPortController.java
  7. 1 0
      fs-admin/src/main/java/com/fs/web/controller/system/SysKeywordController.java
  8. 0 163
      fs-company/src/main/java/com/fs/company/AddSmsApiMenu.java
  9. 0 58
      fs-company/src/main/java/com/fs/company/CheckSmsConfig.java
  10. 206 0
      fs-company/src/main/java/com/fs/company/SmsApiMigration.java
  11. 31 9
      fs-company/src/main/java/com/fs/company/controller/workflow/LobsterSalesCorpusController.java
  12. 293 328
      fs-service/src/main/java/com/fs/common/service/impl/SmsServiceImpl.java
  13. 62 0
      fs-service/src/main/java/com/fs/proxy/domain/CompanySmsApi.java
  14. 44 0
      fs-service/src/main/java/com/fs/proxy/domain/CompanySmsApiPort.java
  15. 58 0
      fs-service/src/main/java/com/fs/proxy/domain/CompanySmsApiTenant.java
  16. 101 0
      fs-service/src/main/java/com/fs/proxy/domain/CompanySmsCard.java
  17. 42 0
      fs-service/src/main/java/com/fs/proxy/domain/CompanySmsCardMiddleware.java
  18. 37 0
      fs-service/src/main/java/com/fs/proxy/domain/CompanySmsPortAssign.java
  19. 22 0
      fs-service/src/main/java/com/fs/proxy/mapper/CompanySmsApiMapper.java
  20. 30 0
      fs-service/src/main/java/com/fs/proxy/mapper/CompanySmsApiPortMapper.java
  21. 33 0
      fs-service/src/main/java/com/fs/proxy/mapper/CompanySmsApiTenantMapper.java
  22. 35 0
      fs-service/src/main/java/com/fs/proxy/mapper/CompanySmsCardMapper.java
  23. 21 0
      fs-service/src/main/java/com/fs/proxy/mapper/CompanySmsCardMiddlewareMapper.java
  24. 23 0
      fs-service/src/main/java/com/fs/proxy/mapper/CompanySmsPortAssignMapper.java
  25. 18 0
      fs-service/src/main/java/com/fs/proxy/service/ICompanySmsApiService.java
  26. 27 0
      fs-service/src/main/java/com/fs/proxy/service/ICompanySmsApiTenantService.java
  27. 50 0
      fs-service/src/main/java/com/fs/proxy/service/ICompanySmsPortService.java
  28. 22 0
      fs-service/src/main/java/com/fs/proxy/service/impl/BalanceServiceImpl.java
  29. 52 0
      fs-service/src/main/java/com/fs/proxy/service/impl/CompanySmsApiServiceImpl.java
  30. 57 0
      fs-service/src/main/java/com/fs/proxy/service/impl/CompanySmsApiTenantServiceImpl.java
  31. 283 0
      fs-service/src/main/java/com/fs/proxy/service/impl/CompanySmsPortServiceImpl.java
  32. 9 1
      fs-service/src/main/java/com/fs/tenant/service/impl/TenantInfoServiceImpl.java
  33. 84 0
      fs-service/src/main/resources/mapper/proxy/CompanySmsApiMapper.xml
  34. 85 0
      fs-service/src/main/resources/mapper/proxy/CompanySmsApiPortMapper.xml
  35. 89 0
      fs-service/src/main/resources/mapper/proxy/CompanySmsApiTenantMapper.xml
  36. 150 0
      fs-service/src/main/resources/mapper/proxy/CompanySmsCardMapper.xml
  37. 65 0
      fs-service/src/main/resources/mapper/proxy/CompanySmsCardMiddlewareMapper.xml
  38. 68 0
      fs-service/src/main/resources/mapper/proxy/CompanySmsPortAssignMapper.xml
  39. 2 2
      fs-service/src/main/resources/mapper/proxy/ProxyOperLogMapper.xml

+ 45 - 0
fs-admin-saas/src/main/java/com/fs/admin/controller/AdminLobsterBridgeController.java

@@ -3,6 +3,10 @@ package com.fs.admin.controller;
 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.DataSourceType;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import com.fs.tenant.domain.TenantInfo;
+import com.fs.tenant.mapper.TenantInfoMapper;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.web.bind.annotation.*;
@@ -20,6 +24,9 @@ public class AdminLobsterBridgeController extends BaseController {
     @Autowired(required = false)
     private JdbcTemplate jdbcTemplate;
 
+    @Autowired(required = false)
+    private TenantInfoMapper tenantInfoMapper;
+
     private TableDataInfo emptyTable() {
         TableDataInfo r = new TableDataInfo();
         r.setCode(200);
@@ -37,6 +44,7 @@ public class AdminLobsterBridgeController extends BaseController {
             if (jdbcTemplate != null) {
                 List<Map<String, Object>> rows = jdbcTemplate.queryForList(
                         "SELECT * FROM " + table + " ORDER BY 1 DESC LIMIT 200");
+                rows = enrichWithTenantName(rows);
                 r.setRows(rows);
                 r.setTotal(rows.size());
             } else {
@@ -50,6 +58,43 @@ public class AdminLobsterBridgeController extends BaseController {
         return r;
     }
 
+    /**
+     * 根据 company_id 批量查询 tenant_info 表,将 tenant_name 回填到每行
+     */
+    private List<Map<String, Object>> enrichWithTenantName(List<Map<String, Object>> rows) {
+        if (rows.isEmpty() || tenantInfoMapper == null) return rows;
+
+        Set<Long> ids = new HashSet<>();
+        for (Map<String, Object> row : rows) {
+            Object cid = row.get("company_id");
+            if (cid != null) ids.add(((Number) cid).longValue());
+        }
+        if (ids.isEmpty()) return rows;
+
+        String prev = DynamicDataSourceContextHolder.getDataSourceType();
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        try {
+            List<TenantInfo> tenants = tenantInfoMapper.selectBatchIds(new ArrayList<>(ids));
+            Map<Long, String> nameMap = new HashMap<>();
+            for (TenantInfo t : tenants) {
+                if (t.getTenantName() != null) nameMap.put(t.getId(), t.getTenantName());
+            }
+            for (Map<String, Object> row : rows) {
+                Object cid = row.get("company_id");
+                if (cid != null) {
+                    String name = nameMap.get(((Number) cid).longValue());
+                    row.put("tenant_name", name != null ? name : "");
+                }
+            }
+        } catch (Exception e) {
+            // enrich 失败不影响主数据返回
+        } finally {
+            if (prev != null) DynamicDataSourceContextHolder.setDataSourceType(prev);
+            else DynamicDataSourceContextHolder.clearDataSourceType();
+        }
+        return rows;
+    }
+
     // ========== AI工作流生成 ==========
     @GetMapping({"/workflow/lobster/generate", "/workflow/lobster/generate/list"})
     public TableDataInfo lobsterGenerate() {

+ 1 - 0
fs-admin-saas/src/main/java/com/fs/web/controller/system/SysKeywordController.java

@@ -142,6 +142,7 @@ public class SysKeywordController extends BaseController
             List<SysKeyword> sysKeywords = sysKeywordService.selectSysKeywordList(sysKeywordParam);
             List<String> keywords = sysKeywords.stream()
                     .map(SysKeyword::getKeyword)
+                    .filter(k -> k != null && !k.isEmpty())
                     .collect(Collectors.toList());
 
             if (!keywords.isEmpty()) {

+ 143 - 5
fs-admin/src/main/java/com/fs/admin/controller/AdminLobsterBridgeController.java

@@ -3,6 +3,10 @@ package com.fs.admin.controller;
 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.DataSourceType;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import com.fs.tenant.domain.TenantInfo;
+import com.fs.tenant.mapper.TenantInfoMapper;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.web.bind.annotation.*;
@@ -20,6 +24,9 @@ public class AdminLobsterBridgeController extends BaseController {
     @Autowired(required = false)
     private JdbcTemplate jdbcTemplate;
 
+    @Autowired(required = false)
+    private TenantInfoMapper tenantInfoMapper;
+
     private TableDataInfo emptyTable() {
         TableDataInfo r = new TableDataInfo();
         r.setCode(200);
@@ -37,6 +44,7 @@ public class AdminLobsterBridgeController extends BaseController {
             if (jdbcTemplate != null) {
                 List<Map<String, Object>> rows = jdbcTemplate.queryForList(
                         "SELECT * FROM " + table + " ORDER BY 1 DESC LIMIT 200");
+                rows = enrichWithTenantName(rows);
                 r.setRows(rows);
                 r.setTotal(rows.size());
             } else {
@@ -50,6 +58,44 @@ public class AdminLobsterBridgeController extends BaseController {
         return r;
     }
 
+    /**
+     * 根据 company_id 批量查询 tenant_info 表,将 tenant_name 回填到每行
+     */
+    private List<Map<String, Object>> enrichWithTenantName(List<Map<String, Object>> rows) {
+        if (rows.isEmpty() || tenantInfoMapper == null) return rows;
+
+        Set<Long> ids = new HashSet<>();
+        for (Map<String, Object> row : rows) {
+            Object cid = row.get("company_id");
+            if (cid != null) ids.add(((Number) cid).longValue());
+        }
+        if (ids.isEmpty()) return rows;
+
+        // 确保在主库查询 tenant_info
+        String prev = DynamicDataSourceContextHolder.getDataSourceType();
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        try {
+            List<TenantInfo> tenants = tenantInfoMapper.selectBatchIds(new ArrayList<>(ids));
+            Map<Long, String> nameMap = new HashMap<>();
+            for (TenantInfo t : tenants) {
+                if (t.getTenantName() != null) nameMap.put(t.getId(), t.getTenantName());
+            }
+            for (Map<String, Object> row : rows) {
+                Object cid = row.get("company_id");
+                if (cid != null) {
+                    String name = nameMap.get(((Number) cid).longValue());
+                    row.put("tenant_name", name != null ? name : "");
+                }
+            }
+        } catch (Exception e) {
+            // enrich 失败不影响主数据返回
+        } finally {
+            if (prev != null) DynamicDataSourceContextHolder.setDataSourceType(prev);
+            else DynamicDataSourceContextHolder.clearDataSourceType();
+        }
+        return rows;
+    }
+
     // ========== AI工作流生成 ==========
     @GetMapping({"/workflow/lobster/generate", "/workflow/lobster/generate/list"})
     public TableDataInfo lobsterGenerate() {
@@ -130,8 +176,53 @@ public class AdminLobsterBridgeController extends BaseController {
     // ========== 销冠语料学习 ==========
     @GetMapping({"/workflow/lobster/sales-corpus", "/workflow/lobster/sales-corpus/list",
                  "/workflow/lobster/corpus", "/workflow/lobster/corpus/list"})
-    public TableDataInfo lobsterSalesCorpus() {
-        return safeListFromTable("lobster_sales_corpus");
+    public TableDataInfo lobsterSalesCorpus(
+            @RequestParam(defaultValue = "1") int page,
+            @RequestParam(defaultValue = "10") int size,
+            @RequestParam(required = false) String scenario,
+            @RequestParam(required = false) String status,
+            @RequestParam(required = false) Long companyId) {
+        TableDataInfo r = new TableDataInfo();
+        r.setCode(200);
+        r.setMsg("查询成功");
+        try {
+            if (jdbcTemplate != null) {
+                StringBuilder where = new StringBuilder(" WHERE 1=1 ");
+                List<Object> params = new ArrayList<>();
+                if (scenario != null && !scenario.isEmpty()) {
+                    where.append("AND scenario=? ");
+                    params.add(scenario);
+                }
+                if (status != null && !status.isEmpty()) {
+                    where.append("AND status=? ");
+                    params.add(status);
+                }
+                if (companyId != null) {
+                    where.append("AND company_id=? ");
+                    params.add(companyId);
+                }
+                // 查总数
+                Long total = jdbcTemplate.queryForObject(
+                        "SELECT COUNT(*) FROM lobster_sales_corpus " + where, Long.class, params.toArray());
+                r.setTotal(total != null ? total.intValue() : 0);
+                // 分页查询
+                List<Object> pageParams = new ArrayList<>(params);
+                pageParams.add((page - 1) * size);
+                pageParams.add(size);
+                List<Map<String, Object>> rows = jdbcTemplate.queryForList(
+                        "SELECT * FROM lobster_sales_corpus " + where +
+                        "ORDER BY create_time DESC LIMIT ?, ?", pageParams.toArray());
+                rows = enrichWithTenantName(rows);
+                r.setRows(rows);
+            } else {
+                r.setRows(new ArrayList<>());
+                r.setTotal(0);
+            }
+        } catch (Exception e) {
+            r.setRows(new ArrayList<>());
+            r.setTotal(0);
+        }
+        return r;
     }
 
     @GetMapping("/workflow/lobster/sales-corpus/scenarios")
@@ -148,7 +239,25 @@ public class AdminLobsterBridgeController extends BaseController {
 
     @PostMapping("/workflow/lobster/sales-corpus/dialog")
     public AjaxResult lobsterSalesCorpusAdd(@RequestBody(required = false) Map<String, Object> body) {
-        return AjaxResult.success("录入成功");
+        if (jdbcTemplate == null) return AjaxResult.error("数据库未初始化");
+        try {
+            Long companyId = body != null && body.get("companyId") != null ?
+                    Long.valueOf(body.get("companyId").toString()) : null;
+            String salespersonName = body != null ? (String) body.getOrDefault("salespersonName", "销冠") : "销冠";
+            String customerQuestion = body != null ? (String) body.get("customerQuestion") : null;
+            String salesAnswer = body != null ? (String) body.get("salesAnswer") : null;
+            String scenario = body != null ? (String) body.getOrDefault("scenario", "通用") : "通用";
+            if (customerQuestion == null || salesAnswer == null) {
+                return AjaxResult.error("客户问题和销冠回答不能为空");
+            }
+            jdbcTemplate.update(
+                "INSERT INTO lobster_sales_corpus (company_id, salesperson_name, customer_question, sales_answer, scenario, status, create_time) " +
+                "VALUES (?,?,?,?,?,'raw',NOW())",
+                companyId, salespersonName, customerQuestion, salesAnswer, scenario);
+            return AjaxResult.success("录入成功");
+        } catch (Exception e) {
+            return AjaxResult.error("录入失败: " + e.getMessage());
+        }
     }
 
     @PostMapping("/workflow/lobster/sales-corpus/analyze")
@@ -340,8 +449,37 @@ public class AdminLobsterBridgeController extends BaseController {
 
     // ========== 销冠语料补充 ==========
     @PostMapping("/workflow/lobster/sales-corpus/batch-import")
-    public AjaxResult lobsterSalesCorpusBatchImport() {
-        return AjaxResult.success("导入成功");
+    public AjaxResult lobsterSalesCorpusBatchImport(@RequestBody Map<String, Object> body) {
+        if (jdbcTemplate == null) return AjaxResult.error("数据库未初始化");
+        try {
+            Long companyId = body.get("companyId") != null ?
+                    Long.valueOf(body.get("companyId").toString()) : null;
+            String salespersonName = (String) body.getOrDefault("salespersonName", "销冠");
+            String scenario = (String) body.getOrDefault("scenario", "通用");
+            List<Map<String, Object>> dialogs = (List<Map<String, Object>>) body.get("dialogs");
+            if (dialogs == null || dialogs.isEmpty()) {
+                return AjaxResult.error("导入数据不能为空");
+            }
+            int count = 0;
+            for (Map<String, Object> dialog : dialogs) {
+                String customer = (String) dialog.get("customer");
+                String sales = (String) dialog.get("sales");
+                if (customer == null || customer.trim().isEmpty() || sales == null || sales.trim().isEmpty()) {
+                    continue;
+                }
+                jdbcTemplate.update(
+                    "INSERT INTO lobster_sales_corpus (company_id, salesperson_name, customer_question, sales_answer, scenario, status, create_time) " +
+                    "VALUES (?,?,?,?,?,'raw',NOW())",
+                    companyId, salespersonName, customer.trim(), sales.trim(), scenario);
+                count++;
+            }
+            Map<String, Object> result = new LinkedHashMap<>();
+            result.put("message", "批量导入完成");
+            result.put("count", count);
+            return AjaxResult.success(result);
+        } catch (Exception e) {
+            return AjaxResult.error("批量导入失败: " + e.getMessage());
+        }
     }
 
     // ========== 死信队列补充 ==========

+ 60 - 0
fs-admin/src/main/java/com/fs/web/controller/system/CompanySmsApiController.java

@@ -0,0 +1,60 @@
+package com.fs.web.controller.system;
+
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.proxy.domain.CompanySmsApi;
+import com.fs.proxy.service.ICompanySmsApiService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 短信接口管理Controller (adminUI)
+ */
+@RestController
+@RequestMapping("/admin/smsApi")
+public class CompanySmsApiController extends BaseController {
+
+    @Autowired
+    private ICompanySmsApiService smsApiService;
+
+    /** 查询接口列表 */
+    @PreAuthorize("@ss.hasPermi('platform:smsApi:list')")
+    @GetMapping("/list")
+    public AjaxResult list(CompanySmsApi query) {
+        List<CompanySmsApi> list = smsApiService.selectSmsApiList(query);
+        return AjaxResult.success(list);
+    }
+
+    /** 获取接口详情 */
+    @PreAuthorize("@ss.hasPermi('platform:smsApi:query')")
+    @GetMapping("/{apiId}")
+    public AjaxResult getInfo(@PathVariable Long apiId) {
+        return AjaxResult.success(smsApiService.selectSmsApiById(apiId));
+    }
+
+    /** 新增接口 */
+    @PreAuthorize("@ss.hasPermi('platform:smsApi:add')")
+    @PostMapping
+    public AjaxResult add(@RequestBody CompanySmsApi smsApi) {
+        smsApi.setCreateBy(getUsername());
+        return toAjax(smsApiService.insertSmsApi(smsApi));
+    }
+
+    /** 修改接口 */
+    @PreAuthorize("@ss.hasPermi('platform:smsApi:edit')")
+    @PutMapping
+    public AjaxResult edit(@RequestBody CompanySmsApi smsApi) {
+        smsApi.setUpdateBy(getUsername());
+        return toAjax(smsApiService.updateSmsApi(smsApi));
+    }
+
+    /** 删除接口 */
+    @PreAuthorize("@ss.hasPermi('platform:smsApi:remove')")
+    @DeleteMapping("/{apiId}")
+    public AjaxResult remove(@PathVariable Long apiId) {
+        return toAjax(smsApiService.deleteSmsApiById(apiId));
+    }
+}

+ 65 - 0
fs-admin/src/main/java/com/fs/web/controller/system/CompanySmsApiTenantController.java

@@ -0,0 +1,65 @@
+package com.fs.web.controller.system;
+
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.proxy.domain.CompanySmsApiTenant;
+import com.fs.proxy.service.ICompanySmsApiTenantService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 短信接口-租户绑定Controller (adminUI)
+ */
+@RestController
+@RequestMapping("/admin/smsApiTenant")
+public class CompanySmsApiTenantController extends BaseController {
+
+    @Autowired
+    private ICompanySmsApiTenantService smsApiTenantService;
+
+    /** 查询绑定列表 */
+    @PreAuthorize("@ss.hasPermi('platform:smsApiTenant:list')")
+    @GetMapping("/list")
+    public AjaxResult list(CompanySmsApiTenant query) {
+        List<CompanySmsApiTenant> list = smsApiTenantService.selectSmsApiTenantList(query);
+        return AjaxResult.success(list);
+    }
+
+    /** 获取绑定详情 */
+    @PreAuthorize("@ss.hasPermi('platform:smsApiTenant:query')")
+    @GetMapping("/{id}")
+    public AjaxResult getInfo(@PathVariable Long id) {
+        return AjaxResult.success(smsApiTenantService.selectSmsApiTenantById(id));
+    }
+
+    /** 查询租户已绑定的接口 */
+    @PreAuthorize("@ss.hasPermi('platform:smsApiTenant:list')")
+    @GetMapping("/byCompany/{companyId}")
+    public AjaxResult getByCompany(@PathVariable Long companyId) {
+        return AjaxResult.success(smsApiTenantService.selectByCompanyId(companyId));
+    }
+
+    /** 新增绑定 */
+    @PreAuthorize("@ss.hasPermi('platform:smsApiTenant:add')")
+    @PostMapping
+    public AjaxResult add(@RequestBody CompanySmsApiTenant tenant) {
+        return toAjax(smsApiTenantService.insertSmsApiTenant(tenant));
+    }
+
+    /** 修改绑定(调价/启停) */
+    @PreAuthorize("@ss.hasPermi('platform:smsApiTenant:edit')")
+    @PutMapping
+    public AjaxResult edit(@RequestBody CompanySmsApiTenant tenant) {
+        return toAjax(smsApiTenantService.updateSmsApiTenant(tenant));
+    }
+
+    /** 解除绑定 */
+    @PreAuthorize("@ss.hasPermi('platform:smsApiTenant:remove')")
+    @DeleteMapping("/{id}")
+    public AjaxResult remove(@PathVariable Long id) {
+        return toAjax(smsApiTenantService.deleteSmsApiTenantById(id));
+    }
+}

+ 180 - 0
fs-admin/src/main/java/com/fs/web/controller/system/CompanySmsPortController.java

@@ -0,0 +1,180 @@
+package com.fs.web.controller.system;
+
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.proxy.domain.*;
+import com.fs.proxy.service.ICompanySmsPortService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 短信端口+卡管理+中间件 Controller (adminUI)
+ */
+@RestController
+@RequestMapping("/admin/smsPort")
+public class CompanySmsPortController extends BaseController {
+
+    @Autowired
+    private ICompanySmsPortService portService;
+
+    // ========== 端口池管理 ==========
+
+    /** 查询端口列表 */
+    @PreAuthorize("@ss.hasPermi('platform:smsApi:list')")
+    @GetMapping("/port/list")
+    public AjaxResult portList(CompanySmsApiPort query) {
+        List<CompanySmsApiPort> list = portService.selectPortList(query);
+        return AjaxResult.success(list);
+    }
+
+    /** 按接口ID查询端口 */
+    @PreAuthorize("@ss.hasPermi('platform:smsApi:list')")
+    @GetMapping("/port/listByApi/{apiId}")
+    public AjaxResult portListByApi(@PathVariable Long apiId) {
+        return AjaxResult.success(portService.selectPortByApiId(apiId));
+    }
+
+    /** 新增端口 */
+    @PreAuthorize("@ss.hasPermi('platform:smsApi:edit')")
+    @PostMapping("/port")
+    public AjaxResult addPort(@RequestBody CompanySmsApiPort port) {
+        port.setCreateBy(getUsername());
+        return toAjax(portService.insertPort(port));
+    }
+
+    /** 修改端口 */
+    @PreAuthorize("@ss.hasPermi('platform:smsApi:edit')")
+    @PutMapping("/port")
+    public AjaxResult editPort(@RequestBody CompanySmsApiPort port) {
+        port.setUpdateBy(getUsername());
+        return toAjax(portService.updatePort(port));
+    }
+
+    /** 删除端口 */
+    @PreAuthorize("@ss.hasPermi('platform:smsApi:edit')")
+    @DeleteMapping("/port/{portId}")
+    public AjaxResult removePort(@PathVariable Long portId) {
+        return toAjax(portService.deletePortById(portId));
+    }
+
+    // ========== 端口分配管理 ==========
+
+    /** 查询分配列表 */
+    @PreAuthorize("@ss.hasPermi('platform:smsApi:list')")
+    @GetMapping("/assign/list")
+    public AjaxResult assignList(CompanySmsPortAssign query) {
+        return AjaxResult.success(portService.selectAssignList(query));
+    }
+
+    /** 新增分配 */
+    @PreAuthorize("@ss.hasPermi('platform:smsApi:edit')")
+    @PostMapping("/assign")
+    public AjaxResult addAssign(@RequestBody CompanySmsPortAssign assign) {
+        return toAjax(portService.insertAssign(assign));
+    }
+
+    /** 修改分配 */
+    @PreAuthorize("@ss.hasPermi('platform:smsApi:edit')")
+    @PutMapping("/assign")
+    public AjaxResult editAssign(@RequestBody CompanySmsPortAssign assign) {
+        return toAjax(portService.updateAssign(assign));
+    }
+
+    /** 删除分配 */
+    @PreAuthorize("@ss.hasPermi('platform:smsApi:edit')")
+    @DeleteMapping("/assign/{id}")
+    public AjaxResult removeAssign(@PathVariable Long id) {
+        return toAjax(portService.deleteAssignById(id));
+    }
+
+    // ========== 手机卡管理 ==========
+
+    /** 查询卡列表 */
+    @PreAuthorize("@ss.hasPermi('platform:smsCard:list')")
+    @GetMapping("/card/list")
+    public AjaxResult cardList(CompanySmsCard query) {
+        return AjaxResult.success(portService.selectCardList(query));
+    }
+
+    /** 卡详情 */
+    @PreAuthorize("@ss.hasPermi('platform:smsCard:query')")
+    @GetMapping("/card/{cardId}")
+    public AjaxResult cardInfo(@PathVariable Long cardId) {
+        return AjaxResult.success(portService.selectCardById(cardId));
+    }
+
+    /** 新增卡 */
+    @PreAuthorize("@ss.hasPermi('platform:smsCard:add')")
+    @PostMapping("/card")
+    public AjaxResult addCard(@RequestBody CompanySmsCard card) {
+        return toAjax(portService.insertCard(card));
+    }
+
+    /** 修改卡 */
+    @PreAuthorize("@ss.hasPermi('platform:smsCard:edit')")
+    @PutMapping("/card")
+    public AjaxResult editCard(@RequestBody CompanySmsCard card) {
+        return toAjax(portService.updateCard(card));
+    }
+
+    /** 删除卡 */
+    @PreAuthorize("@ss.hasPermi('platform:smsCard:remove')")
+    @DeleteMapping("/card/{cardId}")
+    public AjaxResult removeCard(@PathVariable Long cardId) {
+        return toAjax(portService.deleteCardById(cardId));
+    }
+
+    // ========== 手机卡心跳(无需登录鉴权,手机APP调用) ==========
+
+    /** 心跳上报 */
+    @PostMapping("/card/heartbeat")
+    public AjaxResult heartbeat(@RequestParam String imei,
+                                @RequestParam(required = false) String appVersion,
+                                @RequestParam(required = false) String phone1,
+                                @RequestParam(required = false) String phone2,
+                                @RequestParam(required = false) String deviceName,
+                                @RequestParam(required = false) Long tenantId) {
+        portService.heartbeat(imei, appVersion, phone1, phone2, deviceName, tenantId);
+        return AjaxResult.success();
+    }
+
+    // ========== 中间件管理 ==========
+
+    /** 查询中间件列表 */
+    @PreAuthorize("@ss.hasPermi('platform:smsCard:list')")
+    @GetMapping("/middleware/list")
+    public AjaxResult middlewareList(CompanySmsCardMiddleware query) {
+        return AjaxResult.success(portService.selectMiddlewareList(query));
+    }
+
+    /** 按接口ID查中间件 */
+    @PreAuthorize("@ss.hasPermi('platform:smsCard:query')")
+    @GetMapping("/middleware/byApi/{apiId}")
+    public AjaxResult middlewareByApi(@PathVariable Long apiId) {
+        return AjaxResult.success(portService.selectMiddlewareByApiId(apiId));
+    }
+
+    /** 新增中间件 */
+    @PreAuthorize("@ss.hasPermi('platform:smsCard:add')")
+    @PostMapping("/middleware")
+    public AjaxResult addMiddleware(@RequestBody CompanySmsCardMiddleware mw) {
+        return toAjax(portService.insertMiddleware(mw));
+    }
+
+    /** 修改中间件 */
+    @PreAuthorize("@ss.hasPermi('platform:smsCard:edit')")
+    @PutMapping("/middleware")
+    public AjaxResult editMiddleware(@RequestBody CompanySmsCardMiddleware mw) {
+        return toAjax(portService.updateMiddleware(mw));
+    }
+
+    /** 删除中间件 */
+    @PreAuthorize("@ss.hasPermi('platform:smsCard:remove')")
+    @DeleteMapping("/middleware/{id}")
+    public AjaxResult removeMiddleware(@PathVariable Long id) {
+        return toAjax(portService.deleteMiddlewareById(id));
+    }
+}

+ 1 - 0
fs-admin/src/main/java/com/fs/web/controller/system/SysKeywordController.java

@@ -143,6 +143,7 @@ public class SysKeywordController extends BaseController
             List<SysKeyword> sysKeywords = sysKeywordService.selectSysKeywordList(sysKeywordParam);
             List<String> keywords = sysKeywords.stream()
                     .map(SysKeyword::getKeyword)
+                    .filter(k -> k != null && !k.isEmpty())
                     .collect(Collectors.toList());
 
             if (!keywords.isEmpty()) {

+ 0 - 163
fs-company/src/main/java/com/fs/company/AddSmsApiMenu.java

@@ -1,163 +0,0 @@
-package com.fs.company;
-
-import java.sql.*;
-import java.io.*;
-
-public class AddSmsApiMenu {
-    public static void main(String[] args) {
-        String url = "jdbc:mysql://cq-cdb-8fjmemkb.sql.tencentcdb.com:27220/ylrz_saas?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&useSSL=false";
-        String user = "root";
-        String password = "Ylrz_1q2w3e4r5t6y";
-        
-        String outFile = "d:/AICODE/saas/add_sms_api_menu_result.txt";
-        PrintWriter pw = null;
-        try {
-            pw = new PrintWriter(new OutputStreamWriter(new FileOutputStream(outFile), "UTF-8"));
-        } catch (Exception e) {
-            System.err.println("无法创建输出文件: " + e.getMessage());
-            return;
-        }
-        
-        try {
-            Class.forName("com.mysql.cj.jdbc.Driver");
-            pw.println("DEBUG: Driver loaded");
-            pw.flush();
-            
-            Connection conn = DriverManager.getConnection(url, user, password);
-            pw.println("DEBUG: Connected to database");
-            pw.flush();
-            
-            // 1. 查找通信管理分组
-            String findGroupSql = "SELECT menu_id, menu_name, parent_id, order_num, path, component FROM sys_menu WHERE menu_name = '通信管理' AND menu_type = 'M' AND status = '0'";
-            Statement stmt = conn.createStatement();
-            ResultSet rs = stmt.executeQuery(findGroupSql);
-            
-            long groupId = -1;
-            String groupName = "";
-            pw.println("\n=== 通信管理分组 ===");
-            while (rs.next()) {
-                groupId = rs.getLong("menu_id");
-                groupName = rs.getString("menu_name");
-                pw.println("menu_id=" + groupId + ", menu_name=" + groupName + ", parent_id=" + rs.getLong("parent_id"));
-            }
-            rs.close();
-            stmt.close();
-            
-            if (groupId == -1) {
-                pw.println("ERROR: 找不到通信管理分组!");
-                conn.close();
-                pw.close();
-                return;
-            }
-            
-            // 2. 查看通信管理下的现有子菜单
-            String findChildrenSql = "SELECT menu_id, menu_name, order_num, path, component FROM sys_menu WHERE parent_id = " + groupId + " AND menu_type IN ('M','C') AND status = '0' ORDER BY order_num";
-            Statement stmt2 = conn.createStatement();
-            ResultSet rs2 = stmt2.executeQuery(findChildrenSql);
-            
-            pw.println("\n=== 通信管理下的子菜单 ===");
-            int maxOrderNum = 0;
-            long maxMenuId = 0;
-            boolean smsApiExists = false;
-            while (rs2.next()) {
-                long mid = rs2.getLong("menu_id");
-                String mname = rs2.getString("menu_name");
-                int order = rs2.getInt("order_num");
-                String mpath = rs2.getString("path");
-                pw.println("menu_id=" + mid + ", menu_name=" + mname + ", order_num=" + order + ", path=" + mpath);
-                if (order > maxOrderNum) maxOrderNum = order;
-                if (mid > maxMenuId) maxMenuId = mid;
-                if ("smsApi".equals(mpath) || "短信接口".equals(mname)) smsApiExists = true;
-            }
-            rs2.close();
-            stmt2.close();
-            
-            if (smsApiExists) {
-                pw.println("\n短信接口菜单已存在,跳过插入");
-                conn.close();
-                pw.close();
-                return;
-            }
-            
-            // 3. 插入短信接口菜单
-            // 找一个不冲突的menu_id:在现有最大menu_id基础上+1
-            String findMaxIdSql = "SELECT MAX(menu_id) as max_id FROM sys_menu";
-            Statement stmt3 = conn.createStatement();
-            ResultSet rs3 = stmt3.executeQuery(findMaxIdSql);
-            long newMenuId = 28261; // 默认值
-            if (rs3.next()) {
-                long dbMax = rs3.getLong("max_id");
-                newMenuId = Math.max(dbMax + 1, 28261);
-            }
-            rs3.close();
-            stmt3.close();
-            
-            // 短信相关菜单排在短信管理之后,短信套餐之前
-            // 找短信管理的order_num
-            String findSmsOrderSql = "SELECT order_num FROM sys_menu WHERE parent_id = " + groupId + " AND path = 'sms'";
-            Statement stmt4 = conn.createStatement();
-            ResultSet rs4 = stmt4.executeQuery(findSmsOrderSql);
-            int smsOrder = 8; // 默认
-            if (rs4.next()) {
-                smsOrder = rs4.getInt("order_num");
-            }
-            rs4.close();
-            stmt4.close();
-            
-            // 将短信套餐和之后的菜单order_num后移
-            String shiftOrdersSql = "UPDATE sys_menu SET order_num = order_num + 1 WHERE parent_id = " + groupId + " AND order_num > " + smsOrder;
-            Statement stmt5 = conn.createStatement();
-            int shiftCount = stmt5.executeUpdate(shiftOrdersSql);
-            pw.println("\n后移了 " + shiftCount + " 个菜单的order_num");
-            stmt5.close();
-            
-            // 插入短信接口菜单
-            String insertSql = "INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, menu_type, icon, visible, status, perms, is_frame, is_cache, create_by, create_time) " +
-                "VALUES (" + newMenuId + ", '短信接口', " + groupId + ", " + (smsOrder + 1) + ", 'smsApi', 'admin/smsApi/index', 'C', 'el-icon-connection', '0', '0', '', 0, 0, 'admin', NOW())";
-            Statement stmt6 = conn.createStatement();
-            int insertCount = stmt6.executeUpdate(insertSql);
-            pw.println("\n插入短信接口菜单: menu_id=" + newMenuId + ", 影响" + insertCount + "行");
-            stmt6.close();
-            
-            // 给超级管理员角色授权 (role_id=1)
-            String insertRoleMenuSql = "INSERT INTO sys_role_menu (role_id, menu_id) VALUES (1, " + newMenuId + ")";
-            Statement stmt7 = conn.createStatement();
-            int rmCount = stmt7.executeUpdate(insertRoleMenuSql);
-            pw.println("插入角色菜单关联: role_id=1, menu_id=" + newMenuId + ", 影响" + rmCount + "行");
-            stmt7.close();
-            
-            // 4. 验证插入结果
-            String verifySql = "SELECT menu_id, menu_name, parent_id, order_num, path, component FROM sys_menu WHERE menu_id = " + newMenuId;
-            Statement stmt8 = conn.createStatement();
-            ResultSet rs8 = stmt8.executeQuery(verifySql);
-            pw.println("\n=== 验证插入结果 ===");
-            while (rs8.next()) {
-                pw.println("menu_id=" + rs8.getLong("menu_id") + ", menu_name=" + rs8.getString("menu_name") + 
-                    ", parent_id=" + rs8.getLong("parent_id") + ", order_num=" + rs8.getInt("order_num") + 
-                    ", path=" + rs8.getString("path") + ", component=" + rs8.getString("component"));
-            }
-            rs8.close();
-            stmt8.close();
-            
-            // 5. 显示更新后的通信管理下子菜单
-            String verifyChildrenSql = "SELECT menu_id, menu_name, order_num, path, component FROM sys_menu WHERE parent_id = " + groupId + " AND menu_type IN ('M','C') AND status = '0' ORDER BY order_num";
-            Statement stmt9 = conn.createStatement();
-            ResultSet rs9 = stmt9.executeQuery(verifyChildrenSql);
-            pw.println("\n=== 更新后通信管理下的子菜单 ===");
-            while (rs9.next()) {
-                pw.println("menu_id=" + rs9.getLong("menu_id") + ", menu_name=" + rs9.getString("menu_name") + 
-                    ", order_num=" + rs9.getInt("order_num") + ", path=" + rs9.getString("path"));
-            }
-            rs9.close();
-            stmt9.close();
-            
-            conn.close();
-            pw.println("\nDONE!");
-        } catch (Exception e) {
-            pw.println("ERROR: " + e.getMessage());
-            e.printStackTrace(pw);
-        }
-        
-        pw.close();
-    }
-}

+ 0 - 58
fs-company/src/main/java/com/fs/company/CheckSmsConfig.java

@@ -1,58 +0,0 @@
-package com.fs.company;
-
-import java.sql.*;
-import java.io.*;
-
-public class CheckSmsConfig {
-    public static void main(String[] args) {
-        String url = "jdbc:mysql://cq-cdb-8fjmemkb.sql.tencentcdb.com:27220/ylrz_saas?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&useSSL=false";
-        String user = "root";
-        String password = "Ylrz_1q2w3e4r5t6y";
-        
-        String outFile = "d:/AICODE/saas/check_sms_config_result.txt";
-        PrintWriter pw = null;
-        try {
-            pw = new PrintWriter(new OutputStreamWriter(new FileOutputStream(outFile), "UTF-8"));
-        } catch (Exception e) {
-            System.err.println("无法创建输出文件: " + e.getMessage());
-            return;
-        }
-        
-        try {
-            Class.forName("com.mysql.cj.jdbc.Driver");
-            Connection conn = DriverManager.getConnection(url, user, password);
-            
-            // 查询 his.sms 配置
-            String sql = "SELECT config_id, config_name, config_key, config_value FROM sys_config WHERE config_key = 'his.sms'";
-            Statement stmt = conn.createStatement();
-            ResultSet rs = stmt.executeQuery(sql);
-            
-            pw.println("=== his.sms 配置 ===");
-            boolean found = false;
-            while (rs.next()) {
-                found = true;
-                pw.println("config_id=" + rs.getLong("config_id"));
-                pw.println("config_name=" + rs.getString("config_name"));
-                pw.println("config_key=" + rs.getString("config_key"));
-                String value = rs.getString("config_value");
-                if (value != null && value.length() > 500) {
-                    pw.println("config_value=" + value.substring(0, 500) + "...(truncated, total=" + value.length() + ")");
-                } else {
-                    pw.println("config_value=" + value);
-                }
-            }
-            if (!found) {
-                pw.println("his.sms 配置不存在!");
-            }
-            
-            rs.close();
-            stmt.close();
-            conn.close();
-            pw.println("\nDONE!");
-        } catch (Exception e) {
-            pw.println("ERROR: " + e.getMessage());
-            e.printStackTrace(pw);
-        }
-        pw.close();
-    }
-}

+ 206 - 0
fs-company/src/main/java/com/fs/company/SmsApiMigration.java

@@ -0,0 +1,206 @@
+package com.fs.company;
+
+import java.sql.*;
+import java.io.*;
+
+public class SmsApiMigration {
+    public static void main(String[] args) throws Exception {
+        String url = "jdbc:mysql://cq-cdb-8fjmemkb.sql.tencentcdb.com:27220/ylrz_saas?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&useSSL=false";
+        String user = "root";
+        String pass = "Ylrz_1q2w3e4r5t6y";
+        String outFile = "d:/AICODE/saas/sms_api_migration_result.txt";
+        PrintWriter pw = new PrintWriter(new OutputStreamWriter(new FileOutputStream(outFile), "UTF-8"));
+
+        Class.forName("com.mysql.cj.jdbc.Driver");
+        Connection conn = DriverManager.getConnection(url, user, pass);
+        Statement stmt = conn.createStatement();
+
+        // 1. Create company_sms_api table
+        String createApiTable = "CREATE TABLE IF NOT EXISTS `company_sms_api` (" +
+            "`api_id` bigint NOT NULL AUTO_INCREMENT COMMENT '接口ID'," +
+            "`api_name` varchar(100) NOT NULL COMMENT '接口名称'," +
+            "`provider` varchar(20) NOT NULL COMMENT '服务商: rf润方/dh德华'," +
+            "`temp_type` int NOT NULL COMMENT '场景类型: 1营销 2通知'," +
+            "`account` varchar(100) DEFAULT NULL COMMENT '账户名'," +
+            "`password` varchar(100) DEFAULT NULL COMMENT '密码'," +
+            "`url` varchar(255) DEFAULT NULL COMMENT '接口地址(润方专用)'," +
+            "`code` varchar(50) DEFAULT NULL COMMENT '扩展码(润方专用)'," +
+            "`sign` varchar(50) DEFAULT NULL COMMENT '短信签名'," +
+            "`status` int DEFAULT 1 COMMENT '状态 0禁用 1正常'," +
+            "`remark` varchar(500) DEFAULT NULL COMMENT '备注'," +
+            "`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
+            "`update_time` datetime DEFAULT NULL COMMENT '更新时间'," +
+            "PRIMARY KEY (`api_id`)," +
+            "KEY `idx_provider` (`provider`)," +
+            "KEY `idx_temp_type` (`temp_type`)" +
+            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='短信接口'";
+        stmt.executeUpdate(createApiTable);
+        pw.println("1. company_sms_api table created");
+
+        // 2. Create company_sms_api_tenant table
+        String createTenantTable = "CREATE TABLE IF NOT EXISTS `company_sms_api_tenant` (" +
+            "`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键'," +
+            "`api_id` bigint NOT NULL COMMENT '短信接口ID'," +
+            "`company_id` bigint NOT NULL COMMENT '租户ID'," +
+            "`status` tinyint DEFAULT 1 COMMENT '状态 1启用 0禁用'," +
+            "`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
+            "PRIMARY KEY (`id`)," +
+            "UNIQUE KEY `uk_api_company` (`api_id`, `company_id`)," +
+            "KEY `idx_company_id` (`company_id`)" +
+            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='短信接口-租户分配关系'";
+        stmt.executeUpdate(createTenantTable);
+        pw.println("2. company_sms_api_tenant table created");
+
+        // 3. Check if data already exists
+        ResultSet rs = stmt.executeQuery("SELECT COUNT(1) FROM company_sms_api");
+        rs.next();
+        int count = rs.getInt(1);
+        rs.close();
+        if (count > 0) {
+            pw.println("3. company_sms_api already has " + count + " records, skipping migration");
+        } else {
+            // 4. Migrate from his.sms config
+            rs = stmt.executeQuery("SELECT config_value FROM sys_config WHERE config_key = 'his.sms'");
+            String configValue = null;
+            if (rs.next()) {
+                configValue = rs.getString("config_value");
+            }
+            rs.close();
+
+            if (configValue != null && !configValue.isEmpty() && !configValue.equals("{}")) {
+                pw.println("3. Found his.sms config, migrating...");
+                // Parse JSON manually (avoid dependency on fastjson in standalone program)
+                // Extract values using simple string parsing
+                String rfAccount1 = extractJson(configValue, "rfAccount1");
+                String rfCode1 = extractJson(configValue, "rfCode1");
+                String rfPassword1 = extractJson(configValue, "rfPassword1");
+                String rfUrl1 = extractJson(configValue, "rfUrl1");
+                String rfAccount2 = extractJson(configValue, "rfAccount2");
+                String rfCode2 = extractJson(configValue, "rfCode2");
+                String rfPassword2 = extractJson(configValue, "rfPassword2");
+                String rfUrl2 = extractJson(configValue, "rfUrl2");
+                String rfSign = extractJson(configValue, "rfSign");
+                String dhAccount1 = extractJson(configValue, "dhAccount1");
+                String dhPassword1 = extractJson(configValue, "dhPassword1");
+                String dhAccount2 = extractJson(configValue, "dhAccount2");
+                String dhPassword2 = extractJson(configValue, "dhPassword2");
+                String dhSign = extractJson(configValue, "dhSign");
+
+                PreparedStatement ps = conn.prepareStatement(
+                    "INSERT INTO company_sms_api (api_name, provider, temp_type, account, password, url, code, sign, status) VALUES (?,?,?,?,?,?,?,?,?,1)");
+
+                // Insert rf marketing channel if account exists
+                if (rfAccount1 != null && !rfAccount1.isEmpty()) {
+                    ps.setString(1, "润方营销通道");
+                    ps.setString(2, "rf");
+                    ps.setInt(3, 1);
+                    ps.setString(4, rfAccount1);
+                    ps.setString(5, rfPassword1);
+                    ps.setString(6, rfUrl1);
+                    ps.setString(7, rfCode1);
+                    ps.setString(8, rfSign);
+                    ps.executeUpdate();
+                    pw.println("  - Inserted: 润方营销通道");
+                }
+
+                // Insert rf notification channel
+                if (rfAccount2 != null && !rfAccount2.isEmpty()) {
+                    ps.setString(1, "润方通知通道");
+                    ps.setString(2, "rf");
+                    ps.setInt(3, 2);
+                    ps.setString(4, rfAccount2);
+                    ps.setString(5, rfPassword2);
+                    ps.setString(6, rfUrl2);
+                    ps.setString(7, rfCode2);
+                    ps.setString(8, rfSign);
+                    ps.executeUpdate();
+                    pw.println("  - Inserted: 润方通知通道");
+                }
+
+                // Insert dh marketing channel
+                if (dhAccount1 != null && !dhAccount1.isEmpty()) {
+                    ps.setString(1, "德华营销通道");
+                    ps.setString(2, "dh");
+                    ps.setInt(3, 1);
+                    ps.setString(4, dhAccount1);
+                    ps.setString(5, dhPassword1);
+                    ps.setString(6, null);
+                    ps.setString(7, null);
+                    ps.setString(8, dhSign);
+                    ps.executeUpdate();
+                    pw.println("  - Inserted: 德华营销通道");
+                }
+
+                // Insert dh notification channel
+                if (dhAccount2 != null && !dhAccount2.isEmpty()) {
+                    ps.setString(1, "德华通知通道");
+                    ps.setString(2, "dh");
+                    ps.setInt(3, 2);
+                    ps.setString(4, dhAccount2);
+                    ps.setString(5, dhPassword2);
+                    ps.setString(6, null);
+                    ps.setString(7, null);
+                    ps.setString(8, dhSign);
+                    ps.executeUpdate();
+                    pw.println("  - Inserted: 德华通知通道");
+                }
+                ps.close();
+            } else {
+                pw.println("3. No his.sms config found, skipping migration");
+            }
+        }
+
+        // 5. Verify inserted data
+        rs = stmt.executeQuery("SELECT api_id, api_name, provider, temp_type, account, sign FROM company_sms_api");
+        pw.println("\n4. company_sms_api records:");
+        while (rs.next()) {
+            pw.println("  api_id=" + rs.getLong("api_id") + ", name=" + rs.getString("api_name") +
+                ", provider=" + rs.getString("provider") + ", tempType=" + rs.getInt("temp_type") +
+                ", account=" + rs.getString("account") + ", sign=" + rs.getString("sign"));
+        }
+        rs.close();
+
+        // 6. Assign all existing APIs to all active tenants
+        rs = stmt.executeQuery("SELECT COUNT(1) FROM company_sms_api_tenant");
+        rs.next();
+        int tenantCount = rs.getInt(1);
+        rs.close();
+        if (tenantCount == 0) {
+            String assignSql = "INSERT IGNORE INTO company_sms_api_tenant (api_id, company_id, status, create_time) " +
+                "SELECT a.api_id, c.company_id, 1, NOW() " +
+                "FROM company_sms_api a CROSS JOIN company c " +
+                "WHERE c.status = 0";
+            int assigned = stmt.executeUpdate(assignSql);
+            pw.println("\n5. Assigned APIs to " + assigned + " tenant-api pairs");
+        } else {
+            pw.println("\n5. Tenant assignments already exist (" + tenantCount + "), skipping");
+        }
+
+        stmt.close();
+        conn.close();
+        pw.println("\nDONE!");
+        pw.close();
+    }
+
+    static String extractJson(String json, String key) {
+        String searchKey = "\"" + key + "\":\"";
+        int idx = json.indexOf(searchKey);
+        if (idx < 0) {
+            // Try without quotes (for numbers)
+            searchKey = "\"" + key + "\":";
+            idx = json.indexOf(searchKey);
+            if (idx < 0) return null;
+            idx += searchKey.length();
+            int end = json.indexOf(",", idx);
+            int end2 = json.indexOf("}", idx);
+            if (end < 0 || (end2 >= 0 && end2 < end)) end = end2;
+            if (end < 0) return null;
+            String val = json.substring(idx, end).trim();
+            return val.equals("null") ? null : val;
+        }
+        idx += searchKey.length();
+        int end = json.indexOf("\"", idx);
+        if (end < 0) return null;
+        return json.substring(idx, end);
+    }
+}

+ 31 - 9
fs-company/src/main/java/com/fs/company/controller/workflow/LobsterSalesCorpusController.java

@@ -73,8 +73,10 @@ public class LobsterSalesCorpusController extends BaseController {
     }
 
     /**
-     * 批量导入(json格式)
-     * body: {"salespersonName":"张三","industryType":"education","chats":"[{...},{...}]"}
+     * 批量导入
+     * 支持两种格式:
+     * 1. dialogs数组: {"salespersonName":"张三","scenario":"通用","dialogs":[{"customer":"...","sales":"..."},...]}
+     * 2. chats JSON字符串(兼容旧版): {"salespersonName":"张三","chats":"[{...}]"}
      */
     @PreAuthorize("@ss.hasPermi('workflow:lobster:edit')")
     @PostMapping("/batch-import")
@@ -82,14 +84,34 @@ public class LobsterSalesCorpusController extends BaseController {
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
         Long companyId = loginUser.getCompany().getCompanyId();
 
-        if (corpusAnalyzer == null) return AjaxResult.error("语料分析器未初始化");
-
         String salespersonName = (String) body.getOrDefault("salespersonName", "销冠");
-        String industryType = (String) body.get("industryType");
-        String chats = (String) body.get("chats");
-        if (chats == null || chats.isEmpty()) return AjaxResult.error("chats不能为空");
-
-        int count = corpusAnalyzer.batchUpload(companyId, salespersonName, industryType, chats);
+        String scenario = (String) body.getOrDefault("scenario", "通用");
+        int count = 0;
+
+        // 优先使用dialogs数组格式
+        Object dialogsObj = body.get("dialogs");
+        if (dialogsObj instanceof List) {
+            @SuppressWarnings("unchecked")
+            List<Map<String, Object>> dialogs = (List<Map<String, Object>>) dialogsObj;
+            for (Map<String, Object> dialog : dialogs) {
+                String customer = (String) dialog.get("customer");
+                String sales = (String) dialog.get("sales");
+                if (customer == null || customer.trim().isEmpty() || sales == null || sales.trim().isEmpty()) {
+                    continue;
+                }
+                if (corpusAnalyzer != null) {
+                    corpusAnalyzer.uploadDialog(companyId, salespersonName, customer.trim(), sales.trim(), scenario, null, null);
+                }
+                count++;
+            }
+        } else {
+            // 兼容旧版chats JSON字符串格式
+            if (corpusAnalyzer == null) return AjaxResult.error("语料分析器未初始化");
+            String chats = (String) body.get("chats");
+            if (chats == null || chats.isEmpty()) return AjaxResult.error("dialogs或chats不能为空");
+            String industryType = (String) body.get("industryType");
+            count = corpusAnalyzer.batchUpload(companyId, salespersonName, industryType, chats);
+        }
 
         Map<String, Object> result = new LinkedHashMap<>();
         result.put("message", "批量导入完成");

+ 293 - 328
fs-service/src/main/java/com/fs/common/service/impl/SmsServiceImpl.java

@@ -98,6 +98,224 @@ public class SmsServiceImpl implements ISmsService
     @Autowired
     private BalanceService balanceService;
 
+    @Autowired
+    private com.fs.proxy.service.ICompanySmsPortService smsPortService;
+
+    @Autowired
+    private com.fs.proxy.mapper.CompanySmsApiMapper smsApiMapper;
+
+    @Autowired
+    private com.fs.proxy.mapper.CompanySmsApiTenantMapper smsApiTenantMapper;
+
+    @Autowired
+    private com.fs.proxy.mapper.CompanySmsCardMiddlewareMapper smsCardMiddlewareMapper;
+
+    @Autowired
+    private com.fs.proxy.mapper.CompanySmsCardMapper smsCardMapper;
+
+    /**
+     * 统一发送方法 - 替代原来6处硬编码的 his.sms 配置读取
+     * 
+     * @param phone         目标手机号
+     * @param content       短信内容
+     * @param tempType      模板类型: 1=行业通知 2=营销短信
+     * @param tenantId      租户ID
+     * @param companyUserId 销售人员ID(可为null)
+     * @param preferApiId   销售手动选择的接口ID(可为null, null则自动路由)
+     * @return 发送结果 "OK"=成功, 其他=错误信息
+     */
+    private String resolveAndSend(String phone, String content, Integer tempType,
+                                   Long tenantId, Long companyUserId, Long preferApiId) {
+        // 将模板类型映射到短信发送类型: tempType 1=行业 → smsType 1; tempType 2=营销 → smsType 2
+        Integer smsType = tempType;
+
+        // 1. 通过端口服务解析可用端口(含降级路由逻辑)
+        com.fs.proxy.domain.CompanySmsApiPort port = smsPortService.resolvePort(tenantId, smsType, companyUserId, preferApiId);
+        if (port == null) {
+            log.warn("resolveAndSend: 无可用端口 tenantId={}, smsType={}, userId={}", tenantId, smsType, companyUserId);
+            return "NO_AVAILABLE_PORT";
+        }
+
+        // 2. 获取接口信息
+        com.fs.proxy.domain.CompanySmsApi api = smsApiMapper.selectSmsApiById(port.getApiId());
+        if (api == null) {
+            log.error("resolveAndSend: 接口不存在 apiId={}", port.getApiId());
+            return "API_NOT_FOUND";
+        }
+
+        // 3. 确定实际使用的账户/密码/签名(端口级优先, 接口级兜底)
+        String useAccount = StringUtils.isNotEmpty(port.getAccount()) ? port.getAccount() : api.getAccount();
+        String usePassword = StringUtils.isNotEmpty(port.getPassword()) ? port.getPassword() : api.getPassword();
+        String useSign = StringUtils.isNotEmpty(port.getSign()) ? port.getSign() : api.getSign();
+        String useUrl = api.getUrl();
+
+        // 4. 根据provider分发
+        String provider = api.getProvider();
+        log.info("resolveAndSend: provider={}, apiId={}, portId={}, phone={}", provider, api.getApiId(), port.getPortId(), phone);
+
+        if ("rf".equals(provider)) {
+            // 润方发送
+            return sendByRf(phone, content, tempType, useAccount, usePassword, useSign, useUrl, port.getPortNo(), tenantId, api.getApiId(), port.getPortId());
+        } else if ("dh".equals(provider)) {
+            // 德华发送
+            return sendByDh(phone, content, tempType, useAccount, usePassword, tenantId, api.getApiId(), port.getPortId());
+        } else if ("card".equals(provider)) {
+            // 手机卡发送
+            return sendByCard(phone, content, tenantId, api.getApiId(), port.getPortId(), companyUserId);
+        } else {
+            log.error("resolveAndSend: 未知provider={} apiId={}", provider, api.getApiId());
+            return "UNKNOWN_PROVIDER";
+        }
+    }
+
+    /** 润方发送 */
+    private String sendByRf(String phone, String content, Integer tempType,
+                             String account, String password, String sign, String url, String extno,
+                             Long tenantId, Long apiId, Long portId) {
+        String urls;
+        try {
+            if (tempType.equals(1)) {
+                urls = url + "sms?action=send&account=" + account + "&password=" + password
+                        + "&mobile=" + phone + "&content=" + URLEncoder.encode(sign + content, "UTF-8")
+                        + "&extno=" + extno + "&rt=json";
+            } else if (tempType.equals(2)) {
+                urls = url + "sms?action=send&account=" + account + "&password=" + password
+                        + "&mobile=" + phone + "&content=" + URLEncoder.encode(sign + content + "拒收请回复R", "UTF-8")
+                        + "&extno=" + extno + "&rt=json";
+            } else {
+                return "UNSUPPORTED_TEMP_TYPE";
+            }
+        } catch (UnsupportedEncodingException e) {
+            log.error("sendByRf: URL编码异常", e);
+            return "ENCODE_ERROR";
+        }
+
+        String post = HttpRequest.get(urls).execute().body();
+        SmsSendVO vo = JSONUtil.toBean(post, SmsSendVO.class);
+        if (vo.getStatus().equals(0)) {
+            for (SmsSendItemVO itemVO : vo.getList()) {
+                if (itemVO.getResult().equals("0")) {
+                    // 发送成功, 返回OK (调用方负责写日志)
+                    return "OK";
+                }
+            }
+        }
+        return "SEND_FAILED";
+    }
+
+    /** 德华发送 */
+    private String sendByDh(String phone, String content, Integer tempType,
+                             String account, String password,
+                             Long tenantId, Long apiId, Long portId) {
+        SendSmsReturn sendSmsReturn;
+        if (tempType.equals(1)) {
+            sendSmsReturn = smsTService.sendSms(account, password, content, phone);
+        } else if (tempType.equals(2)) {
+            sendSmsReturn = smsTService.sendSms(account, password, content + "拒收请回复R", phone);
+        } else {
+            return "UNSUPPORTED_TEMP_TYPE";
+        }
+
+        if (sendSmsReturn != null && sendSmsReturn.getResult() != null && sendSmsReturn.getResult().equals("0")) {
+            return "OK";
+        }
+        return "SEND_FAILED";
+    }
+
+    /** 手机卡发送(通过中间件) */
+    private String sendByCard(String phone, String content,
+                               Long tenantId, Long apiId, Long portId, Long companyUserId) {
+        // 1. 查询该端口对应的在线卡
+        com.fs.proxy.domain.CompanySmsCard card = smsCardMapper.selectOnlineCardByPortId(portId);
+        if (card == null) {
+            log.warn("sendByCard: 无在线卡 portId={}", portId);
+            return "CARD_OFFLINE";
+        }
+
+        // 2. 短信发送限制检查
+        String limitCheck = checkCardSmsLimit(card);
+        if (limitCheck != null) {
+            log.warn("sendByCard: 卡限制检查未通过 cardId={}, reason={}", card.getCardId(), limitCheck);
+            return limitCheck;
+        }
+
+        // 3. 查中间件配置
+        com.fs.proxy.domain.CompanySmsCardMiddleware mw = smsCardMiddlewareMapper.selectMiddlewareByApiId(apiId);
+        if (mw == null) {
+            log.error("sendByCard: 中间件未配置 apiId={}", apiId);
+            return "MIDDLEWARE_NOT_FOUND";
+        }
+
+        // 4. 通过HTTP POST下发任务到中间件
+        try {
+            com.alibaba.fastjson.JSONObject task = new com.alibaba.fastjson.JSONObject();
+            task.put("phone", phone);
+            task.put("content", content);
+            task.put("tenantId", tenantId);
+            task.put("portId", portId);
+            task.put("cardId", card.getCardId());
+            task.put("companyUserId", companyUserId);
+            task.put("senderPhone", card.getPhone1());
+            task.put("timestamp", System.currentTimeMillis());
+
+            String result = cn.hutool.http.HttpRequest.post(mw.getCallbackUrl())
+                    .header("Authorization", "Bearer " + mw.getAuthToken())
+                    .body(task.toJSONString())
+                    .timeout(mw.getTimeoutSeconds() * 1000)
+                    .execute().body();
+            log.info("sendByCard: 中间件响应={}", result);
+
+            // 5. 发送成功后递增短信计数
+            smsCardMapper.incrementSmsCount(card.getCardId());
+            return "OK";
+        } catch (Exception e) {
+            log.error("sendByCard: 中间件调用失败", e);
+            return "MIDDLEWARE_ERROR";
+        }
+    }
+
+    /**
+     * 检查卡的短信发送限制
+     * @return null=通过, 非null=限制原因
+     */
+    private String checkCardSmsLimit(com.fs.proxy.domain.CompanySmsCard card) {
+        java.util.Date now = new java.util.Date();
+        java.util.Date today = org.apache.commons.lang3.time.DateUtils.truncate(now, java.util.Calendar.DAY_OF_MONTH);
+
+        // 检查剩余短信余额
+        if (card.getSmsBalance() != null && card.getSmsBalance() <= 0) {
+            return "CARD_SMS_BALANCE_EXHAUSTED";
+        }
+
+        // 检查每日限制
+        if (card.getSmsDailyLimit() != null && card.getSmsDailyLimit() > 0) {
+            int sentToday = 0;
+            // 如果日期不是今天, 说明需要重置
+            if (card.getSmsSentDate() != null && org.apache.commons.lang3.time.DateUtils.isSameDay(card.getSmsSentDate(), now)) {
+                sentToday = card.getSmsSentToday() != null ? card.getSmsSentToday() : 0;
+            }
+            if (sentToday >= card.getSmsDailyLimit()) {
+                return "CARD_DAILY_LIMIT_EXCEEDED";
+            }
+        }
+
+        // 检查每小时限制
+        if (card.getSmsHourlyLimit() != null && card.getSmsHourlyLimit() > 0) {
+            int currentHour = new java.util.GregorianCalendar().get(java.util.Calendar.HOUR_OF_DAY);
+            int sentHour = 0;
+            // 如果日期是今天且小时数匹配, 才使用当前小时计数
+            if (card.getSmsSentDate() != null && org.apache.commons.lang3.time.DateUtils.isSameDay(card.getSmsSentDate(), now)
+                    && card.getSmsSentHourNum() != null && card.getSmsSentHourNum() == currentHour) {
+                sentHour = card.getSmsSentHour() != null ? card.getSmsSentHour() : 0;
+            }
+            if (sentHour >= card.getSmsHourlyLimit()) {
+                return "CARD_HOURLY_LIMIT_EXCEEDED";
+            }
+        }
+
+        return null; // 通过
+    }
+
     @Override
     public R sendTSms(String mobile, String code) {
 //        try{
@@ -231,6 +449,8 @@ public class SmsServiceImpl implements ISmsService
         if(StringUtils.isNotEmpty(UserName)){
                 content=content.replace("${sms.csName}",UserName);
         }
+        // sendUserSms为系统级发送(无companyId), 回退到全局配置
+        // TODO: 后续如需支持租户级, 可在此处获取companyId后调用resolveAndSend
             String urls= null;
             SysConfig sysConfig = sysConfigMapper.selectConfigByConfigKey("his.sms");
             FsSmsConfig sms = JSON.parseObject(sysConfig.getConfigValue(), FsSmsConfig.class);
@@ -336,85 +556,25 @@ public class SmsServiceImpl implements ISmsService
                         if(StringUtils.isNotEmpty(param.getCardUrl())){
                             content=content.replace("${sms.cardUrl}",param.getCardUrl());
                         }
-                        String urls= null;
-                        SysConfig sysConfig = sysConfigMapper.selectConfigByConfigKey("his.sms");
-                        FsSmsConfig sms = JSON.parseObject(sysConfig.getConfigValue(), FsSmsConfig.class);
-                        if (sms.getType().equals("rf")){
-                            try {
-                                if(temp.getTempType().equals(1)){
-                                    urls = sms.getRfUrl1()+"sms?action=send&account="+sms.getRfAccount1()+"&password="+sms.getRfPassword1()+"&mobile="+fsStoreOrder.getUserPhone()+"&content="+ URLEncoder.encode(sms.getRfSign()+content, "UTF-8")+"&extno="+sms.getRfCode1()+"&rt=json";
-                                }
-                                else if(temp.getTempType().equals(2)){
-                                    urls = sms.getRfUrl2()+"sms?action=send&account="+sms.getRfAccount2()+"&password="+sms.getRfPassword2()+"&mobile="+fsStoreOrder.getUserPhone()+"&content="+ URLEncoder.encode(sms.getRfSign()+content+"拒收请回复R", "UTF-8")+"&extno="+sms.getRfCode2()+"&rt=json";
-                                }
-                            } catch (UnsupportedEncodingException e) {
-                                e.printStackTrace();
-                            }
-                            String post = HttpRequest.get(urls)
-//                            .body(String.valueOf(jsonObject))
-                                    .execute().body();
-                            SmsSendVO vo=JSONUtil.toBean(post, SmsSendVO.class);
-                            if(vo.getStatus().equals(0)){
-                                for(SmsSendItemVO itemVO:vo.getList()){
-                                    if(itemVO.getResult().equals("0")){
-                                        CompanySmsLogs logs=new CompanySmsLogs();
-                                        logs.setCompanyId(param.getCompanyId());
-                                        logs.setContent(content);
-                                        logs.setTempCode(temp.getTempCode());
-                                        logs.setCompanyUserId(param.getCompanyUserId());
-                                        logs.setTempId(temp.getTempId());
-                                        logs.setPhone(fsStoreOrder.getUserPhone());
-                                        logs.setSendTime(new Date());
-                                        logs.setStatus(0);
-                                        logs.setType(sms.getType());
-                                        logs.setMid(itemVO.getMid());
-                                        Integer counts=logs.getContent().length()/67;
-                                        if(logs.getContent().length()%67>0){
-                                            counts=counts+1;
-                                        }
-                                        if(counts==0){
-                                            counts=1;
-                                        }
-                                        logs.setNumber(counts);
-                                        smsLogsService.insertCompanySmsLogs(logs);
-                                        companySmsService.subCompanySms(logs.getCompanyId(),logs.getNumber());
-                                    }
-                                }
-                            }
-                        }else if (sms.getType().equals("dh")){
-                            SendSmsReturn sendSmsReturn =null;
-                            if(temp.getTempType().equals(1)){
-                                sendSmsReturn = smsTService.sendSms(sms.getDhAccount1(), sms.getDhPassword1(), content, fsStoreOrder.getUserPhone());
-                            }
-                            else if(temp.getTempType().equals(2)){
-                                sendSmsReturn=  smsTService.sendSms(sms.getDhAccount2(),sms.getDhPassword2(),content+"拒收请回复R",fsStoreOrder.getUserPhone());
-                            }
-                            System.out.println(sendSmsReturn);
-                            if (sendSmsReturn!=null){
-                                if (sendSmsReturn.getResult()!=null&&sendSmsReturn.getResult().equals("0")){
-                                    CompanySmsLogs logs=new CompanySmsLogs();
-                                    logs.setCompanyId(param.getCompanyId());
-                                    logs.setContent(content);
-                                    logs.setTempCode(temp.getTempCode());
-                                    logs.setCompanyUserId(param.getCompanyUserId());
-                                    logs.setTempId(temp.getTempId());
-                                    logs.setPhone(fsStoreOrder.getUserPhone());
-                                    logs.setSendTime(new Date());
-                                    logs.setStatus(0);
-                                    logs.setType(sms.getType());
-                                    logs.setMid(sendSmsReturn.getMsgid());
-                                    Integer counts=logs.getContent().length()/67;
-                                    if(logs.getContent().length()%67>0){
-                                        counts=counts+1;
-                                    }
-                                    if(counts==0){
-                                        counts=1;
-                                    }
-                                    logs.setNumber(counts);
-                                    smsLogsService.insertCompanySmsLogs(logs);
-                                    companySmsService.subCompanySms(logs.getCompanyId(),logs.getNumber());
-                                }
-                            }
+                        // ===== 动态路由发送 =====
+                        String sendResult = resolveAndSend(fsStoreOrder.getUserPhone(), content, temp.getTempType(),
+                                param.getCompanyId(), param.getCompanyUserId(), null);
+                        if ("OK".equals(sendResult)) {
+                            CompanySmsLogs logs=new CompanySmsLogs();
+                            logs.setCompanyId(param.getCompanyId());
+                            logs.setContent(content);
+                            logs.setTempCode(temp.getTempCode());
+                            logs.setCompanyUserId(param.getCompanyUserId());
+                            logs.setTempId(temp.getTempId());
+                            logs.setPhone(fsStoreOrder.getUserPhone());
+                            logs.setSendTime(new Date());
+                            logs.setStatus(0);
+                            Integer counts = content.length()/67;
+                            if(content.length()%67>0) counts++;
+                            if(counts==0) counts=1;
+                            logs.setNumber(counts);
+                            smsLogsService.insertCompanySmsLogs(logs);
+                            companySmsService.subCompanySms(logs.getCompanyId(),logs.getNumber());
                         }
 
                     return R.ok("短信提交成功,正在发送中...");
@@ -459,85 +619,25 @@ public class SmsServiceImpl implements ISmsService
                     if(StringUtils.isNotEmpty(param.getCardUrl())){
                         content=content.replace("${sms.cardUrl}",param.getCardUrl());
                     }
-                    String urls= null;
-                    SysConfig sysConfig = sysConfigMapper.selectConfigByConfigKey("his.sms");
-                    FsSmsConfig sms = JSON.parseObject(sysConfig.getConfigValue(), FsSmsConfig.class);
-                    if (sms.getType().equals("rf")){
-                        try {
-                            if(temp.getTempType().equals(1)){
-                                urls = sms.getRfUrl1()+"sms?action=send&account="+sms.getRfAccount1()+"&password="+sms.getRfPassword1()+"&mobile="+packageOrder.getPhone()+"&content="+ URLEncoder.encode(sms.getRfSign()+content, "UTF-8")+"&extno="+sms.getRfCode1()+"&rt=json";
-                            }
-                            else if(temp.getTempType().equals(2)){
-                                urls = sms.getRfUrl2()+"sms?action=send&account="+sms.getRfAccount2()+"&password="+sms.getRfPassword2()+"&mobile="+packageOrder.getPhone()+"&content="+ URLEncoder.encode(sms.getRfSign()+content+"拒收请回复R", "UTF-8")+"&extno="+sms.getRfCode2()+"&rt=json";
-                            }
-                        } catch (UnsupportedEncodingException e) {
-                            e.printStackTrace();
-                        }
-                        String post = HttpRequest.get(urls)
-//                            .body(String.valueOf(jsonObject))
-                                .execute().body();
-                        SmsSendVO vo=JSONUtil.toBean(post, SmsSendVO.class);
-                        if(vo.getStatus().equals(0)){
-                            for(SmsSendItemVO itemVO:vo.getList()){
-                                if(itemVO.getResult().equals("0")){
-                                    CompanySmsLogs logs=new CompanySmsLogs();
-                                    logs.setCompanyId(param.getCompanyId());
-                                    logs.setContent(content);
-                                    logs.setTempCode(temp.getTempCode());
-                                    logs.setCompanyUserId(param.getCompanyUserId());
-                                    logs.setTempId(temp.getTempId());
-                                    logs.setPhone(packageOrder.getPhone());
-                                    logs.setSendTime(new Date());
-                                    logs.setStatus(0);
-                                    logs.setType(sms.getType());
-                                    logs.setMid(itemVO.getMid());
-                                    Integer counts=logs.getContent().length()/67;
-                                    if(logs.getContent().length()%67>0){
-                                        counts=counts+1;
-                                    }
-                                    if(counts==0){
-                                        counts=1;
-                                    }
-                                    logs.setNumber(counts);
-                                    smsLogsService.insertCompanySmsLogs(logs);
-                                    companySmsService.subCompanySms(logs.getCompanyId(),logs.getNumber());
-                                }
-                            }
-                        }
-                    }else if (sms.getType().equals("dh")){
-                        SendSmsReturn sendSmsReturn =null;
-                        if(temp.getTempType().equals(1)){
-                            sendSmsReturn = smsTService.sendSms(sms.getDhAccount1(), sms.getDhPassword1(), content, packageOrder.getPhone());
-                        }
-                        else if(temp.getTempType().equals(2)){
-                            sendSmsReturn=  smsTService.sendSms(sms.getDhAccount2(),sms.getDhPassword2(),content+"拒收请回复R", packageOrder.getPhone());
-                        }
-                        System.out.println(sendSmsReturn);
-                        if (sendSmsReturn!=null){
-                            if (sendSmsReturn.getResult()!=null&&sendSmsReturn.getResult().equals("0")){
-                                CompanySmsLogs logs=new CompanySmsLogs();
-                                logs.setCompanyId(param.getCompanyId());
-                                logs.setContent(content);
-                                logs.setTempCode(temp.getTempCode());
-                                logs.setCompanyUserId(param.getCompanyUserId());
-                                logs.setTempId(temp.getTempId());
-                                logs.setPhone(packageOrder.getPhone());
-                                logs.setSendTime(new Date());
-                                logs.setStatus(0);
-                                logs.setType(sms.getType());
-                                logs.setMid(sendSmsReturn.getMsgid());
-                                Integer counts=logs.getContent().length()/67;
-                                if(logs.getContent().length()%67>0){
-                                    counts=counts+1;
-                                }
-                                if(counts==0){
-                                    counts=1;
-                                }
-                                logs.setNumber(counts);
-                                smsLogsService.insertCompanySmsLogs(logs);
-                                companySmsService.subCompanySms(logs.getCompanyId(),logs.getNumber());
-                            }
-                        }
+                    // ===== 动态路由发送 =====
+                    String sendResult = resolveAndSend(packageOrder.getPhone(), content, temp.getTempType(),
+                            param.getCompanyId(), param.getCompanyUserId(), null);
+                    if ("OK".equals(sendResult)) {
+                        CompanySmsLogs logs=new CompanySmsLogs();
+                        logs.setCompanyId(param.getCompanyId());
+                        logs.setContent(content);
+                        logs.setTempCode(temp.getTempCode());
+                        logs.setCompanyUserId(param.getCompanyUserId());
+                        logs.setTempId(temp.getTempId());
+                        logs.setPhone(packageOrder.getPhone());
+                        logs.setSendTime(new Date());
+                        logs.setStatus(0);
+                        Integer counts = content.length()/67;
+                        if(content.length()%67>0) counts++;
+                        if(counts==0) counts=1;
+                        logs.setNumber(counts);
+                        smsLogsService.insertCompanySmsLogs(logs);
+                        companySmsService.subCompanySms(logs.getCompanyId(),logs.getNumber());
                     }
                     return R.ok("短信提交成功,正在发送中...");
                 }
@@ -566,6 +666,8 @@ public class SmsServiceImpl implements ISmsService
             content = content.replace("${sms.captcha}", captcha);
         }
         String urls = null;
+        // sendCaptcha为验证码发送(系统级,无companyId), 回退到全局配置
+        // TODO: 后续可传入companyId后调用resolveAndSend
         SysConfig sysConfig = sysConfigMapper.selectConfigByConfigKey("his.sms");
         FsSmsConfig sms = JSON.parseObject(sysConfig.getConfigValue(), FsSmsConfig.class);
         if (sms.getType().equals("rf")) {
@@ -679,58 +781,12 @@ public class SmsServiceImpl implements ISmsService
                 content=content.replace("${sms.cardUrl}",param.getCardUrl());
             }
 
-            String urls= null;
-            // 通知类的不加 退订回T 只有营销类的加
-            //最多500个手机号
-            SysConfig sysConfig = sysConfigMapper.selectConfigByConfigKey("his.sms");
-            FsSmsConfig sms = JSON.parseObject(sysConfig.getConfigValue(), FsSmsConfig.class);
-            if (sms.getType().equals("rf")){
-                try {
-                    if(temp.getTempType().equals(1)){
-                        urls = sms.getRfUrl1()+"sms?action=send&account="+sms.getRfAccount1()+"&password="+sms.getRfPassword1()+"&mobile="+crmCustomer.getMobile()+"&content="+ URLEncoder.encode(sms.getRfSign()+content, "UTF-8")+"&extno="+sms.getRfCode1()+"&rt=json";
-                    }
-                    else if(temp.getTempType().equals(2)){
-                        urls = sms.getRfUrl2()+"sms?action=send&account="+sms.getRfAccount2()+"&password="+sms.getRfPassword2()+"&mobile="+crmCustomer.getMobile()+"&content="+ URLEncoder.encode(sms.getRfSign()+content+"拒收请回复R", "UTF-8")+"&extno="+sms.getRfCode2()+"&rt=json";
-                    }
-                } catch (UnsupportedEncodingException e) {
-                    e.printStackTrace();
-                }
-                String post = HttpRequest.get(urls)
-//                            .body(String.valueOf(jsonObject))
-                        .execute().body();
-                SmsSendVO vo=JSONUtil.toBean(post, SmsSendVO.class);
-                if(vo.getStatus().equals(0)){
-                    Integer resultCount=0;
-                    for(SmsSendItemVO itemVO:vo.getList()){
-                        if(itemVO.getResult().equals("0")){
-                            CompanySmsLogs logs=new CompanySmsLogs();
-                            logs.setCompanyId(param.getCompanyId());
-                            logs.setCustomerId(id);
-                            logs.setContent(content);
-                            logs.setTempCode(temp.getTempCode());
-                            logs.setCompanyUserId(param.getCompanyUserId());
-                            logs.setTempId(temp.getTempId());
-                            logs.setPhone(crmCustomer.getMobile());
-                            logs.setSendTime(new Date());
-                            logs.setStatus(0);
-                            logs.setType(sms.getType());
-                            logs.setMid(itemVO.getMid());
-                            Integer counts=logs.getContent().length()/67;
-                            if(logs.getContent().length()%67>0){
-                                counts=counts+1;
-                            }
-                            if(counts==0){
-                                counts=1;
-                            }
-                            logs.setNumber(counts);
-                            smsLogsService.insertCompanySmsLogs(logs);
-                            companySmsService.subCompanySms(logs.getCompanyId(),logs.getNumber());
-                            resultCount++;
-                        }
-                    }
-                }
-            }else if (sms.getType().equals("dh")){
-                SendSmsReturn sendSmsReturn =null;
+            // ===== 动态路由发送 =====
+            Long preferApiId = null; // TODO: 从param中获取销售手动选择的接口ID
+            String sendResult = resolveAndSend(crmCustomer.getMobile(), content, temp.getTempType(),
+                    param.getCompanyId(), param.getCompanyUserId(), preferApiId);
+
+            if ("OK".equals(sendResult)) {
                 CompanySmsLogs logs=new CompanySmsLogs();
                 logs.setCompanyId(param.getCompanyId());
                 logs.setCustomerId(id);
@@ -741,34 +797,15 @@ public class SmsServiceImpl implements ISmsService
                 logs.setPhone(crmCustomer.getMobile());
                 logs.setSendTime(new Date());
                 logs.setStatus(0);
-                logs.setType(sms.getType());
-                Integer counts=logs.getContent().length()/67;
-                if(logs.getContent().length()%67>0){
-                    counts=counts+1;
-                }
-                if(counts==0){
-                    counts=1;
-                }
+                Integer counts = content.length()/67;
+                if(content.length()%67>0) counts++;
+                if(counts==0) counts=1;
                 logs.setNumber(counts);
-                if(temp.getTempType().equals(1)){
-                    sendSmsReturn = smsTService.sendSms(sms.getDhAccount1(), sms.getDhPassword1(), content, crmCustomer.getMobile());
-                }
-                else if(temp.getTempType().equals(2)){
-                    sendSmsReturn=  smsTService.sendSms(sms.getDhAccount2(),sms.getDhPassword2(),content+"拒收请回复R",crmCustomer.getMobile());
-                }
-                System.out.println(sendSmsReturn);
-                if (sendSmsReturn!=null){
-                    if (sendSmsReturn.getResult()!=null&&sendSmsReturn.getResult().equals("0")){
-                        logs.setMid(sendSmsReturn.getMsgid());
-                        smsLogsService.insertCompanySmsLogs(logs);
-                        companySmsService.subCompanySms(logs.getCompanyId(),logs.getNumber());
-                    }
-                }
+                smsLogsService.insertCompanySmsLogs(logs);
+                companySmsService.subCompanySms(logs.getCompanyId(),logs.getNumber());
+            } else {
+                log.warn("batchSmsOp: 发送失败 phone={}, result={}", crmCustomer.getMobile(), sendResult);
             }
-
-
-
-
         }
     }
 
@@ -799,68 +836,11 @@ public class SmsServiceImpl implements ISmsService
                 content=content.replace("${sms.senderName}",param.getSenderName());
             }
 
-            String urls= null;
-            // 通知类的不加 退订回T 只有营销类的加
-            //最多500个手机号
-            SysConfig sysConfig = sysConfigMapper.selectConfigByConfigKey("his.sms");
-            FsSmsConfig sms = JSON.parseObject(sysConfig.getConfigValue(), FsSmsConfig.class);
-            if (sms.getType().equals("rf")){
-                try {
-                    if(temp.getTempType().equals(1)){
-                        urls = sms.getRfUrl1()+"sms?action=send&account="+sms.getRfAccount1()+"&password="+sms.getRfPassword1()+"&mobile="+crmCustomer.getMobile()+"&content="+ URLEncoder.encode(sms.getRfSign()+content, "UTF-8")+"&extno="+sms.getRfCode1()+"&rt=json";
-                    }
-                    else if(temp.getTempType().equals(2)){
-                        urls = sms.getRfUrl2()+"sms?action=send&account="+sms.getRfAccount2()+"&password="+sms.getRfPassword2()+"&mobile="+crmCustomer.getMobile()+"&content="+ URLEncoder.encode(sms.getRfSign()+content+"拒收请回复R", "UTF-8")+"&extno="+sms.getRfCode2()+"&rt=json";
-                    }
-                } catch (UnsupportedEncodingException e) {
-                    e.printStackTrace();
-                }
-                String post = HttpRequest.get(urls)
-//                            .body(String.valueOf(jsonObject))
-                        .execute().body();
-                SmsSendVO vo=JSONUtil.toBean(post, SmsSendVO.class);
-                if(vo.getStatus().equals(0)){
-                    Integer resultCount=0;
-                    for(SmsSendItemVO itemVO:vo.getList()){
-                        if(itemVO.getResult().equals("0")){
-                            CompanySmsLogs logs=new CompanySmsLogs();
-                            logs.setCompanyId(param.getCompanyId());
-                            logs.setCustomerId(id);
-                            logs.setContent(content);
-                            logs.setTempCode(temp.getTempCode());
-                            logs.setCompanyUserId(param.getCompanyUserId());
-                            logs.setTempId(temp.getTempId());
-                            logs.setPhone(crmCustomer.getMobile());
-                            logs.setSendTime(new Date());
-                            logs.setStatus(0);
-                            logs.setType(sms.getType());
-                            logs.setMid(itemVO.getMid());
-                            Integer counts=logs.getContent().length()/67;
-                            if(logs.getContent().length()%67>0){
-                                counts=counts+1;
-                            }
-                            if(counts==0){
-                                counts=1;
-                            }
-                            logs.setNumber(counts);
-                            smsLogsService.insertCompanySmsLogs(logs);
-                            // 使用Redisson分布式锁,按公司ID级别锁定,确保集群环境下扣减准确
-                            String lockKey = SMS_SUB_LOCK_PREFIX + logs.getCompanyId();
-                            RLock lock = redissonClient.getLock(lockKey);
-                            try {
-                                lock.lock(30, TimeUnit.SECONDS);
-                                companySmsService.subCompanySms(logs.getCompanyId(), logs.getNumber());
-                            } finally {
-                                if (lock.isHeldByCurrentThread()) {
-                                    lock.unlock();
-                                }
-                            }
-                            resultCount++;
-                        }
-                    }
-                }
-            }else if (sms.getType().equals("dh")){
-                SendSmsReturn sendSmsReturn =null;
+            // ===== 动态路由发送 =====
+            String sendResult = resolveAndSend(crmCustomer.getMobile(), content, temp.getTempType(),
+                    param.getCompanyId(), param.getCompanyUserId(), null);
+
+            if ("OK".equals(sendResult)) {
                 CompanySmsLogs logs=new CompanySmsLogs();
                 logs.setCompanyId(param.getCompanyId());
                 logs.setCustomerId(id);
@@ -871,39 +851,24 @@ public class SmsServiceImpl implements ISmsService
                 logs.setPhone(crmCustomer.getMobile());
                 logs.setSendTime(new Date());
                 logs.setStatus(0);
-                logs.setType(sms.getType());
-                Integer counts=logs.getContent().length()/67;
-                if(logs.getContent().length()%67>0){
-                    counts=counts+1;
-                }
-                if(counts==0){
-                    counts=1;
-                }
+                Integer counts = content.length()/67;
+                if(content.length()%67>0) counts++;
+                if(counts==0) counts=1;
                 logs.setNumber(counts);
-                if(temp.getTempType().equals(1)){
-                    sendSmsReturn = smsTService.sendSms(sms.getDhAccount1(), sms.getDhPassword1(), content, crmCustomer.getMobile());
-                }
-                else if(temp.getTempType().equals(2)){
-                    sendSmsReturn=  smsTService.sendSms(sms.getDhAccount2(),sms.getDhPassword2(),content+"拒收请回复R",crmCustomer.getMobile());
-                }
-                System.out.println(sendSmsReturn);
-                if (sendSmsReturn!=null){
-                    if (sendSmsReturn.getResult()!=null&&sendSmsReturn.getResult().equals("0")){
-                        logs.setMid(sendSmsReturn.getMsgid());
-                        smsLogsService.insertCompanySmsLogs(logs);
-                        // 使用Redisson分布式锁,按公司ID级别锁定,确保集群环境下扣减准确
-                        String lockKey = SMS_SUB_LOCK_PREFIX + logs.getCompanyId();
-                        RLock lock = redissonClient.getLock(lockKey);
-                        try {
-                            lock.lock(30, TimeUnit.SECONDS);
-                            companySmsService.subCompanySms(logs.getCompanyId(), logs.getNumber());
-                        } finally {
-                            if (lock.isHeldByCurrentThread()) {
-                                lock.unlock();
-                            }
-                        }
+                smsLogsService.insertCompanySmsLogs(logs);
+                // 使用Redisson分布式锁
+                String lockKey = SMS_SUB_LOCK_PREFIX + logs.getCompanyId();
+                RLock lock = redissonClient.getLock(lockKey);
+                try {
+                    lock.lock(30, TimeUnit.SECONDS);
+                    companySmsService.subCompanySms(logs.getCompanyId(), logs.getNumber());
+                } finally {
+                    if (lock.isHeldByCurrentThread()) {
+                        lock.unlock();
                     }
                 }
+            } else {
+                log.warn("batchSmsOp4AiSend: 发送失败 phone={}, result={}", crmCustomer.getMobile(), sendResult);
             }
         }
     }

+ 62 - 0
fs-service/src/main/java/com/fs/proxy/domain/CompanySmsApi.java

@@ -0,0 +1,62 @@
+package com.fs.proxy.domain;
+
+import com.fs.common.annotation.Excel;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 短信接口对象 company_sms_api
+ */
+@Data
+public class CompanySmsApi extends BaseEntity {
+    private static final long serialVersionUID = 1L;
+
+    /** 接口ID */
+    private Long apiId;
+
+    /** 接口名称 */
+    @Excel(name = "接口名称")
+    private String apiName;
+
+    /** 服务商: rf润方/dh德华/card手机卡 */
+    @Excel(name = "服务商")
+    private String provider;
+
+    /** 短信发送类型: 1行业验证码通知 2营销短信 3 5G消息 */
+    @Excel(name = "发送类型")
+    private Integer smsType;
+
+    /** 账户名 */
+    @Excel(name = "账户名")
+    private String account;
+
+    /** 密码 */
+    private String password;
+
+    /** 接口地址(润方专用) */
+    private String url;
+
+    /** 扩展码(润方专用) */
+    private String code;
+
+    /** 短信签名 */
+    @Excel(name = "短信签名")
+    private String sign;
+
+    /** 平台成本价(元/条) */
+    @Excel(name = "成本价")
+    private BigDecimal costPrice;
+
+    /** 是否默认接口 */
+    @Excel(name = "是否默认")
+    private Integer isDefault;
+
+    /** 状态 0禁用 1正常 */
+    @Excel(name = "状态")
+    private Integer status;
+
+    /** 备注 */
+    private String remark;
+}

+ 44 - 0
fs-service/src/main/java/com/fs/proxy/domain/CompanySmsApiPort.java

@@ -0,0 +1,44 @@
+package com.fs.proxy.domain;
+
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+
+/**
+ * 短信端口池对象 company_sms_api_port
+ */
+@Data
+public class CompanySmsApiPort extends BaseEntity {
+    private static final long serialVersionUID = 1L;
+
+    /** 端口ID */
+    private Long portId;
+
+    /** 接口ID */
+    private Long apiId;
+
+    /** 端口名称 */
+    private String portName;
+
+    /** 端口号(rf=extno/手机卡=手机号) */
+    private String portNo;
+
+    /** 端口独立账户(覆盖接口级) */
+    private String account;
+
+    /** 端口独立密码(覆盖接口级) */
+    private String password;
+
+    /** 端口独立签名(覆盖接口级) */
+    private String sign;
+
+    /** 卡槽号(手机卡专用:1/2) */
+    private Integer slotIndex;
+
+    /** 0禁用/1正常 */
+    private Integer status;
+
+    // ========== 关联查询字段(非表字段) ==========
+    private String apiName;
+    private String provider;
+    private Integer smsType;
+}

+ 58 - 0
fs-service/src/main/java/com/fs/proxy/domain/CompanySmsApiTenant.java

@@ -0,0 +1,58 @@
+package com.fs.proxy.domain;
+
+import com.fs.common.annotation.Excel;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * 短信接口-租户分配关系对象 company_sms_api_tenant
+ */
+@Data
+public class CompanySmsApiTenant extends BaseEntity {
+    private static final long serialVersionUID = 1L;
+
+    /** 主键 */
+    private Long id;
+
+    /** 短信接口ID */
+    @Excel(name = "接口ID")
+    private Long apiId;
+
+    /** 租户ID */
+    @Excel(name = "租户ID")
+    private Long tenantId;
+
+    /** 租户售价(元/条) */
+    @Excel(name = "租户售价")
+    private BigDecimal price;
+
+    /** 优先级(1最高,数字越大越低) */
+    @Excel(name = "优先级")
+    private Integer priority;
+
+    /** 是否允许销售手动选择(0否1是) */
+    @Excel(name = "允许手动选择")
+    private Integer allowManual;
+
+    /** 状态 1启用 0禁用 */
+    @Excel(name = "状态")
+    private Integer status;
+
+    /** 更新时间 */
+    private Date updateTime;
+
+    // ========== 关联查询字段(非表字段) ==========
+    /** 接口名称(关联查) */
+    private String apiName;
+    /** 短信类型(关联查) */
+    private Integer smsType;
+    /** 服务商(关联查) */
+    private String provider;
+    /** 租户名称(关联查) */
+    private String tenantName;
+    /** 平台成本价(关联查) */
+    private BigDecimal costPrice;
+}

+ 101 - 0
fs-service/src/main/java/com/fs/proxy/domain/CompanySmsCard.java

@@ -0,0 +1,101 @@
+package com.fs.proxy.domain;
+
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * 手机卡管理对象 company_sms_card
+ */
+@Data
+public class CompanySmsCard extends BaseEntity {
+    private static final long serialVersionUID = 1L;
+
+    /** 卡ID */
+    private Long cardId;
+
+    /** 关联端口ID */
+    private Long portId;
+
+    /** 所属租户 */
+    private Long tenantId;
+
+    /** 手机IMEI */
+    private String imei;
+
+    /** 设备名称 */
+    private String deviceName;
+
+    /** 卡槽数(1/2) */
+    private Integer simCount;
+
+    /** 卡槽1手机号 */
+    private String phone1;
+
+    /** 卡槽2手机号 */
+    private String phone2;
+
+    /** 最后心跳时间 */
+    private Date lastHeartbeat;
+
+    /** 0离线/1在线/2禁用 */
+    private Integer status;
+
+    /** APP版本 */
+    private String appVersion;
+
+    /** 今日已发送条数 */
+    private Integer smsSentToday;
+
+    /** 今日发送计数日期(判断是否需要重置) */
+    private Date smsSentDate;
+
+    /** 当前小时已发送条数 */
+    private Integer smsSentHour;
+
+    /** 当前小时数(0-23) */
+    private Integer smsSentHourNum;
+
+    /** 每小时发送条数限制 */
+    private Integer smsHourlyLimit;
+
+    /** 每天最高发送条数 */
+    private Integer smsDailyLimit;
+
+    /** 剩余短信余额(条) */
+    private Integer smsBalance;
+
+    /** 今日已拨电话数 */
+    private Integer callSentToday;
+
+    /** 今日拨号计数日期 */
+    private Date callSentDate;
+
+    /** 拨打间隔(秒) */
+    private Integer callIntervalSeconds;
+
+    /** 卡通话分钟余额 */
+    private BigDecimal callMinutesBalance;
+
+    /** 卡话费余额(元) */
+    private BigDecimal phoneBillBalance;
+
+    /** 是否允许呼转(0否1是) */
+    private Integer allowCallForward;
+
+    /** 呼转手机号 */
+    private String forwardPhone;
+
+    /** 最后一次拨号时间 */
+    private Date lastCallTime;
+
+    /** 备注 */
+    private String remark;
+
+    // ========== 关联查询字段(非表字段) ==========
+    private String portName;
+    private String portNo;
+    private String tenantName;
+}

+ 42 - 0
fs-service/src/main/java/com/fs/proxy/domain/CompanySmsCardMiddleware.java

@@ -0,0 +1,42 @@
+package com.fs.proxy.domain;
+
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+
+/**
+ * 手机卡发送中间件对象 company_sms_card_middleware
+ */
+@Data
+public class CompanySmsCardMiddleware extends BaseEntity {
+    private static final long serialVersionUID = 1L;
+
+    /** 主键 */
+    private Long id;
+
+    /** 接口ID(provider=card) */
+    private Long apiId;
+
+    /** 中间件名称 */
+    private String middlewareName;
+
+    /** 下发任务URL */
+    private String callbackUrl;
+
+    /** 心跳上报URL */
+    private String heartbeatUrl;
+
+    /** 鉴权Token */
+    private String authToken;
+
+    /** 最大重试次数 */
+    private Integer maxRetry;
+
+    /** 超时秒数 */
+    private Integer timeoutSeconds;
+
+    /** 0禁用/1正常 */
+    private Integer status;
+
+    // ========== 关联查询字段(非表字段) ==========
+    private String apiName;
+}

+ 37 - 0
fs-service/src/main/java/com/fs/proxy/domain/CompanySmsPortAssign.java

@@ -0,0 +1,37 @@
+package com.fs.proxy.domain;
+
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+
+/**
+ * 短信端口分配对象 company_sms_port_assign
+ */
+@Data
+public class CompanySmsPortAssign extends BaseEntity {
+    private static final long serialVersionUID = 1L;
+
+    /** 主键 */
+    private Long id;
+
+    /** 端口ID */
+    private Long portId;
+
+    /** 租户ID */
+    private Long tenantId;
+
+    /** 销售人员ID(NULL=共享) */
+    private Long companyUserId;
+
+    /** 1启用/0禁用 */
+    private Integer status;
+
+    // ========== 关联查询字段(非表字段) ==========
+    private String portName;
+    private String portNo;
+    private Long apiId;
+    private String apiName;
+    private String provider;
+    private Integer smsType;
+    private String tenantName;
+    private String userName;
+}

+ 22 - 0
fs-service/src/main/java/com/fs/proxy/mapper/CompanySmsApiMapper.java

@@ -0,0 +1,22 @@
+package com.fs.proxy.mapper;
+
+import com.fs.proxy.domain.CompanySmsApi;
+import org.apache.ibatis.annotations.Mapper;
+
+import java.util.List;
+
+@Mapper
+public interface CompanySmsApiMapper {
+
+    List<CompanySmsApi> selectSmsApiList(CompanySmsApi query);
+
+    CompanySmsApi selectSmsApiById(Long apiId);
+
+    CompanySmsApi selectSmsApiBySmsType(Integer smsType);
+
+    int insertSmsApi(CompanySmsApi smsApi);
+
+    int updateSmsApi(CompanySmsApi smsApi);
+
+    int deleteSmsApiById(Long apiId);
+}

+ 30 - 0
fs-service/src/main/java/com/fs/proxy/mapper/CompanySmsApiPortMapper.java

@@ -0,0 +1,30 @@
+package com.fs.proxy.mapper;
+
+import com.fs.proxy.domain.CompanySmsApiPort;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+@Mapper
+public interface CompanySmsApiPortMapper {
+
+    List<CompanySmsApiPort> selectPortList(CompanySmsApiPort query);
+
+    List<CompanySmsApiPort> selectPortByApiId(@Param("apiId") Long apiId);
+
+    CompanySmsApiPort selectPortById(@Param("portId") Long portId);
+
+    int insertPort(CompanySmsApiPort port);
+
+    int updatePort(CompanySmsApiPort port);
+
+    int deletePortById(@Param("portId") Long portId);
+
+    int deletePortByApiId(@Param("apiId") Long apiId);
+
+    /** 查询租户+销售分配的可用端口(精确匹配销售优先,共享兜底) */
+    List<CompanySmsApiPort> selectAvailablePort(@Param("tenantId") Long tenantId,
+                                                 @Param("apiId") Long apiId,
+                                                 @Param("companyUserId") Long companyUserId);
+}

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

@@ -0,0 +1,33 @@
+package com.fs.proxy.mapper;
+
+import com.fs.proxy.domain.CompanySmsApiTenant;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+@Mapper
+public interface CompanySmsApiTenantMapper {
+
+    List<CompanySmsApiTenant> selectSmsApiTenantList(CompanySmsApiTenant query);
+
+    CompanySmsApiTenant selectSmsApiTenantById(Long id);
+
+    /** 查询租户绑定的指定短信类型的有效接口(按优先级排序,可能多个) */
+    List<CompanySmsApiTenant> selectActiveByCompanyAndType(@Param("tenantId") Long tenantId, @Param("smsType") Integer smsType);
+
+    /** 查询租户某接口的售价 */
+    BigDecimal selectPriceByCompanyAndApi(@Param("tenantId") Long tenantId, @Param("apiId") Long apiId);
+
+    List<CompanySmsApiTenant> selectByCompanyId(@Param("tenantId") Long tenantId);
+
+    int insertSmsApiTenant(CompanySmsApiTenant tenant);
+
+    int updateSmsApiTenant(CompanySmsApiTenant tenant);
+
+    int deleteSmsApiTenantById(Long id);
+
+    /** 批量删除某接口的所有租户绑定 */
+    int deleteSmsApiTenantByApiId(Long apiId);
+}

+ 35 - 0
fs-service/src/main/java/com/fs/proxy/mapper/CompanySmsCardMapper.java

@@ -0,0 +1,35 @@
+package com.fs.proxy.mapper;
+
+import com.fs.proxy.domain.CompanySmsCard;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+@Mapper
+public interface CompanySmsCardMapper {
+
+    List<CompanySmsCard> selectCardList(CompanySmsCard query);
+
+    CompanySmsCard selectCardById(@Param("cardId") Long cardId);
+
+    CompanySmsCard selectCardByImei(@Param("imei") String imei);
+
+    /** 根据portId查询在线的卡 */
+    CompanySmsCard selectOnlineCardByPortId(@Param("portId") Long portId);
+
+    int insertCard(CompanySmsCard card);
+
+    int updateCard(CompanySmsCard card);
+
+    int deleteCardById(@Param("cardId") Long cardId);
+
+    /** 发送成功后递增短信计数(自动判断日期/小时重置) */
+    int incrementSmsCount(@Param("cardId") Long cardId);
+
+    /** 批量更新离线状态(超过指定秒数无心跳的设为离线) */
+    int updateOfflineCards(@Param("timeoutSeconds") int timeoutSeconds);
+
+    /** 更新心跳 */
+    int updateHeartbeat(@Param("imei") String imei, @Param("appVersion") String appVersion);
+}

+ 21 - 0
fs-service/src/main/java/com/fs/proxy/mapper/CompanySmsCardMiddlewareMapper.java

@@ -0,0 +1,21 @@
+package com.fs.proxy.mapper;
+
+import com.fs.proxy.domain.CompanySmsCardMiddleware;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+@Mapper
+public interface CompanySmsCardMiddlewareMapper {
+
+    List<CompanySmsCardMiddleware> selectMiddlewareList(CompanySmsCardMiddleware query);
+
+    CompanySmsCardMiddleware selectMiddlewareByApiId(@Param("apiId") Long apiId);
+
+    int insertMiddleware(CompanySmsCardMiddleware mw);
+
+    int updateMiddleware(CompanySmsCardMiddleware mw);
+
+    int deleteMiddlewareById(@Param("id") Long id);
+}

+ 23 - 0
fs-service/src/main/java/com/fs/proxy/mapper/CompanySmsPortAssignMapper.java

@@ -0,0 +1,23 @@
+package com.fs.proxy.mapper;
+
+import com.fs.proxy.domain.CompanySmsPortAssign;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+@Mapper
+public interface CompanySmsPortAssignMapper {
+
+    List<CompanySmsPortAssign> selectAssignList(CompanySmsPortAssign query);
+
+    int insertAssign(CompanySmsPortAssign assign);
+
+    int updateAssign(CompanySmsPortAssign assign);
+
+    int deleteAssignById(@Param("id") Long id);
+
+    /** 查询租户+销售已分配的端口(精确匹配+共享兜底) */
+    List<CompanySmsPortAssign> selectByCompanyAndUser(@Param("tenantId") Long tenantId,
+                                                       @Param("companyUserId") Long companyUserId);
+}

+ 18 - 0
fs-service/src/main/java/com/fs/proxy/service/ICompanySmsApiService.java

@@ -0,0 +1,18 @@
+package com.fs.proxy.service;
+
+import com.fs.proxy.domain.CompanySmsApi;
+
+import java.util.List;
+
+public interface ICompanySmsApiService {
+
+    List<CompanySmsApi> selectSmsApiList(CompanySmsApi query);
+
+    CompanySmsApi selectSmsApiById(Long apiId);
+
+    int insertSmsApi(CompanySmsApi smsApi);
+
+    int updateSmsApi(CompanySmsApi smsApi);
+
+    int deleteSmsApiById(Long apiId);
+}

+ 27 - 0
fs-service/src/main/java/com/fs/proxy/service/ICompanySmsApiTenantService.java

@@ -0,0 +1,27 @@
+package com.fs.proxy.service;
+
+import com.fs.proxy.domain.CompanySmsApiTenant;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+public interface ICompanySmsApiTenantService {
+
+    List<CompanySmsApiTenant> selectSmsApiTenantList(CompanySmsApiTenant query);
+
+    CompanySmsApiTenant selectSmsApiTenantById(Long id);
+
+    /** 查询租户指定短信类型的有效绑定(按优先级排序,可能多个) */
+    List<CompanySmsApiTenant> selectActiveByCompanyAndType(Long tenantId, Integer smsType);
+
+    /** 查询租户某接口的售价,未绑定返回null */
+    BigDecimal selectPriceByCompanyAndApi(Long tenantId, Long apiId);
+
+    List<CompanySmsApiTenant> selectByCompanyId(Long tenantId);
+
+    int insertSmsApiTenant(CompanySmsApiTenant tenant);
+
+    int updateSmsApiTenant(CompanySmsApiTenant tenant);
+
+    int deleteSmsApiTenantById(Long id);
+}

+ 50 - 0
fs-service/src/main/java/com/fs/proxy/service/ICompanySmsPortService.java

@@ -0,0 +1,50 @@
+package com.fs.proxy.service;
+
+import com.fs.proxy.domain.CompanySmsApiPort;
+import com.fs.proxy.domain.CompanySmsPortAssign;
+import com.fs.proxy.domain.CompanySmsCard;
+import com.fs.proxy.domain.CompanySmsCardMiddleware;
+
+import java.util.List;
+
+/**
+ * 短信端口+卡管理+中间件 Service
+ */
+public interface ICompanySmsPortService {
+
+    // ========== 端口池 ==========
+    List<CompanySmsApiPort> selectPortList(CompanySmsApiPort query);
+    List<CompanySmsApiPort> selectPortByApiId(Long apiId);
+    CompanySmsApiPort selectPortById(Long portId);
+    int insertPort(CompanySmsApiPort port);
+    int updatePort(CompanySmsApiPort port);
+    int deletePortById(Long portId);
+
+    /** 解析可用端口(降级路由核心方法) */
+    CompanySmsApiPort resolvePort(Long tenantId, Integer smsType, Long companyUserId, Long preferApiId);
+
+    // ========== 端口分配 ==========
+    List<CompanySmsPortAssign> selectAssignList(CompanySmsPortAssign query);
+    int insertAssign(CompanySmsPortAssign assign);
+    int updateAssign(CompanySmsPortAssign assign);
+    int deleteAssignById(Long id);
+
+    // ========== 手机卡管理 ==========
+    List<CompanySmsCard> selectCardList(CompanySmsCard query);
+    CompanySmsCard selectCardById(Long cardId);
+    CompanySmsCard selectCardByImei(String imei);
+    int insertCard(CompanySmsCard card);
+    int updateCard(CompanySmsCard card);
+    int deleteCardById(Long cardId);
+    /** 心跳上报 */
+    int heartbeat(String imei, String appVersion, String phone1, String phone2, String deviceName, Long tenantId);
+    /** 批量更新离线卡 */
+    int updateOfflineCards();
+
+    // ========== 中间件 ==========
+    List<CompanySmsCardMiddleware> selectMiddlewareList(CompanySmsCardMiddleware query);
+    CompanySmsCardMiddleware selectMiddlewareByApiId(Long apiId);
+    int insertMiddleware(CompanySmsCardMiddleware mw);
+    int updateMiddleware(CompanySmsCardMiddleware mw);
+    int deleteMiddlewareById(Long id);
+}

+ 22 - 0
fs-service/src/main/java/com/fs/proxy/service/impl/BalanceServiceImpl.java

@@ -3,10 +3,12 @@ package com.fs.proxy.service.impl;
 import com.fs.proxy.domain.TenantBalance;
 import com.fs.proxy.domain.TenantConsumeRecord;
 import com.fs.proxy.domain.ServiceFeeConfig;
+import com.fs.proxy.domain.CompanySmsApiTenant;
 import com.fs.proxy.enums.ConsumeTypeEnum;
 import com.fs.proxy.mapper.TenantBalanceMapper;
 import com.fs.proxy.mapper.TenantConsumeRecordMapper;
 import com.fs.proxy.mapper.ServiceFeeConfigMapper;
+import com.fs.proxy.mapper.CompanySmsApiTenantMapper;
 import com.fs.proxy.service.BalanceService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
@@ -36,6 +38,9 @@ public class BalanceServiceImpl implements BalanceService {
     @Autowired
     private ServiceFeeConfigMapper feeConfigMapper;
 
+    @Autowired
+    private CompanySmsApiTenantMapper smsApiTenantMapper;
+
     @Override
     public TenantBalance getTenantBalance(Long tenantId) {
         return balanceMapper.selectBalanceByTenantId(tenantId);
@@ -154,6 +159,21 @@ public class BalanceServiceImpl implements BalanceService {
         }
 
         BigDecimal unitPrice = config.getFeeStandard();
+        BigDecimal platformCost = config.getPlatformCost();
+
+        // 短信发送:优先使用租户绑定的接口售价(按优先级取第一个)
+        if (consumeType == ConsumeTypeEnum.SMS_SEND) {
+            List<CompanySmsApiTenant> smsBindings = smsApiTenantMapper.selectActiveByCompanyAndType(tenantId, null);
+            if (smsBindings != null && !smsBindings.isEmpty()) {
+                CompanySmsApiTenant smsBinding = smsBindings.get(0);
+                if (smsBinding.getPrice() != null && smsBinding.getPrice().compareTo(BigDecimal.ZERO) > 0) {
+                    unitPrice = smsBinding.getPrice();
+                    if (smsBinding.getCostPrice() != null) {
+                        platformCost = smsBinding.getCostPrice();
+                    }
+                }
+            }
+        }
         BigDecimal totalCost = unitPrice.multiply(BigDecimal.valueOf(quantity));
 
         if (isPayAsYouGo(consumeType)) {
@@ -173,6 +193,8 @@ public class BalanceServiceImpl implements BalanceService {
             record.setConsumeTypeName(consumeType.getName());
             record.setAmount(totalCost);
             record.setUnitPrice(unitPrice);
+            record.setPlatformCost(platformCost);
+            record.setTenantPrice(unitPrice);
             record.setQuantity(quantity);
             record.setBeforeBalance(beforeBalance);
             record.setAfterBalance(updated.getTotalBalance());

+ 52 - 0
fs-service/src/main/java/com/fs/proxy/service/impl/CompanySmsApiServiceImpl.java

@@ -0,0 +1,52 @@
+package com.fs.proxy.service.impl;
+
+import com.fs.proxy.domain.CompanySmsApi;
+import com.fs.proxy.mapper.CompanySmsApiMapper;
+import com.fs.proxy.mapper.CompanySmsApiPortMapper;
+import com.fs.proxy.mapper.CompanySmsApiTenantMapper;
+import com.fs.proxy.service.ICompanySmsApiService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+@Service
+public class CompanySmsApiServiceImpl implements ICompanySmsApiService {
+
+    @Autowired
+    private CompanySmsApiMapper smsApiMapper;
+
+    @Autowired
+    private CompanySmsApiTenantMapper smsApiTenantMapper;
+
+    @Autowired
+    private CompanySmsApiPortMapper smsApiPortMapper;
+
+    @Override
+    public List<CompanySmsApi> selectSmsApiList(CompanySmsApi query) {
+        return smsApiMapper.selectSmsApiList(query);
+    }
+
+    @Override
+    public CompanySmsApi selectSmsApiById(Long apiId) {
+        return smsApiMapper.selectSmsApiById(apiId);
+    }
+
+    @Override
+    public int insertSmsApi(CompanySmsApi smsApi) {
+        return smsApiMapper.insertSmsApi(smsApi);
+    }
+
+    @Override
+    public int updateSmsApi(CompanySmsApi smsApi) {
+        return smsApiMapper.updateSmsApi(smsApi);
+    }
+
+    @Override
+    public int deleteSmsApiById(Long apiId) {
+        // 删除接口时同步删除端口池+租户绑定
+        smsApiPortMapper.deletePortByApiId(apiId);
+        smsApiTenantMapper.deleteSmsApiTenantByApiId(apiId);
+        return smsApiMapper.deleteSmsApiById(apiId);
+    }
+}

+ 57 - 0
fs-service/src/main/java/com/fs/proxy/service/impl/CompanySmsApiTenantServiceImpl.java

@@ -0,0 +1,57 @@
+package com.fs.proxy.service.impl;
+
+import com.fs.proxy.domain.CompanySmsApiTenant;
+import com.fs.proxy.mapper.CompanySmsApiTenantMapper;
+import com.fs.proxy.service.ICompanySmsApiTenantService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+@Service
+public class CompanySmsApiTenantServiceImpl implements ICompanySmsApiTenantService {
+
+    @Autowired
+    private CompanySmsApiTenantMapper smsApiTenantMapper;
+
+    @Override
+    public List<CompanySmsApiTenant> selectSmsApiTenantList(CompanySmsApiTenant query) {
+        return smsApiTenantMapper.selectSmsApiTenantList(query);
+    }
+
+    @Override
+    public CompanySmsApiTenant selectSmsApiTenantById(Long id) {
+        return smsApiTenantMapper.selectSmsApiTenantById(id);
+    }
+
+    @Override
+    public List<CompanySmsApiTenant> selectActiveByCompanyAndType(Long tenantId, Integer smsType) {
+        return smsApiTenantMapper.selectActiveByCompanyAndType(tenantId, smsType);
+    }
+
+    @Override
+    public BigDecimal selectPriceByCompanyAndApi(Long tenantId, Long apiId) {
+        return smsApiTenantMapper.selectPriceByCompanyAndApi(tenantId, apiId);
+    }
+
+    @Override
+    public List<CompanySmsApiTenant> selectByCompanyId(Long tenantId) {
+        return smsApiTenantMapper.selectByCompanyId(tenantId);
+    }
+
+    @Override
+    public int insertSmsApiTenant(CompanySmsApiTenant tenant) {
+        return smsApiTenantMapper.insertSmsApiTenant(tenant);
+    }
+
+    @Override
+    public int updateSmsApiTenant(CompanySmsApiTenant tenant) {
+        return smsApiTenantMapper.updateSmsApiTenant(tenant);
+    }
+
+    @Override
+    public int deleteSmsApiTenantById(Long id) {
+        return smsApiTenantMapper.deleteSmsApiTenantById(id);
+    }
+}

+ 283 - 0
fs-service/src/main/java/com/fs/proxy/service/impl/CompanySmsPortServiceImpl.java

@@ -0,0 +1,283 @@
+package com.fs.proxy.service.impl;
+
+import com.fs.proxy.domain.*;
+import com.fs.proxy.mapper.*;
+import com.fs.proxy.service.ICompanySmsPortService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+@Service
+public class CompanySmsPortServiceImpl implements ICompanySmsPortService {
+
+    private static final Logger log = LoggerFactory.getLogger(CompanySmsPortServiceImpl.class);
+
+    @Autowired
+    private CompanySmsApiPortMapper portMapper;
+    @Autowired
+    private CompanySmsPortAssignMapper assignMapper;
+    @Autowired
+    private CompanySmsCardMapper cardMapper;
+    @Autowired
+    private CompanySmsCardMiddlewareMapper middlewareMapper;
+    @Autowired
+    private CompanySmsApiTenantMapper tenantMapper;
+
+    // ========== 端口池 ==========
+
+    @Override
+    public List<CompanySmsApiPort> selectPortList(CompanySmsApiPort query) {
+        return portMapper.selectPortList(query);
+    }
+
+    @Override
+    public List<CompanySmsApiPort> selectPortByApiId(Long apiId) {
+        return portMapper.selectPortByApiId(apiId);
+    }
+
+    @Override
+    public CompanySmsApiPort selectPortById(Long portId) {
+        return portMapper.selectPortById(portId);
+    }
+
+    @Override
+    public int insertPort(CompanySmsApiPort port) {
+        return portMapper.insertPort(port);
+    }
+
+    @Override
+    public int updatePort(CompanySmsApiPort port) {
+        return portMapper.updatePort(port);
+    }
+
+    @Override
+    public int deletePortById(Long portId) {
+        return portMapper.deletePortById(portId);
+    }
+
+    /**
+     * 核心方法:降级路由解析可用端口
+     * 
+     * 逻辑:
+     * 1. 如果指定了preferApiId(销售手动选择), 直接查该api下的可用端口
+     * 2. 否则按优先级遍历租户绑定的接口:
+     *    a. 查该接口下是否有分配给当前销售的端口
+     *    b. 没有则查共享端口(company_user_id IS NULL)
+     *    c. 如果是card类型, 还要检查卡是否在线
+     *    d. 都没找到 → 降级到下一个接口
+     * 3. 全部接口都没可用端口 → 返回null
+     */
+    @Override
+    public CompanySmsApiPort resolvePort(Long tenantId, Integer smsType, Long companyUserId, Long preferApiId) {
+        // 1. 销售手动选择接口
+        if (preferApiId != null) {
+            CompanySmsApiPort port = findAvailablePort(tenantId, preferApiId, companyUserId);
+            if (port != null) {
+                log.info("resolvePort: 销售手动选择 apiId={}, portId={}", preferApiId, port.getPortId());
+                return port;
+            }
+            log.warn("resolvePort: 销售手动选择 apiId={} 无可用端口", preferApiId);
+            return null;
+        }
+
+        // 2. 自动路由: 按优先级遍历租户绑定的接口
+        List<CompanySmsApiTenant> tenants = tenantMapper.selectActiveByCompanyAndType(tenantId, smsType);
+
+        for (CompanySmsApiTenant tenant : tenants) {
+            CompanySmsApiPort port = findAvailablePort(tenantId, tenant.getApiId(), companyUserId);
+            if (port != null) {
+                log.info("resolvePort: 自动路由 tenantId={}, smsType={}, apiId={}, portId={}, priority={}",
+                        tenantId, smsType, tenant.getApiId(), port.getPortId(), tenant.getPriority());
+                return port;
+            }
+            // 降级: 这个接口没可用端口, 尝试下一个
+            log.info("resolvePort: 降级 tenantId={}, apiId={} 无可用端口, 尝试下一个", tenantId, tenant.getApiId());
+        }
+
+        log.warn("resolvePort: 所有接口均无可用端口 tenantId={}, smsType={}, userId={}", tenantId, smsType, companyUserId);
+        return null;
+    }
+
+    /** 查找指定接口下分配给租户+销售的可用端口 */
+    private CompanySmsApiPort findAvailablePort(Long tenantId, Long apiId, Long companyUserId) {
+        List<CompanySmsApiPort> ports = portMapper.selectAvailablePort(tenantId, apiId, companyUserId);
+        if (ports == null || ports.isEmpty()) {
+            return null;
+        }
+
+        // 检查provider类型
+        CompanySmsApiPort first = ports.get(0);
+        if ("card".equals(first.getProvider())) {
+            // card类型: 需要检查卡是否在线 + 短信发送限制
+            CompanySmsCard cardQuery = new CompanySmsCard();
+            cardQuery.setPortId(first.getPortId());
+            cardQuery.setStatus(1); // 只查在线
+            List<CompanySmsCard> onlineCards = cardMapper.selectCardList(cardQuery);
+            if (onlineCards == null || onlineCards.isEmpty()) {
+                return null; // 卡离线, 不可用
+            }
+            // 检查卡的短信发送限制
+            CompanySmsCard card = onlineCards.get(0);
+            if (!isCardSmsAvailable(card)) {
+                log.info("findAvailablePort: 卡短信限制未通过 cardId={}, 降级到下一个接口", card.getCardId());
+                return null; // 卡限制超了, 降级
+            }
+        }
+
+        return first;
+    }
+
+    /**
+     * 检查卡的短信发送是否可用(限制检查)
+     * 在resolvePort阶段做预检查, 发送阶段还会做完整检查
+     */
+    private boolean isCardSmsAvailable(CompanySmsCard card) {
+        java.util.Date now = new java.util.Date();
+
+        // 剩余短信余额
+        if (card.getSmsBalance() != null && card.getSmsBalance() <= 0) {
+            return false;
+        }
+
+        // 每日限制
+        if (card.getSmsDailyLimit() != null && card.getSmsDailyLimit() > 0) {
+            int sentToday = 0;
+            if (card.getSmsSentDate() != null && org.apache.commons.lang3.time.DateUtils.isSameDay(card.getSmsSentDate(), now)) {
+                sentToday = card.getSmsSentToday() != null ? card.getSmsSentToday() : 0;
+            }
+            if (sentToday >= card.getSmsDailyLimit()) {
+                return false;
+            }
+        }
+
+        // 每小时限制
+        if (card.getSmsHourlyLimit() != null && card.getSmsHourlyLimit() > 0) {
+            int currentHour = new java.util.GregorianCalendar().get(java.util.Calendar.HOUR_OF_DAY);
+            int sentHour = 0;
+            if (card.getSmsSentDate() != null && org.apache.commons.lang3.time.DateUtils.isSameDay(card.getSmsSentDate(), now)
+                    && card.getSmsSentHourNum() != null && card.getSmsSentHourNum() == currentHour) {
+                sentHour = card.getSmsSentHour() != null ? card.getSmsSentHour() : 0;
+            }
+            if (sentHour >= card.getSmsHourlyLimit()) {
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    // ========== 端口分配 ==========
+
+    @Override
+    public List<CompanySmsPortAssign> selectAssignList(CompanySmsPortAssign query) {
+        return assignMapper.selectAssignList(query);
+    }
+
+    @Override
+    public int insertAssign(CompanySmsPortAssign assign) {
+        return assignMapper.insertAssign(assign);
+    }
+
+    @Override
+    public int updateAssign(CompanySmsPortAssign assign) {
+        return assignMapper.updateAssign(assign);
+    }
+
+    @Override
+    public int deleteAssignById(Long id) {
+        return assignMapper.deleteAssignById(id);
+    }
+
+    // ========== 手机卡管理 ==========
+
+    @Override
+    public List<CompanySmsCard> selectCardList(CompanySmsCard query) {
+        return cardMapper.selectCardList(query);
+    }
+
+    @Override
+    public CompanySmsCard selectCardById(Long cardId) {
+        return cardMapper.selectCardById(cardId);
+    }
+
+    @Override
+    public CompanySmsCard selectCardByImei(String imei) {
+        return cardMapper.selectCardByImei(imei);
+    }
+
+    @Override
+    public int insertCard(CompanySmsCard card) {
+        return cardMapper.insertCard(card);
+    }
+
+    @Override
+    public int updateCard(CompanySmsCard card) {
+        return cardMapper.updateCard(card);
+    }
+
+    @Override
+    public int deleteCardById(Long cardId) {
+        return cardMapper.deleteCardById(cardId);
+    }
+
+    /**
+     * 心跳上报:
+     * 1. 根据imei查卡, 存在则更新心跳+状态为在线
+     * 2. 不存在则自动注册新卡
+     */
+    @Override
+    public int heartbeat(String imei, String appVersion, String phone1, String phone2, String deviceName, Long tenantId) {
+        CompanySmsCard existing = cardMapper.selectCardByImei(imei);
+        if (existing != null) {
+            // 更新心跳
+            return cardMapper.updateHeartbeat(imei, appVersion);
+        }
+        // 自动注册新卡
+        CompanySmsCard card = new CompanySmsCard();
+        card.setImei(imei);
+        card.setAppVersion(appVersion);
+        card.setPhone1(phone1);
+        card.setPhone2(phone2);
+        card.setDeviceName(deviceName);
+        card.setTenantId(tenantId);
+        card.setSimCount(phone2 != null && !phone2.isEmpty() ? 2 : 1);
+        card.setLastHeartbeat(new java.util.Date());
+        card.setStatus(1); // 在线
+        return cardMapper.insertCard(card);
+    }
+
+    @Override
+    public int updateOfflineCards() {
+        return cardMapper.updateOfflineCards(90); // 90秒无心跳视为离线
+    }
+
+    // ========== 中间件 ==========
+
+    @Override
+    public List<CompanySmsCardMiddleware> selectMiddlewareList(CompanySmsCardMiddleware query) {
+        return middlewareMapper.selectMiddlewareList(query);
+    }
+
+    @Override
+    public CompanySmsCardMiddleware selectMiddlewareByApiId(Long apiId) {
+        return middlewareMapper.selectMiddlewareByApiId(apiId);
+    }
+
+    @Override
+    public int insertMiddleware(CompanySmsCardMiddleware mw) {
+        return middlewareMapper.insertMiddleware(mw);
+    }
+
+    @Override
+    public int updateMiddleware(CompanySmsCardMiddleware mw) {
+        return middlewareMapper.updateMiddleware(mw);
+    }
+
+    @Override
+    public int deleteMiddlewareById(Long id) {
+        return middlewareMapper.deleteMiddlewareById(id);
+    }
+}

+ 9 - 1
fs-service/src/main/java/com/fs/tenant/service/impl/TenantInfoServiceImpl.java

@@ -281,12 +281,16 @@ public class TenantInfoServiceImpl extends ServiceImpl<TenantInfoMapper, TenantI
 
         List<SysMenu> result = new ArrayList<>();
         for (SysMenu standard : standardList) {
+            // 过滤:只显示模板中 visible=0(显示状态)的菜单,隐藏状态的不展示
+            if (!"0".equals(standard.getVisible())) {
+                continue;
+            }
             Long menuId = standard.getMenuId();
             // 租户有这个 menuId → 用租户的覆盖
             if (tenantMap.containsKey(menuId)) {
                 result.add(tenantMap.get(menuId));
             }
-            // 租户没有 → 用标准菜单
+            // 租户没有 → 用标准菜单,标记为隐藏(未分配)
             else {
                 standard.setVisible("1");
                 result.add(standard);
@@ -308,6 +312,10 @@ public class TenantInfoServiceImpl extends ServiceImpl<TenantInfoMapper, TenantI
 
         List<TenantCompanyMenu> result = new ArrayList<>();
         for (TenantCompanyMenu standard : standardList) {
+            // 过滤:只显示模板中 visible=0(显示状态)的菜单,隐藏状态的不展示
+            if (!"0".equals(standard.getVisible())) {
+                continue;
+            }
             Long menuId = standard.getMenuId();
             // 租户有 → 覆盖
             if (tenantMap.containsKey(menuId)) {

+ 84 - 0
fs-service/src/main/resources/mapper/proxy/CompanySmsApiMapper.xml

@@ -0,0 +1,84 @@
+<?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.CompanySmsApiMapper">
+
+    <resultMap type="com.fs.proxy.domain.CompanySmsApi" id="CompanySmsApiResult">
+        <result property="apiId"      column="api_id"/>
+        <result property="apiName"    column="api_name"/>
+        <result property="provider"   column="provider"/>
+        <result property="smsType"    column="sms_type"/>
+        <result property="account"    column="account"/>
+        <result property="password"   column="password"/>
+        <result property="url"        column="url"/>
+        <result property="code"       column="code"/>
+        <result property="sign"       column="sign"/>
+        <result property="costPrice"  column="cost_price"/>
+        <result property="isDefault"  column="is_default"/>
+        <result property="status"     column="status"/>
+        <result property="remark"     column="remark"/>
+        <result property="createTime" column="create_time"/>
+        <result property="updateTime" column="update_time"/>
+    </resultMap>
+
+    <sql id="selectSmsApiVo">
+        SELECT api_id, api_name, provider, sms_type, account, password, url, code, sign,
+               cost_price, is_default, status, remark, create_time, update_time
+        FROM company_sms_api
+    </sql>
+
+    <select id="selectSmsApiList" resultMap="CompanySmsApiResult">
+        <include refid="selectSmsApiVo"/>
+        <where>
+            <if test="apiName != null and apiName != ''">AND api_name LIKE CONCAT('%', #{apiName}, '%')</if>
+            <if test="provider != null and provider != ''">AND provider = #{provider}</if>
+            <if test="smsType != null">AND sms_type = #{smsType}</if>
+            <if test="status != null">AND status = #{status}</if>
+        </where>
+        ORDER BY sms_type ASC, api_id ASC
+    </select>
+
+    <select id="selectSmsApiById" resultMap="CompanySmsApiResult">
+        <include refid="selectSmsApiVo"/>
+        WHERE api_id = #{apiId}
+    </select>
+
+    <select id="selectSmsApiBySmsType" resultMap="CompanySmsApiResult">
+        <include refid="selectSmsApiVo"/>
+        WHERE sms_type = #{smsType} AND is_default = 1 AND status = 1
+        LIMIT 1
+    </select>
+
+    <insert id="insertSmsApi" useGeneratedKeys="true" keyProperty="apiId">
+        INSERT INTO company_sms_api (
+            api_name, provider, sms_type, account, password, url, code, sign,
+            cost_price, is_default, status, remark, create_time
+        ) VALUES (
+            #{apiName}, #{provider}, #{smsType}, #{account}, #{password}, #{url}, #{code}, #{sign},
+            #{costPrice}, #{isDefault}, #{status}, #{remark}, NOW()
+        )
+    </insert>
+
+    <update id="updateSmsApi">
+        UPDATE company_sms_api
+        <set>
+            <if test="apiName != null">api_name = #{apiName},</if>
+            <if test="provider != null">provider = #{provider},</if>
+            <if test="smsType != null">sms_type = #{smsType},</if>
+            <if test="account != null">account = #{account},</if>
+            <if test="password != null">password = #{password},</if>
+            <if test="url != null">url = #{url},</if>
+            <if test="code != null">code = #{code},</if>
+            <if test="sign != null">sign = #{sign},</if>
+            <if test="costPrice != null">cost_price = #{costPrice},</if>
+            <if test="isDefault != null">is_default = #{isDefault},</if>
+            <if test="status != null">status = #{status},</if>
+            <if test="remark != null">remark = #{remark},</if>
+            update_time = NOW()
+        </set>
+        WHERE api_id = #{apiId}
+    </update>
+
+    <delete id="deleteSmsApiById">
+        DELETE FROM company_sms_api WHERE api_id = #{apiId}
+    </delete>
+</mapper>

+ 85 - 0
fs-service/src/main/resources/mapper/proxy/CompanySmsApiPortMapper.xml

@@ -0,0 +1,85 @@
+<?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.CompanySmsApiPortMapper">
+
+    <resultMap type="com.fs.proxy.domain.CompanySmsApiPort" id="PortResult">
+        <result property="portId"    column="port_id"/>
+        <result property="apiId"     column="api_id"/>
+        <result property="portName"  column="port_name"/>
+        <result property="portNo"    column="port_no"/>
+        <result property="account"   column="account"/>
+        <result property="password"  column="password"/>
+        <result property="sign"      column="sign"/>
+        <result property="slotIndex" column="slot_index"/>
+        <result property="status"    column="status"/>
+        <result property="createTime" column="create_time"/>
+        <result property="updateTime" column="update_time"/>
+        <result property="apiName"   column="api_name"/>
+        <result property="provider"  column="provider"/>
+        <result property="smsType"   column="sms_type"/>
+    </resultMap>
+
+    <sql id="selectPortVo">
+        SELECT p.port_id, p.api_id, p.port_name, p.port_no, p.account, p.password, p.sign,
+               p.slot_index, p.status, p.create_time, p.update_time,
+               a.api_name, a.provider, a.sms_type
+        FROM company_sms_api_port p
+        LEFT JOIN company_sms_api a ON p.api_id = a.api_id
+    </sql>
+
+    <select id="selectPortList" resultMap="PortResult">
+        <include refid="selectPortVo"/>
+        <where>
+            <if test="apiId != null">AND p.api_id = #{apiId}</if>
+            <if test="portName != null and portName != ''">AND p.port_name LIKE CONCAT('%', #{portName}, '%')</if>
+            <if test="status != null">AND p.status = #{status}</if>
+        </where>
+        ORDER BY p.port_id ASC
+    </select>
+
+    <select id="selectPortByApiId" resultMap="PortResult">
+        <include refid="selectPortVo"/>
+        WHERE p.api_id = #{apiId} AND p.status = 1
+        ORDER BY p.port_id ASC
+    </select>
+
+    <select id="selectPortById" resultMap="PortResult">
+        <include refid="selectPortVo"/>
+        WHERE p.port_id = #{portId}
+    </select>
+
+    <!-- 查询租户+销售可用的端口(通过port_assign关联,精确匹配销售优先) -->
+    <select id="selectAvailablePort" resultMap="PortResult">
+        <include refid="selectPortVo"/>
+        INNER JOIN company_sms_port_assign pa ON p.port_id = pa.port_id
+        WHERE pa.tenant_id = #{tenantId}
+          AND p.api_id = #{apiId}
+          AND p.status = 1 AND pa.status = 1
+          AND (pa.company_user_id = #{companyUserId} OR pa.company_user_id IS NULL)
+        ORDER BY pa.company_user_id IS NULL ASC, p.port_id ASC
+        LIMIT 1
+    </select>
+
+    <insert id="insertPort" useGeneratedKeys="true" keyProperty="portId">
+        INSERT INTO company_sms_api_port (api_id, port_name, port_no, account, password, sign, slot_index, status, create_time)
+        VALUES (#{apiId}, #{portName}, #{portNo}, #{account}, #{password}, #{sign}, #{slotIndex}, #{status}, NOW())
+    </insert>
+
+    <update id="updatePort">
+        UPDATE company_sms_api_port
+        <set>
+            <if test="portName != null">port_name = #{portName},</if>
+            <if test="portNo != null">port_no = #{portNo},</if>
+            <if test="account != null">account = #{account},</if>
+            <if test="password != null">password = #{password},</if>
+            <if test="sign != null">sign = #{sign},</if>
+            <if test="slotIndex != null">slot_index = #{slotIndex},</if>
+            <if test="status != null">status = #{status},</if>
+            update_time = NOW()
+        </set>
+        WHERE port_id = #{portId}
+    </update>
+
+    <delete id="deletePortById">DELETE FROM company_sms_api_port WHERE port_id = #{portId}</delete>
+    <delete id="deletePortByApiId">DELETE FROM company_sms_api_port WHERE api_id = #{apiId}</delete>
+</mapper>

+ 89 - 0
fs-service/src/main/resources/mapper/proxy/CompanySmsApiTenantMapper.xml

@@ -0,0 +1,89 @@
+<?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.CompanySmsApiTenantMapper">
+
+    <resultMap type="com.fs.proxy.domain.CompanySmsApiTenant" id="CompanySmsApiTenantResult">
+        <result property="id"          column="id"/>
+        <result property="apiId"       column="api_id"/>
+        <result property="tenantId"    column="tenant_id"/>
+        <result property="price"       column="price"/>
+        <result property="priority"    column="priority"/>
+        <result property="allowManual" column="allow_manual"/>
+        <result property="status"      column="status"/>
+        <result property="createTime"  column="create_time"/>
+        <result property="updateTime"  column="update_time"/>
+        <result property="apiName"     column="api_name"/>
+        <result property="smsType"     column="sms_type"/>
+        <result property="provider"    column="provider"/>
+        <result property="tenantName"  column="tenant_name"/>
+        <result property="costPrice"   column="cost_price"/>
+    </resultMap>
+
+    <sql id="selectSmsApiTenantVo">
+        SELECT t.id, t.api_id, t.tenant_id, t.price, t.priority, t.allow_manual, t.status, t.create_time, t.update_time,
+               a.api_name, a.sms_type, a.provider, a.cost_price,
+               ti.tenant_name
+        FROM company_sms_api_tenant t
+        LEFT JOIN company_sms_api a ON t.api_id = a.api_id
+        LEFT JOIN tenant_info ti ON t.tenant_id = ti.id
+    </sql>
+
+    <select id="selectSmsApiTenantList" resultMap="CompanySmsApiTenantResult">
+        <include refid="selectSmsApiTenantVo"/>
+        <where>
+            <if test="apiId != null">AND t.api_id = #{apiId}</if>
+            <if test="tenantId != null">AND t.tenant_id = #{tenantId}</if>
+            <if test="status != null">AND t.status = #{status}</if>
+            <if test="smsType != null">AND a.sms_type = #{smsType}</if>
+        </where>
+        ORDER BY t.id DESC
+    </select>
+
+    <select id="selectSmsApiTenantById" resultMap="CompanySmsApiTenantResult">
+        <include refid="selectSmsApiTenantVo"/>
+        WHERE t.id = #{id}
+    </select>
+
+    <select id="selectActiveByCompanyAndType" resultMap="CompanySmsApiTenantResult">
+        <include refid="selectSmsApiTenantVo"/>
+        WHERE t.tenant_id = #{tenantId} AND a.sms_type = #{smsType} AND t.status = 1 AND a.status = 1
+        ORDER BY t.priority ASC
+    </select>
+
+    <select id="selectPriceByCompanyAndApi" resultType="java.math.BigDecimal">
+        SELECT t.price FROM company_sms_api_tenant t
+        WHERE t.tenant_id = #{tenantId} AND t.api_id = #{apiId} AND t.status = 1
+        LIMIT 1
+    </select>
+
+    <select id="selectByCompanyId" resultMap="CompanySmsApiTenantResult">
+        <include refid="selectSmsApiTenantVo"/>
+        WHERE t.tenant_id = #{tenantId}
+        ORDER BY a.sms_type ASC
+    </select>
+
+    <insert id="insertSmsApiTenant" useGeneratedKeys="true" keyProperty="id">
+        INSERT INTO company_sms_api_tenant (api_id, tenant_id, price, priority, allow_manual, status, create_time)
+        VALUES (#{apiId}, #{tenantId}, #{price}, #{priority}, #{allowManual}, #{status}, NOW())
+    </insert>
+
+    <update id="updateSmsApiTenant">
+        UPDATE company_sms_api_tenant
+        <set>
+            <if test="price != null">price = #{price},</if>
+            <if test="priority != null">priority = #{priority},</if>
+            <if test="allowManual != null">allow_manual = #{allowManual},</if>
+            <if test="status != null">status = #{status},</if>
+            update_time = NOW()
+        </set>
+        WHERE id = #{id}
+    </update>
+
+    <delete id="deleteSmsApiTenantById">
+        DELETE FROM company_sms_api_tenant WHERE id = #{id}
+    </delete>
+
+    <delete id="deleteSmsApiTenantByApiId">
+        DELETE FROM company_sms_api_tenant WHERE api_id = #{apiId}
+    </delete>
+</mapper>

+ 150 - 0
fs-service/src/main/resources/mapper/proxy/CompanySmsCardMapper.xml

@@ -0,0 +1,150 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.proxy.mapper.CompanySmsCardMapper">
+
+    <resultMap type="com.fs.proxy.domain.CompanySmsCard" id="CardResult">
+        <result property="cardId"        column="card_id"/>
+        <result property="portId"        column="port_id"/>
+        <result property="tenantId"      column="tenant_id"/>
+        <result property="imei"          column="imei"/>
+        <result property="deviceName"    column="device_name"/>
+        <result property="simCount"      column="sim_count"/>
+        <result property="phone1"        column="phone_1"/>
+        <result property="phone2"        column="phone_2"/>
+        <result property="lastHeartbeat" column="last_heartbeat"/>
+        <result property="status"        column="status"/>
+        <result property="appVersion"    column="app_version"/>
+        <result property="smsSentToday"  column="sms_sent_today"/>
+        <result property="smsSentDate"   column="sms_sent_date"/>
+        <result property="smsSentHour"   column="sms_sent_hour"/>
+        <result property="smsSentHourNum" column="sms_sent_hour_num"/>
+        <result property="smsHourlyLimit" column="sms_hourly_limit"/>
+        <result property="smsDailyLimit" column="sms_daily_limit"/>
+        <result property="smsBalance"    column="sms_balance"/>
+        <result property="callSentToday" column="call_sent_today"/>
+        <result property="callSentDate"  column="call_sent_date"/>
+        <result property="callIntervalSeconds" column="call_interval_seconds"/>
+        <result property="callMinutesBalance" column="call_minutes_balance"/>
+        <result property="phoneBillBalance" column="phone_bill_balance"/>
+        <result property="allowCallForward" column="allow_call_forward"/>
+        <result property="forwardPhone"  column="forward_phone"/>
+        <result property="lastCallTime"  column="last_call_time"/>
+        <result property="remark"        column="remark"/>
+        <result property="createTime"    column="create_time"/>
+        <result property="updateTime"    column="update_time"/>
+        <result property="portName"      column="port_name"/>
+        <result property="portNo"        column="port_no"/>
+        <result property="tenantName"    column="tenant_name"/>
+    </resultMap>
+
+    <sql id="selectCardVo">
+        SELECT c.card_id, c.port_id, c.tenant_id, c.imei, c.device_name, c.sim_count,
+               c.phone_1, c.phone_2, c.last_heartbeat, c.status, c.app_version,
+               c.sms_sent_today, c.sms_sent_date, c.sms_sent_hour, c.sms_sent_hour_num,
+               c.sms_hourly_limit, c.sms_daily_limit, c.sms_balance,
+               c.call_sent_today, c.call_sent_date, c.call_interval_seconds,
+               c.call_minutes_balance, c.phone_bill_balance,
+               c.allow_call_forward, c.forward_phone, c.last_call_time,
+               c.remark, c.create_time, c.update_time,
+               p.port_name, p.port_no,
+               ti.tenant_name
+        FROM company_sms_card c
+        LEFT JOIN company_sms_api_port p ON c.port_id = p.port_id
+        LEFT JOIN tenant_info ti ON c.tenant_id = ti.id
+    </sql>
+
+    <select id="selectCardList" resultMap="CardResult">
+        <include refid="selectCardVo"/>
+        <where>
+            <if test="tenantId != null">AND c.tenant_id = #{tenantId}</if>
+            <if test="imei != null and imei != ''">AND c.imei = #{imei}</if>
+            <if test="status != null">AND c.status = #{status}</if>
+            <if test="phone1 != null and phone1 != ''">AND c.phone_1 = #{phone1}</if>
+        </where>
+        ORDER BY c.card_id DESC
+    </select>
+
+    <select id="selectCardById" resultMap="CardResult">
+        <include refid="selectCardVo"/>
+        WHERE c.card_id = #{cardId}
+    </select>
+
+    <select id="selectCardByImei" resultMap="CardResult">
+        <include refid="selectCardVo"/>
+        WHERE c.imei = #{imei}
+    </select>
+
+    <!-- 根据portId查询在线的卡 -->
+    <select id="selectOnlineCardByPortId" resultMap="CardResult">
+        <include refid="selectCardVo"/>
+        WHERE c.port_id = #{portId} AND c.status = 1
+        LIMIT 1
+    </select>
+
+    <insert id="insertCard" useGeneratedKeys="true" keyProperty="cardId">
+        INSERT INTO company_sms_card (port_id, tenant_id, imei, device_name, sim_count,
+            phone_1, phone_2, last_heartbeat, status, app_version,
+            sms_hourly_limit, sms_daily_limit, sms_balance,
+            call_interval_seconds, call_minutes_balance, phone_bill_balance,
+            allow_call_forward, forward_phone,
+            remark, create_time)
+        VALUES (#{portId}, #{tenantId}, #{imei}, #{deviceName}, #{simCount},
+            #{phone1}, #{phone2}, #{lastHeartbeat}, #{status}, #{appVersion},
+            #{smsHourlyLimit}, #{smsDailyLimit}, #{smsBalance},
+            #{callIntervalSeconds}, #{callMinutesBalance}, #{phoneBillBalance},
+            #{allowCallForward}, #{forwardPhone},
+            #{remark}, NOW())
+    </insert>
+
+    <update id="updateCard">
+        UPDATE company_sms_card
+        <set>
+            <if test="portId != null">port_id = #{portId},</if>
+            <if test="tenantId != null">tenant_id = #{tenantId},</if>
+            <if test="deviceName != null">device_name = #{deviceName},</if>
+            <if test="simCount != null">sim_count = #{simCount},</if>
+            <if test="phone1 != null">phone_1 = #{phone1},</if>
+            <if test="phone2 != null">phone_2 = #{phone2},</if>
+            <if test="status != null">status = #{status},</if>
+            <if test="smsHourlyLimit != null">sms_hourly_limit = #{smsHourlyLimit},</if>
+            <if test="smsDailyLimit != null">sms_daily_limit = #{smsDailyLimit},</if>
+            <if test="smsBalance != null">sms_balance = #{smsBalance},</if>
+            <if test="callIntervalSeconds != null">call_interval_seconds = #{callIntervalSeconds},</if>
+            <if test="callMinutesBalance != null">call_minutes_balance = #{callMinutesBalance},</if>
+            <if test="phoneBillBalance != null">phone_bill_balance = #{phoneBillBalance},</if>
+            <if test="allowCallForward != null">allow_call_forward = #{allowCallForward},</if>
+            <if test="forwardPhone != null">forward_phone = #{forwardPhone},</if>
+            <if test="remark != null">remark = #{remark},</if>
+            update_time = NOW()
+        </set>
+        WHERE card_id = #{cardId}
+    </update>
+
+    <!-- 发送成功后递增短信计数(自动判断日期/小时重置) -->
+    <update id="incrementSmsCount">
+        UPDATE company_sms_card
+        SET sms_sent_today = CASE WHEN sms_sent_date = CURDATE() THEN sms_sent_today + 1 ELSE 1 END,
+            sms_sent_date = CURDATE(),
+            sms_sent_hour = CASE WHEN sms_sent_date = CURDATE() AND sms_sent_hour_num = HOUR(NOW()) THEN sms_sent_hour + 1 ELSE 1 END,
+            sms_sent_hour_num = HOUR(NOW()),
+            sms_balance = CASE WHEN sms_balance > 0 THEN sms_balance - 1 ELSE 0 END,
+            update_time = NOW()
+        WHERE card_id = #{cardId}
+    </update>
+
+    <!-- 批量将超时无心跳的卡设为离线 -->
+    <update id="updateOfflineCards">
+        UPDATE company_sms_card SET status = 0
+        WHERE status = 1 AND last_heartbeat &lt; DATE_SUB(NOW(), INTERVAL #{timeoutSeconds} SECOND)
+    </update>
+
+    <!-- 心跳更新 -->
+    <update id="updateHeartbeat">
+        UPDATE company_sms_card
+        SET last_heartbeat = NOW(), status = 1
+            <if test="appVersion != null">, app_version = #{appVersion}</if>
+        WHERE imei = #{imei}
+    </update>
+
+    <delete id="deleteCardById">DELETE FROM company_sms_card WHERE card_id = #{cardId}</delete>
+</mapper>

+ 65 - 0
fs-service/src/main/resources/mapper/proxy/CompanySmsCardMiddlewareMapper.xml

@@ -0,0 +1,65 @@
+<?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.CompanySmsCardMiddlewareMapper">
+
+    <resultMap type="com.fs.proxy.domain.CompanySmsCardMiddleware" id="MiddlewareResult">
+        <result property="id"              column="id"/>
+        <result property="apiId"           column="api_id"/>
+        <result property="middlewareName"  column="middleware_name"/>
+        <result property="callbackUrl"     column="callback_url"/>
+        <result property="heartbeatUrl"    column="heartbeat_url"/>
+        <result property="authToken"       column="auth_token"/>
+        <result property="maxRetry"        column="max_retry"/>
+        <result property="timeoutSeconds"  column="timeout_seconds"/>
+        <result property="status"          column="status"/>
+        <result property="createTime"      column="create_time"/>
+        <result property="updateTime"      column="update_time"/>
+        <result property="apiName"         column="api_name"/>
+    </resultMap>
+
+    <sql id="selectMiddlewareVo">
+        SELECT m.id, m.api_id, m.middleware_name, m.callback_url, m.heartbeat_url, m.auth_token,
+               m.max_retry, m.timeout_seconds, m.status, m.create_time, m.update_time,
+               a.api_name
+        FROM company_sms_card_middleware m
+        LEFT JOIN company_sms_api a ON m.api_id = a.api_id
+    </sql>
+
+    <select id="selectMiddlewareList" resultMap="MiddlewareResult">
+        <include refid="selectMiddlewareVo"/>
+        <where>
+            <if test="apiId != null">AND m.api_id = #{apiId}</if>
+            <if test="status != null">AND m.status = #{status}</if>
+        </where>
+        ORDER BY m.id ASC
+    </select>
+
+    <select id="selectMiddlewareByApiId" resultMap="MiddlewareResult">
+        <include refid="selectMiddlewareVo"/>
+        WHERE m.api_id = #{apiId} AND m.status = 1
+    </select>
+
+    <insert id="insertMiddleware" useGeneratedKeys="true" keyProperty="id">
+        INSERT INTO company_sms_card_middleware (api_id, middleware_name, callback_url, heartbeat_url,
+            auth_token, max_retry, timeout_seconds, status, create_time)
+        VALUES (#{apiId}, #{middlewareName}, #{callbackUrl}, #{heartbeatUrl},
+            #{authToken}, #{maxRetry}, #{timeoutSeconds}, #{status}, NOW())
+    </insert>
+
+    <update id="updateMiddleware">
+        UPDATE company_sms_card_middleware
+        <set>
+            <if test="middlewareName != null">middleware_name = #{middlewareName},</if>
+            <if test="callbackUrl != null">callback_url = #{callbackUrl},</if>
+            <if test="heartbeatUrl != null">heartbeat_url = #{heartbeatUrl},</if>
+            <if test="authToken != null">auth_token = #{authToken},</if>
+            <if test="maxRetry != null">max_retry = #{maxRetry},</if>
+            <if test="timeoutSeconds != null">timeout_seconds = #{timeoutSeconds},</if>
+            <if test="status != null">status = #{status},</if>
+            update_time = NOW()
+        </set>
+        WHERE id = #{id}
+    </update>
+
+    <delete id="deleteMiddlewareById">DELETE FROM company_sms_card_middleware WHERE id = #{id}</delete>
+</mapper>

+ 68 - 0
fs-service/src/main/resources/mapper/proxy/CompanySmsPortAssignMapper.xml

@@ -0,0 +1,68 @@
+<?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.CompanySmsPortAssignMapper">
+
+    <resultMap type="com.fs.proxy.domain.CompanySmsPortAssign" id="AssignResult">
+        <result property="id"             column="id"/>
+        <result property="portId"         column="port_id"/>
+        <result property="tenantId"       column="tenant_id"/>
+        <result property="companyUserId"  column="company_user_id"/>
+        <result property="status"         column="status"/>
+        <result property="createTime"     column="create_time"/>
+        <result property="portName"       column="port_name"/>
+        <result property="portNo"         column="port_no"/>
+        <result property="apiId"          column="api_id"/>
+        <result property="apiName"        column="api_name"/>
+        <result property="provider"       column="provider"/>
+        <result property="smsType"        column="sms_type"/>
+        <result property="tenantName"     column="tenant_name"/>
+        <result property="userName"       column="user_name"/>
+    </resultMap>
+
+    <sql id="selectAssignVo">
+        SELECT a.id, a.port_id, a.tenant_id, a.company_user_id, a.status, a.create_time,
+               p.port_name, p.port_no, p.api_id,
+               api.api_name, api.provider, api.sms_type,
+               ti.tenant_name,
+               u.nick_name AS user_name
+        FROM company_sms_port_assign a
+        LEFT JOIN company_sms_api_port p ON a.port_id = p.port_id
+        LEFT JOIN company_sms_api api ON p.api_id = api.api_id
+        LEFT JOIN tenant_info ti ON a.tenant_id = ti.id
+        LEFT JOIN company_user u ON a.company_user_id = u.company_user_id
+    </sql>
+
+    <select id="selectAssignList" resultMap="AssignResult">
+        <include refid="selectAssignVo"/>
+        <where>
+            <if test="portId != null">AND a.port_id = #{portId}</if>
+            <if test="tenantId != null">AND a.tenant_id = #{tenantId}</if>
+            <if test="companyUserId != null">AND a.company_user_id = #{companyUserId}</if>
+            <if test="status != null">AND a.status = #{status}</if>
+        </where>
+        ORDER BY a.id DESC
+    </select>
+
+    <select id="selectByCompanyAndUser" resultMap="AssignResult">
+        <include refid="selectAssignVo"/>
+        WHERE a.tenant_id = #{tenantId} AND a.status = 1
+          AND (a.company_user_id = #{companyUserId} OR a.company_user_id IS NULL)
+        ORDER BY a.company_user_id IS NULL ASC
+    </select>
+
+    <insert id="insertAssign" useGeneratedKeys="true" keyProperty="id">
+        INSERT INTO company_sms_port_assign (port_id, tenant_id, company_user_id, status, create_time)
+        VALUES (#{portId}, #{tenantId}, #{companyUserId}, #{status}, NOW())
+    </insert>
+
+    <update id="updateAssign">
+        UPDATE company_sms_port_assign
+        <set>
+            <if test="companyUserId != null">company_user_id = #{companyUserId},</if>
+            <if test="status != null">status = #{status},</if>
+        </set>
+        WHERE id = #{id}
+    </update>
+
+    <delete id="deleteAssignById">DELETE FROM company_sms_port_assign WHERE id = #{id}</delete>
+</mapper>

+ 2 - 2
fs-service/src/main/resources/mapper/proxy/ProxyOperLogMapper.xml

@@ -35,7 +35,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     </insert>
 
     <select id="selectProxyOperLogList" resultMap="ProxyOperLogResult">
-        select ol.*, p.nick_name as proxy_name
+        select ol.*, p.proxy_name as proxy_name
         from proxy_oper_log ol
         left join proxy p on p.proxy_id = ol.proxy_id
         <where>
@@ -50,7 +50,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     </select>
 
     <select id="selectProxyOperLogById" resultMap="ProxyOperLogResult">
-        select ol.*, p.nick_name as proxy_name from proxy_oper_log ol
+        select ol.*, p.proxy_name as proxy_name from proxy_oper_log ol
         left join proxy p on p.proxy_id = ol.proxy_id
         where ol.oper_id = #{operId}
     </select>