Skip to content

一代机录音上行接入 WebSocket API

一代机(DYJ V1.6,安卓盒子 App dyj-app-v2)录音数据的接入点,协议兼容老云 hardware-server/api/ws/record(对齐设备侧真实实现),落地到该设备绑定用户的「个人默认项目」存储里。

验证状态(如实声明):代码完成 + mock/模拟集成测试验证,未经真实一代机硬件端到端验证。 一代机通过 WiFi + WebSocket 直连主后端(不经手机 App、不经浏览器 Web Bluetooth),前端 DeviceTestDrawer.vue(Web Bluetooth API)服务的是二代机蓝牙直连场景,无法用来验证本端点。 真机验证需要项目负责人在设备在场、且 dyj-app-v2 完成下方"设备侧接入建议"之后,用真实设备 走一遍完整流程。权威设计文档:plans/2026-07-03-dyj-gen1-phase2b-recording-ingest.md(含 4 轮 Codex 审查修订记录,边界情况和取舍理由都在文档里)。

基础信息

  • 端点WS /api/ws/record(Bun 原生 WS 升级路由,绕过 Hono 中间件栈)。同域反代场景 (如把后端挂在 /api 前缀、nginx proxy_pass 末尾带 / 做 strip,例如树莓派节点的 infra/raspberry/agent/nginx-raspberry.conf.tpl)下,请求到达后端时前缀已被去掉, 后端同时接受 /ws/record(去前缀路径)——不需要额外配置,两种路径都能连上。
  • 鉴权device-id 必须能在 hw_user_devices 解析出 is_active=true 且所属产品型号 generation='gen1' 的绑定记录(强制层,join hw_product_models 校验世代——已绑定的二代机 或其它世代设备不能打开本端点);token(一代机 legacy Bearer JWT,见 一代机激活 API)可选携带,带了就必须验证通过且 payload.deviceIddevice-id 一致,否则拒绝(不存在"带了但验证不过仍放行")。
  • 落地目标:设备绑定用户的「个人默认项目」(ensure_personal_default_project RPC,与移动端录音 上传共用同一落点,参见项目仓库 docs/features/mobile-default-project-upload.md)。

连接参数(Header)

Header必填说明
device-id设备 hardware_id;缺失或未在 hw_user_devices 命中绑定记录都会被拒绝
data-format音频编码格式,见下方枚举;未知格式直接拒绝(不兜底猜测)
session-id会话 ID;缺失时服务端自动生成
scene录音场景(自由文本,写入 files.metadata.deviceMetadata.scene,不做枚举校验)。设备侧
当前实际发送 file-record(一次性文件上传,非流式实时录音)
original-name原始文件名
token一代机 legacy Bearer JWT,见上方鉴权说明

data-format → MIME 映射

data-formatMIME说明
aacaudio/aac设备侧当前实际发送的格式dyj-app-v2FileUploadHandler.kt 硬编码)
apmaudio/x-adpcm与移动端录音上传既有约定一致,预留兼容
mp3audio/mpeg
wavaudio/wav
webmaudio/webm
pcmaudio/pcm

其余一律拒绝升级(400)。

连接流程

  1. 客户端发起 WS 升级请求,带上述 Header。
  2. 服务端按顺序校验:data-format 映射 → 限流(per-IP/per-device 滑窗)→ 设备绑定 → token 可选校验 → 团队存储配额预检 → 单设备并发占位(同一设备同时只允许 1 个活跃连接)。 任一环节失败,升级请求被拒绝(见下方错误码),不建立 WS 连接
  3. 全部通过后,服务端立即(无条件)发送一条 TEXT 帧:
    json
    { "status": "ready" }
    这一步是必需的:设备侧 FileUploadHandler 会等待这条消息最长 15 秒才开始发送数据, 不发送等于设备侧上传必然超时失败。
  4. 客户端持续发送二进制音频帧(不限制单帧大小,服务端按累计字节数控制上限)。服务端维护一个 滚动式空闲/停滞超时:ready 握手发出后、以及此后每收到一个非空二进制帧,都会重新起 30 秒倒计时;只要数据持续流动(真实设备分片间隔 ~20ms,远小于 30 秒)就不会触发;一旦 停滞满 30 秒(不管是从未发过数据,还是发过一帧后转入静默)连接就会被主动断开。
  5. 客户端以正常关闭码(WS close code 1000)主动关闭连接ws.close())才代表这段录音 正常结束,服务端据此落地文件。非正常关闭(网络中断、客户端异常退出、连接被服务端因空闲/ 超限主动断开等)不会把已收到的部分数据当成完整录音落地——避免截断录音被误当作完整文件 写入用户项目。

