解决浏览器 WebSocket 认证难题:豆包语音识别的代理方案实践
最近在给一个内部工具加实时语音转写功能,技术选型时盯上了豆包的流式语音识别接口。原因很简单:它的 WebSocket 接口支持低延迟的增量返回,特别适合做“边说边转”的交互体验,比先录音再上传的 HTTP 方案自然得多。但真开始动手时发现,浏览器直接连豆包 WebSocket 服务,远没有想象中那么简单。认证怎么处理、密钥放哪、跨域怎么绕、连接怎么保活,每一个都是坑。折腾了两天后,我用一个轻量代理服务把整个链路打通了,顺手把过程中踩过的坑和思路整理了出来。
先说清楚这篇文章解决的问题:如果你也想在浏览器里跑豆包语音识别(或者其他任何需要 WebSocket 实时传输且带认证的服务),但又不想把密钥暴露在前端,也不确定 WebSocket 握手阶段的鉴权该怎么设计,那这篇内容正好对症。我会用一个基于 Node.js 的认证代理方案,把浏览器到豆包服务的整条链路拆开讲清楚,包括代码、参数选择和排障经验。不需要你有多深的后端基础,只要会一点 Node.js,能照着把服务跑起来,就能复现整个方案。
1. 问题剖析:为什么浏览器直连豆包 WebSocket 服务这么难
先把问题的根源说透。很多人第一次写 WebSocket 客户端时,觉得无非就是new WebSocket(url)然后收发消息,这个理解本身没错。但一旦接入的是像豆包语音识别这类商业服务,事情就变味了——它不是简单给你一个公开的 WebSocket 地址,而是要求你在握手阶段完成身份认证,并且在后续的数据帧交互中遵循私有协议。这两点叠加在浏览器环境下,就成了三个绕不开的坎。
1.1 前端直连的三大致命问题:密钥暴露、跨域、握手复杂性
第一个问题,也是被我列为“不可妥协”的问题,就是密钥暴露。调用豆包语音识别接口需要 API Key 和 Secret,这是服务的计费凭证。如果你在浏览器代码里写死这些信息,意味着任何能打开你页面的人,按一下 F12 就能从源码或网络面板里把它们扒出来。有人可能觉得“我用的是免费额度,不怕泄露”,但你无法控制别人拿这个凭证去做什么,超量调用产生的账单最后都会落到你头上。密钥必须藏在服务端,这是云服务使用的基本纪律,前端直连模式天然违背后这种安全底线。
第二个问题是跨域限制。WebSocket 虽然不像普通 HTTP 请求那样受同源策略的严格管制,但服务端依然会校验 Origin 头。豆包的服务端在检测到浏览器发起的跨域 WebSocket 连接时,如果发现请求来源不在白名单里,会直接拒绝握手。你当然可以尝试在服务端把 Origin 配置成你的域名,但这里有个尴尬的现实:你的应用可能跑在 localhost、测试域名、多个正式域名上,配置怎么都得动态调整,而且每个环境都得单独去申请,灵活性很差。
第三个问题藏在握手协议里。豆包语音识别的 WebSocket 接入不是浏览器直接传几个参数就能通的,它需要你在建立连接时携带完整的鉴权信息,通常是基于时间戳、随机数和密钥计算出的签名。计算签名要用到 Secret,而 Secret 又必须留在服务端——这就形成了一个死锁:浏览器需要算签名才能连 WebSocket,但算签名需要的密钥又不该在浏览器里出现。这两头堵死,直连方案直接宣告破产。
1.2 认证链路的本质:为什么“带 token 连 WebSocket”并不是小事
有些人会想,那我让后端先发一个临时 token 给前端,前端拿这个 token 去连豆包的 WebSocket 不就行了吗?思路是对的,但不现实。豆包服务并不认识你后端签发的 token,它只认自己的鉴权机制。你用自签 token 去连豆包,对方在握手阶段就给你打回来了。所以正确的思路不是让前端绕过去认证,而是把整个认证过程“托管”给一个中间层。
这个中间层就是代理。代理服务器作为豆包官方服务的合法客户端,持有真正的密钥,负责完成签名计算和握手认证。浏览器的 WebSocket 请求先到达代理,代理验证通过后,代替浏览器与豆包服务建立另一条 WebSocket 连接。之后就进入纯转发模式:浏览器传过来的音频数据帧,经代理原样推给豆包;豆包推回来的识别结果帧,经代理原样转发给浏览器。
把认证职责从客户端剥离到代理端,这不仅是安全考虑,也是架构上的必然选择。即使豆包未来调整认证协议,你也只需要改代理端的实现,前端代码完全不用动,这个维护成本优势在后期的价值会越来越明显。
2. 代理方案的整体设计与认证机制选择
理清思路之后,我先在纸上画了一下整个链路的组成,确定了代理服务的职责边界、认证方案和协议转换策略。这个前置设计阶段花的时间不少,但后面写代码时几乎没有返工。下面把设计要点完整展开。
2.1 代理服务器在设计时承担的职责边界
代理服务不是一个简单的“二传手”,它至少要扛起四件事:
- 连接认证:对浏览器过来的 WebSocket 请求做身份校验,只有携带合法会话凭证的客户端才能连接。
- 上游握手代理:代表浏览器向豆包服务发起 WebSocket 连接,完成签名、鉴权、协议协商等所有握手环节。
- 双向数据转发:把浏览器发来的音频二进制帧和文本控制帧转发到上游,同时把上游返回的识别结果帧转发回浏览器。
- 状态监控与清理:维护每条连接的映射关系,处理异常断开、超时、心跳失败等情况,保证连接生命周期内资源不会泄漏。
我特意把“协议转换”也考虑进去了。因为豆包服务端的帧格式未必和浏览器约定的帧格式完全一致。比如浏览器端可能按 40ms 一个音频块发送,豆包则要求按固定字节数或者带特定头部的帧结构上传。代理在这中间可以充当适配层,把浏览器端的帧格式翻译成上游要求的格式,这样前端代码就能保持逻辑简单,只负责录音和展示结果。
2.2 为什么选择自建代理,而不是官方 SDK 或网关产品
在确定自建代理之前,我确实也考虑过两条替代路线:直接用官方提供的浏览器端 SDK,或者用云厂商的 API 网关服务。先说官方 SDK,它在后端语言环境下确实很好用,封装好了连接、签名、断线重连等逻辑,但那是以 Node.js/Python 后端为假设场景的。浏览器场景下,官方 SDK 反而难落地,因为它的运行环境要求和浏览器安全模型经常打架,比如对全局 fetch 的依赖、对某些头部字段的修改权限限制,都会触发问题。
再看网关产品,比如火山引擎的 API 网关也能做 WebSocket 转发,但在实时语音这种场景下,网关的配置复杂度反而比自建代理高:需要对上游的 WebSocket 子协议做额外配置,要处理比较复杂的签名转发规则,而且每增加一个功能都要去翻网关文档。对于一个小型内部工具来说,自建代理的成本更低——一个 Node.js 文件就能解决,依赖少,逻辑透明,出了问题能直接 debug。
选 Node.js 还有个现实理由:语音识别场景天然是事件驱动的,WebSocket 连接有大量并发但每个连接的数据传输并不密集,Node.js 的单线程事件循环模型刚好匹配这种 IO 密集型负载,写起来比用 Java 或 Go 要轻快得多。生产环境中如果需要更高性能,再换成 Go 重写也不迟,但这个项目规模完全没有必要。
2.3 认证机制的分层设计:客户端会话鉴权和上游服务鉴权分开
这是整个方案中最关键、也最容易被忽视的设计点。我把整个链路拆成两段独立的认证关系:
第一段:浏览器与代理之间。认证凭证是应用自己的用户会话,比如登录后下发的 JWT。浏览器发起 WebSocket 连接时,在 URL 查询参数里带上 JWT,或者在子协议(Sec-WebSocket-Protocol)字段里带上 token。代理收到请求后先校验 JWT 的签名和有效期,通过才继续处理,不通过直接返回 401。
第二段:代理与豆包服务之间。认证凭证是豆包的 API Key 和 Secret。代理在收到浏览器的合法连接后,用自己的密钥计算签名,生成带上游地址的连接参数,主动建立第二段 WebSocket 连接。
为什么要分成两段而不是让代理直接转发浏览器拿到的浏览器 token 去访问豆包?因为两者的认证体系完全不同。代理对内是“看门人”,对外是“合法租户”。这种分层设计还有个额外好处:你可以随时调整对内的认证策略(比如换成 OAuth、短信验证码),完全不影响对外的连接逻辑;反过来,豆包修改了鉴权算法,也只影响代理内部那一段代码。
另外我建议把上游凭证放在环境变量里,用.env文件管理,不要写进代码仓库。虽然这次只是个人项目,但养成这个习惯之后,换一台服务器部署,只需要改环境变量,代码一行都不用动,非常省心。
3. 实操:基于 Node.js 实现完整的 WebSocket 认证代理
设计定稿后,实现阶段反而顺利很多。我尽量把一个能跑起来的最小完整版本分享出来,核心代码不多,但每一行都值得留意,尤其是签名算法那段和转发时序,踩过坑的都知道那里面细节多。
3.1 环境准备与依赖安装
我建议 Node.js 版本不低于 16,较新的 LTS 版本(18 或 20)用起来最顺手。核心依赖只有一个ws库,用来处理 WebSocket 服务器和客户端。另一个用得比较多的是dotenv,用来加载.env环境变量。如果你想省事,也可以直接用 Node.js 内置的--env-file参数替代,但考虑到大多数人的习惯,这里还是用 dotenv 演示。
mkdir doubao-websocket-proxy cd doubao-websocket-proxy npm init -y npm install ws dotenv有两点提前说明:第一,代理服务不依赖 express 这类 HTTP 框架,因为核心逻辑就是 WebSocket 转发,用 Node.js 内置http模块创建 HTTP 服务,再把 WebSocket 服务器挂载到同一个端口上就行,少一个依赖就少一个攻击面。第二,如果你需要提供 HTTPS/WSS 能力,生产环境建议前置一层 nginx 做 SSL 终结,Node.js 层不需要直接处理证书,代理内部只用 ws 协议即可。
3.2 核心实现:双连接状态机与服务端签名计算
先看完整的代理服务器代码,我将它拆成了几段:连接鉴权、上游握手、双向转发、心跳保活。
const WebSocket = require('ws'); const http = require('http'); const crypto = require('crypto'); // ===== 配置区:所有敏感信息和地址统一从环境变量读取 ===== const PORT = process.env.PORT || 8080; const JWT_SECRET = process.env.JWT_SECRET; // 对内签发的 JWT 密钥 const API_KEY = process.env.DOUBAO_API_KEY; // 豆包 API Key const API_SECRET = process.env.DOUBAO_API_SECRET; // 豆包 API Secret const UPSTREAM_WS = process.env.DOUBAO_WS_URL; // 豆包 WebSocket 接入地址在 HTTP 层面,我提供一个用于签发 JWT 的简单接口/api/auth,实际项目中这个接口应该由自己的登录系统替代,签发逻辑需要校验用户名密码。这里为了完整性起见,我用一个固定的内部密钥来签发 JWT,方便演示:
// ===== 签发 JWT(简化版,生产环境请用 jsonwebtoken 库)===== function signJwt(payload, expiresInSec = 3600) { const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url'); const exp = Math.floor(Date.now() / 1000) + expiresInSec; const body = Buffer.from(JSON.stringify({ ...payload, exp })).toString('base64url'); const signature = crypto.createHmac('sha256', JWT_SECRET) .update(`${header}.${body}`) .digest('base64url'); return `${header}.${body}.${signature}`; } function verifyJwt(token) { const [header, body, signature] = token.split('.'); const expected = crypto.createHmac('sha256', JWT_SECRET) .update(`${header}.${body}`) .digest('base64url'); if (expected !== signature) return null; const payload = JSON.parse(Buffer.from(body, 'base64url').toString()); if (payload.exp < Date.now() / 1000) return null; return payload; }这里签名算法是基于标准的crypto.createHmac实现的 HMAC-SHA256,和 JWT 规范完全兼容。生产环境直接用jsonwebtoken库就好,我手写是为了方便你理解 JWT 在 WebSocket 认证场景下是怎么工作的。
接下来是重头戏:豆包 WebSocket 接入需要的服务端签名。实际签名规则依据你申请开通的服务版本而定,大体上都是“时间戳 + 随机字符串 + 密钥做拼接 → 哈希签名”,再把签名和凭证信息放到连接参数里。为了能让你跟着操作,我写一个通用实现,你只需要按你的 API 文档替换拼接格式:
// ===== 生成上游连接签名(以常见的 v2 签名方式为例)===== function buildUpstreamAuthQuery() { const timestamp = Math.floor(Date.now() / 1000); const nonce = crypto.randomBytes(16).toString('hex'); const payload = `${API_KEY}${timestamp}${nonce}`; const signature = crypto.createHmac('sha256', API_SECRET) .update(payload) .digest('hex'); const query = new URLSearchParams({ access_token: API_KEY, timestamp: String(timestamp), nonce, signature, expires_in: '600', // 签名有效期,单位秒,豆包一般要求 600 秒内有效 // 如果你的服务要求传 audio_format、sample_rate 等参数,也在这一步统一拼好 }); return query.toString(); }注意生成签名的payload拼接方式务必和豆包提供的文档保持一致。有的版本要求把expires_in也拼进签名串,有的要求用SHA1而不是SHA256。这部分是上游鉴权的核心,任何一个字段的顺序错位都会导致 401。我在调试阶段就在这上面栽过跟头,后文会细说。
现在实现双 WebSocket 状态机:代理先启动 HTTP 服务,挂载一个 WebSocket server 等待浏览器连接,浏览器连上之后,再触发向上游的连接建立。
// ===== HTTP 服务:提供 JWT 签发接口(演示用)和健康检查 ===== const server = http.createServer((req, res) => { if (req.url === '/api/auth' && req.method === 'POST') { res.setHeader('Content-Type', 'application/json'); // 生产环境需要验证请求体里的用户信息,这里直接签发 res.end(JSON.stringify({ token: signJwt({ role: 'user' }) })); } else if (req.url === '/health') { res.end('ok'); } else { res.writeHead(404); res.end(); } }); const wss = new WebSocket.Server({ server }); wss.on('connection', async (browserWs, req) => { // 1. 从 URL / header / 子协议里取到 JWT 并校验 const { url } = req; const token = new URL(url, `http://${req.headers.host}`).searchParams.get('token'); const payload = verifyJwt(token); if (!payload) { browserWs.close(4001, 'invalid token'); return; } // 2. 通过校验后,代理作为客户端连接上游豆包服务 try { const upstreamUrl = `${UPSTREAM_WS}?${buildUpstreamAuthQuery()}`; const upstreamWs = new WebSocket(upstreamUrl, { headers: { 'Origin': 'https://your-backend-domain.com' } }); // 3. 双向转发 browserWs.on('message', (data) => { // 浏览器传来的可能是二进制音频帧,或 JSON 控制帧 if (upstreamWs.readyState === WebSocket.OPEN) { upstreamWs.send(data, { binary: true }); } }); upstreamWs.on('message', (data) => { if (browserWs.readyState === WebSocket.OPEN) { browserWs.send(data, { binary: true }); } }); browserWs.on('close', () => upstreamWs.close()); upstreamWs.on('close', () => browserWs.close()); upstreamWs.on('error', (err) => { console.error('upstream error:', err); browserWs.close(4002, 'upstream error'); }); } catch (e) { console.error('upstream connect failed:', e); browserWs.close(4002); } }); server.listen(PORT, () => { console.log(`Proxy listening on port ${PORT}`); });这段代码的核心思路用一个词就能概括:转发器。代理不关心消息内容,只确保消息在两个端之间可靠流动。代码里几个细节值得留意:
第一,browserWs.send(data, { binary: true })这里第二参数显式指定二进制传输,确保音频帧原样到达上游。WebSocket 在传输 Blob 和 ArrayBuffer 时的表现有差异,这里我们统一在上游连接发送时标记为二进制,减少解析歧义。
第二,上游握手时我传了Origin头。有些 WebSocket 服务端会校验这个字段,默认的 Node.jsws客户端不会自动携带浏览器环境下那种 Origin 头,所以显式设置可以避免一部分来源校验失败的问题。
第三,代理内建立的是两个独立连接:浏览器到代理是一条,代理到上游是一条。千万别只建立一条连接然后在客户端和服务端之间做纯透传,那样上游的认证信息就暴露给了浏览器,整个方案就失去了意义。
3.3 浏览器客户端如何接入:录音、连接、发送
聊完服务端,再看看浏览器端怎么把音频送出去并接收识别结果。这段我用纯原生 Web 技术实现,没有引入额外的 SDK,只做三件事:调用 getUserMedia 录音、建立 WebSocket 连接、按音频帧定时发送数据。
const WEBSOCKET_PROXY_URL = 'wss://your-proxy-domain.com/ws'; async function startRecognition() { // 1. 从代理后端的认证接口获取 JWT const resp = await fetch('/api/auth', { method: 'POST' }); const { token } = await resp.json(); // 2. 建立 WebSocket 连接,token 放在查询参数里 const ws = new WebSocket(`${WEBSOCKET_PROXY_URL}?token=${encodeURIComponent(token)}`); ws.binaryType = 'arraybuffer'; // 保证二进制数据以 ArrayBuffer 形式接收 ws.onopen = async () => { // 3. 打开麦克风,开始采集音频 const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const mediaRecorder = new MediaRecorder(stream); mediaRecorder.ondataavailable = (event) => { if (event.data.size > 0 && ws.readyState === WebSocket.OPEN) { ws.send(event.data); } }; // 每 250ms 取一次音频数据块,避免发送太密集 mediaRecorder.start(250); }; ws.onmessage = (event) => { const data = event.data; // 上游返回的轮数。是文本帧:JSON 字符串;是音频帧:二进制 if (typeof data === 'string') { const result = JSON.parse(data); if (result.recognized_text) { console.log('识别结果:', result.recognized_text); // 更新页面 UI,展示流式结果 } } // 如果你想让代理做某种音频格式转换,则二进制帧走其他分支 }; ws.onclose = (e) => { console.log('连接关闭:', e.code, e.reason); }; ws.onerror = (err) => { console.error('WebSocket 错误:', err); }; } // 页面加载完成后启动识别 startRecognition();浏览器端我特意用了MediaRecorder,因为它是原生的录音 API,不需要封装底层音频格式的代码。mediaRecorder.start(250)里的 250 是时间片,每 250ms 触发一次ondataavailable,把这段时间内采集的音频作为 Blob 发出。这个间隔可以根据识别实时性需求调整,我测试下来 200~300ms 是比较均衡的取值:太短了音频包过小、网络开销变大;太长了识别首字返回变慢,交互感变差。
需要提醒的是,MediaRecorder默认输出的是 webm/opus 格式,而豆包服务可能要求特定的编码格式,比如 16kHz 16bit 的 PCM 或 opus。如果你的服务端不支持 webm,就得在代理层做音频转码,或者浏览器端改用 AudioWorklet 采集 PCM 数据直接发送。这属于后话,但你要有这个概念。
3.4 关键参数的解释与调整建议
在调试整个链路时,有几个参数直接决定服务能不能跑通,我列成一张表方便你对照检查:
| 参数 | 建议值/说明 | 常见错误 |
|---|---|---|
expires_in | 600 秒 | 签名过期后继续请求,上游返回 401 |
| 非对称签名算法 | 取决于服务文档 | 用错算法(SHA1 vs SHA256)导致签名不匹配 |
| 音频采样率 | 与上游服务要求一致 | 16k 音频发给要求 8k 的服务,识别率大幅下降 |
ws.binaryType | 'arraybuffer' | 默认'blob'导致二进制帧解析类型不匹配 |
| WebSocket 心跳间隔 | 30~60 秒 | 长时间不发数据,连接被服务端或中间节点回收 |
| 浏览器发送间隔 | 200~300ms | 过密或过疏都会影响实时体验 |
其中有几个参数值的选定逻辑我说明一下。expires_in取 600 秒主要是考虑到语音识别是短连接场景,单次识别流程基本不会超过 10 分钟,600 秒既能覆盖完整过程,又避免签名长期有效带来的安全隐患。binaryType设置为arraybuffer是因为我们要对二进制音频帧做转发,在浏览器端直接按字节来操作比处理 Blob 方便。心跳间隔 30 秒是安全的保守值,后续我会在踩坑部分讲它带来的问题。
4. 常见问题与排查技巧实录
这部分是整个实践过程中最值得沉淀的内容。我在调试期间至少踩了六七个不同的坑,有些看一眼报错就能定位,有些查了半天才回过神来。我把典型问题按场景整理成速查表,再把几个调试心得写成统一经验。
4.1 握手阶段反复出现 401/403:签名算法与参数顺序是头号嫌疑
无论是浏览器到代理这一段报 401,还是代理到豆包上游这一段报 401,都需要先把你的排查范围缩窄。浏览器到代理的 401,优先检查 JWT 是否过期、签名密钥是否一致、token 是否被 URL 编码后又被服务端解码。我曾经遇到过一个很隐蔽的问题:浏览器端用encodeURIComponent(token)把 token 拼入 URL,但服务端从URLSearchParams取值时已经自动解码一次,而原始 token 字符串里如果含有=(Base64 常见的填充符),在传输过程中很容易被截断或替换,导致签名校验失败。解决方法是连接成功后立刻检查服务端日志里的req.url,看 token 是否完整。
代理到上游的 401 则要按签名参数逐项核对:时间戳是不是服务器当前时间、nonce 是不是每次连接唯一、签名的字符串拼接顺序是不是和文档完全一致。签名算法的调试绝不能靠肉眼比对,我建议写一个独立脚本,把你要签名的 payload 和算出的 signature 打印出来,再用豆包官方提供的调试工具或者样例代码生成的签名做对照。如果官方工具用的是 Python,而你用的是 Node.js,那么最容易踩的坑就是二进制编码不一致——比如 Python 默认对字符串做 UTF-8 编码,而 Node.js 的Buffer默认也是 UTF-8,但如果有一方用了utf16le或者加了 BOM,结果就完全对不上。
4.2 连接频繁断开:心跳机制不是可选项,而是保障项
跑通链路只是第一步,让它稳定工作才是真正的考验。我遇到过的最棘手问题是 WebSocket 连接会在运行 1~2 分钟后自动断掉,客户端毫无征兆地触发onclose。排查网络环境、代理配置都没有发现问题,最后仔细看豆包服务的接入文档才发现,上游服务要求客户端在空闲时定期发送心跳包,否则会认为连接已死亡并主动关闭。
心跳机制在 WebSocket 场景下的实现方式有很多种。豆包这类 AI 服务通常约定固定格式的心跳消息,比如:{"type": "heartbeat", "timestamp": 1710000000}。你需要在前端或代理端维护一个定时器,定期发送这个心跳帧。我选择在浏览器端做心跳,因为上游服务要收的是“最终客户端”的心跳,而代理的转发是透明的,浏览器直接发心跳帧就能经过代理送到上游,逻辑最简单。
实现上,在ws.onopen之后设置一个setInterval,每 30 秒发送一次心跳:
let heartbeatTimer; ws.onopen = () => { // 其他初始化逻辑... heartbeatTimer = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'heartbeat', timestamp: Date.now() })); } }, 30000); }; ws.onclose = () => { clearInterval(heartbeatTimer); };值得注意的一个细节是,心跳定时器与 WiFi 省电模式的冲突。移动设备或笔记本在闲置时会进入省电状态,导致定时器被系统节流,心跳间隔被拉长到 60 秒以上。此时服务端的超时判定就可能生效。应对办法是把间隔设在服务端超时时间的一半以下(比如服务端 60 秒超时,心跳间隔就设 25 秒),并允许客户端在检测到onclose时自动重连。
4.3 浏览器端跨域与混合内容限制:两个容易忽略的前置条件
如果你把代理服务部署在http://localhost:8080,而页面本身是https://your-app.com,浏览器会直接阻止你向ws://地址发起 WebSocket 请求——这就是混合内容限制(Mixed Content)。解决方式很简单:让你的代理服务提供 WSS 能力,或者更稳妥的做法是,用 nginx 反代同时暴露你的应用页面和 WebSocket 代理,让它们共享同一个 HTTPS 域名,这样既没有跨域问题,也没有混合内容警告。
另一个容易忽略的点是,代理 WebSocket 服务端本身也要对来自不同域名的Origin做配置。默认ws库不会校验 Origin,但它会在握手时把这个头带上,如果你的代理部署在某个平台上而被扫描到存在 CSWSH(Cross-Site WebSocket Hijacking)风险,那就不太好了。建议在wss.on('connection')里主动校验 Origin:
const ALLOWED_ORIGINS = new Set(['https://your-app.com', 'http://localhost:3000']); wss.on('connection', (ws, req) => { const origin = req.headers.origin; if (!origin || !ALLOWED_ORIGINS.has(origin)) { ws.close(4003, 'origin not allowed'); return; } // 后面的正常逻辑 });4.4 音频帧格式与采样率不匹配:识别率突然拉胯的隐形原因
如果连接都正常,但识别结果总是乱的、识别率极低,排查方向多半在音频编码格式上。浏览器MediaRecorder默认输出 webm/opus 编码,而豆包语音识别服务对输入音频的编码格式是有明确要求的,有的版本支持 opus,有的只接受 PCM。如果你的代理直接把这些 webm 数据帧一股脑推给上游,可能遇到两种情况:要么上游直接报错,要么更糟糕——服务端把数据当作 PCM 解析,结果识别出一堆无意义字符。
对策是在代理层加入音频转码。最直接的方法是引入 FFmpeg,在代理接收浏览器音频帧后,先转成上游要求的 16k 16bit PCM,再发送到豆包。但为了控制文章篇幅和实现的复杂度,我更建议在浏览器端就解决编码问题:使用 AudioWorklet 直接采集 PCM 而不是依赖 MediaRecorder。好处是既避免了转码环节,又能精准控制采样率,缺点是要额外写一段音频采集代码,复杂度和 MediaRecorder 不是一个量级。这部分我在后面的项目迭代中会拆开细讲,如果你现在就有这个需求,可以优先考虑引入成熟的 WebAudio 录制库。
4.5 常见问题速查表
把调试中积累的问题和直接解决方案整理成一个速查表,方便你遇到同类问题时快速定位:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 代理返回 401 | JWT 过期或签名密钥不匹配 | 检查 JWT 的 exp 字段,检查 JWT_SECRET 环境变量 |
| 代理连接成功,上游立即关闭 | 上游签名错误或过期 | 核对签名算法、参数拼接顺序、时间戳非 nounce 唯一性 |
| 连接无规律断开 | 未发送心跳或心跳间隔过长 | 按服务端超时时间的 1/2 设置心跳间隔,添加重连逻辑 |
| 识别结果杂乱 | 音频编码格式不匹配 | 统一到上游要求的编码格式,参考服务文档调整采样率 |
| 浏览器访问 ws:// 被阻止 | HTTPS 页面连接非加密 WebSocket | 通过 nginx 提供 WSS 反向代理 |
| 生产环境突然全部失败 | 上游服务调整了签名规则 | 保留日志,检查最新服务文档,更新代理签名逻辑 |
| 多个浏览器同时连接时,部分连接异常 | 代理未做并发连接数控制 | 为代理增加连接数上限和负载情况监控 |
4.6 一个值得养成的调试习惯:建一个“裸脚本”先验证上游
在正式写代理服务之前,我强烈建议你先用 Node.js 写一个不含浏览器相关逻辑的最小脚本,直接以服务器身份连接豆包 WebSocket 服务,把签名、握手、音频发送、结果接收这四步跑通。这个脚本调试起来比浏览器里调试快很多,因为你可以随时打印原始报文、查看二进制帧的内容、改签名参数后立即重跑。等这个脚本稳定之后,再把逻辑迁入代理服务,届时大多数让浏览器环境的干扰因素已经被提前排掉了。
我在实践里就是先用一个 30 行脚本做连通性测试,脚本里直接用本地文件里的音频数据发送,确认上游能返回识别结果,才开始搭代理。实践证明,这个前置验证至少帮我省了两个小时的浏览器端调试时间。
结尾
做完整个项目,我个人最大的体会是:在浏览器里对接 WebSocket 类 AI 服务,真正难的往往不是 WebSocket 本身,而是认证体系的迁移。把认证从客户端迁移到代理端,不只是安全上的妥协,更是架构合理性的选择。代理方案让浏览器端代码保持简洁,让敏感凭证安全地待在服务端,也让上游协议的变化被隔离在一条窄边界内,这是我在实践中得到的最核心的经验。
如果你接下来要接的不止豆包语音识别,而是多个需要认证的 WebSocket 服务,可以顺着这个代理思路继续扩展。把上游连接的身份验证、心跳保活、转发逻辑抽象成一个可配置的池子,每个服务对应一套连接配置,就能复用同一套代理框架接入大量不同服务。我这次只是做了一步基础验证,后续如果有新的心得再继续分享。