瀏覽代碼

Merge branch 'master' of http://1.14.104.71:10880/wushubo/easycallcenter365-gui-dev

yzx 3 天之前
父節點
當前提交
57b520aafb
共有 34 個文件被更改,包括 2085 次插入335 次删除
  1. 85 35
      ruoyi-admin/src/main/java/com/ruoyi/aicall/controller/ApiController.java
  2. 89 8
      ruoyi-admin/src/main/java/com/ruoyi/aicall/controller/CcCallPhoneController.java
  3. 16 9
      ruoyi-admin/src/main/java/com/ruoyi/aicall/controller/CcCallTaskController.java
  4. 17 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/mapper/CcCallPhoneMapper.java
  5. 18 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/service/ICcCallPhoneService.java
  6. 84 45
      ruoyi-admin/src/main/java/com/ruoyi/aicall/service/impl/CcCallPhoneServiceImpl.java
  7. 48 12
      ruoyi-admin/src/main/java/com/ruoyi/aicall/utils/ClientIpCheck.java
  8. 48 10
      ruoyi-admin/src/main/java/com/ruoyi/cc/controller/CcInboundCdrController.java
  9. 10 0
      ruoyi-admin/src/main/java/com/ruoyi/cc/mapper/CcExtNumMapper.java
  10. 10 0
      ruoyi-admin/src/main/java/com/ruoyi/cc/service/ICcExtNumService.java
  11. 10 0
      ruoyi-admin/src/main/java/com/ruoyi/cc/service/impl/CcExtNumServiceImpl.java
  12. 48 7
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysIndexController.java
  13. 44 7
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysLoginController.java
  14. 112 48
      ruoyi-admin/src/main/resources/mapper/aicall/CcCallPhoneMapper.xml
  15. 16 0
      ruoyi-admin/src/main/resources/mapper/cc/CcExtNumMapper.xml
  16. 2 2
      ruoyi-admin/src/main/resources/mapper/cc/CcGatewaysMapper.xml
  17. 25 6
      ruoyi-admin/src/main/resources/static/phone-bar/ccPhoneBarSocket.js
  18. 9 1
      ruoyi-admin/src/main/resources/static/ruoyi/js/common.js
  19. 160 30
      ruoyi-admin/src/main/resources/static/ruoyi/login.js
  20. 22 4
      ruoyi-admin/src/main/resources/templates/aicall/callPhone/callPhone.html
  21. 28 0
      ruoyi-admin/src/main/resources/templates/error/embedWorkbench.html
  22. 2 2
      ruoyi-admin/src/main/resources/templates/index-topnav.html
  23. 1 1
      ruoyi-admin/src/main/resources/templates/index.html
  24. 366 24
      ruoyi-admin/src/main/resources/templates/login.html
  25. 362 72
      ruoyi-admin/src/main/resources/templates/main.html
  26. 29 0
      ruoyi-common/src/main/java/com/ruoyi/common/config/RuoYiConfig.java
  27. 29 8
      ruoyi-framework/src/main/java/com/ruoyi/framework/config/ResourcesConfig.java
  28. 27 0
      ruoyi-framework/src/main/java/com/ruoyi/framework/config/SessionCookieFilterConfig.java
  29. 30 3
      ruoyi-framework/src/main/java/com/ruoyi/framework/config/ShiroConfig.java
  30. 24 0
      ruoyi-framework/src/main/java/com/ruoyi/framework/shiro/web/filter/embed/EmbedAwareUserFilter.java
  31. 63 0
      ruoyi-framework/src/main/java/com/ruoyi/framework/shiro/web/filter/embed/EmbedRequestUtils.java
  32. 7 1
      ruoyi-framework/src/main/java/com/ruoyi/framework/shiro/web/filter/online/OnlineSessionFilter.java
  33. 61 0
      ruoyi-framework/src/main/java/com/ruoyi/framework/web/filter/LocalProfileProxyFilter.java
  34. 183 0
      ruoyi-framework/src/main/java/com/ruoyi/framework/web/filter/SessionCookieRewriteFilter.java

+ 85 - 35
ruoyi-admin/src/main/java/com/ruoyi/aicall/controller/ApiController.java

@@ -177,6 +177,22 @@ public class ApiController extends BaseController {
         return AjaxResult.success(list);
     }
 
+    /**
+     * Login page gateway list: only purposes 1(phonebar) and 3(unlimited), exclude 2(outbound tasks)
+     */
+    @GetMapping("/gateway/phonebarList")
+    @ResponseBody
+    public AjaxResult getPhonebarGatewayList() {
+        Map<String, Object> params = new HashMap<>();
+        params.put("purposes", Arrays.asList(1, 3));
+        List<CcGateways> list = ccGatewaysService.selectCcGatewaysList(new CcGateways().setParams(params));
+        // defense: never return purpose=2 (outbound tasks)
+        list = list.stream()
+                .filter(g -> g.getPurpose() != null && (g.getPurpose() == 1 || g.getPurpose() == 3))
+                .collect(Collectors.toList());
+        return AjaxResult.success(list);
+    }
+
     /**
      * 通话记录查询接口(支持按时间、坐席、号码、呼入/呼出类型筛选)
      * @param req
@@ -234,17 +250,27 @@ public class ApiController extends BaseController {
         List<CcCallTask> list = ccCallTaskService.selectCcCallTaskList(ccCallTask);
         tableDataInfo = getDataTable(list);
         List<CcCallTask> records = (List<CcCallTask>) tableDataInfo.getRows();
-        for (CcCallTask data: records){
-            CallTaskStatModel statModel = ccCallPhoneService.statByBatchId(data.getBatchId());
-            data.setPhoneCount(statModel.getPhoneCount());
-            data.setCallCount(statModel.getCallCount());
-            data.setNoCallCount(statModel.getPhoneCount() - statModel.getCallCount());
-            data.setConnectCount(statModel.getConnectCount());
-            data.setNoConnectCount(statModel.getCallCount() - statModel.getConnectCount());
-            if (data.getCallCount() > 0) {
-                data.setRealConnectRate(data.getConnectCount()*1.0/data.getCallCount());
-            } else {
-                data.setRealConnectRate(0.0);
+        if (records != null && !records.isEmpty()) {
+            List<Long> batchIds = new ArrayList<>(records.size());
+            for (CcCallTask data : records) {
+                batchIds.add(data.getBatchId());
+            }
+            Map<Long, CallTaskStatModel> statMap = ccCallPhoneService.statByBatchIds(batchIds);
+            for (CcCallTask data: records){
+                CallTaskStatModel statModel = statMap.get(data.getBatchId());
+                if (statModel == null) {
+                    statModel = new CallTaskStatModel();
+                }
+                data.setPhoneCount(statModel.getPhoneCount());
+                data.setCallCount(statModel.getCallCount());
+                data.setNoCallCount(statModel.getPhoneCount() - statModel.getCallCount());
+                data.setConnectCount(statModel.getConnectCount());
+                data.setNoConnectCount(statModel.getCallCount() - statModel.getConnectCount());
+                if (data.getCallCount() > 0) {
+                    data.setRealConnectRate(data.getConnectCount()*1.0/data.getCallCount());
+                } else {
+                    data.setRealConnectRate(0.0);
+                }
             }
         }
         tableDataInfo.setRows(records);
@@ -1221,6 +1247,10 @@ public class ApiController extends BaseController {
         encryptStr = String.format("var _phoneEncryptPassword='%s';", encryptStr);
 
         CcGateways ccGatewaysVo = new CcGateways();
+        // 电话条仅允许 purposes 1/3,排除 2(外呼任务)
+        Map<String, Object> phonebarPurposes = new HashMap<>();
+        phonebarPurposes.put("purposes", Arrays.asList(1, 3));
+        ccGatewaysVo.setParams(phonebarPurposes);
         //判断指定网关还是全部网关
         if(StringUtils.isNotBlank(myGateway)){
             List<Long> gatewayIds = Arrays.stream(myGateway.split(","))
@@ -1231,11 +1261,6 @@ public class ApiController extends BaseController {
             String wgName = param.get("wgName");
             if(StringUtils.isNotBlank(wgName)){
                 ccGatewaysVo.setGwName(wgName);
-            }else{
-                // 网关用途 0 已废弃; 1 电话条; 2 外呼任务; 3 无限制
-                Map<String, Object> params = new HashMap<>();
-                params.put("purposes", Arrays.asList(1,3));
-                ccGatewaysVo.setParams(params);
             }
         }
         //3.获取工具条网关列表
@@ -1321,7 +1346,7 @@ public class ApiController extends BaseController {
         List<CcExtNum> list = ccExtNumService.selectCcExtNumList(ccExtNum);
         if(!CollectionUtils.isEmpty(list)){
             list.forEach(extNum -> {
-                if(StringUtils.isNotBlank(extNum.getUserCode()) && (extNum.getUserCode().startsWith("Runtian_cp_") || extNum.getUserCode().startsWith("_cp_"))){
+                if(StringUtils.isNotBlank(extNum.getUserCode()) && (extNum.getUserCode().startsWith("Runtian_cp_") || extNum.getUserCode().contains("_cp_"))){
                     extNum.setUserCode("");
                 }
             });
@@ -1437,29 +1462,32 @@ public class ApiController extends BaseController {
         AuthorizationUtils.clearAllCachedAuthorizationInfo();
         int i = userService.updateUser(user);
         if(i>0){
-            int cleanNum;
-            if(StringUtils.isNotBlank(user.getUserSource()) && (user.getUserSource().equals("0") || user.getUserSource().equals("2"))){
-                cleanNum = ccExtNumService.updateUserCodeByLoginName(user.getCompanyName(),user.getLoginName());
+            //这里可能不存在绑定信息
+            if(StringUtils.isNotBlank(user.getUserSource()) && user.getUserSource().equals("0")){
+                ccExtNumService.updateUserCodeByLoginName(user.getCompanyName(),user.getLoginName());
             }else{
                 //先清除原分机绑定
-                cleanNum = ccExtNumService.cleanCcExtNumByUserCode(user.getLoginName());
+                ccExtNumService.cleanCcExtNumByUserCode(user.getLoginName());
             }
-            if(cleanNum>0){
-                //修改绑定分机
-                CcExtNum extNum = new CcExtNum();
-                extNum.setExtNum(user.getExtNum());
-                extNum.setUserCode(user.getLoginName());
-                int updateNum = ccExtNumService.updateCcExtNumByUserCode(extNum);
-                if(updateNum>0){
-                    CcExtNum num = ccExtNumService.selectCcExtNumByExtNum(user.getExtNum());
-                    if(null != num){
-                        user.setExtPass(num.getExtPass());
-                        return AjaxResult.success(user);
-                    }
+            //修改绑定分机
+            CcExtNum extNum = new CcExtNum();
+            extNum.setExtNum(user.getExtNum());
+            extNum.setUserCode(user.getLoginName());
+            int updateNum = ccExtNumService.updateCcExtNumByUserCode(extNum);
+            if(updateNum>0){
+                CcExtNum num = ccExtNumService.selectCcExtNumByExtNum(user.getExtNum());
+                if(null != num){
+                    user.setExtPass(num.getExtPass());
+                    return AjaxResult.success(user);
+                }else{
+                    throw new RuntimeException("分机号:"+user.getExtNum()+" 不存在");
                 }
+            }else{
+                throw new RuntimeException("分机号重绑失败");
             }
+        }else{
+            throw new RuntimeException("修改用户失败");
         }
-        throw new RuntimeException("修改用户失败");
     }
 
     /**
@@ -1525,7 +1553,29 @@ public class ApiController extends BaseController {
         mmap.put("countys", countys);
         return AjaxResult.success(mmap);
     }
-
+    /**
+     * 同步本地分机号绑定
+     * 远端传入分机号与工号集合,按 extNum 批量更新 userCode
+     */
+    @PostMapping("/syncLocalExtNumBind")
+    @ResponseBody
+    @Transactional
+    public AjaxResult syncLocalExtNumBind(@RequestBody List<CcExtNum> extNumList)
+    {
+        if (CollectionUtils.isEmpty(extNumList)) {
+            return AjaxResult.error("参数为空");
+        }
+        List<CcExtNum> validList = extNumList.stream()
+                .filter(item -> item != null && item.getExtNum() != null)
+                .map(item -> new CcExtNum()
+                        .setExtNum(item.getExtNum())
+                        .setUserCode(item.getUserCode() == null ? "" : item.getUserCode()))
+                .collect(Collectors.toList());
+        if (CollectionUtils.isEmpty(validList)) {
+            return AjaxResult.error("有效分机号为空");
+        }
+        return toAjax(ccExtNumService.batchUpdateUserCodeByExtNum(validList));
+    }
     /**
      * 新增保存手动外呼沟通记录
      */

+ 89 - 8
ruoyi-admin/src/main/java/com/ruoyi/aicall/controller/CcCallPhoneController.java

@@ -58,6 +58,7 @@ public class CcCallPhoneController extends BaseController
 
     /**
      * 查询外呼号码列表
+     * 两阶段查询:先分页查 id(避开大字段),再按 id 回表列表字段;dialogue/biz_json/tts_text 按需加载
      */
     @RequiresPermissions("aicall:callPhone:list")
     @PostMapping("/list")
@@ -66,16 +67,26 @@ public class CcCallPhoneController extends BaseController
     public TableDataInfo list(CcCallPhone ccCallPhone)
     {
         Long t0 = System.currentTimeMillis();
+        // 任务名称已通过 batchId 选择;若仍传 batchName,先解析为 batchIds,避免大表 JOIN
+        resolveBatchNameToIds(ccCallPhone);
+
         startPage();
-        List<CcCallPhone> list = ccCallPhoneService.selectCcCallPhoneList(ccCallPhone);
-        logger.info("通话记录查询执行sql耗时:{}ms", (System.currentTimeMillis() - t0));
-        TableDataInfo tableDataInfo = getDataTable(list);
-        List<CcCallPhone> records = (List<CcCallPhone>) tableDataInfo.getRows();
+        List<String> ids = ccCallPhoneService.selectCcCallPhoneIds(ccCallPhone);
+        logger.info("通话记录查询id分页耗时:{}ms, size={}", (System.currentTimeMillis() - t0), ids == null ? 0 : ids.size());
+        TableDataInfo tableDataInfo = getDataTable(ids);
+        if (ids == null || ids.isEmpty()) {
+            return tableDataInfo;
+        }
+
+        Long t1 = System.currentTimeMillis();
+        List<CcCallPhone> records = ccCallPhoneService.selectCcCallPhoneLiteByIds(ids);
+        fillBatchNames(records);
         for (CcCallPhone data: records) {
-            if (data.getWavfile().startsWith("/")) {
-                data.setWavfile(data.getWavfile().substring(1));
+            String wavfile = data.getWavfile();
+            if (StringUtils.isNotEmpty(wavfile) && wavfile.startsWith("/")) {
+                wavfile = wavfile.substring(1);
             }
-            data.setWavfile("/recordings/files?filename=" + data.getWavfile());
+            data.setWavfile("/recordings/files?filename=" + (wavfile == null ? "" : wavfile));
             data.setTelephone(CommonUtils.maskPhoneNumber(data.getTelephone()));
             data.setCallerNumber(CommonUtils.maskPhoneNumber(data.getCallerNumber()));
             if (StringUtils.isBlank(data.getBatchName())) {
@@ -105,10 +116,80 @@ public class CcCallPhoneController extends BaseController
             }
         }
         tableDataInfo.setRows(records);
-        logger.info("通话记录查询后端累计耗时:{}ms", (System.currentTimeMillis() - t0));
+        logger.info("通话记录回表组装耗时:{}ms, 后端累计:{}ms", (System.currentTimeMillis() - t1), (System.currentTimeMillis() - t0));
         return tableDataInfo;
     }
 
+    /**
+     * 按需加载通话详情大字段(播放录音时拉取 dialogue 等)
+     */
+    @RequiresPermissions("aicall:callPhone:list")
+    @GetMapping("/detail/{id}")
+    @ResponseBody
+    public AjaxResult detail(@PathVariable("id") String id)
+    {
+        CcCallPhone phone = ccCallPhoneService.selectCcCallPhoneById(id);
+        if (phone == null) {
+            return AjaxResult.error("记录不存在");
+        }
+        Map<String, Object> data = new HashMap<>();
+        data.put("id", phone.getId());
+        data.put("uuid", phone.getUuid());
+        data.put("dialogue", phone.getDialogue());
+        data.put("bizJson", phone.getBizJson());
+        data.put("ttsText", phone.getTtsText());
+        data.put("wavfile", phone.getWavfile());
+        data.put("recordServerUrl", phone.getRecordServerUrl());
+        return AjaxResult.success(data);
+    }
+
+    private void resolveBatchNameToIds(CcCallPhone ccCallPhone) {
+        if (ccCallPhone.getBatchId() != null || StringUtils.isEmpty(ccCallPhone.getBatchName())) {
+            return;
+        }
+        List<CcCallTask> tasks = ccCallTaskService.selectCcCallTaskList(
+                new CcCallTask().setBatchName(ccCallPhone.getBatchName()));
+        List<Long> batchIds = new ArrayList<>();
+        if (tasks != null) {
+            for (CcCallTask task : tasks) {
+                if (task.getBatchId() != null) {
+                    batchIds.add(task.getBatchId());
+                }
+            }
+        }
+        Map<String, Object> params = ccCallPhone.getParams();
+        if (params == null) {
+            params = new HashMap<>();
+            ccCallPhone.setParams(params);
+        }
+        // 无匹配任务时放入空列表,后续 SQL 用不可能命中的条件短路
+        if (batchIds.isEmpty()) {
+            batchIds.add(-1L);
+        }
+        params.put("batchIds", batchIds);
+        ccCallPhone.setBatchName(null);
+    }
+
+    private void fillBatchNames(List<CcCallPhone> records) {
+        Set<Long> batchIdSet = new HashSet<>();
+        for (CcCallPhone data : records) {
+            if (data.getBatchId() != null) {
+                batchIdSet.add(data.getBatchId());
+            }
+        }
+        if (batchIdSet.isEmpty()) {
+            return;
+        }
+        Map<Long, String> batchNameMap = new HashMap<>();
+        for (Long batchId : batchIdSet) {
+            CcCallTask task = ccCallTaskService.selectCcCallTaskByBatchId(batchId);
+            batchNameMap.put(batchId, task == null || StringUtils.isBlank(task.getBatchName()) ? "-" : task.getBatchName());
+        }
+        for (CcCallPhone data : records) {
+            data.setBatchName(batchNameMap.getOrDefault(data.getBatchId(), "-"));
+        }
+    }
+
     /**
      * 导出外呼号码列表
      */

+ 16 - 9
ruoyi-admin/src/main/java/com/ruoyi/aicall/controller/CcCallTaskController.java

@@ -92,9 +92,21 @@ public class CcCallTaskController extends BaseController
         List<CcCallTask> list = ccCallTaskService.selectCcCallTaskList(ccCallTask);
         TableDataInfo tableDataInfo = getDataTable(list);
         List<CcCallTask> records = (List<CcCallTask>) tableDataInfo.getRows();
+        if (records == null || records.isEmpty()) {
+            return tableDataInfo;
+        }
+        List<Long> batchIds = new ArrayList<>(records.size());
+        for (CcCallTask data : records) {
+            batchIds.add(data.getBatchId());
+        }
+        Map<Long, CallTaskStatModel> statMap = ccCallPhoneService.statByBatchIds(batchIds);
         Integer allDelDays = Integer.valueOf(paramsService.getParamValueByCode("callTask_allowDel_days", "30"));
+        long allowDelDeadline = System.currentTimeMillis() - allDelDays * 24L * 3600L * 1000L;
         for (CcCallTask data: records){
-            CallTaskStatModel statModel = ccCallPhoneService.statByBatchId(data.getBatchId());
+            CallTaskStatModel statModel = statMap.get(data.getBatchId());
+            if (statModel == null) {
+                statModel = new CallTaskStatModel();
+            }
             data.setPhoneCount(statModel.getPhoneCount());
             data.setCallCount(statModel.getCallCount());
             data.setNoCallCount(statModel.getPhoneCount() - statModel.getCallCount());
@@ -105,17 +117,10 @@ public class CcCallTaskController extends BaseController
             } else {
                 data.setRealConnectRate(0.0);
             }
-//            if (StringUtils.isNotEmpty(data.getVoiceCode())) {
-//                CcTtsAliyun ccTtsAliyun = ttsAliyunService.selectCcTtsAliyunByVoiceCode(data.getVoiceCode());
-//                if (null != ccTtsAliyun) {
-//                    data.setVoiceSource(ccTtsAliyun.getVoiceSource());
-//                    data.setProvider(ccTtsAliyun.getProvider());
-//                }
-//            }
             data.setAllowDel(0);
             if (data.getIfcall() == 0
                     && data.getStopTime() > 0
-                    && (System.currentTimeMillis() - allDelDays * 24*3600*1000L) > data.getStopTime()) {
+                    && allowDelDeadline > data.getStopTime()) {
                 data.setAllowDel(1);
             }
         }
