import JsSIP from "jssip";
|
import { Notification, MessageBox, Message, Loading } from "element-ui";
|
// 医院机构与SIP服务器映射配置
|
const HOSPITAL_CONFIG = {
|
丽水市中医院: {
|
wsUrl: "wss://192.168.10.124:7443",
|
domain: "192.168.10.124",
|
},
|
龙泉市人民医院: {
|
wsUrl: "wss://10.10.0.220:7443",
|
domain: "10.10.0.220",
|
},
|
// 可以继续添加其他医院配置
|
default: {
|
wsUrl: "wss://192.168.10.124:7443",
|
domain: "192.168.10.124",
|
},
|
};
|
class SipService {
|
constructor() {
|
this.ua = null;
|
this.currentSession = null;
|
this.onStatusChange = null;
|
this.onCallStatusChange = null;
|
this.onIncomingCall = null;
|
this.isRegistered = false; // 新增注册状态标志
|
this.registrationTime = null; // 新增注册成功时间戳
|
this.currentConfig = null; // 存储当前配置
|
}
|
// 获取医院配置方法
|
getHospitalConfig() {
|
const orgName = localStorage.getItem("orgname");
|
return HOSPITAL_CONFIG[orgName] || HOSPITAL_CONFIG.default;
|
}
|
init(baseConfig) {
|
try {
|
// 获取机构名称,如果没有传入则从localStorage读取
|
const orgName = baseConfig.orgName || localStorage.getItem("orgname");
|
|
// 根据机构名称获取对应的服务器配置
|
const hospitalConfig = this.getHospitalConfig(orgName);
|
console.log(hospitalConfig, "88");
|
|
// 合并配置
|
this.currentConfig = {
|
...baseConfig,
|
...hospitalConfig,
|
};
|
|
console.log(
|
`当前机构: ${orgName}, 使用服务器: ${this.currentConfig.domain}`
|
);
|
|
this.updateStatus("connecting", "连接中...");
|
console.log(baseConfig.sipUri, "baseConfig.sipUri");
|
|
this.ua = new JsSIP.UA({
|
sockets: [new JsSIP.WebSocketInterface(this.currentConfig.wsUrl)],
|
uri: baseConfig.sipUri, // 这里使用基础的sipUri,domain部分会被动态替换
|
password: baseConfig.password,
|
display_name: baseConfig.displayName,
|
iceServers: [],
|
register: true,
|
sessionExpires: 1800,
|
minSessionExpires: 90,
|
register_expires: 300,
|
});
|
|
this.ua.start();
|
|
// 事件监听
|
this.ua.on("registered", () => {
|
this.isRegistered = true;
|
this.registrationTime = Date.now(); // 记录注册成功时间
|
console.log(this.registrationTime, "注册时间");
|
|
this.updateStatus("registered", "已注册");
|
});
|
|
this.ua.on("registrationFailed", (e) => {
|
this.isRegistered = false;
|
this.updateStatus("failed", `注册失败: ${e.cause}`);
|
});
|
|
this.ua.on("unregistered", () => {
|
this.isRegistered = false;
|
let registrationTime = Date.now(); // 记录注销成功时间
|
console.log(registrationTime, "注销时间");
|
this.updateStatus("disconnected", "已注销");
|
});
|
this.ua.on("disconnected", () =>
|
this.updateStatus("disconnected", "连接断开")
|
);
|
this.ua.on("connected", () =>
|
this.updateStatus("connecting", "重新连接中...")
|
);
|
this.ua.on("newRTCSession", (data) =>
|
this.handleIncomingCall(data.session)
|
);
|
} catch (error) {
|
this.updateStatus("failed", `初始化失败: ${error.message}`);
|
console.error("SIP初始化失败:", error);
|
throw error;
|
}
|
}
|
// 新增方法:检查是否可以呼叫
|
canMakeCall(minDelay = 2000) {
|
if (!this.isRegistered) {
|
return { canCall: false, reason: "SIP未注册,无法呼叫" };
|
}
|
|
const now = Date.now();
|
const timeSinceRegistration = now - this.registrationTime;
|
|
if (timeSinceRegistration < minDelay) {
|
const remaining = minDelay - timeSinceRegistration;
|
return {
|
canCall: false,
|
reason: `注册成功,资源加载中请等待 ${Math.ceil(
|
remaining / 1000
|
)} 秒后再呼叫`,
|
};
|
}
|
|
return { canCall: true, reason: "" };
|
}
|
makeCall(targetNumber) {
|
const { canCall, reason } = this.canMakeCall();
|
if (!canCall) {
|
Message.error(reason);
|
return Promise.reject(new Error(reason));
|
}
|
return new Promise((resolve, reject) => {
|
try {
|
if (!this.ua) {
|
throw new Error("SIP客户端未初始化");
|
}
|
|
if (!this.ua.isRegistered()) {
|
throw new Error("SIP未注册,无法呼叫");
|
}
|
const targetUri = `sip:${targetNumber}@${this.currentConfig.domain}`;
|
console.log(`呼叫目标: ${targetUri}`);
|
const options = {
|
sessionTimers: true, // 启用会话计时器
|
sessionTimersExpires: 150,
|
extraHeaders: ["Accept: application/sdp"],
|
mediaConstraints: { audio: true, video: false },
|
rtcOfferConstraints: {
|
offerToReceiveAudio: true,
|
offerToReceiveVideo: false,
|
},
|
eventHandlers: {
|
progress: () => this.updateCallStatus("calling", "呼叫中..."),
|
failed: (e) => {
|
this.handleCallFailure(e, reject);
|
},
|
ended: () => this.updateCallStatus("ended", "通话结束"),
|
confirmed: () => {
|
this.updateCallStatus("connected", "通话已接通");
|
resolve();
|
},
|
},
|
};
|
|
this.currentSession = this.ua.call(targetUri, options);
|
|
this.setupPeerConnection(this.currentSession);
|
this.setupAudio(this.currentSession);
|
} catch (error) {
|
this.updateCallStatus("failed", `呼叫失败22: ${error.message}`);
|
reject(error);
|
}
|
});
|
}
|
|
setupPeerConnection(session) {
|
session.on("peerconnection", (pc) => {
|
const originalCreateOffer = pc.createOffer.bind(pc);
|
|
pc.createOffer = async (offerOptions) => {
|
try {
|
const offer = await originalCreateOffer(offerOptions);
|
return this.normalizeSDP(offer);
|
} catch (error) {
|
console.error("创建Offer失败:", error);
|
throw error;
|
}
|
};
|
});
|
}
|
|
// normalizeSDP(offer) {
|
// let sdp = offer.sdp;
|
// console.log("原始SDP:", sdp); // 调试用,捕获原始SDP
|
// // 标准化SDP
|
// sdp = sdp.replace(/c=IN IP4.*\r\n/, "c=IN IP4 0.0.0.0\r\n");
|
// sdp = sdp.replace(
|
// /m=audio \d+.*\r\n/,
|
// "m=audio 9 UDP/TLS/RTP/SAVPF 0 8\r\n"
|
// );
|
|
// // 确保包含基本编解码器
|
// if (!sdp.includes("PCMU/8000")) sdp += "a=rtpmap:0 PCMU/8000\r\n";
|
// if (!sdp.includes("PCMA/8000")) sdp += "a=rtpmap:8 PCMA/8000\r\n";
|
|
// // 添加必要属性
|
// sdp += "a=rtcp-mux\r\n";
|
// sdp += "a=sendrecv\r\n";
|
|
// console.log("标准化后的SDP:", sdp);
|
// return new RTCSessionDescription({
|
// type: offer.type,
|
// sdp: sdp,
|
// });
|
// }
|
// 在 SipService 类中新增方法,用于获取针对特定服务器的SDP处理策略
|
getSDPNormalizationStrategy(orgName) {
|
const strategies = {
|
龙泉市人民医院: "conservative", // 保守策略:最小化修改,优先兼容
|
丽水市中医院: "aggressive", // 激进策略:保持原有强标准化逻辑
|
// 可以为其他机构添加更多策略
|
};
|
return strategies[orgName] || "moderate"; // 默认策略
|
}
|
|
/**
|
* 标准化SDP Offer - 修复龙泉市人民医院488错误
|
* 核心思路:从“强制覆盖”改为“智能修补”,针对不同服务器使用差异化策略
|
*/
|
normalizeSDP(offer) {
|
const orgName = localStorage.getItem("orgname");
|
const strategy = this.getSDPNormalizationStrategy(orgName);
|
let sdp = offer.sdp;
|
|
console.log(`[SDP标准化] 机构: ${orgName}, 策略: ${strategy}`);
|
console.log("[SDP标准化] 原始SDP:", sdp);
|
|
if (strategy === "conservative") {
|
// ==================== 保守策略:针对龙泉市人民医院等严格服务器 ====================
|
// 原则:除非必要,否则不修改原有SDP结构,仅添加缺失的关键属性
|
|
// 1. 谨慎处理连接地址:仅在地址是明显内网地址时才修改
|
const privateIPRegex =
|
/c=IN IP4 (192\.168|10\.|172\.(1[6-9]|2[0-9]|3[0-1]))/;
|
if (privateIPRegex.test(sdp)) {
|
sdp = sdp.replace(/c=IN IP4.*\r\n/, "c=IN IP4 0.0.0.0\r\n");
|
console.log("[SDP标准化] 已修改连接地址为 0.0.0.0");
|
}
|
|
// 2. 保持媒体行原样,不强制修改端口和协议
|
// sdp = sdp.replace(/m=audio \d+.*\r\n/, "m=audio 9 UDP/TLS/RTP/SAVPF 0 8\r\n");
|
|
// 3. 智能添加基础编解码器映射(仅在缺失时添加)
|
const codecMappings = [
|
{ pt: 0, name: "PCMU/8000" },
|
{ pt: 8, name: "PCMA/8000" },
|
];
|
|
codecMappings.forEach((codec) => {
|
const rtpmapPattern = `a=rtpmap:${codec.pt} ${codec.name}`;
|
const payloadPattern = ` ${codec.pt} `;
|
|
// 只有当SDP中包含该负载类型但缺少详细映射时才添加
|
if (sdp.includes(payloadPattern) && !sdp.includes(rtpmapPattern)) {
|
sdp += `${rtpmapPattern}\r\n`;
|
console.log(`[SDP标准化] 已添加编解码器映射: ${rtpmapPattern}`);
|
}
|
});
|
|
// 4. 条件性添加必要属性(避免重复)
|
const essentialAttributes = [
|
{ attr: "a=rtcp-mux", desc: "RTCP复用" },
|
{ attr: "a=sendrecv", desc: "双向媒体流" },
|
];
|
|
essentialAttributes.forEach((item) => {
|
if (!sdp.includes(item.attr)) {
|
sdp += `${item.attr}\r\n`;
|
console.log(`[SDP标准化] 已添加属性: ${item.desc}`);
|
}
|
});
|
} else if (strategy === "aggressive") {
|
// ==================== 激进策略:保持原有逻辑 ====================
|
sdp = sdp.replace(/c=IN IP4.*\r\n/, "c=IN IP4 0.0.0.0\r\n");
|
sdp = sdp.replace(
|
/m=audio \d+.*\r\n/,
|
"m=audio 9 UDP/TLS/RTP/SAVPF 0 8\r\n"
|
);
|
|
// 确保包含基础编解码器
|
if (!sdp.includes("PCMU/8000")) sdp += "a=rtpmap:0 PCMU/8000\r\n";
|
if (!sdp.includes("PCMA/8000")) sdp += "a=rtpmap:8 PCMA/8000\r\n";
|
|
// 添加通用属性
|
sdp += "a=rtcp-mux\r\n";
|
sdp += "a=sendrecv\r\n";
|
} else {
|
// ==================== 默认策略:平衡方案 ====================
|
// 适度修改,兼顾兼容性和功能性
|
sdp = sdp.replace(/c=IN IP4.*\r\n/, "c=IN IP4 0.0.0.0\r\n");
|
|
// 仅在媒体行格式明显异常时修改
|
if (!sdp.match(/m=audio \d+ RTP\/AVP/)) {
|
sdp = sdp.replace(
|
/m=audio \d+.*\r\n/,
|
"m=audio 9 UDP/TLS/RTP/SAVPF 0 8\r\n"
|
);
|
}
|
|
// 智能添加缺失的属性
|
if (!sdp.includes("a=rtcp-mux")) sdp += "a=rtcp-mux\r\n";
|
if (!sdp.includes("a=sendrecv")) sdp += "a=sendrecv\r\n";
|
}
|
|
console.log("[SDP标准化] 标准化后SDP:", sdp);
|
return new RTCSessionDescription({
|
type: offer.type,
|
sdp: sdp,
|
});
|
}
|
|
/**
|
* 增强的SDP调试方法 - 用于对比分析
|
*/
|
debugSDPComparison(originalOffer, normalizedOffer, context) {
|
console.group(`[SDP调试] ${context}`);
|
console.log(
|
"原始SDP媒体行:",
|
originalOffer.sdp.match(/m=audio.*\r\n/)?.[0] || "未找到"
|
);
|
console.log(
|
"标准化后媒体行:",
|
normalizedOffer.sdp.match(/m=audio.*\r\n/)?.[0] || "未找到"
|
);
|
console.log(
|
"原始编解码器列表:",
|
originalOffer.sdp.match(/a=rtpmap:\d+.*\r\n/g) || []
|
);
|
console.log(
|
"标准化后编解码器列表:",
|
normalizedOffer.sdp.match(/a=rtpmap:\d+.*\r\n/g) || []
|
);
|
console.groupEnd();
|
}
|
|
// 在 setupPeerConnection 方法中集成调试功能
|
setupPeerConnection(session) {
|
session.on("peerconnection", (pc) => {
|
const originalCreateOffer = pc.createOffer.bind(pc);
|
|
pc.createOffer = async (offerOptions) => {
|
try {
|
const offer = await originalCreateOffer(offerOptions);
|
const normalizedOffer = this.normalizeSDP(offer);
|
|
// 调试信息输出
|
this.debugSDPComparison(offer, normalizedOffer, "Offer创建阶段");
|
|
return normalizedOffer;
|
} catch (error) {
|
console.error("创建Offer失败:", error);
|
throw error;
|
}
|
};
|
});
|
}
|
handleCallFailure(e, reject) {
|
if (e.response?.status_code === 422) {
|
const serverMinSE = e.response.headers["Min-SE"]?.[0]?.raw || "未知";
|
console.error(`服务器要求 Min-SE ≤ ${serverMinSE},当前设置: 120`);
|
}
|
console.error("呼叫失败详情:", {
|
cause: e.cause,
|
message: e.message,
|
response: e.response && {
|
status: e.response.status_code,
|
reason: e.response.reason_phrase,
|
},
|
});
|
|
let errorMessage = "呼叫失败";
|
switch (e.cause) {
|
case "Incompatible SDP":
|
errorMessage = "媒体协商失败,请检查编解码器配置";
|
break;
|
case "488":
|
case "606":
|
errorMessage = "对方设备不支持当前媒体配置";
|
break;
|
case "422":
|
errorMessage = "会话参数不满足服务器要求";
|
break;
|
default:
|
errorMessage = `呼叫失败3: ${e.cause || e.message}`;
|
}
|
|
this.updateCallStatus("failed55", errorMessage);
|
reject(new Error(errorMessage));
|
}
|
|
setupAudio(session) {
|
session.connection.addEventListener("addstream", (e) => {
|
const audioElement = document.getElementById("remoteAudio");
|
if (audioElement) {
|
audioElement.srcObject = e.stream;
|
}
|
});
|
}
|
|
endCall() {
|
if (this.currentSession) {
|
this.currentSession.terminate();
|
this.updateCallStatus("ended", "通话已结束");
|
this.currentSession = null;
|
}
|
}
|
|
updateStatus(type, text) {
|
console.log(`SIP状态更新: ${type} - ${text}`);
|
if (this.onStatusChange) {
|
this.onStatusChange({ type, text });
|
}
|
}
|
|
updateCallStatus(type, text) {
|
console.log(`通话状态更新: ${type} - ${text}`);
|
if (this.onCallStatusChange) {
|
this.onCallStatusChange({ type, text });
|
}
|
}
|
|
handleIncomingCall(session) {
|
if (session.direction === "incoming") {
|
console.log("来电:", session.remote_identity.uri.toString());
|
if (this.onIncomingCall) {
|
this.onIncomingCall(session);
|
}
|
}
|
}
|
}
|
|
export default new SipService();
|