数据落地

连接以正常关闭码(1000)结束时,服务端:

  1. 若累计接收 0 字节,视为空连接(可能是握手失败后设备重连/健康探测),不落地、不报错。
  2. 复核团队存储配额(升级阶段预留的额度可能因同团队其他并发连接过期),超限则不落地,只记日志。
  3. 调用与 POST /storage/:projectId/upload 相同的底层落地原语(storageService.uploadFile),写入 files 表:
    • project_id = 该设备绑定用户的个人默认项目
    • metadata.source = "hw_gen1_recording"
    • metadata.sourceRef = 设备 hardware_id
    • metadata.deviceMetadata = { sessionId, scene, dataFormat, originalName, hardwareId }

数据消费:当前平台没有"文件上传自动触发某工作流"的系统级钩子(只有 knowledgeBaseTag 这一种需要显式打标签的现成机制,且当前不支持音频 MIME)。落地的文件走的是与移动端录音上传 完全相同files 表 + metadata.source/sourceRef 标签约定,任何下游消费方(工作流、 未来的 KB 音频支持、管理员批处理脚本)都能凭 files.metadata->>'source' = 'hw_gen1_recording' 做 JSONB 查询稳定定位到这些文件——本端点刻意不预先猜测"要不要转写、通知谁"这类还未拍板的 产品决策,只保证数据落点与平台既有模型对齐。当前平台没有现成的按 source/sourceRef 过滤的 REST 查询端点,下游需直接查 DB 或未来另行补充查询接口。

限制

项目说明
单连接最大字节数min(50MB, 套餐 maxFileSize, 团队剩余存储配额)升级阶段一次性算好;超限主动关闭连接(1009),已收到的部分不落地
单设备并发连接数1对齐设备侧 WebSocketAudioSender 单例、一次一连接的真实实现;并发占位持续到落地上传完成才释放,快速断线重连也不会绕过
空闲/停滞超时30 秒,滚动式ready 握手后、以及此后每收到一个非空二进制帧都重新起 30 秒倒计时;持续无数据满 30 秒(无论是从未发过数据、还是发过一帧后转入静默)连接即被主动关闭(1008),回收并发占位
进程级全局并发连接数默认 10(DYJ_GEN1_RECORD_MAX_GLOBAL_CONCURRENT 可调)保护后端进程内存;单连接峰值内存 ≈ 2 倍单连接字节上限(buffer 累积 + 落地时的 concat 副本)
限流per-IP/per-device 滑窗,默认 20/min · 10/min(DYJ_GEN1_RECORD_RATE_LIMIT_* 可调)与一代机激活接口使用同一限流算法但独立实例/阈值

升级拒绝错误码

客户端收到的 HTTP 响应体只有通用状态文案(如 Forbidden/Unauthorized),不包含下表的具体 原因——详细原因只进服务端结构化日志(ws_record_upgrade_rejected),避免未认证调用方通过响应 差异枚举设备绑定状态或服务端配置细节。下表供后端排障/服务端日志对照用。