@@ -556,6 +561,8 @@ public class CcCallTaskController extends BaseController
     @ResponseBody
     public AjaxResult all(CcCallTask ccCallTask)
     {
+        // 供通话记录页任务名称下拉模糊搜索,限制返回条数避免拖慢
+        startPage(1, 20);
         List<CcCallTask> list = ccCallTaskService.selectCcCallTaskList(ccCallTask);
         return AjaxResult.success(list);
     }

+ 17 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/mapper/CcCallPhoneMapper.java

@@ -31,6 +31,16 @@ public interface CcCallPhoneMapper
      */
     public List<CcCallPhone> selectCcCallPhoneList(CcCallPhone ccCallPhone);
 
+    /**
+     * 列表加速:仅查询符合条件的主键(供分页/COUNT)
+     */
+    List<String> selectCcCallPhoneIds(CcCallPhone ccCallPhone);
+
+    /**
+     * 列表加速:按主键批量回表轻量字段(不含 dialogue/biz_json/tts_text)
+     */
+    List<CcCallPhone> selectCcCallPhoneLiteByIds(@Param("ids") List<String> ids);
+
     /**
      * 新增外呼号码
      * 
@@ -70,6 +80,13 @@ public interface CcCallPhoneMapper
      */
     CallTaskStatModel statByBatchId(Long batchId);
 
+    /**
+     * 根据多个batchId批量统计外呼数据
+     * @param batchIds
+     * @return
+     */
+    List<CallTaskStatModel> statByBatchIds(@Param("batchIds") List<Long> batchIds);
+
     /**
      * 批量入库
      * @param phoneList

+ 18 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/service/ICcCallPhoneService.java

@@ -6,6 +6,7 @@ import com.ruoyi.aicall.model.CallPhoneExportVo;
 import com.ruoyi.aicall.model.CallTaskStatModel;
 
 import java.util.List;
+import java.util.Map;
 
 /**
  * 外呼号码Service接口
@@ -31,6 +32,16 @@ public interface ICcCallPhoneService
      */
     public List<CcCallPhone> selectCcCallPhoneList(CcCallPhone ccCallPhone);
 
+    /**
+     * 列表加速:仅查询符合条件的主键(供分页/COUNT)
+     */
+    List<String> selectCcCallPhoneIds(CcCallPhone ccCallPhone);
+
+    /**
+     * 列表加速:按主键批量回表轻量字段(不含 dialogue/biz_json/tts_text)
+     */
+    List<CcCallPhone> selectCcCallPhoneLiteByIds(List<String> ids);
+
     /**
      * 新增外呼号码
      * 
@@ -70,6 +81,13 @@ public interface ICcCallPhoneService
      */
     public CallTaskStatModel statByBatchId(Long batchId);
 
+    /**
+     * 根据多个batchId批量统计外呼数据
+     * @param batchIds
+     * @return key=batchId
+     */
+    Map<Long, CallTaskStatModel> statByBatchIds(List<Long> batchIds);
+
     /**
      * 批量入库
      * @param phoneList

+ 84 - 45
ruoyi-admin/src/main/java/com/ruoyi/aicall/service/impl/CcCallPhoneServiceImpl.java

@@ -55,58 +55,72 @@ public class CcCallPhoneServiceImpl implements ICcCallPhoneService
     @Override
     public List<CcCallPhone> selectCcCallPhoneList(CcCallPhone ccCallPhone)
     {
+        normalizeListQueryParams(ccCallPhone);
+        return ccCallPhoneMapper.selectCcCallPhoneList(ccCallPhone);
+    }
 
-        Map<String, Object> params = ccCallPhone.getParams();
-        if (null != params.get("calloutTimeStart")
-                && !"".equals(params.get("calloutTimeStart"))) {
-            params.put("calloutTimeStart", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("calloutTimeStart")).getTime());
-        }
-        if (null != params.get("calloutTimeEnd")
-                && !"".equals(params.get("calloutTimeEnd"))) {
-            params.put("calloutTimeEnd", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("calloutTimeEnd")).getTime());
-        }
-        if (null != params.get("callEndTimeStart")
-                && !"".equals(params.get("callEndTimeStart"))) {
-            params.put("callEndTimeStart", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("callEndTimeStart")).getTime());
-        }
-        if (null != params.get("callEndTimeEnd")
-                && !"".equals(params.get("callEndTimeEnd"))) {
-            params.put("callEndTimeEnd", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("callEndTimeEnd")).getTime());
-        }
-        if (null != params.get("connectedTimeStart")
-                && !"".equals(params.get("connectedTimeStart"))) {
-            params.put("connectedTimeStart", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("connectedTimeStart")).getTime());
-        }
-        if (null != params.get("connectedTimeEnd")
-                && !"".equals(params.get("connectedTimeEnd"))) {
-            params.put("connectedTimeEnd", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("connectedTimeEnd")).getTime());
-        }
-        if (null != params.get("timeLenStart")
-                && !"".equals(params.get("timeLenStart"))) {
-            params.put("timeLenStart", Double.valueOf((String)params.get("timeLenStart")) * 60 * 1000L);
+    @Override
+    public List<String> selectCcCallPhoneIds(CcCallPhone ccCallPhone) {
+        normalizeListQueryParams(ccCallPhone);
+        return ccCallPhoneMapper.selectCcCallPhoneIds(ccCallPhone);
+    }
+
+    @Override
+    public List<CcCallPhone> selectCcCallPhoneLiteByIds(List<String> ids) {
+        if (ids == null || ids.isEmpty()) {
+            return new java.util.ArrayList<>();
         }
-        if (null != params.get("timeLenEnd")
-                && !"".equals(params.get("timeLenEnd"))) {
-            params.put("timeLenEnd", Double.valueOf((String)params.get("timeLenEnd")) * 60 * 1000L);
+        return ccCallPhoneMapper.selectCcCallPhoneLiteByIds(ids);
+    }
+
+    /**
+     * 统一处理列表查询时间参数;已是数值则不再转换,避免重复转换异常
+     */
+    private void normalizeListQueryParams(CcCallPhone ccCallPhone) {
+        Map<String, Object> params = ccCallPhone.getParams();
+        if (params == null) {
+            params = new HashMap<>();
+            ccCallPhone.setParams(params);
+            return;
         }
-        if (null != params.get("timeLenSecondStart")
-                && !"".equals(params.get("timeLenSecondStart"))) {
-            params.put("timeLenSecondStart", Double.valueOf((String)params.get("timeLenSecondStart")) * 60 * 1000L);
+        convertStringDateToMillis(params, "calloutTimeStart");
+        convertStringDateToMillis(params, "calloutTimeEnd");
+        convertStringDateToMillis(params, "callEndTimeStart");
+        convertStringDateToMillis(params, "callEndTimeEnd");
+        convertStringDateToMillis(params, "connectedTimeStart");
+        convertStringDateToMillis(params, "connectedTimeEnd");
+        convertStringDateToMillis(params, "answeredTimeStart");
+        convertStringDateToMillis(params, "answeredTimeEnd");
+        // 页面时长按“分钟”筛选(历史逻辑)
+        convertMinutesToMillis(params, "timeLenStart");
+        convertMinutesToMillis(params, "timeLenEnd");
+        convertMinutesToMillis(params, "timeLenSecondStart");
+        convertMinutesToMillis(params, "timeLenSecondEnd");
+        ccCallPhone.setParams(params);
+    }
+
+    private void convertStringDateToMillis(Map<String, Object> params, String key) {
+        Object value = params.get(key);
+        if (!(value instanceof String) || StringUtils.isEmpty((String) value)) {
+            return;
         }
-        if (null != params.get("timeLenSecondEnd")
-                && !"".equals(params.get("timeLenSecondEnd"))) {
-            params.put("timeLenSecondEnd", Double.valueOf((String)params.get("timeLenSecondEnd")) * 60 * 1000L);
+        String text = ((String) value).trim();
+        if (text.matches("^\\d+$")) {
+            params.put(key, Long.parseLong(text));
+            return;
         }
-        if (null != params.get("answeredTimeStart")
-                && !"".equals(params.get("answeredTimeStart"))) {
-            params.put("answeredTimeStart", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("answeredTimeStart")).getTime());
+        params.put(key, DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", text).getTime());
+    }
+
+    private void convertMinutesToMillis(Map<String, Object> params, String key) {
+        Object value = params.get(key);
+        if (value == null || "".equals(value)) {
+            return;
         }
-        if (null != params.get("answeredTimeEnd")
-                && !"".equals(params.get("answeredTimeEnd"))) {
-            params.put("answeredTimeEnd", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("answeredTimeEnd")).getTime());
+        if (value instanceof Number) {
+            return;
         }
-        ccCallPhone.setParams(params);
-        return ccCallPhoneMapper.selectCcCallPhoneList(ccCallPhone);
+        params.put(key, Double.valueOf(value.toString()) * 60 * 1000L);
     }
 
     /**
@@ -160,6 +174,31 @@ public class CcCallPhoneServiceImpl implements ICcCallPhoneService
     @Override
     public CallTaskStatModel statByBatchId(Long batchId) {
         CallTaskStatModel callTaskStatModel = ccCallPhoneMapper.statByBatchId(batchId);
+        return normalizeStat(callTaskStatModel);
+    }
+
+    @Override
+    public Map<Long, CallTaskStatModel> statByBatchIds(List<Long> batchIds) {
+        Map<Long, CallTaskStatModel> result = new HashMap<>();
+        if (batchIds == null || batchIds.isEmpty()) {
+            return result;
+        }
+        List<CallTaskStatModel> stats = ccCallPhoneMapper.statByBatchIds(batchIds);
+        if (stats != null) {
+            for (CallTaskStatModel stat : stats) {
+                CallTaskStatModel normalized = normalizeStat(stat);
+                if (normalized.getBatchId() != null) {
+                    result.put(normalized.getBatchId(), normalized);
+                }
+            }
+        }
+        return result;
+    }
+
+    private CallTaskStatModel normalizeStat(CallTaskStatModel callTaskStatModel) {
+        if (callTaskStatModel == null) {
+            callTaskStatModel = new CallTaskStatModel();
+        }
         if (null == callTaskStatModel.getPhoneCount()) {
             callTaskStatModel.setPhoneCount(0);
         }

+ 48 - 12
ruoyi-admin/src/main/java/com/ruoyi/aicall/utils/ClientIpCheck.java

@@ -12,7 +12,6 @@ public class ClientIpCheck {
 
     public static Boolean checkIp(HttpServletRequest request){
         String clientIp = getClientIp(request);
-        log.info("get client ip:{}", clientIp);
         ICcParamsService ccParamsService = SpringUtils.getBean(ICcParamsService.class);
         String whiteIpList = ccParamsService.getParamValueByCode("api-client-white-ips", "");
         if (StringUtils.isNotEmpty(whiteIpList)) {
@@ -29,21 +28,58 @@ public class ClientIpCheck {
         return true;
     }
 
+    /**
+     * 解析客户端 IP,并打印诊断日志(XFF / X-Real-IP / remoteAddr / 选用源 / URI)。
+     * 用于排查偶发 172.17.0.1(docker0)与公网 IP 并存的问题。
+     */
     public static String getClientIp(HttpServletRequest request) {
-        String[] headers = {
-                "X-Forwarded-For",
-                "X-Real-IP",
-                "Proxy-Client-IP",
-                "WL-Proxy-Client-IP",
-                "HTTP_CLIENT_IP",
-                "HTTP_X_FORWARDED_FOR"
+        String xff = request.getHeader("X-Forwarded-For");
+        String xRealIp = request.getHeader("X-Real-IP");
+        String proxyClientIp = request.getHeader("Proxy-Client-IP");
+        String wlProxyClientIp = request.getHeader("WL-Proxy-Client-IP");
+        String httpClientIp = request.getHeader("HTTP_CLIENT_IP");
+        String httpXff = request.getHeader("HTTP_X_FORWARDED_FOR");
+        String remoteAddr = request.getRemoteAddr();
+        String uri = request.getRequestURI();
+        String query = request.getQueryString();
+
+        String[][] headerCandidates = {
+                {"X-Forwarded-For", xff},
+                {"X-Real-IP", xRealIp},
+                {"Proxy-Client-IP", proxyClientIp},
+                {"WL-Proxy-Client-IP", wlProxyClientIp},
+                {"HTTP_CLIENT_IP", httpClientIp},
+                {"HTTP_X_FORWARDED_FOR", httpXff}
         };
-        for (String h : headers) {
-            String value = request.getHeader(h);
+
+        String chosenSource = "remoteAddr";
+        String clientIp = remoteAddr;
+        for (String[] item : headerCandidates) {
+            String name = item[0];
+            String value = item[1];
             if (value != null && !value.isEmpty() && !"unknown".equalsIgnoreCase(value)) {
-                return value.split(",")[0].trim();
+                clientIp = value.split(",")[0].trim();
+                chosenSource = name;
+                break;
             }
         }
-        return request.getRemoteAddr();
+
+        log.info("get client ip:{}, source={}, X-Forwarded-For={}, X-Real-IP={}, remoteAddr={}, uri={}{}",
+                clientIp,
+                chosenSource,
+                emptyToDash(xff),
+                emptyToDash(xRealIp),
+                emptyToDash(remoteAddr),
+                uri,
+                query == null ? "" : ("?" + query));
+
+        return clientIp;
+    }
+
+    private static String emptyToDash(String value) {
+        if (value == null || value.isEmpty()) {
+            return "-";
+        }
+        return value;
     }
 }

+ 48 - 10
ruoyi-admin/src/main/java/com/ruoyi/cc/controller/CcInboundCdrController.java

@@ -16,6 +16,7 @@ import com.ruoyi.common.core.domain.entity.SysUser;
 import com.ruoyi.common.core.page.TableDataInfo;
 import com.ruoyi.common.enums.BusinessType;
 import com.ruoyi.common.utils.CommonUtils;
+import com.ruoyi.common.utils.DateUtils;
 import com.ruoyi.common.utils.ExceptionUtil;
 import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.common.utils.StringUtils;
@@ -31,6 +32,7 @@ import org.springframework.ui.ModelMap;
 import org.springframework.web.bind.annotation.*;
 
 import java.util.ArrayList;
+import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -100,25 +102,30 @@ public class CcInboundCdrController extends BaseController
         }
         params.put("manageOpnum", manageOpnum);
         ccInboundCdr.setParams(params);
+        normalizeQueryTimeParams(ccInboundCdr);
 
-        Map<String, String> groupNames = new HashMap<>();
         startPage();
         List<CcInboundCdr> list = ccInboundCdrService.selectCcInboundCdrList(ccInboundCdr);
         TableDataInfo tableDataInfo = getDataTable(list);
         List<CcInboundCdr> records = (List<CcInboundCdr>) tableDataInfo.getRows();
+        if (records == null || records.isEmpty()) {
+            return tableDataInfo;
+        }
+
+        Map<String, String> groupNames = new HashMap<>();
+        for (CcBizGroup group : groupService.selectCcBizGroupList(new CcBizGroup())) {
+            if (group.getGroupId() != null) {
+                groupNames.put(String.valueOf(group.getGroupId()), group.getBizGroupName());
+            }
+        }
         for (CcInboundCdr data: records) {
-            data.setWavFileUrl("/recordings/files?filename=" + data.getWavFile());
+            String wavFile = data.getWavFile();
+            data.setWavFileUrl("/recordings/files?filename=" + (wavFile == null ? "" : wavFile));
             data.setCaller(CommonUtils.maskPhoneNumber(data.getCaller()));
-            String groupName = groupNames.getOrDefault(data.getGroupId(), "");
+            String groupName = groupNames.getOrDefault(data.getGroupId(), "-");
             if (StringUtils.isBlank(groupName)) {
-                CcBizGroup ccBizGroup = groupService.selectCcBizGroupByGroupId(data.getGroupId());
-                if (null != ccBizGroup) {
-                    groupName = ccBizGroup.getBizGroupName();
-                } else {
-                    groupName = "-";
-                }
+                groupName = "-";
             }
-            groupNames.put(data.getGroupId(), groupName);
             data.setGroupName(groupName);
 
             // 挂机原因处理
@@ -149,6 +156,37 @@ public class CcInboundCdrController extends BaseController
         return tableDataInfo;
     }
 
+    /**
+     * 前端传入 yyyy-MM-dd HH:mm:ss,库字段为毫秒时间戳,统一转换以便命中索引
+     */
+    private void normalizeQueryTimeParams(CcInboundCdr ccInboundCdr) {
+        Map<String, Object> params = ccInboundCdr.getParams();
+        if (params == null || params.isEmpty()) {
+            return;
+        }
+        convertDatetimeParamToMillis(params, "inboundTimeStart");
+        convertDatetimeParamToMillis(params, "inboundTimeEnd");
+        convertDatetimeParamToMillis(params, "answeredTimeStart");
+        convertDatetimeParamToMillis(params, "answeredTimeEnd");
+        convertDatetimeParamToMillis(params, "hangupTimeStart");
+        convertDatetimeParamToMillis(params, "hangupTimeEnd");
+    }
+
+    private void convertDatetimeParamToMillis(Map<String, Object> params, String key) {
+        Object value = params.get(key);
+        if (value == null || StringUtils.isEmpty(value.toString())) {
+            return;
+        }
+        String text = value.toString().trim();
+        if (text.matches("^\\d+$")) {
+            return;
+        }
+        Date date = DateUtils.parseDate(text);
+        if (date != null) {
+            params.put(key, date.getTime());
+        }
+    }
+
     /**
      * 导出呼入记录列表
      */

+ 10 - 0
ruoyi-admin/src/main/java/com/ruoyi/cc/mapper/CcExtNumMapper.java

@@ -105,4 +105,14 @@ public interface CcExtNumMapper
      * 销售归还公司分机
      */
     int updateUserCodeByLoginName(@Param("companyName") String companyName,@Param("loginName") String loginName);
+
+    /**
+     * 根据分机号批量更新绑定工号
+     *
+     * @param list 分机号与工号集合
+     * @return 结果
+     */
+    int batchUpdateUserCodeByExtNum(@Param("list") List<CcExtNum> list);
+
+    CcExtNum selectCcExtNumByUserCodeTwo(@Param("username") String username);
 }

+ 10 - 0
ruoyi-admin/src/main/java/com/ruoyi/cc/service/ICcExtNumService.java

@@ -113,4 +113,14 @@ public interface ICcExtNumService
     int companyBatchUnbindSipExt(Set<Long> extNums);
 
     int updateUserCodeByLoginName(String companyName, String loginName);
+
+    /**
+     * 根据分机号批量更新绑定工号
+     *
+     * @param list 分机号与工号集合
+     * @return 结果
+     */
+    int batchUpdateUserCodeByExtNum(List<CcExtNum> list);
+
+    CcExtNum selectCcExtNumByUserCodeTwo(String username);
 }

+ 10 - 0
ruoyi-admin/src/main/java/com/ruoyi/cc/service/impl/CcExtNumServiceImpl.java

@@ -357,4 +357,14 @@ public class CcExtNumServiceImpl implements ICcExtNumService
     public int updateUserCodeByLoginName(String companyName, String loginName) {
         return ccExtNumMapper.updateUserCodeByLoginName(companyName, loginName);
     }
+
+    @Override
+    public int batchUpdateUserCodeByExtNum(List<CcExtNum> list) {
+        return ccExtNumMapper.batchUpdateUserCodeByExtNum(list);
+    }
+
+    @Override
+    public CcExtNum selectCcExtNumByUserCodeTwo(String username) {
+        return ccExtNumMapper.selectCcExtNumByUserCodeTwo(username);
+    }
 }

