Sip-Proxy

SIP Proxy

Maven Central GitHub license Spring Boot Java Version

项目文档 问题反馈 CHANGELOG

基于 Java 17 + Spring Boot 3.3.1 实现的 SIP 协议代理框架,以 Maven 库的形式集成到业务进程中使用。

核心定位:纯协议层框架,屏蔽 SIP 协议细节。业务方实现 Listener 接口(推荐)或监听 Layer 1 协议事件 接收消息,通过 ClientCommandSender / ServerCommandSender 发送命令,不直接接触 JAIN-SIP。框架内置 GB28181-2016 + GB/T 28181-2022 协议实现,单 JVM 可同时启用平台服务端(gb28181-server)和设备客户端(gb28181-client),支持级联代理场景。

架构主线

完整版本历史见 CHANGELOG.md

模块结构

sip-proxy
├── sip-common          # 通用 SIP 协议栈(JAIN-SIP 封装、事务注册表、缓存、指标、TimeSync);零 GB28181 代码
├── gb28181-common      # GB28181 数据模型 + GB SDP 工具(JAXB XML 实体,无业务逻辑)
├── gb28181-client      # 设备客户端:ClientCommandSender、L0 入站 handler、L1 协议事件、L2 listener API
├── gb28181-server      # 平台服务端:ServerCommandSender、L0 入站 handler、L1 协议事件、L2 listener API
└── gb28181-test        # 集成测试 + sip-gateway 业务侧单机参考实现

依赖顺序:sip-commongb28181-commongb28181-client / gb28181-servergb28181-test

⚠️ sip-common 协议纯净性:不允许出现任何 GB28181 关键词(gb28181 / GB28181 / gbproxy / Catalog / MobilePosition / GbSession / GbSip / GbUtil)。CI 通过 scripts/check-sip-common-purity.sh 强制校验。GB28181 相关逻辑下沉至 gb28181-common(如 GbSdpUtilsGbUtil)。详见 doc/plans/1.3.0/PROTOCOL-DECOUPLING-PLAN.md

整体分层(部署形态)

sip-proxy 不是独立服务,而是嵌入到业务方实现的 sip-gateway 网关进程中。三层架构:

┌─────────────────────────────────────────────────────────┐
│                     业务服务器                            │
│  设备管理、录像、告警、流媒体调度等业务逻辑                  │
│  调 sip-gateway HTTP/MQ 接口触发 SIP 命令                 │
└──────────────────────────┬──────────────────────────────┘
                           │ HTTP / MQ
┌──────────────────────────▼──────────────────────────────┐
│                     sip-gateway(业务方实现)              │
│  Spring Boot 应用,多节点部署,与 sip-proxy 同 JVM         │
│  ├── 实现 DeviceSessionCache  → Redis(共享)             │
│  ├── 实现 ServerDeviceSupplier → Redis(共享)            │
│  ├── 继承 ServerGb28181Adapter / ClientGb28181Adapter    │
│  │   或 implements 单个 listener interface(按需)        │
│  └── 暴露 HTTP API → 接收业务指令调 ServerCommandSender    │
└──────────────────────────┬──────────────────────────────┘
                           │ Maven 依赖
┌──────────────────────────▼──────────────────────────────┐
│                     sip-proxy(本框架)                   │
│  L0 解析 SIP 消息 → L1 发布外层 ApplicationEvent          │
│  → L2 ListenerAdapter 分发到 listener 接口(含自动回包)   │
│  提供 ClientCommandSender / ServerCommandSender           │
└─────────────────────────────────────────────────────────┘

为什么 sip-proxy 必须与 sip-gateway 同 JVMSipTransactionRegistry 持有的 ServerTransaction 是 JAIN-SIP 实现类(SIPServerTransactionImpl),不可序列化;RequestEvent 内部持有 socket 引用(SipProviderDialog),跨进程后无法回包。完整方案见 doc/architecture/LAYERED-ARCHITECTURE.md

