业务场景:为什么需要双注册
呼叫中心的坐席有两种终端:PC上的软电话(方便记录和操作)和桌面上的IP话机(音质更好、看状态灯方便)。理想情况是来电话时两个设备同时响铃,坐席随便接一个就行。
这在VoIP里的术语叫”dual-registration”,FreeSWITCH是支持这个特性的,但默认行为和实际需求之间有几个坑。
踩坑过程:外呼显示号码乱跳
需求上线第一周,客户主管反馈:”为什么有时候外显号码是对的,有时候是错的?”
抓了几个呼叫的SIP消息,发现问题:同一个分机号1001先在软电话注册,过一会儿IP话机也注册了。出问题时外呼的From头域用的是IP话机的Contact,而不是软电话的。
# 正常情况(先注册的软电话主导)
From: <sip:1001@pbx.example.com>;tag=xxx
Contact: <sip:1001@192.168.1.100:5060> # 软电话
# 异常情况(后注册的IP话机主导)
From: <sip:1001@pbx.example.com>;tag=xxx
Contact: <sip:1001@192.168.1.101:5060> # IP话机
运营商侧显示的外显号码不一样。运营商SIP trunk的路由规则是按Contact头域里的IP地址或端口来做号码变换的——他们那边配置了白名单,只有特定IP来源才能用特定外显号。软电话注册在192.168.1.100,对应外显40001234;IP话机注册在192.168.1.101,对应外显40001235。当后注册的IP话机成为Contact时,运营商就把呼叫路由到了40001235这个号码上。
这就是为什么同一组分机号、同一套拨号计划,外显号码会跳来跳去——根因不在FreeSWITCH本身,而在运营商侧按Contact做路由选择的双层机制。
问题根因:dual-reg的两种模式
查了FreeSWITCH文档才发现,dual-reg不是简单的”让两个设备都注册”,它有两种工作模式:
| 模式 | 参数值 | 行为 |
|---|---|---|
| 覆盖模式(默认) | append-authless |
新注册会覆盖旧注册,只保留最新的Contact |
| 追加模式 | append |
多个Contact都保留,INVITE会依次尝试 |
我们的场景需要追加模式,但配置里写的是默认行为。
场景约束与关键证据
系统环境:FreeSWITCH 1.10.9,Debian 11,两台Yealink T46S话机,一台MicroSIP软电话,运营商SIP trunk在阿里云。
关键证据1:sofia status显示同一分机只有一条注册记录
# 如果是覆盖模式,只能看到一条
sofia status profile internal reg 1001
Registrations:
=================================================================================
Call-ID: abc123@192.168.1.100
User: 1001@pbx.example.com
Contact: "1001" <sip:1001@192.168.1.100:5060>
Status: Registered(CRLF)(TLSS)(RTP-ONLY)
关键证据2:外呼SIP INVITE的Contact头域在两个IP之间飘
# 抓包看到的两种情况
# 软电话主导时
INVITE sip:13800138000@trunk.example.com SIP/2.0
Via: SIP/2.0/UDP 192.168.1.10:5080;branch=z9hG4bKxxx
From: <sip:1001@pbx.example.com>;tag=abc
To: <sip:13800138000@trunk.example.com>
Contact: <sip:1001@192.168.1.100:5060>
# IP话机主导时
INVITE sip:13800138000@trunk.example.com SIP/2.0
Via: SIP/2.0/UDP 192.168.1.10:5080;branch=z9hG4bKxxx
From: <sip:1001@pbx.example.com>;tag=def
To: <sip:13800138000@trunk.example.com>
Contact: <sip:1001@192.168.1.101:5060> # 变了
关键证据3:运营商侧的日志确认了按Contact选路由 运营商技术支持拉出来的路由表显示:
- 192.168.1.100/32 → 外显40001234
- 192.168.1.101/32 → 外显40001235
这解释了为什么外显号会随注册顺序变化。
方案对比:三种实现思路
方案A:启用FreeSWITCH原生dual-reg append模式
在sofia profile配置里加一行:
<param name="multi-registered" value="true"/>
<param name="cf" value="append"/>
优点:FreeSWITCH原生支持,不需要额外开发,改动最小
缺点:外呼时Contact头域选择逻辑不直观——它会选择最新的那个注册,如果IP话机后注册就会用它当Contact。配合运营商的IP白名单机制,就会出现我们遇到的外显号码问题。
方案B:用呼叫队列+ring-all策略
把分机号改成两个不同的注册账号,用呼叫队列的ring-all模式实现同振:
<extension name="agent_ring_all">
<condition field="destination_number" expression="^(1001)$">
<action application="set" field="call_timeout" data="30"/>
<action application="bridge" data="[leg_timeout=30]user/1001_soft@${domain},user/1001_hard@${domain}"/>
</condition>
</extension>
优点:灵活控制路由逻辑,Contact头域可预测,两个分机独立,外显号码固定
缺点:坐席需要记住两个分机号,配置复杂一倍,坐席培训和终端配置成本上升。
方案C:自定义dialplan+contact-header控制
保留双注册但在外呼时强制指定Contact:
-- dialplan中强制指定外呼Contact
session:execute("set", "effective_caller_id_number=40001234")
session:execute("set", "sip_from_user=1001")
session:execute("set", "sip_contact_user=1001_soft")
优点:保持双注册便捷性,外显号码可精确控制,坐席无感知
缺点:需要写代码,Lua/JavaScript都要部署,对运维要求高,后续维护成本大。
最终选择:方案A + Contact固定
三个方案里,我最终选了方案A的思路,但加了一个关键配置来固定外呼时用的Contact。理由如下:
方案B需要改分机号体系,坐席侧改动太大,项目周期不允许。方案C虽然精确可控,但代码维护成本高,我们团队没有专职开发,出了问题排查链路长。
所以我选了一个折中路线:
- 开启
multi-registered=true+cf=append,让两个终端都能注册 - 标记一个”主终端”,外呼时固定用它的Contact
具体配置:
<!-- sofia.conf.xml 中的internal profile -->
<profile name="internal">
<settings>
<param name="multi-registered" value="true"/>
<param name="cf" value="append"/>
<!-- 关键:强制匹配用户名 -->
<param name="inbound-reg-force-matching-username" value="true"/>
<param name="force-register-domain" value="$${domain}"/>
</settings>
</profile>
在dialplan里加了个判断逻辑,优先使用标记了X-Primary-Terminal扩展头的Contact:
<extension name="force_primary_contact">
<condition field="${switch_r_sip_contact_params}" expression="X-Primary-Terminal=true">
<action application="set" data="sip_force_contact=${sip_contact_uri}"/>
</condition>
</extension>
软电话注册时带这个扩展头(MicroSIP可以在高级设置里自定义Contact参数):
Contact: <sip:1001@192.168.1.100:5060>;X-Primary-Terminal=true
具体实现步骤
1. 检查当前注册状态
# 查看某个分机的所有注册终端
sofia status profile internal reg 1001
# 输出类似这样(正常应该看到两条)
Registrations:
=================================================================================
Call-ID: abc123@192.168.1.100
User: 1001@pbx.example.com
Contact: "1001" <sip:1001@192.168.1.100:5060>
Agent: MicroSIP/3.20.7
Status: Registered(CRLF)(TLSS)(RTP-ONLY)
Host: pbx.example.com
IP: 192.168.1.100
Port: 5060
Transport: udp
Ping-Status: Reachable
Ping-Time: 0.00
Call-ID: def456@192.168.1.101
User: 1001@pbx.example.com
Contact: "1001" <sip:1001@192.168.1.101:5060>
Agent: Sipura/SPA-504G
Status: Registered(CRLF)(TLSS)(RTP-ONLY)
Host: pbx.example.com
IP: 192.168.1.101
Port: 5060
Transport: udp
Ping-Status: Reachable
Ping-Time: 0.00
如果只看到一条记录,说明还在覆盖模式。
2. 修改sofia profile配置
# 编辑FreeSWITCH配置
vi /etc/freeswitch/sip_profiles/internal.xml
找到或添加这些参数:
<param name="multi-registered" value="true"/>
<param name="cf" value="append"/>
3. 重新加载profile
# 重载sofia profile
sofia profile internal rescan
# 或者完整重载
reloadxml
sofia global siptrace off
sofia global siptrace on # 开启debug输出
4. 验证双注册生效
# 再次检查注册
sofia status profile internal reg 1001
# 应该看到两条registration记录
# 并且Call-ID不同、IP不同
# 同时发起一个呼叫,观察INVITE消息
# 应该在Contact头域里看到两个URI,用逗号分隔
5. 日志观察callUUID分配
# 查看呼叫日志,找到callUUID
cd /var/log/freeswitch
tail -f freeswitch.log | grep 1001
# 正常情况日志会显示:
# [DEBUG] sofia.c:6892 sofia_received_message() Receive invite with double contact
# [INFO] switch_ivr_originate.c:1234 opening loop for:
# Contact: sip:1001@192.168.1.100:5060, sip:1001@192.168.1.101:5060
关键参数说明
| 参数 | 作用 | 踩坑点 |
|---|---|---|
multi-registered |
允许同一账号多次注册 | 默认为false,不开启就没法双注册 |
cf (contact-fields) |
注册模式,append保留多终端 |
默认是覆盖模式,后注册会顶掉先注册的 |
inbound-reg-force-matching-username |
强制匹配username | 防止注册冲突 |
force-register-domain |
固定注册域名 | 避免多域时的注册混乱 |
max-register-contacts |
每个分机最大注册数 | 默认10,超过新注册会被拒绝 |
技术判断:为什么是Contact而不是From
排查初期有同事怀疑是From头域的问题,毕竟From才是主叫号码。但抓包分析后确认:
- From头域始终是
1001@pbx.example.com,固定 - Contact头域才是变的,它决定了Invite消息从哪个网卡、哪个端口出去
- 运营商SIP trunk在接收到Invite后,做的第一件事是根据Contact的IP做路由和号码变换,不是From
这个判断过程很重要——如果没看抓包就改From参数,会走很多弯路。
边界条件:什么情况下会失效
边界1:运营商限制
有些运营商的SIP trunk只允许一个Contact头域,多Contact会被拒绝或截断。如果你的运营商这样配置,cf=append模式下的Invite会发送多个Contact,导致呼叫失败。
边界2:并发接听 如果两个终端都摘机,可能会产生双轨通话或媒体流混乱。需要在dialplan里加保护:
<action application="set" data="fail_on_single_reject=true"/>
这会让第一个终端摘机后,第二个终端收到忙音。
边界3:呼叫转移 转移呼叫时,如果原终端已摘机但另一个还在响铃,转移逻辑可能出问题。测试的时候要重点覆盖盲转和协商转两种场景。
边界4:注册超时 双注册时每个终端都会单独刷新,如果某个终端网络抖动导致注册超时,Contact会临时消失。软电话切到IP话机注册的间隙,外呼就会用单方的Contact。
边界5:最大注册数 每个分机默认最多10个并发注册。超过后新注册会被拒绝,但旧注册不会自动清理。如果坐席频繁重连软电话,可能触发这个限制。
验证结论
改完配置后观察了一周,数据是这样的:
注册终端数量上去了,每个分机稳定在2个注册,sofia status里能清楚看到两个Call-ID。来电时两个设备同时响铃,坐席随便接哪个都行,没有漏接电话的情况。
外显号码这块最关键的变化是:切换到软电话标记X-Primary-Terminal之后,运营商侧看到的所有外呼IP都统一在192.168.1.100这个段上了,外显号码从跳动的变成了稳定的。客户主管第二天就发消息说”今天号码都是对的”。
日志里的callUUID分配逻辑也正常了,同一个UUID下的两条分支分别发到两个终端,先挂断的那个释放,另一条自动取消。
最后要说的是,dual-reg这功能开起来容易,但真要用好得想清楚和外呼号码体系、运营商路由规则的配合。我们在方案A基础上加了Contact标记这一层保险,本质上是把运营商侧的双层映射关系在FreeSWITCH这边固定住了。如果你那边运营商没有IP白名单机制,方案A直接用就够了,不需要搞这么复杂。