+ 48 - 7
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysIndexController.java

@@ -95,6 +95,8 @@ public class SysIndexController extends BaseController
         mmap.put("mainClass", contentMainClass(footer, tagsView));
         mmap.put("copyrightYear", RuoYiConfig.getCopyrightYear());
         mmap.put("sysVersion", sysVersion);
+        mmap.put("isLocalDebug", RuoYiConfig.isLocalDebug());
+        mmap.put("localRemoteHost", RuoYiConfig.getLocalRemoteHost());
         if (user.getLoginName().equals("admin")) {
             mmap.put("demoEnabled", RuoYiConfig.isDemoEnabled());
         } else {
@@ -171,16 +173,40 @@ public class SysIndexController extends BaseController
         CookieUtils.setCookie(response, "nav-style", style);
     }
 
-    // 系统介绍
+    // system main / agent workbench (embed=1 supported)
     @GetMapping("/system/main")
     public String main(ModelMap mmap)
     {
         mmap.put("version", RuoYiConfig.getVersion());
+        boolean embed = "1".equals(ServletUtils.getParameter("embed"));
+        mmap.put("embedMode", embed);
+        mmap.put("isLocalDebug", RuoYiConfig.isLocalDebug());
+        mmap.put("localRemoteHost", RuoYiConfig.getLocalRemoteHost());
 
-        // 获取当前的用户
+        // current user
         SysUser currentUser = ShiroUtils.getSysUser();
-        // 获取分机号
-        CcExtNum ccExtNum = ccExtNumService.selectCcExtNumByUserCode(currentUser.getLoginName());
+        if (currentUser == null)
+        {
+            if (embed)
+            {
+                mmap.put("errorMsg", "登录会话无效,请重新外呼登录");
+                mmap.put("errorCode", "NO_SESSION");
+                return "error/embedWorkbench";
+            }
+            return "redirect:/login";
+        }
+        // extension number
+        CcExtNum ccExtNum = ccExtNumService.selectCcExtNumByUserCodeTwo(currentUser.getLoginName());
+        if (ccExtNum == null || ccExtNum.getExtNum() == null) {
+            // embed 下禁止 redirect:/login(会再次触发 LOGIN_READY,被 HIS 误判为 Cookie 回弹)
+            if (embed)
+            {
+                mmap.put("errorMsg", "未绑定分机号,请联系系统管理员");
+                mmap.put("errorCode", "NO_EXT");
+                return "error/embedWorkbench";
+            }
+            return "redirect:/login";
+        }
 
         String extnum = ccExtNum.getExtNum().toString();
         String opnum = ccExtNum.getUserCode();
@@ -191,10 +217,22 @@ public class SysIndexController extends BaseController
         String skillLevel = "9";
         String projectId = "1";
         String loginToken = ccExtNumService.createToken(extnum, opnum, groupId, skillLevel, projectId);
-        // 网关用途 0 dropped; 1 phonebar; 2 outbound tasks; 3. Unlimited
+
+        // myGateway from login; always restrict to purposes 1/3 (exclude 2 outbound tasks)
+        String myGateway = (String) CacheUtils.get("myGateway-" + currentUser.getLoginName());
+        CcGateways ccGatewaysVo = new CcGateways();
         Map<String, Object> params = new HashMap<>();
-        params.put("purposes", Arrays.asList(1,3));
-        List<CcGateways> gatewaysList = ccGatewaysService.selectCcGatewaysList(new CcGateways().setParams(params));
+        params.put("purposes", Arrays.asList(1, 3));
+        ccGatewaysVo.setParams(params);
+        if (StringUtils.isNotBlank(myGateway)) {
+            List<Long> gatewayIds = Arrays.stream(myGateway.split(","))
+                    .filter(StringUtils::isNotBlank)
+                    .map(String::trim)
+                    .map(Long::parseLong)
+                    .collect(java.util.stream.Collectors.toList());
+            ccGatewaysVo.setGatewayIds(gatewayIds);
+        }
+        List<CcGateways> gatewaysList = ccGatewaysService.selectCcGatewaysList(ccGatewaysVo);
         List<JSONObject> gatewayList = new ArrayList<>();
         for (CcGateways ccGateways: gatewaysList) {
             JSONObject configGateway = new JSONObject();
@@ -221,6 +259,9 @@ public class SysIndexController extends BaseController
         callConfig.put("scriptPort", scriptPort);
         callConfig.put("loginToken", loginToken);
         callConfig.put("gatewayList", gatewayList);
+        callConfig.put("extNum", extnum);
+        callConfig.put("opNum", opnum);
+        callConfig.put("myGateway", myGateway);
         mmap.put("callConfig", callConfig);
         String autoFree = ccParamsService.getParamValueByCode("phone-bar-free-auto", "false");
         mmap.put("autoFree", Boolean.valueOf(autoFree));

+ 44 - 7
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysLoginController.java

@@ -4,7 +4,9 @@ import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
 import com.ruoyi.cc.domain.CcExtNum;
+import com.ruoyi.cc.domain.CcGateways;
 import com.ruoyi.cc.service.ICcExtNumService;
+import com.ruoyi.cc.service.ICcGatewaysService;
 import com.ruoyi.common.utils.CacheUtils;
 import org.apache.shiro.SecurityUtils;
 import org.apache.shiro.authc.AuthenticationException;
@@ -22,8 +24,15 @@ import com.ruoyi.common.core.domain.AjaxResult;
 import com.ruoyi.common.core.text.Convert;
 import com.ruoyi.common.utils.ServletUtils;
 import com.ruoyi.common.utils.StringUtils;
+import com.ruoyi.framework.shiro.web.filter.embed.EmbedRequestUtils;
 import com.ruoyi.framework.web.service.ConfigService;
 
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
 /**
  * 登录验证
  * 
@@ -54,6 +63,8 @@ public class SysLoginController extends BaseController
     private ConfigService configService;
     @Autowired
     private ICcExtNumService ccExtNumService;
+    @Autowired
+    private ICcGatewaysService ccGatewaysService;
 
     @GetMapping("/login")
     public String login(HttpServletRequest request, HttpServletResponse response, ModelMap mmap)
@@ -76,17 +87,43 @@ public class SysLoginController extends BaseController
 
     @PostMapping("/login")
     @ResponseBody
-    public AjaxResult ajaxLogin(String username, String password, String extNum, String groupId, Boolean rememberMe)
+    public AjaxResult ajaxLogin(String username, String password, String extNum, String groupId, String myGateway, Boolean rememberMe)
     {
-        // 校验分机号
-        CcExtNum ccExtNum = ccExtNumService.selectCcExtNumByUserCode(username);
+        boolean embed = EmbedRequestUtils.isEmbed(ServletUtils.getRequest());
+        // 校验分机号(HIS iframe 嵌入必须绑定分机;禁止登录成功后再被 main 踢回登录)
+        CcExtNum ccExtNum = ccExtNumService.selectCcExtNumByUserCodeTwo(username);
         if (null == ccExtNum) {
-            return AjaxResult.error("未绑定分机号,请联系系统管理员!");
-        }
-        if (!ccExtNum.getExtNum().toString().equals(extNum)) {
-            return AjaxResult.error("分机号不正确!");
+            if (!embed && StringUtils.isNotEmpty(username) && username.startsWith("Runtian_")) {
+                // 非嵌入:保留润天账号兼容
+            } else {
+                return AjaxResult.error("未绑定分机号,请联系系统管理员!");
+            }
+        } else {
+            if (!ccExtNum.getExtNum().toString().equals(extNum)) {
+                return AjaxResult.error("username="+username+"的分机号不正确!线上:"+ccExtNum.getExtNum()+",传入的分机号:" + extNum);
+            }
         }
         CacheUtils.put("groupId-" + username, groupId);
+        // cache myGateway for workbench dialing; only purposes 1/3 allowed
+        if (StringUtils.isNotEmpty(myGateway)) {
+            List<Long> gatewayIds = Arrays.stream(myGateway.split(","))
+                    .filter(StringUtils::isNotBlank)
+                    .map(String::trim)
+                    .map(Long::parseLong)
+                    .collect(Collectors.toList());
+            CcGateways query = new CcGateways();
+            query.setGatewayIds(gatewayIds);
+            Map<String, Object> params = new HashMap<>();
+            params.put("purposes", Arrays.asList(1, 3));
+            query.setParams(params);
+            List<CcGateways> allowed = ccGatewaysService.selectCcGatewaysList(query);
+            if (allowed == null || allowed.isEmpty() || allowed.size() != gatewayIds.size()) {
+                return AjaxResult.error("指定网关无效,仅可选择电话条/不限制用途的网关");
+            }
+            CacheUtils.put("myGateway-" + username, myGateway);
+        } else {
+            CacheUtils.remove("myGateway-" + username);
+        }
         UsernamePasswordToken token = new UsernamePasswordToken(username, password, rememberMe);
         Subject subject = SecurityUtils.getSubject();
         try

+ 112 - 48
ruoyi-admin/src/main/resources/mapper/aicall/CcCallPhoneMapper.xml

@@ -53,60 +53,111 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
                total_cost, billing_status, caller_number, ivr_dtmf_digits, manual_answered_time, manual_answered_time_len from cc_call_phone
     </sql>
 
+    <sql id="ccCallPhoneListWhere">
+        <if test="uuid != null and uuid != '' "> and cp.uuid = #{uuid}</if>
+        <if test="telephone != null  and telephone != ''"> and cp.telephone = #{telephone}</if>
+        <if test="custName != null  and custName != ''"> and cp.cust_name = #{custName}</if>
+        <if test="callstatus != null "> and cp.callstatus = #{callstatus}</if>
+        <if test="params.calloutTimeStart != null and params.calloutTimeStart != ''"><!-- 开始时间检索 -->
+            AND cp.callout_time &gt;= #{params.calloutTimeStart}
+        </if>
+        <if test="params.calloutTimeEnd != null and params.calloutTimeEnd != ''"><!-- 结束时间检索 -->
+            AND cp.callout_time &lt;= #{params.calloutTimeEnd}
+        </if>
+        <if test="params.callEndTimeStart != null and params.callEndTimeStart != ''"><!-- 开始时间检索 -->
+            AND cp.call_end_time &gt;= #{params.callEndTimeStart}
+        </if>
+        <if test="params.callEndTimeEnd != null and params.callEndTimeEnd != ''"><!-- 结束时间检索 -->
+            AND cp.call_end_time &lt;= #{params.callEndTimeEnd}
+        </if>
+        <if test="params.timeLenStart != null and params.timeLenStart != ''"><!-- 开始时间检索 -->
+            AND cp.time_len &gt;= #{params.timeLenStart}
+        </if>
+        <if test="params.timeLenEnd != null and params.timeLenEnd != ''"><!-- 结束时间检索 -->
+            AND cp.time_len &lt;= #{params.timeLenEnd}
+        </if>
+        <if test="params.connectedTimeStart != null and params.connectedTimeStart != ''"><!-- 开始时间检索 -->
+            AND cp.connected_time &gt;= #{params.connectedTimeStart}
+        </if>
+        <if test="params.connectedTimeEnd != null and params.connectedTimeEnd != ''"><!-- 结束时间检索 -->
+            AND cp.connected_time &lt;= #{params.connectedTimeEnd}
+        </if>
+        <if test="params.answeredTimeStart != null and params.answeredTimeStart != ''"><!-- 开始时间检索 -->
+            AND cp.answered_time &gt;= #{params.answeredTimeStart}
+        </if>
+        <if test="params.answeredTimeEnd != null and params.answeredTimeEnd != ''"><!-- 结束时间检索 -->
+            AND cp.answered_time &lt;= #{params.answeredTimeEnd}
+        </if>
+        <if test="batchId != null">
+            AND cp.batch_id = #{batchId}
+        </if>
+        <if test="params.batchIds != null and params.batchIds.size() &gt; 0">
+            AND cp.batch_id IN
+            <foreach collection="params.batchIds" item="bid" open="(" separator="," close=")">
+                #{bid}
+            </foreach>
+        </if>
+        <if test="batchName != null and batchName != ''">
+            AND EXISTS (
+                SELECT 1 FROM cc_call_task ct
+                WHERE ct.batch_id = cp.batch_id
+                  AND ct.batch_name like concat('%', #{batchName}, '%')
+            )
+        </if>
+        <if test="intent != null and intent != ''">
+            AND cp.intent = #{intent}
+        </if>
+        <if test="billingStatus != null ">
+            and cp.billing_status = #{billingStatus}
+        </if>
+        <if test="callerNumber != null and callerNumber != '' ">
+            and cp.caller_number = #{callerNumber}
+        </if>
+        AND cp.callstatus &gt;= 3 AND cp.call_end_time &gt; 0
+    </sql>
+
+    <!-- 列表完整查询(导出/API/计费等仍可用,含大字段) -->
     <select id="selectCcCallPhoneList" parameterType="CcCallPhone" resultMap="CcCallPhoneResult">
-        select cp.*,ct.batch_name as batchName from cc_call_phone cp left join cc_call_task ct on cp.batch_id = ct.batch_id
+        select cp.*, ct.batch_name as batchName
+        from cc_call_phone cp
+        left join cc_call_task ct on cp.batch_id = ct.batch_id
         <where>
-            <if test="uuid != null and uuid != '' "> and cp.uuid = #{uuid}</if>
-            <if test="telephone != null  and telephone != ''"> and cp.telephone = #{telephone}</if>
-            <if test="custName != null  and custName != ''"> and cp.cust_name = #{custName}</if>
-            <if test="callstatus != null "> and cp.callstatus = #{callstatus}</if>
-            <if test="params.calloutTimeStart != null and params.calloutTimeStart != ''"><!-- 开始时间检索 -->
-                AND cp.callout_time &gt;= #{params.calloutTimeStart}
-            </if>
-            <if test="params.calloutTimeEnd != null and params.calloutTimeEnd != ''"><!-- 结束时间检索 -->
-                AND cp.callout_time &lt;= #{params.calloutTimeEnd}
-            </if>
-            <if test="params.callEndTimeStart != null and params.callEndTimeStart != ''"><!-- 开始时间检索 -->
-                AND cp.call_end_time &gt;= #{params.callEndTimeStart}
-            </if>
-            <if test="params.callEndTimeEnd != null and params.callEndTimeEnd != ''"><!-- 结束时间检索 -->
-                AND cp.call_end_time &lt;= #{params.callEndTimeEnd}
-            </if>
-            <if test="params.timeLenStart != null and params.timeLenStart != ''"><!-- 开始时间检索 -->
-                AND cp.time_len &gt;= #{params.timeLenStart}
-            </if>
-            <if test="params.timeLenEnd != null and params.timeLenEnd != ''"><!-- 结束时间检索 -->
-                AND cp.time_len &lt;= #{params.timeLenEnd}
-            </if>
-            <if test="params.connectedTimeStart != null and params.connectedTimeStart != ''"><!-- 开始时间检索 -->
-                AND cp.connected_time &gt;= #{params.connectedTimeStart}
-            </if>
-            <if test="params.connectedTimeEnd != null and params.connectedTimeEnd != ''"><!-- 结束时间检索 -->
-                AND cp.connected_time &lt;= #{connectedTimeEnd}
-            </if>
-            <if test="params.answeredTimeStart != null and params.answeredTimeStart != ''"><!-- 开始时间检索 -->
-                AND cp.answered_time &gt;= #{params.answeredTimeStart}
-            </if>
-            <if test="params.answeredTimeEnd != null and params.answeredTimeEnd != ''"><!-- 结束时间检索 -->
-                AND cp.answered_time &lt;= #{params.answeredTimeEnd}
-            </if>
-            <if test="batchId != null and batchId != ''">
-                AND cp.batch_id = #{batchId}
-            </if>
-            <if test="intent != null and intent != ''">
-                AND cp.intent = #{intent}
-            </if>
-            <if test="billingStatus != null ">
-                and cp.billing_status = #{billingStatus}
-            </if>
-            <if test="callerNumber != null and callerNumber != '' ">
-                and cp.caller_number = #{callerNumber}
-            </if>
-            AND cp.callstatus >= 3 AND cp.call_end_time > 0
+            <include refid="ccCallPhoneListWhere"/>
         </where>
         order by cp.call_end_time desc
     </select>
 
+    <!-- 列表加速第1步:仅查 id,避免分页/COUNT 触碰 TEXT 大字段 -->
+    <select id="selectCcCallPhoneIds" parameterType="CcCallPhone" resultType="java.lang.String">
+        select cp.id
+        from cc_call_phone cp
+        <where>
+            <include refid="ccCallPhoneListWhere"/>
+        </where>
+        order by cp.call_end_time desc
+    </select>
+
+    <!-- 列表加速第2步:按 id 回表列表展示字段(不含 dialogue/biz_json/tts_text) -->
+    <select id="selectCcCallPhoneLiteByIds" resultMap="CcCallPhoneResult">
+        select
+            cp.id, cp.batch_id, cp.telephone, cp.cust_name, cp.createtime, cp.callstatus,
+            cp.callout_time, cp.callcount, cp.call_end_time, cp.time_len, cp.valid_time_len,
+            cp.uuid, cp.connected_time, cp.hangup_cause, cp.answered_time,
+            cp.wavfile, cp.record_server_url, cp.dialogue_count, cp.acd_opnum,
+            cp.acd_queue_time, cp.acd_wait_time, cp.intent, cp.caller_number,
+            cp.ivr_dtmf_digits, cp.manual_answered_time, cp.manual_answered_time_len
+        from cc_call_phone cp
+        where cp.id in
+        <foreach collection="ids" item="id" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+        order by field(cp.id,
+        <foreach collection="ids" item="id" separator=",">
+            #{id}
+        </foreach>
+        )
+    </select>
+
     <select id="getCustIntentionList" parameterType="CcCallPhone" resultMap="CcCallPhoneResult">
         <include refid="selectCcCallPhoneVo"/>
           where intent = '' AND callstatus >= 3 AND call_end_time > 0 limit 100
@@ -227,6 +278,19 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         WHERE batch_id = #{batchId}
     </select>
 
+    <select id="statByBatchIds" resultType="com.ruoyi.aicall.model.CallTaskStatModel">
+        SELECT batch_id AS batchId,
+               COUNT(1) AS phoneCount,
+               SUM(CASE WHEN callout_time > 0 THEN 1 ELSE 0 END) AS callCount,
+               SUM(CASE WHEN answered_time > 0 THEN 1 ELSE 0 END) AS connectCount
+        FROM cc_call_phone
+        WHERE batch_id IN
+        <foreach collection="batchIds" item="batchId" open="(" separator="," close=")">
+            #{batchId}
+        </foreach>
+        GROUP BY batch_id
+    </select>
+
     <insert id="batchInsertCcCallPhone" parameterType="java.util.List">
         INSERT INTO cc_call_phone (
         id, batch_id, telephone, cust_name, createtime, callstatus,

+ 16 - 0
ruoyi-admin/src/main/resources/mapper/cc/CcExtNumMapper.xml

@@ -121,6 +121,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     <select id="selectMaxExtNum" resultType="java.lang.Long">
         select max(ext_num) from cc_ext_num
     </select>
+    <select id="selectCcExtNumByUserCodeTwo" resultMap="CcExtNumResult">
+        select * from cc_ext_num where user_code = #{username} limit 1
+    </select>
 
     <update id="companyBatchUnbindSipExt">
         update cc_ext_num set user_code = '' where ext_num in
@@ -131,4 +134,17 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     <update id="updateUserCodeByLoginName">
         update cc_ext_num set user_code = #{companyName} where user_code = #{loginName}
     </update>
+
+    <update id="batchUpdateUserCodeByExtNum">
+        update cc_ext_num
+        set user_code = case ext_num
+        <foreach collection="list" item="item">
+            when #{item.extNum} then #{item.userCode}
+        </foreach>
+        end
+        where ext_num in
+        <foreach collection="list" item="item" open="(" separator="," close=")">
+            #{item.extNum}
+        </foreach>
+    </update>
 </mapper>

+ 2 - 2
ruoyi-admin/src/main/resources/mapper/cc/CcGatewaysMapper.xml

@@ -45,8 +45,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="purpose != null "> and purpose = #{purpose}</if>
             <if test="params.purposes != null and !params.purposes.isEmpty()">
                 and purpose in
-                <foreach item="purpose" collection="params.purposes" open="(" separator="," close=")">
-                    #{purpose}
+                <foreach item="purposeItem" collection="params.purposes" open="(" separator="," close=")">
+                    #{purposeItem}
                 </foreach>
             </if>
             <if test="gatewayIds != null and !gatewayIds.isEmpty()">

+ 25 - 6
ruoyi-admin/src/main/resources/static/phone-bar/ccPhoneBarSocket.js

@@ -222,14 +222,29 @@ function ccPhoneBarSocket() {
 		}
 
 		console.log("callConfig:", this.callConfig);
-		wsuri = 'ws://' + this.callConfig.ipccServer +
+		/**
+		 * WebSocket 地址规则(兼容 company1/2/3… 多域名 + HIS HTTPS 嵌入):
+		 * - HTTPS 页:必须 wss;主机用当前页 location.host(含端口,如 :8899),
+		 *   由 Nginx 把 /call-center/ 反代到真实 IPCC 1081,避免 Mixed Content,也无需按域名写死。
+		 * - HTTP 页(本地联调):仍用 callConfig.ipccServer(一般为 host:1081)+ ws://。
+		 */
+		var isHttps = (typeof window !== 'undefined' && window.location && window.location.protocol === 'https:');
+		var useWss = isHttps || !!this.callConfig.enableWss;
+		var wsProto = useWss ? 'wss://' : 'ws://';
+		var wsHost;
+		if (isHttps && window.location.host) {
+			wsHost = window.location.host;
+		} else {
+			wsHost = this.callConfig.ipccServer;
+		}
+		wsuri = wsProto + wsHost +
 			'/call-center/websocketServer?' +
 			'&loginToken=' + this.callConfig.loginToken;
 
 		console.log("ipccServer ws url: " + wsuri);
-		var ipccServerIpAddr = this.callConfig.ipccServer.split(":");
-		if(this.callConfig.enableWss &&  this.checkIP(ipccServerIpAddr)){
-			var tipsError = i18n('phonebar.msg.error_wss_domain') + this.callConfig.ipccServer;
+		var ipccServerIpAddr = String(wsHost || '').split(":")[0];
+		if(useWss && this.checkIP(ipccServerIpAddr)){
+			var tipsError = i18n('phonebar.msg.error_wss_domain') + wsHost;
 			console.log(tipsError);
 			alert(tipsError);
 		}
@@ -344,10 +359,14 @@ function ccPhoneBarSocket() {
 		}
 		//收到消息
 		ws.onmessage = function(evt) {
-			console.log("recv msg from websocket server: ", evt.data);
 			var msg = JSON.parse(evt.data);
-			console.log("parsed json data:", msg);
 			var resp_status = msg["status"];
+			// 615 排队人数高频推送:HIS embed / 默认均不打日志,避免控制台刷屏
+			var _skipLog = parseInt(resp_status, 10) === parseInt(ccPhoneBarSocket.eventList.acd_group_queue_number, 10);
+			if (!_skipLog) {
+				console.log("recv msg from websocket server: ", evt.data);
+				console.log("parsed json data:", msg);
+			}
 			switch(resp_status) {
 				case 200:
 					isConnected = true;

+ 9 - 1
ruoyi-admin/src/main/resources/static/ruoyi/js/common.js

@@ -5,7 +5,15 @@
 
 var startLayDate;
 var endLayDate;
-var isScrollToTop = parent.isScrollToTop;
+var isScrollToTop = true;
+try {
+    // embed 到跨域父页时访问 parent 会抛 SecurityError,必须兜底
+    if (window.parent && window.parent !== window && typeof window.parent.isScrollToTop !== 'undefined') {
+        isScrollToTop = !!window.parent.isScrollToTop;
+    }
+} catch (e) {
+    isScrollToTop = true;
+}
 
 $(function() {
 	

+ 160 - 30
ruoyi-admin/src/main/resources/static/ruoyi/login.js

@@ -1,4 +1,3 @@
-
 $(function() {
     validateKickout();
     validateRule();
@@ -6,17 +5,48 @@ $(function() {
         var url = ctx + "captcha/captchaImage?type=" + captchaType + "&s=" + Math.random();
         $(".imgcode").attr("src", url);
     });
+    initEmbedLoginBridge();
 });
 
-function login() {
-    var username = $.common.trim($("input[name='username']").val());
-    var password = $.common.trim($("input[name='password']").val());
-    var extNum = $.common.trim($("input[name='extNum']").val());
-    var groupId = $.common.trim($("select[name='groupId']").val());
+/** 是否为 HIS iframe 嵌入登录(URL embed=1,或实际处于 iframe 中) */
+function isEmbedMode() {
+    if (getParam("embed") === "1") return true;
+    if (document.documentElement.className.indexOf("his-ipcc-embed") >= 0) return true;
+    try { return window.self !== window.top; } catch (e) { return true; }
+}
+
+/** 嵌入登录成功后进工作台(跳过 index 外壳) */
+function getLoginSuccessUrl() {
+    if (isEmbedMode()) {
+        return ctx + "system/main?embed=1";
+    }
+    return ctx + "index";
+}
+
+function login(extra) {
+    extra = extra || {};
+    var username = $.common.trim(extra.username != null ? extra.username : $("input[name='username']").val());
+    var password = $.common.trim(extra.password != null ? extra.password : $("input[name='password']").val());
+    var extNum = $.common.trim(extra.extNum != null ? extra.extNum : $("input[name='extNum']").val());
+    var groupId = $.common.trim(extra.groupId != null ? extra.groupId : ($("select[name='groupId']").val() || "1"));
+    var myGateway = "";
+    if (extra.myGateway != null) {
+        myGateway = $.common.trim(String(extra.myGateway));
+    } else if ($("input[name='useGateway']:checked").val() === "1") {
+        myGateway = $.common.trim($("#gatewaySelect").val() || $("input[name='myGateway']").val() || "");
+    } else {
+        myGateway = $.common.trim(getParam("myGateway") || "");
+    }
+    $("input[name='myGateway']").val(myGateway);
     var validateCode = $("input[name='validateCode']").val();
     var rememberMe = $("input[name='rememberme']").is(':checked');
-    if($.common.isEmpty(validateCode) && captchaEnabled) {
+    if ($.common.isEmpty(validateCode) && captchaEnabled) {
         $.modal.msg("请输入验证码");
+        postEmbedToParent("HIS_IPCC_LOGIN_RESULT", { ok: false, msg: "请输入验证码" });
+        return false;
+    }
+    if ($("input[name='useGateway']:checked").val() === "1" && $.common.isEmpty(myGateway) && !isEmbedMode()) {
+        $.modal.msg("请选择外呼网关");
         return false;
     }
     $.ajax({
@@ -28,20 +58,50 @@ function login() {
             "extNum": extNum,
             "validateCode": validateCode,
             "rememberMe": rememberMe,
-            "groupId": groupId
+            "groupId": groupId,
+            "myGateway": myGateway,
+            "embed": isEmbedMode() ? "1" : ""
         },
+        xhrFields: { withCredentials: true },
         beforeSend: function () {
-            $.modal.loading($("#btnSubmit").data("loading"));
+            if (!isEmbedMode()) {
+                $.modal.loading($("#btnSubmit").data("loading"));
+            }
         },
         success: function(r) {
             if (r.code == web_status.SUCCESS) {
-                location.href = ctx + 'index';
+                var nextUrl = getLoginSuccessUrl();
+                // 先通知父页,再由【本 iframe 内】同源跳转工作台。
+                // 切勿让父页改 iframe.src(跨源会导致会话 Cookie 丢失 → 踢回登录死循环)
+                try { sessionStorage.setItem("his_ipcc_login_jump", "1"); } catch (e) {}
+                postEmbedToParent("HIS_IPCC_LOGIN_RESULT", { ok: true, nextUrl: nextUrl });
+                if (isEmbedMode()) {
+                    window.location.replace(nextUrl);
+                } else {
+                    window.location.href = nextUrl;
+                }
             } else {
+                window.__hisIpccLoginJumping = false;
+                try { sessionStorage.removeItem("his_ipcc_login_jump"); } catch (e) {}
                 $('.imgcode').click();
                 $(".code").val("");
-                $.modal.msg(r.msg);
+                if (!isEmbedMode()) {
+                    $.modal.msg(r.msg);
+                }
+                postEmbedToParent("HIS_IPCC_LOGIN_RESULT", { ok: false, msg: r.msg || "登录失败" });
             }
-            $.modal.closeLoading();
+            if (!isEmbedMode()) {
+                $.modal.closeLoading();
+            }
+        },
+        error: function(xhr, status, error) {
+            window.__hisIpccLoginJumping = false;
+            try { sessionStorage.removeItem("his_ipcc_login_jump"); } catch (e) {}
+            if (!isEmbedMode()) {
+                $.modal.closeLoading();
+                $.modal.msg(error || "登录异常");
+            }
+            postEmbedToParent("HIS_IPCC_LOGIN_RESULT", { ok: false, msg: error || "登录异常" });
         }
     });
 }
@@ -50,35 +110,29 @@ function validateRule() {
     var icon = "<i class='fa fa-times-circle'></i> ";
     $("#signupForm").validate({
         rules: {
-            username: {
-                required: true
-            },
-            password: {
-                required: true
-            }
+            username: { required: true },
+            password: { required: true }
         },
         messages: {
-            username: {
-                required: icon + "请输入您的用户名",
-            },
-            password: {
-                required: icon + "请输入您的密码",
-            }
+            username: { required: icon + "请输入您的用户名" },
+            password: { required: icon + "请输入您的密码" }
         },
-        submitHandler: function(form) {
+        submitHandler: function() {
             login();
         }
-    })
+    });
 }
 
 function validateKickout() {
     if (getParam("kickout") == 1) {
+        if (isEmbedMode()) {
+            postEmbedToParent("HIS_IPCC_LOGIN_RESULT", { ok: false, msg: "您已在别处登录" });
+            return;
+        }
         layer.alert("<font color='red'>您已在别处登录,请您修改密码或重新登录</font>", {
             icon: 0,
             title: "系统提示"
-        },
-        function(index) {
-            //关闭弹窗
+        }, function(index) {
             layer.close(index);
             if (top != self) {
                 top.location = self.location;
@@ -99,4 +153,80 @@ function getParam(paramName) {
     var r = window.location.search.substr(1).match(reg);
     if (r != null) return decodeURI(r[2]);
     return null;
-}
+}
+
+/** 与 main.html / HIS 约定:{ channel, type, payload } */
+function postEmbedToParent(type, payload) {
+    if (!isEmbedMode() || !window.parent || window.parent === window) return;
+    try {
+        window.parent.postMessage({
+            channel: "HIS_IPCC_EMBED",
+            type: type,
+            payload: payload || {}
+        }, "*");
+    } catch (e) {}
+}
+
+function bindEmbedLoginMessage() {
+    if (window.__hisIpccLoginBridgeBound) return;
+    window.__hisIpccLoginBridgeBound = true;
+    window.addEventListener("message", function(event) {
+        var data = event.data;
+        if (!data || data.channel !== "HIS_IPCC_EMBED") return;
+        if (data.type === "HIS_IPCC_LOGIN") {
+            var p = data.payload || {};
+            if (!p.username || !p.password || !p.extNum) {
+                postEmbedToParent("HIS_IPCC_LOGIN_RESULT", { ok: false, msg: "登录参数不完整" });
+                return;
+            }
+            // 正在跳转工作台时忽略重复 LOGIN,避免死循环
+            if (window.__hisIpccLoginJumping) return;
+            window.__hisIpccLoginJumping = true;
+            login({
+                username: p.username,
+                password: p.password,
+                extNum: String(p.extNum),
+                groupId: p.groupId || "1",
+                myGateway: p.myGateway != null ? String(p.myGateway) : ""
+            });
+            // 登录失败时允许再次尝试
+            setTimeout(function () {
+                if (/\/login/i.test(window.location.pathname || "")) {
+                    window.__hisIpccLoginJumping = false;
+                }
+            }, 3000);
+        }
+    });
+}
+
+function initEmbedLoginBridge() {
+    if (!isEmbedMode()) return;
+    bindEmbedLoginMessage();
+
+    // 登录成功后又回到登录页:真会话丢失(Cookie 未带上)
+    var bounced = false;
+    try { bounced = sessionStorage.getItem("his_ipcc_login_jump") === "1"; } catch (e) {}
+    if (bounced) {
+        try { sessionStorage.removeItem("his_ipcc_login_jump"); } catch (e) {}
+        window.__hisIpccLoginJumping = false;
+        postEmbedToParent("ERROR", {
+            msg: "工作台会话未带上(Cookie 丢失或被拒),请检查同主机:8899 反代是否剥离 Domain/Secure",
+            code: "SESSION_COOKIE_LOST",
+            type: "SESSION_COOKIE_LOST"
+        });
+        postEmbedToParent("HIS_IPCC_LOGIN_RESULT", {
+            ok: false,
+            msg: "工作台会话未带上(Cookie 丢失或被拒)",
+            code: "SESSION_COOKIE_LOST"
+        });
+        // 延迟 READY,避免立刻触发父页「登录回弹」误判;供整轮重登使用
+        setTimeout(function () {
+            postEmbedToParent("HIS_IPCC_LOGIN_READY", {});
+        }, 1500);
+        return;
+    }
+
+    // 正常进入登录页:工作台就绪后清跳转标记
+    try { sessionStorage.removeItem("his_ipcc_login_jump"); } catch (e) {}
+    postEmbedToParent("HIS_IPCC_LOGIN_READY", {});
+}

+ 22 - 4
ruoyi-admin/src/main/resources/templates/aicall/callPhone/callPhone.html

@@ -579,11 +579,11 @@
                         const extension = row.wavfile.split('.').pop().toLowerCase();
                         // 判断文件类型
                         if (['wav', 'mp3', 'aac'].includes(extension)) {
-                            actions.push('<a class="btn btn-success btn-xs ' + linstenFlag + '" href="javascript:void(0)" onclick="openModal(\'' + row.recordServerUrl + row.wavfile + '\',\'' + escape(JSON.stringify(row.dialogue)) + '\')"><i class="fa fa-volume-up"></i>' + i18n('btn.record.audio.play') + '</a> ');
-                            actions.push('<a class="btn btn-success btn-xs ' + downloadFlag + '" href="javascript:void(0)" onclick="checkFileExistence(\'' + row.recordServerUrl + row.wavfile + '\',\'' + escape(JSON.stringify(row.dialogue)) + '\')"><i class="fa fa-download"></i>' + i18n('btn.record.audio.download') + '</a>');
+                            actions.push('<a class="btn btn-success btn-xs ' + linstenFlag + '" href="javascript:void(0)" onclick="playRecordById(\'' + row.id + '\',\'' + row.recordServerUrl + row.wavfile + '\')"><i class="fa fa-volume-up"></i>' + i18n('btn.record.audio.play') + '</a> ');
+                            actions.push('<a class="btn btn-success btn-xs ' + downloadFlag + '" href="javascript:void(0)" onclick="checkFileExistence(\'' + row.recordServerUrl + row.wavfile + '\')"><i class="fa fa-download"></i>' + i18n('btn.record.audio.download') + '</a>');
                         } else if (['mp4', 'avi', 'mov'].includes(extension)) {
-                            actions.push('<a class="btn btn-success btn-xs ' + linstenFlag + '" href="javascript:void(0)" onclick="openModal(\'' + row.recordServerUrl + row.wavfile + '\',\'' + escape(JSON.stringify(row.dialogue)) + '\')"><i class="fa fa-volume-up"></i>' + i18n('btn.record.video.play') + '</a> ');
-                            actions.push('<a class="btn btn-success btn-xs ' + downloadFlag + '" href="javascript:void(0)" onclick="checkFileExistence(\'' + row.recordServerUrl + row.wavfile + '\',\'' + escape(JSON.stringify(row.dialogue)) + '\')"><i class="fa fa-download"></i>' + i18n('btn.record.video.download') + '</a>');
+                            actions.push('<a class="btn btn-success btn-xs ' + linstenFlag + '" href="javascript:void(0)" onclick="playRecordById(\'' + row.id + '\',\'' + row.recordServerUrl + row.wavfile + '\')"><i class="fa fa-volume-up"></i>' + i18n('btn.record.video.play') + '</a> ');
+                            actions.push('<a class="btn btn-success btn-xs ' + downloadFlag + '" href="javascript:void(0)" onclick="checkFileExistence(\'' + row.recordServerUrl + row.wavfile + '\')"><i class="fa fa-download"></i>' + i18n('btn.record.video.download') + '</a>');
                         }
                         return actions.join('');
                     }
@@ -644,6 +644,24 @@
     });
 
     var videoPlayer; // 定义一个全局变量来存储播放器实例
+
+    /** 列表已剥离 dialogue,播放时按需拉取大字段 */
+    function playRecordById(id, url) {
+        $.ajax({
+            url: prefix + "/detail/" + id,
+            type: "get",
+            success: function(result) {
+                var dialogue = (result && (result.code === 0 || result.code === 200) && result.data)
+                    ? (result.data.dialogue || '')
+                    : '';
+                openModal(url, escape(JSON.stringify(dialogue)));
+            },
+            error: function() {
+                openModal(url, '');
+            }
+        });
+    }
+
     function openModal(url, escapedDialogContent){
         const type = getMediaType(url);
         console.log(type);

+ 28 - 0
ruoyi-admin/src/main/resources/templates/error/embedWorkbench.html

@@ -0,0 +1,28 @@
+<!DOCTYPE html>
+<html lang="zh" xmlns:th="http://www.thymeleaf.org">
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>工作台不可用</title>
+    <style>
+        html, body { margin: 0; padding: 0; background: transparent; font-family: sans-serif; }
+        .box { padding: 12px; color: #a00; font-size: 13px; }
+    </style>
+</head>
+<body>
+<div class="box" th:text="${errorMsg}">工作台不可用</div>
+<script th:inline="javascript">
+(function () {
+    var msg = /*[[${errorMsg}]]*/ '工作台不可用';
+    var code = /*[[${errorCode}]]*/ 'WORKBENCH_ERROR';
+    try {
+        if (window.parent && window.parent !== window) {
+            var payload = { ok: false, msg: msg, code: code };
+            window.parent.postMessage({ channel: 'HIS_IPCC_EMBED', type: 'ERROR', payload: { msg: msg, code: code, type: code } }, '*');
+            window.parent.postMessage({ channel: 'HIS_IPCC_EMBED', type: 'HIS_IPCC_LOGIN_RESULT', payload: payload }, '*');
+        }
+    } catch (e) {}
+})();
+</script>
+</body>
+</html>

+ 2 - 2
ruoyi-admin/src/main/resources/templates/index-topnav.html

@@ -45,7 +45,7 @@
 				<a class="menuItem noactive" title="个人中心" th:href="@{/system/user/profile}">
 					<div class="hide" th:text="个人中心1"></div>
 					<div class="pull-left image">
-						<img th:src="(${#strings.isEmpty(user.avatar)}) ? @{/img/profile.png} : @{${user.avatar}}" th:onerror="this.src='img/profile.png'" class="img-circle" alt="User Image">
+						<img th:src="${#strings.isEmpty(user.avatar) ? '/img/profile.png' : ((isLocalDebug == true and (#request.serverName == '127.0.0.1' or #request.serverName == 'localhost')) ? ('http://' + localRemoteHost + user.avatar) : user.avatar)}" th:onerror="this.src='img/profile.png'" class="img-circle" alt="User Image">
 					</div>
 				</a>
 				<div class="pull-left info">
@@ -265,7 +265,7 @@
                 
                     <li class="dropdown user-menu">
 						<a href="javascript:void(0)" class="dropdown-toggle" data-hover="dropdown">
-							<img th:src="(${#strings.isEmpty(user.avatar)}) ? @{/img/profile.png} : @{${user.avatar}}" th:onerror="this.src='img/profile.png'" class="user-image">
+							<img th:src="${#strings.isEmpty(user.avatar) ? '/img/profile.png' : ((isLocalDebug == true and (#request.serverName == '127.0.0.1' or #request.serverName == 'localhost')) ? ('http://' + localRemoteHost + user.avatar) : user.avatar)}" th:onerror="this.src='img/profile.png'" class="user-image">
 							<span class="hidden-xs">[[${#strings.defaultString(user.userName, '-')}]]</span>
 						</a>
 						<ul class="dropdown-menu">

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

@@ -214,7 +214,7 @@
 					<li><a data-toggle="tooltip" data-trigger="hover" data-placement="bottom" th:text="#{top.fullScreen.text}" href="javascript:;" id="fullScreen"><i class="fa fa-arrows-alt"></i><span th:title="#{top.fullScreen.title}" /></a></li>
                     <li class="dropdown user-menu">
 						<a href="javascript:void(0)" class="dropdown-toggle" data-hover="dropdown">
-							<img th:src="(${#strings.isEmpty(user.avatar)}) ? @{/img/profile.png} : @{${user.avatar}}" th:onerror="this.src='img/profile.png'" class="user-image">
+							<img th:src="${#strings.isEmpty(user.avatar) ? '/img/profile.png' : ((isLocalDebug == true and (#request.serverName == '127.0.0.1' or #request.serverName == 'localhost')) ? ('http://' + localRemoteHost + user.avatar) : user.avatar)}" th:onerror="this.src='img/profile.png'" class="user-image">
 							<span class="hidden-xs">[[${#strings.defaultString(user.userName, '-')}]]</span>
 						</a>
 						<ul class="dropdown-menu">

+ 366 - 24
ruoyi-admin/src/main/resources/templates/login.html

@@ -16,6 +16,8 @@
     <link href="../static/css/login.min.css" th:href="@{/css/login.min.css}" rel="stylesheet"/>
     <link href="../static/css/theme-modern.css" th:href="@{/css/theme-modern.css?v=20260524}" rel="stylesheet"/>
     <link href="../static/ruoyi/css/ry-ui.css" th:href="@{/ruoyi/css/ry-ui.css?v=4.7.10}" rel="stylesheet"/>
+    <link th:href="@{/ajax/libs/select2/select2.min.css?v=4.0.13}" rel="stylesheet"/>
+    <link th:href="@{/ajax/libs/select2/select2-bootstrap.min.css?v=4.0.13}" rel="stylesheet"/>
     <!-- 360浏览器急速模式 -->
     <meta name="renderer" content="webkit">
     <!-- 避免IE使用兼容模式 -->
@@ -23,8 +25,41 @@
     <link rel="shortcut icon" href="../static/favicon.ico" th:href="@{favicon.ico}"/>
     <style type="text/css">label.error { position:inherit;  }</style>
     <script>
-        if(window.top!==window.self){alert('未登录或登录超时。请重新登录');window.top.location=window.location};
+        (function () {
+            var inFrame = false;
+            try { inFrame = window.self !== window.top; } catch (e) { inFrame = true; }
+            var embed = /(?:^|[?&])embed=1(?:&|$)/.test(window.location.search || '');
+            // 只要在 iframe 里就按 HIS 嵌入处理(防止 Shiro 踢回 /login 时丢掉 embed=1)
+            if (inFrame || embed) {
+                try {
+                    document.documentElement.className += ' his-ipcc-embed';
+                } catch (e) {}
+                try { window.alert = function () {}; } catch (e) {}
+                // URL 丢了 embed 时补回,避免后续语言脚本按非嵌入逻辑整页跳
+                if (inFrame && !embed) {
+                    try {
+                        var p = new URLSearchParams(window.location.search || '');
+                        p.set('embed', '1');
+                        if (!p.get('lang')) {
+                            p.set('lang', localStorage.getItem('lang') || 'zh_CN');
+                        }
+                        window.history.replaceState({}, document.title, window.location.pathname + '?' + p.toString());
+                    } catch (e) {}
+                }
+                return;
+            }
+            // 仅非 iframe 的嵌套页才尝试顶层跳转(正常单页登录不会进这里)
+            if (window.top !== window.self) {
+                try { window.top.location = window.location; } catch (e) {}
+            }
+        })();
     </script>
+    <style>
+        html.his-ipcc-embed body.signin { background: transparent !important; }
+        html.his-ipcc-embed .signinpanel,
+        html.his-ipcc-embed .language-selector,
+        html.his-ipcc-embed .signup-footer { display: none !important; }
+    </style>
 
     <script th:inline="javascript">
         // //获取国际化翻译值
@@ -94,6 +129,147 @@
         #groupId option:first-child {
             color: #999999;              /* 灰色提示文字 */
         }
+
+        /* 外呼网关:与登录表单统一的分段切换 */
+        .gateway-field {
+            margin-top: 14px;
+        }
+        .gateway-field-label {
+            display: block;
+            margin: 0 0 8px;
+            color: #6b7280;
+            font-size: 13px;
+            font-weight: 500;
+            letter-spacing: 0.02em;
+        }
+        .gateway-seg {
+            display: flex;
+            padding: 4px;
+            border: 1px solid #dbe1ee;
+            border-radius: 12px;
+            background: #f3f5fa;
+            gap: 4px;
+        }
+        .gateway-seg label {
+            flex: 1;
+            position: relative;
+            margin: 0;
+            cursor: pointer;
+            font-weight: 500;
+        }
+        .gateway-seg input[type="radio"] {
+            position: absolute;
+            opacity: 0;
+            pointer-events: none;
+        }
+        .gateway-seg-item {
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            gap: 6px;
+            height: 36px;
+            border-radius: 9px;
+            color: #6b7280;
+            font-size: 13px;
+            line-height: 1;
+            transition: background .18s ease, color .18s ease, box-shadow .18s ease;
+            user-select: none;
+        }
+        .gateway-seg-item i {
+            font-size: 13px;
+            opacity: .85;
+        }
+        .gateway-seg input[type="radio"]:checked + .gateway-seg-item {
+            background: #fff;
+            color: #1d6fe8;
+            box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08);
+            font-weight: 600;
+        }
+        .gateway-seg label:hover .gateway-seg-item {
+            color: #374151;
+        }
+        .gateway-seg input[type="radio"]:checked + .gateway-seg-item:hover {
+            color: #1d6fe8;
+        }
+        #gatewaySelectWrap {
+            margin-top: 10px;
+            display: none;
+            animation: gwFadeIn .2s ease;
+        }
+        @keyframes gwFadeIn {
+            from { opacity: 0; transform: translateY(-4px); }
+            to { opacity: 1; transform: translateY(0); }
+        }
+        #gatewaySelectWrap .select2-container {
+            width: 100% !important;
+        }
+        #gatewaySelectWrap .select2-selection--single {
+            height: 46px !important;
+            border: 1px solid #dbe1ee !important;
+            border-radius: 12px !important;
+            background-color: #fff !important;
+            box-shadow: none !important;
+        }
+        #gatewaySelectWrap .select2-selection--single:focus,
+        #gatewaySelectWrap .select2-container--open .select2-selection--single {
+            border-color: #1d6fe8 !important;
+        }
+        #gatewaySelectWrap .select2-selection__rendered {
+            line-height: 44px !important;
+            color: #111827 !important;
+            padding-left: 16px !important;
+            padding-right: 48px !important;
+            font-size: 14px;
+        }
+        #gatewaySelectWrap .select2-selection__placeholder {
+            color: #9ca3af !important;
+        }
+        #gatewaySelectWrap .select2-selection__arrow {
+            height: 44px !important;
+            width: 28px !important;
+            right: 8px !important;
+        }
+        #gatewaySelectWrap .select2-selection__clear {
+            margin-right: 8px;
+            font-size: 16px;
+            color: #9ca3af;
+        }
+        .select2-container--bootstrap .select2-dropdown {
+            border: 1px solid #dbe1ee !important;
+            border-radius: 12px !important;
+            box-shadow: 0 12px 28px rgba(15, 23, 42, 0.1) !important;
+            overflow: hidden;
+            margin-top: 4px;
+        }
+        .select2-container--bootstrap .select2-results__option {
+            padding: 10px 14px;
+            font-size: 13px;
+        }
+        .select2-container--bootstrap .select2-results__option--highlighted {
+            background: #eef4ff !important;
+            color: #1d6fe8 !important;
+        }
+        .select2-gw-option {
+            display: flex;
+            flex-direction: column;
+            gap: 2px;
+            line-height: 1.3;
+        }
+        .select2-gw-option .gw-title {
+            color: #111827;
+            font-size: 13px;
+            font-weight: 500;
+        }
+        .select2-gw-option .gw-sub {
+            color: #9ca3af;
+            font-size: 12px;
+        }
+        .select2-results__option--highlighted .select2-gw-option .gw-title {
+            color: #1d6fe8;
+        }
+        .select2-results__option--highlighted .select2-gw-option .gw-sub {
+            color: #6b9ef5;
+        }
     </style>
 </head>
 <body class="signin">
@@ -116,6 +292,25 @@
                     <input type="text"     name="username" class="form-control uname"     th:attr="placeholder=#{user.login.username}"    />
                     <input type="password" name="password" class="form-control pword"     th:attr="placeholder=#{user.login.password}" />
                     <input type="text"     name="extNum" class="form-control uname"     th:attr="placeholder=#{user.login.extnum}"    />
+                    <input type="hidden"   name="myGateway" id="myGateway" value="" />
+                    <div class="gateway-field">
+                        <span class="gateway-field-label">外呼网关</span>
+                        <div class="gateway-seg" role="radiogroup" aria-label="外呼网关">
+                            <label>
+                                <input type="radio" name="useGateway" value="0" checked="checked" />
+                                <span class="gateway-seg-item"><i class="fa fa-random"></i>自动分配</span>
+                            </label>
+                            <label>
+                                <input type="radio" name="useGateway" value="1" />
+                                <span class="gateway-seg-item"><i class="fa fa-sitemap"></i>指定网关</span>
+                            </label>
+                        </div>
+                        <div id="gatewaySelectWrap">
+                            <select id="gatewaySelect" class="form-control" style="width:100%;">
+                                <option value=""></option>
+                            </select>
+                        </div>
+                    </div>
                     <select th:if="${showDynamicGroupid==true}" name="groupId" id="groupId" class="form-control m-t">
 
                     </select>
@@ -148,7 +343,17 @@
     var captchaEnabled = [[${captchaEnabled}]];
     var sysVersion = [[${sysVersion}]];
     var showDynamicGroupid = [[${showDynamicGroupid}]];
-
+</script>
+<!--[if lte IE 8]><script>window.location.href=ctx+'html/ie.html';</script><![endif]-->
+<!-- 全局js -->
+<script src="../static/js/jquery.min.js" th:src="@{/js/jquery.min.js}"></script>
+<script src="../static/ajax/libs/validate/jquery.validate.min.js" th:src="@{/ajax/libs/validate/jquery.validate.min.js}"></script>
+<script src="../static/ajax/libs/layer/layer.min.js" th:src="@{/ajax/libs/layer/layer.min.js}"></script>
+<script src="../static/ajax/libs/blockUI/jquery.blockUI.js" th:src="@{/ajax/libs/blockUI/jquery.blockUI.js}"></script>
+<script th:src="@{/ajax/libs/select2/select2.min.js?v=4.0.13}"></script>
+<script src="../static/ruoyi/js/ry-ui.js" th:src="@{/ruoyi/js/ry-ui.js?v=4.7.11}"></script>
+<script src="../static/ruoyi/login.js" th:src="@{/ruoyi/login.js}"></script>
+<script th:inline="javascript">
     // 加载业务组列表
     function loadBusiGroups() {
         if (!showDynamicGroupid) return;
@@ -178,54 +383,191 @@
         });
     }
 
-    // 页面加载完成后初始化
+    function syncMyGateway() {
+        var useGateway = $('input[name="useGateway"]:checked').val() === '1';
+        if (!useGateway) {
+            $('#myGateway').val('');
+            return;
+        }
+        var gw = $('#gatewaySelect').val() || '';
+        $('#myGateway').val(gw);
+    }
+
+    function toggleGatewaySelect() {
+        var useGateway = $('input[name="useGateway"]:checked').val() === '1';
+        if (useGateway) {
+            $('#gatewaySelectWrap').show();
+            // 指定网关时优先默认选中 gwName=outbound
+            if (!$('#gatewaySelect').val()) {
+                var $outbound = $('#gatewaySelect option').filter(function() {
+                    return $(this).attr('data-gwname') === 'outbound';
+                }).first();
+                if ($outbound.length) {
+                    $('#gatewaySelect').val($outbound.val()).trigger('change');
+                }
+            }
+        } else {
+            $('#gatewaySelectWrap').hide();
+            $('#gatewaySelect').val(null).trigger('change');
+        }
+        syncMyGateway();
+    }
+
+    function formatGatewayOption(opt) {
+        if (!opt.id) {
+            return opt.text;
+        }
+        var $el = $(opt.element);
+        var title = $el.data('title') || opt.text;
+        var sub = $el.data('sub') || '';
+        if (!sub) {
+            return $('<span class="select2-gw-option"><span class="gw-title"></span></span>')
+                .find('.gw-title').text(title).end();
+        }
+        return $(
+            '<span class="select2-gw-option">' +
+                '<span class="gw-title"></span>' +
+                '<span class="gw-sub"></span>' +
+            '</span>'
+        ).find('.gw-title').text(title).end()
+         .find('.gw-sub').text(sub).end();
+    }
+
+    function formatGatewaySelection(opt) {
+        if (!opt.id) {
+            return opt.text;
+        }
+        var $el = $(opt.element);
+        var title = $el.data('title') || opt.text;
+        var sub = $el.data('sub') || '';
+        return sub ? (title + ' · ' + sub) : title;
+    }
+
+    function initGatewaySelect() {
+        $('#gatewaySelect').select2({
+            theme: 'bootstrap',
+            placeholder: '请选择外呼网关',
+            allowClear: true,
+            width: '100%',
+            dropdownParent: $('.signinpanel form'),
+            templateResult: formatGatewayOption,
+            templateSelection: formatGatewaySelection,
+            language: {
+                noResults: function() { return '无匹配网关'; },
+                searching: function() { return '搜索中…'; }
+            }
+        });
+        $('#gatewaySelect').on('change', syncMyGateway);
+        $('input[name="useGateway"]').on('change', toggleGatewaySelect);
+    }
+
+    function loadGateways() {
+        $.ajax({
+            url: ctx + 'aicall/api/gateway/phonebarList',
+            type: 'GET',
+            dataType: 'json',
+            success: function(response) {
+                var $select = $('#gatewaySelect');
+                $select.empty().append('<option value=""></option>');
+                if (response && response.code === 0 && response.data && response.data.length > 0) {
+                    $.each(response.data, function(index, item) {
+                        // only purposes 1(phonebar) / 3(unlimited), exclude 2
+                        var purpose = item.purpose;
+                        if (purpose != 1 && purpose != 3) {
+                            return;
+                        }
+                        var id = item.id != null ? String(item.id) : '';
+                        var gwName = item.gwName || '';
+                        var gwDesc = item.gwDesc || '';
+                        var title = gwDesc || gwName || id;
+                        var sub = (gwName && gwDesc && gwDesc !== gwName) ? gwName : '';
+                        var text = sub ? (title + ' (' + sub + ')') : title;
+                        $select.append(
+                            $('<option></option>')
+                                .attr('value', id)
+                                .attr('data-gwname', gwName)
+                                .attr('data-title', title)
+                                .attr('data-sub', sub)
+                                .text(text)
+                        );
+                    });
+                }
+                initGatewaySelect();
+                toggleGatewaySelect();
+            },
+            error: function(xhr, status, error) {
+                console.error('请求网关列表失败:', error);
+                initGatewaySelect();
+                toggleGatewaySelect();
+            }
+        });
+    }
+
     $(document).ready(function() {
         loadBusiGroups();
+        loadGateways();
     });
-
 </script>
-<!--[if lte IE 8]><script>window.location.href=ctx+'html/ie.html';</script><![endif]-->
-<!-- 全局js -->
-<script src="../static/js/jquery.min.js" th:src="@{/js/jquery.min.js}"></script>
-<script src="../static/ajax/libs/validate/jquery.validate.min.js" th:src="@{/ajax/libs/validate/jquery.validate.min.js}"></script>
-<script src="../static/ajax/libs/layer/layer.min.js" th:src="@{/ajax/libs/layer/layer.min.js}"></script>
-<script src="../static/ajax/libs/blockUI/jquery.blockUI.js" th:src="@{/ajax/libs/blockUI/jquery.blockUI.js}"></script>
-<script src="../static/ruoyi/js/ry-ui.js" th:src="@{/ruoyi/js/ry-ui.js?v=4.7.11}"></script>
-<script src="../static/ruoyi/login.js" th:src="@{/ruoyi/login.js}"></script>
 <script th:inline="javascript">
     function setLanguage(lang) {
         console.log('设置语言:' + lang)
-        if (lang) {
-            localStorage.setItem('lang', lang); // 存储语言到本地存储
-            window.location.href = window.location.pathname + "?lang=" + lang; // 通过 URL 参数切换语言
+        if (!lang) return;
+        localStorage.setItem('lang', lang);
+        var inFrame = false;
+        try { inFrame = window.self !== window.top; } catch (e) { inFrame = true; }
+        var params = new URLSearchParams(window.location.search || '');
+        params.set('lang', lang);
+        if (inFrame ||
+            /(?:^|[?&])embed=1(?:&|$)/.test(window.location.search || '') ||
+            document.documentElement.className.indexOf('his-ipcc-embed') >= 0) {
+            params.set('embed', '1');
         }
+        // iframe 内只用 replaceState,避免整页刷新丢掉登录桥 / 触发顶层跳转
+        if (inFrame) {
+            try {
+                window.history.replaceState({}, document.title, window.location.pathname + '?' + params.toString());
+            } catch (e) {
+                window.location.href = window.location.pathname + '?' + params.toString();
+            }
+            return;
+        }
+        window.location.href = window.location.pathname + '?' + params.toString();
     }
 
     document.querySelectorAll('.lang-option').forEach(link => {
         link.addEventListener('click', function(event) {
-            event.preventDefault(); // 阻止默认行为
+            event.preventDefault();
             setLanguage(this.getAttribute('data-lang'));
         });
     });
 
     document.addEventListener('DOMContentLoaded', function() {
-        // 从 URL 参数获取语言设置
         var lang = new URLSearchParams(window.location.search).get('lang');
-        // 如果 URL 参数中没有语言设置,则从本地存储获取
         if (!lang) {
             lang = localStorage.getItem('lang');
         }
-        // 如果本地存储中也没有语言设置,则从浏览器设置获取
         if (!lang) {
-            lang = navigator.language || 'en_US'; // 默认值为 'en_US'
+            lang = navigator.language || 'en_US';
         }
         console.log('当前语言:' + lang);
-        localStorage.setItem('lang', lang); // 更新本地存储的语言设置
+        localStorage.setItem('lang', lang);
 
-        // 如果 URL 中没有 lang 参数,且本地存储中有 lang 设置,则更新 URL 参数
+        var inFrame = false;
+        try { inFrame = window.self !== window.top; } catch (e) { inFrame = true; }
+        var isEmbed = inFrame ||
+            /(?:^|[?&])embed=1(?:&|$)/.test(window.location.search || '') ||
+            document.documentElement.className.indexOf('his-ipcc-embed') >= 0;
         if (lang && !new URLSearchParams(window.location.search).has('lang')) {
-            setLanguage(lang);
-            // window.history.replaceState({}, document.title, window.location.pathname + "?lang=" + lang);
+            if (isEmbed) {
+                var p = new URLSearchParams(window.location.search || '');
+                p.set('lang', lang);
+                p.set('embed', '1');
+                try {
+                    window.history.replaceState({}, document.title, window.location.pathname + '?' + p.toString());
+                } catch (e) {}
+            } else {
+                setLanguage(lang);
+            }
         }
     });
 

+ 362 - 72
ruoyi-admin/src/main/resources/templates/main.html

@@ -16,7 +16,7 @@
     <link href="../static/css/style.min.css" th:href="@{/css/style.min.css}" rel="stylesheet"/>
     <link href="../static/css/theme-modern.css" th:href="@{/css/theme-modern.css?v=20260524}" rel="stylesheet"/>
     <link rel="stylesheet" th:href="@{/phone-bar/page.css?v=1.0.4}"></link>
-    <script th:src="@{/phone-bar/ccPhoneBarSocket.js?v=1.0.4}"></script>
+    <script th:src="@{/phone-bar/ccPhoneBarSocket.js?v=1.0.5}"></script>
     <script>
         //所有的列表页默认显示10条记录;
         localStorage["bsTablePageSize"] = 10;
@@ -89,13 +89,74 @@
         #popup-container iframe::-webkit-scrollbar-thumb:hover {
             background: #a8a8a8;
         }
+
+        /* HIS 嵌入模式:隐藏可视 UI,仅保留 PhoneBar 连接 */
+        html.his-ipcc-embed body,
+        html.his-ipcc-embed .wrapper,
+        html.his-ipcc-embed #ccPhoneBar,
+        html.his-ipcc-embed .gray-bg {
+            background: transparent !important;
+        }
+        html.his-ipcc-embed .wrapper,
+        html.his-ipcc-embed form,
+        html.his-ipcc-embed #work-container,
+        html.his-ipcc-embed .ibox,
+        html.his-ipcc-embed .phonebar-box {
+            display: none !important;
+            visibility: hidden !important;
+            height: 0 !important;
+            overflow: hidden !important;
+        }
     </style>
+    <script th:inline="javascript">
+        var _hisEmbedMode = /*[[${embedMode}]]*/ false;
+        if (_hisEmbedMode) {
+            try { document.documentElement.className += ' his-ipcc-embed'; } catch (e) {}
+            // 跨域嵌入:禁止访问 top/parent.document,避免 SecurityError 打断外呼
+            window.isScrollToTop = true;
+            (function () {
+                function safeNoop() {}
+                function patchModal() {
+                    if (!window.$ || !$.modal) return false;
+                    $.modal.loading = safeNoop;
+                    $.modal.closeLoading = safeNoop;
+                    $.modal.disable = safeNoop;
+                    $.modal.enable = safeNoop;
+                    var _msg = $.modal.msg;
+                    $.modal.msg = function () { try { console.debug.apply(console, arguments); } catch (e) {} };
+                    var _alert = $.modal.alert;
+                    $.modal.alert = function (m) { try { console.warn('[embed]', m); } catch (e) {} };
+                    var _msgSuccess = $.modal.msgSuccess;
+                    $.modal.msgSuccess = safeNoop;
+                    var _msgError = $.modal.msgError;
+                    $.modal.msgError = function (m) { try { console.warn('[embed]', m); } catch (e) {} };
+                    return true;
+                }
+                if (!patchModal()) {
+                    document.addEventListener('DOMContentLoaded', function () { patchModal(); });
+                    setTimeout(patchModal, 0);
+                    setTimeout(patchModal, 500);
+                }
+            })();
+        }
+    </script>
 </head>
 
 <body class="gray-bg">
 
+<th:block th:if="${embedMode}">
+<!-- HIS embed:仅加载 IPCC PhoneBar 必要依赖,跳过表格/校验等业务脚本;普通打开工作台不受影响 -->
+<script th:inline="javascript"> var ctx = [[@{/}]]; </script>
+<script th:src="@{/js/bootstrap.min.js?v=3.3.7}"></script>
+<script th:src="@{/ajax/libs/blockUI/jquery.blockUI.js?v=2.70.0}"></script>
+<script th:src="@{/ajax/libs/layer/layer.min.js?v=3.7.0}"></script>
+<script th:src="@{/ruoyi/js/common.js?v=4.7.11}"></script>
+<script th:src="@{/ruoyi/js/ry-ui.js?v=4.7.11}"></script>
+</th:block>
+<th:block th:unless="${embedMode}">
 <th:block th:include="include :: footer" />
 <th:block th:include="include :: layout-latest-js" />
+</th:block>
 
 <form>
     <!-- 首页/工作台 -->
@@ -331,11 +392,20 @@
 <script th:inline="javascript">
     var _phoneBar = new ccPhoneBarSocket();
     var _scriptServer = window.location.host.split(":")[0];
+    // only when ruoyi.isLocalDebug=true
+    var _isLocalDebug = /*[[${isLocalDebug}]]*/ false;
+    var _localRemoteHost = /*[[${localRemoteHost}]]*/ '129.28.164.235';
+    if (_isLocalDebug && (_scriptServer === '127.0.0.1' || _scriptServer === 'localhost')) {
+        _scriptServer = _localRemoteHost || '129.28.164.235';
+    }
     // var _scriptServer = [[${callConfig.scriptServer}]];
     var _scriptPort = [[${callConfig.scriptPort}]];
     var _loginToken = [[${callConfig.loginToken}]];
     var _gatewayList = [[${callConfig.gatewayList}]];
     var _autoFree = [[${autoFree}]];
+    var _embedExtNum = /*[[${callConfig.extNum}]]*/ '';
+    var _embedMyGateway = /*[[${callConfig.myGateway}]]*/ '';
+    var _ctxPath = /*[[@{/}]]*/ '/';
 
     var _phoneNum = '';
     var _uuid = '';
@@ -346,10 +416,37 @@
     var reConnTimes = 0;
 
     var _callConfig = null;
+
+    /** 向 HIS 父页回传 IPCC 状态(embed 模式) */
+    function hisIpccPost(type, payload) {
+        if (!_hisEmbedMode || !window.parent || window.parent === window) return;
+        try {
+            window.parent.postMessage({
+                channel: 'HIS_IPCC_EMBED',
+                type: type,
+                payload: payload || {}
+            }, '*');
+        } catch (e) {}
+    }
+
+    function hisIpccForwardEvent(code, msg) {
+        hisIpccPost('EVENT', {
+            code: String(code),
+            msg: msg || {},
+            connected: !!( _phoneBar && typeof _phoneBar.getIsConnected === 'function' && _phoneBar.getIsConnected()),
+            callConnected: !!( _phoneBar && typeof _phoneBar.getCallConnected === 'function' && _phoneBar.getCallConnected()),
+            activeCallUuid: (_phoneBar && typeof _phoneBar.getActiveCallUuid === 'function') ? (_phoneBar.getActiveCallUuid() || '') : '',
+            isCalling: !!( _phoneBar && _phoneBar.isCalling),
+            agentStatus: (_phoneBar && _phoneBar.agentStatus != null) ? _phoneBar.agentStatus : null,
+            myGateway: _embedMyGateway || ''
+        });
+    }
     $(function() {
-        // 调用函数填充视频清晰度的下拉列表
-        populateVideoLevelDropdown('videoLevelSelect');
-        populateVideoLevelDropdown('member_video_level');
+        // 普通工作台才初始化视频下拉;HIS embed 仅要 IPCC 信令,跳过无关 UI
+        if (!_hisEmbedMode) {
+            populateVideoLevelDropdown('videoLevelSelect');
+            populateVideoLevelDropdown('member_video_level');
+        }
 
         // websocket通信对象断开事件;
         _phoneBar.on(ccPhoneBarSocket.eventListWithTextInfo.ws_disconnected.code, function(msg){
@@ -373,7 +470,9 @@
         _phoneBar.on(ccPhoneBarSocket.eventListWithTextInfo.user_login_on_other_device.code, function(msg){
             console.log(msg, "用户已在其他设备登录");
             _phoneBar.updatePhoneBar(msg, ccPhoneBarSocket.eventListWithTextInfo.user_login_on_other_device.code);
-            $.modal.msgWarning(ccPhoneBarSocket.eventListWithTextInfo.user_login_on_other_device.msg);
+            if (!_hisEmbedMode) {
+                $.modal.msgWarning(ccPhoneBarSocket.eventListWithTextInfo.user_login_on_other_device.msg);
+            }
             manualDisConn = true; // 被挤掉线的也不自动重连
         });
 
@@ -385,16 +484,16 @@
         });
 
         _phoneBar.on(ccPhoneBarSocket.eventList.caller_not_login, function (msg) {
-            $.modal.msgError(msg["msg"]);
+            if (!_hisEmbedMode) $.modal.msgError(msg["msg"]);
         });
         _phoneBar.on(ccPhoneBarSocket.eventList.outbound_finished, function (msg) {
-            $.modal.msgError(msg["msg"]);
+            if (!_hisEmbedMode) $.modal.msgError(msg["msg"]);
         });
         _phoneBar.on(ccPhoneBarSocket.eventList.server_error_audio_codec_not_match, function (msg) {
-            $.modal.msgError(msg["msg"]);
+            if (!_hisEmbedMode) $.modal.msgError(msg["msg"]);
         });
         _phoneBar.on(ccPhoneBarSocket.eventList.server_error, function (msg) {
-            $.modal.msgError(msg["msg"]);
+            if (!_hisEmbedMode) $.modal.msgError(msg["msg"]);
         });
 
         // 被叫振铃事件
@@ -406,11 +505,12 @@
         // 呼入事件
         _phoneBar.on(ccPhoneBarSocket.eventList.new_inbound_call, function(msg){
             console.log(msg, "呼入" );
-
-            // 呼入弹屏
             _phoneNum = msg.object.caller;
             _uuid = msg.object.uuid;
-            addCallRecord(_phoneNum, _uuid, 1);
+            // HIS embed:弹屏/CRM 由父页处理,避免多余 iframe/接口
+            if (!_hisEmbedMode) {
+                addCallRecord(_phoneNum, _uuid, 1);
+            }
         });
 
         // 主叫接通事件
@@ -418,20 +518,16 @@
             console.log(msg, "主叫接通" );
             $("#agentStatus").text("通话中");
             _phoneBar.updatePhoneBar(msg, ccPhoneBarSocket.eventListWithTextInfo.caller_answered.code);
-            // 外呼弹屏
-            // var _phoneNum = $("#ccphoneNumber").val();
-            // var _uuid = msg.object.uuid;
-            // addCallRecord(_phoneNum, _uuid, 2);
-
         });
 
         // 外呼事件
         _phoneBar.on(ccPhoneBarSocket.eventList.OUTBOUND_START, function (msg) {
             console.log('outbound_start',msg);
-            // 外呼弹屏
             _phoneNum = msg.object.destPhone;
             _uuid = msg.object.uuid;
-            addCallRecord(_phoneNum, _uuid, 2);
+            if (!_hisEmbedMode) {
+                addCallRecord(_phoneNum, _uuid, 2);
+            }
         });
 
 
@@ -443,8 +539,10 @@
             $("#sendVideoFileBtn").attr("disabled","disabled");
             $("#transfer_area").hide();
             _phoneBar.updatePhoneBar(msg, ccPhoneBarSocket.eventListWithTextInfo.caller_hangup.code);
-
-            addAsrRecord(_phoneNum, _uuid, _call_type, _asrResult);
+            // HIS embed:话单/ASR 由父页 sipCallCdrSync 负责
+            if (!_hisEmbedMode) {
+                addAsrRecord(_phoneNum, _uuid, _call_type, _asrResult);
+            }
         });
 
         // 被叫接通事件
@@ -478,7 +576,9 @@
             _phoneBar.updatePhoneBar(msg, ccPhoneBarSocket.eventListWithTextInfo.status_changed.code);
         });
 
+        // HIS embed 不需要排队人数展示/日志(615 推送很频繁,避免控制台刷屏)
         _phoneBar.on(ccPhoneBarSocket.eventList.acd_group_queue_number, function(msg){
+            if (_hisEmbedMode) return;
             console.log("当前排队人数消息: " ,msg);
             $("#queueStat").text(msg["object"]["queue_number"]);
         });
@@ -630,9 +730,10 @@
             onTransferToConferenceSuccess(msg);
         });
 
