云联一号 2 недель назад
Родитель
Сommit
1543eb2546
28 измененных файлов с 656 добавлено и 20 удалено
  1. 15 0
      fs-admin/src/main/java/com/fs/admin/controller/AdminProxyController.java
  2. 23 0
      fs-admin/src/main/java/com/fs/admin/controller/CompanyAdminController.java
  3. 56 1
      fs-admin/src/main/java/com/fs/web/controller/system/CompanySmsPortController.java
  4. 7 7
      fs-company/src/main/java/com/fs/company/SmsApiMigration.java
  5. 2 2
      fs-service/src/main/java/com/fs/common/service/impl/SmsServiceImpl.java
  6. 3 3
      fs-service/src/main/java/com/fs/proxy/domain/CompanySmsApi.java
  7. 6 0
      fs-service/src/main/java/com/fs/proxy/domain/CompanySmsCard.java
  8. 3 0
      fs-service/src/main/java/com/fs/proxy/domain/CompanySmsCardMiddleware.java
  9. 61 0
      fs-service/src/main/java/com/fs/proxy/domain/CompanySmsDevice.java
  10. 3 0
      fs-service/src/main/java/com/fs/proxy/domain/Proxy.java
  11. 35 0
      fs-service/src/main/java/com/fs/proxy/mapper/CompanySmsDeviceMapper.java
  12. 2 0
      fs-service/src/main/java/com/fs/proxy/mapper/ProxyMapper.java
  13. 17 2
      fs-service/src/main/java/com/fs/proxy/service/ICompanySmsPortService.java
  14. 2 0
      fs-service/src/main/java/com/fs/proxy/service/ProxyService.java
  15. 80 0
      fs-service/src/main/java/com/fs/proxy/service/impl/CompanySmsPortServiceImpl.java
  16. 10 0
      fs-service/src/main/java/com/fs/proxy/service/impl/ProxyServiceImpl.java
  17. 1 1
      fs-service/src/main/java/com/fs/sms/service/impl/SmsTServiceImpl.java
  18. 7 0
      fs-service/src/main/java/com/fs/tenant/mapper/TenantInfoMapper.java
  19. 8 0
      fs-service/src/main/java/com/fs/tenant/service/TenantInfoService.java
  20. 6 0
      fs-service/src/main/java/com/fs/tenant/service/impl/TenantInfoServiceImpl.java
  21. 9 3
      fs-service/src/main/resources/mapper/proxy/CompanySmsCardMapper.xml
  22. 107 0
      fs-service/src/main/resources/mapper/proxy/CompanySmsDeviceMapper.xml
  23. 8 1
      fs-service/src/main/resources/mapper/proxy/ProxyMapper.xml
  24. 5 0
      fs-service/src/main/resources/mapper/tenant/TenantInfoMapper.xml
  25. 11 0
      sql/add_sms_card_menu.sql
  26. 93 0
      sql/add_sms_device.sql
  27. 26 0
      sql/add_sms_device_menu.sql
  28. 50 0
      sql/add_voice_pricing.sql

+ 15 - 0
fs-admin/src/main/java/com/fs/admin/controller/AdminProxyController.java

@@ -1,6 +1,7 @@
 package com.fs.admin.controller;
 
 import java.util.List;
+import java.util.Map;
 
 import com.fs.common.annotation.ProxyLog;
 import com.fs.common.core.controller.BaseController;
@@ -100,4 +101,18 @@ public class AdminProxyController extends BaseController
         List<Proxy> list = proxyService.selectProxyList(query);
         return AjaxResult.success(list);
     }
+
+    /**
+     * 重置代理密码
+     */
+    @PreAuthorize("@ss.hasPermi('admin:proxy:edit')")
+    @ProxyLog(title = "代理管理", businessType = BusinessType.UPDATE)
+    @PutMapping("/resetPwd/{proxyId}")
+    public AjaxResult resetPwd(@PathVariable Long proxyId, @RequestBody Map<String, String> params) {
+        String password = params.get("password");
+        if (password == null || password.length() < 6) {
+            return AjaxResult.error("密码长度不能少于6位");
+        }
+        return toAjax(proxyService.resetPwd(proxyId, password));
+    }
 }

+ 23 - 0
fs-admin/src/main/java/com/fs/admin/controller/CompanyAdminController.java

@@ -259,4 +259,27 @@ public class CompanyAdminController extends BaseController {
         }
         return new ArrayList<>();
     }
+
+    /**
+     * 重置租户管理员密码
+     */
+    @PreAuthorize("@ss.hasPermi('admin:company:edit')")
+    @Log(title = "租户管理", businessType = BusinessType.UPDATE)
+    @PutMapping("/{id}/resetPwd")
+    public AjaxResult resetPwd(@PathVariable String id, @RequestBody Map<String, String> params) {
+        String password = params.get("password");
+        if (password == null || password.length() < 6) {
+            return AjaxResult.error("密码长度不能少于6位");
+        }
+        TenantInfo tenantInfo = tenantInfoMapper.selectTenantInfoById(id);
+        if (tenantInfo == null) {
+            return AjaxResult.error("租户不存在");
+        }
+        try {
+            tenantDataSourceManager.switchTenant(tenantInfo);
+            return toAjax(tenantInfoService.resetTenantAdminPwd(Long.valueOf(id), password));
+        } finally {
+            tenantDataSourceManager.clear();
+        }
+    }
 }

+ 56 - 1
fs-admin/src/main/java/com/fs/web/controller/system/CompanySmsPortController.java