状态码原因(服务端日志)说明
400missing_device_id / missing_data_format / unknown_data_format必填 Header 缺失或格式不识别
401token_invalid:* / token_requiredtoken 校验失败,或 DYJ_GEN1_RECORD_REQUIRE_TOKEN=true 时未带 token
403device_not_bounddevice-id 未在 hw_user_devices 命中 is_active=true 且所属产品型号 generation='gen1' 的绑定记录(二代机等其它世代设备即使已绑定也会被拒绝——本端点是一代机专属协议)
409device_busy该设备已有活跃连接
413quota_exhausted团队存储配额已满
429rate_limited触发限流
500device_lookup_failed / project_provision_failed设备绑定查询失败,或落点项目创建/查询失败(基础设施故障,非业务拒绝)
503global_limit进程级全局并发连接数已达上限

不落库(无 DB 审计表,见下方安全边界)。

安全边界

  • token 语义唯一:Header 不带 token 允许降级(仅凭 device-id 绑定放行,与现有 MQTT 上行 mqtt-device.ts 只信 hardware_id 的安全水位一致,不是本端点新引入的弱点);带了 token 就必须 验证通过。生产环境可通过 DYJ_GEN1_RECORD_REQUIRE_TOKEN=true 收紧为强制要求 token
  • 并发连接注册表在 server.upgrade() 之前原子占位(无 await 的同步临界段,TOCTOU-safe), 避免同设备并发升级请求绕过"单设备一连接"约束。
  • 审计走结构化日志(不写 DB):二期-A 的 hw_device_activation_audit_logsCHECK (event_type IN ('generate','exchange','info')) 约束,塞不进录音相关的新事件类型;本端点 刻意保持"零 DB 变更"边界,未来如需持久化审计是独立评估项。
  • 日志不打印 token 原文,IP 用哈希摘要(复用 utils/client-ip.tsmaskIP)。
  • 滚动式 30 秒空闲/停滞超时(见上方"限制"表),防止已知 device-id 的调用方建连后保持空闲、 或发一帧数据后转入静默,长期占住该设备的并发槽位。
  • 升级拒绝的 HTTP 响应体只给通用状态文案,不透出具体拒绝原因,降低响应差异枚举设备绑定 状态的可探测面(详细原因仅进服务端结构化日志)。

已知风险

该协议为兼容老云 + 对齐设备侧当前实现,token 默认可选(见上方安全边界),这意味着知道一个 有效、已绑定的 gen1 hardware_id 的调用方,在不提供 token 的情况下即可向该设备绑定用户的默认 项目写入任意二进制内容、消耗其存储配额。这不是本端点独有的新弱点——现有 MQTT 上行 (mqtt-device.ts)同样只信 hardware_id,是平台对一代机设备身份的既定信任模型(见 一代机激活 API 的"已知风险"一节);但本端点的影响面比 MQTT(只写状态日志) 更大——能写入用户实际看得到的文件、消耗真实存储配额。

当前缓解:库存/绑定门禁(必须是已绑定的 active gen1 设备)、限流、滚动式空闲超时(防止空闲占槽或发一帧后转入静默占槽)、 生产可选收紧开关 DYJ_GEN1_RECORD_REQUIRE_TOKEN、通用错误响应(降低可探测性)。未完全解决token 默认仍是可选而非强制——因为当前唯一的真实设备实现(dyj-app-v2)完全不发送 token Header,强制要求会导致现有设备 100% 无法连接。彻底解决需要设备团队先在 WebSocketAudioSender 里补上 token Header(见下方"设备侧接入建议"),之后运维即可把 DYJ_GEN1_RECORD_REQUIRE_TOKEN 收紧为 true。在此之前,hardware_id 的推测/枚举难度是当前实际的安全边界。

设备侧接入建议(跨仓库协调,不阻塞)

主后端侧已就绪。dyj-app-v2 若要切换到本端点:

  1. WebSocketAudioSender 的服务器地址切到本端点(协议 Header/握手/data-format 枚举均已对齐 设备侧当前真实实现,不需要改协议逻辑)。
  2. 可选:把 /api/auth/exchange 已拿到的 accessToken 顺手作为这里的 token Header 带上, 享受更强的身份校验(见上方安全边界);不带也完全能用。

详见 ReAI-com/dyj-app-v2 仓库 issue #1 的协调评论。

AI Workflow Editor