| 项目文档 | 问题反馈 | 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),支持级联代理场景。
架构主线:
- 1.7.0 — 出站 Dialog 维护:BYE / SUBSCRIBE 续订 / 退订改为 dialog-aware。
ServerCommandSender.deviceBye(deviceId, callId)→deviceBye(callId),ClientCommandSender.sendByeCommand(FromDevice, ToDevice)→sendByeCommand(callId),SipSender.doByeRequest(FromDevice, ToDevice)直接删除改doByeRequest(callId)。无 dialog 时抛DialogNotFoundException而非吞481。新增进程内DialogRegistry+DialogRegistryCleaner,INVITE / SUBSCRIBE 改走 stateful 发送,自动建立 JAIN-SIP Dialog;新增refreshSubscribe(callId, ...)/unsubscribe(callId)API 替代历史”重发 SUBSCRIBE”做法。详见 doc/plans/1.7.0/OUTBOUND-DIALOG-PLAN.md。- 1.5.x — Listener 化业务接口:业务接口完全 listener 化(client 5 个 listener + server 4 个 listener,全部默认方法、按需 override);server 端 32 个
Device*Event已收敛为 4 个Server*Event+ 强类型 payload;client 端 4 个旧*Handler接口与 10 个细粒度 query/config event 已删除;GB/T 28181-2022 命令集(设备升级、抓拍、PTZ 精准、SD 卡、看守位、巡航轨迹、目标跟踪、报警订阅、语音对讲、视频下载等)全量落地。详见 doc/architecture/LISTENER-LAYERED-DESIGN.md 与 doc/architecture/PROTOCOL-LAYERING-MATRIX.md。完整版本历史见 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-common ← gb28181-common ← gb28181-client / gb28181-server ← gb28181-test
⚠️
sip-common协议纯净性:不允许出现任何 GB28181 关键词(gb28181 / GB28181 / gbproxy / Catalog / MobilePosition / GbSession / GbSip / GbUtil)。CI 通过scripts/check-sip-common-purity.sh强制校验。GB28181 相关逻辑下沉至gb28181-common(如GbSdpUtils、GbUtil)。详见 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 同 JVM:SipTransactionRegistry 持有的 ServerTransaction 是 JAIN-SIP 实现类(SIPServerTransactionImpl),不可序列化;RequestEvent 内部持有 socket 引用(SipProvider、Dialog),跨进程后无法回包。完整方案见 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 的私有协议 |
InMemoryInviteContextStore)<!-- 平台服务端 -->
<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 | 用途 | 实现要求 |
|---|---|---|
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 策略 |
|---|---|---|---|
QueryListener |
平台主动查询:13 个查询 hook(Catalog/DeviceInfo/DeviceStatus/RecordInfo/Alarm/ConfigDownload/Preset/MobilePosition/PTZPosition/SDCardStatus/HomePosition/CruiseTrackList/CruiseTrack) | 返回非 null = Adapter 自动 sendXxxResponse 回包;返回 null = 不回包 |
强制单 bean(ObjectProvider#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 | 全部调用 |
| 接口 | 职责 | 调用语义 |
|---|---|---|
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 |
// 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。
适合 metrics / audit / tracing / 全链路 trace 等横切关注点,与 listener 互不干扰:
| 事件 | 触发时机 |
|---|---|
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 |
客户端注册响应 |
| 事件 | 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 起
ServerCommandSender31 个@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 通知、报警上报、抓拍完成通知、升级结果通知、视频上传通知等)。绝大部分查询应答由 ClientListenerAdapter 在 QueryListener 返回非 null 时自动调用,业务方一般无需直接接触。
INVITE 与 SUBSCRIBE 走 stateful 发送(SipMessageTransmitter.transmitStateful / transmitStatefulPreRegister),通过 ClientTransaction 让 JAIN-SIP 自动建立 Dialog,同时记录到进程内 DialogRegistry(按 callId 索引,含 kind=INVITE|SUBSCRIBE 与 expiresAtMs)。
约束:
deviceBye(callId) / sendByeCommand(callId) / doByeRequest(callId)。无 dialog 则抛 DialogNotFoundException,不会被设备 481 静默吞掉。refreshSubscribe(callId, expires) / unsubscribe(callId),禁止历史”重发新 SUBSCRIBE”做法——会在设备侧产生孤儿订阅持续浪费带宽。dialog.sendAck(见 InviteResponseProcessor.sendAck),与 BYE 路径对称。Dialog 清理路径:
| 场景 | 触发方式 | 路径 |
|---|---|---|
| INVITE 主���径(BYE 后) | JAIN-SIP DialogTerminatedEvent |
AbstractSipListener.processDialogTerminated → DialogRegistry.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(如语音对讲)需要业务方准备 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 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.md 与 doc/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)
业务服务器
业务方多节点部署时必须:
DeviceSessionCache,使用 Redis 等共享存储(不得用本地 Map)ServerDeviceSupplier,设备信息从共享存储读取external-ip 为 VIP,确保 SIP 包内 Via / Contact 头是集群可达地址InviteContextStore(参考 InMemoryInviteContextStore 改 Redis 版),处理 INVITE 异步回包跨节点路由nodeAddressMap Bean(K8s Endpoints / Nacos / Consul),将 nodeId 映射到内网地址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%。
jakarta.* 包,禁止 javax.*(Spring Boot 3.x 要求)@MockitoBean,@MockBean 已废弃Request 强转为 SIPRequestfastjson2sip-common 中禁止出现 GB28181 关键词(CI 校验,详见模块结构小节)sip.server.* — 服务端协议监听设置(enabled / ip / port / external-ip / external-port / server-id / domain / realm / enable-udp / enable-tcp 等)sip.client.* — 客户端协议设置sip.common.* — 通用框架配置(user-agent、time-sync.{enabled,mode,offset-threshold,ntp-server,ntp-sync-interval,...})application-{env}.yml| 章节 | 覆盖范围 | 状态 |
|---|---|---|
| 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 | 各版本对外可见变更 |
如果这个项目对您有帮助,请给我们一个 ⭐️ Star!
Made with ❤️ by @lunasaw