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

47 KiB
Raw Permalink Blame History

嵌入式设备与平台交互接口文档

文档范围:嵌入式语音终端与平台、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 当前是注册但未绑定设备:

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. 总体时序

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 成功和错误包装

一般成功响应:

{
  "request_id": "01JTESTREQUEST",
  "data": {}
}

统一错误响应:

{
  "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,不需要计算。本文示例固定为:

10000000-0000-0000-0000-000000000512

设备必须把 Device ID 与对应的 device_secret 成对保存。Device ID 可以出现在普通日志中,device_secret 不可以。

4.2.2 X-Device-Timestamp

该值由设备在每次 HTTP 尝试前读取当前 UTC 时间生成,格式必须为 RFC3339。推荐统一使用带毫秒的 UTC Z 格式:

2026-08-29T08:31:00.050Z

生成要求:

  1. 设备启动后通过可信 RTC/NTP、蜂窝网络时间或平台 bootstrap.data.server_time 校时。
  2. 默认服务端允许时间偏差为前后 5 分钟,量产固件应把本地允许偏差控制得更小。
  3. 写入请求头的字符串必须原样写入签名原文,不能在签名前后改变小数位、时区或大小写。
  4. 网络重试时必须生成新的 Timestamp、Nonce 和 Signature。

伪代码:

// 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 个小写十六进制字符:

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));

示例:

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。

伪代码:

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 示例:

#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 固定为:

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,不包含协议、域名和端口:

request_uri = path + 可选的问号和原始查询串

示例:

POST https://121.41.46.238/device/v2/conversations
签名URI:/device/v2/conversations
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,最后一行末尾不能再添加换行:

UPPERCASE_HTTP_METHOD
REQUEST_URI_WITH_EXACT_QUERY_STRING
X-Device-Timestamp原值
X-Device-Nonce原值
X-Device-Content-SHA256原值

计算公式:

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 核心示例:

#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
    );
}

构造签名原文时可以使用:

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 可复现签名测试向量

下面的密钥仅用于验证算法,绝不能用于真实设备:

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 字节,末尾没有换行:

{"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 应得到:

6ab32d795cf68a2f48d3db879e4811db683309b7a3f631003b47e727646143c9

签名原文的 UTF-8 内容必须准确为:

POST
/device/v2/conversations
2026-08-29T08:31:00.050Z
5d4c79fa3f014e9cacb68eeb481be2c8
6ab32d795cf68a2f48d3db879e4811db683309b7a3f631003b47e727646143c9

使用测试密钥计算后,X-Device-Signature 必须得到:

_spDPNobaFkrX8hInZ26ULuoKsYhSzExpSK7Ww_cVR4

对应请求头为:

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 请求还应设置:

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 个小写十六进制字符,再加上便于排查的接口前缀:

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);

示例:

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 设备启动登记

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

请求示例

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计算>

本地种子中未绑定设备的响应示例

{
  "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 获取设备配置

GET /device/v2/config

设备应持久化上一次成功配置的强 ETag,并在后续请求中发送 If-None-Match,以减少响应流量。首次启动或没有缓存时可以省略。

请求示例

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/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 端口:

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 创建通话

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 同时允许时,才能采集、落盘和上传对应录音。

额外请求头:

Idempotency-Key: create-<设备生成的稳定业务UUID>

请求正文示例

{
  "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 响应示例

{
  "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 确认入房并启动智能体

POST /device/v2/conversations/51200000-0000-4000-8000-000000000001/activate

只有 RTC 入房和字幕启动成功后调用。

额外请求头:

Idempotency-Key: activate-<设备生成的稳定业务UUID>

请求正文示例

{
  "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 响应示例

{
  "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 轮询通话状态

GET /device/v2/conversations/51200000-0000-4000-8000-000000000001

推荐在激活后约 500 ms 首次查询,之后每 1 秒查询,并确保总等待时间不超过 session.allocation_expires_at。设备应设置有限次数和本地超时,不能无限轮询。

等待响应

{
  "request_id": "01J512STATE1",
  "data": {
    "conversation_id": "51200000-0000-4000-8000-000000000001",
    "status": "STARTING_AGENT",
    "action": "wait"
  }
}

激活响应

{
  "request_id": "01J512STATE2",
  "data": {
    "conversation_id": "51200000-0000-4000-8000-000000000001",
    "status": "ACTIVE",
    "action": "continue"
  }
}

失败响应

{
  "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 通话心跳

POST /device/v2/conversations/51200000-0000-4000-8000-000000000001/heartbeat

设备进入 ACTIVE 后应立即发送一次,之后按:

max(5秒, session.heartbeat_interval_sec)

周期调用。当前通常为 15 秒。

请求正文示例

{
  "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 响应示例

{
  "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 批量上传最终字幕

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位}。

请求正文示例

{
  "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 响应示例

{
  "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 停止通话

POST /device/v2/conversations/51200000-0000-4000-8000-000000000001/stop

额外请求头:

Idempotency-Key: stop-<设备生成的稳定业务UUID>

请求正文示例

{
  "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 响应示例

{
  "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,并使用稳定原因码清理通话,例如:

reason = device_start_failed

8.3 等待通话终态

仅当本次实际生成了录音分片时,设备需要等待通话进入允许上传的终态:

GET /device/v2/conversations/51200000-0000-4000-8000-000000000001

建议每 1 秒查询一次,并设置不超过 60 秒的本地等待上限:

  • COMPLETED 或 FAILED:进入录音上传;
  • EXPIRED:停止上传并报错;
  • 超时:本次录音上传失败。

9. 阶段五:录音上传和校验

只有创建通话响应满足以下条件时,设备才允许采集和上传录音:

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 创建上传批次

POST /device/v2/conversations/51200000-0000-4000-8000-000000000001/upload-batches

设备先对每个本地 WAV 分片计算真实字节数和小写 SHA-256,然后只把清单提交给平台,不把音频字节发送给业务 API。

幂等键:

media-{清单JSON的SHA-256前40位}

请求正文示例

{
  "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 响应示例

{
  "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 到对象存储

设备对每个响应中的新分片执行:

PUT <segment.upload_url> HTTP/1.1
Content-Type: audio/wav

<该分片原始WAV字节>

注意:

  • 这是设备到对象存储的直接请求,不经过平台业务 API;
  • 不携带设备 HMAC 请求头;
  • upload_url 自带短期签名,只能上传对应单一对象;
  • 不得持久化或记录完整 upload_url;
  • 返回非 2xx 时,本次上传流程失败并按外层逻辑重试。

9.3 声明上传完成

POST /device/v2/upload-batches/51200000-0000-4000-8000-000000000101/complete

该接口没有请求正文。设备应发送真正的空正文,并使用空正文 SHA-256:

e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

202 响应示例

{
  "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 查询校验结果

GET /device/v2/upload-batches/51200000-0000-4000-8000-000000000101

建议每 1~2 秒查询一次,直到批次进入终态、达到 expires_at,或达到设备本地等待上限(建议不超过 90 秒)。

完成响应示例

{
  "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。设备必须等到该时间之后,才能删除本地加密录音副本,并调用:

POST /device/v2/media-segments/51200000-0000-4000-8000-000000000201/local-delete-ack

删除成功:

{
  "status": "DELETED"
}

删除失败:

{
  "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 后删除本地密文并回报删除结果