Files

1460 lines
47 KiB
Markdown
Raw Permalink Normal View History

2026-08-29 21:56:51 +08:00
# 嵌入式设备与平台交互接口文档
> 文档范围:嵌入式语音终端与平台、RTC 和对象存储的接口及交互流程
> 设备示例:`10000000-0000-0000-0000-000000000512`
> 设备接口版本:`/device/v2`
> OpenAPI 契约版本:`0.70.0`
> 本地平台地址:`http://localhost:58080`
> 公网测试地址:`https://121.41.46.238`
> 编制日期:2026-08-29
## 1. 适用范围和重要前提
本文档面向嵌入式固件、BSP、音频和设备联调开发人员,说明设备从启动登记到完成一次语音通话并上传录音的完整过程,包括:
- 主通话链路使用的 `/device/v2` 平台接口;
- 每个接口的请求参数、响应参数和示例;
- 设备 HMAC 签名、Nonce 和幂等键的生成规则;
- 火山 RTC 和对象存储的非平台 API 交互;
- 正常流程、轮询、重试和异常清理顺序。
仓库本地种子数据中的示例设备 `10000000-0000-0000-0000-000000000512` 当前是注册但未绑定设备:
```text
status = registered
credential_ref = local-dev://unbound-terminal-2
binding.bound = false
```
因此,在未绑定状态下:
- `GET /device/v2/bootstrap` 和 `GET /device/v2/config` 可以通过;
- `POST /device/v2/conversations` 将返回 `409 DEVICE_NOT_BOUND`;
- 后续成功通话示例是假设设备 `...0512` 已经绑定到有效家庭和服务对象,并取得 `voice_chat` 授权后的结果。
设备 ID 和设备密钥必须成对。使用设备 `...0511` 的密钥为 `...0512` 签名会返回 `401 DEVICE_AUTHENTICATION_FAILED`。
本文使用“必须”“应”“可以”区分协议要求、推荐实现和可选能力。Windows 模拟器仅作为已验证参考实现,不构成嵌入式设备的运行时约束。
## 2. 主通话链路接口
| 阶段 | 顺序 | 方法与路径 | 调用方式 |
|---|---:|---|---|
| 环境检测 | 1 | `GET /device/v2/bootstrap` | 调用一次 |
| 环境检测 | 2 | `GET /device/v2/config` | 调用一次 |
| 创建通话 | 3 | `POST /device/v2/conversations` | 调用一次,幂等 |
| 确认入房 | 4 | `POST /device/v2/conversations/{id}/activate` | RTC 入房成功并满足产品前置条件后调用,幂等 |
| 等待智能体 | 5 | `GET /device/v2/conversations/{id}` | 首次约 500 ms,之后建议每 1 秒轮询,不超过房间预留期 |
| 通话中 | 6 | `POST /device/v2/conversations/{id}/heartbeat` | 激活后立即一次,之后通常每 15 秒一次 |
| 通话中 | 7 | `POST /device/v2/conversations/{id}/turns:batch` | 每次收到最终字幕时调用;失败后在心跳或结束前重试 |
| 结束通话 | 8 | `POST /device/v2/conversations/{id}/stop` | 本地 RTC 离房后调用,幂等 |
| 等待结束 | 9 | `GET /device/v2/conversations/{id}` | 有录音时建议每 1 秒轮询,并设置本地上限 |
| 录音上传 | 10 | `POST /device/v2/conversations/{id}/upload-batches` | 有授权录音时调用,幂等 |
| 录音上传 | 外部 | `PUT {upload_url}` | 录音字节直接上传对象存储 |
| 录音上传 | 11 | `POST /device/v2/upload-batches/{batch_id}/complete` | 所有对象上传后调用 |
| 录音校验 | 12 | `GET /device/v2/upload-batches/{batch_id}` | 建议每 1~2 秒轮询,直到终态、批次到期或本地上限 |
| 本地清理 | 13 | `POST /device/v2/media-segments/{segment_id}/local-delete-ack` | 到 `local_delete_after` 后删除本地密文并回报 |
以下能力不在本文主通话流程中展开,嵌入式产品应按产品需求实现:
- `GET /device/v2/commands`
- `POST /device/v2/commands/{id}/ack`
- `GET /device/v2/firmware-releases/{id}/download`
- `POST /device/v2/telemetry:batch`
## 3. 总体时序
```mermaid
sequenceDiagram
participant D as 嵌入式语音终端
participant A as 平台API
participant R as 火山RTC
participant W as Worker
participant B as 豆包智能体
participant O as 对象存储
D->>A: GET /device/v2/bootstrap
A-->>D: 设备、绑定、固件和配置状态
D->>A: GET /device/v2/config
A-->>D: 期望配置和配置版本
D->>R: 可选的TLS 443连通性预检
D->>A: POST /device/v2/conversations
A-->>D: conversation_id、RTC房间和短期Token
D->>R: 加入房间、发布麦克风、启动字幕
D->>A: POST /device/v2/conversations/{id}/activate
A->>W: agent_start_requested
W->>B: StartVoiceChat
B->>R: 智能体加入RTC房间
loop 首次约500ms,之后建议每1秒,不超过房间预留期
D->>A: GET /device/v2/conversations/{id}
A-->>D: wait / continue / leave_room
end
loop 通话期间
D->>A: POST /device/v2/conversations/{id}/heartbeat
A-->>D: actions、配置版本、命令
D->>A: POST /device/v2/conversations/{id}/turns:batch
A-->>D: 最终字幕接收结果
D->>R: 上行麦克风音频
R-->>D: 下行豆包音频
end
D->>R: 停字幕、离房并销毁RTC引擎
D->>A: POST /device/v2/conversations/{id}/stop
A->>W: agent_stop_requested
W->>B: StopVoiceChat
opt 本次存在已授权录音
loop 等待通话终态
D->>A: GET /device/v2/conversations/{id}
A-->>D: STOPPING / COMPLETED / FAILED
end
D->>A: POST /device/v2/conversations/{id}/upload-batches
A-->>D: 批次、分片和短期PUT地址
D->>O: PUT near/far WAV分片
D->>A: POST /device/v2/upload-batches/{batch_id}/complete
loop 等待校验
D->>A: GET /device/v2/upload-batches/{batch_id}
A-->>D: VERIFYING / COMPLETED / FAILED
end
D->>D: 到local_delete_after后删除本地密文
D->>A: POST /device/v2/media-segments/{id}/local-delete-ack
end
```
## 4. 通用协议
### 4.1 成功和错误包装
一般成功响应:
```json
{
"request_id": "01JTESTREQUEST",
"data": {}
}
```
统一错误响应:
```json
{
"request_id": "01JTESTERROR",
"error": {
"code": "VALIDATION_FAILED",
"message": "请求字段不符合契约",
"retryable": false,
"field_errors": [
{
"field": "body.device_time",
"message": "必须是RFC3339时间"
}
],
"details": {}
}
}
```
设备应该使用 `error.code` 和 `retryable` 决定动作,不应匹配中文 `message`。
### 4.2 全部设备接口的 HMAC 请求头
每一个 `/device/v2` 请求都必须携带:
| 请求头 | 示例 | 生成方式 |
|---|---|---|
| `X-Device-Id` | `10000000-0000-0000-0000-000000000512` | 平台为设备分配的公开 UUID |
| `X-Device-Timestamp` | `2026-08-29T08:30:00.123Z` | 每次请求生成当前 UTC RFC3339 时间 |
| `X-Device-Nonce` | `5d4c79fa3f014e9cacb68eeb481be2c8` | 每次 HTTP 尝试生成新的 16~128 字符随机数 |
| `X-Device-Content-SHA256` | 64 位小写十六进制 | 对实际发送的原始正文 UTF-8 字节计算 SHA-256 |
| `X-Device-Signature` | Base64URL,无 `=` | 使用设备密钥计算 HMAC-SHA256 |
设备密钥不发送给平台。量产设备应把密钥保存在安全元件、可信执行环境或受保护的加密存储中,签名运算应尽量在安全边界内完成。
#### 4.2.1 `X-Device-Id`
该值来自平台设备注册/生产烧录流程,是设备的公开 UUID,不需要计算。本文示例固定为:
```text
10000000-0000-0000-0000-000000000512
```
设备必须把 Device ID 与对应的 `device_secret` 成对保存。Device ID 可以出现在普通日志中,`device_secret` 不可以。
#### 4.2.2 `X-Device-Timestamp`
该值由设备在每次 HTTP 尝试前读取当前 UTC 时间生成,格式必须为 RFC3339。推荐统一使用带毫秒的 UTC `Z` 格式:
```text
2026-08-29T08:31:00.050Z
```
生成要求:
1. 设备启动后通过可信 RTC/NTP、蜂窝网络时间或平台 `bootstrap.data.server_time` 校时。
2. 默认服务端允许时间偏差为前后 5 分钟,量产固件应把本地允许偏差控制得更小。
3. 写入请求头的字符串必须原样写入签名原文,不能在签名前后改变小数位、时区或大小写。
4. 网络重试时必须生成新的 Timestamp、Nonce 和 Signature。
伪代码:
```c
// utc_now_ms() 必须返回已经校准的 UTC 时间。
format_rfc3339_utc(utc_now_ms(), timestamp, sizeof(timestamp));
// 期望输出示例:2026-08-29T08:31:00.050Z
```
#### 4.2.3 `X-Device-Nonce`
Nonce 用于阻止签名请求被重放。服务端会按设备 ID 保存近期已经使用的 Nonce,同一个 Nonce 再次出现会被拒绝。
推荐生成方式:使用密码学安全随机数生成器产生 16 个随机字节,再编码成 32 个小写十六进制字符:
```c
uint8_t nonce_raw[16];
char nonce_hex[33];
// rng 必须是芯片 TRNG 或已经正确播种的 CSPRNG,不能使用 rand()。
secure_random(rng, nonce_raw, sizeof(nonce_raw));
hex_lower(nonce_raw, sizeof(nonce_raw), nonce_hex, sizeof(nonce_hex));
```
示例:
```text
5d4c79fa3f014e9cacb68eeb481be2c8
```
要求:
- 长度 16~128 个字符,推荐 32 个十六进制字符;
- 每一次 HTTP 尝试都重新生成,包括相同幂等请求的重试;
- 不能使用开机计数器、时间戳或 `rand()` 作为唯一随机源;
- Nonce 不等于 `Idempotency-Key`,两者生命周期不同。
#### 4.2.4 `X-Device-Content-SHA256`
该值是“真正发送到网络上的 HTTP 正文字节”的 SHA-256,编码为 64 个小写十六进制字符。
正确处理顺序:
1. 把请求对象序列化一次,得到最终 UTF-8 字节数组 `body_bytes`。
2. 对 `body_bytes[0..body_length-1]` 计算 SHA-256。
3. 把 32 字节摘要编码为 64 字符小写十六进制。
4. 使用同一个 `body_bytes` 和相同长度发送 HTTP 请求,不能计算完摘要后再次序列化 JSON。
伪代码:
```c
uint8_t digest_raw[32];
char digest_hex[65];
sha256(body_bytes, body_length, digest_raw);
hex_lower(digest_raw, sizeof(digest_raw), digest_hex, sizeof(digest_hex));
```
mbedTLS 示例:
```c
#include "mbedtls/sha256.h"
int make_content_sha256(
const uint8_t *body,
size_t body_len,
char output_hex[65]
) {
static const char hex[] = "0123456789abcdef";
uint8_t digest[32];
if (mbedtls_sha256_ret(body, body_len, digest, 0) != 0) {
return -1;
}
for (size_t i = 0; i < sizeof(digest); ++i) {
output_hex[i * 2] = hex[digest[i] >> 4];
output_hex[i * 2 + 1] = hex[digest[i] & 0x0f];
}
output_hex[64] = '\0';
return 0;
}
```
GET 和真正空正文的输入长度是 0 字节,因此 SHA-256 固定为:
```text
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
```
以下内容都不是空正文,摘要与上面的固定值不同:
| 正文 | UTF-8 字节数 | 说明 |
|---|---:|---|
| 真正空正文 | 0 | 使用固定空摘要 |
| `{}` | 2 | 对字节 `7b 7d` 计算 |
| `""` | 2 | 对两个双引号字节计算 |
| `\n` | 1 | 对换行字节 `0a` 计算 |
| 一个空格 | 1 | 对字节 `20` 计算 |
JSON 中字段顺序、空格、换行、转义方式或非 ASCII 字符的 UTF-8 编码只要变化,摘要就会变化。平台比较的是原始字节,不是解析后的 JSON 对象。
#### 4.2.5 Request URI 的确定方法
签名使用 Request URI,不包含协议、域名和端口:
```text
request_uri = path + 可选的问号和原始查询串
```
示例:
```text
POST https://121.41.46.238/device/v2/conversations
签名URI:/device/v2/conversations
```
```text
GET https://121.41.46.238/device/v2/bootstrap?firmware_version=1.2.0&network=wifi
签名URI:/device/v2/bootstrap?firmware_version=1.2.0&network=wifi
```
查询参数顺序、重复参数、百分号编码、`+` 与 `%20`、十六进制转义大小写都必须与最终发送的 URI 完全一致。推荐先由 HTTP 客户端构建最终 URI,再从同一缓冲区生成签名;不能签名后重新排序查询参数。
#### 4.2.6 `X-Device-Signature`
签名原文按以下五行拼接,行与行之间只有一个字节 `0x0A`,最后一行末尾不能再添加换行:
```text
UPPERCASE_HTTP_METHOD
REQUEST_URI_WITH_EXACT_QUERY_STRING
X-Device-Timestamp原值
X-Device-Nonce原值
X-Device-Content-SHA256原值
```
计算公式:
```text
signature_raw = HMAC-SHA256(device_secret_raw_bytes, canonical_utf8_bytes)
X-Device-Signature = Base64URL_NoPadding(signature_raw)
```
Base64URL 转换要求:
1. 先得到标准 Base64;
2. 把 `+` 替换为 `-`;
3. 把 `/` 替换为 `_`;
4. 删除末尾所有 `=`;
5. 不能输出十六进制 HMAC,必须输出 Base64URL。
mbedTLS HMAC 核心示例:
```c
#include "mbedtls/md.h"
int hmac_sha256(
const uint8_t *secret,
size_t secret_len,
const uint8_t *canonical,
size_t canonical_len,
uint8_t output[32]
) {
const mbedtls_md_info_t *info =
mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
if (info == NULL) {
return -1;
}
return mbedtls_md_hmac(
info,
secret,
secret_len,
canonical,
canonical_len,
output
);
}
```
构造签名原文时可以使用:
```c
int written = snprintf(
canonical,
sizeof(canonical),
"%s\n%s\n%s\n%s\n%s",
method_upper,
request_uri,
timestamp,
nonce,
content_sha256
);
if (written < 0 || (size_t)written >= sizeof(canonical)) {
return SIGN_BUFFER_TOO_SMALL;
}
```
`snprintf` 产生的结尾 `\0` 不能包含在 HMAC 输入长度中,HMAC 长度应为 `written`。
#### 4.2.7 可复现签名测试向量
下面的密钥仅用于验证算法,绝不能用于真实设备:
```text
device_id = 10000000-0000-0000-0000-000000000512
test_secret = embedded-doc-example-secret-32-bytes-minimum
method = POST
request_uri = /device/v2/conversations
timestamp = 2026-08-29T08:31:00.050Z
nonce = 5d4c79fa3f014e9cacb68eeb481be2c8
```
请求正文必须是下面这一行的 246 个 UTF-8 字节,末尾没有换行:
```json
{"device_time":"2026-08-29T08:31:00.000Z","monotonic_ms":15820,"capabilities":{"rtc":true,"audio_tracks":["near","far"],"upload_resume":true,"rtc_credential_renewal":true},"network":{"type":"wifi","rssi":-55},"requested_goal":"open_conversation"}
```
正文 SHA-256 应得到:
```text
6ab32d795cf68a2f48d3db879e4811db683309b7a3f631003b47e727646143c9
```
签名原文的 UTF-8 内容必须准确为:
```text
POST
/device/v2/conversations
2026-08-29T08:31:00.050Z
5d4c79fa3f014e9cacb68eeb481be2c8
6ab32d795cf68a2f48d3db879e4811db683309b7a3f631003b47e727646143c9
```
使用测试密钥计算后,`X-Device-Signature` 必须得到:
```text
_spDPNobaFkrX8hInZ26ULuoKsYhSzExpSK7Ww_cVR4
```
对应请求头为:
```http
X-Device-Id: 10000000-0000-0000-0000-000000000512
X-Device-Timestamp: 2026-08-29T08:31:00.050Z
X-Device-Nonce: 5d4c79fa3f014e9cacb68eeb481be2c8
X-Device-Content-SHA256: 6ab32d795cf68a2f48d3db879e4811db683309b7a3f631003b47e727646143c9
X-Device-Signature: _spDPNobaFkrX8hInZ26ULuoKsYhSzExpSK7Ww_cVR4
Idempotency-Key: create-51200000-0000-4000-8000-000000000001
```
嵌入式实现应先离线运行该测试向量。如果摘要或签名不一致,不要开始平台联调,应先检查正文长度、换行、URI、Base64URL 和密钥字节。
#### 4.2.8 常见计算错误
| 现象 | 常见原因 |
|---|---|
| 正文摘要不一致 | 计算后重新序列化 JSON、末尾多换行、发送长度与计算长度不同 |
| GET 签名失败 | 忘记使用空正文摘要,或签名 URI 没有包含查询串 |
| HMAC 长度不对 | 把 C 字符串结尾 `\0` 也计入 HMAC |
| 签名包含 `+`、`/`、`=` | 使用了标准 Base64,没有转换为无填充 Base64URL |
| 同一请求第一次成功、第二次 401 | 重用了同一个 Nonce,被重放保护拒绝 |
| 相同幂等操作创建了两条记录 | 重试时更换了 `Idempotency-Key` |
| `IDEMPOTENCY_KEY_REUSED` | 同一幂等键对应的正文原始字节发生变化 |
| Timestamp 过期 | 设备 UTC 未校准,或请求在离线队列停留过久 |
#### 4.2.9 一次请求的完整生成顺序
设备应严格按下面的顺序组装请求,避免参与签名的内容与实际发送内容不一致:
1. 确定 HTTP Method,并转换为大写,例如 `POST`。
2. 构建最终 Request URI;如果有查询参数,此时就完成排序和百分号编码,之后不再修改。
3. 把请求对象序列化一次,得到最终 `body_bytes` 和 `body_length`;GET 或无正文请求使用长度为 0 的字节数组。
4. 计算 `SHA256(body_bytes[0..body_length-1])`,转成 64 位小写十六进制,得到 `X-Device-Content-SHA256`。
5. 读取已经校准的 UTC 时间,生成 `X-Device-Timestamp`。
6. 从 TRNG/CSPRNG 读取随机数,生成新的 `X-Device-Nonce`。
7. 用 Method、Request URI、Timestamp、Nonce 和正文摘要构造五行签名原文。
8. 使用当前设备的 `device_secret` 计算 HMAC-SHA256,并转换成无填充 Base64URL,得到 `X-Device-Signature`。
9. 对要求幂等的接口,读取本次业务操作已经持久化的 `Idempotency-Key`;首次操作才生成新值。
10. 设置 HTTP 请求头并发送第 3 步保存的同一份 `body_bytes`,发送长度必须是同一个 `body_length`。
推荐把“最终 URI、最终正文缓冲区、五个签名请求头”封装成不可变请求对象。请求进入发送阶段后不得再修改 URI 或正文。
重试时按错误发生位置处理:
| 场景 | 正文 | `Idempotency-Key` | Timestamp | Nonce | Signature |
|---|---|---|---|---|---|
| 同一业务操作因超时/断网重试 | 保持完全相同 | 保持相同 | 重新生成 | 重新生成 | 重新计算 |
| 用户发起新的业务操作 | 生成新正文 | 生成新值 | 重新生成 | 重新生成 | 重新计算 |
#### 4.2.10 其他相关 HTTP 请求头
JSON 请求还应设置:
```http
Content-Type: application/json; charset=utf-8
Content-Length: 246
```
- `Content-Length` 是最终正文的字节数,不是字符数。测试向量正文是 246 个 UTF-8 字节,所以值为 `246`。
- 如果 HTTP 库自动生成 `Content-Length`,设备只需保证传给 HTTP 库的缓冲区和长度就是参与 SHA-256 的同一份数据。
- GET/无正文请求正文长度为 0;HTTP 库可以发送 `Content-Length: 0`,也可以省略该头,二者都不改变空正文摘要。
- `Content-Type`、`Content-Length` 和 `Idempotency-Key` 不属于五行 HMAC 签名原文,不能擅自把它们加入签名字符串。
- 所有设备接口都必须使用 HTTPS;HMAC 不能替代 TLS 证书校验。
### 4.3 设备 `...0512` 的密钥来源与保存
本地环境通过 `DEVICE_LOCAL_SECRET` 把演示密钥导入 `device_credential` 表;服务端数据库保存 AES-256-GCM 加密后的密文,不保存明文。公网或生产环境应使用 `device-credential provision/rotate` 为设备生成或轮换独立密钥。
嵌入式设备应在生产或受控测试烧录阶段接收设备 `...0512` 对应的明文密钥,并写入设备安全区。设备 ID 与密钥必须成对保存。明文密钥不能写进本文档、普通 Flash 分区、串口日志、崩溃转储或升级包;如果密钥丢失或疑似泄露,应在平台执行轮换,不能从数据库反查明文。
### 4.4 幂等键
以下接口要求 `Idempotency-Key`:
- 创建通话;
- 激活通话;
- 字幕批量补传;
- 停止通话;
- 创建录音上传批次。
规则:
1. 长度 16~128 字符。
2. 同一次业务操作重试时保持原键和原正文。
3. 每一次 HTTP 重试仍要生成新的 Timestamp、Nonce 和签名。
4. 相同键和相同正文返回原结果,响应可能带 `Idempotency-Replayed: true`。
5. 相同键对应不同正文返回 `409 IDEMPOTENCY_KEY_REUSED`。
推荐生成方法:首次发起一项业务操作时,从 TRNG/CSPRNG 读取 16 个随机字节,编码为 32 个小写十六进制字符,再加上便于排查的接口前缀:
```c
uint8_t operation_random[16];
char operation_hex[33];
char idempotency_key[64];
secure_random(rng, operation_random, sizeof(operation_random));
hex_lower(operation_random, sizeof(operation_random),
operation_hex, sizeof(operation_hex));
snprintf(idempotency_key, sizeof(idempotency_key),
"create-%s", operation_hex);
```
示例:
```text
create-51200000000040008000000000000001
```
前缀建议按接口区分:`create-`、`activate-`、`turns-`、`stop-`、`upload-`。前缀只是便于日志定位,不参与业务判断;整个字符串必须满足 16~128 字符。
设备在首次发送前必须把下面的数据写入掉电可恢复存储:
- 业务操作类型;
- `Idempotency-Key`;
- 最终正文原始字节或能够逐字节恢复同一正文的数据;
- 当前操作状态。
收到明确的成功响应或明确的不可重试失败后,才能结束该幂等操作。HTTP 超时、连接断开或未收到完整响应时,结果属于“未知”,设备应使用原 `Idempotency-Key` 和原正文重试,但必须重新生成 Timestamp、Nonce 和 Signature。
不要使用 Nonce 作为幂等键,也不要从 Timestamp 推导幂等键:Nonce 每次 HTTP 尝试都要变化,而幂等键在同一业务操作的所有重试中必须稳定。`Idempotency-Key` 也不属于五行 HMAC 签名原文。
## 5. 阶段一:启动登记、配置同步和网络准备
设备开机、网络恢复或固件升级后,应先完成平台启动登记和配置同步,再开放通话能力。
### 5.1 设备启动登记
```http
GET /device/v2/bootstrap
```
#### 查询参数
| 参数 | 必填 | 嵌入式示例值 | 说明 |
|---|---:|---|---|
| `firmware_version` | 是 | `1.2.0` | 当前固件版本 |
| `board_rev` | 否 | `board-rev-a` | PCB/硬件版本 |
| `rtc_sdk_version` | 否 | `embedded-rtc-3.0.0` | 设备集成的 RTC SDK 版本 |
| `codec` | 否 | `opus` | RTC 音频编码能力 |
| `sample_rate` | 否 | `16000` | 当前首选采样率 |
| `network` | 否 | `wifi` | 当前网络类型或描述 |
| `device_time` | 是 | 当前 UTC 时间 | RFC3339 |
#### 请求示例
```http
GET /device/v2/bootstrap?firmware_version=1.2.0&board_rev=board-rev-a&rtc_sdk_version=embedded-rtc-3.0.0&codec=opus&sample_rate=16000&network=wifi&device_time=2026-08-29T08%3A30%3A00.000Z HTTP/1.1
Host: 121.41.46.238
X-Device-Id: 10000000-0000-0000-0000-000000000512
X-Device-Timestamp: 2026-08-29T08:30:00.050Z
X-Device-Nonce: 5d4c79fa3f014e9cacb68eeb481be2c8
X-Device-Content-SHA256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
X-Device-Signature: <按包含完整查询串的Request URI计算>
```
#### 本地种子中未绑定设备的响应示例
```json
{
"request_id": "01J512BOOTSTRAP",
"data": {
"server_time": "2026-08-29T08:30:00.080Z",
"clock_skew_ms": 80,
"device": {
"device_id": "10000000-0000-0000-0000-000000000512",
"status": "registered",
"model": "simulated-terminal",
"hardware_revision": "sim-rev-a",
"minimum_security_version": "dev-signature-v1",
"minimum_firmware_version": "0.1.0-sim",
"firmware_compatible": true,
"config_revision": 1,
"binding": {
"bound": false
}
}
}
}
```
设备应检查:
- `status` 必须为 `registered`;
- `firmware_compatible` 必须为 `true`;
- 正常创建通话前 `binding.bound` 应为 `true`;
- 使用 `server_time` 和 `clock_skew_ms` 检测本机时钟偏差。
主要状态码:`200`、`400`、`401`、`503`。
### 5.2 获取设备配置
```http
GET /device/v2/config
```
设备应持久化上一次成功配置的强 ETag,并在后续请求中发送 `If-None-Match`,以减少响应流量。首次启动或没有缓存时可以省略。
#### 请求示例
```http
GET /device/v2/config HTTP/1.1
Host: 121.41.46.238
X-Device-Id: 10000000-0000-0000-0000-000000000512
X-Device-Timestamp: 2026-08-29T08:30:00.100Z
X-Device-Nonce: c84bdfef37594423a62f8ac870c27f10
X-Device-Content-SHA256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
X-Device-Signature: <计算结果>
If-None-Match: "v1"
```
#### 响应示例
```http
HTTP/1.1 200 OK
ETag: "v1"
Content-Type: application/json; charset=utf-8
{
"request_id": "01J512CONFIG",
"data": {
"device_id": "10000000-0000-0000-0000-000000000512",
"revision": 1,
"desired_config": {
"codec": "opus",
"sample_rate": 16000,
"heartbeat_interval_seconds": 15
},
"binding": {
"bound": false
}
}
}
```
配置未变化且客户端发送匹配的 `If-None-Match` 时返回 `304`,无响应正文。平台暂时不可用时,设备应保留上一次有效配置。
### 5.3 火山 RTC 网络探测
完成两个平台接口后,设备可以按网络栈和 RTC SDK 能力预检下列火山 RTC 域名的 443 端口:
```text
access.rtc.volcvideo.com
access2-hl.rtc.volcvideo.com
common.rtc.volcvideo.com
rtcg-access.volcvideos.com
rtcg.volcvideos.com
web-log-report.rtc.volcvideo.com
web-log-report.volcvideos.com
```
这一步不是平台 HTTP API。产品可以将预检失败作为阻止通话的条件,也可以交由 RTC SDK 在入房阶段返回网络错误;无论采用哪种方式,都应提供稳定错误码而不是上传网络堆栈或密钥。
## 6. 阶段二:创建通话并等待智能体
以下成功示例假设设备 `...0512` 已经完成绑定并具有有效语音通话授权。
### 6.1 创建通话
```http
POST /device/v2/conversations
```
本接口只分配 RTC 房间和短期凭证,不启动豆包智能体。
#### 请求字段
| 字段 | 必填 | 嵌入式实现要求或示例 |
|---|---:|---|
| `device_time` | 是 | 当前 UTC RFC3339 |
| `monotonic_ms` | 否 | RTOS/系统启动后的单调毫秒计数,≥0 |
| `capabilities.rtc` | 是 | `true` |
| `capabilities.audio_tracks` | 是 | `['near','far']` |
| `capabilities.upload_resume` | 是 | `true` |
| `capabilities.rtc_credential_renewal` | 否 | `true` |
| `network.type` | 是 | 示例为 `wifi`,也可为 `ethernet`、`4g` 等 |
| `network.rssi` | 否 | 无线网络建议上报,例如 `-55` |
| `requested_goal` | 是 | 固定 `open_conversation` |
`audio_tracks` 是设备能力声明,不是本次通话的录音授权:
| 值 | 含义 | 嵌入式音频来源 |
|---|---|---|
| `near` | 设备近端轨道 | 麦克风采集的用户语音 |
| `far` | 设备远端轨道 | RTC 解码后的智能体下行语音 |
设备可以声明 `['near']`、`['far']` 或 `['near','far']`,至少一项且不能重复。即使设备声明支持某轨道,也只有创建响应中的 `privacy.record_near`、`privacy.record_far` 和 `privacy.upload_allowed` 同时允许时,才能采集、落盘和上传对应录音。
额外请求头:
```http
Idempotency-Key: create-<设备生成的稳定业务UUID>
```
#### 请求正文示例
```json
{
"device_time": "2026-08-29T08:31:00.000Z",
"monotonic_ms": 15820,
"capabilities": {
"rtc": true,
"audio_tracks": ["near", "far"],
"upload_resume": true,
"rtc_credential_renewal": true
},
"network": {
"type": "wifi",
"rssi": -55
},
"requested_goal": "open_conversation"
}
```
#### 201 响应示例
```json
{
"request_id": "01J512CREATE",
"data": {
"conversation_id": "51200000-0000-4000-8000-000000000001",
"status": "ROOM_ISSUED",
"rtc": {
"app_id": "<rtc-app-id>",
"room_id": "r_51200000000040008000000000000001",
"user_id": "d_10000000000000000000000000000512",
"agent_user_id": "a_51200000000040008000000000000001",
"token": "<short-lived-rtc-token>",
"expires_at": "2026-08-29T08:36:00Z"
},
"privacy": {
"retention_mode": "transcript_and_audio",
"record_near": true,
"record_far": true,
"upload_allowed": true
},
"session": {
"engine_mode": "s2s_hybrid",
"heartbeat_interval_sec": 15,
"allocation_expires_at": "2026-08-29T08:33:00Z"
}
}
}
```
关键处理:
- `rtc.token` 只保存在内存中;
- 必须在 `allocation_expires_at` 之前入房并激活;
- `privacy` 决定设备是否允许采集和保存 near/far 原始音频;
- `engine_mode` 只用于诊断,设备不能据此改变音频逻辑。
主要错误:
| HTTP | 错误码 | 含义 |
|---:|---|---|
| 400 | `VALIDATION_FAILED` | 字段、正文或幂等键不符合契约 |
| 401 | `DEVICE_AUTHENTICATION_FAILED` | 设备 ID、时间、摘要、密钥或签名不正确 |
| 403 | `CONSENT_REQUIRED` | 缺少语音通话授权 |
| 409 | `DEVICE_NOT_BOUND` | 设备未绑定;本地种子中的 `...0512` 默认会命中此错误 |
| 409 | `CONVERSATION_ALREADY_ACTIVE` | 设备已有未结束通话 |
| 409 | `IDEMPOTENCY_KEY_REUSED` | 同一键用于不同正文 |
| 422 | `AGENT_CONTEXT_PREPARATION_FAILED` | 背景资料准备失败,不会创建 RTC 房间 |
| 426 | `FIRMWARE_UPDATE_REQUIRED` | 固件低于最低版本 |
| 429 | `USAGE_QUOTA_EXCEEDED` | 费用、用量或并发额度不足 |
| 503 | `RUNTIME_CONFIG_UNAVAILABLE` | 没有可用语音路由配置,可按 `retryable` 退避重试 |
### 6.2 嵌入式设备直接加入火山 RTC
创建成功后,设备不经过平台传输实时音频,而是直接调用火山 RTC SDK:
1. 使用 `rtc.app_id` 创建 RTC Engine;
2. 初始化麦克风、音频编解码和播放链路;
3. 使用 `rtc.token + rtc.room_id + rtc.user_id` 加入房间;
4. 发布近端音频;
5. 订阅并播放智能体远端音频;
6. 如果设备侧承担最终字幕采集,则启动 RTC 字幕/ASR 并确认可用;
7. 只有设备要求“字幕可用后才能通话”时,字幕失败才阻止激活;具体门禁由产品方案确定。
实时麦克风和豆包音频不经过平台 API 或数据库。
### 6.3 确认入房并启动智能体
```http
POST /device/v2/conversations/51200000-0000-4000-8000-000000000001/activate
```
只有 RTC 入房和字幕启动成功后调用。
额外请求头:
```http
Idempotency-Key: activate-<设备生成的稳定业务UUID>
```
#### 请求正文示例
```json
{
"joined_at": "2026-08-29T08:31:03.000Z",
"room_id": "r_51200000000040008000000000000001",
"rtc_user_id": "d_10000000000000000000000000000512",
"rtc_join_result": "success",
"config_revision": 1,
"connection_reference": "embedded-rtc-session-0001"
}
```
#### 202 响应示例
```json
{
"request_id": "01J512ACTIVATE",
"data": {
"conversation_id": "51200000-0000-4000-8000-000000000001",
"status": "STARTING_AGENT",
"accepted_at": "2026-08-29T08:31:03.100Z"
}
}
```
`202 STARTING_AGENT` 只表示平台已经受理。平台随后通过可靠任务让 Worker 调用豆包 `StartVoiceChat`。
主要错误:`400`、`401`、`404 CONVERSATION_NOT_FOUND`、`409 ROOM_ALLOCATION_EXPIRED`、`409 RTC_ALLOCATION_MISMATCH`、`409 CONFIG_VERSION_CONFLICT`、`409 CONVERSATION_STATE_CONFLICT`、`503`。
### 6.4 轮询通话状态
```http
GET /device/v2/conversations/51200000-0000-4000-8000-000000000001
```
推荐在激活后约 500 ms 首次查询,之后每 1 秒查询,并确保总等待时间不超过 `session.allocation_expires_at`。设备应设置有限次数和本地超时,不能无限轮询。
#### 等待响应
```json
{
"request_id": "01J512STATE1",
"data": {
"conversation_id": "51200000-0000-4000-8000-000000000001",
"status": "STARTING_AGENT",
"action": "wait"
}
}
```
#### 激活响应
```json
{
"request_id": "01J512STATE2",
"data": {
"conversation_id": "51200000-0000-4000-8000-000000000001",
"status": "ACTIVE",
"action": "continue"
}
}
```
#### 失败响应
```json
{
"request_id": "01J512STATE3",
"data": {
"conversation_id": "51200000-0000-4000-8000-000000000001",
"status": "FAILED",
"action": "leave_room",
"failure_code": "provider_start_rejected"
}
}
```
| `action` | 设备动作 |
|---|---|
| `wait` | 保持 RTC 房间并继续有限轮询 |
| `continue` | 进入正常通话并启动心跳 |
| `leave_room` | 退出 RTC 并执行失败清理 |
## 7. 阶段三:通话期间
### 7.1 通话心跳
```http
POST /device/v2/conversations/51200000-0000-4000-8000-000000000001/heartbeat
```
设备进入 `ACTIVE` 后应立即发送一次,之后按:
```text
max(5秒, session.heartbeat_interval_sec)
```
周期调用。当前通常为 15 秒。
#### 请求正文示例
```json
{
"sequence_no": 1,
"device_time": "2026-08-29T08:31:05.000Z",
"monotonic_ms": 20820,
"conversation_status": "ACTIVE",
"rtc": {
"joined": true,
"uplink": true,
"downlink": true
},
"network": {
"type": "wifi",
"rssi": -55
},
"firmware_version": "1.2.0",
"reported_config_revision": 1,
"rtc_credential_expires_at": "2026-08-29T08:36:00Z",
"recording_queue": {
"pending_segments": 0,
"failed_segments": 0
},
"command_results": []
}
```
#### 200 响应示例
```json
{
"request_id": "01J512HEARTBEAT",
"data": {
"lease_expires_at": "2026-08-29T08:31:50Z",
"actions": [
{
"type": "continue"
}
],
"desired_config_revision": 1,
"commands": []
}
}
```
可能的动作:
| 动作 | 设备处理 |
|---|---|
| `continue` | 继续通话 |
| `stop` | 尽快停止并离房 |
| `stop_after_turn` | 当前话轮完成后停止 |
| `renew_rtc_credential` | 使用动作中的新 `rtc.token` 调用 RTC SDK 更新 Token,并保存新到期时间 |
心跳 `sequence_no` 从 1 开始严格递增,设备重启后应从耐久确认点恢复。重复或乱序心跳不会延长租约。该接口不要求 `Idempotency-Key`,由序号完成去重。
### 7.2 批量上传最终字幕
```http
POST /device/v2/conversations/51200000-0000-4000-8000-000000000001/turns:batch
```
如果设备 RTC SDK 或本地 ASR 能产生最终字幕,设备应在收到最终结果后加入可靠队列并调用该接口。每批最多 500 条;不具备字幕能力的固件可以不调用,但应与平台确认最终转写由供应商回调提供。
推荐生成规则:
- `turn_key = rtc-subtitle-{speaker}-{RTC sequence}`;
- `sequence_no` 为本通话内从 1 开始的严格递增编号;
- `speaker` 为 `subject` 或 `agent`;
- 幂等键可以使用 `turns-{请求正文SHA-256前32位}`。
#### 请求正文示例
```json
{
"turns": [
{
"turn_key": "rtc-subtitle-subject-1001",
"sequence_no": 1,
"speaker": "subject",
"start_at": "2026-08-29T08:31:20.000Z",
"end_at": "2026-08-29T08:31:23.000Z",
"final_text": "给我讲一个小时候的故事。",
"interrupted": false
},
{
"turn_key": "rtc-subtitle-agent-2001",
"sequence_no": 2,
"speaker": "agent",
"start_at": "2026-08-29T08:31:24.000Z",
"end_at": "2026-08-29T08:31:30.000Z",
"final_text": "好呀,我们从您小时候生活的地方说起。",
"interrupted": false
}
]
}
```
#### 200 响应示例
```json
{
"request_id": "01J512TURNS",
"data": {
"conversation_id": "51200000-0000-4000-8000-000000000001",
"accepted_count": 2,
"duplicate_count": 0
}
}
```
相同终稿重放会计入 `duplicate_count`。相同 `turn_key` 或 `sequence_no` 对应不同内容时,整批返回 `409 TRANSCRIPT_TURN_CONFLICT`。
上传失败时设备应保留可靠队列,并在以下时机重试:
- 下一次心跳之后;
- 结束通话之前,最多连续重试 3 次。
如果产品要求“字幕不丢失”,仍有字幕未上传时设备不应直接清空队列;可以延迟结束流程或把加密队列持久化后异步补传。
## 8. 阶段四:结束通话
### 8.1 本地结束前处理
用户挂断、平台下发停止动作、RTC 失败或设备策略触发结束时,设备建议按以下顺序处理:
1. 停止心跳定时器;
2. 停止 RTC 实时字幕;
3. 等待音频/字幕管线吐出最后的最终结果;
4. 按本地重试策略上传剩余最终字幕;
5. 将 near/far PCM 音频封装为单声道 WAV PCM16 分片;
6. 退出 RTC 房间、停止采集并销毁 RTC Engine;
7. 然后调用平台停止接口。
### 8.2 停止通话
```http
POST /device/v2/conversations/51200000-0000-4000-8000-000000000001/stop
```
额外请求头:
```http
Idempotency-Key: stop-<设备生成的稳定业务UUID>
```
#### 请求正文示例
```json
{
"reason": "user_hangup",
"device_time": "2026-08-29T08:35:00.000Z",
"last_heartbeat_sequence": 16,
"rtc_left": true,
"recording": {
"near_segments": 1,
"far_segments": 1
}
}
```
#### 202 响应示例
```json
{
"request_id": "01J512STOP",
"data": {
"conversation_id": "51200000-0000-4000-8000-000000000001",
"status": "STOPPING",
"accepted_at": "2026-08-29T08:35:00.100Z"
}
}
```
响应状态可能为 `STOPPING`、`COMPLETED`、`FAILED` 或 `EXPIRED`。相同停止请求重放不会重复触发供应商 Stop。
启动流程任何一步失败时,只要已经拿到 `conversation_id`,设备也应释放 RTC,并使用稳定原因码清理通话,例如:
```text
reason = device_start_failed
```
### 8.3 等待通话终态
仅当本次实际生成了录音分片时,设备需要等待通话进入允许上传的终态:
```http
GET /device/v2/conversations/51200000-0000-4000-8000-000000000001
```
建议每 1 秒查询一次,并设置不超过 60 秒的本地等待上限:
- `COMPLETED` 或 `FAILED`:进入录音上传;
- `EXPIRED`:停止上传并报错;
- 超时:本次录音上传失败。
## 9. 阶段五:录音上传和校验
只有创建通话响应满足以下条件时,设备才允许采集和上传录音:
```text
privacy.upload_allowed = true
privacy.record_near = true 或 privacy.record_far = true
```
当前平台接受的嵌入式录音基线:
| 项目 | 当前值 |
|---|---|
| 编码 | `wav_pcm_s16le` |
| Content-Type | `audio/wav` |
| 声道 | 单声道 |
| near | 设备麦克风原始音频帧 |
| far | 智能体远端原始音频帧 |
| 单片最大时长 | 9 分钟,低于平台 10 分钟上限 |
| 单片最大大小 | 64 MiB |
### 9.1 创建上传批次
```http
POST /device/v2/conversations/51200000-0000-4000-8000-000000000001/upload-batches
```
设备先对每个本地 WAV 分片计算真实字节数和小写 SHA-256,然后只把清单提交给平台,不把音频字节发送给业务 API。
幂等键:
```text
media-{清单JSON的SHA-256前40位}
```
#### 请求正文示例
```json
{
"segments": [
{
"track": "near",
"sequence_no": 0,
"codec": "wav_pcm_s16le",
"content_type": "audio/wav",
"duration_ms": 239000,
"expected_size": 7648044,
"expected_digest": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
},
{
"track": "far",
"sequence_no": 0,
"codec": "wav_pcm_s16le",
"content_type": "audio/wav",
"duration_ms": 238500,
"expected_size": 7632044,
"expected_digest": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
}
]
}
```
#### 201 响应示例
```json
{
"request_id": "01J512MEDIA1",
"data": {
"id": "51200000-0000-4000-8000-000000000101",
"conversation_id": "51200000-0000-4000-8000-000000000001",
"status": "UPLOADING",
"segment_count": 2,
"expected_total_size": 15280088,
"verified_count": 0,
"rejected_count": 0,
"verification_attempts": 0,
"expires_at": "2026-08-29T09:05:00Z",
"segments": [
{
"id": "51200000-0000-4000-8000-000000000201",
"track": "near",
"sequence_no": 0,
"codec": "wav_pcm_s16le",
"content_type": "audio/wav",
"duration_ms": 239000,
"expected_size": 7648044,
"expected_digest": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"status": "AUTHORIZED",
"upload_url": "https://object-store.example/temporary/near.wav?<signed-query>",
"upload_until": "2026-08-29T08:55:00Z"
},
{
"id": "51200000-0000-4000-8000-000000000202",
"track": "far",
"sequence_no": 0,
"codec": "wav_pcm_s16le",
"content_type": "audio/wav",
"duration_ms": 238500,
"expected_size": 7632044,
"expected_digest": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
"status": "AUTHORIZED",
"upload_url": "https://object-store.example/temporary/far.wav?<signed-query>",
"upload_until": "2026-08-29T08:55:00Z"
}
]
}
}
```
主要错误:`400`、`401`、`403`(通话、归属或录音授权不允许)、`409`(幂等键或分片元数据冲突)。
### 9.2 直接 PUT 到对象存储
设备对每个响应中的新分片执行:
```http
PUT <segment.upload_url> HTTP/1.1
Content-Type: audio/wav
<该分片原始WAV字节>
```
注意:
- 这是设备到对象存储的直接请求,不经过平台业务 API;
- 不携带设备 HMAC 请求头;
- `upload_url` 自带短期签名,只能上传对应单一对象;
- 不得持久化或记录完整 `upload_url`;
- 返回非 2xx 时,本次上传流程失败并按外层逻辑重试。
### 9.3 声明上传完成
```http
POST /device/v2/upload-batches/51200000-0000-4000-8000-000000000101/complete
```
该接口没有请求正文。设备应发送真正的空正文,并使用空正文 SHA-256:
```text
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
```
#### 202 响应示例
```json
{
"request_id": "01J512MEDIA2",
"data": {
"id": "51200000-0000-4000-8000-000000000101",
"conversation_id": "51200000-0000-4000-8000-000000000001",
"status": "VERIFYING",
"segment_count": 2,
"expected_total_size": 15280088,
"verified_count": 0,
"rejected_count": 0,
"verification_attempts": 0,
"expires_at": "2026-08-29T09:05:00Z",
"segments": [
{
"id": "51200000-0000-4000-8000-000000000201",
"track": "near",
"sequence_no": 0,
"codec": "wav_pcm_s16le",
"content_type": "audio/wav",
"duration_ms": 239000,
"expected_size": 7648044,
"expected_digest": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"status": "VERIFYING"
},
{
"id": "51200000-0000-4000-8000-000000000202",
"track": "far",
"sequence_no": 0,
"codec": "wav_pcm_s16le",
"content_type": "audio/wav",
"duration_ms": 238500,
"expected_size": 7632044,
"expected_digest": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
"status": "VERIFYING"
}
]
}
}
```
重复调用不会重复创建后台校验任务。主要状态码:`202`、`401`、`404`、`409`、`410`。
### 9.4 查询校验结果
```http
GET /device/v2/upload-batches/51200000-0000-4000-8000-000000000101
```
建议每 1~2 秒查询一次,直到批次进入终态、达到 `expires_at`,或达到设备本地等待上限(建议不超过 90 秒)。
#### 完成响应示例
```json
{
"request_id": "01J512MEDIA3",
"data": {
"id": "51200000-0000-4000-8000-000000000101",
"conversation_id": "51200000-0000-4000-8000-000000000001",
"status": "COMPLETED",
"segment_count": 2,
"expected_total_size": 15280088,
"verified_count": 2,
"rejected_count": 0,
"verification_attempts": 1,
"expires_at": "2026-08-29T09:05:00Z",
"segments": [
{
"id": "51200000-0000-4000-8000-000000000201",
"track": "near",
"sequence_no": 0,
"codec": "wav_pcm_s16le",
"content_type": "audio/wav",
"duration_ms": 239000,
"expected_size": 7648044,
"expected_digest": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"status": "VERIFIED",
"verified_at": "2026-08-29T08:35:05Z",
"local_delete_after": "2026-08-29T08:40:05Z"
},
{
"id": "51200000-0000-4000-8000-000000000202",
"track": "far",
"sequence_no": 0,
"codec": "wav_pcm_s16le",
"content_type": "audio/wav",
"duration_ms": 238500,
"expected_size": 7632044,
"expected_digest": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
"status": "VERIFIED",
"verified_at": "2026-08-29T08:35:05Z",
"local_delete_after": "2026-08-29T08:40:05Z"
}
]
}
}
```
遇到以下结果时设备应判定上传失败:
- 批次状态为 `FAILED` 或 `EXPIRED`;
- 任一分片状态为 `REJECTED`;
- 等待超过 90 秒。
### 9.5 删除本地录音密文并回报
每个分片达到 `VERIFIED` 后,平台返回 `local_delete_after`。设备必须等到该时间之后,才能删除本地加密录音副本,并调用:
```http
POST /device/v2/media-segments/51200000-0000-4000-8000-000000000201/local-delete-ack
```
删除成功:
```json
{
"status": "DELETED"
}
```
删除失败:
```json
{
"status": "FAILED",
"error_code": "LOCAL_STORAGE_DELETE_FAILED"
}
```
`DELETED` 重复回报是幂等的。过早回报、分片尚未验证或状态冲突返回 `409`。此接口只证明设备副本已删除,不删除云端已验证对象。
## 10. 重试和异常处理汇总
| 场景 | 嵌入式设备建议行为 |
|---|---|
| 平台登记或配置同步失败 | 保持不可通话状态,记录稳定错误码并按错误策略重试 |
| RTC 网络不可达 | 保持不可通话状态,记录稳定网络错误码并按网络恢复策略处理 |
| 创建后 RTC/激活失败 | 释放 RTC;已有通话编号时调用 `/stop`,原因使用稳定错误码 |
| 等待 ACTIVE 超过房间预留期或本地上限 | 作为启动失败清理通话 |
| 心跳失败 | 保存待确认序号和状态,按退避策略继续;不得用旧序号覆盖新状态 |
| 字幕上传失败 | 保留可靠队列,在网络恢复、后续心跳或结束流程中重试 |
| 结束时字幕仍上传失败 | 按产品留存策略延迟结束,或持久化加密队列后异步补传 |
| 录音上传失败 | 保留本地加密分片,执行有上限的退避重试或进入补偿队列 |
| 等待通话终态超过本地上限 | 保留加密录音并进入受控补偿或失败队列 |
| 等待录音校验超过批次或本地上限 | 保留加密录音并进入受控补偿或失败队列 |
通用重试原则:
- `400/401/403/404/409/422/426` 通常不能盲目重试,应先修正状态或参数;
- `429` 按 `Retry-After` 或业务额度提示处理;
- `503` 且 `error.retryable=true` 时使用指数退避;
- 同一幂等业务重试时保留原 `Idempotency-Key` 和原正文;
- 每次 HTTP 尝试都重新生成 HMAC Timestamp、Nonce 和 Signature。
## 11. 代码与契约依据
- OpenAPI 契约:`openapi/openapi.yaml`
- 设备接口总文档:`docs/设备终端接口文档.md`
- 设备 HMAC 验签:`internal/deviceauth/verifier.go`
- 通话 HTTP 处理:`internal/httpapi/conversation_handlers.go`
- 通话业务编排:`internal/orchestration/service.go`
- 本地设备种子:`test/fixtures/local_seed.sql`
- 已验证参考实现(非规范):`device-simulator-windows/src/main.ts`
- 参考实现签名与请求代码:`device-simulator-windows/electron/main.cjs`
- 参考实现录音分片代码:`device-simulator-windows/src/recording.ts`
## 12. 联调检查清单
- [ ] 烧录/配置的设备 ID 为 `10000000-0000-0000-0000-000000000512`
- [ ] 设备安全区保存的是设备 `...0512` 对应密钥
- [ ] 设备 UTC 时钟与平台相差不超过允许窗口
- [ ] Bootstrap 返回 `registered` 和 `firmware_compatible=true`
- [ ] 设备已经绑定有效家庭和服务对象
- [ ] 服务对象具有有效 `voice_chat` 授权
- [ ] 火山 RTC 相关域名 TCP/TLS 443 可达
- [ ] RTC AppID 已开启实时字幕和流式语音识别
- [ ] 创建响应中的 Token 未被写入日志或磁盘
- [ ] RTC 入房成功并满足产品前置条件后才调用 Activate
- [ ] 心跳序号严格递增
- [ ] 最终字幕全部上传后才结束通话
- [ ] 只有 `privacy` 明确授权的轨道才采集和上传
- [ ] PUT 的实际 WAV 大小和 SHA-256 与声明一致
- [ ] 上传批次最终达到 `COMPLETED`
- [ ] 到 `local_delete_after` 后删除本地密文并回报删除结果