终端机与POS后台系统的前端语音通话系统实践

440 阅读9分钟

概述

本项目实现了一个基于WebRTC技术的语音通话系统,用于终端机与pos-web后台系统之间的实时语音通信。该系统由前端和后端两部分组成,前端负责用户界面和音视频捕捉,后端负责信令服务器的搭建和管理通信过程中的逻辑。整个项目由独立的Node.js服务器作为信令服务器,并结合PeerJS实现WebRTC连接。

技术选型

1. 基于 WebRTC 的自建方案

  • WebRTC (Web Real-Time Communication)
    • 特点: 提供点对点的实时语音、视频和数据传输。完全开源,不依赖第三方服务。
    • 优点: 高度可定制化,没有使用限制或费用。适合对通话质量和延迟要求较高的场景。
    • 缺点: 需要自己实现信令服务器和 NAT 穿透等复杂功能。对网络条件的处理能力较弱,开发难度较高。
  • 技术栈
    • 前端:使用 WebRTC 的 API(如 RTCPeerConnectiongetUserMedia 等)
    • 后端:使用信令服务器(如 Socket.io、PeerJS 服务器)来进行会话协商
  • 推荐库和工具
    • PeerJS: 简化 WebRTC 连接的 JavaScript 库
    • SimpleWebRTC: 一个简单易用的 WebRTC 库

2. 第三方实时音视频 SDK

  • Agora.io(声网)

    • 特点: 提供高质量的音视频通话、直播、消息传递等功能。API 丰富,覆盖多种平台。
    • 优点: 快速集成,高稳定性,拥有全球节点和优质的 QoS 服务。
    • 缺点: 需要购买服务,部分功能可能有使用限制。
    • 适用场景: 语音通话、视频会议、在线教育、语音聊天室等。
  • 腾讯云、阿里云音视频通话服务

    • 特点: 针对国内市场优化,提供丰富的音视频通话功能。
    • 优点: 在国内有很好的网络覆盖和服务质量,易于集成。
    • 缺点: 国内市场为主,国外服务可能略差。
  • Twilio

    • 特点: 全球范围内的通讯平台服务,提供短信、语音、视频等多种通讯服务。
    • 优点: API 简单易用,文档丰富,支持多种编程语言。
    • 缺点: 费用较高,部分地区服务稳定性可能欠佳。
  • Vonage (原 Nexmo)

    • 特点: 提供语音、视频、短信和电话验证等通讯 API。
    • 优点: 简单易用,支持多种通讯方式。
    • 缺点: 费用较高,可能需要国际化服务时进行选择。

3. 选型考虑因素

  1. 项目需求

    • 是否需要支持多端(Web、移动端、桌面端)。
    • 是否需要支持全球范围内的高质量通话。
    • 局域网内通话的可行性
  2. 开发周期和成本

    • 自建方案开发周期长,但无长期费用。
    • 第三方 SDK 集成快,但有长期费用。
  3. 稳定性和可扩展性

    • 需要考虑网络抖动、延迟、丢包等因素对通话质量的影响。
  4. 隐私和数据安全

    • 自建方案可完全控制数据流向。
    • 第三方方案需评估数据安全和隐私政策。

项目架构

1. 前端架构

  • 框架/库: React、peerjs@1.3.1

  • 功能模块:

    • 通话界面: 包含通话按钮、挂断按钮、通话状态显示等。
    • 音视频捕捉: 使用navigator.mediaDevices.getUserMedia获取本地音频流。
    • WebRTC连接管理: 使用PeerJS管理Peer-to-Peer连接,处理呼叫、应答、挂断等事件。
    • 用户交互: 通过事件处理用户的通话操作,例如接听、挂断等。

2. 后端架构

  • 框架/库: Nest.js、Express、peerjs-server、WebSocket、SSE(Server-Sent Events)

  • 功能模块:

    • 信令服务器: 通过peerjs-server搭建信令服务器,管理用户的连接请求,维护客户端的连接状态。
    • 音视频数据传输: 信令服务器通过PeerJS将用户之间的音视频数据传递给相应的对端。
    • 实时消息推送: 使用SSE向客户端推送系统状态、通话信息等实时消息。
    • 日志管理: 记录系统日志,用于调试和系统维护。
    • 管理员状态管理: 维护管理员在线、忙碌、离线、等待状态。

系统流程

  1. 通话初始化:

    • 终端用户通过点击“呼叫”按钮发起呼叫请求。
    • 前端调用PeerJS的peer.call()方法发起通话请求,信令服务器通过WebSocket向目标终端传递通话请求。
  2. 信令交换:

    • 信令服务器在接收到通话请求后,向目标终端发送呼叫请求信息。
    • 目标终端通过点击“接听”按钮调用peer.answer()方法,接听通话。
  3. 音视频传输:

    • 通话建立后,双方开始通过WebRTC传输音频数据。
    • 终端用户和POS后台可以进行实时语音对话。
  4. 通话结束:

    • 用户点击“挂断”按钮,调用peer.disconnect()方法,通话结束。
    • 信令服务器通知双方断开连接。
  5. 实时消息推送:

    • 后端通过SSE推送通话状态、系统消息到前端,前端根据消息更新界面。