参考实现gb28181-test/.../gateway/ 提供了 sip-gateway 单机版完整实现(GatewayProperties / InviteContextStore / SipEventForwarder / SipCommandController / BusinessNotifier),生产部署需将 InMemoryInviteContextStore 替换为 Redis 实现、NoopBusinessNotifier 替换为实际 HTTP/MQ 推送、nodeAddressMap 替换为 K8s/Nacos 动态发现。

典型使用场景

场景 引入模块 说明
平台服务端(接收设备注册) gb28181-server 业务系统作为 GB28181 平台,管理下级设备
设备客户端(向平台注册) gb28181-client 业务系统模拟设备,向上级平台注册
级联代理(双角色共存) gb28181-client + gb28181-server 同一进程同时作为下级平台的服务端和上级平台的客户端
自定义 SIP 扩展协议 sip-common + 自定义模块 基于 SIP 协议栈实现非 GB28181 的私有协议

快速开始

环境要求

引入依赖

<!-- 平台服务端 -->
<dependency>
    <groupId>io.github.lunasaw</groupId>
    <artifactId>gb28181-server</artifactId>
    <version>1.7.0</version>
</dependency>

<!-- 设备客户端 -->
<dependency>
    <groupId>io.github.lunasaw</groupId>
    <artifactId>gb28181-client</artifactId>
    <version>1.7.0</version>
</dependency>

启用注解

@SpringBootApplication
@EnableSipServer       // 平台服务端
// @EnableSipClient    // 设备客户端(级联场景两个一起加)
public class SipGatewayApplication {
    public static void main(String[] args) {
        SpringApplication.run(SipGatewayApplication.class, args);
    }
}

@EnableSipServer 自动激活 SipProxyServerAutoConfig + Gb28181CommonAutoConfig + SipProxyAutoConfig@EnableSipClient 同理。

基础配置

sip:
  server:
    enabled: true
    ip: 0.0.0.0                     # 监听地址(推荐 0.0.0.0)
    port: 5060
    external-ip: 1.2.3.4            # NAT/多节点场景填 VIP 或公网地址
    external-port: 5060             # 不填则 fallback 到 port
    server-id: 34020000002000000001
    domain: 34020000002000000001
    realm: "34020000"
    enable-udp: true
    enable-tcp: false
  common:
    user-agent: sip-proxy           # 默认 sip-proxy
    time-sync:
      enabled: true                 # SIP/NTP 校时(详见 SipCommonProperties.TimeSync)
      mode: SIP                     # SIP / NTP / BOTH

external-ip / external-port 写入出站 SIP 包的 Via / Contact 头。多节点部署时填 VIP,确保设备后续消息能回到集群。

必须实现的 Bean

业务方至少需要实现:

Bean 用途 实现要求
DeviceSessionCache 设备会话寻址(ip / port / transport) 多节点部署必须用 Redis 等共享存储,框架默认实现仅适用单机演示
ServerDeviceSupplier 服务端设备身份 + 注册鉴权 启用 @EnableSipServer 必须实现;authenticate(userId, SIPRequest) 默认调 DigestServerAuthenticationHelper.doAuthenticatePlainTextPassword 完成摘要校验
ClientDeviceSupplier 客户端设备身份 启用 @EnableSipClient 必须实现

框架提供 DefaultServerDeviceSupplier / DefaultClientDeviceSupplier(基于配置文件),仅适用于单节点 demo 或测试场景。

业务接入:三层架构

sip-proxy 把入站消息处理拆成三层(listener 三层模型自 v1.5.0 起作为主线),业务方主入口在 L2 Listener 接口,跨切层走 L1 Layer 1 协议事件

┌──────────────────────────────────────────────────────────┐
│  L2 业务接口 + Adapter(gb28181-{client,server}/api)       │
│    Listener 接口(业务方实现,默认方法按需 override)         │
│    ListenerAdapter(框架内部,按 payload 类型分发,自动回包) │
└──────────────────┬─────────────────────────────────────���─┘
                   │ Spring ApplicationEvent