-        // 订阅的坐席状态列表发生改变
+        // 订阅的坐席状态列表发生改变(转接 UI 仅普通工作台)
         _phoneBar.on(ccPhoneBarSocket.eventList.agent_status_data_changed, function (msg) {
             console.log("agent_status_data_changed.");
+            if (_hisEmbedMode) return;
             // 当 transfer_to_groupId 值改变时更新 transfer_to_member
             $(transferToGroupId).off("change");
             $(transferToGroupId).on("change", function () {
@@ -643,7 +744,7 @@
 
         // 电话工具条参数配置;
         _callConfig = {
-            'useDefaultUi' : true,
+            'useDefaultUi' : !_hisEmbedMode,
             // loginToken 信息是加密的字符串, 包含以下字段信息:extnum[分机号]、opnum[工号]、groupId[业务组]、skillLevel[技能等级]
             'loginToken': _loginToken,
 
@@ -654,30 +755,6 @@
             // 注意在注册模式下,网关参数更改之后,必须重启语音服务 [docker restart freeswitch] 方可生效,不支持热更新;
             // 支持多个网关同时使用,按照优先级依次使用, 支持网关负载容错溢出 [第一条网关外呼出错后,自动使用第二条网关重试,直至外呼不出错] ;
             'gatewayList' : _gatewayList,
-            // 'gatewayList': [
-            //     {
-            //         uuid: '01',   // 网关唯一编号;
-            //         updateTime: 1712611571863,  //网关信息更新时间, 每次修改必须更新这个字段,否则配置无法生效;
-            //         gatewayAddr: '192.168.67.210:5090',  // 网关地址或名称,  如果是注册模式: 网关地址参数则填写为网关名称;
-            //         callerNumber: '64901409',                 //主叫号码
-            //         calleePrefix: '',                    // 被叫前缀
-            //         priority: 2,                         //优先级,数字越小,越优先被使用;
-            //         concurrency: 20,                    // 网关并发数,同时支持呼叫数;
-            //         register: false,                     // 是否为注册模式
-            //         audioCodec: 'g711'                   // 网关通信语音编码;
-            //     } ,
-            //     {
-            //         uuid: '02',   // 网关唯一编号;
-            //         updateTime: 1712611571863,  //网关信息更新时间, 每次修改必须更新这个字段,否则配置无法生效;
-            //         gatewayAddr: '192.168.67.210:5092',  // 网关地址或名称,  如果是注册模式: 网关地址参数则填写为网关名称;
-            //         callerNumber: '64901408',                 //主叫号码
-            //         calleePrefix: '',                    // 被叫前缀
-            //         priority: 1,                         //优先级,数字越小,越优先被使用;
-            //         concurrency: 20,                    // 网关并发数,同时支持呼叫数;
-            //         register: false,                     // 是否为注册模式
-            //         audioCodec: 'g711'                   // 网关通信语音编码;
-            //     }
-            // ],
 
             // 网关列表信息是否为加密模式;
             'gatewayEncrypted': false
@@ -689,17 +766,226 @@
         console.log(_callConfig);
         _phoneBar.initConfig(_callConfig);
 
+        // embed:转发关键 IPCC 事件给父页
+        if (_hisEmbedMode) {
+            var _forwardCodes = [
+                ccPhoneBarSocket.eventListWithTextInfo.ws_connected.code,
+                ccPhoneBarSocket.eventListWithTextInfo.ws_disconnected.code,
+                ccPhoneBarSocket.eventListWithTextInfo.user_login_on_other_device.code,
+                ccPhoneBarSocket.eventListWithTextInfo.request_args_error.code,
+                ccPhoneBarSocket.eventListWithTextInfo.status_changed.code,
+                ccPhoneBarSocket.eventListWithTextInfo.callee_ringing.code,
+                ccPhoneBarSocket.eventListWithTextInfo.caller_answered.code,
+                ccPhoneBarSocket.eventListWithTextInfo.caller_hangup.code,
+                ccPhoneBarSocket.eventListWithTextInfo.callee_answered.code,
+                ccPhoneBarSocket.eventListWithTextInfo.callee_hangup.code,
+                ccPhoneBarSocket.eventList.OUTBOUND_START,
+                ccPhoneBarSocket.eventList.caller_not_login,
+                ccPhoneBarSocket.eventList.outbound_finished,
+                ccPhoneBarSocket.eventList.server_error,
+                ccPhoneBarSocket.eventList.server_error_audio_codec_not_match,
+                ccPhoneBarSocket.eventList.on_audio_call_connected,
+                ccPhoneBarSocket.eventList.customer_channel_hold,
+                ccPhoneBarSocket.eventList.customer_channel_unhold,
+                ccPhoneBarSocket.eventList.customer_on_hold_hangup,
+                ccPhoneBarSocket.eventList.callee_busy,
+                ccPhoneBarSocket.eventList.caller_busy
+            ];
+            _forwardCodes.forEach(function(code) {
+                if (code == null) return;
+                _phoneBar.on(code, function(msg) {
+                    hisIpccForwardEvent(code, msg);
+                    var codeStr = String(code);
+                    if (codeStr === String(ccPhoneBarSocket.eventListWithTextInfo.ws_connected.code)) {
+                        hisIpccPost('CONNECTED', {
+                            connected: true,
+                            extNum: _embedExtNum,
+                            myGateway: _embedMyGateway || ''
+                        });
+                    } else if (codeStr === String(ccPhoneBarSocket.eventListWithTextInfo.ws_disconnected.code)
+                        || codeStr === String(ccPhoneBarSocket.eventListWithTextInfo.user_login_on_other_device.code)) {
+                        hisIpccPost('DISCONNECTED', {
+                            connected: false,
+                            code: codeStr,
+                            msg: msg
+                        });
+                    } else if (codeStr === String(ccPhoneBarSocket.eventListWithTextInfo.status_changed.code)) {
+                        hisIpccPost('STATUS', {
+                            status: msg && msg.object ? msg.object.status : null,
+                            text: msg && msg.object ? msg.object.text : '',
+                            msg: msg
+                        });
+                    }
+                });
+            });
+            try { sessionStorage.removeItem('his_ipcc_login_jump'); } catch (e) {}
+            hisIpccPost('READY', { extNum: _embedExtNum, myGateway: _embedMyGateway || '' });
+        }
 
         // 设置自动连接电话工具条
         setTimeout(function () {
             console.log(" 正在尝试连接电话工具条, 端口 1081 ");
             _phoneBar.connect();
-        }, 1500);
+        }, _hisEmbedMode ? 300 : 1500);
 
         $("#work-container").hide();
 
+        // embed 控制指令
+        if (_hisEmbedMode) {
+            initHisIpccEmbedCommandBridge();
+        }
+
     });
 
+    function initHisIpccEmbedCommandBridge() {
+        window.addEventListener('message', function(event) {
+            var data = event.data;
+            if (!data || data.channel !== 'HIS_IPCC_EMBED') return;
+            var type = data.type;
+            var payload = data.payload || {};
+            try {
+                if (type === 'PING') {
+                    hisIpccPost('PONG', {
+                        connected: !!( _phoneBar && _phoneBar.getIsConnected && _phoneBar.getIsConnected()),
+                        callConnected: !!( _phoneBar && _phoneBar.getCallConnected && _phoneBar.getCallConnected()),
+                        activeCallUuid: (_phoneBar && _phoneBar.getActiveCallUuid) ? (_phoneBar.getActiveCallUuid() || '') : '',
+                        myGateway: _embedMyGateway || ''
+                    });
+                    return;
+                }
+                if (type === 'CALL') {
+                    var phone = payload.phone;
+                    var callType = payload.callType || 'audio';
+                    var videoLevel = payload.videoLevel;
+                    var started = false;
+                    var failMsg = '';
+                    try {
+                        if (!_phoneBar) {
+                            failMsg = '电话条未就绪';
+                        } else if (!phone) {
+                            failMsg = '号码为空';
+                        } else if (typeof _phoneBar.getIsConnected === 'function' && !_phoneBar.getIsConnected()) {
+                            failMsg = 'IPCC未连接';
+                        } else {
+                            // call() 本身无布尔返回值,发出指令即视为已受理
+                            _phoneBar.call(phone, callType, videoLevel);
+                            started = true;
+                        }
+                    } catch (callErr) {
+                        failMsg = (callErr && callErr.message) || String(callErr);
+                        started = false;
+                    }
+                    hisIpccPost('CALL_RESULT', { ok: started, phone: phone, msg: failMsg || '' });
+                    return;
+                }
+                if (type === 'HANGUP') {
+                    if (_phoneBar) _phoneBar.hangup();
+                    hisIpccPost('HANGUP_RESULT', { ok: true });
+                    return;
+                }
+                if (type === 'HOLD') {
+                    var holdOk = false;
+                    if (_phoneBar && typeof _phoneBar.holdCall === 'function') holdOk = !!_phoneBar.holdCall();
+                    hisIpccPost('HOLD_RESULT', { ok: holdOk });
+                    return;
+                }
+                if (type === 'UNHOLD') {
+                    var unholdOk = false;
+                    if (_phoneBar && typeof _phoneBar.unHoldCall === 'function') unholdOk = !!_phoneBar.unHoldCall();
+                    hisIpccPost('UNHOLD_RESULT', { ok: unholdOk });
+                    return;
+                }
+                if (type === 'DISCONNECT') {
+                    manualDisConn = true;
+                    if (_phoneBar) {
+                        if (typeof _phoneBar.forceDisconnect === 'function') _phoneBar.forceDisconnect();
+                        else _phoneBar.disconnect();
+                    }
+                    hisIpccPost('DISCONNECTED', { connected: false, manual: true });
+                    return;
+                }
+                if (type === 'SET_STATUS') {
+                    var st = payload.status;
+                    if (_phoneBar) {
+                        if (st === 'busy' && typeof _phoneBar.setStatusBusy === 'function') _phoneBar.setStatusBusy();
+                        else if (st === 'free' && typeof _phoneBar.setStatusFree === 'function') _phoneBar.setStatusFree();
+                        else if (st != null && typeof _phoneBar.setStatus === 'function') _phoneBar.setStatus(st);
+                    }
+                    hisIpccPost('SET_STATUS_RESULT', { ok: true, status: st });
+                    return;
+                }
+                if (type === 'SWITCH_GATEWAY') {
+                    hisIpccSwitchGateway(payload.myGateway);
+                    return;
+                }
+                if (type === 'RECONNECT') {
+                    manualDisConn = false;
+                    if (_phoneBar) {
+                        if (typeof _phoneBar.resetKickedState === 'function') _phoneBar.resetKickedState();
+                        if (typeof _phoneBar.reconnect === 'function') _phoneBar.reconnect();
+                        else _phoneBar.connect();
+                    }
+                    return;
+                }
+            } catch (err) {
+                hisIpccPost('ERROR', { msg: (err && err.message) || String(err), type: type });
+            }
+        });
+    }
+
+    function hisIpccSwitchGateway(myGateway) {
+        var gw = (myGateway == null || myGateway === '') ? '' : String(myGateway);
+        var extNum = _embedExtNum;
+        if (!extNum) {
+            hisIpccPost('ERROR', { msg: '分机号缺失,无法切换网关' });
+            return;
+        }
+        hisIpccPost('SWITCH_GATEWAY_START', { myGateway: gw });
+        $.ajax({
+            url: (typeof ctx !== 'undefined' ? ctx : (_ctxPath || '/')) + 'aicall/api/myPhoneBar/params',
+            type: 'POST',
+            contentType: 'application/json;charset=utf-8',
+            dataType: 'json',
+            data: JSON.stringify({ extNum: String(extNum), myGateway: gw }),
+            success: function(r) {
+                if (!r || (r.code !== 0 && r.code !== 200) || !r.data || !r.data.loginToken) {
+                    hisIpccPost('ERROR', { msg: (r && (r.msg || r.message)) || '切换网关失败', type: 'SWITCH_GATEWAY' });
+                    return;
+                }
+                _embedMyGateway = gw;
+                var data = r.data;
+                var scriptServer = data.scriptServer || _scriptServer;
+                var scriptPort = data.scriptPort || _scriptPort;
+                _callConfig = {
+                    useDefaultUi: false,
+                    loginToken: data.loginToken,
+                    ipccServer: scriptServer + ':' + scriptPort,
+                    gatewayList: data.gatewayList || [],
+                    gatewayEncrypted: false
+                };
+                try {
+                    if (_phoneBar) {
+                        if (typeof _phoneBar.forceDisconnect === 'function') _phoneBar.forceDisconnect();
+                        else _phoneBar.disconnect();
+                    }
+                } catch (e) {}
+                setTimeout(function() {
+                    try {
+                        _phoneBar.initConfig(_callConfig);
+                        if (typeof _phoneBar.resetKickedState === 'function') _phoneBar.resetKickedState();
+                        _phoneBar.connect();
+                        hisIpccPost('SWITCH_GATEWAY_DONE', { myGateway: gw, ok: true });
+                    } catch (err) {
+                        hisIpccPost('ERROR', { msg: (err && err.message) || String(err), type: 'SWITCH_GATEWAY' });
+                    }
+                }, 400);
+            },
+            error: function(xhr, status, error) {
+                hisIpccPost('ERROR', { msg: error || '切换网关请求失败', type: 'SWITCH_GATEWAY' });
+            }
+        });
+    }
+
     function onConferenceEnd() {
         document.getElementById("endConference").setAttribute("disabled", "true");
         document.getElementById("startConference").removeAttribute("disabled");
@@ -810,6 +1096,8 @@
     }
 
     function addCallRecord(phoneNum, uuid, call_type) {
+        // HIS iframe 集成:不加载弹屏/CRM iframe,避免多余接口与跨域噪音
+        if (_hisEmbedMode) return;
 
         $("#work-container").show();
         $("#chat-container").show();
@@ -830,16 +1118,6 @@
         $('#popup-container').empty().append(
             '<iframe src="' + url + '" title="' + title + '"></iframe>'
         );
-
-        // _phoneNum = phoneNum;
-        // _uuid = uuid;
-        // _call_type = call_type;
-        // var modealName = '外呼弹屏';
-        // if (_call_type == 1) {
-        //     modealName = '呼入弹屏';
-        // }
-        // var url = ctx + "cc/custcallrecord/add?phoneNum=" + _phoneNum + "&callType=" + _call_type + "&uuid=" + _uuid;
-        // $.modal.open(modealName, url);
     }
 
     $('#onLineBtn').on('click', function () {
@@ -854,23 +1132,26 @@
         }
     });
 