开发与调试

关键技术点

  1. WebRTC:

    • 主要用于点对点音视频通信。
    • 通过navigator.mediaDevices.getUserMedia获取音频流,并将流对象传递给PeerJS进行连接。
  2. PeerJS:

    • 简化了WebRTC的信令交换过程,提供简单易用的API来管理Peer-to-Peer连接。
    • 使用peer.call()peer.answer()方法进行呼叫和应答。
  3. Node信令服务器:

    • 通过peerjs库搭建信令服务器,负责管理通话过程中的信令交换。
    • 使用WebSocket管理用户的连接状态,并通过SSE向客户端推送实时消息。
  4. SSE(Server-Sent Events) :

    • 用于后端向前端推送实时消息,例如通话状态、系统提示等。
    • 前端使用EventSource API接收来自后端的实时消息流。
  5. 测试通话功能:

    • 模拟两个用户终端,分别在不同的浏览器或设备上打开前端页面。
    • 发起通话请求,检查通话是否正常连接、声音是否清晰。
  6. 信令服务器调试:

    • 使用console.logwinston等日志工具记录信令服务器的各种事件。
    • 检查WebSocket连接状态,确保信令交换过程顺畅。
  7. SSE消息调试:

连接服务器发起呼叫

const localPeerId = 'test-id'
// 自己搭建的信令服务器
const peerInstance = new Peer(localPeerId, {
    host: '127.0.0.1',  // 你的 PeerJS 服务器地址
    port: 5501,         // 服务器端口
    path: '/',          // 服务器路径
    secure: true        // 如果是 https 连接则设置为 true
});

// 也可以使用官方免费的服务器
const peerInstance = new Peer(localPeerId);
呼叫方(终端机代码)
import React, { useEffect, useRef, useState } from 'react';
import Peer from 'peerjs';

const Caller = () => {
  const [peer, setPeer] = useState(null);
  const [remotePeerId, setRemotePeerId] = useState('');
  const localStreamRef = useRef();
  const remoteAudioRef = useRef();
  const [call, setCall] = useState(null);
  const [isCalling, setIsCalling] = useState(false);

  useEffect(() => {
    // 初始化 Peer 实例
    const peerId = 'caller-peer-id'; // 呼叫方的自定义 Peer ID
    const peerInstance = new Peer(peerId, {
      host: 'your-peerjs-server-host',  // 你的 PeerJS 服务器地址
      port: 5501,                       // 服务器端口
      path: '/',                        // 服务器路径
      secure: true                      // 如果是 https 连接则设置为 true
    });

    peerInstance.on('open', (id) => {
      console.log('Caller Peer ID: ' + id);
      setPeer(peerInstance);
    });

    peerInstance.on('call', (incomingCall) => {
      console.log('呼叫方不处理接听逻辑');
    });

    return () => {
      peerInstance.destroy();
    };
  }, []);

  const startCall = () => {
    if (!remotePeerId) {
      alert('请输入接收方的 Peer ID');
      return;
    }

    navigator.mediaDevices.getUserMedia({ video: false, audio: true })
      .then((stream) => {
        localStreamRef.current = stream; // 保存本地音频流

        const outgoingCall = peer.call(remotePeerId, stream); // 发起呼叫
        setIsCalling(true);
        outgoingCall.on('stream', (remoteStream) => {
          remoteAudioRef.current.srcObject = remoteStream; // 播放接收方的音频流
        });

        setCall(outgoingCall);
      })
      .catch((err) => {
        console.error('获取本地音频流失败:', err);
      });
  };

  const endCall = () => {
    if (call) {
      call.close();
      setCall(null);
      setIsCalling(false);
    }
  };

  return (
    <div>
      <h2>呼叫方</h2>
      <div>
        <label>接收方 Peer ID: </label>
        <input
          type="text"
          value={remotePeerId}
          onChange={(e) => setRemotePeerId(e.target.value)}
        />
        <button onClick={startCall} disabled={isCalling}>呼叫</button>
        <button onClick={endCall} disabled={!isCalling}>挂断</button>
      </div>
      <div>
        <h3>远程音频</h3>
        <audio ref={remoteAudioRef} controls autoPlay />
      </div>
    </div>
  );
};

export default Caller;
接听方(POS代码)
import React, { useEffect, useRef, useState } from 'react';
import Peer from 'peerjs';

