peicj 1 deň pred
rodič
commit
5a0deb6d1a
18 zmenil súbory, kde vykonal 775 pridanie a 282 odobranie
  1. 69 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/aspect/ApiClientIpAspect.java
  2. 6 79
      ruoyi-admin/src/main/java/com/ruoyi/aicall/controller/ApiController.java
  3. 32 9
      ruoyi-admin/src/main/java/com/ruoyi/aicall/controller/XfVoiceCloneController.java
  4. 10 1
      ruoyi-admin/src/main/java/com/ruoyi/cc/controller/CcGatewaysController.java
  5. 35 14
      ruoyi-admin/src/main/java/com/ruoyi/cc/controller/CcIvrController.java
  6. 15 2
      ruoyi-admin/src/main/java/com/ruoyi/cc/controller/CcParamsController.java
  7. 108 7
      ruoyi-admin/src/main/java/com/ruoyi/cc/controller/FsConfController.java
  8. 33 22
      ruoyi-admin/src/main/java/com/ruoyi/cc/controller/RecordingFileController.java
  9. 2 1
      ruoyi-admin/src/main/java/com/ruoyi/cc/service/IFsConfService.java
  10. 14 12
      ruoyi-admin/src/main/java/com/ruoyi/cc/service/impl/CcGatewaysServiceImpl.java
  11. 166 114
      ruoyi-admin/src/main/java/com/ruoyi/cc/service/impl/FsConfServiceImpl.java
  12. 87 0
      ruoyi-admin/src/main/java/com/ruoyi/cc/utils/FsConfPathUtils.java
  13. 36 0
      ruoyi-admin/src/main/java/com/ruoyi/cc/utils/FsEslNameUtils.java
  14. 129 0
      ruoyi-admin/src/main/java/com/ruoyi/cc/utils/RecordingPathUtils.java
  15. 22 18
      ruoyi-admin/src/main/java/com/ruoyi/cc/utils/ShellUtil.java
  16. 1 1
      ruoyi-admin/src/main/resources/templates/cc/ivr/ivr.html
  17. 5 1
      ruoyi-admin/src/main/resources/templates/cc/licenseconf/licenseconf.html
  18. 5 1
      ruoyi-admin/src/main/resources/templates/cc/switchconf/switchconf.html

+ 69 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/aspect/ApiClientIpAspect.java

