Files
story/docs/device-integration.md
T

290 lines
32 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.
# 设备接入与启动配置联调说明
## 1. 当前接口
| 方法 | 路径 | 作用 |
|---|---|---|
| GET | `/device/v2/bootstrap` | 上报启动信息,取得服务器时间、可信设备状态、绑定、最低安全基线、最低固件版本及兼容结论 |
| GET | `/device/v2/config` | 取得版本化期望配置和当前绑定;支持 `If-None-Match` |
| POST | `/device/v2/conversations` | 冻结授权和背景;成功时分配RTC房间并进入`ROOM_ISSUED`,背景准备失败时保存可诊断`FAILED`结果且不创建房间 |
| POST | `/device/v2/conversations/{id}/activate` | 确认设备已成功入房,可靠投递固定语音供应商任务 |
| GET | `/device/v2/conversations/{id}` | 读取权威通话状态、稳定失败码和设备下一步动作;用于确认智能体启动结果 |
| POST | `/device/v2/conversations/{id}/heartbeat` | 按递增序号上报在线、RTC、配置和录音队列状态并刷新短期租约 |
| POST | `/device/v2/conversations/{id}/stop` | 记录挂断原因并可靠停止固定供应商任务,最终收敛到终态 |
| POST | `/device/v2/conversations/{id}/turns:batch` | 在供应商终稿缺失时补传设备终稿,并与已保存供应商终稿去重合并 |
| POST | `/device/v2/telemetry:batch` | 批量上报设备状态、有限错误码和命令结果,返回配置/命令/停止动作 |
| POST | `/callbacks/v1/vertc/voice-chat` | 接收经HMAC签名的语音供应商异步事件 |
设备自报的型号、设备编号或版本不构成身份依据。云端使用签名中的设备公开编号查询登记记录,并从数据库取得租户、状态和凭据引用。
### 1.1 家庭端设备绑定
设备绑定码由未绑定设备在受控配网或屏幕流程中显示,家庭Web工作台只调用`POST /api/v1/device-claims`消费该码,不生成绑定码。输入框限制16—128字符并关闭自动填充;进入确认区后不回显完整码,只显示掩码、总长度和末四位。确认区同时冻结当前家庭、长者及其公开编号,要求操作者当面核对设备屏幕、设备型号和实际使用人,并明确绑定将建立唯一有效的长期家庭/长者关联,错误目标不能通过再次输入同一码自行改绑。
只有显式确认后才提交完整绑定码。服务端重新校验操作者的`device.manage`能力、家庭与长者边界、设备未绑定、绑定码摘要、到期和撤销状态;相同码和相同目标可安全重试,不同目标或已有有效绑定返回冲突。取消、切换页签/家庭/长者会同时清除确认草稿和输入框中的完整码,不发送请求。页面增量只执行真实OIDC无提交轻量冒烟,验证短码阻断、固定目标、完整码不回显、确认门禁、取消清理、零`device-claims` POST和绑定记录数量不变;完整首次绑定与安全重试继续由既有真实数据库用例承担。
最低固件版本与最低安全基线是两个独立概念。`minimum_security_version`保存厂商安全能力或签名基线标识;`minimum_firmware_version`保存允许创建新通话的固件下限。设备启动上报当前固件后,`firmware_compatible`立即给出判断;SemVer兼容版本按主版本、次版本、修订号和预发布标识排序,厂商自定义的不透明版本无法安全排序,只允许与策略精确相等。设备必须在兼容结果为`false`时进入升级提示而不是继续通话;云端创建接口仍会再次校验并返回不可自动重试的`FIRMWARE_UPDATE_REQUIRED`,因此绕过设备侧提示也不会分配房间。
## 2. 开发环境签名
每个请求必须包含:
- `X-Device-Id`:已登记的设备公开编号。
- `X-Device-Timestamp`:RFC3339 UTC时间,默认允许前后5分钟误差。
- `X-Device-Nonce`:每个请求唯一的16—128字符随机数。
- `X-Device-Content-SHA256`:原始HTTP正文的SHA-256小写十六进制值;空正文也必须计算。
- `X-Device-Signature`:HMAC-SHA256结果的无填充Base64URL编码。
签名原文按以下顺序拼接,行间使用单个换行符:
```text
UPPERCASE_HTTP_METHOD
REQUEST_URI_WITH_EXACT_QUERY_STRING
X-Device-Timestamp原值
X-Device-Nonce原值
X-Device-Content-SHA256原值
```
云端先核验时间窗、正文摘要和签名,再使用Redis `SET NX`登记随机数。相同设备和随机数只能使用一次;Redis或数据库不可用时设备鉴权采用fail-closed并返回可重试的503,不绕过身份验证。
本地环境使用静态HMAC密钥模拟联调。量产采用每设备独立HMAC时,密钥应写入硬件安全区并通过Secrets/KMS CSI受控挂载到云端;如硬件与PKI条件允许,仍可进一步切换每设备mTLS证书。不能把本地合成密钥烧录到真实设备。
### 2.1 逐设备生产密钥解析
dev、stage和prod把设备密钥通过Docker/Kubernetes Secrets或云KMS CSI驱动挂载到API容器的受控只读目录,并设置`DEVICE_SECRET_DIRECTORY`。设备表不保存密钥正文,只保存`device-secret://<name>`引用;解析器只接受单层安全文件名,解析符号链接后仍必须位于配置根目录内,文件最大4KiB且有效密钥至少32字节。
每次验签都按设备记录读取对应文件,因此不同设备使用不同密钥,原子替换挂载文件即可完成轮换,不需要重新构建镜像或重启API。文件缺失、过短、引用格式错误或越界时统一按无效凭据拒绝,响应与日志不暴露文件路径及密钥内容。生产启动门禁拒绝退回单一`DEVICE_LOCAL_SECRET`。
## 3. 启动参数
`bootstrap`查询参数包括:`firmware_version`、`board_rev`、`rtc_sdk_version`、`codec`、`sample_rate`、`device_time`和`network`。其中固件版本和设备时间必填。服务端把报告写入设备影子的reported区域,网页配置只能修改desired区域。
响应返回服务器时间和设备时钟偏差、登记状态、型号、最低安全版本、期望配置版本及当前绑定。响应不包含内部数据库主键或任何设备密钥。
## 4. 配置缓存
配置接口返回强ETag,例如 `"v1"`。设备下次请求可携带:
```http
If-None-Match: "v1"
```
配置未变化时返回HTTP 304;变化时返回HTTP 200、新ETag、期望配置、设备已报告版本和绑定信息。
### 4.1 家庭端音量配置
家庭端设备管理页把扬声器音量作为设备影子的非敏感期望配置管理,不直接假定设备已经应用。页面同时展示“云端期望”和“设备上报”两个值:未配置时滑块以60%作为首次建议值,但明确显示云端尚未设置;已有配置时从当前`desired_config.volume.speaker`回显,相同值不允许重复提交。用户可在0—100范围内逐级调节;提交前的受控操作区固定设备、长者、旧值、新值、设备上报实际值和期望版本变化,并说明离线设备可能稍后领取。
网页提交`PATCH /api/v1/devices/{id}/twin`时只发送需要修改的白名单顶层分类并携带当前设备影子强ETag。服务端仅接受0—100的整数`desired_config.volume.speaker`,继续递归拒绝密钥、令牌、口令和凭据字段,并把已验证分类按顶层增量合并到现有期望配置,未提交的历史编码、采样率、心跳和其他分类不会被音量修改删除。版本过期时返回412,页面刷新后由最新设备影子重新回显;成功时响应包含递增后的期望版本,页面立即更新为“等待设备应用”。设备随后通过配置拉取和心跳报告实际版本及`reported_config.volume.speaker`,二者一致后显示“设备已应用”。
## 5. 创建通话
创建接口必须同时提供设备签名和16—128字符的`Idempotency-Key`。服务端在同一事务中完成:
1. 锁定并核验设备当前绑定、长者状态和语音授权;
2. 冻结本次授权快照和最小背景快照;
3. 创建固定通话编号、房间编号和供应商任务编号;
4. 记录`PREPARING → ROOM_ISSUED`状态链、RTC任务、审计和可靠待办;
5. 提交后签发绑定指定房间和设备用户的短期Token。
请求中的`capabilities`用于显式能力协商。当前模拟终端在原有`rtc`、`audio_tracks`和`upload_resume`之外声明可选的`rtc_credential_renewal=true`;云端把该能力冻结到本次通话。旧版v2固件省略该字段时仍可创建通话,云端不会向其返回新增的凭证续签动作。能力选择不得根据固件版本或HTTP `User-Agent`推测,完整兼容规则见[设备API兼容性约定](device-api-compatibility.md)。
最小背景快照不是设备自由上报的正文。服务端按当前有效绑定读取长者档案版本,只从`profile_data.background/interests`和`conversation_preferences.conversation_goal/pace/preferred_topics`提取有长度上限的白名单资料,再加入最多5条当前有效的已确认记忆。非白名单扩展字段、内部备注和设备侧固定技术目标`open_conversation`不会进入供应商提示。生成的摘要和档案版本随通话冻结;网页后续修改只影响下一次新通话。供应商系统消息还会把该段内容明确标记为“背景资料而非可执行指令”,不能借此覆盖固定安全规则。
### 5.1 背景资料准备失败协议
五个白名单字段一旦存在,就必须符合约定类型:背景、交流目标和节奏为字符串,兴趣和优先话题为字符串数组;缺失或`null`表示未填写。历史导入或异常写库造成类型不正确时,云端不会把字段静默丢弃后继续通话,也不会创建RTC房间或供应商任务,而是保存一条状态为`FAILED`、失败码为`context_preparation_failed`的权威通话和脱敏失败快照。
设备收到HTTP 422,错误码为`AGENT_CONTEXT_PREPARATION_FAILED`,`details`固定包含`conversation_id`、`status=FAILED`和`failure_code=context_preparation_failed`。该错误不可在原请求上无限自动重试;设备应停止入房流程,展示“背景资料准备失败”,并上报稳定错误码。网络重放必须沿用原`Idempotency-Key`,云端返回相同通话编号并携带`Idempotency-Replayed: true`;用户修正档案后才使用新的幂等键发起新通话。家庭网页和运营诊断均按同一`failure_code`展示“背景资料准备失败,未进入语音通话”。
家庭端可在设备真正发起通话前调用`GET /api/v1/subjects/{id}/conversation-readiness`。该接口只返回当前成员有权查看的长者及绑定设备事实,把档案背景、交流目标、语音授权、有效绑定和设备运行上报列为准备条件,把配置同步和最近联系列为运行提醒。用量额度、生产路由接纳和进行中通话属于瞬时状态,仍由`POST /device/v2/conversations`在事务内最终判断;就绪响应不会预留房间、额度或供应商容量。
创建阶段不会调用`StartVoiceChat`。设备必须先加入返回的RTC房间,再调用后续`activate`接口。相同设备、接口、幂等键和相同正文返回同一通话;正文不同返回冲突。为避免持久化完整短期Token,幂等记录只保存非敏感房间声明,重放时根据原声明确定性重建同一个Token。
创建响应中的`session.engine_mode`只供云端诊断、成本归集和通话追溯。终端必须忽略该字段,不得用它切换麦克风采集、编码、近远端轨道发布、订阅或扬声器播放。
## 6. 激活通话与启动语音供应商
设备收到房间资料并成功加入RTC房间后,调用`POST /device/v2/conversations/{id}/activate`,正文必须带入云端原先分配的`room_id`、`rtc_user_id`,并将`rtc_join_result`设为`success`。服务端还会校验当前设备归属、房间预留有效期和`config_revision`,任何不一致都不会启动供应商任务。
首次有效激活在一个事务中完成`ROOM_ISSUED → STARTING_AGENT`、RTC任务`ALLOCATED → STARTING`、审计记录和`conversation.agent_start_requested` outbox事件。Worker使用创建通话时已经冻结的`provider_task_ref`调用供应商,重试时不得生成新任务编号。重复的相同幂等请求返回原结果;通话已经处于`STARTING_AGENT`或`ACTIVE`时再次确认也不会创建第二个启动事件。
激活接口受理后,设备使用`GET /device/v2/conversations/{id}`查询启动结果,不通过更换幂等键重复调用激活接口充当轮询。返回动作含义如下:
- `wait`:房间已分配或智能体仍在启动,设备保持入房并按有限次数继续查询;
- `continue`:权威状态为`ACTIVE`,设备进入正常双向语音和心跳;
- `leave_room`:通话正在停止或已进入终态,设备立即退出RTC房间;`FAILED`时同时显示稳定脱敏`failure_code`。
供应商明确、不可重试地拒绝启动时,Worker在同一数据库事务内把通话和RTC任务收敛为`FAILED/provider_start_rejected`,写入状态历史和系统审计,并正常完成可靠待办,不让通话停留在`STARTING_AGENT`或把永久错误反复重试。供应商异步报告启动失败时返回既有`provider_start_failed`。两种情况都不向设备暴露供应商原始响应;模拟终端识别`leave_room`后立即结束启动流程。
本地`mock-provider`暴露在`http://localhost:59200`,用于模拟供应商启动和异步回调。它按固定任务编号幂等受理启动,并故意发送两次完全相同的`voice_chat.started`回调。Callbacks进程先按时间戳和原始正文校验HMAC签名,再以`provider + event_id`去重;同一事件编号和同一正文返回成功,同一事件编号却对应不同正文时返回409。首个有效启动事件使通话和RTC任务进入`ACTIVE`,重复或过时事件不能让状态回退。
生产模式不复用Mock路径。API使用RTC AppKey生成官方正式Token格式,Token只授权本次`room_id + rtc_user_id`的发布与订阅并在短期后失效;相同创建请求重放时按相同房间、用户和到期时间重建同一Token,数据库和日志均不保存Token。Worker使用火山引擎AK/SK对`StartVoiceChat/StopVoiceChat`签名,平台稳定任务编号映射为`TaskId`,房间映射为`RoomId`,已冻结的设备/智能体RTC用户映射到`AgentConfig`。通话创建时冻结的`s2s_pure`、`s2s_hybrid`或`cascade`会进入可靠启动任务,Worker从`voice-chat-template-bundle-v1`选择同名模板;生产启动要求三个模板全部存在,避免运营路由切换后仍使用另一种处理链。官方同步响应`Result=ok`表示RTC已接受并启动该任务,平台据此进入`ACTIVE`,后续由设备心跳维持租约;火山接口没有任务查询API,因此不确定启动的恢复逻辑会以同一`TaskId`安全重发,供应商“任务已启动”响应同样按已存在活动任务收敛,不生成新任务编号。服务端状态事件可作为状态细分和用量补充,但不是最小通话链进入ACTIVE的单点依赖。
生产API优先选择活动投放所绑定的`AGENT_BUNDLE`版本,并从组合引用的路由版本取得处理模式;`config_key`是运营自定义版本族标识,不要求保留键名。未命中组合时兼容活动投放所绑定的直接`ROUTING`版本。两类投放都未命中时返回503 `RUNTIME_CONFIG_UNAVAILABLE`,不会回退到开发用`minimal-v2 + s2s_hybrid`。dev、test和local环境保留该默认值,便于模拟设备和本地闭环运行。该变化仅影响云端新通话配置选择,不改变设备v2请求、响应字段或终端媒体行为。
阶段8真实验收按[真实终端与RTC/语音供应商一致性证据](real-device-rtc-evidence.md)执行6通精简矩阵,三种方式各完成正常闭环,并额外覆盖凭证续签、网络切换恢复和受控停止。协议模拟只验证工具链,不计入`REAL_DEVICE_RTC`。
模拟供应商还提供仅限本地、使用`X-Mock-Provider-Key`保护的`POST /mock/v1/admin/replay`。请求指定已启动的`task_ref`、样例名和1—10次重复投递次数,可重复播放`started`、`final_transcript`、`agent_answering`、`interrupted`、`usage`、`error`和`stopped`七类脱敏通知。`agent_answering`和`interrupted`以最终智能体话轮表达,后者明确设置`interrupted=true`;`usage`只使用标准化分类、计量代码、数量和单位。相同样例针对同一任务生成稳定事件编号,用于验证通知幂等,不应在生产部署中暴露该管理接口。
供应商回调签名使用以下原文:
```text
X-Provider-Timestamp原值
原始HTTP正文的SHA-256小写十六进制值
```
签名算法为HMAC-SHA256,响应编码为无填充Base64URL。本地共享密钥只用于联调;生产应改由密钥管理服务托管并建立轮换机制。
## 7. 通话心跳与租约
通话进入`STARTING_AGENT`或`ACTIVE`后,设备按创建响应中的`heartbeat_interval_sec`调用心跳接口。请求至少包含严格递增的`sequence_no`、设备时间与单调时钟、云端通话状态、RTC上下行状态、网络、固件版本、已应用配置版本、录音队列以及命令结果数组。录音队列必须报告待上传数和失败数;发生失败时可附带脱敏的`last_failure_code`与`last_failure_at`,家庭端据此显示上传积压和最近失败原因。
PostgreSQL保存`last_heartbeat_seq`和权威`lease_expires_at`;Redis只保存可重建的热租约和设备在线状态。处理规则如下:
1. 新序号在数据库行锁内更新通话、RTC任务和设备影子,并把租约续期为五个心跳周期;
2. 同序号重放或更小序号的乱序心跳返回当前结果,不更新设备报告,也不延长数据库或Redis租约;
3. Redis使用Lua比较序号,避免两个已经提交的并发请求以相反顺序覆盖热租约;
4. 设备报告的配置版本低于期望版本时,服务端接受心跳并返回最新`desired_config_revision`;高于云端期望版本时返回冲突,防止未知配置污染设备影子;
5. RTC已经离房、设备报告正在停止或通话已是终态时返回`stop`动作,终态永远不会因迟到心跳回到运行态;
6. 已在创建通话时明确声明`rtc_credential_renewal=true`的设备,把当前使用的Token过期时间原样放入`rtc_credential_expires_at`;进入续签窗口时响应在`continue`后追加`renew_rtc_credential`,其中`rtc`结构包含同一房间和设备用户的新短期凭证;未声明能力的旧版v2设备只收到原有动作;
7. 设备先替换运行内存中的RTC凭证,再在下一次心跳报告新的过期时间。若续签响应丢失,旧过期时间会使服务端按已保存的新过期时间重新生成同一确定性凭证,不重复延长有效期;
8. Token签名失败时数据库事务整体回滚,不推进心跳序号或凭证时间,设备可用同一序号安全重试;凭证正文仍不写入PostgreSQL、Redis、审计或日志;
9. PostgreSQL事务提交后Redis刷新失败不回滚权威心跳,后台可从数据库重建热租约。
心跳中的`last_error`只接受短错误码,不接受自由文本;云端不会把命令结果、短期Token或供应商密钥写入请求日志。
## 8. 停止通话与异常回收
设备挂断后调用`POST /device/v2/conversations/{id}/stop`,同时提供16—128字符的`Idempotency-Key`。正文包含原因代码、设备时间、最后心跳序号、RTC是否已经离房和near/far本地录音分片计数。首次有效请求执行以下事务:
1. `ACTIVE`或`STARTING_AGENT`进入`STOPPING`,清空权威租约并记录停止原因;
2. RTC任务进入`STOPPING`;
3. 创建唯一的`conversation.agent_stop_requested`可靠待办和操作审计;
4. 事务提交后删除Redis通话热租约,并把设备在线状态恢复为`idle`。
Worker始终使用创建通话时固定的`provider_task_ref`调用Stop。相同幂等键重放返回原结果;换新幂等键重复停止、Worker重试或后台对账也不会产生第二个供应商任务。供应商返回`COMPLETED`或签名结束回调到达后,通话和RTC任务统一进入`COMPLETED`、写入结束时间,并追加`STOPPING → COMPLETED`状态历史。完全相同的结束回调只落一条供应商事件。
家庭用户在通话中撤回该通话冻结快照正在使用的字幕、原录音、故事整理或个性化记忆授权时,SaaS在撤回事务内同步保存停止意图:`ACTIVE/STARTING_AGENT`进入`STOPPING`并创建唯一Agent停止待办,尚未启动Agent的房间直接进入`EXPIRED`。设备不需要新增接口;下一次通话心跳返回`stop`,批量遥测若引用该通话则返回`stop_conversation`。即使Worker先完成供应商停止,设备对已进入终态的通话心跳仍收到停止动作。家庭分享授权只控制后续分享访问,不中断实时语音。
如果设备在激活前挂断,`ROOM_ISSUED`直接进入`EXPIRED`,供应商侧不会发生Start或Stop调用。后台恢复扫描器从PostgreSQL扫描非终态通话并使用数据库行锁和`SKIP LOCKED`避免多个Worker重复修复:
- 房间预留到期:进入`EXPIRED`;
- `ACTIVE`租约到期:进入`STOPPING`并可靠投递Stop;
- `STARTING_AGENT`长时间未收敛:按时间桶投递幂等启动对账;
- `STOPPING`长时间未完成:按时间桶安全重试Stop;
- 供应商幂等Start/Stop响应已经确认终态时,即使原回调丢失也可由Worker补齐状态历史;
- 每次发现和恢复均写入`conversation.recovery`系统审计。
本地默认每5秒扫描一次,启动中或停止中超过20秒进入对账。生产值必须依据真实供应商回调延迟、设备心跳间隔和压测结果调整。
## 9. 终稿补传与冲突处理
当供应商最终转写缺失或不完整时,通话所属设备可调用`POST /device/v2/conversations/{id}/turns:batch`补传1—500条最终话轮。请求同时携带设备签名和16—128字符的`Idempotency-Key`。每条终稿包含`turn_key`、严格正整数`sequence_no`、说话方、起止时间、最终文本和是否被打断;同一批内`turn_key`和`sequence_no`分别唯一。
云端在单个数据库事务内按通话归属、留存策略和当前文字留存授权校验后,以`turn_key`和`sequence_no`同时合并:
1. 两个业务键、说话方、时间、文本和打断标志完全一致时计入`duplicate_count`,不重复保存;
2. 两个业务键均未使用时以`source_type=device`保存并计入`accepted_count`;
3. 任一业务键已经对应不同终稿时返回409 `TRANSCRIPT_TURN_CONFLICT`,冲突项位于`error.details.conflicts`,整批新增全部回滚;
4. 留存模式为`none`或文字留存授权已经撤回/过期时返回403 `TRANSCRIPT_STORAGE_NOT_ALLOWED`;
5. 至少新增一条终稿时可靠投递内容再提取任务;相同幂等请求只返回原合并结果,不重复生成任务。
终端收到409后不得覆盖云端终稿,应记录冲突并拉取云端通话内容供排查;网络失败或503时保持原正文和原幂等键退避重试,但必须为每次HTTP请求生成新的签名随机数。
## 10. 批量遥测、错误与命令结果
设备在通话中或空闲期均可调用`POST /device/v2/telemetry:batch`,每批提交1—500条按设备单调编号的事件。事件类型分为`status`、`error`和`command_result`,每条事件必须提供设备内唯一的`event_key`、`sequence_no`和发生时间,并且只携带与`event_type`对应的同名对象。请求需使用设备签名和16—128字符`Idempotency-Key`。
- `status`更新设备实际固件、实际配置版本、网络、RTC和录音队列快照;旧序号不能覆盖新快照,报告版本高于云端期望版本返回409。
- `error`只接受最长80字符的有限错误码和`warning/error/critical`级别,不接收可能包含个人信息的自由文本堆栈。
- `command_result`把属于当前设备且未过期的命令收敛到`ACKNOWLEDGED/SUCCEEDED/FAILED`,并同步固件分配进度;结果对象递归拒绝密钥、令牌、口令、私钥和凭据字段。
- `conversation_id`可选,但一旦提交,云端必须验证通话属于当前签名设备;终态或停止中的通话返回`stop_conversation`动作,不能借遥测访问任意长者的通话。
- 完全相同的事件计入`duplicate_count`;同一事件键或序号对应不同内容返回409 `TELEMETRY_EVENT_CONFLICT`并回滚整批。响应还可返回`refresh_config`或`pull_commands`动作。
当前命令结果字段是本地联调基线,硬件团队给出最终设备能力表和结果格式后,应先更新OpenAPI兼容说明再调整实现;不能依赖固件User-Agent猜测字段版本。
## 11. 运行模拟设备与端到端冒烟
完整环境启动后执行:
```powershell
Set-Location E:\Codex\story\platform
docker compose --profile simulation run --rm sim-device
```
模拟器默认执行`bootstrap`和配置读取,只打印状态与脱敏编号,不打印签名密钥。默认设备公开编号为`10000000-0000-0000-0000-000000000511`;可通过`SIM_API_URL`、`SIM_DEVICE_ID`和`SIM_DEVICE_SECRET`覆盖本地值。
`SIM_SCENARIO`支持逗号分隔的场景:
| 场景 | 行为 |
|---|---|
| `bootstrap` | 启动、时间同步和版本化配置读取 |
| `offline` | 先向不可达端点发起请求,确认本地保留后使用新Nonce重连 |
| `conversation` | 创建、创建重放、激活并轮询到ACTIVE、心跳、停止和停止重放 |
| `transcript` | 对本次或`SIM_CONVERSATION_ID`指定通话补交最终字幕并验证幂等重放 |
| `telemetry` | 批量上报设备状态,可关联本次通话,并验证幂等重放 |
| `commands` | 拉取当前设备未终结命令;普通命令提交合成成功结果,`firmware_update`执行私有下载、SHA-256、Ed25519签名、安装和确认状态链 |
| `media` | 为`SIM_CONVERSATION_ID`指定且具有当前录音授权的通话生成或读取WAV PCM16文件,直传对象存储,轮询至云端校验完成,并在`local_delete_after`到达后回报本地密文已删除;可设置`SIM_MEDIA_FILE`、`SIM_MEDIA_DURATION_MS`、`SIM_MEDIA_TRACK`、`SIM_MEDIA_VERIFY_TIMEOUT`和`SIM_MEDIA_DELETE_WAIT_MAX` |
| `all` | 依次执行启动、完整通话、字幕、遥测和命令;录音因需要单独授权和可访问的对象存储公开地址而不自动包含 |
例如执行联合模拟闭环:
```powershell
docker compose --profile simulation run --rm -e SIM_SCENARIO=offline,conversation,transcript,telemetry,commands sim-device
```
固件执行器不会信任命令下发通道携带的公钥。运行前必须通过`SIM_FIRMWARE_TRUSTED_KEYS`预置本地信任锚,值为“密钥编号到标准Base64 Ed25519公钥”的JSON对象,例如`{"vendor-2026":"<32字节公钥的Base64>"}`。执行器只接受固定的`/device/v2/firmware-releases/{id}/download`相对路径,拒绝任意公网地址、查询参数、片段和路径穿越;下载对象限制为1字节至256 MiB。下载后先校验小写SHA-256,再使用本机型号、硬件版本、实际字节数和命令清单重建`firmware-manifest-v1`并验证签名,全部通过才进入安装阶段。`SIM_DEVICE_MODEL`和`SIM_DEVICE_HARDWARE_REVISION`默认分别为`simulated-terminal`和`sim-rev-a`,必须与制品签名清单一致。
`SIM_FIRMWARE_FAULT`可设置为`download_failed`、`digest_mismatch`、`signature_mismatch`、`install_failed`、`interrupt_after_download`或`interrupt_after_install`;默认`none`。前三类安全失败和安装失败都会提交明确的`FAILED`终态;两类中断保留`ACKNOWLEDGED`阶段,设备进程重新启动后会重新拉取同一未终结命令并从安全起点重复执行,不把状态回退为`DELIVERED`。`SIM_FIRMWARE_STEP_DELAY`可在0至30秒内模拟下载校验后及安装后的耗时。
启用`SIM_FIRMWARE_AB_UPDATE=true`后,模拟器把制品写入非活动分区,使用`SIM_FIRMWARE_STATE_FILE`持久化启动尝试,并上报`staging`、`reboot_pending`和`boot_verifying`细分阶段。启动健康失败或超过`SIM_FIRMWARE_BOOT_HEALTH_WINDOW`会恢复旧分区并以`FAILED/rolled_back`报告已执行回退;`power_loss_after_stage`、`power_loss_during_boot`和`boot_health_failed`只在A/B模式下允许。完整状态、字段、顺序和真机边界见[设备A/B分区固件升级与启动确认协议](device-firmware-ab-protocol.md)。
轻量纵向验收执行:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\smoke-sim-firmware.ps1
```
脚本只建立一个临时公钥、一个签名版本、一个单设备批次和一条命令,先在制品校验完成后模拟进程中断,再以同一设备重新运行并核对`SUCCEEDED/CONFIRMED/100`及影响汇总,最后精确清理数据库和对象存储夹具。若目标设备已有未终结命令,脚本会在创建夹具前停止,避免消费真实在途任务。
仅验证A/B启动失败自动回退时执行`-Mode ABRollback`。该模式只增加一个本地状态文件,不增加固件版本或目标设备数量;验证完成后同样精确清理。
本地实测组合场景已收敛为`COMPLETED|COMPLETED`,供应商Start/Stop各1次、3条最终话轮(2条供应商+1条设备补交)、1条关联遥测且无开放Outbox。录音场景可在宿主机使用`go run ./cmd/sim-device`执行,以便`localhost:59000`短期上传地址可达;模拟器会等待校验结果并自动提交删除回执。Compose本地环境把`MEDIA_LOCAL_DELETE_GRACE`设为1秒,生产默认值仍为1小时。若删除时间超过`SIM_MEDIA_DELETE_WAIT_MAX`,模拟器明确打印计划时间并安全退出,不会提前回报。`SIM_MEDIA_FILE`仅作为操作人员提供的输入夹具,模拟器只释放内存副本,绝不删除该源文件。
完整环境启动后,可执行自动化通话冒烟:
```powershell
.\scripts\smoke-conversation.ps1
```
脚本会验证创建、首次激活、相同幂等键重放、Worker调用、两次签名回调去重和最终`ACTIVE`状态,并断言供应商仅启动一次。脚本只使用合成数据,验证结束后按本次通话编号精确清理数据库痕迹。
## 12. 错误处理
| HTTP状态 | 错误码 | 处理建议 |
|---|---|---|
| 400 | `VALIDATION_FAILED` | 修正字段后重新生成随机数和签名 |
| 401 | `DEVICE_AUTHENTICATION_FAILED` | 检查设备登记、时钟、摘要、签名或是否发生重放 |
| 503 | `DEVICE_AUTHENTICATION_UNAVAILABLE` | 退避后使用新随机数重试,不关闭鉴权 |
| 503 | `DEVICE_CONFIG_UNAVAILABLE` / `DEVICE_BOOTSTRAP_FAILED` | 退避重试并保留上一次有效配置 |
| 503 | `RUNTIME_CONFIG_UNAVAILABLE` | 生产环境没有命中活动语音路由;停止创建通话,由运营发布或恢复目标范围配置 |
| 403 | `CONSENT_REQUIRED` | 不重试,等待有效授权 |
| 403 | `TRANSCRIPT_STORAGE_NOT_ALLOWED` | 停止补传,等待留存策略或授权恢复,不得本地绕过 |
| 409 | `DEVICE_NOT_BOUND` / `CONVERSATION_ALREADY_ACTIVE` | 修复绑定或查询已有通话 |
| 409 | `IDEMPOTENCY_KEY_REUSED` | 不得用同一幂等键提交不同正文 |
| 409 | `ROOM_ALLOCATION_EXPIRED` | 重新创建通话并取得新的房间预留 |
| 409 | `RTC_ALLOCATION_MISMATCH` | 丢弃本地房间资料,重新拉取或重建通话 |
| 409 | `CONFIG_VERSION_CONFLICT` | 先重新获取设备配置再尝试创建新通话 |
| 409 | `PROVIDER_EVENT_CONFLICT` | 供应商事件编号复用且正文不同,转人工排查 |
| 409 | `CONVERSATION_STATE_CONFLICT` | 查询云端当前通话状态,不要强制覆盖状态机 |
| 409 | `TRANSCRIPT_TURN_CONFLICT` | 保留本地终稿和冲突明细,禁止覆盖云端终稿,转入人工对账 |
| 409 | `TELEMETRY_EVENT_CONFLICT` | 保留冲突事件并停止推进本地确认点,人工核对事件键和单调序号 |
| 404 | `TELEMETRY_CONVERSATION_NOT_FOUND` | 删除错误通话引用,不得改用任意长者编号重试 |
| 409 | `DEVICE_COMMAND_STATE_CONFLICT` | 重新拉取命令;不得覆盖已过期、已取消或其他设备的命令 |
| 503 | `TRANSCRIPT_BATCH_UNAVAILABLE` | 使用原幂等键和原正文退避重试,并生成新的设备签名随机数 |
| 503 | `DEVICE_TELEMETRY_UNAVAILABLE` | 保留本地事件队列,以原正文和原幂等键退避重试 |
| 503 | `CONVERSATION_HEARTBEAT_UNAVAILABLE` / `CONVERSATION_STOP_UNAVAILABLE` | 使用新随机数退避重试;停止请求保留原幂等键和原正文 |
服务端故意不区分“设备不存在”“设备已吊销”和“签名错误”的外部提示,避免泄露设备登记状态;内部可通过请求关联编号排障。