WXL (wul)
4 天以前 8dfc9189443d7baf2e73d99a73e1b20eefba366e
src/utils/sipService.js
@@ -1,87 +1,443 @@
import JsSIP from 'jssip'
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.ua = null;
    this.currentSession = null;
    this.onStatusChange = null;
    this.onCallStatusChange = null;
    this.onIncomingCall = null;
    this.isRegistered = false; // 新增注册状态标志
    this.registrationTime = null; // 新增注册成功时间戳
    this.currentConfig = null; // 存储当前配置
  }
  // 初始化SIP客户端
  init(config) {
    this.ua = new JsSIP.UA({
      sockets: [new JsSIP.WebSocketInterface(config.wsUrl)],
      uri: config.sipUri,
      password: config.password,
      display_name: config.displayName,
      realm: config.realm,
      ha1: config.ha1,
      register: true
    })
    this.ua.start()
    // 注册事件监听
    this.ua.on('registered', () => {
      console.log('SIP注册成功')
    })
    this.ua.on('registrationFailed', (e) => {
      console.error('SIP注册失败:', e)
    })
    // 监听来电
    this.ua.on('newRTCSession', (data) => {
      this.handleIncomingCall(data.session)
    })
  // 获取医院配置方法
  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) {
    if (!this.ua) {
      console.error('SIP客户端未初始化')
      return
    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客户端未初始化");
        }
    const options = {
      eventHandlers: {
        progress: (e) => console.log('呼叫中...'),
        failed: (e) => console.error('呼叫失败:', e),
        ended: (e) => console.log('通话结束'),
        confirmed: (e) => console.log('通话已接通')
      },
      mediaConstraints: { audio: true, video: false },
      rtcOfferConstraints: { offerToReceiveAudio: 1 }
    }
        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(`sip:${targetNumber}`, options)
    this.setupAudio(this.currentSession)
        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.currentSession = null
      this.currentSession.terminate();
      this.updateCallStatus("ended", "通话已结束");
      this.currentSession = null;
    }
  }
  // 处理音频流
  setupAudio(session) {
    session.connection.addEventListener('addstream', (e) => {
      const audioElement = document.getElementById('remoteAudio')
      if (audioElement) {
        audioElement.srcObject = e.stream
      }
    })
  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())
      // 这里可以触发UI通知
    if (session.direction === "incoming") {
      console.log("来电:", session.remote_identity.uri.toString());
      if (this.onIncomingCall) {
        this.onIncomingCall(session);
      }
    }
  }
}
export default new SipService()
export default new SipService();