-    /* asr实时对话文本框的功能 */
-    _phoneBar.on(ccPhoneBarSocket.eventList.asr_process_started, function (msg) {
-        $(chatMessages).html("");
-    });
-    _phoneBar.on(ccPhoneBarSocket.eventList.asr_result_generate, function (msg) {
-        handleAsrMessage(msg);
-    });
-    _phoneBar.on(ccPhoneBarSocket.eventList.asr_process_end_customer, function (msg) {
-        handleAsrMessage(msg);
-    });
-    _phoneBar.on(ccPhoneBarSocket.eventList.asr_process_end_agent, function (msg) {
-        handleAsrMessage(msg);
-    });
+    /* asr实时对话:仅普通工作台;HIS embed 跳过,减少无效监听/接口 */
+    if (!_hisEmbedMode) {
+        _phoneBar.on(ccPhoneBarSocket.eventList.asr_process_started, function (msg) {
+            $(chatMessages).html("");
+        });
+        _phoneBar.on(ccPhoneBarSocket.eventList.asr_result_generate, function (msg) {
+            handleAsrMessage(msg);
+        });
+        _phoneBar.on(ccPhoneBarSocket.eventList.asr_process_end_customer, function (msg) {
+            handleAsrMessage(msg);
+        });
+        _phoneBar.on(ccPhoneBarSocket.eventList.asr_process_end_agent, function (msg) {
+            handleAsrMessage(msg);
+        });
+    }
 
 
     const chatMessages = document.getElementById('chat-messages');
     function handleAsrMessage(data) {
+        if (_hisEmbedMode) return;
         $("#chat-container").show();
         const { status, object } = data;
         if (status === 619 && object) {
@@ -917,7 +1198,8 @@
     }
 
     function addAsrRecord(_phoneNum, _uuid, _call_type, _asrResult) {
-        console.log(_autoFree);
+        // HIS embed:不写 custDialog / 不点置闲,话单由父页负责
+        if (_hisEmbedMode) return;
         if (_autoFree) {
             $("#setFree").click();
         }
@@ -934,10 +1216,18 @@
                     "dialog": JSON.stringify(_asrResult),
                 },
                 beforeSend: function () {
+                    // embed 跨域时 $.modal.disable 会读 parent.document → SecurityError
+                    if (_hisEmbedMode) return;
                     $.modal.loading(i18n("common.tip.loading"));
                     $.modal.disable();
                 },
                 success: function(result) {
+                    if (_hisEmbedMode) return;
+                    $.modal.closeLoading();
+                    $.modal.enable();
+                },
+                error: function () {
+                    if (_hisEmbedMode) return;
                     $.modal.closeLoading();
                     $.modal.enable();
                 }

+ 29 - 0
ruoyi-common/src/main/java/com/ruoyi/common/config/RuoYiConfig.java

@@ -30,6 +30,15 @@ public class RuoYiConfig
     /** 获取地址开关 */
     private static boolean addressEnabled;
 
+    /**
+     * Local debug switch (default false).
+     * When true, replace 127.0.0.1/localhost with localRemoteHost.
+     */
+    private static boolean isLocalDebug = false;
+
+    /** Remote host used when isLocalDebug=true */
+    private static String localRemoteHost = "129.28.164.235";
+
     public static String getName()
     {
         return name;
@@ -90,6 +99,26 @@ public class RuoYiConfig
         RuoYiConfig.addressEnabled = addressEnabled;
     }
 
+    public static boolean isLocalDebug()
+    {
+        return isLocalDebug;
+    }
+
+    public void setIsLocalDebug(boolean isLocalDebug)
+    {
+        RuoYiConfig.isLocalDebug = isLocalDebug;
+    }
+
+    public static String getLocalRemoteHost()
+    {
+        return localRemoteHost;
+    }
+
+    public void setLocalRemoteHost(String localRemoteHost)
+    {
+        RuoYiConfig.localRemoteHost = localRemoteHost;
+    }
+
     /**
      * 获取导入上传路径
      */

+ 29 - 8
ruoyi-framework/src/main/java/com/ruoyi/framework/config/ResourcesConfig.java

@@ -1,7 +1,11 @@
 package com.ruoyi.framework.config;
 
+import javax.servlet.DispatcherType;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.web.servlet.FilterRegistrationBean;
+import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
 import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
@@ -10,17 +14,18 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
 import com.ruoyi.common.config.RuoYiConfig;
 import com.ruoyi.common.constant.Constants;
 import com.ruoyi.framework.interceptor.RepeatSubmitInterceptor;
+import com.ruoyi.framework.web.filter.LocalProfileProxyFilter;
 
 /**
- * 通用配置
- * 
+ * General web mvc config
+ *
  * @author ruoyi
  */
 @Configuration
 public class ResourcesConfig implements WebMvcConfigurer
 {
     /**
-     * 首页地址
+     * Index url
      */
     @Value("${shiro.user.indexUrl}")
     private String indexUrl;
@@ -29,7 +34,23 @@ public class ResourcesConfig implements WebMvcConfigurer
     private RepeatSubmitInterceptor repeatSubmitInterceptor;
 
     /**
-     * 默认首页的设置,当输入域名是可以自动跳转到默认指定的网页
+     * Register local profile redirect filter only when ruoyi.isLocalDebug=true
+     */
+    @Bean
+    @ConditionalOnProperty(prefix = "ruoyi", name = "isLocalDebug", havingValue = "true")
+    public FilterRegistrationBean<LocalProfileProxyFilter> localProfileProxyFilterRegistration()
+    {
+        FilterRegistrationBean<LocalProfileProxyFilter> registration = new FilterRegistrationBean<>();
+        registration.setFilter(new LocalProfileProxyFilter());
+        registration.addUrlPatterns("/*");
+        registration.setName("localProfileProxyFilter");
+        registration.setDispatcherTypes(DispatcherType.REQUEST);
+        registration.setOrder(FilterRegistrationBean.HIGHEST_PRECEDENCE + 20);
+        return registration;
+    }
+
+    /**
+     * Default home page forward
      */
     @Override
     public void addViewControllers(ViewControllerRegistry registry)
@@ -40,19 +61,19 @@ public class ResourcesConfig implements WebMvcConfigurer
     @Override
     public void addResourceHandlers(ResourceHandlerRegistry registry)
     {
-        /** 本地文件上传路径 */
+        /** Local upload path */
         registry.addResourceHandler(Constants.RESOURCE_PREFIX + "/**").addResourceLocations("file:" + RuoYiConfig.getProfile() + "/");
 
-        /** swagger配置 */
+        /** swagger */
         registry.addResourceHandler("/swagger-ui/**").addResourceLocations("classpath:/META-INF/resources/webjars/springfox-swagger-ui/");
     }
 
     /**
-     * 自定义拦截规则
+     * Interceptors
      */
     @Override
     public void addInterceptors(InterceptorRegistry registry)
     {
         registry.addInterceptor(repeatSubmitInterceptor).addPathPatterns("/**");
     }
-}
+}

