一代机录音上行接入 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前缀、nginxproxy_pass末尾带/做 strip,例如树莓派节点的infra/raspberry/agent/nginx-raspberry.conf.tpl)下,请求到达后端时前缀已被去掉, 后端同时接受/ws/record(去前缀路径)——不需要额外配置,两种路径都能连上。 - 鉴权:
device-id必须能在hw_user_devices解析出is_active=true且所属产品型号generation='gen1'的绑定记录(强制层,joinhw_product_models校验世代——已绑定的二代机 或其它世代设备不能打开本端点);token(一代机 legacy Bearer JWT,见 一代机激活 API)可选携带,带了就必须验证通过且payload.deviceId与device-id一致,否则拒绝(不存在"带了但验证不过仍放行")。 - 落地目标:设备绑定用户的「个人默认项目」(
ensure_personal_default_projectRPC,与移动端录音 上传共用同一落点,参见项目仓库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-format | MIME | 说明 |
|---|---|---|
aac | audio/aac | 设备侧当前实际发送的格式(dyj-app-v2 的 FileUploadHandler.kt 硬编码) |
apm | audio/x-adpcm | 与移动端录音上传既有约定一致,预留兼容 |
mp3 | audio/mpeg | |
wav | audio/wav | |
webm | audio/webm | |
pcm | audio/pcm |
其余一律拒绝升级(400)。
连接流程
- 客户端发起 WS 升级请求,带上述 Header。
- 服务端按顺序校验:
data-format映射 → 限流(per-IP/per-device 滑窗)→ 设备绑定 →token可选校验 → 团队存储配额预检 → 单设备并发占位(同一设备同时只允许 1 个活跃连接)。 任一环节失败,升级请求被拒绝(见下方错误码),不建立 WS 连接。 - 全部通过后,服务端立即(无条件)发送一条 TEXT 帧:json这一步是必需的:设备侧
{ "status": "ready" }FileUploadHandler会等待这条消息最长 15 秒才开始发送数据, 不发送等于设备侧上传必然超时失败。 - 客户端持续发送二进制音频帧(不限制单帧大小,服务端按累计字节数控制上限)。服务端维护一个 滚动式空闲/停滞超时:ready 握手发出后、以及此后每收到一个非空二进制帧,都会重新起 30 秒倒计时;只要数据持续流动(真实设备分片间隔 ~20ms,远小于 30 秒)就不会触发;一旦 停滞满 30 秒(不管是从未发过数据,还是发过一帧后转入静默)连接就会被主动断开。
- 客户端以正常关闭码(WS close code
1000)主动关闭连接(ws.close())才代表这段录音 正常结束,服务端据此落地文件。非正常关闭(网络中断、客户端异常退出、连接被服务端因空闲/ 超限主动断开等)不会把已收到的部分数据当成完整录音落地——避免截断录音被误当作完整文件 写入用户项目。
数据落地
连接以正常关闭码(1000)结束时,服务端:
- 若累计接收 0 字节,视为空连接(可能是握手失败后设备重连/健康探测),不落地、不报错。
- 复核团队存储配额(升级阶段预留的额度可能因同团队其他并发连接过期),超限则不落地,只记日志。
- 调用与
POST /storage/:projectId/upload相同的底层落地原语(storageService.uploadFile),写入files表:project_id= 该设备绑定用户的个人默认项目metadata.source="hw_gen1_recording"metadata.sourceRef= 设备hardware_idmetadata.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),避免未认证调用方通过响应 差异枚举设备绑定状态或服务端配置细节。下表供后端排障/服务端日志对照用。
| 状态码 | 原因(服务端日志) | 说明 |
|---|---|---|
| 400 | missing_device_id / missing_data_format / unknown_data_format | 必填 Header 缺失或格式不识别 |
| 401 | token_invalid:* / token_required | token 校验失败,或 DYJ_GEN1_RECORD_REQUIRE_TOKEN=true 时未带 token |
| 403 | device_not_bound | device-id 未在 hw_user_devices 命中 is_active=true 且所属产品型号 generation='gen1' 的绑定记录(二代机等其它世代设备即使已绑定也会被拒绝——本端点是一代机专属协议) |
| 409 | device_busy | 该设备已有活跃连接 |
| 413 | quota_exhausted | 团队存储配额已满 |
| 429 | rate_limited | 触发限流 |
| 500 | device_lookup_failed / project_provision_failed | 设备绑定查询失败,或落点项目创建/查询失败(基础设施故障,非业务拒绝) |
| 503 | global_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_logs有CHECK (event_type IN ('generate','exchange','info'))约束,塞不进录音相关的新事件类型;本端点 刻意保持"零 DB 变更"边界,未来如需持久化审计是独立评估项。 - 日志不打印
token原文,IP 用哈希摘要(复用utils/client-ip.ts的maskIP)。 - 滚动式 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 若要切换到本端点:
WebSocketAudioSender的服务器地址切到本端点(协议 Header/握手/data-format枚举均已对齐 设备侧当前真实实现,不需要改协议逻辑)。- 可选:把
/api/auth/exchange已拿到的accessToken顺手作为这里的tokenHeader 带上, 享受更强的身份校验(见上方安全边界);不带也完全能用。
详见 ReAI-com/dyj-app-v2 仓库 issue #1 的协调评论。