┌──────────────────▼───────────────────────────────────────┐
│  L1 协议层事件(gb28181-{client,server}/eventbus|event)   │
│    Client: ClientQuery/Control/Keepalive/Config/Subscribe/ │
│            Notify/Invite/Bye/Ack/Cancel/Info/Register*Event│
│    Server: ServerLifecycleEvent / ServerSessionEvent /     │
│            ServerNotifyEvent / ServerQueryResponseEvent    │
│    跨切层(metrics / audit / tracing)可同时监听            │
└──────────────────┬───────────────────────────────────────┘
                   │ publishEvent
┌──────────────────▼───────────────────────────────────────┐
│  L0 协议解析(*MessageHandler / *RequestProcessor)        │
│    parseXml → publishEvent(L1 外层事件 + 多态 payload)     │
└──────────────────────────────────────────────────────────┘

📌 每行 cmdType 在 L0 / L1 / L2 三层的具体落点,见 doc/architecture/PROTOCOL-LAYERING-MATRIX.md。新增 cmdType 时先改矩阵,再改代码

一、Listener 接口(业务方主入口)

Client 端(设备侧)— 5 个 listener

接口 职责 调用语义 多 listener 策略
QueryListener 平台主动查询:13 个查询 hook(Catalog/DeviceInfo/DeviceStatus/RecordInfo/Alarm/ConfigDownload/Preset/MobilePosition/PTZPosition/SDCardStatus/HomePosition/CruiseTrackList/CruiseTrack) 返回非 null = Adapter 自动 sendXxxResponse 回包;返回 null = 不回包 强制单 beanObjectProvider#getIfUnique()),多实例 fail fast
ControlListener 平台控制:13 个 cmdType=DeviceControl 子标签(PTZ / TeleBoot / Record / Guard / AlarmReset / IFrame / DragIn / DragOut / HomePosition / DeviceUpgrade / PtzPrecise / FormatSdCard / TargetTrack)+ Keepalive fire-and-forget 全部调用(观察者)
ConfigListener 平台配置:12 个 cmdType=DeviceConfig 子标签(BasicParam / VideoParamOpt / SVAC{En,De}code / VideoParamAttribute / VideoRecordPlan / VideoAlarmRecord / PictureMask / FrameMirror / AlarmReport / Osd / SnapShot) fire-and-forget;用 Class<?> → Consumer 显式映射避免 instanceof 顺序陷阱 全部调用
SubscribeListener 平台订阅:4 个订阅 hook(Catalog / Alarm / MobilePosition / PTZPosition) fire-and-forget;200 OK 由协议层同步返回,listener 不能拒绝订阅 全部调用
NotifyListener 平台通知:Broadcast 语音广播 + 兜底 fire-and-forget 全部调用

Server 端(平台侧)— 4 个 listener

接口 职责 调用语义
DeviceLifecycleListener 设备生命周期:注册 / 挑战 / 在线 / 离线 / 远端地址变更(NAT 漂移) fire-and-forget
DeviceSessionListener INVITE/BYE/ACK 状态机:onInviteTrying / onInviteOk / onInviteFailure / onAck / onBye / onByeError + onServerInvite(设备主动 INVITE,含 transactionContextKey 用于异步回包) fire-and-forget;UDP 重传场景需按 callId 自行幂等
DeviceNotifyListener 设备主动通知:Alarm / Keepalive / MediaStatus / MobilePosition / UpgradeResult / SnapShotFinished / VideoUploadNotify fire-and-forget
DeviceResponseListener 设备应答:Catalog / DeviceInfo / DeviceStatus / RecordInfo / PTZPosition / SDCardStatus / HomePosition / CruiseTrack{List,Single} / DeviceConfig / ConfigDownload / PresetQuery / Subscribe / NotifyUpdate(目录变更通知) fire-and-forget

一站式 Adapter

// Server 端:业务方继承基类即可获得全部 4 个 listener 的所有 hook
@Component
public class MyServerAdapter extends ServerGb28181Adapter {

    @Override
    public void onDeviceRegister(String deviceId, RegisterInfo info) {
        log.info("设备上线: {} from {}", deviceId, info.getRemoteIp());
    }