+ 27 - 0
ruoyi-framework/src/main/java/com/ruoyi/framework/config/SessionCookieFilterConfig.java

@@ -0,0 +1,27 @@
+package com.ruoyi.framework.config;
+
+import javax.servlet.DispatcherType;
+import org.springframework.boot.web.servlet.FilterRegistrationBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.Ordered;
+import com.ruoyi.framework.web.filter.SessionCookieRewriteFilter;
+
+/**
+ * 注册会话 Cookie 改写过滤器(须早于 Shiro,才能包住 Set-Cookie)
+ */
+@Configuration
+public class SessionCookieFilterConfig
+{
+    @Bean
+    public FilterRegistrationBean<SessionCookieRewriteFilter> sessionCookieRewriteFilterRegistration()
+    {
+        FilterRegistrationBean<SessionCookieRewriteFilter> registration = new FilterRegistrationBean<>();
+        registration.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.FORWARD, DispatcherType.INCLUDE);
+        registration.setFilter(new SessionCookieRewriteFilter());
+        registration.addUrlPatterns("/*");
+        registration.setName("sessionCookieRewriteFilter");
+        registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 20);
+        return registration;
+    }
+}

+ 30 - 3
ruoyi-framework/src/main/java/com/ruoyi/framework/config/ShiroConfig.java