@@ -129,7 +129,7 @@ public class CompanySmsPortController extends BaseController {
 
     // ========== 手机卡心跳(无需登录鉴权,手机APP调用) ==========
 
-    /** 心跳上报 */
+    /** 心跳上报(旧接口,兼容保留) */
     @PostMapping("/card/heartbeat")
     public AjaxResult heartbeat(@RequestParam String imei,
                                 @RequestParam(required = false) String appVersion,
@@ -141,6 +141,61 @@ public class CompanySmsPortController extends BaseController {
         return AjaxResult.success();
     }
 
+    // ========== 设备管理 ==========
+
+    /** 查询设备列表 */
+    @PreAuthorize("@ss.hasPermi('platform:smsDevice:list')")
+    @GetMapping("/device/list")
+    public AjaxResult deviceList(CompanySmsDevice query) {
+        List<CompanySmsDevice> list = portService.selectDeviceList(query);
+        return AjaxResult.success(list);
+    }
+
+    /** 设备详情 */
+    @PreAuthorize("@ss.hasPermi('platform:smsDevice:query')")
+    @GetMapping("/device/{deviceId}")
+    public AjaxResult deviceInfo(@PathVariable Long deviceId) {
+        return AjaxResult.success(portService.selectDeviceById(deviceId));
+    }
+
+    /** 新增设备 */
+    @PreAuthorize("@ss.hasPermi('platform:smsDevice:add')")
+    @PostMapping("/device")
+    public AjaxResult addDevice(@RequestBody CompanySmsDevice device) {
+        device.setCreateBy(getUsername());
+        return toAjax(portService.insertDevice(device));
+    }
+
+    /** 修改设备 */
+    @PreAuthorize("@ss.hasPermi('platform:smsDevice:edit')")
+    @PutMapping("/device")
+    public AjaxResult editDevice(@RequestBody CompanySmsDevice device) {
+        device.setUpdateBy(getUsername());
+        return toAjax(portService.updateDevice(device));
+    }
+
+    /** 删除设备 */
+    @PreAuthorize("@ss.hasPermi('platform:smsDevice:remove')")
+    @DeleteMapping("/device/{deviceId}")
+    public AjaxResult removeDevice(@PathVariable Long deviceId) {
+        return toAjax(portService.deleteDeviceById(deviceId));
+    }
+
+    /** 分配设备给销售 */
+    @PreAuthorize("@ss.hasPermi('platform:smsDevice:edit')")
+    @PutMapping("/device/assign")
+    public AjaxResult assignDevice(@RequestParam Long deviceId, @RequestParam Long companyUserId) {
+        return toAjax(portService.assignDeviceUser(deviceId, companyUserId));
+    }
+
+    /** 设备心跳上报(无需登录鉴权,手机APP调用) */
+    @PostMapping("/device/heartbeat")
+    public AjaxResult deviceHeartbeat(@RequestParam String imei,
+                                      @RequestParam(required = false) String appVersion) {
+        portService.deviceHeartbeat(imei, appVersion);
+        return AjaxResult.success();
+    }
+
     // ========== 中间件管理 ==========
 
     /** 查询中间件列表 */

+ 7 - 7
fs-company/src/main/java/com/fs/company/SmsApiMigration.java

@@ -19,12 +19,12 @@ public class SmsApiMigration {
         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德华'," +
+            "`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 '扩展码(润方专用)'," +
+            "`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 '备注'," +
@@ -91,7 +91,7 @@ public class SmsApiMigration {
 
                 // Insert rf marketing channel if account exists
                 if (rfAccount1 != null && !rfAccount1.isEmpty()) {
-                    ps.setString(1, "润方营销通道");
+                    ps.setString(1, "迈远营销通道");
                     ps.setString(2, "rf");
                     ps.setInt(3, 1);
                     ps.setString(4, rfAccount1);
@@ -100,12 +100,12 @@ public class SmsApiMigration {
                     ps.setString(7, rfCode1);
                     ps.setString(8, rfSign);
                     ps.executeUpdate();
-                    pw.println("  - Inserted: 润方营销通道");
+                    pw.println("  - Inserted: 迈远营销通道");
                 }
 
                 // Insert rf notification channel
                 if (rfAccount2 != null && !rfAccount2.isEmpty()) {
-                    ps.setString(1, "润方通知通道");
+                    ps.setString(1, "迈远通知通道");
                     ps.setString(2, "rf");
                     ps.setInt(3, 2);
                     ps.setString(4, rfAccount2);
@@ -114,7 +114,7 @@ public class SmsApiMigration {
                     ps.setString(7, rfCode2);
                     ps.setString(8, rfSign);
                     ps.executeUpdate();
-                    pw.println("  - Inserted: 润方通知通道");
+                    pw.println("  - Inserted: 迈远通知通道");
                 }
 
                 // Insert dh marketing channel

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

@@ -154,7 +154,7 @@ public class SmsServiceImpl implements ISmsService
         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)) {
             // 德华发送
@@ -168,7 +168,7 @@ public class SmsServiceImpl implements ISmsService
         }
     }
 
-    /** 润方发送 */
+    /** 迈远发送 */
     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) {

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

@@ -20,7 +20,7 @@ public class CompanySmsApi extends BaseEntity {
     @Excel(name = "接口名称")
     private String apiName;
 
-    /** 服务商: rf润方/dh德华/card手机卡 */
+    /** 服务商: rf迈远/dh德华/card手机卡 */
     @Excel(name = "服务商")
     private String provider;
 
@@ -35,10 +35,10 @@ public class CompanySmsApi extends BaseEntity {
     /** 密码 */
     private String password;
 
-    /** 接口地址(润方专用) */
+    /** 接口地址(迈远专用) */
     private String url;
 
-    /** 扩展码(润方专用) */
+    /** 扩展码(迈远专用) */
     private String code;
 
     /** 短信签名 */

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

@@ -22,6 +22,12 @@ public class CompanySmsCard extends BaseEntity {
     /** 所属租户 */
     private Long tenantId;
 
+    /** 所属设备ID */
+    private Long deviceId;
+
+    /** 卡槽位置(1或2) */
+    private Integer slotIndex;
+
     /** 手机IMEI */
     private String imei;
 

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

@@ -16,6 +16,9 @@ public class CompanySmsCardMiddleware extends BaseEntity {
     /** 接口ID(provider=card) */
     private Long apiId;
 
+    /** 所属租户 */
+    private Long tenantId;
+
     /** 中间件名称 */
     private String middlewareName;
 

+ 61 - 0
fs-service/src/main/java/com/fs/proxy/domain/CompanySmsDevice.java

@@ -0,0 +1,61 @@
+package com.fs.proxy.domain;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 手机设备管理对象 company_sms_device
+ *
+ * @author fs
+ * @date 2026-05-24
+ */
+@Data
+public class CompanySmsDevice extends BaseEntity {
+    private static final long serialVersionUID = 1L;
+
+    /** 设备ID */
+    private Long deviceId;
+
+    /** 所属租户 */
+    private Long tenantId;
+
+    /** 绑定销售用户ID(NULL=未分配) */
+    private Long companyUserId;
+
+    /** 设备名称 */
+    private String deviceName;
+
+    /** IMEI(唯一标识) */
+    private String imei;
+
+    /** APP版本 */
+    private String appVersion;
+
+    /** 关联中间件ID */
+    private Long middlewareId;
+
+    /** 最后心跳时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date lastHeartbeat;
+
+    /** 状态: 0离线/1在线/2禁用 */
+    private Integer status;
+
+    /** 备注 */
+    private String remark;
+
+    // ========== 关联查询字段(非表字段) ==========
+    /** 租户名称 */
+    private String tenantName;
+    /** 销售用户名称 */
+    private String userName;
+    /** 中间件名称 */
+    private String middlewareName;
+    /** 卡数量 */
+    private Integer cardCount;
+    /** 在线卡数量 */
+    private Integer onlineCardCount;
+}

+ 3 - 0
fs-service/src/main/java/com/fs/proxy/domain/Proxy.java

@@ -74,6 +74,9 @@ public class Proxy extends BaseEntity
     /** 备注 */
     private String remark;
 
+    /** 密码(登录用) */
+    private String password;
+
     /** 是否删除 */
     private Integer isDel;
 

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

@@ -0,0 +1,35 @@
+package com.fs.proxy.mapper;
+
+import com.fs.proxy.domain.CompanySmsDevice;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 设备管理 Mapper
+ */
+@Mapper
+public interface CompanySmsDeviceMapper {
+
+    List<CompanySmsDevice> selectDeviceList(CompanySmsDevice query);
+
+    CompanySmsDevice selectDeviceById(@Param("deviceId") Long deviceId);
+
+    CompanySmsDevice selectDeviceByImei(@Param("imei") String imei);
+
+    int insertDevice(CompanySmsDevice device);
+
+    int updateDevice(CompanySmsDevice device);
+
+    /** 分配销售: 设置 device.company_user_id */
+    int assignUser(@Param("deviceId") Long deviceId, @Param("companyUserId") Long companyUserId);
+
+    int deleteDeviceById(@Param("deviceId") Long deviceId);
+
+    /** 心跳更新: 按imei更新心跳时间+在线状态 */
+    int updateHeartbeat(@Param("imei") String imei, @Param("appVersion") String appVersion);
+
+    /** 批量更新离线状态(超过指定秒数无心跳的设为离线) */
+    int updateOfflineDevices(@Param("timeoutSeconds") int timeoutSeconds);
+}

+ 2 - 0
fs-service/src/main/java/com/fs/proxy/mapper/ProxyMapper.java

@@ -28,4 +28,6 @@ public interface ProxyMapper
     int deleteProxyByIds(Long[] proxyIds);
 
     int checkNameUnique(@Param("proxyName") String proxyName, @Param("proxyId") Long proxyId);
+
+    int resetPwd(@Param("proxyId") Long proxyId, @Param("password") String password);
 }

+ 17 - 2
fs-service/src/main/java/com/fs/proxy/service/ICompanySmsPortService.java

@@ -4,11 +4,12 @@ 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 com.fs.proxy.domain.CompanySmsDevice;
 
 import java.util.List;
 
 /**
- * 短信端口+卡管理+中间件 Service
+ * 短信端口+卡管理+中间件+设备管理 Service
  */
 public interface ICompanySmsPortService {
 
@@ -29,6 +30,20 @@ public interface ICompanySmsPortService {
     int updateAssign(CompanySmsPortAssign assign);
     int deleteAssignById(Long id);
 
+    // ========== 设备管理 ==========
+    List<CompanySmsDevice> selectDeviceList(CompanySmsDevice query);
+    CompanySmsDevice selectDeviceById(Long deviceId);
+    CompanySmsDevice selectDeviceByImei(String imei);
+    int insertDevice(CompanySmsDevice device);
+    int updateDevice(CompanySmsDevice device);
+    int deleteDeviceById(Long deviceId);
+    /** 分配设备给销售 */
+    int assignDeviceUser(Long deviceId, Long companyUserId);
+    /** 心跳上报(设备级) */
+    int deviceHeartbeat(String imei, String appVersion);
+    /** 批量更新离线设备(含其下卡) */
+    int updateOfflineDevices();
+
     // ========== 手机卡管理 ==========
     List<CompanySmsCard> selectCardList(CompanySmsCard query);
     CompanySmsCard selectCardById(Long cardId);
@@ -36,7 +51,7 @@ public interface ICompanySmsPortService {
     int insertCard(CompanySmsCard card);
     int updateCard(CompanySmsCard card);
     int deleteCardById(Long cardId);
-    /** 心跳上报 */
+    /** 心跳上报(兼容旧接口, 委托到deviceHeartbeat) */
     int heartbeat(String imei, String appVersion, String phone1, String phone2, String deviceName, Long tenantId);
     /** 批量更新离线卡 */
     int updateOfflineCards();

+ 2 - 0
fs-service/src/main/java/com/fs/proxy/service/ProxyService.java

@@ -27,4 +27,6 @@ public interface ProxyService
     int deleteProxyByIds(Long[] proxyIds);
 
     boolean checkNameUnique(Proxy proxy);
+
+    int resetPwd(Long proxyId, String password);
 }

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

@@ -25,6 +25,8 @@ public class CompanySmsPortServiceImpl implements ICompanySmsPortService {
     private CompanySmsCardMiddlewareMapper middlewareMapper;
     @Autowired
     private CompanySmsApiTenantMapper tenantMapper;
+    @Autowired
+    private CompanySmsDeviceMapper deviceMapper;
 
     // ========== 端口池 ==========
 
@@ -191,6 +193,84 @@ public class CompanySmsPortServiceImpl implements ICompanySmsPortService {
         return assignMapper.deleteAssignById(id);
     }
 
+    // ========== 设备管理 ==========
+
+    @Override
+    public List<CompanySmsDevice> selectDeviceList(CompanySmsDevice query) {
+        return deviceMapper.selectDeviceList(query);
+    }
+
+    @Override
+    public CompanySmsDevice selectDeviceById(Long deviceId) {
+        return deviceMapper.selectDeviceById(deviceId);
+    }
+
+    @Override
+    public CompanySmsDevice selectDeviceByImei(String imei) {
+        return deviceMapper.selectDeviceByImei(imei);
+    }
+
+    @Override
+    public int insertDevice(CompanySmsDevice device) {
+        return deviceMapper.insertDevice(device);
+    }
+
+    @Override
+    public int updateDevice(CompanySmsDevice device) {
+        return deviceMapper.updateDevice(device);
+    }
+
+    @Override
+    public int deleteDeviceById(Long deviceId) {
+        return deviceMapper.deleteDeviceById(deviceId);
+    }
+
+    @Override
+    public int assignDeviceUser(Long deviceId, Long companyUserId) {
+        return deviceMapper.assignUser(deviceId, companyUserId);
+    }
+
+    /**
+     * 设备心跳上报(新的核心方法):
+     * 1. 按imei查设备, 存在则更新心跳+状态为在线
+     * 2. 设备不存在则自动注册新设备
+     * 3. 设备下的卡同步更新状态为在线
+     */
+    @Override
+    public int deviceHeartbeat(String imei, String appVersion) {
+        CompanySmsDevice existing = deviceMapper.selectDeviceByImei(imei);
+        if (existing != null) {
+            // 设备存在: 更新心跳
+            int rows = deviceMapper.updateHeartbeat(imei, appVersion);
+            // 同步更新该设备下卡的状态为在线
+            cardMapper.updateHeartbeat(imei, appVersion);
+            return rows;
+        }
+        // 设备不存在: 自动注册(但此时不知道租户, 先设为null, adminUI后续可修改)
+        CompanySmsDevice device = new CompanySmsDevice();
+        device.setImei(imei);
+        device.setAppVersion(appVersion);
+        device.setDeviceName("未知设备");
+        device.setStatus(1); // 在线
+        device.setLastHeartbeat(new java.util.Date());
+        log.info("deviceHeartbeat: 自动注册新设备 imei={}", imei);
+        return deviceMapper.insertDevice(device);
+    }
+
+    /**
+     * 批量更新离线设备, 同时同步其下的卡为离线
+     */
+    @Override
+    public int updateOfflineDevices() {
+        // 先标记离线设备
+        int rows = deviceMapper.updateOfflineDevices(90);
+        // 同步: 离线设备下的卡也标记为离线
+        if (rows > 0) {
+            cardMapper.updateOfflineCards(90);
+        }
+        return rows;
+    }
+
     // ========== 手机卡管理 ==========
 
     @Override

+ 10 - 0
fs-service/src/main/java/com/fs/proxy/service/impl/ProxyServiceImpl.java

@@ -3,6 +3,7 @@ package com.fs.proxy.service.impl;
 import java.util.List;
 
 import com.fs.common.exception.ServiceException;
+import com.fs.common.utils.SecurityUtils;
 import com.fs.proxy.domain.Proxy;
 import com.fs.proxy.mapper.ProxyMapper;
 import com.fs.proxy.service.ProxyService;
@@ -43,6 +44,9 @@ public class ProxyServiceImpl implements ProxyService
         if (!checkNameUnique(proxy)) {
             throw new ServiceException("代理名称已存在");
         }
+        if (proxy.getPassword() != null && !proxy.getPassword().isEmpty()) {
+            proxy.setPassword(SecurityUtils.encryptPassword(proxy.getPassword()));
+        }
         return proxyMapper.insertProxy(proxy);
     }
 
@@ -73,4 +77,10 @@ public class ProxyServiceImpl implements ProxyService
         int count = proxyMapper.checkNameUnique(proxy.getProxyName(), proxyId);
         return count == 0;
     }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public int resetPwd(Long proxyId, String password) {
+        return proxyMapper.resetPwd(proxyId, SecurityUtils.encryptPassword(password));
+    }
 }

+ 1 - 1
fs-service/src/main/java/com/fs/sms/service/impl/SmsTServiceImpl.java

@@ -41,7 +41,7 @@ public class SmsTServiceImpl implements SmsService {
             ClientProfile clientProfile = new ClientProfile();
             clientProfile.setHttpProfile(httpProfile);
             SmsClient client = new SmsClient(cred, "ap-beijing", clientProfile);
-            String params = "{\"PhoneNumberSet\":[\"86" + mobile + "\"],\"TemplateID\":\"1982054\",\"Sign\":\"重庆润方数字科技有限公司\",\"TemplateParamSet\":[\"" + code + "\"],\"SmsSdkAppid\":\"1400867365\"}";
+            String params = "{\"PhoneNumberSet\":[\"86" + mobile + "\"],\"TemplateID\":\"1982054\",\"Sign\":\"重庆迈远科技有限公司\",\"TemplateParamSet\":[\"" + code + "\"],\"SmsSdkAppid\":\"1400867365\"}";
             SendSmsRequest req = SendSmsRequest.fromJsonString(params, SendSmsRequest.class);
 
             SendSmsResponse resp = client.SendSms(req);

+ 7 - 0
fs-service/src/main/java/com/fs/tenant/mapper/TenantInfoMapper.java

@@ -151,6 +151,13 @@ public interface TenantInfoMapper extends BaseMapper<TenantInfo> {
      * @return 影响行数
      */
     int updateBalance(@Param("id") Long id, @Param("amount") java.math.BigDecimal amount);
+
+    /**
+     * 重置租户管理员密码(sys_user表,user_id=1)
+     * @param password 加密后的密码
+     * @return 影响行数
+     */
+    int resetTenantAdminPwd(@Param("password") String password);
 }
 
 

+ 8 - 0
fs-service/src/main/java/com/fs/tenant/service/TenantInfoService.java

@@ -127,4 +127,12 @@ public interface TenantInfoService extends IService<TenantInfo> {
      * @return 影响行数
      */
     int updateBalance(Long id, BigDecimal amount);
+
+    /**
+     * 重置租户管理员密码
+     * @param id 租户ID
+     * @param password 新密码(明文,service层加密)
+     * @return 影响行数
+     */
+    int resetTenantAdminPwd(Long id, String password);
 }

+ 6 - 0
fs-service/src/main/java/com/fs/tenant/service/impl/TenantInfoServiceImpl.java

@@ -19,6 +19,7 @@ import com.fs.common.core.domain.entity.TenantCompanyMenu;
 import com.fs.common.enums.DataSourceType;
 import com.fs.common.exception.CustomException;
 import com.fs.common.utils.DateUtils;
+import com.fs.common.utils.SecurityUtils;
 import com.fs.common.utils.StringUtils;
 
 import com.fs.company.domain.CompanyVoiceRoboticCallLogCallphone;
@@ -679,6 +680,11 @@ public class TenantInfoServiceImpl extends ServiceImpl<TenantInfoMapper, TenantI
     public int updateBalance(Long id, BigDecimal amount) {
         return baseMapper.updateBalance(id, amount);
     }
+
+    @Override
+    public int resetTenantAdminPwd(Long id, String password) {
+        return baseMapper.resetTenantAdminPwd(SecurityUtils.encryptPassword(password));
+    }
 }
 
 

+ 9 - 3
fs-service/src/main/resources/mapper/proxy/CompanySmsCardMapper.xml

@@ -6,6 +6,8 @@
         <result property="cardId"        column="card_id"/>
         <result property="portId"        column="port_id"/>
         <result property="tenantId"      column="tenant_id"/>
+        <result property="deviceId"      column="device_id"/>
+        <result property="slotIndex"     column="slot_index"/>
         <result property="imei"          column="imei"/>
         <result property="deviceName"    column="device_name"/>
         <result property="simCount"      column="sim_count"/>
@@ -38,7 +40,7 @@
     </resultMap>
 
     <sql id="selectCardVo">
-        SELECT c.card_id, c.port_id, c.tenant_id, c.imei, c.device_name, c.sim_count,
+        SELECT c.card_id, c.port_id, c.tenant_id, c.device_id, c.slot_index, 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,
@@ -51,12 +53,14 @@
         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
+        LEFT JOIN company_sms_device d ON c.device_id = d.device_id
     </sql>
 
     <select id="selectCardList" resultMap="CardResult">
         <include refid="selectCardVo"/>
         <where>
             <if test="tenantId != null">AND c.tenant_id = #{tenantId}</if>
+            <if test="deviceId != null">AND c.device_id = #{deviceId}</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>
@@ -82,13 +86,13 @@
     </select>
 
     <insert id="insertCard" useGeneratedKeys="true" keyProperty="cardId">
-        INSERT INTO company_sms_card (port_id, tenant_id, imei, device_name, sim_count,
+        INSERT INTO company_sms_card (port_id, tenant_id, device_id, slot_index, 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},
+        VALUES (#{portId}, #{tenantId}, #{deviceId}, #{slotIndex}, #{imei}, #{deviceName}, #{simCount},
             #{phone1}, #{phone2}, #{lastHeartbeat}, #{status}, #{appVersion},
             #{smsHourlyLimit}, #{smsDailyLimit}, #{smsBalance},
             #{callIntervalSeconds}, #{callMinutesBalance}, #{phoneBillBalance},
@@ -101,6 +105,8 @@
         <set>
             <if test="portId != null">port_id = #{portId},</if>
             <if test="tenantId != null">tenant_id = #{tenantId},</if>
+            <if test="deviceId != null">device_id = #{deviceId},</if>
+            <if test="slotIndex != null">slot_index = #{slotIndex},</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>

+ 107 - 0
fs-service/src/main/resources/mapper/proxy/CompanySmsDeviceMapper.xml

@@ -0,0 +1,107 @@
+<?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.CompanySmsDeviceMapper">
+
+    <resultMap type="com.fs.proxy.domain.CompanySmsDevice" id="DeviceResult">
+        <result property="deviceId"       column="device_id"/>
+        <result property="tenantId"       column="tenant_id"/>
+        <result property="companyUserId"  column="company_user_id"/>
+        <result property="deviceName"     column="device_name"/>
+        <result property="imei"           column="imei"/>
+        <result property="appVersion"     column="app_version"/>
+        <result property="middlewareId"   column="middleware_id"/>
+        <result property="lastHeartbeat"  column="last_heartbeat"/>
+        <result property="status"         column="status"/>
+        <result property="remark"         column="remark"/>
+        <result property="createTime"     column="create_time"/>
+        <result property="updateTime"     column="update_time"/>
+        <result property="tenantName"     column="tenant_name"/>
+        <result property="userName"       column="user_name"/>
+        <result property="middlewareName" column="middleware_name"/>
+        <result property="cardCount"      column="card_count"/>
+        <result property="onlineCardCount" column="online_card_count"/>
+    </resultMap>
+
+    <sql id="selectDeviceVo">
+        SELECT d.device_id, d.tenant_id, d.company_user_id, d.device_name, d.imei,
+               d.app_version, d.middleware_id, d.last_heartbeat, d.status,
+               d.remark, d.create_time, d.update_time,
+               ti.tenant_name,
+               u.user_name,
+               m.middleware_name,
+               (SELECT COUNT(1) FROM company_sms_card c WHERE c.device_id = d.device_id) AS card_count,
+               (SELECT COUNT(1) FROM company_sms_card c WHERE c.device_id = d.device_id AND c.status = 1) AS online_card_count
+        FROM company_sms_device d
+        LEFT JOIN tenant_info ti ON d.tenant_id = ti.id
+        LEFT JOIN sys_user u ON d.company_user_id = u.user_id
+        LEFT JOIN company_sms_card_middleware m ON d.middleware_id = m.id
+    </sql>
+
+    <select id="selectDeviceList" resultMap="DeviceResult">
+        <include refid="selectDeviceVo"/>
+        <where>
+            <if test="tenantId != null">AND d.tenant_id = #{tenantId}</if>
+            <if test="companyUserId != null">AND d.company_user_id = #{companyUserId}</if>
+            <if test="imei != null and imei != ''">AND d.imei LIKE CONCAT('%', #{imei}, '%')</if>
+            <if test="deviceName != null and deviceName != ''">AND d.device_name LIKE CONCAT('%', #{deviceName}, '%')</if>
+            <if test="status != null">AND d.status = #{status}</if>
+        </where>
+        ORDER BY d.device_id DESC
+    </select>
+
+    <select id="selectDeviceById" resultMap="DeviceResult">
+        <include refid="selectDeviceVo"/>
+        WHERE d.device_id = #{deviceId}
+    </select>
+
+    <select id="selectDeviceByImei" resultMap="DeviceResult">
+        <include refid="selectDeviceVo"/>
+        WHERE d.imei = #{imei}
+    </select>
+
+    <insert id="insertDevice" useGeneratedKeys="true" keyProperty="deviceId">
+        INSERT INTO company_sms_device (tenant_id, company_user_id, device_name, imei,
+            app_version, middleware_id, last_heartbeat, status, remark, create_time)
+        VALUES (#{tenantId}, #{companyUserId}, #{deviceName}, #{imei},
+            #{appVersion}, #{middlewareId}, #{lastHeartbeat}, #{status}, #{remark}, NOW())
+    </insert>
+
+    <update id="updateDevice">
+        UPDATE company_sms_device
+        <set>
+            <if test="tenantId != null">tenant_id = #{tenantId},</if>
+            <if test="companyUserId != null">company_user_id = #{companyUserId},</if>
+            <if test="deviceName != null">device_name = #{deviceName},</if>
+            <if test="appVersion != null">app_version = #{appVersion},</if>
+            <if test="middlewareId != null">middleware_id = #{middlewareId},</if>
+            <if test="status != null">status = #{status},</if>
+            <if test="remark != null">remark = #{remark},</if>
+            update_time = NOW()
+        </set>
+        WHERE device_id = #{deviceId}
+    </update>
+
+    <update id="assignUser">
+        UPDATE company_sms_device SET company_user_id = #{companyUserId}, update_time = NOW()
+        WHERE device_id = #{deviceId}
+    </update>
+
+    <delete id="deleteDeviceById">
+        DELETE FROM company_sms_device WHERE device_id = #{deviceId}
+    </delete>
+
+    <!-- 心跳更新: 按imei更新 -->
+    <update id="updateHeartbeat">
+        UPDATE company_sms_device
+        SET last_heartbeat = NOW(), status = 1
+            <if test="appVersion != null">, app_version = #{appVersion}</if>
+        WHERE imei = #{imei}
+    </update>
+
+    <!-- 批量更新离线: 90秒无心跳 -->
+    <update id="updateOfflineDevices">
+        UPDATE company_sms_device SET status = 0
+        WHERE status = 1 AND last_heartbeat &lt; DATE_SUB(NOW(), INTERVAL #{timeoutSeconds} SECOND)
+    </update>
+
+</mapper>

+ 8 - 1
fs-service/src/main/resources/mapper/proxy/ProxyMapper.xml

@@ -24,7 +24,7 @@
     </resultMap>
 
     <sql id="selectVo">
-        select proxy_id, proxy_name, contact_name, contact_mobile, email, status,
+        select proxy_id, proxy_name, password, contact_name, contact_mobile, email, status,
                balance, frozen_amount, profit_share_ratio, account_fee,
                open_time, expire_time, remark, is_del, create_time, create_by, update_time, update_by
         from proxy
@@ -56,6 +56,7 @@
         insert into proxy
         <trim prefix="(" suffix=")" suffixOverrides=",">
             <if test="proxyName != null">proxy_name,</if>
+            <if test="password != null">password,</if>
             <if test="contactName != null">contact_name,</if>
             <if test="contactMobile != null">contact_mobile,</if>
             <if test="email != null">email,</if>
@@ -72,6 +73,7 @@
         </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="proxyName != null">#{proxyName},</if>
+            <if test="password != null">#{password},</if>
             <if test="contactName != null">#{contactName},</if>
             <if test="contactMobile != null">#{contactMobile},</if>
             <if test="email != null">#{email},</if>
@@ -92,6 +94,7 @@
         update proxy
         <set>
             <if test="proxyName != null">proxy_name = #{proxyName},</if>
+            <if test="password != null">password = #{password},</if>
             <if test="contactName != null">contact_name = #{contactName},</if>
             <if test="contactMobile != null">contact_mobile = #{contactMobile},</if>
             <if test="email != null">email = #{email},</if>
@@ -125,4 +128,8 @@
         <if test="proxyId != null and proxyId > 0">and proxy_id != #{proxyId}</if>
     </select>
 
+    <update id="resetPwd">
+        update proxy set password = #{password}, update_time = now() where proxy_id = #{proxyId}
+    </update>
+
 </mapper>

+ 5 - 0
fs-service/src/main/resources/mapper/tenant/TenantInfoMapper.xml

@@ -619,4 +619,9 @@
         UPDATE tenant_info SET balance = balance + #{amount}, update_time = NOW()
         WHERE id = #{id} AND (balance + #{amount}) >= 0
     </update>
+
+    <update id="resetTenantAdminPwd">
+        UPDATE sys_user SET password = #{password}, update_time = NOW()
+        WHERE user_id = 1
+    </update>
 </mapper>

+ 11 - 0
sql/add_sms_card_menu.sql

@@ -0,0 +1,11 @@
+-- =====================================================
+-- adminUI 总后台菜单补充 - 卡管理(smsCard)
+-- 挂载到 通信管理(2400) 分组下,order_num=12
+-- =====================================================
+
+-- 卡管理 (menu_id=2412)
+INSERT INTO fs_menu (menu_id, menu_name, parent_id, order_num, path, component, menu_type, icon, visible, status, is_frame, is_cache, create_by, create_time, remark)
+VALUES (2412, '卡管理', 2400, 12, 'smsCard', 'admin/smsCard/index', 'C', 'el-icon-postcard', '0', '0', 0, 0, 'admin', NOW(), '手机卡管理');
+
+-- 将新菜单关联到admin角色(role_id=1)
+INSERT INTO fs_role_menu (role_id, menu_id) VALUES (1, 2412);

+ 93 - 0
sql/add_sms_device.sql

@@ -0,0 +1,93 @@
+-- ============================================================
+-- 设备管理改造 - 数据库变更脚本
+-- 1. 新建 company_sms_device 表
+-- 2. company_sms_card 新增 device_id + slot_index
+-- 3. 数据迁移: 从 card 表提取设备数据
+-- 4. company_sms_card_middleware 新增 tenant_id
+-- ============================================================
+
+-- ========== 1. 新建设备表 ==========
+CREATE TABLE IF NOT EXISTS `company_sms_device` (
+  `device_id`        BIGINT NOT NULL AUTO_INCREMENT COMMENT '设备ID',
+  `tenant_id`        BIGINT NOT NULL COMMENT '所属租户',
+  `company_user_id`  BIGINT DEFAULT NULL COMMENT '绑定销售用户ID(NULL=未分配)',
+  `device_name`      VARCHAR(100) DEFAULT NULL COMMENT '设备名称',
+  `imei`             VARCHAR(50) NOT NULL COMMENT 'IMEI(唯一标识)',
+  `app_version`      VARCHAR(30) DEFAULT NULL COMMENT 'APP版本',
+  `middleware_id`    BIGINT DEFAULT NULL COMMENT '关联中间件ID',
+  `last_heartbeat`   DATETIME DEFAULT NULL COMMENT '最后心跳时间',
+  `status`           TINYINT DEFAULT 0 COMMENT '0离线/1在线/2禁用',
+  `remark`           VARCHAR(500) DEFAULT NULL COMMENT '备注',
+  `create_time`      DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_time`      DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`device_id`),
+  UNIQUE KEY `uk_imei` (`imei`),
+  KEY `idx_tenant_id` (`tenant_id`),
+  KEY `idx_company_user_id` (`company_user_id`),
+  KEY `idx_middleware_id` (`middleware_id`),
+  KEY `idx_status` (`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='手机设备管理';
+
+-- ========== 2. card 表新增 device 关联字段 ==========
+-- 使用存储过程检测列是否存在再ALTER
+DROP PROCEDURE IF EXISTS add_card_device_columns;
+DELIMITER //
+CREATE PROCEDURE add_card_device_columns()
+BEGIN
+  IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
+                 WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'company_sms_card' AND COLUMN_NAME = 'device_id') THEN
+    ALTER TABLE company_sms_card ADD COLUMN device_id BIGINT DEFAULT NULL COMMENT '所属设备ID' AFTER tenant_id;
+  END IF;
+  IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
+                 WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'company_sms_card' AND COLUMN_NAME = 'slot_index') THEN
+    ALTER TABLE company_sms_card ADD COLUMN slot_index TINYINT DEFAULT 1 COMMENT '卡槽(1或2)' AFTER device_id;
+  END IF;
+  IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
+                 WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'company_sms_card' AND INDEX_NAME = 'idx_device_id') THEN
+    ALTER TABLE company_sms_card ADD INDEX idx_device_id (device_id);
+  END IF;
+END //
+DELIMITER ;
+CALL add_card_device_columns();
+DROP PROCEDURE add_card_device_columns;
+
+-- ========== 3. 数据迁移:从 card 表提取设备 ==========
+-- 按 imei 去重,每个唯一 imei 对应一台设备
+INSERT INTO company_sms_device (tenant_id, device_name, imei, app_version, last_heartbeat, status, create_time)
+SELECT c.tenant_id, MAX(c.device_name), c.imei, MAX(c.app_version), MAX(c.last_heartbeat), MAX(c.status), MIN(c.create_time)
+FROM company_sms_card c
+WHERE c.imei IS NOT NULL AND c.imei != ''
+  AND NOT EXISTS (SELECT 1 FROM company_sms_device d WHERE d.imei = c.imei)
+GROUP BY c.tenant_id, c.imei;
+
+-- 回填 card.device_id
+UPDATE company_sms_card c
+INNER JOIN company_sms_device d ON c.imei = d.imei AND c.tenant_id = d.tenant_id
+SET c.device_id = d.device_id, c.slot_index = 1
+WHERE c.device_id IS NULL;
+
+-- 同一设备下第二张卡(phone2不为空且phone1不等于phone2)的slot_index设为2
+UPDATE company_sms_card c
+INNER JOIN company_sms_device d ON c.device_id = d.device_id
+SET c.slot_index = 2
+WHERE c.device_id IS NOT NULL
+  AND c.phone_2 IS NOT NULL AND c.phone_2 != ''
+  AND c.phone_1 IS NOT NULL AND c.phone_1 != c.phone_2
+  AND c.card_id > (
+    SELECT MIN(c2.card_id) FROM (SELECT card_id, device_id FROM company_sms_card) c2
+    WHERE c2.device_id = c.device_id
+  );
+
+-- ========== 4. 中间件表新增 tenant_id ==========
+DROP PROCEDURE IF EXISTS add_middleware_tenant_id;
+DELIMITER //
+CREATE PROCEDURE add_middleware_tenant_id()
+BEGIN
+  IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
+                 WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'company_sms_card_middleware' AND COLUMN_NAME = 'tenant_id') THEN
+    ALTER TABLE company_sms_card_middleware ADD COLUMN tenant_id BIGINT DEFAULT NULL COMMENT '所属租户' AFTER api_id;
+  END IF;
+END //
+DELIMITER ;
+CALL add_middleware_tenant_id();
+DROP PROCEDURE add_middleware_tenant_id;

+ 26 - 0
sql/add_sms_device_menu.sql

@@ -0,0 +1,26 @@
+-- =====================================================
+-- adminUI 总后台菜单补充 - 设备管理(smsDevice)
+-- 挂载到 通信管理(2400) 分组下,order_num=11(在卡管理之前)
+-- =====================================================
+
+-- 设备管理 (menu_id=2413)
+INSERT INTO fs_menu (menu_id, menu_name, parent_id, order_num, path, component, menu_type, icon, visible, status, is_frame, is_cache, create_by, create_time, remark)
+VALUES (2413, '设备管理', 2400, 11, 'smsDevice', 'admin/smsDevice/index', 'C', 'el-icon-mobile-phone', '0', '0', 0, 0, 'admin', NOW(), '手机设备管理');
+
+-- 设备管理按钮权限
+INSERT INTO fs_menu (menu_id, menu_name, parent_id, order_num, path, component, menu_type, perms, icon, visible, status, is_frame, is_cache, create_by, create_time)
+VALUES
+(24131, '设备查询', 2413, 1, '', '', 'F', 'platform:smsDevice:list',   '#', '0', '0', 0, 0, 'admin', NOW()),
+(24132, '设备详情', 2413, 2, '', '', 'F', 'platform:smsDevice:query',  '#', '0', '0', 0, 0, 'admin', NOW()),
+(24133, '设备新增', 2413, 3, '', '', 'F', 'platform:smsDevice:add',    '#', '0', '0', 0, 0, 'admin', NOW()),
+(24134, '设备修改', 2413, 4, '', '', 'F', 'platform:smsDevice:edit',   '#', '0', '0', 0, 0, 'admin', NOW()),
+(24135, '设备删除', 2413, 5, '', '', 'F', 'platform:smsDevice:remove', '#', '0', '0', 0, 0, 'admin', NOW());
+
+-- 将新菜单关联到admin角色(role_id=1)
+INSERT INTO fs_role_menu (role_id, menu_id) VALUES
+(1, 2413),
+(1, 24131),
+(1, 24132),
+(1, 24133),
+(1, 24134),
+(1, 24135);

+ 50 - 0
sql/add_voice_pricing.sql

@@ -0,0 +1,50 @@
+-- ============================================================
+-- 外呼接口改造 DDL
+-- 1. company_voice_api 新增 provider + cost_price
+-- 2. company_voice_api_tenant 新增 price + priority + is_primary + allow_manual
+-- ============================================================
+
+-- ========== 1. voice_api 新增字段 ==========
+-- provider 用存储过程检测列是否存在
+DROP PROCEDURE IF EXISTS add_voice_api_columns;
+DELIMITER //
+CREATE PROCEDURE add_voice_api_columns()
+BEGIN
+  IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
+                 WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'company_voice_api' AND COLUMN_NAME = 'provider') THEN
+    ALTER TABLE company_voice_api ADD COLUMN provider VARCHAR(20) DEFAULT 'platform' COMMENT '服务商:platform平台/card手机卡' AFTER api_type;
+  END IF;
+  IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
+                 WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'company_voice_api' AND COLUMN_NAME = 'cost_price') THEN
+    ALTER TABLE company_voice_api ADD COLUMN cost_price DECIMAL(10,4) DEFAULT NULL COMMENT '平台成本价(元/分钟)' AFTER status;
+  END IF;
+END //
+DELIMITER ;
+CALL add_voice_api_columns();
+DROP PROCEDURE add_voice_api_columns;
+
+-- ========== 2. voice_api_tenant 新增字段 ==========
+DROP PROCEDURE IF EXISTS add_voice_api_tenant_columns;
+DELIMITER //
+CREATE PROCEDURE add_voice_api_tenant_columns()
+BEGIN
+  IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
+                 WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'company_voice_api_tenant' AND COLUMN_NAME = 'price') THEN
+    ALTER TABLE company_voice_api_tenant ADD COLUMN price DECIMAL(10,4) DEFAULT NULL COMMENT '租户售价(元/分钟)' AFTER company_id;
+  END IF;
+  IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
+                 WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'company_voice_api_tenant' AND COLUMN_NAME = 'priority') THEN
+    ALTER TABLE company_voice_api_tenant ADD COLUMN priority INT DEFAULT 1 COMMENT '优先级(1最高)' AFTER price;
+  END IF;
+  IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
+                 WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'company_voice_api_tenant' AND COLUMN_NAME = 'is_primary') THEN
+    ALTER TABLE company_voice_api_tenant ADD COLUMN is_primary TINYINT DEFAULT 0 COMMENT '是否主线路(0否1是)' AFTER priority;
+  END IF;
+  IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
+                 WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'company_voice_api_tenant' AND COLUMN_NAME = 'allow_manual') THEN
+    ALTER TABLE company_voice_api_tenant ADD COLUMN allow_manual TINYINT DEFAULT 0 COMMENT '允许销售手动选择(0否1是)' AFTER is_primary;
+  END IF;
+END //
+DELIMITER ;
+CALL add_voice_api_tenant_columns();
+DROP PROCEDURE add_voice_api_tenant_columns;