Parcourir la source

适配SIP注册模式的硬件使用改造

lmx il y a 23 heures
Parent
commit
ecac044156

+ 9 - 1
src/main/java/com/telerobot/fs/entity/dto/GatewayConfig.java

@@ -165,9 +165,10 @@ public class GatewayConfig {
         if(StringUtils.isNullOrEmpty(md5Info)){
         if(StringUtils.isNullOrEmpty(md5Info)){
             synchronized (this){
             synchronized (this){
                 if(StringUtils.isNullOrEmpty(md5Info)){
                 if(StringUtils.isNullOrEmpty(md5Info)){
+                    String gwAddrSafe = gatewayAddr == null ? "" : gatewayAddr;
                     String input = String.format("%s %s %s %s %s %s %s %s",
                     String input = String.format("%s %s %s %s %s %s %s %s",
                             uuid,
                             uuid,
-                            gatewayAddr.contains(";") ? gatewayAddr : (gatewayAddr + ";" + callProfile),
+                            gwAddrSafe.contains(";") ? gwAddrSafe : (gwAddrSafe + ";" + callProfile),
                             callerNumber,
                             callerNumber,
                             calleePrefix,
                             calleePrefix,
                             priority,
                             priority,
@@ -210,6 +211,13 @@ public class GatewayConfig {
         this.authUsername = authUsername;
         this.authUsername = authUsername;
     }
     }
 
 
+    /**
+     * 兼容 demo / 旧字段 authUserName(N 大写)
+     */
+    public void setAuthUserName(String authUserName) {
+        this.authUsername = authUserName;
+    }
+
     @Override
     @Override
     public boolean equals(Object obj) {
     public boolean equals(Object obj) {
         //先对地址值进行判断
         //先对地址值进行判断

+ 7 - 3
src/main/java/com/telerobot/fs/outbound/batchcall/CallTask.java

@@ -766,18 +766,22 @@ public class CallTask implements Runnable {
 					callee
 					callee
 			);
 			);
 		}else if(batchEntity.getRegister() == 2) {
 		}else if(batchEntity.getRegister() == 2) {
+			// 反向注册:按 authUsername 查已注册分机 Contact
 			String authUsername = batchEntity.getAuthUsername();
 			String authUsername = batchEntity.getAuthUsername();
-			String dynamicGateway = CommonUtils.getDynamicGatewayAddr(authUsername, getTraceId());
+			String profile = batchEntity.getProfileName();
+			String dynamicGateway = CommonUtils.getDynamicGatewayAddr(authUsername, getTraceId(), profile);
 			if(StringUtils.isEmpty(dynamicGateway)){
 			if(StringUtils.isEmpty(dynamicGateway)){
 				log.error("{} Failed to parse dynamic gateway address. ", getTraceId());
 				log.error("{} Failed to parse dynamic gateway address. ", getTraceId());
 				releaseThreadNum();
 				releaseThreadNum();
 				return;
 				return;
 			}
 			}
 			log.info("{} successfully get dynamic gateway address : {}", getTraceId(), dynamicGateway);
 			log.info("{} successfully get dynamic gateway address : {}", getTraceId(), dynamicGateway);
-			// for dynamic gateway, we must use internal profile
-			callParameter = String.format("{%s%s}sofia/internal/%s%s@%s  &park()",
+			String outboundProfile = StringUtils.isEmpty(profile) ? "external" : profile;
+			// sip_sticky_contact=true:对话内请求(ACK/BYE)发往实际网络地址,避免对端 200 OK 的 Contact 为内网时 ACK 无法送达
+			callParameter = String.format("{sip_sticky_contact=true,%s%s}sofia/%s/%s%s@%s  &park()",
 					callPrefix.toString(),
 					callPrefix.toString(),
 					extraParamsFinal,
 					extraParamsFinal,
+					outboundProfile,
 					batchEntity.getCalleePrefix(),
 					batchEntity.getCalleePrefix(),
 					callee,
 					callee,
 					dynamicGateway
 					dynamicGateway

+ 18 - 4
src/main/java/com/telerobot/fs/robot/TransferToAgent.java

@@ -270,6 +270,11 @@ public class TransferToAgent {
         );
         );
 
 
         if(gatewayInfo.getRegister() == 1){
         if(gatewayInfo.getRegister() == 1){
+            // 注册模式:优先 gwName,兼容历史 gatewayAddr 存网关名
+            String gatewayName = gatewayInfo.getGwName();
+            if(StringUtils.isNullOrEmpty(gatewayName)){
+                gatewayName = gatewayInfo.getGatewayAddr();
+            }
             bridgeString = String.format(
             bridgeString = String.format(
                     "{hangup_after_bridge=false,absolute_codec_string=%s,originate_timeout=%d,origination_uuid=%s,origination_caller_id_number=%s,effective_caller_id_number=%s%s}sofia/gateway/%s/%s%s  &park",
                     "{hangup_after_bridge=false,absolute_codec_string=%s,originate_timeout=%d,origination_uuid=%s,origination_caller_id_number=%s,effective_caller_id_number=%s%s}sofia/gateway/%s/%s%s  &park",
                     gatewayInfo.getAudioCodec(),
                     gatewayInfo.getAudioCodec(),
@@ -278,22 +283,31 @@ public class TransferToAgent {
                     callerNumber,
                     callerNumber,
                     callerNumber,
                     callerNumber,
                     extraParamsFinal,
                     extraParamsFinal,
-                    gatewayInfo.getGwName(),
+                    gatewayName,
                     gatewayInfo.getCalleePrefix(),
                     gatewayInfo.getCalleePrefix(),
                     destPhone
                     destPhone
             );
             );
         } else if(gatewayInfo.getRegister() == 2) {
         } else if(gatewayInfo.getRegister() == 2) {
+            // 反向注册:查已注册分机 Contact 后出局
             String authUsername = gatewayInfo.getAuthUsername();
             String authUsername = gatewayInfo.getAuthUsername();
-            String dynamicGateway = CommonUtils.getDynamicGatewayAddr(authUsername, inboundDetail.getUuid());
+            String profile = gatewayInfo.getCallProfile();
+            String dynamicGateway = CommonUtils.getDynamicGatewayAddr(authUsername, inboundDetail.getUuid(), profile);
+            if(StringUtils.isNullOrEmpty(dynamicGateway)){
+                logger.error("{} reverse-register contact empty, authUsername={}, profile={}",
+                        inboundDetail.getUuid(), authUsername, profile);
+                return;
+            }
             logger.info("{} successfully get dynamic gateway address : {}", inboundDetail.getUuid(), dynamicGateway);
             logger.info("{} successfully get dynamic gateway address : {}", inboundDetail.getUuid(), dynamicGateway);
-            // for dynamic gateway, we must use internal profile
-            bridgeString = String.format("{hangup_after_bridge=false,absolute_codec_string=%s,originate_timeout=%d,origination_uuid=%s,origination_caller_id_number=%s,effective_caller_id_number=%s%s}sofia/internal/%s%s@%s  &park",
+            String outboundProfile = StringUtils.isNullOrEmpty(profile) ? "external" : profile;
+            // sip_sticky_contact=true:对话内请求(ACK/BYE)发往实际网络地址,避免对端 200 OK 的 Contact 为内网时 ACK 无法送达
+            bridgeString = String.format("{sip_sticky_contact=true,hangup_after_bridge=false,absolute_codec_string=%s,originate_timeout=%d,origination_uuid=%s,origination_caller_id_number=%s,effective_caller_id_number=%s%s}sofia/%s/%s%s@%s  &park",
                     gatewayInfo.getAudioCodec(),
                     gatewayInfo.getAudioCodec(),
                     callTimeout,
                     callTimeout,
                     outboundUuid,
                     outboundUuid,
                     callerNumber,
                     callerNumber,
                     callerNumber,
                     callerNumber,
                     extraParamsFinal,
                     extraParamsFinal,
+                    outboundProfile,
                     gatewayInfo.getCalleePrefix(),
                     gatewayInfo.getCalleePrefix(),
                     destPhone,
                     destPhone,
                     dynamicGateway
                     dynamicGateway

+ 49 - 10
src/main/java/com/telerobot/fs/utils/CommonUtils.java

@@ -102,18 +102,57 @@ public class CommonUtils<T>  {
 	}
 	}
 
 
 	/**
 	/**
-	 *  get dynamic gateway ip address and port
-	 * @return
+	 * 查询反向注册分机的 Contact(ip:port),默认先查 internal。
 	 */
 	 */
 	public static String getDynamicGatewayAddr(String extensionNumber, String traceId){
 	public static String getDynamicGatewayAddr(String extensionNumber, String traceId){
-		EslMessage response = EslConnectionUtil.sendSyncApiCommand("sofia", "xmlstatus profile internal reg");
-		StringBuilder xml = new StringBuilder();
-		for(String s : response.getBodyLines()){
-			xml.append(s);
-		}
-		String xmlStr = xml.toString();
-		logger.info("{} execute cmd 'sofia xmlstatus profile internal reg', response length={}", traceId, xml.length());
-		return XmlUtils.parseFsOnlineUserListXml(xmlStr, extensionNumber);
+		return getDynamicGatewayAddr(extensionNumber, traceId, "internal");
+	}
+
+	/**
+	 * 按指定 sofia profile 查询已注册分机的 Contact(ip:port)。
+	 * 反向注册线路通常在 external;查不到时再回退 external / internal。
+	 */
+	public static String getDynamicGatewayAddr(String extensionNumber, String traceId, String profileName){
+		if(StringUtils.isBlank(extensionNumber)){
+			logger.warn("{} 反向注册 authUsername 为空,无法查询 Contact", traceId);
+			return "";
+		}
+		List<String> profiles = new ArrayList<String>(3);
+		if(StringUtils.isNotBlank(profileName)){
+			profiles.add(profileName.trim());
+		}
+		if(!profiles.contains("external")){
+			profiles.add("external");
+		}
+		if(!profiles.contains("internal")){
+			profiles.add("internal");
+		}
+		for(String profile : profiles){
+			EslMessage response = EslConnectionUtil.sendSyncApiCommand(
+					"sofia", "xmlstatus profile " + profile + " reg");
+			if(response == null || response.getBodyLines() == null){
+				logger.warn("{} 查询反向注册 Contact:profile={} 无响应,authUsername={}",
+						traceId, profile, extensionNumber);
+				continue;
+			}
+			StringBuilder xml = new StringBuilder();
+			for(String s : response.getBodyLines()){
+				xml.append(s);
+			}
+			logger.info("{} 查询反向注册 Contact:sofia xmlstatus profile {} reg,响应长度={}, authUsername={}",
+					traceId, profile, xml.length(), extensionNumber);
+			String contact = XmlUtils.parseFsOnlineUserListXml(xml.toString(), extensionNumber);
+			if(StringUtils.isNotBlank(contact)){
+				logger.info("{} 已找到反向注册 Contact:authUsername={}, profile={}, contact={}",
+						traceId, extensionNumber, profile, contact);
+				return contact;
+			}
+			logger.info("{} 当前 profile 未找到 Contact:profile={}, authUsername={}",
+					traceId, profile, extensionNumber);
+		}
+		logger.warn("{} 反向注册 Contact 为空,authUsername={},已查 profiles={},不再 originate 空地址",
+				traceId, extensionNumber, profiles);
+		return "";
 	}
 	}
 
 
 	/**
 	/**

+ 150 - 16
src/main/java/com/telerobot/fs/utils/XmlUtils.java

@@ -10,46 +10,180 @@ import org.w3c.dom.NodeList;
 import javax.xml.parsers.DocumentBuilder;
 import javax.xml.parsers.DocumentBuilder;
 import javax.xml.parsers.DocumentBuilderFactory;
 import javax.xml.parsers.DocumentBuilderFactory;
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 
 public class XmlUtils {
 public class XmlUtils {
     protected final static Logger logger = LoggerFactory.getLogger(XmlUtils.class);
     protected final static Logger logger = LoggerFactory.getLogger(XmlUtils.class);
 
 
+    /** 从 contact 标签中提取 sip:user@host:port 的 host:port */
+    private static final Pattern CONTACT_HOST_PORT = Pattern.compile(
+            "sip:[^@;>\\s]+@([^:;>\\s]+):(\\d+)", Pattern.CASE_INSENSITIVE);
+
     /**
     /**
-     * parse freeSWITCH online registration user list info.
-     * @param xml
-     * @return
+     * 从 sofia xmlstatus profile X reg 的 XML 中解析已注册分机的 Contact(ip:port)。
+     * 匹配规则与 GUI FsConfController.convertProfileRegExtnumXml 对齐:
+     * 1) sip-auth-user 等于目标分机;或 2) user(@ 前部分)等于目标分机。
+     * 地址优先取 network-ip:network-port(与 GUI 展示一致,为 NAT 后公网地址);
+     * 若缺失再从 contact 的 sip URI 回退解析。
+     * 注意:反向注册场景 ESL 事件常见 username=unknown,sip-auth-user 可能为空/unknown,
+     * 仅匹配 sip-auth-user 会导致漏找(GUI 能显示而 callcenter 解析不到)。
+     *
+     * @param xml sofia xmlstatus 响应全文
+     * @param extensionNumber 网关 authUsername / 分机号
+     * @return ip:port,未找到返回空串
      */
      */
     public static String parseFsOnlineUserListXml(String xml, String extensionNumber) {
     public static String parseFsOnlineUserListXml(String xml, String extensionNumber) {
-        if(StringUtils.isNullOrEmpty(xml)){
+        if (StringUtils.isNullOrEmpty(xml) || StringUtils.isNullOrEmpty(extensionNumber)) {
             return "";
             return "";
         }
         }
+        String target = extensionNumber.trim();
         try {
         try {
             DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
             DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
             DocumentBuilder builder = factory.newDocumentBuilder();
             DocumentBuilder builder = factory.newDocumentBuilder();
-            Document doc = builder.parse(new ByteArrayInputStream(xml.getBytes()));
+            Document doc = builder.parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)));
 
 
             NodeList registrations = doc.getElementsByTagName("registration");
             NodeList registrations = doc.getElementsByTagName("registration");
+            int regCount = registrations.getLength();
+            logger.info("parseFsOnlineUserListXml: registration条数={}, 目标分机={}", regCount, target);
 
 
-            for (int i = 0; i < registrations.getLength(); i++) {
+            for (int i = 0; i < regCount; i++) {
                 Element registration = (Element) registrations.item(i);
                 Element registration = (Element) registrations.item(i);
 
 
-                // get sip-auth-user element
-                Node sipAuthUserNode = registration.getElementsByTagName("sip-auth-user").item(0);
-                if (sipAuthUserNode != null && extensionNumber.equals(sipAuthUserNode.getTextContent())) {
+                String sipAuthUser = getChildText(registration, "sip-auth-user");
+                String userRaw = getChildText(registration, "user");
+                String userPart = extractUserPart(userRaw);
+
+                boolean matchedByAuth = target.equals(sipAuthUser);
+                boolean matchedByUser = target.equals(userPart);
+                if (!matchedByAuth && !matchedByUser) {
+                    continue;
+                }
+
+                String matchBy = matchedByAuth ? "sip-auth-user" : "user";
+                String networkIp = getChildText(registration, "network-ip");
+                String networkPort = getChildText(registration, "network-port");
+                String contactTag = getChildText(registration, "contact");
+                String contact;
+                if (!StringUtils.isNullOrEmpty(networkIp) && !StringUtils.isNullOrEmpty(networkPort)) {
+                    contact = networkIp + ":" + networkPort;
+                } else {
+                    contact = extractHostPortFromContact(contactTag);
+                    logger.info("parseFsOnlineUserListXml: network-ip/port 缺失,回退解析 contact 标签,matchBy={}, contact={}", matchBy, contact);
+                }
 
 
-                    // get network-ip和network-port
-                    String networkIp = registration.getElementsByTagName("network-ip")
-                            .item(0).getTextContent();
-                    String networkPort = registration.getElementsByTagName("network-port")
-                            .item(0).getTextContent();
+                if (StringUtils.isNullOrEmpty(contact)) {
+                    logger.warn("parseFsOnlineUserListXml: 已匹配 registration 但 Contact 为空,matchBy={}, user={}, sip-auth-user={}, contactTag={}", matchBy, userRaw, sipAuthUser, contactTag);
+                    continue;
+                }
 
 
-                    return networkIp + ":" + networkPort;
+                logger.info("parseFsOnlineUserListXml: 匹配成功 index={}, matchBy={}, user={}, sip-auth-user={}, contact={}", i, matchBy, userRaw, sipAuthUser, contact);
+                return contact;
+            }
+
+            // 未命中时输出少量候选,便于对比 GUI「查看状态」
+            int sample = Math.min(regCount, 5);
+            for (int i = 0; i < sample; i++) {
+                Element registration = (Element) registrations.item(i);
+                logger.info("parseFsOnlineUserListXml: 未命中样例[{}] user={}, sip-auth-user={}, network={}:{}",
+                        i,
+                        getChildText(registration, "user"),
+                        getChildText(registration, "sip-auth-user"),
+                        getChildText(registration, "network-ip"),
+                        getChildText(registration, "network-port"));
+            }
+        } catch (Throwable e) {
+            logger.error("parseFsOnlineUserListXml error! {} {}", e.toString(),
+                    CommonUtils.getStackTraceString(e.getStackTrace()));
+            // DOM 失败时(contact 含未转义 <> 等)用正则兜底,对齐 GUI 的 user + network-ip
+            String fallback = parseFsOnlineUserListXmlByRegex(xml, target);
+            if (!StringUtils.isNullOrEmpty(fallback)) {
+                return fallback;
+            }
+        }
+        return "";
+    }
+
+    /**
+     * DOM 解析失败时的正则兜底:按 <user>分机@...</user> 定位 registration 片段,再取 network-ip/port。
+     */
+    private static String parseFsOnlineUserListXmlByRegex(String xml, String target) {
+        try {
+            Pattern regBlock = Pattern.compile("<registration>([\\s\\S]*?)</registration>", Pattern.CASE_INSENSITIVE);
+            Matcher m = regBlock.matcher(xml);
+            int idx = 0;
+            while (m.find()) {
+                String block = m.group(1);
+                String sipAuthUser = extractXmlTag(block, "sip-auth-user");
+                String userRaw = extractXmlTag(block, "user");
+                String userPart = extractUserPart(userRaw);
+                boolean matched = target.equals(sipAuthUser) || target.equals(userPart);
+                if (!matched) {
+                    idx++;
+                    continue;
+                }
+                String networkIp = extractXmlTag(block, "network-ip");
+                String networkPort = extractXmlTag(block, "network-port");
+                String contact;
+                if (!StringUtils.isNullOrEmpty(networkIp) && !StringUtils.isNullOrEmpty(networkPort)) {
+                    contact = networkIp + ":" + networkPort;
+                } else {
+                    contact = extractHostPortFromContact(extractXmlTag(block, "contact"));
+                }
+                if (!StringUtils.isNullOrEmpty(contact)) {
+                    logger.info("parseFsOnlineUserListXml: 正则兜底匹配成功 index={}, user={}, sip-auth-user={}, contact={}", idx, userRaw, sipAuthUser, contact);
+                    return contact;
                 }
                 }
+                idx++;
             }
             }
         } catch (Throwable e) {
         } catch (Throwable e) {
-            logger.error("parseFsOnlineUserListXml error! {} {}", e.toString(), CommonUtils.getStackTraceString(e.getStackTrace()) );
+            logger.error("parseFsOnlineUserListXmlByRegex error! {}", e.toString());
         }
         }
         return "";
         return "";
     }
     }
 
 
+    private static String getChildText(Element parent, String tagName) {
+        NodeList list = parent.getElementsByTagName(tagName);
+        if (list == null || list.getLength() == 0) {
+            return "";
+        }
+        Node node = list.item(0);
+        if (node == null || node.getTextContent() == null) {
+            return "";
+        }
+        return node.getTextContent().trim();
+    }
+
+    private static String extractUserPart(String userRaw) {
+        if (StringUtils.isNullOrEmpty(userRaw)) {
+            return "";
+        }
+        int at = userRaw.indexOf('@');
+        if (at > 0) {
+            return userRaw.substring(0, at).trim();
+        }
+        return userRaw.trim();
+    }
+
+    private static String extractHostPortFromContact(String contactTag) {
+        if (StringUtils.isNullOrEmpty(contactTag)) {
+            return "";
+        }
+        Matcher matcher = CONTACT_HOST_PORT.matcher(contactTag);
+        if (matcher.find()) {
+            return matcher.group(1) + ":" + matcher.group(2);
+        }
+        return "";
+    }
+
+    private static String extractXmlTag(String block, String tagName) {
+        Pattern p = Pattern.compile("<" + tagName + ">\\s*([^<]*?)\\s*</" + tagName + ">",
+                Pattern.CASE_INSENSITIVE);
+        Matcher m = p.matcher(block);
+        if (m.find()) {
+            return m.group(1).trim();
+        }
+        return "";
+    }
 }
 }

+ 76 - 11
src/main/java/com/telerobot/fs/wshandle/impl/CallApi.java

@@ -1015,6 +1015,34 @@ public class CallApi extends MsgHandlerBase {
 		);
 		);
 	}
 	}
 
 
+    /**
+     * 网关列表诊断摘要(uuid/register/callProfile 等),便于对照外呼日志。
+     */
+    private String summarizeGatewayList(List<GatewayConfig> gatewayList) {
+        if (gatewayList == null || gatewayList.isEmpty()) {
+            return "[]";
+        }
+        StringBuilder sb = new StringBuilder("[");
+        for (int i = 0; i < gatewayList.size(); i++) {
+            GatewayConfig g = gatewayList.get(i);
+            if (i > 0) {
+                sb.append("; ");
+            }
+            sb.append(String.format(
+                    "uuid=%s,register=%s,callProfile=%s,authUsername=%s,gatewayAddr=%s,gwName=%s,audioCodec=%s",
+                    g.getUuid(),
+                    g.getRegister(),
+                    g.getCallProfile(),
+                    g.getAuthUsername(),
+                    g.getGatewayAddr(),
+                    g.getGwName(),
+                    g.getAudioCodec()
+            ));
+        }
+        sb.append("]");
+        return sb.toString();
+    }
+
     private String genCallPhoneString(GatewayConfig gatewayConfig, String projectId, String caseNo,
     private String genCallPhoneString(GatewayConfig gatewayConfig, String projectId, String caseNo,
                                  String uuidInner, String uuidOuter, String phone,
                                  String uuidInner, String uuidOuter, String phone,
                                  String callType, String videoLevel)
                                  String callType, String videoLevel)
@@ -1068,23 +1096,39 @@ public class CallApi extends MsgHandlerBase {
         );
         );
 
 
         if(gatewayConfig.getRegister() == 1){
         if(gatewayConfig.getRegister() == 1){
+            // 注册模式:优先 gwName;兼容历史把网关名写在 gatewayAddr 的写法
+            String gatewayName = gatewayConfig.getGwName();
+            if(StringUtils.isNullOrEmpty(gatewayName)){
+                gatewayName = gatewayAddress;
+            }
             bridgeString = String.format("{execute_on_answer='record_session %s',%s%s}sofia/gateway/%s/%s%s  &park",
             bridgeString = String.format("{execute_on_answer='record_session %s',%s%s}sofia/gateway/%s/%s%s  &park",
                     CallConfig.RECORDINGS_PATH + fullRecordPath,
                     CallConfig.RECORDINGS_PATH + fullRecordPath,
                     callPrefixOuterLine,
                     callPrefixOuterLine,
                     extraParamsFinal,
                     extraParamsFinal,
-                    gatewayConfig.getGwName(),
+                    gatewayName,
                     calleePrefix,
                     calleePrefix,
                     phone
                     phone
             );
             );
         } else if(gatewayConfig.getRegister() == 2) {
         } else if(gatewayConfig.getRegister() == 2) {
+            // 反向注册:按 authUsername 查已注册分机 Contact,再经 callProfile 出局
             String authUsername = gatewayConfig.getAuthUsername();
             String authUsername = gatewayConfig.getAuthUsername();
-            String dynamicGateway = CommonUtils.getDynamicGatewayAddr(authUsername, getTraceId());
-            logger.info("{} successfully get dynamic gateway address : {}", getTraceId(), dynamicGateway);
-            // for dynamic gateway, we must use internal profile
-            bridgeString = String.format("{execute_on_answer='record_session %s',%s%s}sofia/internal/%s%s@%s  &park()",
+            logger.info("{} 软电话外呼 register=2:查询 Contact,authUsername={}, callProfile={}",
+                    getTraceId(), authUsername, sipProfile);
+            String dynamicGateway = CommonUtils.getDynamicGatewayAddr(authUsername, getTraceId(), sipProfile);
+            if(StringUtils.isNullOrEmpty(dynamicGateway)){
+                logger.warn("{} 软电话外呼 register=2:Contact 为空,authUsername={}, profile={},跳过空地址 originate",
+                        getTraceId(), authUsername, sipProfile);
+                return "";
+            }
+            String outboundProfile = StringUtils.isNullOrEmpty(sipProfile) ? "external" : sipProfile;
+            logger.info("{} 软电话外呼 register=2:已找到 Contact={},出局 profile={}",
+                    getTraceId(), dynamicGateway, outboundProfile);
+            // sip_sticky_contact=true:对话内请求(ACK/BYE)发往实际网络地址,避免对端 200 OK 的 Contact 为内网时 ACK 无法送达
+            bridgeString = String.format("{sip_sticky_contact=true,execute_on_answer='record_session %s',%s%s}sofia/%s/%s%s@%s  &park()",
                     CallConfig.RECORDINGS_PATH + fullRecordPath,
                     CallConfig.RECORDINGS_PATH + fullRecordPath,
                     callPrefixOuterLine,
                     callPrefixOuterLine,
                     extraParamsFinal,
                     extraParamsFinal,
+                    outboundProfile,
                     calleePrefix,
                     calleePrefix,
                     callee,
                     callee,
                     dynamicGateway
                     dynamicGateway
@@ -1173,6 +1217,9 @@ public class CallApi extends MsgHandlerBase {
             }
             }
         }
         }
 
 
+        logger.info("{} 软电话外呼收到 startSession:destPhone={}, callType={}, gatewayList摘要={}",
+                getTraceId(), phone, callType, summarizeGatewayList(gatewayList));
+
         String uuidInner = UuidGenerator.GetOneUuid();
         String uuidInner = UuidGenerator.GetOneUuid();
         String uuidOuter = UuidGenerator.GetOneUuid();
         String uuidOuter = UuidGenerator.GetOneUuid();
         SwitchChannel agentChannel = new SwitchChannel(uuidInner, uuidOuter, callType, CallDirection.OUTBOUND);
         SwitchChannel agentChannel = new SwitchChannel(uuidInner, uuidOuter, callType, CallDirection.OUTBOUND);
@@ -1262,21 +1309,36 @@ public class CallApi extends MsgHandlerBase {
                     );
                     );
                     break;
                     break;
                 }
                 }
-                logger.info("{} successfully get a gateway: {} ", getTraceId(), JSON.toJSONString(gatewayConfig));
+                logger.info("{} 软电话外呼选中网关:uuid={}, register={}, callProfile={}, authUsername={}, gatewayAddr={}, gwName={}",
+                        getTraceId(),
+                        gatewayConfig.getUuid(),
+                        gatewayConfig.getRegister(),
+                        gatewayConfig.getCallProfile(),
+                        gatewayConfig.getAuthUsername(),
+                        gatewayConfig.getGatewayAddr(),
+                        gatewayConfig.getGwName());
 
 
                 String originationStr = genCallPhoneString(gatewayConfig, projectId, caseNo, uuidInner,
                 String originationStr = genCallPhoneString(gatewayConfig, projectId, caseNo, uuidInner,
                         uuidOuter, phone, callType, videoLevel);
                         uuidOuter, phone, callType, videoLevel);
                 customerChannel.setGatewayConfig(gatewayConfig);
                 customerChannel.setGatewayConfig(gatewayConfig);
+                // 反向注册 Contact 解析失败时 dialstring 为空,换下一网关重试
+                if(StringUtils.isNullOrEmpty(originationStr)){
+                    logger.warn("{} 软电话外呼 dialstring 为空(多为 register=2 Contact 未找到),跳过 originate,标记网关已尝试:uuid={}, register={}",
+                            getTraceId(), gatewayConfig.getUuid(), gatewayConfig.getRegister());
+                    triedList.add(gatewayConfig);
+                    SipGatewayLoadBalance.releaseGateway(gatewayConfig);
+                    continue;
+                }
 
 
                 connectionPool.getDefaultEslConn().addListener(uuidOuter, listener);
                 connectionPool.getDefaultEslConn().addListener(uuidOuter, listener);
-                logger.info("{} originationStr: originate {}", getTraceId(), originationStr);
+                logger.info("{} 软电话外呼最终 dialstring:register={}, originate {}", getTraceId(), gatewayConfig.getRegister(), originationStr);
                 String jobId = EslConnectionUtil.sendAsyncApiCommand("originate", originationStr, connectionPool);
                 String jobId = EslConnectionUtil.sendAsyncApiCommand("originate", originationStr, connectionPool);
-                logger.info("{} fs bgapi originate response: {}", getTraceId(), jobId);
+                logger.info("{} 软电话外呼 originate 已提交,bgJobId={}", getTraceId(), jobId);
                 if (!StringUtils.isNullOrEmpty(jobId)) {
                 if (!StringUtils.isNullOrEmpty(jobId)) {
                     connectionPool.getDefaultEslConn().addListener(jobId.trim(), listener);
                     connectionPool.getDefaultEslConn().addListener(jobId.trim(), listener);
                     this.listener.setBackgroundJobUuid(jobId.trim());
                     this.listener.setBackgroundJobUuid(jobId.trim());
                 } else {
                 } else {
-                    logger.error("{}  cant not get FreeSWITCH backGroundJobUuid", getTraceId());
+                    logger.error("{} 软电话外呼未能获取 FreeSWITCH backGroundJobUuid", getTraceId());
                 }
                 }
 
 
                 listener.waitForSignal();
                 listener.waitForSignal();
@@ -1285,10 +1347,13 @@ public class CallApi extends MsgHandlerBase {
                 }
                 }
 
 
                 if (!listener.checkCustomerChannelCallStatus()) {
                 if (!listener.checkCustomerChannelCallStatus()) {
-                    logger.info("{} call originate failed,add current gateway to  triedList: gateway = {}  ", getTraceId(), gatewayConfig);
+                    logger.warn("{} 软电话外呼 originate 失败:uuid={}, register={}, sipCode={},加入 triedList",
+                            getTraceId(), gatewayConfig.getUuid(), gatewayConfig.getRegister(),
+                            customerChannel.getHangupSipCode());
                     triedList.add(gatewayConfig);
                     triedList.add(gatewayConfig);
                 } else {
                 } else {
-                    logger.info("{} call originate finished successfully,tried gateway list count:{} , details: {}", getTraceId(), triedList.size(), triedList);
+                    logger.info("{} 软电话外呼 originate 成功:uuid={}, register={}, 已尝试网关数={}",
+                            getTraceId(), gatewayConfig.getUuid(), gatewayConfig.getRegister(), triedList.size());
                 }
                 }
             } while (!listener.checkCustomerChannelCallStatus() &&
             } while (!listener.checkCustomerChannelCallStatus() &&
                     agentChannel.getHangupTime() == 0L && !getIsDisposed());
                     agentChannel.getHangupTime() == 0L && !getIsDisposed());