const Receiver = () => {
  const [peer, setPeer] = useState(null);
  const localAudioRef = useRef();
  const remoteAudioRef = useRef();
  const [call, setCall] = useState(null);

  useEffect(() => {
    // 初始化 Peer 实例
    const peerId = 'receiver-peer-id'; // 接听方的自定义 Peer ID
    const peerInstance = new Peer(peerId, {
      host: '127.0.0.1',  // 你的 PeerJS 服务器地址
      port: 5501,         // 服务器端口 https 默认443端口
      path: '/',          // 服务器路径
      secure: true        // 如果是 https 连接则设置为 true port默认443
    });

    peerInstance.on('open', (id) => {
      console.log('Receiver Peer ID: ' + id);
      setPeer(peerInstance);
    });

    peerInstance.on('call', (incomingCall) => {
      navigator.mediaDevices.getUserMedia({ video: false, audio: true })
        .then((stream) => {
          localAudioRef.current.srcObject = stream; // 播放本地音频流
          incomingCall.answer(stream); // 接听呼叫
          incomingCall.on('stream', (remoteStream) => {
            remoteAudioRef.current.srcObject = remoteStream; // 播放呼叫方的音频流
          });
          setCall(incomingCall);
        })
        .catch((err) => {
          console.error('获取本地音频流失败:', err);
        });
    });

    return () => {
      peerInstance.destroy();
    };
  }, []);

  const endCall = () => {
    if (call) {
      call.close();
      setCall(null);
    }
  };

  return (
    <div>
      <h2>接听方</h2>
      <div>
        <h3>本地音频</h3>
        <audio ref={localAudioRef} controls autoPlay muted />
      </div>
      <div>
        <h3>远程音频</h3>
        <audio ref={remoteAudioRef} controls autoPlay />
      </div>
      <button onClick={endCall} disabled={!call}>挂断</button>
    </div>
  );
};

export default Receiver;

信令服务器代码(极简版)

const express = require("express");
const { ExpressPeerServer } = require("peer");
const app = express();

app.enable("trust proxy");

const PORT = process.env.PORT || 9877;
const server = app.listen(PORT, () => {
	console.log(`App listening on port ${PORT}`);
});

app.get("/", (req, res) => {
  console.log("Hello 音视频通话测试!");
  res.json({
    name: "测试",
    description: "音视频通话测试 https证书 域名验证 终端机环境验证",
    website: "https://www.xxxx.com",
  });
});

const peerServer = ExpressPeerServer(server, {
	path: "/",
  key: 'CIMC_PEER',
});

app.use("/", peerServer);

module.exports = app;

部署与配置

前端部署:

  • 将打包后的静态文件部署到Web服务器。
  • 配置Nginx或其他Web服务器进行静态资源托管。

后端部署:

  • 使用Node.js搭建信令服务器,监听特定端口(如5501)。
  • k8s部署。
  • 如果使用HTTPS,需要配置SSL证书。

Nginx配置:

  • 配置Nginx进行反向代理,将WebSocket和SSE请求转发到Node.js服务器。
  • 例如:
location /VoIP/ {
    rewrite ^/VoIP/(.*) /$1 break;  
    proxy_http_version 1.1;  
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "Upgrade";
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_cache_bypass $http_upgrade;
    proxy_redirect off;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_pass http://parking-terminal-server:5501/;
}
location /peerlog {
    # 保持连接以支持 Server-Sent Events
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    
    # 代理到 PeerJS 服务器上的 SSE 端点
    proxy_pass http://parking-terminal-server:5501/sse/events;
    
    # 设置 SSE 所需的特定头部
    proxy_buffering off;  # 禁用缓冲
    proxy_cache off;  # 禁用缓存
    proxy_http_version 1.1;  # 使用 HTTP 1.1 保持连接
    proxy_set_header Connection keep-alive;  # 保持连接以支持 SSE
    add_header Cache-Control no-cache;  # 禁用缓存
}

常见问题

  1. 音频流获取失败:

    • 检查浏览器是否授予麦克风权限。
    • 确保HTTPS协议被正确使用。
  2. WebSocket连接失败:

    • 确保信令服务器和前端WebSocket URL一致。
    • 检查Nginx的WebSocket代理配置是否正确。
  3. SSE消息丢失或无法接收:

    • 检查Nginx的SSE代理配置是否正确,确保没有缓存。
    • 确保前端EventSource没有被意外关闭。
  4. 信令超时问题:

    • P2P的点对点模式在发起请求收短时间内没有接听
  5. https相关问题

    • 信令服务器是https而客户端使用http访问会出现网络异常
    • 获取音视频权限必须使用https,node服务正常开发,上线的时候在nginx层配置处理
  6. 信令超时问题

结语

本系统为终端机器与POS后台提供了稳定的语音通话解决方案,采用WebRTC和Node.js信令服务器相结合的技术栈实现了实时通信和消息推送功能。未来可以进一步扩展视频通话、多终端会议等功能,为用户提供更加丰富的应用场景。