@@ -16,6 +16,7 @@ import org.apache.shiro.mgt.SecurityManager;
 import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
 import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
 import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
+import org.apache.shiro.web.servlet.Cookie;
 import org.apache.shiro.web.servlet.SimpleCookie;
 import org.springframework.beans.factory.annotation.Qualifier;
 import org.springframework.beans.factory.annotation.Value;
@@ -28,6 +29,7 @@ import com.ruoyi.common.utils.spring.SpringUtils;
 import com.ruoyi.framework.config.properties.PermitAllUrlProperties;
 import com.ruoyi.framework.shiro.realm.UserRealm;
 import com.ruoyi.framework.shiro.rememberMe.CustomCookieRememberMeManager;
+import com.ruoyi.framework.shiro.web.filter.embed.EmbedAwareUserFilter;
 import com.ruoyi.framework.shiro.session.OnlineSessionDAO;
 import com.ruoyi.framework.shiro.session.OnlineSessionFactory;
 import com.ruoyi.framework.shiro.web.CustomShiroFilterFactoryBean;
@@ -222,8 +224,16 @@ public class ShiroConfig
         manager.setDeleteInvalidSessions(true);
         // 设置全局session超时时间
         manager.setGlobalSessionTimeout(expireTime * 60 * 1000);
-        // 去掉 JSESSIONID
+        // 去掉 URL 重写 ;JSESSIONID=(仍使用 Cookie 传递会话)
         manager.setSessionIdUrlRewritingEnabled(false);