    @Override
    public void onAlarmNotify(String deviceId, DeviceAlarmNotify notify) {
        alarmService.dispatch(deviceId, notify);
    }

    @Override
    public void onServerInvite(String callId, String fromUserId, String toUserId,
                               GbSessionDescription sd, String transactionContextKey) {
        inviteContextStore.save(callId, nodeId, transactionContextKey, ttlMs);
        businessNotifier.inviteIncoming(callId, fromUserId, toUserId, sd);
    }
    // 其他 hook 不写一行
}

// Client 端:同理
@Component
public class MyDeviceImpl extends ClientGb28181Adapter {

    @Override
    public DeviceResponse onCatalogQuery(String platformId, DeviceQuery q) {
        return buildCatalogResponse();   // 返回非 null = 框架自动回包
    }

    @Override
    public void onPtzControl(String platformId, DeviceControlPtz cmd) {
        ptzExecutor.execute(cmd);
    }
}

业务方也可按需 implements 单个或几个 listener interface,不必继承 Adapter。

二、Layer 1 协议事件(跨切层)

适合 metrics / audit / tracing / 全链路 trace 等横切关注点,与 listener 互不干扰:

Client 端 L1 事件

事件 触发时机
ClientQueryEvent rootType=Query 的统一外层事件,多态承载 13 类 query payload
ClientControlEvent rootType=Control + cmdType=DeviceControl
ClientKeepaliveEvent cmdType=Keepalive(独立事件,与控制指令拆开)
ClientConfigEvent cmdType=DeviceConfig,多态 payload
ClientSubscribeEvent method=SUBSCRIBE
ClientNotifyEvent rootType=Notify(含 Broadcast 等)
ClientInviteEvent / ClientByeEvent / ClientAckEvent / ClientCancelEvent / ClientInfoEvent 上级 INVITE / BYE / ACK / CANCEL / INFO(INFO 含结构化 MANSRTSP)
ClientRegister{Success,Failure,Challenge,Redirect}Event 客户端注册响应

Server 端 L1 事件(v1.5.2 起从 32 个 typed event 收敛为 4 个外层事件)

