Files
story/docs/嵌入式设备与平台交互接口文档.md
T
2026-08-29 21:56:51 +08:00

1460 lines
47 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 嵌入式设备与平台交互接口文档
> 文档范围:嵌入式语音终端与平台、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` 后删除本地密文并回报删除结果