diff --git a/.gitignore b/.gitignore
index 9ca24a1..f1d9b30 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,3 +35,4 @@ build/
/log/
/logs/
/logs/inspect-tcpserver/info.log
+sipserverlog.txt
diff --git a/pom.xml b/pom.xml
index 3e9dab4..64bd741 100644
--- a/pom.xml
+++ b/pom.xml
@@ -113,6 +113,31 @@
okhttp
4.11.0
+
+
+ javax.sip
+ jain-sip-ri
+ 1.2.279
+
+
+
+ org.slf4j
+ log4j-over-slf4j
+ 1.7.36
+
+
+
+ log4j
+ log4j
+ 1.2.17
+
+
+
+ com.fasterxml.jackson.dataformat
+ jackson-dataformat-xml
+
+
+
diff --git a/src/main/java/com/inspect/tcpserver/sip/config/AccountManagerImpl.java b/src/main/java/com/inspect/tcpserver/sip/config/AccountManagerImpl.java
new file mode 100644
index 0000000..0c20786
--- /dev/null
+++ b/src/main/java/com/inspect/tcpserver/sip/config/AccountManagerImpl.java
@@ -0,0 +1,27 @@
+package com.inspect.tcpserver.sip.config;
+
+import gov.nist.javax.sip.clientauthutils.AccountManager;
+import gov.nist.javax.sip.clientauthutils.UserCredentials;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import javax.sip.ClientTransaction;
+
+@Component
+public class AccountManagerImpl implements AccountManager {
+
+ @Value("${sip.username}")
+ private String username;
+
+ @Value("${sip.password}")
+ private String password;
+
+ @Value("${sip.domain}")
+ private String domain;
+
+ @Override
+ public UserCredentials getCredentials(ClientTransaction clientTransaction, String s) {
+
+ return null;
+ }
+}
diff --git a/src/main/java/com/inspect/tcpserver/sip/config/SipConfig.java b/src/main/java/com/inspect/tcpserver/sip/config/SipConfig.java
new file mode 100644
index 0000000..8dca165
--- /dev/null
+++ b/src/main/java/com/inspect/tcpserver/sip/config/SipConfig.java
@@ -0,0 +1,36 @@
+package com.inspect.tcpserver.sip.config;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import javax.sip.SipFactory;
+import javax.sip.SipStack;
+import java.util.Properties;
+
+@Configuration
+public class SipConfig {
+
+ @Value("${sip.local-ip}")
+ private String localIp;
+
+ @Value("${sip.transport}")
+ private String transport;
+
+ @Bean
+ public SipStack sipStack() throws Exception {
+ SipFactory sipFactory = SipFactory.getInstance();
+ sipFactory.setPathName("gov.nist");
+
+ Properties properties = new Properties();
+ properties.setProperty("javax.sip.STACK_NAME", "spring-boot-sip-stack");
+ properties.setProperty("gov.nist.javax.sip.IP_ADDRESS", localIp);
+ // 日志文件
+ properties.setProperty("gov.nist.javax.sip.DEBUG_LOG", "sipdebug.txt");
+ properties.setProperty("gov.nist.javax.sip.SERVER_LOG", "sipserverlog.txt");
+ // 延迟/线程池配置
+ properties.setProperty("gov.nist.javax.sip.THREAD_POOL_SIZE", "20");
+
+ return sipFactory.createSipStack(properties);
+ }
+}
diff --git a/src/main/java/com/inspect/tcpserver/sip/items/RequestHistoryAlarmItem.java b/src/main/java/com/inspect/tcpserver/sip/items/RequestHistoryAlarmItem.java
new file mode 100644
index 0000000..63ccec8
--- /dev/null
+++ b/src/main/java/com/inspect/tcpserver/sip/items/RequestHistoryAlarmItem.java
@@ -0,0 +1,33 @@
+package com.inspect.tcpserver.sip.items;
+
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+public class RequestHistoryAlarmItem {
+ @JacksonXmlProperty(isAttribute = true, localName = "Code")
+ private String code;
+
+ @JacksonXmlProperty(isAttribute = true, localName = "UserCode")
+ private String userCode;
+
+ @JacksonXmlProperty(isAttribute = true, localName = "Type")
+ private String type;
+
+ @JacksonXmlProperty(isAttribute = true, localName = "BeginTime")
+ private String beginTime;
+
+ @JacksonXmlProperty(isAttribute = true, localName = "EndTime")
+ private String endTime;
+
+ @JacksonXmlProperty(isAttribute = true, localName = "Level")
+ private String level;
+
+ @JacksonXmlProperty(isAttribute = true, localName = "FromIndex")
+ private int fromIndex;
+
+ @JacksonXmlProperty(isAttribute = true, localName = "ToIndex")
+ private int toIndex;
+}
diff --git a/src/main/java/com/inspect/tcpserver/sip/items/RequestResourceItem.java b/src/main/java/com/inspect/tcpserver/sip/items/RequestResourceItem.java
new file mode 100644
index 0000000..6102d9a
--- /dev/null
+++ b/src/main/java/com/inspect/tcpserver/sip/items/RequestResourceItem.java
@@ -0,0 +1,18 @@
+package com.inspect.tcpserver.sip.items;
+
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+public class RequestResourceItem {
+ @JacksonXmlProperty(isAttribute = true, localName = "Code")
+ private String code;
+
+ @JacksonXmlProperty(isAttribute = true, localName = "FromIndex")
+ private int fromIndex;
+
+ @JacksonXmlProperty(isAttribute = true, localName = "ToIndex")
+ private int toIndex;
+}
diff --git a/src/main/java/com/inspect/tcpserver/sip/items/SipEventRegistry.java b/src/main/java/com/inspect/tcpserver/sip/items/SipEventRegistry.java
new file mode 100644
index 0000000..bd9ba8f
--- /dev/null
+++ b/src/main/java/com/inspect/tcpserver/sip/items/SipEventRegistry.java
@@ -0,0 +1,18 @@
+package com.inspect.tcpserver.sip.items;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public class SipEventRegistry {
+ private static final Map> EVENT_MAP = new HashMap<>();
+
+ static {
+ EVENT_MAP.put("Request_Resource", RequestResourceItem.class);
+ EVENT_MAP.put("Request_History_Alarm", RequestHistoryAlarmItem.class);
+ }
+
+ public static Class> getItemClass(String eventType) {
+ return EVENT_MAP.get(eventType);
+ }
+}
+
diff --git a/src/main/java/com/inspect/tcpserver/sip/service/SipClientService.java b/src/main/java/com/inspect/tcpserver/sip/service/SipClientService.java
new file mode 100644
index 0000000..dbe6ad3
--- /dev/null
+++ b/src/main/java/com/inspect/tcpserver/sip/service/SipClientService.java
@@ -0,0 +1,583 @@
+package com.inspect.tcpserver.sip.service;
+
+import com.inspect.tcpserver.sip.items.SipEventRegistry;
+import com.inspect.tcpserver.sip.utils.DigestUtil;
+import com.inspect.tcpserver.sip.utils.SipXmlEnvelope;
+import com.inspect.tcpserver.sip.utils.SipXmlParser;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.PostConstruct;
+import javax.annotation.PreDestroy;
+import javax.sip.*;
+import javax.sip.address.Address;
+import javax.sip.address.AddressFactory;
+import javax.sip.address.SipURI;
+import javax.sip.header.*;
+import javax.sip.message.MessageFactory;
+import javax.sip.message.Request;
+import javax.sip.message.Response;
+import java.io.UnsupportedEncodingException;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.*;
+import java.util.concurrent.atomic.AtomicInteger;
+
+@Slf4j
+@Service
+@ConditionalOnProperty(name = "sip.enable", havingValue = "true")
+public class SipClientService implements SipListener {
+
+ private SipFactory sipFactory;
+ private SipStack sipStack;
+ private SipProvider sipProvider;
+ private MessageFactory messageFactory;
+ private HeaderFactory headerFactory;
+ private AddressFactory addressFactory;
+
+ // 保存上次请求信息,重发用
+ private String lastDeviceCode;
+ private String lastServerIp;
+ private int lastServerPort;
+ private String lastXmlBody;
+
+ // 保存 WWW-Authenticate 参数
+ private String lastNonce;
+ private String lastRealm;
+ private String lastOpaque;
+ private String lastQop;
+
+
+ @Value("${sip.username}")
+ private String username;
+
+ @Value("${sip.password}")
+ private String password;
+
+ @Value("${sip.domain}")
+ private String domain;
+
+ @Value("${sip.port}")
+ private int port;
+
+ @Value("${sip.transport}")
+ private String transport;
+
+ @Value("${sip.local-ip}")
+ private String localIp;
+
+ @Value("${sip.local-port}")
+ private int localPort;
+
+ @Value("${sip.expires}")
+ private int expires;
+
+ private ClientTransaction lastRegisterTransaction;
+ private Timer refreshTimer = new Timer(true);
+ private String fromTag = Long.toHexString(System.currentTimeMillis());
+
+ private AtomicInteger cSeqCounter = new AtomicInteger(1);
+
+ String testXml = "\n" +
+ "\n" +
+ " 1234567890\n" +
+ " \n" +
+ " \n" +
+ " \n" +
+ " \n" +
+ " \n" +
+ " \n" +
+ " \n" +
+ " \n" +
+ " \n" +
+ " \n" +
+ " - \n" +
+ "
- \n" +
+ "
- \n" +
+ "
- \n" +
+ "
- \n" +
+ "
- \n" +
+ "
- \n" +
+ "
- \n" +
+ "
- \n" +
+ "
- \n" +
+ "
- \n" +
+ "
- \n" +
+ "
- \n" +
+ "
- \n" +
+ "
- \n" +
+ "
- \n" +
+ "
- \n" +
+ "
- \n" +
+ "
- \n" +
+ "
- \n" +
+ "
- \n" +
+ "
- \n" +
+ "
- \n" +
+ "
- \n" +
+ "
- \n" +
+ "
- \n" +
+ "
- \n" +
+ "
- \n" +
+ "
- \n" +
+ "
- \n" +
+ "
- \n" +
+ "
- \n" +
+ "
- \n" +
+ "
- \n" +
+ "
- \n" +
+ "
- \n" +
+ "
- \n" +
+ "
- \n" +
+ "
- \n" +
+ "
- \n" +
+ "
- \n" +
+ "
\n" +
+ "\n";
+
+
+ @PostConstruct
+ public void init() throws Exception {
+ sipFactory = SipFactory.getInstance();
+ sipFactory.setPathName("gov.nist");
+
+ headerFactory = sipFactory.createHeaderFactory();
+ addressFactory = sipFactory.createAddressFactory();
+ messageFactory = sipFactory.createMessageFactory();
+
+ Properties properties = new Properties();
+ properties.setProperty("javax.sip.STACK_NAME", "spring-boot-sip-stack");
+ properties.setProperty("gov.nist.javax.sip.IP_ADDRESS", localIp);
+ properties.setProperty("gov.nist.javax.sip.DEBUG_LOG", "sipdebug.txt");
+ properties.setProperty("gov.nist.javax.sip.SERVER_LOG", "sipserverlog.txt");
+
+ sipStack = sipFactory.createSipStack(properties);
+
+ ListeningPoint lp = sipStack.createListeningPoint(localIp, localPort, transport);
+
+ sipProvider = sipStack.createSipProvider(lp);
+ sipProvider.addSipListener(this);
+
+ sendRegister(null);
+ }
+
+ public void sendRegister(AuthorizationHeader authHeader) throws Exception {
+ SipURI requestUri = addressFactory.createSipURI(null, domain);
+ requestUri.setTransportParam(transport.toUpperCase());
+ requestUri.setPort(port);
+
+ Address fromAddress = addressFactory.createAddress(username, addressFactory.createSipURI(username, domain));
+ FromHeader fromHeader = headerFactory.createFromHeader(fromAddress, fromTag);
+ Address toAddress = addressFactory.createAddress(username, addressFactory.createSipURI(username, domain));
+ ToHeader toHeader = headerFactory.createToHeader(toAddress, null);
+
+ CallIdHeader callId = sipProvider.getNewCallId();
+ CSeqHeader cSeq = headerFactory.createCSeqHeader(1L, Request.REGISTER);
+ MaxForwardsHeader maxForwards = headerFactory.createMaxForwardsHeader(70);
+
+ // 创建带自定义deviceid参数的Contact URI
+ SipURI contactUri = addressFactory.createSipURI(username, localIp);
+ contactUri.setPort(localPort);
+ contactUri.setTransportParam(transport.toUpperCase());
+ contactUri.setParameter("deviceid", "123456");
+
+ Address contactAddress = addressFactory.createAddress(contactUri);
+ ContactHeader contactHeader = headerFactory.createContactHeader(contactAddress);
+ ExpiresHeader expiresHeader = headerFactory.createExpiresHeader(expires);
+
+ List viaHeaders = new ArrayList<>();
+ ViaHeader viaHeader = headerFactory.createViaHeader(localIp, localPort, transport.toUpperCase(), null);
+ viaHeaders.add(viaHeader);
+
+ Request request = messageFactory.createRequest(
+ requestUri,
+ Request.REGISTER,
+ callId,
+ cSeq,
+ fromHeader,
+ toHeader,
+ viaHeaders,
+ maxForwards
+ );
+
+ request.addHeader(contactHeader);
+ request.addHeader(expiresHeader);
+
+ // 添加自定义头,携带设备编号
+ Header deviceIdHeader = headerFactory.createHeader("X-Device-ID", "123456"); // 设备编号
+ request.addHeader(deviceIdHeader);
+ if (authHeader != null) {
+ request.addHeader(authHeader);
+ }
+
+ lastRegisterTransaction = sipProvider.getNewClientTransaction(request);
+ lastRegisterTransaction.sendRequest();
+ }
+
+ public void sendXmlResource(String targetSipUri, String xml) throws Exception {
+ log.info("Sending XML resource to {}", targetSipUri);
+
+ SipURI fromUri = addressFactory.createSipURI(username, domain + ":" + port);
+ Address fromAddress = addressFactory.createAddress(fromUri);
+ FromHeader fromHeader = headerFactory.createFromHeader(fromAddress, fromTag);
+
+ javax.sip.address.URI toUri = addressFactory.createURI(targetSipUri);
+ Address toAddress = addressFactory.createAddress(toUri);
+ ToHeader toHeader = headerFactory.createToHeader(toAddress, null);
+
+ // 创建空的Via头列表
+ List viaHeaders = new ArrayList<>();
+
+ Request message = messageFactory.createRequest(
+ toUri,
+ Request.MESSAGE,
+ sipProvider.getNewCallId(),
+ headerFactory.createCSeqHeader(1L, Request.MESSAGE),
+ fromHeader,
+ toHeader,
+ viaHeaders,
+ headerFactory.createMaxForwardsHeader(70)
+ );
+
+ // 手动创建Via头并添加
+ ViaHeader viaHeader = headerFactory.createViaHeader(localIp, localPort, "UDP", null);
+ message.addHeader(viaHeader);
+
+ ContentTypeHeader ct = headerFactory.createContentTypeHeader("Application", "MANSCDP+xml");
+ message.setContent(xml, ct);
+
+ ClientTransaction tx = sipProvider.getNewClientTransaction(message);
+ tx.sendRequest();
+ }
+
+
+ @Override
+ public void processResponse(ResponseEvent responseEvent) {
+ Response response = responseEvent.getResponse();
+ int status = response.getStatusCode();
+ log.info("Received response: {}", status);
+ CSeqHeader cSeqHeader = (CSeqHeader) response.getHeader(CSeqHeader.NAME);
+ if (cSeqHeader == null) return;
+ String method = cSeqHeader.getMethod();
+
+ try {
+ if (Request.REGISTER.equalsIgnoreCase(method)) {
+ if (status == 401 || status == 407) {
+ log.info("Received 401/407, attempt auth...");
+
+ WWWAuthenticateHeader wwwAuth = (WWWAuthenticateHeader) response.getHeader(WWWAuthenticateHeader.NAME);
+ if (wwwAuth == null) {
+ log.error("No WWW-Authenticate header found, cannot authenticate");
+ return;
+ }
+
+ String realm = wwwAuth.getRealm();
+ String nonce = wwwAuth.getNonce();
+ String qop = wwwAuth.getQop();
+
+ String uri = "sip:" + domain + ":" + port;
+
+ String nc = "00000001";
+ String cNonce = generateCNonce();
+
+ String responseDigest = DigestUtil.computeResponse(
+ username,
+ password,
+ realm,
+ nonce,
+ "REGISTER",
+ uri,
+ nc,
+ cNonce,
+ qop
+ );
+
+ AuthorizationHeader authHeader = headerFactory.createAuthorizationHeader("Digest");
+ authHeader.setUsername(username);
+ authHeader.setRealm(realm);
+ authHeader.setNonce(nonce);
+ authHeader.setURI(addressFactory.createURI(uri));
+ authHeader.setResponse(responseDigest);
+
+ if (qop != null) {
+ authHeader.setQop(qop);
+ authHeader.setCNonce(cNonce);
+ authHeader.setNonceCount(Integer.parseInt(nc, 16));
+ }
+
+ sendRegister(authHeader);
+
+ } else if (status >= 200 && status < 300) {
+ log.info("REGISTER success: {}", status);
+ int delay = Math.max(5, expires - 60);
+ refreshTimer.schedule(new TimerTask() {
+ @Override
+ public void run() {
+ try {
+ sendRegister(null);
+ } catch (Exception e) {
+ log.error("Failed to refresh register", e);
+ }
+ }
+ }, delay * 1000L);
+
+ sendNotify(username, domain, port, testXml);
+ } else {
+ log.warn("REGISTER response: {}", status);
+ }
+ } else if (Request.MESSAGE.equalsIgnoreCase(method)) {
+ log.info("MESSAGE response: {}", status);
+ } else if (Request.NOTIFY.equalsIgnoreCase(method)) {
+ log.info("NOTIFY response: {}", status);
+ if (status == 401) {
+ WWWAuthenticateHeader wwwAuth = (WWWAuthenticateHeader) response.getHeader(WWWAuthenticateHeader.NAME);
+ if (wwwAuth != null) {
+ lastNonce = wwwAuth.getNonce();
+ lastRealm = wwwAuth.getRealm();
+ lastOpaque = wwwAuth.getOpaque();
+ lastQop = wwwAuth.getQop();
+ try {
+ resendNotifyWithAuth();
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+ }
+ }
+ } catch (Exception ex) {
+ log.error("processResponse error", ex);
+ }
+ }
+
+ private void resendNotifyWithAuth() throws Exception {
+ String method = Request.NOTIFY;
+ String uriStr = lastDeviceCode + "@" + lastServerIp;
+ String nc = "00000001";
+ String cNonce = md5Hex(Long.toString(System.currentTimeMillis()));
+
+ String responseDigest = computeResponse(
+ username,
+ lastRealm,
+ password,
+ method,
+ "sip:" + uriStr,
+ lastNonce,
+ nc,
+ cNonce,
+ lastQop != null ? lastQop : "auth"
+ );
+
+ AuthorizationHeader authHeader = headerFactory.createAuthorizationHeader("Digest");
+ authHeader.setUsername(username);
+ authHeader.setRealm(lastRealm);
+ authHeader.setNonce(lastNonce);
+ authHeader.setURI(addressFactory.createURI("sip:" + uriStr));
+ authHeader.setResponse(responseDigest);
+ authHeader.setAlgorithm("MD5");
+ authHeader.setCNonce(cNonce);
+ if (lastOpaque != null) authHeader.setOpaque(lastOpaque);
+ authHeader.setQop(lastQop != null ? lastQop : "auth");
+ authHeader.setNonceCount(1);
+
+ Request requestWithAuth = createNotifyRequest(lastDeviceCode, lastServerIp, lastServerPort, lastXmlBody, authHeader);
+ ClientTransaction transaction = sipProvider.getNewClientTransaction(requestWithAuth);
+ transaction.sendRequest();
+ }
+
+ // 构造NOTIFY请求
+ private Request createNotifyRequest(String deviceCode, String serverIp, int serverPort, String xmlBody, AuthorizationHeader authHeader) throws Exception {
+ String notifierUser = "290010021201070000";
+ String localHost = this.localIp;
+ int localPort = this.localPort;
+
+ SipURI requestUri = addressFactory.createSipURI(deviceCode, serverIp);
+ requestUri.setTransportParam("tcp");
+ requestUri.setPort(serverPort);
+
+ Address fromAddress = addressFactory.createAddress("sip:" + username + "@" + localHost);
+ FromHeader fromHeader = headerFactory.createFromHeader(fromAddress, UUID.randomUUID().toString().substring(0, 8));
+
+ Address toAddress = addressFactory.createAddress("sip:" + deviceCode + "@" + serverIp);
+ ToHeader toHeader = headerFactory.createToHeader(toAddress, null);
+
+ CallIdHeader callId = sipProvider.getNewCallId();
+ CSeqHeader cSeq = headerFactory.createCSeqHeader(cSeqCounter.getAndIncrement(), Request.NOTIFY);
+ MaxForwardsHeader maxForwards = headerFactory.createMaxForwardsHeader(70);
+
+ List viaHeaders = new ArrayList<>();
+ ViaHeader viaHeader = headerFactory.createViaHeader(localHost, localPort, transport, null);
+ viaHeaders.add(viaHeader);
+
+ Address contactAddr = addressFactory.createAddress("sip:" + username + "@" + localHost + ":" + localPort);
+ ContactHeader contactHeader = headerFactory.createContactHeader(contactAddr);
+
+ EventHeader eventHeader = headerFactory.createEventHeader("Push_Resource");
+
+ SubscriptionStateHeader subscriptionStateHeader = headerFactory.createSubscriptionStateHeader(SubscriptionStateHeader.TERMINATED);
+
+ ContentTypeHeader contentTypeHeader = headerFactory.createContentTypeHeader("application", "xml");
+
+ Request request = messageFactory.createRequest(
+ requestUri,
+ Request.NOTIFY,
+ callId,
+ cSeq,
+ fromHeader,
+ toHeader,
+ viaHeaders,
+ maxForwards
+ );
+
+ request.addHeader(contactHeader);
+ request.addHeader(eventHeader);
+ request.addHeader(subscriptionStateHeader);
+ request.addHeader(contentTypeHeader);
+ request.setContent(xmlBody, contentTypeHeader);
+
+ if (authHeader != null) {
+ request.addHeader(authHeader);
+ }
+
+ return request;
+ }
+
+ private String generateCNonce() {
+ return Long.toHexString(System.currentTimeMillis() & 0xffffffffL);
+ }
+
+ @Override
+ public void processRequest(RequestEvent requestEvent) {
+ Request request = requestEvent.getRequest();
+ String method = request.getMethod();
+ log.info("Received SIP request: {}", method);
+ try {
+ if (Request.MESSAGE.equalsIgnoreCase(method)) {
+ log.info("Processing MESSAGE request from {}", ((FromHeader) request.getHeader(FromHeader.NAME)).getAddress());
+
+ ContentTypeHeader contentTypeHeader = (ContentTypeHeader) request.getHeader(ContentTypeHeader.NAME);
+ if (contentTypeHeader != null &&
+ "application".equalsIgnoreCase(contentTypeHeader.getContentType()) &&
+ "xml".equalsIgnoreCase(contentTypeHeader.getContentSubType())) {
+
+ String xml = new String(request.getRawContent(), StandardCharsets.UTF_8);
+ xml = xml.replace("”", "\"").replace("“", "\"");
+ log.info("MESSAGE body:\n{}", xml);
+
+ String eventType = SipXmlParser.peekEventType(xml);
+ log.info("RECOGNIZE EventType = {}", eventType);
+
+ Class> itemClass = SipEventRegistry.getItemClass(eventType);
+ if (itemClass == null) {
+ log.warn("UNREGISTERED EventType: {}", eventType);
+ return;
+ }
+
+ SipXmlEnvelope> envelope = SipXmlParser.parse(xml, itemClass);
+ //handleSipEvent(envelope);
+ }
+
+ } else {
+ Response notImpl = messageFactory.createResponse(Response.NOT_IMPLEMENTED, request);
+ requestEvent.getServerTransaction().sendResponse(notImpl);
+ log.warn("Method {} not implemented, replied 501", method);
+ }
+ } catch (Exception ex) {
+ log.error("Error processing request: {}", ex.getMessage(), ex);
+ try {
+ Response errorResponse = messageFactory.createResponse(Response.SERVER_INTERNAL_ERROR, request);
+ requestEvent.getServerTransaction().sendResponse(errorResponse);
+ } catch (Exception e) {
+ log.error("Failed to send error response", e);
+ }
+ }
+ }
+
+ @Override
+ public void processTimeout(TimeoutEvent timeoutEvent) {
+ log.warn("SIP timeout: {}", timeoutEvent);
+ }
+
+ @Override
+ public void processIOException(IOExceptionEvent exceptionEvent) {
+ log.error("SIP IO Exception: {}", exceptionEvent);
+ }
+
+ @Override
+ public void processTransactionTerminated(TransactionTerminatedEvent tte) {
+ }
+
+ @Override
+ public void processDialogTerminated(DialogTerminatedEvent dte) {
+ }
+
+ @PreDestroy
+ public void shutdown() {
+ log.info("Shutting down SIP stack...");
+ refreshTimer.cancel();
+ if (sipProvider != null) sipProvider.removeSipListener(this);
+ if (sipStack != null) sipStack.stop();
+ }
+
+
+ /**
+ * 发送 SIP NOTIFY 消息
+ *
+ * @param deviceCode 客户端编码(也是 SIP 用户名)
+ * @param serverIp 服务器 IP
+ * @param serverPort 服务器 平台端口
+ * @param xmlBody XML 消息体
+ * @throws Exception
+ */
+ // 公开调用,发送NOTIFY(首次不带认证)
+ public void sendNotify(String deviceCode, String serverIp, int serverPort, String xmlBody) throws Exception {
+ lastDeviceCode = deviceCode;
+ lastServerIp = serverIp;
+ lastServerPort = serverPort;
+ lastXmlBody = xmlBody;
+
+ Request request = createNotifyRequest(deviceCode, serverIp, serverPort, xmlBody, null);
+ ClientTransaction transaction = sipProvider.getNewClientTransaction(request);
+ transaction.sendRequest();
+ }
+
+ /**
+ * 分片 XML(保证每块 <= maxBytes)
+ */
+ private List splitXml(String xml, int maxBytes) throws UnsupportedEncodingException {
+ List parts = new ArrayList<>();
+ byte[] data = xml.getBytes("UTF-8");
+ int offset = 0;
+ while (offset < data.length) {
+ int len = Math.min(maxBytes, data.length - offset);
+ parts.add(new String(data, offset, len, "UTF-8"));
+ offset += len;
+ }
+ return parts;
+ }
+
+ // 计算Digest响应值
+ private String computeResponse(String username, String realm, String password, String method,
+ String uri, String nonce, String nc, String cNonce, String qop) throws Exception {
+ MessageDigest md = MessageDigest.getInstance("MD5");
+
+ String ha1 = md5Hex(md, username + ":" + realm + ":" + password);
+ String ha2 = md5Hex(md, method + ":" + uri);
+ return md5Hex(md, ha1 + ":" + nonce + ":" + nc + ":" + cNonce + ":" + qop + ":" + ha2);
+ }
+
+ private String md5Hex(String data) throws Exception {
+ MessageDigest md = MessageDigest.getInstance("MD5");
+ return md5Hex(md, data);
+ }
+
+ private String md5Hex(MessageDigest md, String data) {
+ byte[] digest = md.digest(data.getBytes(StandardCharsets.UTF_8));
+ StringBuilder sb = new StringBuilder();
+ for (byte b : digest) {
+ sb.append(String.format("%02x", b & 0xff));
+ }
+ return sb.toString();
+ }
+}
diff --git a/src/main/java/com/inspect/tcpserver/sip/service/SipVideoClient.java b/src/main/java/com/inspect/tcpserver/sip/service/SipVideoClient.java
new file mode 100644
index 0000000..8b6c0e7
--- /dev/null
+++ b/src/main/java/com/inspect/tcpserver/sip/service/SipVideoClient.java
@@ -0,0 +1,229 @@
+package com.inspect.tcpserver.sip.service;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.PostConstruct;
+import javax.sip.*;
+import javax.sip.address.Address;
+import javax.sip.address.AddressFactory;
+import javax.sip.address.SipURI;
+import javax.sip.header.*;
+import javax.sip.message.MessageFactory;
+import javax.sip.message.Request;
+import javax.sip.message.Response;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Properties;
+import java.util.Random;
+
+@Slf4j
+@Component
+@ConditionalOnProperty(name = "sip.enable", havingValue = "true")
+public class SipVideoClient implements SipListener {
+
+ private SipFactory sipFactory;
+ private SipStack sipStack;
+ private SipProvider sipProvider;
+ private HeaderFactory headerFactory;
+ private AddressFactory addressFactory;
+ private MessageFactory messageFactory;
+
+ @Value("${sip.local-ip}")
+ private String localIp; // 本地IP
+ @Value("${sip.local-port2:5062}")
+ private int localPort; // 本地监听端口
+ @Value("${sip.domain}")
+ private String remoteIp; // Asterisk IP
+ @Value("${sip.port}")
+ private int remotePort; // Asterisk端口
+
+ @Value("${sip.username}")
+ private String username;
+ @Value("${sip.domain}")
+ private String domain;
+ @Value("${sip.transport}")
+ private String transport;
+
+ @PostConstruct
+ public void init() throws Exception {
+ sipFactory = SipFactory.getInstance();
+ sipFactory.setPathName("gov.nist");
+
+ headerFactory = sipFactory.createHeaderFactory();
+ addressFactory = sipFactory.createAddressFactory();
+ messageFactory = sipFactory.createMessageFactory();
+
+ Properties properties = new Properties();
+ properties.setProperty("javax.sip.STACK_NAME", "sip-video-client");
+ properties.setProperty("gov.nist.javax.sip.IP_ADDRESS", localIp);
+
+ sipStack = sipFactory.createSipStack(properties);
+
+ ListeningPoint lp = sipStack.createListeningPoint(localIp, localPort, transport);
+ sipProvider = sipStack.createSipProvider(lp);
+ sipProvider.addSipListener(this);
+ }
+
+ public void sendInvite() throws Exception {
+ SipURI requestUri = addressFactory.createSipURI(null, domain);
+ requestUri.setTransportParam(transport);
+ requestUri.setPort(remotePort);
+
+ Address fromAddress = addressFactory.createAddress("sip:" + username + "@" + domain);
+ FromHeader fromHeader = headerFactory.createFromHeader(fromAddress, Integer.toHexString(new Random().nextInt()));
+
+ Address toAddress = addressFactory.createAddress("sip:" + username + "@" + domain);
+ ToHeader toHeader = headerFactory.createToHeader(toAddress, null);
+
+ CallIdHeader callIdHeader = sipProvider.getNewCallId();
+ CSeqHeader cSeqHeader = headerFactory.createCSeqHeader(1L, Request.INVITE);
+ MaxForwardsHeader maxForwards = headerFactory.createMaxForwardsHeader(70);
+
+ List viaHeaders = new ArrayList<>();
+ viaHeaders.add(headerFactory.createViaHeader(localIp, localPort, transport, null));
+
+ Request inviteRequest = messageFactory.createRequest(
+ requestUri,
+ Request.INVITE,
+ callIdHeader,
+ cSeqHeader,
+ fromHeader,
+ toHeader,
+ viaHeaders,
+ maxForwards);
+
+ // 添加Contact头
+ Address contactAddress = addressFactory.createAddress("sip:" + username + "@" + localIp + ":" + localPort);
+ ContactHeader contactHeader = headerFactory.createContactHeader(contactAddress);
+ inviteRequest.addHeader(contactHeader);
+
+ // 添加SDP Body — 这描述视频流的RTP信息
+ String sdpData = buildSdpBody(localIp, 9000); // 本地RTP端口9000
+ ContentTypeHeader contentTypeHeader = headerFactory.createContentTypeHeader("application", "sdp");
+ inviteRequest.setContent(sdpData, contentTypeHeader);
+
+ ClientTransaction inviteTransaction = sipProvider.getNewClientTransaction(inviteRequest);
+ inviteTransaction.sendRequest();
+ }
+
+ private String buildSdpBody(String ip, int rtpPort) {
+ return "v=0\r\n" +
+ "o=- 0 0 IN IP4 " + ip + "\r\n" +
+ "s=Video Call\r\n" +
+ "c=IN IP4 " + ip + "\r\n" +
+ "t=0 0\r\n" +
+ "m=video " + rtpPort + " RTP/AVP 96\r\n" +
+ "a=rtpmap:96 H264/90000\r\n" +
+ "a=sendrecv\r\n";
+ }
+
+ @Override
+ public void processResponse(ResponseEvent responseEvent) {
+ Response response = responseEvent.getResponse();
+ CSeqHeader cSeq = (CSeqHeader) response.getHeader(CSeqHeader.NAME);
+ int statusCode = response.getStatusCode();
+ log.info("Received response: {} for: {}", statusCode, cSeq.getMethod());
+ if (statusCode == 200 && Request.INVITE.equals(cSeq.getMethod())) {
+ try {
+ ClientTransaction ct = responseEvent.getClientTransaction();
+ Dialog dialog = ct.getDialog();
+
+ Request ackRequest = dialog.createAck(cSeq.getSeqNumber());
+ dialog.sendAck(ackRequest);
+
+ log.info("Sent ACK, 开始推送视频流");
+ startVideoStreaming();
+
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+ }
+
+ @Override
+ public void processRequest(RequestEvent requestEvent) {
+ Request request = requestEvent.getRequest();
+ String method = request.getMethod();
+ log.info("Received request: {}", method);
+
+ if (Request.BYE.equals(method)) {
+ try {
+ ServerTransaction st = requestEvent.getServerTransaction();
+ if (st == null) {
+ st = sipProvider.getNewServerTransaction(request);
+ }
+ Response response = messageFactory.createResponse(200, request);
+ st.sendResponse(response);
+
+ log.info("Received BYE,通话结束,停止推流");
+ stopVideoStreaming();
+
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+ }
+
+ @Override
+ public void processTimeout(TimeoutEvent timeoutEvent) {}
+
+ @Override
+ public void processIOException(IOExceptionEvent exceptionEvent) {}
+
+ @Override
+ public void processTransactionTerminated(TransactionTerminatedEvent transactionTerminatedEvent) {}
+
+ @Override
+ public void processDialogTerminated(DialogTerminatedEvent dialogTerminatedEvent) {}
+
+ private Process ffmpegProcess;
+
+ private void startVideoStreaming() {
+ try {
+ String rtspUrl = "rtsp://localhost:8554/mystream";
+
+ ProcessBuilder pb = new ProcessBuilder(
+ "ffmpeg",
+ "-rtsp_transport", "tcp", // 如果rtsp拉流失败可试试tcp
+ "-i", rtspUrl,
+ "-vcodec", "copy",
+ "-an",
+ "-f", "rtp",
+ "rtp://" + remoteIp + ":9000"
+ );
+
+ pb.redirectErrorStream(true);
+ ffmpegProcess = pb.start();
+
+ // 启动一个线程打印FFMPEG日志
+ new Thread(() -> {
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(ffmpegProcess.getInputStream()))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ log.info("[FFMPEG] {}", line);
+ }
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }).start();
+
+ log.info("视频推流开始");
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+
+ private void stopVideoStreaming() {
+ if (ffmpegProcess != null) {
+ ffmpegProcess.destroy();
+ System.out.println("视频推流停止");
+ }
+ }
+}
+
diff --git a/src/main/java/com/inspect/tcpserver/sip/utils/DigestUtil.java b/src/main/java/com/inspect/tcpserver/sip/utils/DigestUtil.java
new file mode 100644
index 0000000..fc147dd
--- /dev/null
+++ b/src/main/java/com/inspect/tcpserver/sip/utils/DigestUtil.java
@@ -0,0 +1,62 @@
+package com.inspect.tcpserver.sip.utils;
+
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Formatter;
+
+public class DigestUtil {
+
+ /**
+ * 计算Digest响应值
+ * @param username 用户名
+ * @param password 密码
+ * @param realm 域
+ * @param nonce 随机数
+ * @param method SIP请求方法,如REGISTER
+ * @param uri 请求URI
+ * @param nc 请求计数(可以传null或"00000001"初始值)
+ * @param cnonce 客户端随机串(可传null)
+ * @param qop 质量保护参数(可传null)
+ * @return response摘要字符串
+ */
+ public static String computeResponse(String username, String password, String realm, String nonce,
+ String method, String uri, String nc, String cnonce, String qop) {
+ try {
+ String ha1 = md5Hex(username + ":" + realm + ":" + password);
+ String ha2 = md5Hex(method + ":" + uri);
+
+ String response;
+ if (qop != null && (qop.equals("auth") || qop.equals("auth-int"))) {
+ response = md5Hex(ha1 + ":" + nonce + ":" + nc + ":" + cnonce + ":" + qop + ":" + ha2);
+ } else {
+ response = md5Hex(ha1 + ":" + nonce + ":" + ha2);
+ }
+ return response;
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to compute digest response", e);
+ }
+ }
+
+ /**
+ * MD5加密,返回16进制字符串
+ */
+ private static String md5Hex(String data) throws NoSuchAlgorithmException {
+ MessageDigest md = MessageDigest.getInstance("MD5");
+ byte[] digest = md.digest(data.getBytes());
+ return byteArrayToHexString(digest);
+ }
+
+ /**
+ * 将字节数组转为16进制字符串
+ */
+ private static String byteArrayToHexString(byte[] bytes) {
+ Formatter formatter = new Formatter();
+ for (byte b : bytes) {
+ formatter.format("%02x", b);
+ }
+ String res = formatter.toString();
+ formatter.close();
+ return res;
+ }
+}
+
diff --git a/src/main/java/com/inspect/tcpserver/sip/utils/SipXmlEnvelope.java b/src/main/java/com/inspect/tcpserver/sip/utils/SipXmlEnvelope.java
new file mode 100644
index 0000000..91631a5
--- /dev/null
+++ b/src/main/java/com/inspect/tcpserver/sip/utils/SipXmlEnvelope.java
@@ -0,0 +1,20 @@
+package com.inspect.tcpserver.sip.utils;
+
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.util.List;
+
+@Getter
+@Setter
+public class SipXmlEnvelope {
+ @JacksonXmlProperty(isAttribute = true, localName = "EventType")
+ private String eventType;
+
+ @JacksonXmlElementWrapper(useWrapping = false)
+ @JacksonXmlProperty(localName = "Item")
+ private List items;
+}
+
diff --git a/src/main/java/com/inspect/tcpserver/sip/utils/SipXmlParser.java b/src/main/java/com/inspect/tcpserver/sip/utils/SipXmlParser.java
new file mode 100644
index 0000000..fd13301
--- /dev/null
+++ b/src/main/java/com/inspect/tcpserver/sip/utils/SipXmlParser.java
@@ -0,0 +1,29 @@
+package com.inspect.tcpserver.sip.utils;
+
+import com.fasterxml.jackson.dataformat.xml.XmlMapper;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+public class SipXmlParser {
+ private static final XmlMapper XML_MAPPER = new XmlMapper();
+
+ public static String peekEventType(String xml) throws Exception {
+ EventTypeOnly temp = XML_MAPPER.readValue(xml, EventTypeOnly.class);
+ return temp.getEventType();
+ }
+
+ public static SipXmlEnvelope parse(String xml, Class itemClass) throws Exception {
+ return XML_MAPPER.readValue(xml,
+ XML_MAPPER.getTypeFactory().constructParametricType(SipXmlEnvelope.class, itemClass));
+ }
+
+ public static class EventTypeOnly {
+ @com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty(isAttribute = true, localName = "EventType")
+ private String eventType;
+
+ public String getEventType() { return eventType; }
+ public void setEventType(String eventType) { this.eventType = eventType; }
+ }
+}
+
+
diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml
index 7e19e3f..ce12085 100644
--- a/src/main/resources/application-dev.yml
+++ b/src/main/resources/application-dev.yml
@@ -45,3 +45,14 @@ minio:
secret-key: Cgygs@2025
bucket-name: znxsxt-production-upload
+sip:
+ enable: true
+ username: 290010021201070000
+ password: 123456
+ domain: 192.168.1.116 # 平台SIP服务IP或域名
+ port: 5060
+ transport: tcp # 也可以为udp
+ local-ip: 192.168.1.11 # 本服务的外网或可达IP
+ local-port: 5061
+ expires: 3600
+
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index a6c5860..c1d06e7 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -46,4 +46,15 @@ minio:
secret-key: minioadmin
bucket-name: mybucket
+sip:
+ enable: true
+ username: 290010021201070000
+ password: 123456
+ domain: 192.168.1.116 # 平台SIP服务IP或域名
+ port: 5060
+ transport: tcp # 也可以为udp
+ local-ip: 192.168.1.11 # 本服务的外网或可达IP
+ local-port: 5061
+ expires: 3600
+