+        // 显式会话 Cookie:host-only + SameSite=Lax,保证同主机:8899 iframe 登录后能带到工作台
+        SimpleCookie sessionIdCookie = new SimpleCookie("JSESSIONID");
+        sessionIdCookie.setHttpOnly(true);
+        sessionIdCookie.setPath(StringUtils.isNotEmpty(path) ? path : "/");
+        // 不设置 Domain → host-only;避免 yml/反代写成上游 IP 导致浏览器拒收
+        sessionIdCookie.setSameSite(Cookie.SameSiteOptions.LAX);
+        manager.setSessionIdCookie(sessionIdCookie);
+        manager.setSessionIdCookieEnabled(true);
         // 定义要使用的无效的Session定时调度器
         manager.setSessionValidationScheduler(SpringUtils.getBean(SpringSessionValidationScheduler.class));
         // 是否定时检查session
@@ -314,6 +324,8 @@ public class ShiroConfig
         // filterChainDefinitionMap.putAll(SpringUtils.getBean(IMenuService.class).selectPermsAll());
 
         Map<String, Filter> filters = new LinkedHashMap<String, Filter>();
+        // 替换默认 user:未登录踢回时保留 embed=1
+        filters.put("user", embedAwareUserFilter());
         filters.put("onlineSession", onlineSessionFilter());
         filters.put("syncOnlineSession", syncOnlineSessionFilter());
         filters.put("captchaValidate", captchaValidateFilter());
@@ -367,10 +379,15 @@ public class ShiroConfig
     public SimpleCookie rememberMeCookie()
     {
         SimpleCookie cookie = new SimpleCookie("rememberMe");
-        cookie.setDomain(domain);
+        // Domain 为空时不要 setDomain,保持 host-only(与会话 Cookie 一致)
+        if (StringUtils.isNotEmpty(domain))
+        {
+            cookie.setDomain(domain);
+        }
         cookie.setPath(path);
         cookie.setHttpOnly(httpOnly);
         cookie.setMaxAge(maxAge * 24 * 60 * 60);
+        cookie.setSameSite(Cookie.SameSiteOptions.LAX);
         return cookie;
     }
 
@@ -404,11 +421,21 @@ public class ShiroConfig
         kickoutSessionFilter.setMaxSession(maxSession);
         // 是否踢出后来登录的,默认是false;即后者登录的用户踢出前者登录的用户;踢出顺序
         kickoutSessionFilter.setKickoutAfter(kickoutAfter);
-        // 被踢出后重定向到的地址;
+        // 被踢出后重定向到的地址(不带 embed,避免影响线上主流登录页iframe 内由 login.html 自动补 embed=1)
         kickoutSessionFilter.setKickoutUrl("/login?kickout=1");
         return kickoutSessionFilter;
     }
 
+    /**
+     * 未登录跳转:保留 HIS iframe embed 参数
+     */
+    public EmbedAwareUserFilter embedAwareUserFilter()
+    {
+        EmbedAwareUserFilter filter = new EmbedAwareUserFilter();
+        filter.setLoginUrl(loginUrl);
+        return filter;
+    }
+
     /**
      * thymeleaf模板引擎和shiro框架的整合
      */

+ 24 - 0
ruoyi-framework/src/main/java/com/ruoyi/framework/shiro/web/filter/embed/EmbedAwareUserFilter.java

@@ -0,0 +1,24 @@
+package com.ruoyi.framework.shiro.web.filter.embed;
+
+import java.io.IOException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import org.apache.shiro.web.filter.authc.UserFilter;
+import org.apache.shiro.web.util.WebUtils;
+
+/**
+ * 未登录跳转登录页时保留 embed=1,避免 iframe 内丢失嵌入态后反复 LOGIN_READY。
+ */
+public class EmbedAwareUserFilter extends UserFilter
+{
+    @Override
+    protected void redirectToLogin(ServletRequest request, ServletResponse response) throws IOException
+    {
+        String loginUrl = getLoginUrl();
+        if (EmbedRequestUtils.isEmbed(request))
+        {
+            loginUrl = EmbedRequestUtils.withEmbedParams(loginUrl);
+        }
+        WebUtils.issueRedirect(request, response, loginUrl);
+    }
+}

+ 63 - 0
ruoyi-framework/src/main/java/com/ruoyi/framework/shiro/web/filter/embed/EmbedRequestUtils.java

@@ -0,0 +1,63 @@
+package com.ruoyi.framework.shiro.web.filter.embed;
+
+import javax.servlet.ServletRequest;
+import javax.servlet.http.HttpServletRequest;
+import com.ruoyi.common.utils.StringUtils;
+
+/**
+ * HIS iframe 嵌入模式识别(embed=1 / Referer / 自定义头)
+ */
+public final class EmbedRequestUtils
+{
+    public static final String EMBED_PARAM = "embed";
+    public static final String EMBED_HEADER = "X-HIS-IPCC-Embed";
+
+    private EmbedRequestUtils()
+    {
+    }
+
+    public static boolean isEmbed(ServletRequest request)
+    {
+        if (request == null)
+        {
+            return false;
+        }
+        if ("1".equals(request.getParameter(EMBED_PARAM)))
+        {
+            return true;
+        }
+        if (!(request instanceof HttpServletRequest))
+        {
+            return false;
+        }
+        HttpServletRequest req = (HttpServletRequest) request;
+        if ("1".equals(req.getHeader(EMBED_HEADER)))
+        {
+            return true;
+        }
+        String referer = req.getHeader("Referer");
+        if (StringUtils.isNotEmpty(referer) && referer.contains("embed=1"))
+        {
+            return true;
+        }
+        return false;
+    }
+
+    /** 登录 URL 补齐 embed=1&lang=zh_CN,避免踢回登录后丢失嵌入态 */
+    public static String withEmbedParams(String loginUrl)
+    {
+        String url = StringUtils.isEmpty(loginUrl) ? "/login" : loginUrl;
+        if (url.contains("embed=1"))
+        {
+            return url;
+        }
+        StringBuilder sb = new StringBuilder(url);
+        sb.append(url.contains("?") ? "&" : "?");
+        sb.append("embed=1");
+        if (!url.contains("lang="))
+        {
+            sb.append("&lang=zh_CN");
+        }
+        return sb.toString();
+    }
+}

+ 7 - 1
ruoyi-framework/src/main/java/com/ruoyi/framework/shiro/web/filter/online/OnlineSessionFilter.java

@@ -14,6 +14,7 @@ import com.ruoyi.common.enums.OnlineStatus;
 import com.ruoyi.common.utils.ShiroUtils;
 import com.ruoyi.framework.shiro.session.OnlineSession;
 import com.ruoyi.framework.shiro.session.OnlineSessionDAO;
+import com.ruoyi.framework.shiro.web.filter.embed.EmbedRequestUtils;
 
 /**
  * 自定义访问控制
@@ -89,7 +90,12 @@ public class OnlineSessionFilter extends AccessControlFilter
     @Override
     protected void redirectToLogin(ServletRequest request, ServletResponse response) throws IOException
     {
-        WebUtils.issueRedirect(request, response, loginUrl);
+        String url = loginUrl;
+        if (EmbedRequestUtils.isEmbed(request))
+        {
+            url = EmbedRequestUtils.withEmbedParams(url);
+        }
+        WebUtils.issueRedirect(request, response, url);
     }
 
     public void setOnlineSessionDAO(OnlineSessionDAO onlineSessionDAO)

+ 61 - 0
ruoyi-framework/src/main/java/com/ruoyi/framework/web/filter/LocalProfileProxyFilter.java

@@ -0,0 +1,61 @@
+package com.ruoyi.framework.web.filter;
+
+import java.io.IOException;
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.springframework.web.filter.OncePerRequestFilter;
+import com.ruoyi.common.config.RuoYiConfig;
+import com.ruoyi.common.constant.Constants;
+import com.ruoyi.common.utils.StringUtils;
+
+/**
+ * Local debug only: redirect /profile/** from 127.0.0.1/localhost to remote host.
+ * Enabled only when ruoyi.isLocalDebug=true.
+ */
+public class LocalProfileProxyFilter extends OncePerRequestFilter
+{
+    @Override
+    protected boolean shouldNotFilter(HttpServletRequest request)
+    {
+        if (!RuoYiConfig.isLocalDebug())
+        {
+            return true;
+        }
+        String host = request.getServerName();
+        if (!"127.0.0.1".equals(host) && !"localhost".equals(host))
+        {
+            return true;
+        }
+        String path = resolvePath(request);
+        return path == null || !path.startsWith(Constants.RESOURCE_PREFIX + "/");
+    }
+
+    @Override
+    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+            throws ServletException, IOException
+    {
+        String path = resolvePath(request);
+        String qs = request.getQueryString();
+        String remoteHost = RuoYiConfig.getLocalRemoteHost();
+        if (StringUtils.isEmpty(remoteHost))
+        {
+            remoteHost = "129.28.164.235";
+        }
+        String remoteUrl = "http://" + remoteHost + path + (StringUtils.isNotEmpty(qs) ? "?" + qs : "");
+        response.setStatus(HttpServletResponse.SC_FOUND);
+        response.setHeader("Location", remoteUrl);
+    }
+
+    private String resolvePath(HttpServletRequest request)
+    {
+        String uri = request.getRequestURI();
+        String contextPath = request.getContextPath();
+        if (StringUtils.isNotEmpty(contextPath) && uri != null && uri.startsWith(contextPath))
+        {
+            return uri.substring(contextPath.length());
+        }
+        return uri;
+    }
+}

+ 183 - 0
ruoyi-framework/src/main/java/com/ruoyi/framework/web/filter/SessionCookieRewriteFilter.java

@@ -0,0 +1,183 @@
+package com.ruoyi.framework.web.filter;
+
+import java.io.IOException;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.Cookie;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpServletResponseWrapper;
+import com.ruoyi.framework.shiro.web.filter.embed.EmbedRequestUtils;
+
+/**
+ * 仅改写会话类 Cookie(JSESSIONID / rememberMe),不影响业务 Cookie。
+ * <p>
+ * 目标:在无法改 Nginx 时,尽量让浏览器收下会话 Cookie,供 HIS 同主机:8899 iframe 使用。
+ * 非嵌入主流访问:只清理「Domain=IP」这类明显错误,不强制改其它合法 Domain。
+ */
+public class SessionCookieRewriteFilter implements Filter
+{
+    private static final Pattern COOKIE_NAME = Pattern.compile("^\\s*([^=\\s;]+)\\s*=");
+    private static final Pattern DOMAIN_ATTR = Pattern.compile("(?i);\\s*Domain=([^;]*)");
+    private static final Pattern IP_DOMAIN = Pattern.compile(
+            "^\\[?[0-9a-fA-F:.]+\\]?$|^\\d{1,3}(?:\\.\\d{1,3}){3}$");
+
+    @Override
+    public void init(FilterConfig filterConfig)
+    {
+    }
+
+    @Override
+    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+            throws IOException, ServletException
+    {
+        HttpServletRequest req = (HttpServletRequest) request;
+        HttpServletResponse res = (HttpServletResponse) response;
+        boolean embed = EmbedRequestUtils.isEmbed(req);
+        boolean secure = req.isSecure()
+                || "https".equalsIgnoreCase(req.getHeader("X-Forwarded-Proto"));
+        chain.doFilter(request, new CookieRewriteResponse(res, embed, secure));
+    }
+
+    @Override
+    public void destroy()
+    {
+    }
+
+    static boolean isSessionCookieName(String name)
+    {
+        if (name == null)
+        {
+            return false;
+        }
+        String n = name.trim();
+        return "JSESSIONID".equalsIgnoreCase(n)
+                || "rememberMe".equalsIgnoreCase(n)
+                || "SHIROJSESSIONID".equalsIgnoreCase(n);
+    }
+
+    static boolean isIpLiteral(String domain)
+    {
+        if (domain == null)
+        {
+            return false;
+        }
+        String d = domain.trim();
+        if (d.startsWith("."))
+        {
+            d = d.substring(1);
+        }
+        return IP_DOMAIN.matcher(d).matches();
+    }
+
+    /**
+     * @param embedMode HIS iframe 嵌入:会话 Cookie 强制 host-only + SameSite=Lax
+     * @param secureRequest 当前是否 HTTPS(含 X-Forwarded-Proto)
+     */
+    static String rewriteSetCookie(String raw, boolean embedMode, boolean secureRequest)
+    {
+        if (raw == null || raw.isEmpty())
+        {
+            return raw;
+        }
+        Matcher nameMatcher = COOKIE_NAME.matcher(raw);
+        if (!nameMatcher.find())
+        {
+            return raw;
+        }
+        String cookieName = nameMatcher.group(1);
+        if (!isSessionCookieName(cookieName))
+        {
+            // 业务 Cookie 原样返回,不影响主流任务
+            return raw;
+        }
+
+        String value = raw;
+        Matcher domainMatcher = DOMAIN_ATTR.matcher(value);
+        if (domainMatcher.find())
+        {
+            String domainVal = domainMatcher.group(1) == null ? "" : domainMatcher.group(1).trim();
+            // embed:一律 host-only;主流:仅去掉 Domain=IP(反代常见误配)
+            if (embedMode || isIpLiteral(domainVal))
+            {
+                value = domainMatcher.replaceFirst("");
+            }
+        }
+
+        if (!secureRequest)
+        {
+            value = value.replaceAll("(?i);\\s*Secure", "");
+            value = value.replaceAll("(?i)SameSite=None", "SameSite=Lax");
+        }
+
+        // 会话 Cookie 补 SameSite=Lax(同站不同端口 iframe 可带上;不使用 None 以免无 Secure 被丢)
+        if (!value.matches("(?i).*\\bSameSite\\s*=.*"))
+        {
+            value = value + "; SameSite=Lax";
+        }
+        return value;
+    }
+
+    private static final class CookieRewriteResponse extends HttpServletResponseWrapper
+    {
+        private final boolean embedMode;
+        private final boolean secureRequest;
+
+        CookieRewriteResponse(HttpServletResponse response, boolean embedMode, boolean secureRequest)
+        {
+            super(response);
+            this.embedMode = embedMode;
+            this.secureRequest = secureRequest;
+        }
+
+        @Override
+        public void addCookie(Cookie cookie)
+        {
+            if (cookie != null && isSessionCookieName(cookie.getName()))
+            {
+                String domain = cookie.getDomain();
+                if (embedMode || isIpLiteral(domain))
+                {
+                    cookie.setDomain(null);
+                }
+                if (cookie.getPath() == null || cookie.getPath().isEmpty())
+                {
+                    cookie.setPath("/");
+                }
+                if (!secureRequest)
+                {
+                    cookie.setSecure(false);
+                }
+            }
+            super.addCookie(cookie);
+        }
+
+        @Override
+        public void setHeader(String name, String value)
+        {
+            if ("Set-Cookie".equalsIgnoreCase(name))
+            {
+                super.setHeader(name, rewriteSetCookie(value, embedMode, secureRequest));
+                return;
+            }
+            super.setHeader(name, value);
+        }
+
+        @Override
+        public void addHeader(String name, String value)
+        {
+            if ("Set-Cookie".equalsIgnoreCase(name))
+            {
+                super.addHeader(name, rewriteSetCookie(value, embedMode, secureRequest));
+                return;
+            }
+            super.addHeader(name, value);
+        }
+    }
+}