@@ -0,0 +1,69 @@
+package com.ruoyi.aicall.aspect;
+
+import com.alibaba.fastjson.JSONObject;
+import com.ruoyi.aicall.utils.ClientIpCheck;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.core.page.TableDataInfo;
+import lombok.extern.slf4j.Slf4j;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.Around;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * ApiController IP whitelist check (api-client-white-ips)
+ */
+@Slf4j
+@Aspect
+@Order(1)
+@Component
+public class ApiClientIpAspect {
+
+    private static final String NO_AUTH_MSG = "未授权,请联系系统管理员添加ip白名单!";
+
+    @Around("execution(* com.ruoyi.aicall.controller.ApiController.*(..))")
+    public Object checkClientIp(ProceedingJoinPoint pjp) throws Throwable {
+        HttpServletRequest request = resolveRequest(pjp.getArgs());
+        if (request != null && !ClientIpCheck.checkIp(request)) {
+            Class<?> returnType = ((MethodSignature) pjp.getSignature()).getReturnType();
+            return buildDeniedResult(returnType);
+        }
+        return pjp.proceed();
+    }
+
+    private HttpServletRequest resolveRequest(Object[] args) {
+        if (args != null) {
+            for (Object arg : args) {
+                if (arg instanceof HttpServletRequest) {
+                    return (HttpServletRequest) arg;
+                }
+            }
+        }
+        ServletRequestAttributes attrs =
+                (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
+        return attrs == null ? null : attrs.getRequest();
+    }
+
+    private Object buildDeniedResult(Class<?> returnType) {
+        if (TableDataInfo.class.isAssignableFrom(returnType)) {
+            TableDataInfo tableDataInfo = new TableDataInfo();
+            tableDataInfo.setTotal(0);
+            tableDataInfo.setCode(AjaxResult.Type.NO_AUTH.value());
+            tableDataInfo.setMsg(NO_AUTH_MSG);
+            return tableDataInfo;
+        }
+        if (JSONObject.class.isAssignableFrom(returnType)) {
+            JSONObject result = new JSONObject();
+            result.put("code", AjaxResult.Type.NO_AUTH.value());
+            result.put("msg", NO_AUTH_MSG);
+            return result;
+        }
+        return AjaxResult.error(AjaxResult.Type.NO_AUTH, NO_AUTH_MSG, "");
+    }
+}

+ 6 - 79
ruoyi-admin/src/main/java/com/ruoyi/aicall/controller/ApiController.java

@@ -6,7 +6,6 @@ import com.alibaba.fastjson.JSONObject;
 import com.ruoyi.aicall.domain.*;
 import com.ruoyi.aicall.model.*;
 import com.ruoyi.aicall.service.*;
-import com.ruoyi.aicall.utils.ClientIpCheck;
 import com.ruoyi.aicall.utils.DESUtil;
 import com.ruoyi.cc.domain.*;
 import com.ruoyi.cc.service.*;
@@ -35,12 +34,17 @@ import org.springframework.util.CollectionUtils;
 import org.springframework.web.bind.annotation.*;
 
 import javax.servlet.http.HttpServletRequest;
-import java.io.UnsupportedEncodingException;
 import java.net.URLDecoder;
 import java.net.URLEncoder;
 import java.util.*;
 import java.util.stream.Collectors;
 
+/**
+ * 对外开放 API。
+ * <p>IP 白名单统一由 AOP 校验,勿在本类方法内重复校验:
+ * {@link com.ruoyi.aicall.aspect.ApiClientIpAspect}
+ * (参数码:api-client-white-ips)</p>
+ */
 @Controller
 @Slf4j
 @RequestMapping("/aicall/api")
@@ -95,10 +99,6 @@ public class ApiController extends BaseController {
     @GetMapping("/gateway/list")
     @ResponseBody
     public AjaxResult getGatewayList(HttpServletRequest req, @RequestParam(value = "purposes", required = false) String purposes){
-        // 校验客户端ip是否在白名单内
-        if (!ClientIpCheck.checkIp(req)) {
-            return AjaxResult.error(AjaxResult.Type.NO_AUTH, "未授权,请联系系统管理员添加ip白名单!", "");
-        }
         // 获取外呼网关列表
         Map<String, Object> params = new HashMap<>();
         if (StringUtils.isBlank(purposes)) {
@@ -124,10 +124,6 @@ public class ApiController extends BaseController {
     @GetMapping("/llmacount/list")
     @ResponseBody
     public AjaxResult getLlmAcountList(HttpServletRequest req){
-        // 校验客户端ip是否在白名单内
-        if (!ClientIpCheck.checkIp(req)) {
-            return AjaxResult.error(AjaxResult.Type.NO_AUTH, "未授权,请联系系统管理员添加ip白名单!", "");
-        }
         // 获取大模型列表
         List<CcLlmAgentAccount> list = ccLlmAgentAccountService.selectCcLlmAgentAccountList(new CcLlmAgentAccount());
         return AjaxResult.success(list);
@@ -141,10 +137,6 @@ public class ApiController extends BaseController {
     @ResponseBody
     public AjaxResult getVoiceCodeList(HttpServletRequest req)
     {
-        // 校验客户端ip是否在白名单内
-        if (!ClientIpCheck.checkIp(req)) {
-            return AjaxResult.error(AjaxResult.Type.NO_AUTH, "未授权,请联系系统管理员添加ip白名单!", "");
-        }
         // 获取音色列表
         List<CcTtsAliyun> list = ccTtsAliyunService.selectCcTtsAliyunList(new CcTtsAliyun());
         JSONArray result = new JSONArray();
@@ -168,10 +160,6 @@ public class ApiController extends BaseController {
     @GetMapping("/busigroup/list")
     @ResponseBody
     public AjaxResult getBusigroupList(HttpServletRequest req){
-//        // 校验客户端ip是否在白名单内
-//        if (!ClientIpCheck.checkIp(req)) {
-//            return AjaxResult.error(AjaxResult.Type.NO_AUTH, "未授权,请联系系统管理员添加ip白名单!", "");
-//        }
         // 获取技能组列表
         List<CcBizGroup> list = ccBizGroupService.selectCcBizGroupList(new CcBizGroup());
         return AjaxResult.success(list);
@@ -204,14 +192,6 @@ public class ApiController extends BaseController {
     public TableDataInfo getCallTaskList(HttpServletRequest req, @RequestBody ApiCallTaskQueryParams queryParams)
     {
         TableDataInfo tableDataInfo;
-        // 校验请求方ip是否合法
-        if (!ClientIpCheck.checkIp(req)) {
-            tableDataInfo = new TableDataInfo();
-            tableDataInfo.setTotal(0);
-            tableDataInfo.setCode(AjaxResult.Type.NO_AUTH.value());
-            tableDataInfo.setMsg("未授权,请联系系统管理员添加ip白名单!");
-            return tableDataInfo;
-        }
         // 处理分页
         if (null == queryParams.getPageNum()) {
             queryParams.setPageNum(1);
@@ -288,10 +268,6 @@ public class ApiController extends BaseController {
     @ResponseBody
     public AjaxResult getRecordByUuid(HttpServletRequest req, @RequestParam String uuid, @RequestParam String callType)
     {
-        // 校验客户端ip是否在白名单内
-        if (!ClientIpCheck.checkIp(req)) {
-            return AjaxResult.error(AjaxResult.Type.NO_AUTH, "未授权,请联系系统管理员添加ip白名单!", "");
-        }
         if (StringUtils.isBlank(callType)) {
             return AjaxResult.error(AjaxResult.Type.INVALID_PARAM, "callType不能为空!", "");
 
@@ -324,14 +300,6 @@ public class ApiController extends BaseController {
     public TableDataInfo getRecordsList(HttpServletRequest req, @RequestBody ApiCallRecordQueryParams queryParams)
     {
         TableDataInfo tableDataInfo;
-        // 校验请求方ip是否合法
-        if (!ClientIpCheck.checkIp(req)) {
-            tableDataInfo = new TableDataInfo();
-            tableDataInfo.setTotal(0);
-            tableDataInfo.setCode(AjaxResult.Type.NO_AUTH.value());
-            tableDataInfo.setMsg("未授权,请联系系统管理员添加ip白名单!");
-            return tableDataInfo;
-        }
         // 分页参数处理
         if (null == queryParams.getPageNum()
                 && null == queryParams.getPageSize()) {
@@ -396,10 +364,6 @@ public class ApiController extends BaseController {
     @PostMapping("/ai/createTask")
     @ResponseBody
     public AjaxResult createCallTask(HttpServletRequest req, @RequestBody ApiCallTaskModel apiCallTaskModel) {
-        // 校验ip白名单
-        if (!ClientIpCheck.checkIp(req)) {
-            return AjaxResult.error(AjaxResult.Type.NO_AUTH, "未授权,请联系系统管理员添加ip白名单!", "");
-        }
         CcCallTask ccCallTask = new CcCallTask();
         // 校验参数
         // 任务名称不能为空
@@ -486,10 +450,6 @@ public class ApiController extends BaseController {
     @GetMapping("/ai/startTask")
     @ResponseBody
     public AjaxResult startTask(HttpServletRequest req, @RequestParam("batchId") Long batchId) {
-        // 校验ip白名单
-        if (!ClientIpCheck.checkIp(req)) {
-            return AjaxResult.error(AjaxResult.Type.NO_AUTH, "未授权,请联系系统管理员添加ip白名单", "");
-        }
         // 启动任务
         CcCallTask ccCallTask = ccCallTaskService.selectCcCallTaskByBatchId(batchId);
         if (null == ccCallTask) {
@@ -512,10 +472,6 @@ public class ApiController extends BaseController {
     @ResponseBody
     public AjaxResult stopTask(HttpServletRequest req, @RequestParam("batchId") Long batchId)
     {
-        // 校验ip白名单
-        if (!ClientIpCheck.checkIp(req)) {
-            return AjaxResult.error(AjaxResult.Type.NO_AUTH, "未授权,请联系系统管理员添加ip白名单", "");
-        }
         // 停止任务
         CcCallTask ccCallTask = ccCallTaskService.selectCcCallTaskByBatchId(batchId);
         if (null == ccCallTask) {
@@ -537,9 +493,6 @@ public class ApiController extends BaseController {
     @PostMapping("/ai/addCallList")
     @ResponseBody
     public AjaxResult addAiCallList(HttpServletRequest req, @RequestBody AiCallListModel aiCallListModel) {
-        if (!ClientIpCheck.checkIp(req)) {
-            return AjaxResult.error(AjaxResult.Type.NO_AUTH, "未授权,请联系系统管理员添加ip白名单", "");
-        }
         Long batchId = aiCallListModel.getBatchId();
         if (null == batchId) {
             return AjaxResult.error(AjaxResult.Type.INVALID_PARAM, "参数batchId不能为空", "");
@@ -589,9 +542,6 @@ public class ApiController extends BaseController {
     @PostMapping("/common/addCallList")
     @ResponseBody
     public AjaxResult addCommonCallList(HttpServletRequest req, @RequestBody CommonCallListModel commonCallListModel) {
-        if (!ClientIpCheck.checkIp(req)) {
-            return AjaxResult.error(AjaxResult.Type.NO_AUTH, "未授权,请联系系统管理员添加ip白名单", "");
-        }
         Long batchId = commonCallListModel.getBatchId();
         if (null == batchId) {
             return AjaxResult.error(AjaxResult.Type.INVALID_PARAM, "参数batchId不能为空", "");
@@ -647,9 +597,6 @@ public class ApiController extends BaseController {
     @PostMapping("/notice/call")
     @ResponseBody
     public AjaxResult callNotice(HttpServletRequest req, @RequestBody NoticeCallModel noticeCallModel) {
-        if (!ClientIpCheck.checkIp(req)) {
-            return AjaxResult.error(AjaxResult.Type.NO_AUTH, "未授权,请联系系统管理员添加ip白名单", "");
-        }
         // 获取任务
         String batchName = paramsService.getParamValueByCode("testNoticeCallTaskName", "test");
         String phoneNum = paramsService.getParamValueByCode("testNoticeCallPhoneNum", "13908113506");
@@ -1033,9 +980,6 @@ public class ApiController extends BaseController {
     @PostMapping("/local/addCall")
     @ResponseBody
     public AjaxResult addLocalCall(HttpServletRequest req, @RequestBody LocalCallModel localCallModel) {
-//        if (!ClientIpCheck.checkIp(req)) {
-//            return AjaxResult.error(AjaxResult.Type.NO_AUTH, "未授权,请联系系统管理员添加ip白名单", "");
-//        }
         // 获取任务
         String batchName = localCallModel.getBatchName();
         if (StringUtils.isEmpty(batchName)) {
@@ -1637,14 +1581,6 @@ public class ApiController extends BaseController {
     public TableDataInfo getcallPhoneRecords(HttpServletRequest req, @RequestBody ApiCallRecordQueryParams queryParams)
     {
         TableDataInfo tableDataInfo;
-        // 校验请求方ip是否合法
-        if (!ClientIpCheck.checkIp(req)) {
-            tableDataInfo = new TableDataInfo();
-            tableDataInfo.setTotal(0);
-            tableDataInfo.setCode(AjaxResult.Type.NO_AUTH.value());
-            tableDataInfo.setMsg("未授权,请联系系统管理员添加ip白名单!");
-            return tableDataInfo;
-        }
         // 分页参数处理
         if (null == queryParams.getPageNum()
                 && null == queryParams.getPageSize()) {
@@ -1822,15 +1758,6 @@ public class ApiController extends BaseController {
     @PostMapping("/gateway/myList")
     @ResponseBody
     public TableDataInfo getGatewayMyList(@RequestBody CcGateways queryParams, HttpServletRequest req){
-        // 校验请求方ip是否合法
-        if (!ClientIpCheck.checkIp(req)) {
-            TableDataInfo tableDataInfo;
-            tableDataInfo = new TableDataInfo();
-            tableDataInfo.setTotal(0);
-            tableDataInfo.setCode(AjaxResult.Type.NO_AUTH.value());
-            tableDataInfo.setMsg("未授权,请联系系统管理员添加ip白名单!");
-            return tableDataInfo;
-        }
         startPage(queryParams.getPageNum(), queryParams.getPageSize());
         return getDataTable(ccGatewaysService.selectCcGatewaysList(queryParams));
     }

+ 32 - 9
ruoyi-admin/src/main/java/com/ruoyi/aicall/controller/XfVoiceCloneController.java

@@ -553,18 +553,26 @@ public class XfVoiceCloneController extends BaseController {
     private void reportDebugEvent(String hypothesisId, String location, String msg, JSONObject data) {
         HttpURLConnection connection = null;
         try {
-            String debugUrl = "http://127.0.0.1:7777/event";
-            String sessionId = "xfvoiceclone-403";
             Path envPath = Paths.get(System.getProperty("user.dir"), ".dbg", "xfvoiceclone-403.env");
-            if (Files.exists(envPath)) {
-                for (String line : Files.readAllLines(envPath, StandardCharsets.UTF_8)) {
-                    if (line.startsWith("DEBUG_SERVER_URL=")) {
-                        debugUrl = line.substring("DEBUG_SERVER_URL=".length()).trim();
-                    } else if (line.startsWith("DEBUG_SESSION_ID=")) {
-                        sessionId = line.substring("DEBUG_SESSION_ID=".length()).trim();
-                    }
+            // Production-safe default: no env file => no outbound debug traffic
+            if (!Files.exists(envPath)) {
+                return;
+            }
+            String debugUrl = null;
+            String sessionId = "xfvoiceclone-403";
+            boolean enabled = false;
+            for (String line : Files.readAllLines(envPath, StandardCharsets.UTF_8)) {
+                if (line.startsWith("DEBUG_ENABLED=")) {
+                    enabled = "true".equalsIgnoreCase(line.substring("DEBUG_ENABLED=".length()).trim());
+                } else if (line.startsWith("DEBUG_SERVER_URL=")) {
+                    debugUrl = line.substring("DEBUG_SERVER_URL=".length()).trim();
+                } else if (line.startsWith("DEBUG_SESSION_ID=")) {
+                    sessionId = line.substring("DEBUG_SESSION_ID=".length()).trim();
                 }
             }
+            if (!enabled || StringUtils.isBlank(debugUrl) || !isLocalDebugUrl(debugUrl)) {
+                return;
+            }
             JSONObject payload = new JSONObject(true);
             payload.put("sessionId", sessionId);
             payload.put("runId", "pre-fix");
@@ -595,6 +603,21 @@ public class XfVoiceCloneController extends BaseController {
         }
     }
 
+    /** Only allow loopback debug collectors to reduce SSRF risk. */
+    private boolean isLocalDebugUrl(String debugUrl) {
+        try {
+            URL url = new URL(debugUrl);
+            String protocol = url.getProtocol();
+            if (!"http".equalsIgnoreCase(protocol) && !"https".equalsIgnoreCase(protocol)) {
+                return false;
+            }
+            String host = url.getHost();
+            return "127.0.0.1".equals(host) || "localhost".equalsIgnoreCase(host);
+        } catch (Exception e) {
+            return false;
+        }
+    }
+
     private String safeReadResponseBody(Response response) {
         if (response == null || response.body() == null) {
             return "";

+ 10 - 1
ruoyi-admin/src/main/java/com/ruoyi/cc/controller/CcGatewaysController.java

@@ -2,10 +2,10 @@ package com.ruoyi.cc.controller;
 
 import java.util.*;
 
-import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
 import com.ruoyi.cc.model.FsConfProfile;
 import com.ruoyi.cc.service.IFsConfService;
+import com.ruoyi.cc.utils.FsEslNameUtils;
 import com.ruoyi.common.utils.StringUtils;
 import link.thingscloud.freeswitch.esl.EslConnectionUtil;
 import link.thingscloud.freeswitch.esl.transport.message.EslMessage;
@@ -83,6 +83,7 @@ public class CcGatewaysController extends BaseController
     /**
      * 新增线路配置
      */
+    @RequiresPermissions("cc:gateways:add")
     @GetMapping("/add")
     public String add(ModelMap mmap)
     {
@@ -100,6 +101,10 @@ public class CcGatewaysController extends BaseController
     @ResponseBody
     public AjaxResult addSave(CcGateways ccGateways)
     {
+        if (!FsEslNameUtils.isSafeName(ccGateways.getProfileName())
+                || !FsEslNameUtils.isSafeName(ccGateways.getGwName())) {
+            return AjaxResult.error("profileName或gwName参数不合法");
+        }
 
         if (null != ccGateways.getRegister() && ccGateways.getRegister() == 0) {
             ccGateways.setAuthUsername("");
@@ -197,6 +202,10 @@ public class CcGatewaysController extends BaseController
     @ResponseBody
     public AjaxResult editSave(CcGateways ccGateways)
     {
+        if (!FsEslNameUtils.isSafeName(ccGateways.getProfileName())
+                || !FsEslNameUtils.isSafeName(ccGateways.getGwName())) {
+            return AjaxResult.error("profileName或gwName参数不合法");
+        }
         if (null != ccGateways.getRegister() && ccGateways.getRegister() == 0) {
             ccGateways.setAuthUsername("");
             ccGateways.setAuthPassword("");

+ 35 - 14
ruoyi-admin/src/main/java/com/ruoyi/cc/controller/CcIvrController.java

@@ -4,18 +4,14 @@ import java.io.File;
 import java.util.*;
 
 import com.alibaba.fastjson.JSONObject;
-import com.ruoyi.aicall.domain.CcCallTask;
 import com.ruoyi.aicall.tts.aliyun.AliyunTTSWebApi;
 import com.ruoyi.aicall.tts.doubao.DoubaoTTSWebApi;
 import com.ruoyi.cc.service.ICcParamsService;
-import com.ruoyi.common.core.domain.entity.SysMenu;
 import com.ruoyi.common.utils.DateUtils;
 import com.ruoyi.common.utils.ExceptionUtil;
 import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.common.utils.StringUtils;
-import com.ruoyi.common.utils.file.FileUtils;
 import com.ruoyi.common.utils.uuid.UuidGenerator;
-import com.ruoyi.framework.shiro.util.AuthorizationUtils;
 import org.apache.commons.io.FilenameUtils;
 import org.apache.commons.lang3.RandomStringUtils;
 import org.apache.shiro.authz.annotation.RequiresPermissions;
@@ -23,16 +19,18 @@ import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Controller;
 import org.springframework.ui.ModelMap;
 import org.springframework.web.bind.annotation.*;
+import com.ruoyi.cc.utils.RecordingPathUtils;
 import com.ruoyi.common.annotation.Log;
 import com.ruoyi.common.enums.BusinessType;
 import com.ruoyi.cc.domain.CcIvr;
 import com.ruoyi.cc.service.ICcIvrService;
 import com.ruoyi.common.core.controller.BaseController;
 import com.ruoyi.common.core.domain.AjaxResult;
-import com.ruoyi.common.utils.poi.ExcelUtil;
-import com.ruoyi.common.core.page.TableDataInfo;
+import org.apache.shiro.authz.annotation.Logical;
 import org.springframework.web.multipart.MultipartFile;
 
+import java.nio.file.Path;
+
 /**
  * IVR配置Controller
  * 
@@ -72,6 +70,7 @@ public class CcIvrController extends BaseController
     /**
      * 新增IVR配置
      */
+    @RequiresPermissions("cc:ivr:add")
     @GetMapping("/add/{parentId}")
     public String add(@PathVariable("parentId") String parentId, ModelMap mmap)
     {
@@ -219,6 +218,10 @@ public class CcIvrController extends BaseController
         return toAjax(ccIvrService.deleteCcIvrById(id));
     }
 
+    /**
+     * 上传 IVR 语音(目录限制在 recording_path/{rootId}/ 下)
+     */
+    @RequiresPermissions(value = {"cc:ivr:add", "cc:ivr:edit", "aicall:account:add", "aicall:account:edit"}, logical = Logical.OR)
     @PostMapping("/uploadVoice")
     @ResponseBody
     public AjaxResult uploadVoice(@RequestParam("file") MultipartFile file,
@@ -238,17 +241,23 @@ public class CcIvrController extends BaseController
                 return AjaxResult.error("文件大小不能超过50MB");
             }
 
+            String recordingPath = ccParamsService.getParamValueByCode("recording_path", "/home/Records/");
             String fileName = DateUtils.format(new Date(), "yyyyMMddHHmmssSSS") + RandomStringUtils.random(3, false, true) + ".wav";
-            String absolutePath = ccParamsService.getParamValueByCode("recording_path", "/home/Records/") + rootId + "/";
+            Path destFilePath = RecordingPathUtils.resolveRootFile(recordingPath, rootId, fileName);
+            if (destFilePath == null) {
+                return AjaxResult.error("rootId参数不合法");
+            }
 
-            File destPath = new File(absolutePath);
-            destPath.mkdirs();
-            File destFile = new File(absolutePath + fileName);
+            File destFile = destFilePath.toFile();
+            File destPath = destFile.getParentFile();
+            if (destPath != null) {
+                destPath.mkdirs();
+            }
             file.transferTo(destFile);
 
             Map<String, String> result = new HashMap<>();
             result.put("fileUrl", "recordings/files?filename=" + rootId + "/" + fileName);
-            result.put("filePath", absolutePath  + fileName);
+            result.put("filePath", destFile.getAbsolutePath());
             result.put("originalName", file.getOriginalFilename());
             result.put("fileSize", String.valueOf(file.getSize()));
 
@@ -282,6 +291,9 @@ public class CcIvrController extends BaseController
     }
 
 
+    /**
+     * IVR 根节点列表(外呼任务/呼入 AI 等页面也会调用,仅需登录,不加 ivr 菜单权限以免打断现有流程)
+     */
     @GetMapping("/all")
     @ResponseBody
     public AjaxResult all()
@@ -291,6 +303,7 @@ public class CcIvrController extends BaseController
     }
 
 
+    @RequiresPermissions("cc:ivr:edit")
     @GetMapping("/reload")
     @ResponseBody
     public AjaxResult reload()
@@ -312,6 +325,10 @@ public class CcIvrController extends BaseController
     }
 
 
+    /**
+     * TTS 合成并写入 recording_path/{rootId}/(仅 wav,目录做 containment)
+     */
+    @RequiresPermissions(value = {"cc:ivr:add", "cc:ivr:edit"}, logical = Logical.OR)
     @GetMapping("/tts")
     @ResponseBody
     public AjaxResult tts(@RequestParam("rootId") String rootId,
@@ -336,13 +353,17 @@ public class CcIvrController extends BaseController
                 && StringUtils.isBlank(voiceCode)) {
             return AjaxResult.error("请选择音色!");
         }
+        String recordingPath = ccParamsService.getParamValueByCode("recording_path", "/home/Records/");
         String fileName = DateUtils.format(new Date(), "yyyyMMddHHmmssSSS") + RandomStringUtils.random(3, false, true) + ".wav";
-        String fileDirName = ccParamsService.getParamValueByCode("recording_path", "/home/Records/") + rootId + "/";
-        File fileDir = new File(fileDirName);
+        Path ttsFilePath = RecordingPathUtils.resolveRootFile(recordingPath, rootId, fileName);
+        if (ttsFilePath == null) {
+            return AjaxResult.error("rootId参数不合法");
+        }
+        File fileDir = ttsFilePath.getParent().toFile();
         if (!fileDir.exists()) {
             fileDir.mkdirs();
         }
-        String ttsPath = fileDirName + fileName;
+        String ttsPath = ttsFilePath.toAbsolutePath().toString();
 
         if ("aliyun_tts".equals(voiceSource)) {
             String aliyunTtsAccountJson = ccParamsService.getParamValueByCode("aliyun-tts-account-json", "{}");

+ 15 - 2
ruoyi-admin/src/main/java/com/ruoyi/cc/controller/CcParamsController.java

@@ -6,7 +6,6 @@ import com.ruoyi.cc.domain.CcParams;
 import com.ruoyi.cc.service.ICcParamsService;
 import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.common.utils.StringUtils;
-import com.ruoyi.common.utils.http.HttpUtils;
 import org.apache.shiro.authz.annotation.RequiresPermissions;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Controller;
@@ -146,6 +145,10 @@ public class CcParamsController extends BaseController
     }
 
 
+    /**
+     * 前端按码读取参数(仅白名单,避免泄露命令/凭据类配置)
+     * 当前页面使用:IVR tts_content_variables
+     */
     @GetMapping( "/getByParamCode")
     @ResponseBody
     public AjaxResult getByParamCode(@RequestParam(value = "paramCode") String paramCode, @RequestParam(value = "defaultValue", required = false) String defaultValue)
@@ -153,7 +156,17 @@ public class CcParamsController extends BaseController
         if (StringUtils.isBlank(defaultValue)) {
             defaultValue = "";
         }
-
+        if (!isPublicReadableParamCode(paramCode)) {
+            return AjaxResult.error("无权读取该参数");
+        }
         return AjaxResult.success("", ccParamsService.getParamValueByCode(paramCode, defaultValue));
     }
+
+    private boolean isPublicReadableParamCode(String paramCode) {
+        if (StringUtils.isBlank(paramCode)) {
+            return false;
+        }
+        // Only codes that frontend pages are known to need via this open endpoint
+        return "tts_content_variables".equals(paramCode);
+    }
 }

+ 108 - 7
ruoyi-admin/src/main/java/com/ruoyi/cc/controller/FsConfController.java

@@ -4,25 +4,24 @@ import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
 import com.ruoyi.aicall.domain.CcCallPhone;
 import com.ruoyi.aicall.service.ICcCallPhoneService;
-import com.ruoyi.aicall.service.ICcInboundLlmAccountService;
 import com.ruoyi.cc.domain.CcInboundCdr;
 import com.ruoyi.cc.domain.CcOutboundCdr;
-import com.ruoyi.cc.domain.CcParams;
 import com.ruoyi.cc.model.FsConfProfile;
 import com.ruoyi.cc.model.FsMod;
 import com.ruoyi.cc.model.ProfileRegExtnumModel;
 import com.ruoyi.cc.model.ProfileStatusModel;
 import com.ruoyi.cc.service.*;
+import com.ruoyi.cc.utils.FsEslNameUtils;
 import com.ruoyi.common.core.controller.BaseController;
 import com.ruoyi.common.core.domain.AjaxResult;
 import com.ruoyi.common.core.page.TableDataInfo;
 import com.ruoyi.common.utils.CommonUtils;
 import com.ruoyi.common.utils.DateUtils;
 import com.ruoyi.common.utils.StringUtils;
-import com.ruoyi.framework.web.domain.server.Sys;
 import link.thingscloud.freeswitch.esl.EslConnectionUtil;
 import link.thingscloud.freeswitch.esl.transport.message.EslMessage;
 import org.apache.shiro.authz.annotation.RequiresPermissions;
+import org.apache.shiro.authz.annotation.Logical;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Controller;
 import org.springframework.ui.ModelMap;
@@ -87,6 +86,7 @@ public class FsConfController extends BaseController {
      * @param params
      * @return
      */
+    @RequiresPermissions("cc:switchconf:view")
     @PostMapping(value = "/setSwitchConf")
     @ResponseBody
     public AjaxResult setSwitchConf(@RequestBody JSONArray params) {
@@ -98,10 +98,14 @@ public class FsConfController extends BaseController {
      * 重启fs服务
      * @return
      */
+    @RequiresPermissions("cc:switchconf:view")
     @GetMapping(value = "/restartFs")
     @ResponseBody
     public AjaxResult restartFs() {
-        fsConfService.restartFs();
+        String err = fsConfService.restartFs();
+        if (StringUtils.isNotEmpty(err)) {
+            return AjaxResult.error(err);
+        }
         return AjaxResult.success("重启成功!");
     }
 
@@ -109,6 +113,7 @@ public class FsConfController extends BaseController {
      * 获取系统全局配置
      * @return
      */
+    @RequiresPermissions("cc:switchconf:view")
     @GetMapping(value = "/getSwitchConf")
     @ResponseBody
     public AjaxResult getSwitchConf() {
@@ -155,6 +160,7 @@ public class FsConfController extends BaseController {
      * @param params
      * @return
      */
+    @RequiresPermissions("cc:varsconf:view")
     @PostMapping(value = "/setVarsConf")
     @ResponseBody
     public AjaxResult setVarsConf(@RequestBody JSONArray params) {
@@ -167,6 +173,7 @@ public class FsConfController extends BaseController {
      * 获取全局编码配置
      * @return
      */
+    @RequiresPermissions("cc:varsconf:view")
     @GetMapping(value = "/getVarsConf")
     @ResponseBody
     public AjaxResult getVarsConf() {
@@ -260,6 +267,7 @@ public class FsConfController extends BaseController {
      * 获取阿里云tts配置
      * @return
      */
+    @RequiresPermissions("cc:alittsconf:view")
     @GetMapping(value = "/getAliTtsConf")
     @ResponseBody
     public AjaxResult getAliTtsConf() {
@@ -271,6 +279,7 @@ public class FsConfController extends BaseController {
      * 获取讯飞tts配置
      * @return
      */
+    @RequiresPermissions("cc:xfttsconf:view")
     @GetMapping(value = "/getXfTtsConf")
     @ResponseBody
     public AjaxResult getXfTtsConf() {
@@ -282,6 +291,7 @@ public class FsConfController extends BaseController {
      * 获取腾讯云tts配置
      * @return
      */
+    @RequiresPermissions("cc:txtts1conf:view")
     @GetMapping(value = "/getTxTts1Conf")
     @ResponseBody
     public AjaxResult getTxTts1Conf() {
@@ -293,6 +303,7 @@ public class FsConfController extends BaseController {
      * 获取ViiTorTTS配置
      * @return
      */
+    @RequiresPermissions("cc:vtttsconf:view")
     @GetMapping(value = "/getVtTtsConf")
     @ResponseBody
     public AjaxResult getVtTtsConf() {
@@ -314,6 +325,7 @@ public class FsConfController extends BaseController {
      * 获取豆包tts配置
      * @return
      */
+    @RequiresPermissions("cc:doubaottsconf:view")
     @GetMapping(value = "/getDoubaoTtsConf")
     @ResponseBody
     public AjaxResult getDoubaoTtsConf() {
@@ -326,6 +338,7 @@ public class FsConfController extends BaseController {
      * @param params
      * @return
      */
+    @RequiresPermissions("cc:doubaottsconf:view")
     @PostMapping(value = "/setDoubaoTtsConf")
     @ResponseBody
     public AjaxResult setDoubaoTtsConf(@RequestBody JSONArray params) {
@@ -381,6 +394,7 @@ public class FsConfController extends BaseController {
      * 获取ASR配置
      * @return
      */
+    @RequiresPermissions("cc:aliasrconf:view")
     @GetMapping(value = "/getAliAsrConf")
     @ResponseBody
     public AjaxResult getAliAsrConf() {
@@ -392,6 +406,7 @@ public class FsConfController extends BaseController {
      * 获取新 ASR 模块配置
      * @return
      */
+    @RequiresPermissions("cc:aliasrbridgeconf:view")
     @GetMapping(value = "/getAliBridgeAsrConf")
     @ResponseBody
     public AjaxResult getAliBridgeAsrConf() {
@@ -403,6 +418,7 @@ public class FsConfController extends BaseController {
      * 获取腾讯 ASR 模块配置
      * @return
      */
+    @RequiresPermissions("cc:txasrbridgeconf:view")
     @GetMapping(value = "/getTxBridgeAsrConf")
     @ResponseBody
     public AjaxResult getTxBridgeAsrConf() {
@@ -414,6 +430,7 @@ public class FsConfController extends BaseController {
      * 获取腾讯 ASR1 模块配置
      * @return
      */
+    @RequiresPermissions("cc:txasr1bridgeconf:view")
     @GetMapping(value = "/getTxBridgeAsr1Conf")
     @ResponseBody
     public AjaxResult getTxBridgeAsr1Conf() {
@@ -425,6 +442,7 @@ public class FsConfController extends BaseController {
      * 获取FunASR配置
      * @return
      */
+    @RequiresPermissions("cc:funasrconf:view")
     @GetMapping(value = "/getFunAsrConf")
     @ResponseBody
     public AjaxResult getFunAsrConf() {
@@ -434,16 +452,19 @@ public class FsConfController extends BaseController {
 
 
 
+    @RequiresPermissions("cc:aliasrconf:view")
     @GetMapping(value = "/chinatelecomasrconf")
     public String chinatelecomAsrConf() {
         return "cc/chinatelecomasrconf/chinatelecomasrconf";
     }
 
+    @RequiresPermissions("cc:alittsconf:view")
     @GetMapping(value = "/chinatelecomttsconf")
     public String chinatelecomTtsConf() {
         return "cc/chinatelecomttsconf/chinatelecomttsconf";
     }
 
+    @RequiresPermissions("cc:aliasrconf:view")
     @GetMapping(value = "/getChinatelecomAsrConf")
     @ResponseBody
     public AjaxResult getChinatelecomAsrConf() {
@@ -451,6 +472,7 @@ public class FsConfController extends BaseController {
         return getConfigFileJsonData(asrFileName, 5);
     }
 
+    @RequiresPermissions("cc:alittsconf:view")
     @GetMapping(value = "/getChinatelecomTtsConf")
     @ResponseBody
     public AjaxResult getChinatelecomTtsConf() {
@@ -462,6 +484,7 @@ public class FsConfController extends BaseController {
      * 获取ASR配置
      * @return
      */
+    @RequiresPermissions("cc:xunfeiasrconf:view")
     @GetMapping(value = "/getXunfeiAsrConf")
     @ResponseBody
     public AjaxResult getXunfeiAsrConf() {
@@ -609,6 +632,7 @@ public class FsConfController extends BaseController {
      * @param params
      * @return
      */
+    @RequiresPermissions("cc:aliasrconf:view")
     @PostMapping(value = "/setAliAsrConf")
     @ResponseBody
     public AjaxResult setAliAsrConf(@RequestBody JSONArray params) {        
@@ -622,6 +646,7 @@ public class FsConfController extends BaseController {
      * @param params
      * @return
      */
+    @RequiresPermissions("cc:aliasrbridgeconf:view")
     @PostMapping(value = "/setAliBridgeAsrConf")
     @ResponseBody
     public AjaxResult setAliBridgeAsrConf(@RequestBody JSONArray params) {
@@ -635,6 +660,7 @@ public class FsConfController extends BaseController {
      * @param params
      * @return
      */
+    @RequiresPermissions("cc:txasrbridgeconf:view")
     @PostMapping(value = "/setTxBridgeAsrConf")
     @ResponseBody
     public AjaxResult setTxBridgeAsrConf(@RequestBody JSONArray params) {
@@ -648,6 +674,7 @@ public class FsConfController extends BaseController {
      * @param params
      * @return
      */
+    @RequiresPermissions("cc:txasr1bridgeconf:view")
     @PostMapping(value = "/setTxBridgeAsr1Conf")
     @ResponseBody
     public AjaxResult setTxBridgeAsr1Conf(@RequestBody JSONArray params) {
@@ -661,6 +688,7 @@ public class FsConfController extends BaseController {
      * @param params
      * @return
      */
+    @RequiresPermissions("cc:aliasrconf:view")
     @PostMapping(value = "/setChinatelecomAsrConf")
     @ResponseBody
     public AjaxResult setChinatelecomAsrConf(@RequestBody JSONArray params) {       
@@ -674,6 +702,7 @@ public class FsConfController extends BaseController {
      * @param params
      * @return
      */
+    @RequiresPermissions("cc:alittsconf:view")
     @PostMapping(value = "/setAliTtsConf")
     @ResponseBody
     public AjaxResult setAliTtsConf(@RequestBody JSONArray params) {
@@ -689,6 +718,7 @@ public class FsConfController extends BaseController {
      * @param params
      * @return
      */
+    @RequiresPermissions("cc:xfttsconf:view")
     @PostMapping(value = "/setXfTtsConf")
     @ResponseBody
     public AjaxResult setXfTtsConf(@RequestBody JSONArray params) {
@@ -703,6 +733,7 @@ public class FsConfController extends BaseController {
      * @param params
      * @return
      */
+    @RequiresPermissions("cc:txtts1conf:view")
     @PostMapping(value = "/setTxTts1Conf")
     @ResponseBody
     public AjaxResult setTxTts1Conf(@RequestBody JSONArray params) {
@@ -717,6 +748,7 @@ public class FsConfController extends BaseController {
      * @param params
      * @return
      */
+    @RequiresPermissions("cc:vtttsconf:view")
     @PostMapping(value = "/setVtTtsConf")
     @ResponseBody
     public AjaxResult setVtTtsConf(@RequestBody JSONArray params) {
@@ -731,6 +763,7 @@ public class FsConfController extends BaseController {
      * @param params
      * @return
      */
+    @RequiresPermissions("cc:alittsconf:view")
     @PostMapping(value = "/setChinatelecomTtsConf")
     @ResponseBody
     public AjaxResult setChinatelecomTtsConf(@RequestBody JSONArray params) {
@@ -745,6 +778,7 @@ public class FsConfController extends BaseController {
      * @param params
      * @return
      */
+    @RequiresPermissions("cc:xunfeiasrconf:view")
     @PostMapping(value = "/setXunfeiAsrConf")
     @ResponseBody
     public AjaxResult setXunfeiAsrConf(@RequestBody JSONArray params) {       
@@ -758,6 +792,7 @@ public class FsConfController extends BaseController {
      * @param params
      * @return
      */
+    @RequiresPermissions("cc:funasrconf:view")
     @PostMapping(value = "/setFunAsrConf")
     @ResponseBody
     public AjaxResult setFunAsrConf(@RequestBody JSONArray params) {
@@ -787,6 +822,7 @@ public class FsConfController extends BaseController {
      * @param asrengine
      * @return
      */
+    @RequiresPermissions("cc:asrengine:view")
     @GetMapping(value = "/setAsrengine")
     @ResponseBody
     public AjaxResult setAsrengine(@RequestParam String asrengine) {
@@ -825,6 +861,7 @@ public class FsConfController extends BaseController {
      * 获取证书配置
      * @return
      */
+    @RequiresPermissions("cc:certwsspen:view")
     @GetMapping(value = "/getCertWssPen")
     @ResponseBody
     public AjaxResult getCertWssPen() {
@@ -838,6 +875,7 @@ public class FsConfController extends BaseController {
      * @param params
      * @return
      */
+    @RequiresPermissions("cc:certwsspen:view")
     @PostMapping(value = "/setCertWssPen")
     @ResponseBody
     public AjaxResult setCertWssPen(@RequestBody JSONObject params) {
@@ -855,16 +893,22 @@ public class FsConfController extends BaseController {
     @RequiresPermissions("cc:licenseconf:view")
     @GetMapping(value = "/licenseconf")
     public String licenseconf() {
+        if (!getSysUser().isAdmin()) {
+            return "error/unauth";
+        }
         return "cc/licenseconf/licenseconf";
     }
 
     /**
-     * 获取授权配置
+     * 获取授权配置(仅超级管理员)
      * @return
      */
     @GetMapping(value = "/getLicenseConf")
     @ResponseBody
     public AjaxResult getLicenseConf() {
+        if (!getSysUser().isAdmin()) {
+            return AjaxResult.error("权限不足");
+        }
         JSONObject result = new JSONObject();
         result.put("fingerprintValue", fsConfService.getFingerprintValue());
         result.put("licenseValue", fsConfService.getLicenseValue());
@@ -873,13 +917,16 @@ public class FsConfController extends BaseController {
     }
 
     /**
-     * 保存授权配置
+     * 保存授权配置(仅超级管理员)
      * @param params
      * @return
      */
     @PostMapping(value = "/setLicenseConf")
     @ResponseBody
     public AjaxResult setLicenseConf(@RequestBody JSONObject params) {
+        if (!getSysUser().isAdmin()) {
+            return AjaxResult.error("权限不足");
+        }
         fsConfService.setLicenseConf(params.getString("licenseValue"));
         return AjaxResult.success("设置成功!");
     }
@@ -904,6 +951,7 @@ public class FsConfController extends BaseController {
      * 获取日志
      * @return
      */
+    @RequiresPermissions("cc:catlogs:view")
     @GetMapping(value = "/getLogs")
     @ResponseBody
     public AjaxResult getLogs(@RequestParam String uuid) {
@@ -911,6 +959,9 @@ public class FsConfController extends BaseController {
             return AjaxResult.error("请输入正确的uuid");
         }
         uuid = uuid.trim();
+        if (!FsEslNameUtils.isSafeLogQueryId(uuid)) {
+            return AjaxResult.error("请输入正确的uuid");
+        }
         JSONObject result = new JSONObject();
 //        result.put("fsLogs", "这是freeswitch日志:\r\n19:21:38.255 [schedule-pool-3] INFO  c.r.f.s.w.s.OnlineWebSessionManager - [validateSessions,100] - invalidation sessions...\r\n19:21:38.281 [schedule-pool-3] DEBUG c.r.s.m.S.selectOnlineByExpired - [debug,135] - ==>  Preparing: select sessionId, login_name, dept_name, ipaddr, login_location, browser, os, status, start_timestamp, last_access_time, expire_time from sys_user_online o WHERE o.last_access_time <= ? ORDER BY o.last_access_time ASC\r\n19:21:38.283 [schedule-pool-3] DEBUG c.r.s.m.S.selectOnlineByExpired - [debug,135] - ==> Parameters: 2024-12-22 18:51:38(String)\r\n19:21:38.312 [schedule-pool-3] DEBUG c.r.s.m.S.selectOnlineByExpired - [debug,135] - <==      Total: 0\r\n19:21:38.313 [schedule-pool-3] INFO  c.r.f.s.w.s.OnlineWebSessionManager - [validateSessions,165] - Finished invalidation session. No sessions were stopped.\r\n");
 //        result.put("ccLogs", "这是callcenter日志:\r\n19:21:38.255 [schedule-pool-3] INFO  c.r.f.s.w.s.OnlineWebSessionManager - [validateSessions,100] - invalidation sessions...\r\n19:21:38.281 [schedule-pool-3] DEBUG c.r.s.m.S.selectOnlineByExpired - [debug,135] - ==>  Preparing: select sessionId, login_name, dept_name, ipaddr, login_location, browser, os, status, start_timestamp, last_access_time, expire_time from sys_user_online o WHERE o.last_access_time <= ? ORDER BY o.last_access_time ASC\r\n19:21:38.283 [schedule-pool-3] DEBUG c.r.s.m.S.selectOnlineByExpired - [debug,135] - ==> Parameters: 2024-12-22 18:51:38(String)\r\n19:21:38.312 [schedule-pool-3] DEBUG c.r.s.m.S.selectOnlineByExpired - [debug,135] - <==      Total: 0\r\n19:21:38.313 [schedule-pool-3] INFO  c.r.f.s.w.s.OnlineWebSessionManager - [validateSessions,165] - Finished invalidation session. No sessions were stopped.\r\n");
@@ -957,6 +1008,7 @@ public class FsConfController extends BaseController {
      * 获取日志
      * @return
      */
+    @RequiresPermissions("cc:catlogs:view")
     @GetMapping(value = "/downloadLogs")
     @ResponseBody
     public void downloadLogs(@RequestParam String uuid, HttpServletResponse response) {
@@ -971,7 +1023,15 @@ public class FsConfController extends BaseController {
         }
 
         uuid = uuid.trim();
-
+        if (!FsEslNameUtils.isSafeLogQueryId(uuid)) {
+            try {
+                response.setContentType("application/json;charset=UTF-8");
+                response.getWriter().write("{\"code\":500,\"msg\":\"请输入正确的uuid\"}");
+            } catch (IOException e) {
+                logger.error("写入错误响应失败", e);
+            }
+            return;
+        }
         // 获取日志内容
         String fsLogFiles = ccParamsService.getParamValueByCode("fs_log_file_path", "");
         String fsLogs = fsConfService.getLogs(uuid, fsLogFiles, "cc");
@@ -1091,6 +1151,10 @@ public class FsConfController extends BaseController {
         return getDataTable(list);
     }
 
+    /**
+     * 保存 profile 配置
+     */
+    @RequiresPermissions(value = {"cc:profileconf:add", "cc:profileconf:edit"}, logical = Logical.OR)
     @PostMapping(value = "/setProfileConf")
     @ResponseBody
     public AjaxResult setProfileConf(@RequestBody JSONArray params) {
@@ -1118,6 +1182,12 @@ public class FsConfController extends BaseController {
         logger.info("profileName:" + profileName);
         logger.info("profileType:" + profileType);
         logger.info("operaType:" + operaType);
+        if (!FsEslNameUtils.isSafeName(profileName)) {
+            return AjaxResult.error("profileName参数不合法");
+        }
+        if (StringUtils.isNotEmpty(profileType) && !FsEslNameUtils.isSafeName(profileType)) {
+            return AjaxResult.error("profileType参数不合法");
+        }
         String saveSuccess = fsConfService.setProfileConf(profileName, profileType, xmlParams);
         if(!StringUtils.isEmpty(saveSuccess)){
             return AjaxResult.error(saveSuccess);
@@ -1138,6 +1208,7 @@ public class FsConfController extends BaseController {
         return AjaxResult.success("设置成功!");
     }
 
+    @RequiresPermissions(value = {"cc:profileconf:view", "cc:profileconf:add", "cc:profileconf:edit"}, logical = Logical.OR)
     @GetMapping(value = "/getProfileConf")
     @ResponseBody
     public AjaxResult getProfileConf(@RequestParam(required = false) String profileName, @RequestParam(required = false)  String profileType) {
@@ -1167,9 +1238,13 @@ public class FsConfController extends BaseController {
         return AjaxResult.success("success", result);
     }
 
+    @RequiresPermissions("cc:profileconf:status")
     @GetMapping(value = "/getProfileStatus")
     @ResponseBody
     public AjaxResult getProfileStatus(@RequestParam String profileName) {
+        if (!FsEslNameUtils.isSafeName(profileName)) {
+            return AjaxResult.error("profileName参数不合法");
+        }
         JSONObject result = new JSONObject();
         EslMessage eslMessage = EslConnectionUtil.sendSyncApiCommand("sofia", "xmlstatus");
         if (null != eslMessage) {
@@ -1233,9 +1308,13 @@ public class FsConfController extends BaseController {
         return list;
     }
 
+    @RequiresPermissions("cc:profileconf:start")
     @GetMapping(value = "/startProfile")
     @ResponseBody
     public AjaxResult startProfile(@RequestParam String profileName) {
+        if (!FsEslNameUtils.isSafeName(profileName)) {
+            return AjaxResult.error("profileName参数不合法");
+        }
         JSONObject result = new JSONObject();
         EslMessage eslMessage = EslConnectionUtil.sendSyncApiCommand("sofia", "profile " + profileName + " start");
         if (null != eslMessage) {
@@ -1246,9 +1325,13 @@ public class FsConfController extends BaseController {
         return AjaxResult.success("success", result);
     }
 
+    @RequiresPermissions("cc:profileconf:stop")
     @GetMapping(value = "/stopProfile")
     @ResponseBody
     public AjaxResult stopProfile(@RequestParam String profileName) {
+        if (!FsEslNameUtils.isSafeName(profileName)) {
+            return AjaxResult.error("profileName参数不合法");
+        }
         JSONObject result = new JSONObject();
         EslMessage eslMessage = EslConnectionUtil.sendSyncApiCommand("sofia", "profile " + profileName + " stop");
         if (null != eslMessage) {
@@ -1259,9 +1342,13 @@ public class FsConfController extends BaseController {
         return AjaxResult.success("success", result);
     }
 
+    @RequiresPermissions("cc:profileconf:restart")
     @GetMapping(value = "/restartProfile")
     @ResponseBody
     public AjaxResult restartProfile(@RequestParam String profileName) {
+        if (!FsEslNameUtils.isSafeName(profileName)) {
+            return AjaxResult.error("profileName参数不合法");
+        }
         JSONObject result = new JSONObject();
         EslMessage eslMessage = EslConnectionUtil.sendSyncApiCommand("sofia", "profile " + profileName + " restart");
         if (null != eslMessage) {
@@ -1273,9 +1360,13 @@ public class FsConfController extends BaseController {
     }
 
 
+    @RequiresPermissions("cc:profileconf:status")
     @GetMapping(value = "/getExtnumList")
     @ResponseBody
     public AjaxResult getExtnumList(@RequestParam String profileName) {
+        if (!FsEslNameUtils.isSafeName(profileName)) {
+            return AjaxResult.error("profileName参数不合法");
+        }
         JSONObject result = new JSONObject();
         EslMessage eslMessage = EslConnectionUtil.sendSyncApiCommand("sofia", "xmlstatus profile " + profileName + " reg");
         if (null != eslMessage) {
@@ -1343,6 +1434,7 @@ public class FsConfController extends BaseController {
      * ASR(亚马逊)参数配置
      * @return
      */
+    @RequiresPermissions("cc:awsasrconf:view")
     @GetMapping(value = "/awsasrconf")
     public String awsAsrConf() {
         return "cc/awsasrconf/awsasrconf";
@@ -1352,6 +1444,7 @@ public class FsConfController extends BaseController {
      * 获取亚马逊ASR配置
      * @return
      */
+    @RequiresPermissions("cc:awsasrconf:view")
     @GetMapping(value = "/getAwsAsrConf")
     @ResponseBody
     public AjaxResult getAwsAsrConf() {
@@ -1364,6 +1457,7 @@ public class FsConfController extends BaseController {
      * @param params
      * @return
      */
+    @RequiresPermissions("cc:awsasrconf:view")
     @PostMapping(value = "/setAwsAsrConf")
     @ResponseBody
     public AjaxResult setAwsAsrConf(@RequestBody JSONArray params) {
@@ -1387,6 +1481,7 @@ public class FsConfController extends BaseController {
      * 获取亚马逊tts配置
      * @return
      */
+    @RequiresPermissions("cc:awsttsconf:view")
     @GetMapping(value = "/getAwsTtsConf")
     @ResponseBody
     public AjaxResult getAwsTtsConf() {
@@ -1400,6 +1495,7 @@ public class FsConfController extends BaseController {
      * @param params
      * @return
      */
+    @RequiresPermissions("cc:awsttsconf:view")
     @PostMapping(value = "/setAwsTtsConf")
     @ResponseBody
     public AjaxResult setAwsTtsConf(@RequestBody JSONArray params) {
@@ -1415,6 +1511,7 @@ public class FsConfController extends BaseController {
      * ASR(deepgram)参数配置
      * @return
      */
+    @RequiresPermissions("cc:deepgramasrconf:view")
     @GetMapping(value = "/deepgramasrconf")
     public String deepgramAsrConf() {
         return "cc/deepgramasrconf/deepgramasrconf";
@@ -1424,6 +1521,7 @@ public class FsConfController extends BaseController {
      * 获取Deepgram ASR配置
      * @return
      */
+    @RequiresPermissions("cc:deepgramasrconf:view")
     @GetMapping(value = "/getDeepgramAsrConf")
     @ResponseBody
     public AjaxResult getDeepgramAsrConf() {
@@ -1436,6 +1534,7 @@ public class FsConfController extends BaseController {
      * @param params
      * @return
      */
+    @RequiresPermissions("cc:deepgramasrconf:view")
     @PostMapping(value = "/setDeepgramAsrConf")
     @ResponseBody
     public AjaxResult setDeepgramAsrConf(@RequestBody JSONArray params) {
@@ -1459,6 +1558,7 @@ public class FsConfController extends BaseController {
      * 获取deepgram tts配置
      * @return
      */
+    @RequiresPermissions("cc:deepgramttsconf:view")
     @GetMapping(value = "/getDeepgramTtsConf")
     @ResponseBody
     public AjaxResult getDeepgramTtsConf() {
@@ -1472,6 +1572,7 @@ public class FsConfController extends BaseController {
      * @param params
      * @return
      */
+    @RequiresPermissions("cc:deepgramttsconf:view")
     @PostMapping(value = "/setDeepgramTtsConf")
     @ResponseBody
     public AjaxResult setDeepgramTtsConf(@RequestBody JSONArray params) {

+ 33 - 22
ruoyi-admin/src/main/java/com/ruoyi/cc/controller/RecordingFileController.java

@@ -1,27 +1,27 @@
 package com.ruoyi.cc.controller;
 
+import com.ruoyi.aicall.utils.ClientIpCheck;
 import com.ruoyi.cc.service.ICcParamsService;
+import com.ruoyi.cc.utils.RecordingPathUtils;
+import com.ruoyi.cc.utils.RecordingPathUtils.FileResolveResult;
+import com.ruoyi.cc.utils.RecordingPathUtils.ResolveStatus;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.utils.StringUtils;
 import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.beans.factory.annotation.Value;
 import org.springframework.core.io.Resource;
 import org.springframework.core.io.UrlResource;
 import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
 import org.springframework.http.ResponseEntity;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.PathVariable;
-import org.springframework.web.bind.annotation.RestController;
-
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import com.ruoyi.common.core.controller.BaseController;
 import org.springframework.stereotype.Controller;
-import org.springframework.web.bind.annotation.*;
-
-import java.util.List;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
 
+import javax.servlet.http.HttpServletRequest;
 
 /**
- * freeswitch配置文件控制层
+ * 录音文件访问
  */
 @Controller
 @RequestMapping("/recordings")
@@ -30,22 +30,33 @@ public class RecordingFileController extends BaseController {
     private ICcParamsService ccParamsService;
 
     @GetMapping("/files")
-    public ResponseEntity<Resource> downloadFile(@RequestParam String filename) {
+    public ResponseEntity<Resource> downloadFile(@RequestParam String filename, HttpServletRequest request) {
+        if (!ClientIpCheck.checkIp(request)) {
+            return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
+        }
+        if (StringUtils.isEmpty(filename)) {
+            return ResponseEntity.badRequest().build();
+        }
         try {
-            logger.info(filename);
             String wavBasePath = ccParamsService.getParamValueByCode(
                     "recording_path", "/home/Records/");
-            Path file = Paths.get(wavBasePath).resolve(filename).normalize();
-            Resource resource = new UrlResource(file.toUri());
-            if (resource.exists() || resource.isReadable()) {
-                return ResponseEntity.ok()
-                        .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + resource.getFilename() + "\"")
-                        .body(resource);
-            } else {
+            FileResolveResult resolved = RecordingPathUtils.resolveReadableRecordingFile(wavBasePath, filename);
+            if (resolved.getStatus() == ResolveStatus.FORBIDDEN) {
+                return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
+            }
+            if (resolved.getStatus() == ResolveStatus.NOT_FOUND) {
                 return ResponseEntity.notFound().build();
             }
+            if (resolved.getStatus() != ResolveStatus.OK || resolved.getPath() == null) {
+                return ResponseEntity.badRequest().build();
+            }
+            Resource resource = new UrlResource(resolved.getPath().toUri());
+            return ResponseEntity.ok()
+                    .header(HttpHeaders.CONTENT_DISPOSITION,
+                            "attachment; filename=\"" + resource.getFilename() + "\"")
+                    .body(resource);
         } catch (Exception e) {
-            return ResponseEntity.badRequest().body(null);
+            return ResponseEntity.badRequest().build();
         }
     }
 }

+ 2 - 1
ruoyi-admin/src/main/java/com/ruoyi/cc/service/IFsConfService.java

@@ -26,8 +26,9 @@ public interface IFsConfService {
 
     /**
      * 重启fs服务
+     * @return empty on success, error message on failure
      */
-    void restartFs();
+    String restartFs();
 
     /**
      * 获取switch.conf.xml文件的参数值

+ 14 - 12
ruoyi-admin/src/main/java/com/ruoyi/cc/service/impl/CcGatewaysServiceImpl.java

@@ -3,15 +3,15 @@ package com.ruoyi.cc.service.impl;
 import java.io.File;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.nio.file.Paths;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.stream.Collectors;
 
 import com.alibaba.fastjson.JSONObject;
 import com.ruoyi.cc.service.ICcParamsService;
+import com.ruoyi.cc.utils.FsConfPathUtils;
+import com.ruoyi.cc.utils.FsEslNameUtils;
 import com.ruoyi.common.utils.DateUtils;
 import com.ruoyi.common.utils.StringUtils;
 import link.thingscloud.freeswitch.esl.EslConnectionUtil;
@@ -141,33 +141,35 @@ public class CcGatewaysServiceImpl implements ICcGatewaysService
             String profile = entry.getKey();
             List<String> gatewayNames = entry.getValue();
             log.info("==========profile:{}, gatewayNames:{}", profile, JSONObject.toJSONString(gatewayNames));
-
-            // 构建profile目录路径
-            Path profileDir = Paths.get(fsConfDirectory, "/sip_profiles/", profile);
-
-            // 检查目录是否存在且为目录
-            if (!Files.exists(profileDir) || !Files.isDirectory(profileDir)) {
+            if (!FsEslNameUtils.isSafeName(profile)) {
+                log.warn("skip unsafe profile name in refreshGatewaysFiles: {}", profile);
+                continue;
+            }
+            Path profileDir = FsConfPathUtils.resolveProfileDir(fsConfDirectory, profile);
+            if (profileDir == null || !Files.exists(profileDir) || !Files.isDirectory(profileDir)) {
                 continue;
             }
 
             try {
-                // 获取目录下的所有文件
                 File[] files = profileDir.toFile().listFiles();
                 if (files == null) {
                     continue;
                 }
 
-                // 遍历文件,删除不在gatewayNames集合中的文件
                 for (File file : files) {
                     String gwName = file.getName().replace(".xml", "");
                     log.info("====================gwName:" + gwName);
                     log.info("=====================" + (file.isFile() && !gatewayNames.contains(file.getName())));
                     log.info("=====================" + (gatewayNames.contains(file.getName())));
                     if (file.isFile() && !gatewayNames.contains(file.getName())) {
+                        if (!FsEslNameUtils.isSafeName(gwName)) {
+                            log.warn("skip unsafe gwName when cleaning: {}", gwName);
+                            continue;
+                        }
                         boolean deleted = file.delete();
                         if (deleted) {
-                            // 删除文件后要kill掉网关
-                            EslMessage eslMessage1 = EslConnectionUtil.sendSyncApiCommand("sofia", "profile " + profile + " killgw " + gwName);
+                            EslMessage eslMessage1 = EslConnectionUtil.sendSyncApiCommand(
+                                    "sofia", "profile " + profile + " killgw " + gwName);
                             if (null != eslMessage1) {
                                 log.info(StringUtils.joinWith("/r/n", eslMessage1.getBodyLines().toArray()));
                             }

+ 166 - 114
ruoyi-admin/src/main/java/com/ruoyi/cc/service/impl/FsConfServiceImpl.java

@@ -5,6 +5,8 @@ import com.alibaba.fastjson.JSONObject;
 import com.ruoyi.cc.model.FsConfProfile;
 import com.ruoyi.cc.service.ICcParamsService;
 import com.ruoyi.cc.service.IFsConfService;
+import com.ruoyi.cc.utils.FsConfPathUtils;
+import com.ruoyi.cc.utils.FsEslNameUtils;
 import com.ruoyi.cc.utils.ShellUtil;
 import com.ruoyi.common.utils.CommonUtils;
 import com.ruoyi.common.utils.ExceptionUtil;
@@ -29,6 +31,7 @@ import javax.xml.transform.stream.StreamResult;
 import java.io.*;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
+import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.nio.file.StandardCopyOption;
 import java.util.ArrayList;
@@ -61,6 +64,8 @@ public class FsConfServiceImpl implements IFsConfService {
     @Value("${sysconfig.hidden-key-list}")
     private String sysConfigHideKeyList;
 
+    private static final String HW_FINGERPRINT_BIN = "/usr/local/freeswitchvideo/bin/hw_fingerprint";
+
     /**
      * 检查是否需要隐藏指定的字段值
      * @param fieldName
@@ -126,61 +131,87 @@ public class FsConfServiceImpl implements IFsConfService {
     }
 
     @Override
-    public void restartFs() {
-        // 使用ProcessBuilder来正确处理命令字符串
+    public String restartFs() {
         List<String> commands = new ArrayList<>();
 
         String fsDeployType = ccParamsService.getParamValueByCode("fs-deploy-type", "native");
-        if("native".equalsIgnoreCase(fsDeployType)){
-            String fsNativeStartUpScript =  ccParamsService.getParamValueByCode(
+        if ("native".equalsIgnoreCase(fsDeployType)) {
+            String fsNativeStartUpScript = ccParamsService.getParamValueByCode(
                     "fs-deploy-native-start-up-script",
                     "/usr/local/freeswitchvideo/bin/freeswitch.sh"
             );
+            if (!isSafeFsStartupScript(fsNativeStartUpScript)) {
+                log.error("fs-deploy-native-start-up-script invalid: {}", fsNativeStartUpScript);
+                return "启动脚本路径不合法";
+            }
             commands.add("/usr/bin/sh");
             commands.add(fsNativeStartUpScript);
-        }else if("docker".equalsIgnoreCase(fsDeployType)){
+        } else if ("docker".equalsIgnoreCase(fsDeployType)) {
             String fsDockerContainerName = ccParamsService.getParamValueByCode(
                     "fs_docker_container_name",
                     "freeswitchvideo-debian12"
             );
+            if (!FsEslNameUtils.isSafeName(fsDockerContainerName)) {
+                log.error("fs_docker_container_name invalid: {}", fsDockerContainerName);
+                return "容器名称不合法";
+            }
             commands.add("docker");
             commands.add("restart");
             commands.add(fsDockerContainerName);
-        }
-        if(commands.size() == 0){
-           log.error("fs-deploy-type 参数错误!");
-           return;
+        } else {
+            log.error("fs-deploy-type 参数错误!");
+            return "fs-deploy-type参数错误";
         }
         try {
-
-            // 使用ProcessBuilder执行命令
             ProcessBuilder processBuilder = new ProcessBuilder(commands);
             Process process = processBuilder.start();
 
             BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
-
             String line;
             while ((line = reader.readLine()) != null) {
                 log.info(line);
             }
 
-            // 读取命令的错误输出
             BufferedReader errorReader = new BufferedReader(new InputStreamReader(process.getErrorStream()));
             String errorLine;
             while ((errorLine = errorReader.readLine()) != null) {
                 log.info(errorLine);
             }
 
-            int exitCode = process.waitFor(); // 等待进程结束并获取退出值
-            if (exitCode == 0) {
-                log.info(StringUtils.join(commands.toArray(), " ") + " executed successfully.");
-            } else {
-                log.info("exitCode:" + exitCode);
-                log.info(StringUtils.join(commands.toArray(), " ") + " execution failed.");
+            int exitCode = process.waitFor();
+            log.info("restartFs exitCode={}", exitCode);
+            if (exitCode != 0) {
+                return "重启失败,exitCode=" + exitCode;
             }
-        } catch (IOException | InterruptedException e) {
-            e.printStackTrace();
+            return "";
+        } catch (Exception e) {
+            log.error("restartFs failed", e);
+            return "重启失败";
+        }
+    }
+
+    /**
+     * Allow absolute script paths under known FreeSWITCH prefixes; block path tricks.
+     */
+    private boolean isSafeFsStartupScript(String scriptPath) {
+        if (StringUtils.isEmpty(scriptPath) || scriptPath.indexOf('\0') >= 0) {
+            return false;
+        }
+        if (scriptPath.contains("..") || scriptPath.contains(" ")
+                || scriptPath.contains(";") || scriptPath.contains("|")
+                || scriptPath.contains("&") || scriptPath.contains("`")
+                || scriptPath.contains("$") || scriptPath.contains("\n")
+                || scriptPath.contains("\r")) {
+            return false;
+        }
+        Path path = Paths.get(scriptPath).normalize();
+        if (!path.isAbsolute()) {
+            return false;
         }
+        String normalized = path.toString().replace('\\', '/');
+        return normalized.startsWith("/usr/local/freeswitch")
+                || normalized.startsWith("/home/freeswitch")
+                || normalized.startsWith("/opt/freeswitch");
     }
 
     @Override
@@ -569,9 +600,12 @@ public class FsConfServiceImpl implements IFsConfService {
     @Override
     public String getLogs(String uuid, String logFile, String logType) {
         log.info(logFile);
-        // SECURITY: Use Java file reading instead of shell commands to prevent command injection.
-        // Previously used "sh -c cat ... | grep '" + uuid + "'" which allowed RCE via shell metacharacters.
-        String ansiRegex = "\u001b\\[[;\\d]*m"; // 匹配 ANSI 转义序列的正则表达式
+        // Never shell out; filter in-process only. Reject unsafe uuid as defense in depth.
+        if (StringUtils.isNotEmpty(uuid) && !FsEslNameUtils.isSafeLogQueryId(uuid)) {
+            log.warn("reject unsafe log query id");
+            return "";
+        }
+        String ansiRegex = "\u001b\\[[;\\d]*m";
         StringBuilder logs = new StringBuilder();
         try {
             java.io.File file = new java.io.File(logFile);
@@ -580,15 +614,14 @@ public class FsConfServiceImpl implements IFsConfService {
                 return "";
             }
             if (StringUtils.isBlank(uuid)) {
-                // Read last 20 lines without shell
                 java.util.List<String> allLines = java.nio.file.Files.readAllLines(java.nio.file.Paths.get(logFile));
                 int start = Math.max(0, allLines.size() - 20);
                 for (int i = start; i < allLines.size(); i++) {
                     logs.append(allLines.get(i)).append("\r\n");
                 }
             } else {
-                // Filter lines containing uuid without shell grep
-                try (java.io.BufferedReader br = new java.io.BufferedReader(new java.io.FileReader(logFile))) {
+                try (java.io.BufferedReader br = new java.io.BufferedReader(
+                        new java.io.InputStreamReader(new java.io.FileInputStream(logFile), StandardCharsets.UTF_8))) {
                     String line;
                     while ((line = br.readLine()) != null) {
                         if (line.contains(uuid)) {
@@ -665,52 +698,47 @@ public class FsConfServiceImpl implements IFsConfService {
 
     @Override
     public String setProfileConf(String profileName, String profileType, JSONArray params) {
+        if (!FsEslNameUtils.isSafeName(profileName)) {
+            return "profileName参数不合法";
+        }
         String fsConfDirectory = ccParamsService.getParamValueByCode("fs_conf_directory", "");
+        Path profileXml = FsConfPathUtils.resolveProfileXml(fsConfDirectory, profileName);
+        Path profileDir = FsConfPathUtils.resolveProfileDir(fsConfDirectory, profileName);
+        if (profileXml == null || profileDir == null) {
+            return "profile路径不合法";
+        }
         try {
-            // 创建DocumentBuilderFactory对象
             DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
-            // 创建DocumentBuilder对象
             DocumentBuilder builder = factory.newDocumentBuilder();
-            // 解析XML文件获取Document对象
-            String profilefXmlPath = fsConfDirectory + "/sip_profiles/" + profileName + ".xml";
-            File profilefXmlFile = new File(profilefXmlPath);
-            if (!profilefXmlFile.exists()) {
-                // 如果xml文件不存在(新增profile),则先从模板文件里拷贝一份,并创建对应的文件夹
-                try {
-                    // 使用Files.copy方法拷贝文件
-                    Files.copy(Paths.get(fsConfDirectory + "/template/" + profileType + ".xml"), Paths.get(profilefXmlPath), StandardCopyOption.REPLACE_EXISTING);
-                    log.info("文件拷贝成功!");
-                    new File(fsConfDirectory + "/sip_profiles/" + profileName).mkdirs();
-                    log.info("创建文件夹成功!");
-                } catch (IOException e) {
-                    e.printStackTrace();
-                    log.info("文件拷贝失败!");
+            if (!Files.exists(profileXml)) {
+                // 新增 profile:仅在模板名安全且模板文件存在时从模板拷贝
+                Path templateXml = FsConfPathUtils.resolveTemplateXml(fsConfDirectory, profileType);
+                if (templateXml == null || !Files.exists(templateXml)) {
+                    return "profile模板不存在或不合法";
                 }
+                Files.copy(templateXml, profileXml, StandardCopyOption.REPLACE_EXISTING);
+                log.info("文件拷贝成功!");
+                Files.createDirectories(profileDir);
+                log.info("创建文件夹成功!");
             }
-            Document document = builder.parse(profilefXmlFile);
-            // profile元素
-            Element profile = (Element)document.getElementsByTagName("profile").item(0);
+            Document document = builder.parse(profileXml.toFile());
+            Element profile = (Element) document.getElementsByTagName("profile").item(0);
             profile.setAttribute("name", profileName);
-            // 获取X-PRE-PROCESS元素,修改data
-            Element gatways = (Element)document.getElementsByTagName("X-PRE-PROCESS").item(0);
-            if(gatways == null){
+            Element gatways = (Element) document.getElementsByTagName("X-PRE-PROCESS").item(0);
+            if (gatways == null) {
                 return "profile 模板文件错误,无法读取网关配置节点!";
             }
             gatways.setAttribute("data", profileName + "/*.xml");
-            // 获取settings元素
-            Element settings = (Element)document.getElementsByTagName("settings").item(0);
-            // 更新属性值
+            Element settings = (Element) document.getElementsByTagName("settings").item(0);
             NodeList nodes = settings.getElementsByTagName("param");
             for (int j = 0; j < params.size(); j++) {
                 JSONObject param = params.getJSONObject(j);
                 String attrName = param.getString("name");
                 String attrValue = param.getString("value");
-                // 忽略参数名或者参数值为空的参数
-                if (StringUtils.isBlank(attrName)
-                        || StringUtils.isBlank(attrValue)) {
+                if (StringUtils.isBlank(attrName) || StringUtils.isBlank(attrValue)) {
                     continue;
                 }
-                Boolean newParams = true; // 是否是新增参数
+                boolean newParams = true;
                 for (int i = 0; i < nodes.getLength(); i++) {
                     if (nodes.item(i) instanceof Element) {
                         Element element = (Element) nodes.item(i);
@@ -728,44 +756,41 @@ public class FsConfServiceImpl implements IFsConfService {
                     settings.appendChild(element);
                 }
             }
-            // 将更新后的Document对象写回XML文件
             TransformerFactory transformerFactory = TransformerFactory.newInstance();
             Transformer transformer = transformerFactory.newTransformer();
             transformer.setOutputProperty(OutputKeys.INDENT, "yes");
             DOMSource source = new DOMSource(document);
             StringWriter writer = new StringWriter();
             transformer.transform(source, new StreamResult(writer));
-            String updatedXML = writer.toString();
-            // 将updatedXML写入文件
-            java.nio.file.Files.write(java.nio.file.Paths.get(profilefXmlPath), updatedXML.getBytes());
+            Files.write(profileXml, writer.toString().getBytes(StandardCharsets.UTF_8));
         } catch (Exception e) {
-           return String.format("修改profile失败, %s \n %s", e.toString(), CommonUtils.getStackTraceString(e.getStackTrace()));
+            log.error("修改profile失败", e);
+            return "修改profile失败";
         }
         return "";
     }
 
     @Override
     public JSONObject getProfileConf(String profileName, String profileType) {
+        JSONObject confAllVars = new JSONObject();
         String fsConfDirectory = ccParamsService.getParamValueByCode("fs_conf_directory", "");
-        String profilefXmlPath = "";
+        Path profileXml;
         if (StringUtils.isBlank(profileName)) {
-            // 新增
-            profilefXmlPath = fsConfDirectory + "/template/" + profileType + ".xml";
+            profileXml = FsConfPathUtils.resolveTemplateXml(fsConfDirectory, profileType);
         } else {
-            // 更新/查看
-            profilefXmlPath = fsConfDirectory + "/sip_profiles/" + profileName + ".xml";
+            if (!FsEslNameUtils.isSafeName(profileName)) {
+                return confAllVars;
+            }
+            profileXml = FsConfPathUtils.resolveProfileXml(fsConfDirectory, profileName);
+        }
+        if (profileXml == null) {
+            return confAllVars;
         }
-        JSONObject confAllVars = new JSONObject();
         try {
-            // 创建DocumentBuilderFactory对象
             DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
-            // 创建DocumentBuilder对象
             DocumentBuilder builder = factory.newDocumentBuilder();
-            // 解析XML文件获取Document对象
-            Document document = builder.parse(new File(profilefXmlPath));
-            // 获取settings元素
-            Element settings = (Element)document.getElementsByTagName("settings").item(0);
-            // 获取属性值
+            Document document = builder.parse(profileXml.toFile());
+            Element settings = (Element) document.getElementsByTagName("settings").item(0);
             NodeList nodes = settings.getElementsByTagName("param");
             for (int i = 0; i < nodes.getLength(); i++) {
                 if (nodes.item(i) instanceof Element) {
@@ -774,7 +799,7 @@ public class FsConfServiceImpl implements IFsConfService {
                 }
             }
         } catch (Exception e) {
-            e.printStackTrace();
+            log.error("读取profile配置失败", e);
         }
         return confAllVars;
     }
@@ -782,81 +807,111 @@ public class FsConfServiceImpl implements IFsConfService {
     @Override
     public void setGwRegisterConf(String orginProfileName, String profileName, String gwName, JSONObject params) {
         String fsConfDirectory = ccParamsService.getParamValueByCode("fs_conf_directory", "");
-        setGwConf(fsConfDirectory + "/template/MRWG1.xml", orginProfileName, profileName, gwName, params);
+        Path template = FsConfPathUtils.resolveTemplateXml(fsConfDirectory, "MRWG1");
+        if (template == null) {
+            log.error("网关注册模板路径不合法: MRWG1");
+            return;
+        }
+        setGwConf(template.toString(), orginProfileName, profileName, gwName, params);
     }
 
     @Override
     public void setGwUnRegisterConf(String orginProfileName, String profileName, String gwName, JSONObject params) {
         String fsConfDirectory = ccParamsService.getParamValueByCode("fs_conf_directory", "");
-        setGwConf(fsConfDirectory + "/template/MRWG0.xml", orginProfileName, profileName, gwName, params);
+        Path template = FsConfPathUtils.resolveTemplateXml(fsConfDirectory, "MRWG0");
+        if (template == null) {
+            log.error("网关非注册模板路径不合法: MRWG0");
+            return;
+        }
+        setGwConf(template.toString(), orginProfileName, profileName, gwName, params);
     }
 
     @Override
     public String getFingerprintValue() {
-        String cmd = ccParamsService.getParamValueByCode("system_hw_fingerprint_cmd", "");
+        if (!isSafeFsStartupScript(HW_FINGERPRINT_BIN)) {
+            log.error("hw_fingerprint bin path invalid: {}", HW_FINGERPRINT_BIN);
+            return "";
+        }
+        String fsDeployType = ccParamsService.getParamValueByCode("fs-deploy-type", "docker");
         try {
-            String fingerprintValue = ShellUtil.exec(cmd);
-            return fingerprintValue;
+            if ("native".equalsIgnoreCase(fsDeployType)) {
+                return ShellUtil.execArgs(HW_FINGERPRINT_BIN);
+            }
+            String containerName = ccParamsService.getParamValueByCode(
+                    "fs_docker_container_name",
+                    "freeswitchvideo-debian12"
+            );
+            if (!FsEslNameUtils.isSafeName(containerName)) {
+                log.error("fs_docker_container_name invalid: {}", containerName);
+                return "";
+            }
+            return ShellUtil.execArgs("docker", "exec", "-i", containerName, HW_FINGERPRINT_BIN);
         } catch (Exception e) {
-            log.error(ExceptionUtil.getExceptionMessage(e));
-            return e.getMessage();
+            log.error("getFingerprintValue failed", e);
+            return "";
         }
     }
 
     private void setGwConf(String gwTemplate, String orginProfileName, String profileName, String gwName, JSONObject params) {
+        if (!FsEslNameUtils.isSafeName(profileName) || !FsEslNameUtils.isSafeName(gwName)) {
+            log.error("profileName/gwName 不合法: profileName={}, gwName={}", profileName, gwName);
+            return;
+        }
+        if (StringUtils.isNotEmpty(orginProfileName) && !FsEslNameUtils.isSafeName(orginProfileName)) {
+            log.error("orginProfileName 不合法: {}", orginProfileName);
+            return;
+        }
         String fsConfDirectory = ccParamsService.getParamValueByCode("fs_conf_directory", "");
+        Path gatewayXml = FsConfPathUtils.resolveGatewayXml(fsConfDirectory, profileName, gwName);
+        Path profileDir = FsConfPathUtils.resolveProfileDir(fsConfDirectory, profileName);
+        if (gatewayXml == null || profileDir == null) {
+            log.error("网关路径不合法: profileName={}, gwName={}", profileName, gwName);
+            return;
+        }
         try {
-            // 创建DocumentBuilderFactory对象
             DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
-            // 创建DocumentBuilder对象
             DocumentBuilder builder = factory.newDocumentBuilder();
             // 如果修改了profile,则删除原文件
             if (StringUtils.isNotEmpty(orginProfileName) && !orginProfileName.equals(profileName)) {
-                String orignGatewayXmlPath = fsConfDirectory + "/sip_profiles/" + orginProfileName + "/" + gwName + ".xml";
+                Path originGatewayXml = FsConfPathUtils.resolveGatewayXml(fsConfDirectory, orginProfileName, gwName);
+                if (originGatewayXml == null) {
+                    log.error("原网关路径不合法: orginProfileName={}, gwName={}", orginProfileName, gwName);
+                    return;
+                }
                 try {
-                    Files.delete(Paths.get(orignGatewayXmlPath));
-                    // 删除文件后要kill掉网关
-                    EslMessage eslMessage1 = EslConnectionUtil.sendSyncApiCommand("sofia", "profile " + orginProfileName + " killgw " + gwName);
+                    Files.delete(originGatewayXml);
+                    EslMessage eslMessage1 = EslConnectionUtil.sendSyncApiCommand(
+                            "sofia", "profile " + orginProfileName + " killgw " + gwName);
                     if (null != eslMessage1) {
                         log.info(StringUtils.joinWith("/r/n", eslMessage1.getBodyLines().toArray()));
                     }
                     log.info("文件删除成功!");
                 } catch (Exception e) {
-                    log.error("删除文件失败:{}", orignGatewayXmlPath);
+                    log.error("删除文件失败:{}", originGatewayXml);
                     log.error(ExceptionUtil.getExceptionMessage(e));
                 }
             }
-            // 如果目录不存在则自动创建
-            File profileDir = new File(fsConfDirectory + "/sip_profiles/" + profileName);
-            if (!profileDir.exists()) {
-                profileDir.mkdirs();
+            if (!Files.exists(profileDir)) {
+                Files.createDirectories(profileDir);
             }
-            // 解析XML文件获取Document对象
-            String gatewayXmlPath = fsConfDirectory + "/sip_profiles/" + profileName + "/" + gwName + ".xml";
-            File profilefXmlFile = new File(gatewayXmlPath);
             // 每次都从模板文件里拷贝一份,并覆盖参数值,确保修改是否注册模式时属性正确
             try {
-                // 使用Files.copy方法拷贝文件
-                Files.copy(Paths.get(gwTemplate), Paths.get(gatewayXmlPath), StandardCopyOption.REPLACE_EXISTING);
+                Files.copy(Paths.get(gwTemplate), gatewayXml, StandardCopyOption.REPLACE_EXISTING);
                 log.info("文件拷贝成功!");
             } catch (IOException e) {
-                e.printStackTrace();
-                log.info("文件拷贝失败!");
+                log.error("文件拷贝失败!", e);
+                return;
             }
-            Document document = builder.parse(profilefXmlFile);
-            // gateway元素
-            Element gateway = (Element)document.getElementsByTagName("gateway").item(0);
+            Document document = builder.parse(gatewayXml.toFile());
+            Element gateway = (Element) document.getElementsByTagName("gateway").item(0);
             gateway.setAttribute("name", gwName);
-            // 更新属性值
             NodeList nodes = document.getElementsByTagName("param");
-            for (String attrName: params.keySet()) {
+            for (String attrName : params.keySet()) {
                 String attrValue = params.getString(attrName);
-                // 忽略参数名或者参数值为空的参数
-                if (StringUtils.isBlank(attrName)
-                        || StringUtils.isBlank(attrValue)) {
+                if (StringUtils.isBlank(attrName) || StringUtils.isBlank(attrValue)) {
                     continue;
                 }
-                Boolean newParams = true; // 是否是新增参数
+                boolean newParams = true;
                 for (int i = 0; i < nodes.getLength(); i++) {
                     if (nodes.item(i) instanceof Element) {
                         Element element = (Element) nodes.item(i);
@@ -874,18 +929,15 @@ public class FsConfServiceImpl implements IFsConfService {
                     gateway.appendChild(element);
                 }
             }
-            // 将更新后的Document对象写回XML文件
             TransformerFactory transformerFactory = TransformerFactory.newInstance();
             Transformer transformer = transformerFactory.newTransformer();
             transformer.setOutputProperty(OutputKeys.INDENT, "yes");
             DOMSource source = new DOMSource(document);
             StringWriter writer = new StringWriter();
             transformer.transform(source, new StreamResult(writer));
-            String updatedXML = writer.toString();
-            // 将updatedXML写入文件
-            java.nio.file.Files.write(java.nio.file.Paths.get(gatewayXmlPath), updatedXML.getBytes());
+            Files.write(gatewayXml, writer.toString().getBytes(StandardCharsets.UTF_8));
         } catch (Exception e) {
-            e.printStackTrace();
+            log.error("setGwConf failed", e);
         }
     }
 

+ 87 - 0
ruoyi-admin/src/main/java/com/ruoyi/cc/utils/FsConfPathUtils.java

@@ -0,0 +1,87 @@
+package com.ruoyi.cc.utils;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+/**
+ * Resolve FreeSWITCH conf paths under fs_conf_directory with name whitelist + containment.
+ */
+public final class FsConfPathUtils {
+
+    private FsConfPathUtils() {
+    }
+
+    public static Path resolveSipProfilesBase(String fsConfDirectory) {
+        if (fsConfDirectory == null || fsConfDirectory.isEmpty()) {
+            return null;
+        }
+        return Paths.get(fsConfDirectory, "sip_profiles").toAbsolutePath().normalize();
+    }
+
+    public static Path resolveTemplateBase(String fsConfDirectory) {
+        if (fsConfDirectory == null || fsConfDirectory.isEmpty()) {
+            return null;
+        }
+        return Paths.get(fsConfDirectory, "template").toAbsolutePath().normalize();
+    }
+
+    /**
+     * sip_profiles/{profileName}.xml
+     */
+    public static Path resolveProfileXml(String fsConfDirectory, String profileName) {
+        if (!FsEslNameUtils.isSafeName(profileName)) {
+            return null;
+        }
+        Path base = resolveSipProfilesBase(fsConfDirectory);
+        if (base == null) {
+            return null;
+        }
+        Path file = base.resolve(profileName + ".xml").normalize();
+        return file.startsWith(base) ? file : null;
+    }
+
+    /**
+     * sip_profiles/{profileName}/
+     */
+    public static Path resolveProfileDir(String fsConfDirectory, String profileName) {
+        if (!FsEslNameUtils.isSafeName(profileName)) {
+            return null;
+        }
+        Path base = resolveSipProfilesBase(fsConfDirectory);
+        if (base == null) {
+            return null;
+        }
+        Path dir = base.resolve(profileName).normalize();
+        return dir.startsWith(base) ? dir : null;
+    }
+
+    /**
+     * sip_profiles/{profileName}/{gwName}.xml
+     */
+    public static Path resolveGatewayXml(String fsConfDirectory, String profileName, String gwName) {
+        if (!FsEslNameUtils.isSafeName(profileName) || !FsEslNameUtils.isSafeName(gwName)) {
+            return null;
+        }
+        Path dir = resolveProfileDir(fsConfDirectory, profileName);
+        if (dir == null) {
+            return null;
+        }
+        Path file = dir.resolve(gwName + ".xml").normalize();
+        return file.startsWith(dir) ? file : null;
+    }
+
+    /**
+     * template/{templateName}.xml (profileType or MRWG0/MRWG1)
+     */
+    public static Path resolveTemplateXml(String fsConfDirectory, String templateName) {
+        if (!FsEslNameUtils.isSafeName(templateName)) {
+            return null;
+        }
+        Path base = resolveTemplateBase(fsConfDirectory);
+        if (base == null) {
+            return null;
+        }
+        Path file = base.resolve(templateName + ".xml").normalize();
+        return file.startsWith(base) ? file : null;
+    }
+}

+ 36 - 0
ruoyi-admin/src/main/java/com/ruoyi/cc/utils/FsEslNameUtils.java

@@ -0,0 +1,36 @@
+package com.ruoyi.cc.utils;
+
+import java.util.regex.Pattern;
+
+/**
+ * FreeSWITCH name safety for ESL api args and conf path segments.
+ * Rejects whitespace/CRLF/quotes that can break sofia command framing,
+ * while allowing common profile names such as internal, external, my_profile-1.
+ */
+public final class FsEslNameUtils {
+
+    private static final Pattern SAFE_NAME =
+            Pattern.compile("^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$");
+
+    /** Call/FS uuid used only for String.contains filter (no shell). */
+    private static final Pattern SAFE_LOG_QUERY_ID =
+            Pattern.compile("^[A-Za-z0-9][A-Za-z0-9_-]{10,63}$");
+
+    private FsEslNameUtils() {
+    }
+
+    /**
+     * @return true if name is safe to embed in sofia ESL arguments
+     */
+    public static boolean isSafeName(String name) {
+        return name != null && SAFE_NAME.matcher(name).matches();
+    }
+
+    /**
+     * Safe id for log line filtering (FS/call uuid). Blocks shell metacharacters
+     * from legacy "sh -c ... grep '"+uuid+"'" style bugs; allows hyphenated UUIDs.
+     */
+    public static boolean isSafeLogQueryId(String id) {
+        return id != null && SAFE_LOG_QUERY_ID.matcher(id).matches();
+    }
+}

+ 129 - 0
ruoyi-admin/src/main/java/com/ruoyi/cc/utils/RecordingPathUtils.java

@@ -0,0 +1,129 @@
+package com.ruoyi.cc.utils;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+/**
+ * Resolve paths under recording_path with containment checks.
+ */
+public final class RecordingPathUtils {
+
+    public enum ResolveStatus {
+        OK,
+        FORBIDDEN,
+        NOT_FOUND,
+        BAD_REQUEST
+    }
+
+    public static final class FileResolveResult {
+        private final ResolveStatus status;
+        private final Path path;
+
+        private FileResolveResult(ResolveStatus status, Path path) {
+            this.status = status;
+            this.path = path;
+        }
+
+        public static FileResolveResult ok(Path path) {
+            return new FileResolveResult(ResolveStatus.OK, path);
+        }
+
+        public static FileResolveResult of(ResolveStatus status) {
+            return new FileResolveResult(status, null);
+        }
+
+        public ResolveStatus getStatus() {
+            return status;
+        }
+
+        public Path getPath() {
+            return path;
+        }
+    }
+
+    private RecordingPathUtils() {
+    }
+
+    /**
+     * Resolve {@code recordingPath/rootId} and ensure it stays under recording root.
+     *
+     * @return normalized directory path, or null if rootId/path is unsafe
+     */
+    public static Path resolveRootDir(String recordingPath, String rootId) {
+        if (!FsEslNameUtils.isSafeName(rootId)) {
+            return null;
+        }
+        Path base = Paths.get(recordingPath).toAbsolutePath().normalize();
+        Path dir = base.resolve(rootId).normalize();
+        if (!dir.startsWith(base)) {
+            return null;
+        }
+        return dir;
+    }
+
+    /**
+     * Resolve a file under {@code recordingPath/rootId/fileName}.
+     *
+     * @return normalized file path, or null if any segment escapes the recording root
+     */
+    public static Path resolveRootFile(String recordingPath, String rootId, String fileName) {
+        Path dir = resolveRootDir(recordingPath, rootId);
+        if (dir == null || fileName == null || fileName.isEmpty()) {
+            return null;
+        }
+        if (fileName.indexOf('/') >= 0 || fileName.indexOf('\\') >= 0 || fileName.indexOf('\0') >= 0) {
+            return null;
+        }
+        if (fileName.contains("..")) {
+            return null;
+        }
+        Path file = dir.resolve(fileName).normalize();
+        if (!file.startsWith(dir)) {
+            return null;
+        }
+        return file;
+    }
+
+    /**
+     * Resolve a download path under recording_path.
+     * Compatible with relative names and absolute paths that stay under the recording root.
+     * Applies normalize + lexical containment, then toRealPath (symlink-aware) containment.
+     */
+    public static FileResolveResult resolveReadableRecordingFile(String recordingPath, String filename) {
+        if (filename == null || filename.isEmpty() || filename.indexOf('\0') >= 0) {
+            return FileResolveResult.of(ResolveStatus.BAD_REQUEST);
+        }
+        try {
+            Path base = Paths.get(recordingPath).toAbsolutePath().normalize();
+            Path baseReal = Files.exists(base) ? base.toRealPath() : base;
+
+            Path input = Paths.get(filename);
+            Path candidate = input.isAbsolute()
+                    ? input.toAbsolutePath().normalize()
+                    : base.resolve(filename).normalize();
+
+            // Lexical containment (blocks ../ before touching FS)
+            if (!candidate.startsWith(base) && !candidate.startsWith(baseReal)) {
+                return FileResolveResult.of(ResolveStatus.FORBIDDEN);
+            }
+
+            if (!Files.exists(candidate)) {
+                return FileResolveResult.of(ResolveStatus.NOT_FOUND);
+            }
+
+            // Reject symlink escape: real path must remain under recording root
+            Path realFile = candidate.toRealPath();
+            if (!realFile.startsWith(baseReal)) {
+                return FileResolveResult.of(ResolveStatus.FORBIDDEN);
+            }
+            if (!Files.isRegularFile(realFile) || !Files.isReadable(realFile)) {
+                return FileResolveResult.of(ResolveStatus.NOT_FOUND);
+            }
+            return FileResolveResult.ok(realFile);
+        } catch (IOException e) {
+            return FileResolveResult.of(ResolveStatus.BAD_REQUEST);
+        }
+    }
+}

+ 22 - 18
ruoyi-admin/src/main/java/com/ruoyi/cc/utils/ShellUtil.java

@@ -8,45 +8,49 @@ import java.util.Arrays;
 
 @Slf4j
 public class ShellUtil {
-    /** 整串指令 -> 输出文本;超 30s 抛异常 */
+    /** 整串指令 -> 输出文本;超 30s 抛异常(仅内部兼容,业务勿再拼用户可控整串) */
     public static String exec(String wholeCmd) throws Exception {
-        // 1. 自动拆成数组,支持引号、转义
         log.info("wholeCmd:{}", wholeCmd);
         CommandLine cl = CommandLine.parse(wholeCmd);
-        log.info("CommandLine: " + Arrays.toString(cl.toStrings()));
+        return execute(cl);
+    }
+
+    /**
+     * 固定 argv 执行(无 shell、不 parse 整串),用于 fingerprint 等可信命令。
+     */
+    public static String execArgs(String... args) throws Exception {
+        if (args == null || args.length == 0 || args[0] == null || args[0].isEmpty()) {
+            throw new IllegalArgumentException("empty command");
+        }
+        CommandLine cl = new CommandLine(args[0]);
+        for (int i = 1; i < args.length; i++) {
+            // false = do not handle quoting; pass literal argv
+            cl.addArgument(args[i], false);
+        }
+        log.info("CommandLine: {}", Arrays.toString(cl.toStrings()));
+        return execute(cl);
+    }
 
-        // 2. 执行并捕获输出
+    private static String execute(CommandLine cl) throws Exception {
         ByteArrayOutputStream out = new ByteArrayOutputStream();
         ByteArrayOutputStream err = new ByteArrayOutputStream();
 
         Executor exec = new DefaultExecutor();
         exec.setStreamHandler(new PumpStreamHandler(out, err));
-        exec.setWatchdog(new ExecuteWatchdog(30_000)); // 30s 超时
+        exec.setWatchdog(new ExecuteWatchdog(30_000));
         try {
-            int exit = exec.execute(cl);
-            // 0 表示成功
+            exec.execute(cl);
             return out.toString("UTF-8");
         } catch (Exception e) {
-            /* --- 3. 组装统一错误信息 --- */
             int exitCode = -1;
             if (e instanceof ExecuteException) {
                 exitCode = ((ExecuteException) e).getExitValue();
             }
             String stdout = out.toString("UTF-8");
             String stderr = err.toString("UTF-8");
-
-            // 把 docker 原始报错完整带出去
             String msg = String.format("Command failed (exit=%d)%nstdout:%n%s%nstderr:%n%s",
                     exitCode, stdout, stderr);
             throw new CmdExecException(exitCode, msg);
         }
     }
-
-    /* ---------- 使用 ---------- */
-    public static void main(String[] args) throws Exception {
-        String cmd = "docker exec -i freeswitch-debian12 " +
-                "/usr/local/freeswitchvideo/bin/hw_fingerprint -v";
-        String result = exec(cmd);
-        System.out.println(result);
-    }
 }

+ 1 - 1
ruoyi-admin/src/main/resources/templates/cc/ivr/ivr.html

@@ -37,7 +37,7 @@
                 <i class="fa fa-plus"></i> <span th:text="#{ivr.btn.createIVR}"></span>
             </a>
             <!-- 新增:应用配置按钮 -->
-            <a class="btn btn-warning" onclick="applyIvrConfig()" shiro:hasPermission="cc:ivr:apply">
+            <a class="btn btn-warning" onclick="applyIvrConfig()" shiro:hasPermission="cc:ivr:edit">
                 <i class="fa fa-play-circle"></i> <span th:text="#{ivr.btn.apply}">应用</span>
             </a>
         </div>

+ 5 - 1
ruoyi-admin/src/main/resources/templates/cc/licenseconf/licenseconf.html

@@ -138,7 +138,11 @@
                 $.modal.disable();
             },
             success: function(response) {
-                $.modal.msgSuccess(i18n('switchconf.msg.restart.success'));
+                if (response.code === 0) {
+                    $.modal.msgSuccess(i18n('switchconf.msg.restart.success'));
+                } else {
+                    $.modal.msgError(response.msg || '重启失败!');
+                }
                 $.modal.closeLoading();
                 $.modal.enable();
             },

+ 5 - 1
ruoyi-admin/src/main/resources/templates/cc/switchconf/switchconf.html

@@ -129,7 +129,11 @@
                 $.modal.disable();
             },
             success: function(response) {
-                $.modal.msgSuccess(i18n('switchconf.msg.restart.success'));
+                if (response.code === 0) {
+                    $.modal.msgSuccess(i18n('switchconf.msg.restart.success'));
+                } else {
+                    $.modal.msgError(response.msg || '重启失败!');
+                }
                 $.modal.closeLoading();
                 $.modal.enable();
             },