事件 type/payload 覆盖语义
ServerLifecycleEvent LifecycleType ∈ {REGISTER / CHALLENGE / ONLINE / OFFLINE / REMOTE_ADDRESS_CHANGED} 注册 / 上下线 / NAT 漂移
ServerSessionEvent SessionType ∈ {INVITE_TRYING / INVITE_OK / INVITE_FAILURE / ACK / BYE / BYE_ERROR / SERVER_INVITE} INVITE 三向握手 + BYE + 设备主动 INVITE(语音对讲等场景,携带 transactionContextKey
ServerNotifyEvent typed payload:DeviceAlarmNotify / DeviceKeepLiveNotify / MediaStatusNotify / MobilePositionNotify / UpgradeResultNotify / UploadSnapShotFinishedNotify / VideoUploadNotify 设备主动通知
ServerQueryResponseEvent typed payload:DeviceResponse(Catalog) / DeviceInfo / DeviceStatus / DeviceRecord / PTZPositionResponse / SDCardStatusResponse / HomePositionResponse / CruiseTrackListResponse / CruiseTrackResponse / DeviceConfigResponse / DeviceConfigDownloadResponse / PresetQueryResponse / DeviceOtherUpdateNotify / 订阅应答元数据 设备查询应答 + 错误返回

L1 事件携带强类型 payload,业务方既可走 listener 接口,也可 @EventListener 直接监听 L1 事件做跨切。

三、命令发送

ServerCommandSender(平台 → 设备,按 deviceId 寻址,依赖 DeviceSessionCache

48 个出站方法,覆盖 GB/T 28181-2022 全集:

@Autowired ServerCommandSender serverCommandSender;

// 查询类(GB/T 28181-2022 §A.2.4)
String callId = serverCommandSender.deviceInfoQuery(deviceId);
String callId = serverCommandSender.deviceCatalogQuery(deviceId);
String callId = serverCommandSender.deviceStatusQuery(deviceId);
String callId = serverCommandSender.deviceRecordInfoQuery(deviceId, startMs, endMs);
String callId = serverCommandSender.devicePresetQuery(deviceId);
String callId = serverCommandSender.devicePtzPositionQuery(deviceId);
String callId = serverCommandSender.deviceSdCardStatusQuery(deviceId);
String callId = serverCommandSender.deviceHomePositionQuery(deviceId);
String callId = serverCommandSender.deviceCruiseTrackListQuery(deviceId);
String callId = serverCommandSender.deviceCruiseTrackQuery(deviceId, trackNumber);
String callId = serverCommandSender.deviceConfigDownload(deviceId, "BasicParam");
String callId = serverCommandSender.deviceMobilePositionQuery(deviceId, interval);
String callId = serverCommandSender.deviceAlarmQuery(deviceId, start, end, level, method, type);

// 订阅类(§9.11)
String callId = serverCommandSender.deviceCatalogSubscribe(deviceId, expires, eventType);
String callId = serverCommandSender.deviceMobilePositionSubscribe(deviceId, interval, expires);
String callId = serverCommandSender.deviceAlarmSubscribe(deviceId, expires, eventType, /* AlarmSubscribeInfo */);
String callId = serverCommandSender.devicePtzPositionSubscribe(deviceId, expires);

// 控制类(§A.2.3.1)
String callId = serverCommandSender.deviceControlPtzCmd(deviceId, PtzCmdEnum.UP, 50);
String callId = serverCommandSender.deviceControlReboot(deviceId);
String callId = serverCommandSender.deviceControlPtzPrecise(deviceId, pan, tilt, zoom);
String callId = serverCommandSender.deviceControlIFrame(deviceId);
String callId = serverCommandSender.deviceControlDragZoomIn(deviceId, dragZoom);
String callId = serverCommandSender.deviceControlFormatSDCard(deviceId, sdNumber);
String callId = serverCommandSender.deviceControlHomePosition(deviceId, "true", resetTime, presetIndex);
String callId = serverCommandSender.deviceControlTargetTrack(deviceId, mode, target, /* TargetArea */);
String callId = serverCommandSender.deviceUpgrade(deviceId, firmware, fileURL, manufacturer, sessionId);
String callId = serverCommandSender.deviceSnapShot(deviceId, snapNum, interval, uploadURL, sessionId);

// 配置类(§A.2.3.2)
String callId = serverCommandSender.deviceConfig(deviceId, name, expiration, /* heartBeatInterval */, /* heartBeatCount */);
String callId = serverCommandSender.deviceConfigOsd(deviceId, osdInfo);
String callId = serverCommandSender.deviceConfigVideoAlarmRecord(deviceId, /* config */);
String callId = serverCommandSender.deviceConfigAlarmReport(deviceId, /* config */);

// 媒体会话(§9.2 / §9.7~9 / §9.12)
String callId = serverCommandSender.deviceInvitePlay(deviceId, mediaIp, mediaPort, StreamModeEnum.UDP);
String callId = serverCommandSender.deviceInvitePlayBack(deviceId, mediaIp, mediaPort, startMs, endMs, StreamModeEnum.UDP);
String callId = serverCommandSender.deviceInviteTalk(deviceId, mediaIp, mediaPort, StreamModeEnum.UDP);
String callId = serverCommandSender.deviceInviteDownload(deviceId, mediaIp, mediaPort, startMs, endMs, downloadSpeed, StreamModeEnum.UDP);
String callId = serverCommandSender.deviceInvitePlayBackControl(deviceId, PlayActionEnums.PAUSE);
serverCommandSender.deviceBye(callId);   // 1.7.0+:dialog-aware,必须先 INVITE 200 OK

// SUBSCRIBE 续订 / 退订(1.7.0+,dialog-aware)
serverCommandSender.refreshSubscribe(callId, 3600);              // Catalog/PTZPosition:保留原 SubscribeContent
serverCommandSender.refreshSubscribe(callId, content, 3600);     // MobilePosition/Alarm:附携新 content(如新 Interval)
serverCommandSender.unsubscribe(callId);                          // Expires=0,等价退订

// 语音广播(§9.12.1)
String callId = serverCommandSender.deviceBroadcast(deviceId);

v1.5.1 起 ServerCommandSender 31 个 @Deprecated 静态门面方法已删除,业务侧需注入 bean 调用同名实例方法。

v1.7.0 起 deviceBye(deviceId, callId) 改为 deviceBye(callId)SipSender.doByeRequest(FromDevice, ToDevice) 直接删除。详见下文「Dialog-Aware 出站」与 doc/plans/1.7.0/OUTBOUND-DIALOG-PLAN.md

ClientCommandSender(设备 → 平台)

设备侧主动上报与应答(注册、心跳、Catalog 通知、报警上报、抓拍完成通知、升级结果通知、视频上传通知等)。绝大部分查询应答由 ClientListenerAdapterQueryListener 返回非 null 时自动调用,业务方一般无需直接接触。

四、Dialog-Aware 出站(v1.7.0+)

INVITE 与 SUBSCRIBE 走 stateful 发送SipMessageTransmitter.transmitStateful / transmitStatefulPreRegister),通过 ClientTransaction 让 JAIN-SIP 自动建立 Dialog,同时记录到进程内 DialogRegistry(按 callId 索引,含 kind=INVITE|SUBSCRIBEexpiresAtMs)。

约束

Dialog 清理路径

场景 触发方式 路径
INVITE 主���径(BYE 后) JAIN-SIP DialogTerminatedEvent AbstractSipListener.processDialogTerminatedDialogRegistry.remove
SUBSCRIBE 自然过期 兜底定时清理(RFC 6665 §4.4.1 case 3 无 DialogTerminatedEvent DialogRegistryCleaner @Scheduled 60s 跑一次

业务侧调用模板

try {
    commandSender.deviceBye(callId);
} catch (DialogNotFoundException e) {
    // dialog 已不存在(对端先发 BYE / INVITE 还未 200 OK / callId 错误)
    log.warn("BYE 失败:dialog 不存在 callId={}", callId, e);
}

为什么需要先 deprecated 桥接的形式被否决:旧 BYE API(deviceBye(deviceId, callId))会构造缺 to-tag 的 From/To,设备直接返回 481 Call leg/Transaction does not exist,长期被静默吞掉。1.7.0 选择直接删除让编译期一次性暴露所有调用点,强制业务侧迁移到 dialog-aware 路径。

⚠️ 新增任何 in-dialog 出站请求(re-INVITE、UPDATE、INFO、server-side NOTIFY 等)必须走 stateful path 并从 DialogRegistry 取出 dialog,不要自己拼 From/To 头。

五、INVITE 异步回包模型(设备主动 INVITE)

设备主动发起的 INVITE(如语音对讲)需要业务方准备 SDP,框架采用两步异步模型:

设备 → INVITE
  → sip-proxy: ServerInviteRequestProcessor
      1. 立即发 100 Trying(防对端重传)
      2. 存 SipTransactionRegistry(contextKey = callId_fromTag_cseq → RequestEvent,���程内)
      3. 发布 ServerSessionEvent.serverInvite(callId, fromUserId, toUserId, sdp, contextKey)
  → sip-gateway: ServerListenerAdapter → DeviceSessionListener.onServerInvite
      1. 存 Redis: "sip:invite:ctx:{callId}" → "{nodeId}:{contextKey}"(30s TTL)
      2. 推送业务服务器(含 callId、SDP)

业务服务器 → POST /sip/invite/response {callId, sdp}
  → sip-gateway:
      1. 从 Redis 取 "{nodeId}:{contextKey}"
      2. nodeId == 本节点:用 contextKey 取 SipTransactionRegistry 回包
         nodeId != 本节点:通过 nodeAddressMap 转发 HTTP 到对应节点
      3. ResponseCmd.sendResponse(200, sdp, ctx.getOriginalEvent())

⚠️ 设备 Timer B 限制:即使框架侧 extendContext 续期到 90 秒,设备侧(INVITE 客户端)按 RFC 3261 §17.1.1.2 在 Timer B = 64*T1 = 32s 后会放弃事务。业务处理时间应控制在 30s 内直接回包;30~60s 需主动发 180 Ringing + extendContext;> 60s 改为先回 200 OK + 占位 SDP,后续走 re-INVITE。详见 doc/architecture/LAYERED-ARCHITECTURE.md §7

六、协议层扩展点(进阶)

业务方一般只需实现 DeviceSessionCache / *DeviceSupplier + listener。少数高级场景才需要扩展协议层:

扩展点 用途
MessageHandler 接口 新增 GB28181 cmdType 处理器,通过 SipRequestProcessorAbstract.addHandler() 注册
SipRequestProcessorAbstract 子类 新增 SIP method 处理器(如自定义非标准方法);server 端可继承 ServerAbstractSipRequestProcessor
ClientCommandStrategy / ServerCommandStrategy 新增出站命令策略,通过对应的 CommandStrategyFactory 注册

SIP 消息处理流水线(内部)

SIP Message
  → AbstractSipListener            # 统一事件分发,TraceId 传播
  → XXXRequestProcessor            # 消息类型路由(REGISTER / INVITE / MESSAGE / NOTIFY / BYE / SUBSCRIBE / ACK / INFO …)
  → XXXSubProcessor                # MESSAGE 子类型路由(按 GB28181 cmdType)
  → L0 *MessageHandler             # parseXml + publishEvent(L1)
  → L1 ApplicationEvent            # ClientQueryEvent / ServerSessionEvent / ...
  → L2 ListenerAdapter             # @EventListener 监听 L1,按 payload 分发到 listener
  → 业务方 listener 实现             # QueryListener.onCatalogQuery 等

水平扩容

完整方案见 doc/architecture/LAYERED-ARCHITECTURE.mddoc/architecture/HORIZONTAL-SCALING.md

状态分层(核心约束)

本地节点只保留 SIP 事务状态,业务状态必须外化。

状态类型 存储位置 说明
ServerTransaction / SipTransactionRegistry 进程内(不可外化) JAIN-SIP 实现类不可序列化、持有 socket 引用;同设备消息必须打同节点
DialogRegistry(出站 dialog 注册表,1.7.0+) 进程内(不可外化) 持有 JAIN-SIP Dialog 引用,BYE / SUBSCRIBE refresh 必须打到原 INVITE / SUBSCRIBE 所在节点;与 SIP 事务状态同生命周期
DeviceSessionCache(设备注册信息) Redis(共享,需高可用) 业务方实现,节点间共享,节点故障后新节点可接管
ServerDeviceSupplier(设备信息) Redis(共享,需高可用) 业务方实现,读 Redis
设备订阅状态 业务方自管(client 端协议层 SubscribeRegistry 内化) 框架 1.3.0 已删除全局 SubscribeHolder;client 端 SUBSCRIBE 200 OK 由 SubscribeRegistry.put() 内部维护
INVITE 事务上下文 进程内 + Redis 存路由映射(需高可用) transactionContextKey 仅在收到 INVITE 的节点有效;Redis 用 callId 作键存 {nodeId}:{contextKey} 供跨节点回包路由

⚠️ Redis 是新的 SPOF:跨节点 INVITE 路由、设备会话、注册鉴权全部依赖 Redis。生产环境必须使用 Redis Sentinel 或 Cluster;InviteContextStore 实现需把后端故障显式抛 ResponseStatusException(SERVICE_UNAVAILABLE),让 /sip/invite/response 返回 503 触发业务侧重试。

部署拓扑

设备 ──→ VIP 1.2.3.4:5060 ──→ Node-1 (sip-gateway + sip-proxy)
         (keepalived + ipvs) └→ Node-2 (sip-gateway + sip-proxy)
         源 IP 哈希                    │
                                    Redis(共享 DeviceSessionCache + InviteContextStore)
                                    业务服务器

接入要求

业务方多节点部署时必须:

  1. 实现 DeviceSessionCache,使用 Redis 等共享存储(不得用本地 Map)
  2. 实现 ServerDeviceSupplier,设备信息从共享存储读取
  3. 配置 external-ip 为 VIP,确保 SIP 包内 Via / Contact 头是集群可达地址
  4. 实现 InviteContextStore(参考 InMemoryInviteContextStore 改 Redis 版),处理 INVITE 异步回包跨节点路由
  5. 装配 nodeAddressMap Bean(K8s Endpoints / Nacos / Consul),将 nodeId 映射到内网地址
  6. 保证 BYE / SUBSCRIBE refresh 路由到原节点DialogRegistry 进程内持有 dialog,VIP 源 IP 哈希已能保证设备主动 BYE 落到原节点;业务方主动调 deviceBye(callId) / refreshSubscribe(callId, ...) 时,需在 sip-gateway HTTP 入口校验当前节点是否持有该 callId 的 dialog(同 INVITE 路由表,复用 InviteContextStore{nodeId} 映射),否则转发到目标节点

构建与测试

# 编译
mvn clean compile

# 构建(含单元测试)
mvn clean install

# 集成测试
mvn verify

# 指定模块测试
mvn test -pl gb28181-client -Dtest=CancelRequestProcessorTest

# 指定测试方法
mvn test -pl gb28181-client -Dtest=CancelRequestProcessorTest#methodName

# 协议纯净性校验(CI 在 mvn verify 阶段自动调用)
bash scripts/check-sip-common-purity.sh

JaCoCo 强制要求行覆盖率 ≥ 80%。

开发规范

配置命名空间

协议覆盖度

章节 覆盖范围 状态
GB/T 28181-2022 §A.2.3.1 设备控制 13/13 cmdType ✅ 全部 listener 化
GB/T 28181-2022 §A.2.3.2 设备配置 11/11 cmdType(外加 1 个 GB28181-2016 兼容) ✅ 全部 listener 化
GB/T 28181-2022 §A.2.4 查询 13/13 cmdType ✅ 全部 listener 化
GB/T 28181-2022 §A.2.5 通知 8/8 cmdType ✅ 全部 listener 化
GB/T 28181-2022 §A.2.6 应答 12/15 listener 化 + 3 transport-only ✅(剩余 3 项无业务语义)
GB/T 28181-2022 §9.x 流程级能力 注册 / 实时点播 / 历史检索回放下载 / 网络校时 / 订阅通知 / 语音广播 / 语音对讲 / 软件升级 / 图像抓拍

详细矩阵见 doc/architecture/PROTOCOL-LAYERING-MATRIX.md

参考文档

doc/ 目录下的关键文档:

文档 内容
OUTBOUND-DIALOG-PLAN.md 出站 Dialog 维护方案(v1.7.0 主干,BYE / SUBSCRIBE refresh dialog-aware)
LISTENER-LAYERED-DESIGN.md Listener 化业务接口分层设计(v1.5.0 主干)
LISTENER-MIGRATION-GUIDE.md v1.4.0 → v1.5.0 业务侧迁移指南
PROTOCOL-LAYERING-MATRIX.md L0/L1/L2 三层协议栈逐 cmdType 落地矩阵(v1.5.6 基于代码事实)
LAYERED-ARCHITECTURE.md sip-proxy ↔ sip-gateway ↔ 业务服务器分层架构
HORIZONTAL-SCALING.md 多节点部署、状态分层、VIP 拓扑、NAT 处理
PROTOCOL-DECOUPLING-PLAN.md sip-common / gb28181-common 边界规则(1.3.0)
BREAKING-CHANGE-REMOVE-HANDLER-INTERFACE.md 1.3.0 全量删除 *Handler 接口、统一 Spring Event
INVITE-REFACTOR-PLAN.md INVITE 异步化重构(1.3.0)
GB28181-2016.md / GBT-28181-2022.md 协议参考
CHANGELOG.md 各版本对外可见变更

许可证

MIT License


如果这个项目对您有帮助,请给我们一个 ⭐️ Star!

Made with ❤️ by @lunasaw